From 79231a9e6c667e6239c5ac111429cdb1b8cc2f89 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 28 Oct 2026 09:45:48 +0100 Subject: [PATCH] =?UTF-8?q?A=20node's=20engine=20answers=20its=20own=20nod?= =?UTF-8?q?e's=20node-engine=20verbs,=20says=20the=20seat's=20events=20and?= =?UTF-8?q?=20asks=20the=20operator=20as=20node-engine-,=20and=20no?= =?UTF-8?q?=20other=20account=20answers=20them=20(issue=20774,=20ADR=20056?= =?UTF-8?q?3=20=C2=A71,=20=C2=A73,=20=C2=A76)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go/asks/asks.go | 5 +++ go/buscompose/buscompose_test.go | 63 +++++++++++++++++++++++++++++++- go/buscompose/engine.go | 30 +++++++++++++++ go/buscompose/nats.go | 4 ++ go/buscompose/users.go | 2 +- 5 files changed, 102 insertions(+), 2 deletions(-) create mode 100644 go/buscompose/engine.go diff --git a/go/asks/asks.go b/go/asks/asks.go index 596bb8f..480f212 100644 --- a/go/asks/asks.go +++ b/go/asks/asks.go @@ -571,3 +571,8 @@ type ProofAnswer struct { Accepted bool `json:"accepted"` Words string `json:"words"` } + +// EngineAsker is the asker name a node's engine asks the operator under (novox/hq ADR 0563 §3, which changes ADR 0259 +// §2 to admit a node's engine as an asker): `node-engine-`, the last token of the by-caller subjects granted to that +// node's engine account alone, so no engine acts on another node's warrant. +func EngineAsker(node string) string { return "node-engine-" + node } diff --git a/go/buscompose/buscompose_test.go b/go/buscompose/buscompose_test.go index c9d6d2f..119acae 100644 --- a/go/buscompose/buscompose_test.go +++ b/go/buscompose/buscompose_test.go @@ -55,6 +55,18 @@ func mayBeyondTheControllers(user string) map[string]bool { VaultSecretChanged: true} } +// engineBeyond is a node's engine holding its node seat and asking the operator in its own name (novox/hq ADR 0563 §1, +// §3), beyond the controller's on purpose: its own node's verbs, events and discovery, the bus's discovery asked, and +// the router's seat traffic under node-engine-. +func engineBeyond(user, g string) bool { + node, ok := strings.CutPrefix(user, "node.") + if !ok { + return false + } + return strings.HasPrefix(g, "mesh.seat."+EngineSeat+".") && strings.Contains(g, "."+node) || + strings.HasPrefix(g, "$SRV.") || strings.Contains(g, ".node-engine-"+node) +} + // createsAnAccountsConsumer is the grants part's create and read of a durable consumer an account needs, by its stream // and name (novox/hq ADR 0561 §6): beyond the controller's on purpose, since the controller made them alone. func createsAnAccountsConsumer(user, g string) bool { @@ -114,7 +126,7 @@ func grantsAsTheControllers(t *testing.T, got, want string) { } for g := range now { if !was[g] && !intended[g] && !mayBeyondTheControllers(user)[g] && !createdAdvisory.MatchString(`"`+g+`", `) && - !createsAnAccountsConsumer(user, g) { + !createsAnAccountsConsumer(user, g) && !engineBeyond(user, g) { t.Errorf("%s: the copy grants %s beyond the controller's, and no decision names it", user, g) } } @@ -235,6 +247,55 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) { t.Error("a user of a seat not pulled by kind reads DEAD_LETTERS") } }) + // ADR 0563 §1, §3, §6: each node's engine alone answers its own node's node-engine verbs and says its events, any + // runtime may call them, and it asks the operator under node-engine- alone. + t.Run("a node's engine holds its node seat and asks in its own name", func(t *testing.T) { + router := Seat{Name: AsksSeat, Accepts: []string{"ask", "cancel"}, Emits: []string{"decided"}, + ByCaller: []string{"ask", "cancel", "decided"}, Records: []string{"messenger_asks"}} + users, err := Users(Records{Nodes: []string{"one", "two"}, Assigned: map[string][]Declared{ + "one": {{Module: "messenger", Holds: []Seat{router}, Invokes: []string{"seat:" + EngineSeat + ".root-free"}}}, + "two": {{Module: RuntimeModule}, {Module: "shop", Invokes: []string{"*"}}}}}) + if err != nil { + t.Fatal(err) + } + for _, u := range users { + perms, err := PermissionsFor(u) + if err != nil { + t.Fatal(err) + } + answers := MaySubscribe(perms, "mesh.seat."+EngineSeat+".tool.root-free.one") || + MaySubscribe(perms, "mesh.seat."+EngineSeat+".call.root-free.one.messenger~one") + if engineOne := u.Kind == KindNode && u.Node == "one"; answers != engineOne { + t.Errorf("%s answers one's root-free: %v", u.Username(), answers) + } + switch { + case u.Kind == KindNode && u.Node == "one": + for subject, want := range map[string]bool{ + "mesh.seat.operator-channel.accept.ask.node-engine-one": true, + "mesh.seat.operator-channel.accept.cancel.node-engine-one": true, + "mesh.seat.operator-channel.accept.ask.node-engine-two": false, + "$JS.API.DIRECT.GET.KV_messenger_asks.$KV.messenger_asks.node-engine-one.hand-over-1": true, + "$JS.API.DIRECT.GET.KV_messenger_asks.$KV.messenger_asks.node-engine-two.hand-over-1": false, + "mesh.seat." + EngineSeat + ".event.done.one": true, + "mesh.seat." + EngineSeat + ".event.done.two": false, + "$SRV.INFO": true, + } { + if got := MayPublish(perms, subject); got != want { + t.Errorf("node one may publish %s: %v, want %v", subject, got, want) + } + } + if !MaySubscribe(perms, "mesh.seat.operator-channel.event.decided.node-engine-one") || + MaySubscribe(perms, "mesh.seat.operator-channel.event.decided.node-engine-two") || + !MaySubscribe(perms, "$SRV.INFO."+EngineSeat) || MaySubscribe(perms, "mesh.seat."+EngineSeat+".tool.hand-over.two") { + t.Errorf("node one hears %v", perms.Subscribe) + } + case u.Kind == KindNodeTools || u.Module == "messenger": + if !MayPublish(perms, "mesh.seat."+EngineSeat+".tool.root-free.one") { + t.Errorf("%s cannot call one's root-free", u.Username()) + } + } + } + }) // Issue 1126 (ADR 0550 §5): a node's engine asks the vault's `own` in its own name while the vault serves it, and no // other node's. t.Run("a node asks the vault its own secrets", func(t *testing.T) { diff --git a/go/buscompose/engine.go b/go/buscompose/engine.go new file mode 100644 index 0000000..95fa03d --- /dev/null +++ b/go/buscompose/engine.go @@ -0,0 +1,30 @@ +package buscompose + +import ( + "git.novox.be/novox/mesh-sdk/go/asks" + "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// The node seat node-engine (novox/hq ADR 0563 §1): every node's engine holds it on its own bus account, and no module +// claims it. Its verbs and events are the catalogue's (catalogue.EngineVerbs), read here rather than copied. + +// EngineSeat is the seat. +const EngineSeat = catalogue.EngineSeat + +// engineGrants is what a node's engine is granted to hold its node seat and to ask the operator (ADR 0563 §1, §3): it +// answers its own node's verbs (the caller-named subjects are added beside them with every principal's), says the +// seat's events about its node, answers discovery for the seat, asks who answers what (the login shell's execute, ADR +// 0563 §6), and asks the operator under asks.EngineAsker(node) on the router's seat as its holder declares it, as any +// user of that seat is granted. +func engineGrants(p Principal) (pub, sub []string) { + for _, v := range catalogue.EngineVerbs() { + sub = append(sub, "mesh.seat."+EngineSeat+".tool."+v.Name+"."+p.Node) + } + for _, e := range catalogue.EngineEvents { + pub = append(pub, "mesh.seat."+EngineSeat+".event."+e+"."+p.Node) + } + sub = append(sub, announcing(EngineSeat)...) + pub = append(pub, discovering()...) + tp, ts := seatTrafficGrants(SeatTrafficOf(asks.EngineAsker(p.Node), nil, p.Uses, nil)) + return append(pub, tp...), append(sub, ts...) +} diff --git a/go/buscompose/nats.go b/go/buscompose/nats.go index ceed8be..74981b0 100644 --- a/go/buscompose/nats.go +++ b/go/buscompose/nats.go @@ -973,6 +973,10 @@ func PermissionsFor(p Principal) (Permissions, error) { pub = append(pub, readGrants(delivery.SignersBucket, "")...) pub = append(pub, readGrants(LeaseBucket, leaseKey)...) } + // And its node seat, node-engine, and its asks to the operator in its own name (novox/hq ADR 0563 §1, §3). + ep, es := engineGrants(p) + pub = append(pub, ep...) + sub = append(sub, es...) // And, on the node holding mesh-broker, the grants part's sealed user list it follows from take-over (novox/hq ADR // 0547 clause 7): one key, and an object by its name. if p.FollowsGrantsUserList { diff --git a/go/buscompose/users.go b/go/buscompose/users.go index 2424fcf..01616d1 100644 --- a/go/buscompose/users.go +++ b/go/buscompose/users.go @@ -165,7 +165,7 @@ func Users(r Records) ([]Principal, error) { } out = append(out, Principal{Kind: KindNode, Node: node, WitnessesController: witness, Checks: checks, MovesIntoTheVault: servesMoveIn(r), StatesItsKeyToTheVault: servesVerb(r, VaultNodeKey), - AsksItsOwnSecrets: servesVerb(r, VaultOwn), AsksTheVaultToSign: servesVerb(r, VaultSign), + AsksItsOwnSecrets: servesVerb(r, VaultOwn), AsksTheVaultToSign: servesVerb(r, VaultSign), Uses: asksSeatOf(r), FollowsGrantsUserList: bus, ServedReads: servedOn(served, node)}) // **And the machine's provisioner client, on an account of its own** (novox/hq ADR 0349 clause 11): one // per machine that runs it, delivered to the module running it.