diff --git a/modules/mesh-grants/grants_test.go b/modules/mesh-grants/grants_test.go index d211c947..ea058081 100644 --- a/modules/mesh-grants/grants_test.go +++ b/modules/mesh-grants/grants_test.go @@ -2037,7 +2037,8 @@ func TestGrants(t *testing.T) { t.Run("assertions: the node-engine's declared state is created though no node is assigned it", func(t *testing.T) { b := fixture() - b.manifestOf(buscompose.EngineModule, `{"module":"mesh-host","version":"1","state":[{"name":"acts"}]}`) + b.manifestOf(buscompose.EngineModule, `{"module":"mesh-host","version":"1","state":[{"name":"acts"}],`+ + `"invokes":["seat:mesh-catalog.seat-definitions"],"consumes":["mesh-catalog.seat-definition-changed","restic.backed-up"]}`) b.loaded = shadowOf(t, b) p := partOn(b) p.Create = b @@ -2068,6 +2069,39 @@ func TestGrants(t *testing.T) { } } } + // Issue 1966: each node's account calls what the engine's manifest invokes in its own name, as a module's account + // does (G1), and hears a consumed seat's event on the seat's own subject, where the engine subscribes it, and still + // on the emitter's (G2); a consume naming no seat stays on its emitter's alone. + users, err := buscompose.Users(recs) + if err != nil { + t.Fatal(err) + } + nodes := 0 + for _, u := range users { + if u.Kind != buscompose.KindNode { + continue + } + nodes++ + perms, err := buscompose.PermissionsFor(u) + if err != nil { + t.Fatal(err) + } + token := buscompose.CallerToken(u.Username()) + if !buscompose.MayPublish(perms, "mesh.seat.mesh-catalog.call.seat-definitions."+token) || + buscompose.MayPublish(perms, "mesh.seat.mesh-catalog.call.seat-definitions.other~one") || + buscompose.MayPublish(perms, "mesh.seat.mesh-catalog.call.modules."+token) { + t.Errorf("%s: the engine's invoke of seat-definitions is not granted in its own name alone", u.Username()) + } + if !buscompose.MaySubscribe(perms, "mesh.seat.mesh-catalog.event.seat-definition-changed") || + !buscompose.MaySubscribe(perms, "mesh.mod.mesh-catalog.event.seat-definition-changed") || + buscompose.MaySubscribe(perms, "mesh.seat.mesh-catalog.event.manifest-accepted") || + !buscompose.MaySubscribe(perms, "mesh.mod.restic.event.backed-up") { + t.Errorf("%s: the engine's consumes are not heard on the seat's subject and the emitter's", u.Username()) + } + } + if nodes == 0 { + t.Fatal("no node account composed") + } }) t.Run("assertions: a refused create is said, never stops it, and is tried at the next event", func(t *testing.T) { diff --git a/modules/mesh-grants/internal/buscompose/nats.go b/modules/mesh-grants/internal/buscompose/nats.go index b14eaddc..dbee661c 100644 --- a/modules/mesh-grants/internal/buscompose/nats.go +++ b/modules/mesh-grants/internal/buscompose/nats.go @@ -779,6 +779,16 @@ func PermissionsFor(p Principal) (Permissions, error) { } sub = append(sub, subject) } + // And the seats' events the node-engine's manifest consumes, on each seat's own subject, and the verbs it invokes, + // in the node's own name (callerNamed, below): as a module's are (issue 1966: the seat list's verb and events). + sub = append(sub, watchedEvents(p.Watches)...) + tp, ts := seatTrafficGrants(SeatTrafficOf(p.Username(), nil, nil, p.Watches)) + pub, sub = append(pub, tp...), append(sub, ts...) + invoked, err := invokedSubjects(p.Invokes) + if err != nil { + return Permissions{}, err + } + pub = append(pub, invoked...) // And the creation of the seat mesh-module-assigner's two states it composes from (ADR 0567 §9), so a node agent started // before they exist binds each when the bus says it was made, with no timer; no other bucket's. sub = append(sub, NodeComposeAdvisories()...) @@ -918,19 +928,7 @@ func PermissionsFor(p Principal) (Permissions, error) { // 2b. Events of a role it watches, under the seat's own namespace. Subscribe only: watching a // role is hearing what it announced, not taking part in it. - for _, w := range p.Watches { - if w.Kinded { - continue // composed by SeatTrafficOf below - } - for _, e := range w.Emits { - // An event that names its holder's machine is heard from every machine (machineEvent): a flat subject would - // match no event the holder says. - if pattern, ok := machineEvent(w, e); ok { - e = pattern - } - sub = append(sub, seatSubject(w, "event", e)) - } - } + sub = append(sub, watchedEvents(p.Watches)...) // 2c. Its own consumer, which it **pulls**: the runtime asks for the next message and is // answered on its own inbox, so what it needs is to ask about the consumer and to ask it @@ -1609,6 +1607,25 @@ func quoted(values []string) string { return strings.Join(out, ", ") } +// watchedEvents are the subjects a principal subscribes to hear the seats it watches (a `consumes` naming a seat's event): +// each on the seat's own namespace, an event naming its holder's machine heard from every machine (machineEvent), since a +// flat subject would match no event the holder says. A kinded seat's are SeatTrafficOf's. +func watchedEvents(watches []Seat) []string { + var out []string + for _, w := range watches { + if w.Kinded { + continue + } + for _, e := range w.Emits { + if pattern, ok := machineEvent(w, e); ok { + e = pattern + } + out = append(out, seatSubject(w, "event", e)) + } + } + return out +} + // invokedSubjects is the publish side of a grant to call tools: one subject per `.`, // or the whole tool namespace for `*`. A person's authority and a module's `invokes` are both this // (novox/hq ADR 0152), so a malformed entry is refused in one place, before it could be widened into diff --git a/modules/mesh-grants/internal/buscompose/users.go b/modules/mesh-grants/internal/buscompose/users.go index 156a1214..b9fb73ed 100644 --- a/modules/mesh-grants/internal/buscompose/users.go +++ b/modules/mesh-grants/internal/buscompose/users.go @@ -200,12 +200,16 @@ func Users(r Records) ([]Principal, error) { // each with the node's permissions. // What the node-engine consumes is what its account hears (a `consumes` line of its own manifest; Engine): the // backup holder's word and the status view's `settled`, with no module named in this code. - var engineHears []string + // And the seats' events it consumes, on each seat's own subject, and the verbs it invokes, in the node's own name, + // granted as a module's are (issue 1966: the seat list's verb seat-definitions and its two events). + var engineHears, engineInvokes []string + var engineWatches []Seat if r.Engine != nil { - engineHears = r.Engine.Consumes + engineHears, engineWatches, engineInvokes = r.Engine.Consumes, r.Engine.Watches, r.Engine.Invokes } for _, login := range nodeLoginsOf(r, node) { out = append(out, Principal{Kind: KindNode, Node: node, Login: login, Checks: checks, Runner: runner, Consumes: engineHears, + Watches: engineWatches, Invokes: engineInvokes, MovesIntoTheVault: servesMoveIn(r), StatesItsKeyToTheVault: servesVerb(r, VaultNodeKey), AsksItsOwnSecrets: servesVerb(r, VaultOwn), AsksTheVaultToSign: servesVerb(r, VaultSign), Uses: asksSeatOf(r), FollowsGrantsUserList: bus, ServedReads: servedOn(served, node), RotatesItsLogin: servesVerb(r, VaultRotateNode)}) diff --git a/modules/mesh-grants/internal/grants/records.go b/modules/mesh-grants/internal/grants/records.go index 55ac6e90..47bedaf3 100644 --- a/modules/mesh-grants/internal/grants/records.go +++ b/modules/mesh-grants/internal/grants/records.go @@ -111,7 +111,12 @@ func records(in Inputs, union bool) (buscompose.Records, []Gap) { // The node-engine's own manifest, placed on every node (ADR 0563): its state, logs and streams alone, created by this // part; it adds no principal, since each node's engine speaks as the node's account. if m, ok := declared[buscompose.EngineModule]; ok { - out.Engine = &buscompose.Declared{Module: m.Module, Consumes: m.Consumes, State: bucketsFor(m, in.Nodes), Logs: logsOf(m), Streams: streamsOf(m)} + // What it consumes and invokes are its node account's: a seat's event also on the seat's own subject, as a module's + // consumes are split (consumedOf), every consume still on its emitter's as before, and each verb it invokes in the + // node's own name (issue 1966: the seat list's verb and events). + _, watches := consumedOf(m, seats, declarers) + out.Engine = &buscompose.Declared{Module: m.Module, Consumes: m.Consumes, Watches: watches, Invokes: m.Invokes, + State: bucketsFor(m, in.Nodes), Logs: logsOf(m), Streams: streamsOf(m)} } return out, gaps } @@ -119,29 +124,7 @@ func records(in Inputs, union bool) (buscompose.Records, []Gap) { // declaredFor is mesh-controller's declaredFor (internal/inventory/busrecords.go), copied: one module's manifest as the // composer needs it. func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaration, declarers map[string]string) buscompose.Declared { - var fromModules []string - var watches []buscompose.Seat - for _, c := range m.Consumes { - emitter, event, named := strings.Cut(c, ".") - if named { - if s, isASeat := seats[emitter]; isASeat && s.Kinded { - ev := strings.TrimSuffix(event, ".*") - if catalogue.SeatSays(s.Emits, ev) { - watches = append(watches, buscompose.Seat{Name: s.Name, Scope: s.Scope, Emits: []string{ev}, - Kinded: true, Proofs: s.Proofs, DeclaredBy: declarers[s.Name]}) - continue - } - } - if s, isASeat := seats[emitter]; isASeat && catalogue.SeatSays(s.Emits, event) { - // Its scope, and whether the node-engine holds it, place an event that names its holder's machine (ADR 0618 §4). - compiled, _ := catalogue.SeatNamed(s.Name) - watches = append(watches, buscompose.Seat{Name: s.Name, Scope: s.Scope, Emits: []string{event}, - HeldByTheEngine: compiled.HeldByTheEngine}) - continue - } - } - fromModules = append(fromModules, c) - } + fromModules, watches := consumedOf(m, seats, declarers) d := buscompose.Declared{ Module: m.Module, ToolRunner: catalogue.IsToolRunner(m), Emits: m.EmitsAll(), Consumes: fromModules, Watches: watches, Serves: m.Tools, Invokes: m.Invokes, Waits: waitsOf(m, seats), State: bucketsOf(m), Reads: m.Reads, Logs: logsOf(m), @@ -178,6 +161,36 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio return d } +// consumedOf splits what a manifest consumes as the composer grants it: a seat's event on the seat's own subject (its +// watches), every other by its emitter's (novox/hq ADR 0118). A placed module and the node-engine's own manifest are split +// alike. +func consumedOf(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaration, declarers map[string]string) ([]string, []buscompose.Seat) { + var fromModules []string + var watches []buscompose.Seat + for _, c := range m.Consumes { + emitter, event, named := strings.Cut(c, ".") + if named { + if s, isASeat := seats[emitter]; isASeat && s.Kinded { + ev := strings.TrimSuffix(event, ".*") + if catalogue.SeatSays(s.Emits, ev) { + watches = append(watches, buscompose.Seat{Name: s.Name, Scope: s.Scope, Emits: []string{ev}, + Kinded: true, Proofs: s.Proofs, DeclaredBy: declarers[s.Name]}) + continue + } + } + if s, isASeat := seats[emitter]; isASeat && catalogue.SeatSays(s.Emits, event) { + // Its scope, and whether the node-engine holds it, place an event that names its holder's machine (ADR 0618 §4). + compiled, _ := catalogue.SeatNamed(s.Name) + watches = append(watches, buscompose.Seat{Name: s.Name, Scope: s.Scope, Emits: []string{event}, + HeldByTheEngine: compiled.HeldByTheEngine}) + continue + } + } + fromModules = append(fromModules, c) + } + return fromModules, watches +} + // loadsABundle says the node's tool runtime loads a bundle of the module. The controller reads it from the resolved // manifest's Bundles, which a build derives and the indexer's manifest does not carry (the builder's build record names // the repository's manifest digest), so the same answer is derived here from `build.artifacts` by mesh-sdk's rule