diff --git a/modules/mesh-grants/cmd/mesh-grants/main.go b/modules/mesh-grants/cmd/mesh-grants/main.go index a98c8d64..d80ad249 100644 --- a/modules/mesh-grants/cmd/mesh-grants/main.go +++ b/modules/mesh-grants/cmd/mesh-grants/main.go @@ -38,7 +38,9 @@ import ( "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" mcseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshwantsclient" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants" stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/announce" "git.novox.be/novox/mesh-sdk/go/membership" @@ -168,6 +170,13 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro branches.Changed = func(string, string, json.RawMessage, bool, time.Time) { f.Wake() } branches.Entries.Changed = func(meshcatalogclient.EntryRow) { f.Wake() } fv.Catalog = &grants.CatalogOf{Branches: branches, Context: ctx, Wake: f.Wake} + // The assignment half (design 46 approval mark §6, design 47 G §195): its first view by mesh-wants' revision, then its + // assignment-revision, on the same reader; each revision held composes again, since a node's reading may have changed. + assignment, _, err := meshwantsclient.WireAssignmentProcess(reader, conn.Call, func(meshwants.AssignmentRevision) { f.Wake() }) + if err != nil { + return err + } + fv.Assignment = assignment f.Part = &grants.Part{Read: fv, Call: conn, Store: ownStore{conn}, Emit: emitting{conn}, Views: fv, FirstViewContext: ctx, Create: creating{ctx: ctx, js: js}, Consumers: creating{ctx: ctx, js: js}, Objects: sealedObjects{ctx: ctx, js: js}, Purging: ownPurging{ctx: ctx, js: js, module: account.Module}, Logf: logger.Printf, diff --git a/modules/mesh-grants/cmd/mesh-grants/tools.go b/modules/mesh-grants/cmd/mesh-grants/tools.go index 0ad31793..f20ecdd6 100644 --- a/modules/mesh-grants/cmd/mesh-grants/tools.go +++ b/modules/mesh-grants/cmd/mesh-grants/tools.go @@ -275,6 +275,13 @@ func toolsOf(p *grants.Part) []stdio.Tool { Run: func(a map[string]any) (any, error) { return p.GrantsFor(meshgrants.GrantsForInput{Branch: grants.Answer(a, "branch"), Run: grants.Answer(a, "run"), Source: grants.Answer(a, "source")}) }}, + {Name: meshgrants.Seat + "." + meshgrants.VerbWithheld, + Description: "The marked subjects withheld from a node's runtime, by module, and why: where its tool runner does not state " + + "approval-mark/1 or its reading is not the running runner's (design 46 approval mark §6). Read-only.", + Input: schema(map[string]string{"node": "the node"}, "node"), + Run: func(a map[string]any) (any, error) { + return p.WithheldOn(grants.Answer(a, "node")) + }}, {Name: grants.ToolRelease, Description: "Release one held granting wait by a person's act with why, recorded as a hand act " + "(the seat mesh-hand-acts) before it is released (design 47 G §276). grants_waits lists them.", Input: schema(map[string]string{"module": "the held entry's module", "why": "why, kept in the hand act"}, "module", "why"), diff --git a/modules/mesh-grants/grants_test.go b/modules/mesh-grants/grants_test.go index 98086c00..69005946 100644 --- a/modules/mesh-grants/grants_test.go +++ b/modules/mesh-grants/grants_test.go @@ -37,6 +37,7 @@ import ( "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshwantsclient" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants" "git.novox.be/novox/mesh-delivery/modules/mesh-grants/grantsstate" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/keys" @@ -621,9 +622,10 @@ func TestGrants(t *testing.T) { if err != nil { t.Fatalf("mesh-sdk cannot decode the manifest: %v", err) } - // The part claims the seat mesh-grants, which serves `grants-for` (design 47 G §401, §441). - if m.Module != "mesh-grants" || len(m.Claims) != 1 || m.Claims[0].Name != "mesh-grants" || !slices.Equal(m.Claims[0].Serves, []string{"grants-for"}) { - t.Errorf("module %q claims %v: the part claims the seat mesh-grants alone, serving grants-for", m.Module, m.Claims) + // The part claims the seat mesh-grants, which serves `grants-for` (design 47 G §401, §441) and `withheld` (design 46 + // approval mark §6). + if m.Module != "mesh-grants" || len(m.Claims) != 1 || m.Claims[0].Name != "mesh-grants" || !slices.Equal(m.Claims[0].Serves, []string{"grants-for", "withheld"}) { + t.Errorf("module %q claims %v: the part claims the seat mesh-grants alone, serving grants-for and withheld", m.Module, m.Claims) } for _, row := range []struct { what string @@ -1265,6 +1267,45 @@ func TestGrants(t *testing.T) { } }) + // AM-D3 (design 46 approval mark §6, lead answer A-203): the part reads each node's reading from the assignment half it + // holds, none held keeps the list in force; the subjects withheld are kept by node in the user list's state and the + // seat's verb withheld answers them by module. + t.Run("the withheld subjects are kept by node and answered by the verb withheld", func(t *testing.T) { + if _, held := (&grants.Views{Assignment: heldHalf{}}).Readings(); held { + t.Errorf("an assignment half with no revision is said held") + } + half := heldHalf{rev: &meshwants.AssignmentRevision{Capabilities: []meshwants.AssignmentRevisionCapabilitiesItem{ + {Node: "a", Digest: "sha256:x", Readings: []string{"approval-mark/1"}, Fingerprint: "sha256:runner"}}}} + if r, held := (&grants.Views{Assignment: half}).Readings(); !held || r["a"].Fingerprint != "sha256:runner" || + !slices.Equal(r["a"].Capabilities, []string{"approval-mark/1"}) { + t.Errorf("the readings are %+v (%v)", r, held) + } + shell := buscompose.Declared{Module: "shell", Serves: []string{"execute"}, Marked: []string{"execute"}} + recs := buscompose.Records{Nodes: []string{"a", "b"}, Assigned: map[string][]buscompose.Declared{ + "a": {{Module: "node-tools", ToolRunner: true}, shell}, "b": {{Module: "node-tools", ToolRunner: true}, shell}}, + Readings: map[string]buscompose.RunnerReading{"b": {Capabilities: []string{"approval-mark/1"}, Fingerprint: "sha256:runner"}}, + Running: map[string]string{"a.node-tools": "sha256:runner", "b.node-tools": "sha256:runner"}} + byNode := grants.WithheldByNode(recs) + if len(byNode) != 1 || len(byNode["a"]) != 3 || byNode["a"][0].Why != buscompose.WithheldWhy { + t.Fatalf("withheld by node = %+v", byNode) + } + p := partOn(newFakeBus()) + if _, err := p.WithheldOn("a"); err == nil { + t.Errorf("withheld before any composition answered no error") + } + raw, _ := json.Marshal(grants.UserListState{V: 1, Withheld: byNode}) + if _, err := p.Store.Put(grants.StateUserList, grants.KeyList, raw); err != nil { + t.Fatal(err) + } + got, err := p.WithheldOn("a") + if err != nil || got.Node != "a" || len(got.Modules) != 1 || got.Modules[0].Module != "shell" || len(got.Modules[0].Subjects) != 3 || + got.Modules[0].Subjects[0].Subject != "mesh.mod.shell.call.execute.>" { + t.Errorf("withheld on a = %+v (%v)", got, err) + } + if got, err := p.WithheldOn("b"); err != nil || len(got.Modules) != 0 { + t.Errorf("withheld on b = %+v (%v)", got, err) + } + }) t.Run("compare", func(t *testing.T) { b := fixture() shadow := shadowOf(t, b) @@ -5156,3 +5197,13 @@ func TestGrantsFor(t *testing.T) { t.Error("grants-for with neither a source nor a run answered") } } + +// heldHalf is an assignment half holding rev, or none. +type heldHalf struct{ rev *meshwants.AssignmentRevision } + +func (h heldHalf) Revision() (meshwants.AssignmentRevision, bool) { + if h.rev == nil { + return meshwants.AssignmentRevision{}, false + } + return *h.rev, true +} diff --git a/modules/mesh-grants/internal/buscompose/buscompose_test.go b/modules/mesh-grants/internal/buscompose/buscompose_test.go index 8787267c..a708010c 100644 --- a/modules/mesh-grants/internal/buscompose/buscompose_test.go +++ b/modules/mesh-grants/internal/buscompose/buscompose_test.go @@ -63,6 +63,7 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) { t.Run("a process serving a verb is granted its calls and chunk subjects, and the reverse goes to an account naming it", chunkSubjectsFollowTheAnswerer) t.Run("the tool runner's runtime hears every event subject and nothing wider", theToolRunnerHearsEveryEvent) t.Run("a per-machine state reaches its machine's keys below it and no other machine's", aPerMachineStateReachesItsMachinesKeys) + t.Run("a runtime whose tool runner does not honour the mark is denied every marked subject", aRuntimeWithholdsTheMarks) // ADR 0567 §3, §9: a node reads every key of the seat's assignments and its candidates (it composes with the whole // proposal laid over), and none of its open proposals; it writes neither. reads := strings.Join(NodeComposeReads("laptop"), " ") @@ -1564,3 +1565,61 @@ func aPerMachineStateReachesItsMachinesKeys(t *testing.T) { t.Errorf("a's membership issues %+v", issued) } } + +// AM-D3 (design 46 approval mark §6): a runtime whose node's reading lacks approval-mark/1, has no reading, or a reading of +// another runner or of none is denied to subscribe every marked subject of what it carries, a marked tool's three forms, a +// marked verb of a seat held per node with its . form and of a seat held once, each named with why; a reading with +// the capability of the running runner composes none. +func aRuntimeWithholdsTheMarks(t *testing.T) { + shell := Declared{Module: "shell", Serves: []string{"execute", "read"}, Marked: []string{"execute"}, + Holds: []Seat{{Name: "node-shell", Scope: "node", Serves: []string{"run", "look"}, Marked: []string{"run"}}, + {Name: "forge", Scope: "mesh", Serves: []string{"merge"}, Marked: []string{"merge"}}}} + withheld := []string{"mesh.mod.shell.call.execute.>", "mesh.mod.shell.tool.execute", "mesh.mod.shell.tool.execute.*", + "mesh.seat.forge.call.merge.>", "mesh.seat.forge.tool.merge", "mesh.seat.node-shell.call.run.a.>", + "mesh.seat.node-shell.tool.run", "mesh.seat.node-shell.tool.run.a"} + honours := RunnerReading{Capabilities: []string{"bus-state-calls", ApprovalMark}, Fingerprint: "sha256:runner"} + for _, c := range []struct { + name string + readings map[string]RunnerReading + deny bool + }{ + {"a reading lacking the capability", map[string]RunnerReading{"a": {Capabilities: []string{"bus-state-calls"}, Fingerprint: "sha256:runner"}}, true}, + {"no reading", map[string]RunnerReading{}, true}, + {"a reading of another runner", map[string]RunnerReading{"a": {Capabilities: honours.Capabilities, Fingerprint: "sha256:older"}}, true}, + {"a reading of no runner", map[string]RunnerReading{"a": {Capabilities: honours.Capabilities}}, true}, + {"a reading with the capability of the running runner", map[string]RunnerReading{"a": honours}, false}, + } { + users, err := Users(Records{Nodes: []string{"a"}, Assigned: map[string][]Declared{"a": {{Module: "node-tools", ToolRunner: true}, shell}}, + Readings: c.readings, Running: map[string]string{"a.node-tools": "sha256:runner"}}) + if err != nil { + t.Fatal(err) + } + i := slices.IndexFunc(users, func(p Principal) bool { return p.Kind == KindNodeTools }) + if i < 0 { + t.Fatalf("%s: no runtime principal", c.name) + } + perms, err := PermissionsFor(users[i]) + if err != nil { + t.Fatal(err) + } + var named []string + for _, w := range MarksWithheld(users[i]) { + if w.Module != "shell" || w.Why != WithheldWhy { + t.Errorf("%s: withheld %+v", c.name, w) + } + named = append(named, w.Subject) + } + want := []string(nil) + if c.deny { + want = withheld + } + if !slices.Equal(perms.SubscribeDeny, want) || !slices.Equal(named, want) { + t.Errorf("%s: denied %v, named %v, want %v", c.name, perms.SubscribeDeny, named, want) + } + // The deny outranks the allow of the module's tools; an unmarked tool stays served. + if MaySubscribe(perms, "mesh.mod.shell.tool.execute") == c.deny || !MaySubscribe(perms, "mesh.mod.shell.tool.read") { + t.Errorf("%s: execute subscribable %v, read %v", c.name, MaySubscribe(perms, "mesh.mod.shell.tool.execute"), + MaySubscribe(perms, "mesh.mod.shell.tool.read")) + } + } +} diff --git a/modules/mesh-grants/internal/buscompose/nats.go b/modules/mesh-grants/internal/buscompose/nats.go index d23950d9..3cb9f387 100644 --- a/modules/mesh-grants/internal/buscompose/nats.go +++ b/modules/mesh-grants/internal/buscompose/nats.go @@ -89,6 +89,9 @@ type Seat struct { // holds. Kinded bool Kind string + // Marked are the verbs of the seat that need the operator's approval, as its definition marks them and the holder's + // `needs-approval` adds (design 46 approval mark §2, §6). + Marked []string // ByCaller are the accepts and emits whose last token names the calling module (ADR 0259 §3). ByCaller []string // Proofs are the seat's proof verbs: core request and reply, never on a stream (ADR 0259 §3). @@ -186,6 +189,9 @@ type Principal struct { // seat's verbs on this node — derived from the same declarations the modules' own principals // are, so the runtime can serve nothing a module could not have served for itself. Carries []Declared + // Withholds says this node's tool runner does not honour the approval mark (design 46 approval mark §6), for a + // KindNodeTools principal: every marked subject of what it carries is denied it (MarksWithheld). + Withholds bool // Invokes are the tools this principal may call, as `.`; a single `*` is every // tool. A person's whole authority (design 25 §7), and a module's only if its manifest says so @@ -1312,10 +1318,17 @@ func permissionsFor(p Principal) (Permissions, error) { } } } + // And the marked subjects of what a runtime carries, where its tool runner does not honour the mark (design 46 approval + // mark §6): a subscribe deny, which outranks the allow of each module's tools. + var subDeny []string + for _, w := range MarksWithheld(p) { + subDeny = append(subDeny, w.Subject) + } return Permissions{ - Publish: pub, - PublishDeny: deny, - Subscribe: sub, + Publish: pub, + PublishDeny: deny, + Subscribe: sub, + SubscribeDeny: unique(subDeny), // A module answers what it was asked — a tool call reaches it on its own namespace, so the // authority is bounded by having been asked. A node is asked one // thing, a hand-over on its own subject (novox/hq issue 356), and answers that: the node is its diff --git a/modules/mesh-grants/internal/buscompose/users.go b/modules/mesh-grants/internal/buscompose/users.go index c861f9d2..a0ce8f4d 100644 --- a/modules/mesh-grants/internal/buscompose/users.go +++ b/modules/mesh-grants/internal/buscompose/users.go @@ -28,6 +28,9 @@ import ( // Declared is one module on one node, as composing its authority needs it. type Declared struct { Module string + // Marked are the tools it serves that need the operator's approval, as its `needs-approval` names them (design 46 + // approval mark §1, §6); a marked seat verb is its Holds' Marked. + Marked []string // ToolRunner says the module is the node's tool runner, as its manifest says (`tool-runner`, ADR 0601 §4). ToolRunner bool // Objects are its own object buckets, and ReadsObjects the bus names of the object buckets of others its reads name @@ -151,6 +154,10 @@ type ProcessPrincipal struct { // Records is what composing a user list needs to know about the mesh, and nothing more. type Records struct { + // Readings are each node's capability reading, from the assignment half, by node; Running the fingerprint each + // node-engine last said running, by `.` (design 46 approval mark §6, design 47 G §309). + Readings map[string]RunnerReading + Running map[string]string // Kept is every seat event kept in a stream of its own (kept.go): the grants part makes each stream, so its principal is // granted the create of each by name. Kept []KeptEvent @@ -354,7 +361,11 @@ func Users(r Records) ([]Principal, error) { carried = append(carried, d) } } - out = append(out, Principal{Kind: KindNodeTools, Node: node, Module: runner, Carries: carried}) + // Its node's reading and the runner the node-engine says running decide whether the marks are withheld (design 46 + // approval mark §6). + reading, read := r.Readings[node] + out = append(out, Principal{Kind: KindNodeTools, Node: node, Module: runner, Carries: carried, + Withholds: Withholds(reading, read, r.Running[node+"."+runner])}) } } // A node enrols through the seat mesh-enrolment alone (ADR 0587 §6, ADR 0590): while no holder serves redeem, an diff --git a/modules/mesh-grants/internal/buscompose/withheld.go b/modules/mesh-grants/internal/buscompose/withheld.go new file mode 100644 index 00000000..1b233247 --- /dev/null +++ b/modules/mesh-grants/internal/buscompose/withheld.go @@ -0,0 +1,85 @@ +package buscompose + +import ( + "slices" + "sort" +) + +// **A machine whose tool runner does not honour the mark serves no marked tool or verb** (design 46 approval mark §6): on +// the account of a node's runtime whose readings lack approval-mark/1, or are not the running runner's, every marked +// subject of every module it carries is denied to subscribe, which outranks its allow of `mesh.mod..tool.>`, and +// each is named with why. + +// ApprovalMark is the capability a tool runner that honours the mark states among its call capabilities (design 46 +// approval mark §6). +const ApprovalMark = "approval-mark/1" + +// WithheldWhy is why a marked subject is withheld (design 46 approval mark §6). +const WithheldWhy = "its tool runner does not state " + ApprovalMark + +// WithheldSubject is one marked subject withheld from a runtime's account: the module it is of, the subject and why +// (lead answer A-203). +type WithheldSubject struct { + Module string `json:"module"` + Subject string `json:"subject"` + Why string `json:"why"` +} + +// RunnerReading is a node's capability reading as the assignment half carries it: what its tool runner states of the +// calls it answers, and the fingerprint of the runner that announced it (design 46 approval mark §6). +type RunnerReading struct { + Capabilities []string + Fingerprint string +} + +// Withholds says whether a node's runtime withholds the marked subjects: where the node has no reading, the reading +// lacks ApprovalMark, or its fingerprint is absent or differs from the one the node-engine last said running (no time is +// compared, failing closed). +func Withholds(reading RunnerReading, read bool, running string) bool { + return !read || !slices.Contains(reading.Capabilities, ApprovalMark) || reading.Fingerprint == "" || + reading.Fingerprint != running +} + +// MarkedSubjects are the subjects of a module's marked tools and of the marked verbs of the seats it holds, on node: a +// tool's `mesh.mod..tool.`, the same followed by `.*`, and the caller-named `mesh.mod..call..>`; +// a verb of a seat held per node `mesh.seat..tool.`, its `.` form and `mesh.seat..call...>`; +// a verb of a seat held once for the mesh `mesh.seat..tool.` and `mesh.seat..call..>`. +func MarkedSubjects(node string, d Declared) []string { + var out []string + for _, t := range d.Marked { + out = append(out, "mesh.mod."+d.Module+".tool."+t, "mesh.mod."+d.Module+".tool."+t+".*", + "mesh.mod."+d.Module+"."+CallKind+"."+t+".>") + } + for _, s := range d.Holds { + for _, v := range s.Marked { + if s.Scope == "node" { + out = append(out, "mesh.seat."+s.Name+".tool."+v, "mesh.seat."+s.Name+".tool."+v+"."+node, + "mesh.seat."+s.Name+"."+CallKind+"."+v+"."+node+".>") + } else { + out = append(out, "mesh.seat."+s.Name+".tool."+v, "mesh.seat."+s.Name+"."+CallKind+"."+v+".>") + } + } + } + return out +} + +// MarksWithheld are the subjects withheld from a principal's account, each with its module and why: for a node's runtime that +// withholds, every marked subject of every module it carries; none for any other. +func MarksWithheld(p Principal) []WithheldSubject { + if p.Kind != KindNodeTools || !p.Withholds { + return nil + } + var out []WithheldSubject + for _, d := range p.Carries { + for _, s := range MarkedSubjects(p.Node, d) { + out = append(out, WithheldSubject{Module: d.Module, Subject: s, Why: WithheldWhy}) + } + } + sort.Slice(out, func(i, j int) bool { + if out[i].Module != out[j].Module { + return out[i].Module < out[j].Module + } + return out[i].Subject < out[j].Subject + }) + return slices.CompactFunc(out, func(a, b WithheldSubject) bool { return a == b }) +} diff --git a/modules/mesh-grants/internal/grants/catalog.go b/modules/mesh-grants/internal/grants/catalog.go index 2852fb10..a3527e09 100644 --- a/modules/mesh-grants/internal/grants/catalog.go +++ b/modules/mesh-grants/internal/grants/catalog.go @@ -8,10 +8,14 @@ import ( "sync" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshwantsclient" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshdelivery" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/views" + + "mesh-grants/internal/buscompose" ) // **The grants part reads the catalog half** (design 47 G §614, §444, §684, A-5, A-52): through meshcatalogclient's branch follower @@ -24,14 +28,16 @@ import ( // long form with its field `branches`, §558, is not read by mesh-sdk's manifest reader yet.) func catalogHalfVerbs() []string { return []string{"seat:" + meshcatalog.Seat + "." + meshcatalog.VerbRevision, "seat:" + meshcatalog.Seat + "." + meshcatalog.VerbRegistrations, - meshcatalogclient.SourceVerb, meshcatalogclient.EntryVerb} + meshcatalogclient.SourceVerb, meshcatalogclient.EntryVerb, + // And the assignment half's revision (design 46 approval mark §6, design 47 G §195). + meshwantsclient.RevisionVerb} } // catalogHalfEvents are the events every such part names in its `consumes`, as `.`: the catalog's revision and // registration-changed, and the gate's source-moved. func catalogHalfEvents() []string { return []string{meshcatalog.Seat + "." + meshcatalog.EventCatalogRevision, meshcatalog.Seat + "." + meshcatalog.EventRegistrationChanged, - meshdelivery.Seat + "." + meshdelivery.EventSourceMoved} + meshdelivery.Seat + "." + meshdelivery.EventSourceMoved, meshwants.Seat + "." + meshwants.EventAssignmentRevision} } // CatalogHalf is what the part reads of the catalog half: the entry by its fingerprint, the branches whose first view is not in, @@ -200,3 +206,47 @@ func catalogWaiting(r Reader) string { slices.Sort(w) return fmt.Sprintf("the first views of the catalog half are not in (%v), so each account is composed from its want's entry by fingerprint and the list in force is kept", w) } + +// **The grants part reads the assignment half** (design 46 approval mark §6, design 47 G §195, lead answer A-203): the view of +// meshwantsclient's Assignment, a first view by mesh-wants' `revision`, then its `assignment-revision`, adopted on the part's +// views reader; from it each node's capability reading and the runner that announced it. + +// AssignmentHalf is what the part reads of the assignment half: the revision it holds, false while it holds none. +type AssignmentHalf interface { + Revision() (meshwants.AssignmentRevision, bool) +} + +// readingsReader is what a Reader that holds the assignment half answers (Views does). +type readingsReader interface { + Readings() (map[string]buscompose.RunnerReading, bool) +} + +// Readings are each node's capability reading of the assignment half the part holds, by node; false where it holds none, +// and true with none where the reader follows no assignment half (a tool's reads nothing of it). +func (v *Views) Readings() (map[string]buscompose.RunnerReading, bool) { + if v == nil || v.Assignment == nil { + return nil, true + } + rev, ok := v.Assignment.Revision() + if !ok { + return nil, false + } + out := map[string]buscompose.RunnerReading{} + for _, c := range rev.Capabilities { + out[c.Node] = buscompose.RunnerReading{Capabilities: c.Readings, Fingerprint: c.Fingerprint} + } + return out, true +} + +// readingsOf is each node's reading through r, and why the pass composes before the assignment half's first view. +func readingsOf(r Reader) (map[string]buscompose.RunnerReading, string) { + rr, ok := r.(readingsReader) + if !ok { + return nil, "" + } + readings, held := rr.Readings() + if !held { + return nil, "the first view of the assignment half is not in, so no node's capability reading is known and the list in force is kept" + } + return readings, "" +} diff --git a/modules/mesh-grants/internal/grants/firstviews.go b/modules/mesh-grants/internal/grants/firstviews.go index 8ed61c88..6bb851bf 100644 --- a/modules/mesh-grants/internal/grants/firstviews.go +++ b/modules/mesh-grants/internal/grants/firstviews.go @@ -81,6 +81,8 @@ type Views struct { Watching Watcher // Catalog is the part's catalog half (catalog.go): nil in a tool, which reads none. Catalog CatalogHalf + // Assignment is the part's assignment half (catalog.go): nil in a tool, which reads none. + Assignment AssignmentHalf said *untilSaid } diff --git a/modules/mesh-grants/internal/grants/inputs.go b/modules/mesh-grants/internal/grants/inputs.go index 1db94a80..2de53e88 100644 --- a/modules/mesh-grants/internal/grants/inputs.go +++ b/modules/mesh-grants/internal/grants/inputs.go @@ -158,7 +158,10 @@ type Inputs struct { Ran map[string]catalogue.Manifest // Heard is what each node-engine's `running` said of each `.`: a fingerprint, or stopped (design 47 G §404, // §459); a key absent was not heard since the part's consumer began. RanByFingerprint is the manifest of each fingerprint heard. - Heard map[string]catalogue.Heard + Heard map[string]catalogue.Heard + // Readings are each node's capability reading as the assignment half the part holds carries it, by node (design 46 + // approval mark §6); nil while the part holds no assignment half. + Readings map[string]buscompose.RunnerReading RanByFingerprint map[string]catalogue.Manifest // Had is the manifest of the build each machine runs now (its have's fingerprint, the builder's build of it), by // `.`; the want's Running may already be a newer build. A membership is composed from both and the trunk's. @@ -212,6 +215,9 @@ type Inputs struct { // CatalogUnread says why this pass composed before the first views of the catalog half were in (design 47 G §684); empty once they are. // As WantsUnread, such a pass retires nothing (ReadFailures) and only adds: the list in force is kept. CatalogUnread string + // AssignmentUnread says why this pass composed before the first view of the assignment half was in (design 46 approval + // mark §6, design 47 G §195): no node's capability reading is known, so the list in force is kept; empty once it is. + AssignmentUnread string // Held is, by module, the manifest a held module's running build was judged on (its have's fingerprint, the builder's // build of it): what the seats it defines, the streams others hear from it and its reads are composed from while its // trunk manifest is refused or waits, so no other principal loses a grant to it (ADR 0601 clause 3). @@ -366,6 +372,7 @@ func ReadInputs(r Reader, cache *Manifests) Inputs { Values: map[string]any{}, Gaps: append([]Gap(nil), standingGaps...)} in.SeatListUnread = gate != nil && !gate.MayAct() in.CatalogUnread = catalogWaiting(r) + in.Readings, in.AssignmentUnread = readingsOf(r) in.Defined = definedEventsOf(nil) // The definitions the reader holds, the bootstrapped ones before the seat list is read (ADR 0636 §2), name the events kept // on a stream of their own, so a durable made at the first pass already hears them there and never on EVENTS (a held @@ -764,6 +771,9 @@ func (in Inputs) ReadFailures() []string { if in.CatalogUnread != "" { out = append(out, "catalog half: "+in.CatalogUnread) } + if in.AssignmentUnread != "" { + out = append(out, "assignment half: "+in.AssignmentUnread) + } for _, g := range in.Refused { out = append(out, g.Input+": "+g.Why) } diff --git a/modules/mesh-grants/internal/grants/keeploaded.go b/modules/mesh-grants/internal/grants/keeploaded.go index 5cadd5fd..27812cdb 100644 --- a/modules/mesh-grants/internal/grants/keeploaded.go +++ b/modules/mesh-grants/internal/grants/keeploaded.go @@ -25,6 +25,8 @@ func (in Inputs) keepsLoaded() string { return in.WantsUnread case in.CatalogUnread != "": return in.CatalogUnread + case in.AssignmentUnread != "": + return in.AssignmentUnread case in.SeatListUnread: return "the seat list is unread, so only the bootstrapped seats compose" } diff --git a/modules/mesh-grants/internal/grants/part.go b/modules/mesh-grants/internal/grants/part.go index 51ea62c9..11c789f2 100644 --- a/modules/mesh-grants/internal/grants/part.go +++ b/modules/mesh-grants/internal/grants/part.go @@ -6,6 +6,7 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "errors" "fmt" "git.novox.be/novox/mesh-contracts/go/seats/meshbroker" "slices" @@ -15,6 +16,7 @@ import ( "time" "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + "git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshgrants" oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" "git.novox.be/novox/mesh-delivery/modules/mesh-grants/grantsstate" "git.novox.be/novox/mesh-sdk/go/membership" @@ -83,7 +85,10 @@ type UserListState struct { Credentials Credentials `json:"credentials"` BusNode string `json:"bus_node,omitempty"` Gaps []Gap `json:"gaps"` - At time.Time `json:"at"` + // Withheld are, by node, the marked subjects withheld from its runtime's account, each with its module and why (design + // 46 approval mark §6, lead answer A-203): what grants_user_list and the seat's verb withheld say. + Withheld map[string][]buscompose.WithheldSubject `json:"withheld,omitempty"` + At time.Time `json:"at"` } func (s UserListState) inputs() string { @@ -328,6 +333,7 @@ func (p *Part) Compose() Result { st.LeftOut = []Gap{} } users, refused, err := ComposeEach(listRecs) + st.Withheld = WithheldByNode(listRecs) if err != nil { res.Errors = append(res.Errors, "the shadow list could not be composed: "+err.Error()) } @@ -642,3 +648,50 @@ func (p *Part) inputs() Inputs { p.readRan(&in) return in } + +// WithheldByNode is, by node, the marked subjects withheld from its runtime's account (design 46 approval mark §6); nil when +// none is. A principal that cannot be derived withholds nothing here: the list is not composed then either. Pure. +func WithheldByNode(r buscompose.Records) map[string][]buscompose.WithheldSubject { + principals, err := buscompose.Users(r) + if err != nil { + return nil + } + var out map[string][]buscompose.WithheldSubject + for _, p := range principals { + if w := buscompose.MarksWithheld(p); len(w) > 0 { + if out == nil { + out = map[string][]buscompose.WithheldSubject{} + } + out[p.Node] = append(out[p.Node], w...) + } + } + return out +} + +// WithheldOn answers the seat mesh-grants' verb withheld (design 46 approval mark §6, lead answer A-203): the marked subjects +// withheld from node's runtime, grouped by module, from the user list the part composed last. +func (p *Part) WithheldOn(node string) (meshgrants.Withheld, error) { + if node == "" { + return meshgrants.Withheld{}, errors.New("withheld names a node") + } + out := meshgrants.Withheld{Node: node, Modules: []meshgrants.WithheldModulesItem{}} + raw, _, found, err := p.Store.Get(StateUserList, KeyList) + if err != nil { + return out, fmt.Errorf("its own user list cannot be read: %w", err) + } + if !found { + return out, errors.New("nothing composed yet: grants_compose_now composes it") + } + var st UserListState + if err := json.Unmarshal(raw, &st); err != nil { + return out, fmt.Errorf("its own user list cannot be read: %w", err) + } + for _, w := range st.Withheld[node] { + if n := len(out.Modules); n == 0 || out.Modules[n-1].Module != w.Module { + out.Modules = append(out.Modules, meshgrants.WithheldModulesItem{Module: w.Module, Subjects: []meshgrants.WithheldSubject{}}) + } + m := &out.Modules[len(out.Modules)-1] + m.Subjects = append(m.Subjects, meshgrants.WithheldSubject{Subject: w.Subject, Why: w.Why}) + } + return out, nil +} diff --git a/modules/mesh-grants/internal/grants/records.go b/modules/mesh-grants/internal/grants/records.go index 2fa9228b..af201b26 100644 --- a/modules/mesh-grants/internal/grants/records.go +++ b/modules/mesh-grants/internal/grants/records.go @@ -47,7 +47,14 @@ func records(in Inputs, union bool) (buscompose.Records, []Gap) { // And each node's logins (ADR 0583 §1): both while it rotates. NodeLogins: in.NodeLogins, Kept: in.Kept, Chunked: in.Chunked, EngineEvents: in.EventSubjects[buscompose.EngineSeat], // What the bus module's process may define: every manifest's bus state, and what it said it created (DLV-G2). - BusState: map[string][]catalogue.BusStateEntry{}, Bound: in.Bound} + BusState: map[string][]catalogue.BusStateEntry{}, Bound: in.Bound, + // Each node's capability reading and what each node-engine said running (design 46 approval mark §6). + Readings: in.Readings, Running: map[string]string{}} + for key, h := range in.Heard { + if !h.Stopped { + out.Running[key] = h.Fingerprint + } + } for module, m := range in.Manifests { if len(m.BusState) > 0 { out.BusState[module] = m.BusState @@ -163,6 +170,12 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio Ledgers: ledgersOf(m), } d.Loads = loadsABundle(m) + // Its marked tools (design 46 approval mark §1, §6): a name its `needs-approval` gives that it serves as a tool. + for _, mark := range m.NeedsApproval { + if slices.Contains(m.Tools, mark) { + d.Marked = append(d.Marked, mark) + } + } d.CatchUps = catchUpsOf(m) if _, reads := m.OwnSecrets["broker"]; !reads { d.NoAccount = true @@ -176,6 +189,7 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio held := asSeat(s, declarers[s.Name]) held.Kind = c.Kind held.Capabilities = c.Capabilities + held.Marked = markedVerbs(m, s) d.Holds = append(d.Holds, held) } } @@ -396,6 +410,18 @@ func logsOf(m catalogue.Manifest) []buscompose.Log { return out } +// markedVerbs are the verbs of a seat the module holds that need the operator's approval: those its definition marks for every +// holder, and those the module's `needs-approval` names as `.` (design 46 approval mark §2, §6). +func markedVerbs(m catalogue.Manifest, s catalogue.SeatDeclaration) []string { + var out []string + for _, v := range s.Serves { + if v.NeedsApproval || slices.Contains(m.NeedsApproval, s.Name+"."+v.Name) { + out = append(out, v.Name) + } + } + return out +} + func asSeat(s catalogue.SeatDeclaration, declarer string) buscompose.Seat { seat := buscompose.Seat{Name: s.Name, Scope: s.Scope, Accepts: s.Accepts, Emits: s.Emits, Serves: catalogue.VerbNames(s.Serves), Kinded: s.Kinded, ByCaller: s.ByCaller, Proofs: s.Proofs, DeclaredBy: declarer} diff --git a/modules/mesh-grants/module.json b/modules/mesh-grants/module.json index a5b98926..7228ae8f 100644 --- a/modules/mesh-grants/module.json +++ b/modules/mesh-grants/module.json @@ -7,7 +7,8 @@ "name": "mesh-grants", "scope": "mesh", "serves": [ - "grants-for" + "grants-for", + "withheld" ] } ], @@ -37,6 +38,7 @@ "mesh-catalog.seat-definition-changed", "mesh-catalog.seat-definition-removed", "mesh-delivery.source-moved", + "mesh-wants.assignment-revision", "node-engine.running.*.*" ], "uses": [ @@ -57,6 +59,7 @@ "seat:mesh-catalog.revision", "seat:mesh-catalog.seat-definitions", "seat:mesh-delivery.source", + "seat:mesh-wants.revision", "seat:mesh-hand-acts.hand-act", "seat:mesh-broker.list" ],