From 09d2cc5056ef6e7647c1cd2dc5b5d3c3063473d7 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 26 Oct 2026 13:03:15 +0100 Subject: [PATCH] Broker: the bus node may follow the grants part's sealed user list and the grants part may write it, the operator's second one-grant exception (issue 1945, ADR 0547 clauses 7-8) --- internal/broker/creates_streams_test.go | 35 ++++++++++++++-- internal/broker/log_test.go | 5 ++- internal/broker/nats.go | 10 +++++ internal/broker/userlist_switch.go | 55 +++++++++++++++++++++++++ internal/broker/users.go | 7 +++- internal/broker/writers.go | 8 +++- 6 files changed, 114 insertions(+), 6 deletions(-) create mode 100644 internal/broker/userlist_switch.go diff --git a/internal/broker/creates_streams_test.go b/internal/broker/creates_streams_test.go index 226efaa6..0babf983 100644 --- a/internal/broker/creates_streams_test.go +++ b/internal/broker/creates_streams_test.go @@ -4,11 +4,14 @@ import "testing" // Defends issue 1790 and novox/hq ADR 0547 §5 (the operator, 2026-10-26 08:54): the grants part may create the stream // of each bucket, log and stream a placed module's manifest declares, by its own name, and read it back; nothing -// wider, nothing updated or deleted, and no other principal so. Held byte-equal in the controller's copy. +// wider, nothing updated or deleted, and no other principal so. Held byte-equal in the controller's copy. And ADR 0547 +// clause 7 (issue 1945, the operator's second exception): the grants part writes its own object bucket of sealed user +// lists, and the node-engine of the node holding mesh-broker alone reads its one key and an object of it by name. func TestTheGrantsPartCreatesOnlyDeclaredStreams(t *testing.T) { records := Records{ - Nodes: []string{"novox"}, - Assigned: map[string][]Declared{"novox": { + Nodes: []string{"novox", "ace"}, + Assigned: map[string][]Declared{"ace": {{Module: "plex"}}, "novox": { + {Module: "nats", SnapshotsTheBus: true}, {Module: "mesh-delivery-builder", State: []Bucket{{Module: "mesh-delivery-builder", Name: "steps", History: 5}}, Logs: []Log{{Module: "mesh-delivery-builder", Name: "builds"}}, @@ -22,6 +25,14 @@ func TestTheGrantsPartCreatesOnlyDeclaredStreams(t *testing.T) { } perms := map[string]Permissions{} for _, u := range users { + if u.Kind == KindNode { + p, err := PermissionsFor(u) + if err != nil { + t.Fatalf("%s: %v", u.Username(), err) + } + perms[u.Username()] = p + continue + } if u.Kind != KindModule { continue } @@ -48,6 +59,24 @@ func TestTheGrantsPartCreatesOnlyDeclaredStreams(t *testing.T) { {GrantsModule, "$JS.API.STREAM.CREATE.ASSIGNMENTS", false}, {GrantsModule, "$JS.API.STREAM.INFO.EVENTS", false}, {"mesh-delivery-builder", "$JS.API.STREAM.CREATE.KV_mesh-delivery-builder_steps", false}, + // The sealed user list (ADR 0547 clause 7): the grants part writes its own bucket and no other. + {GrantsModule, "$JS.API.STREAM.CREATE.OBJ_mesh-grants-user-list", true}, + {GrantsModule, "$O.mesh-grants-user-list.C.abc", true}, + {GrantsModule, "$JS.API.STREAM.PURGE.OBJ_mesh-grants-user-list", true}, + {GrantsModule, "$O.declaration-files-novox.C.abc", false}, + {GrantsModule, "$JS.API.STREAM.DELETE.OBJ_mesh-grants-user-list", false}, + {"mesh-delivery-builder", "$O.mesh-grants-user-list.C.abc", false}, + // The bus node's engine reads the one key and an object by name; nothing of the bucket's list, no write. + {"node.novox", "$JS.API.DIRECT.GET.KV_mesh-grants_user-list.$KV.mesh-grants_user-list.sealed", true}, + {"node.novox", "$JS.API.CONSUMER.CREATE.KV_mesh-grants_user-list.abc.$KV.mesh-grants_user-list.sealed", true}, + {"node.novox", "$JS.API.DIRECT.GET.OBJ_mesh-grants-user-list.$O.mesh-grants-user-list.M.abc", true}, + {"node.novox", "$JS.API.CONSUMER.CREATE.OBJ_mesh-grants-user-list.abc.$O.mesh-grants-user-list.C.abc", true}, + {"node.novox", "$JS.API.DIRECT.GET.KV_mesh-grants_user-list.$KV.mesh-grants_user-list.list", false}, + {"node.novox", "$JS.API.STREAM.INFO.OBJ_mesh-grants-user-list", false}, + {"node.novox", "$JS.API.STREAM.MSG.GET.OBJ_mesh-grants-user-list", false}, + {"node.novox", "$O.mesh-grants-user-list.C.abc", false}, + {"node.ace", "$JS.API.DIRECT.GET.KV_mesh-grants_user-list.$KV.mesh-grants_user-list.sealed", false}, + {"node.ace", "$JS.API.DIRECT.GET.OBJ_mesh-grants-user-list.$O.mesh-grants-user-list.M.abc", false}, } { if got := MayPublish(perms[row.who], row.subject); got != row.may { t.Errorf("%s may publish %s: %v, want %v", row.who, row.subject, got, row.may) diff --git a/internal/broker/log_test.go b/internal/broker/log_test.go index c41038c3..bfe7af62 100644 --- a/internal/broker/log_test.go +++ b/internal/broker/log_test.go @@ -213,8 +213,11 @@ func TestNoPathDeletesALogOrABucket(t *testing.T) { // Only the writers table names it, as a subject no one but the controller may publish. // And the operator's release 6 hand path, whose object store purges a replaced object's chunks in a node's // object bucket (novox/hq ADR 0539 clause 10): a grant, never a call of this repository. + // And the grants part's own object bucket of sealed user lists, whose put purges a replaced object's chunks + // (novox/hq ADR 0547 clause 7): a grant, never a call of this repository. if !strings.HasSuffix(filepath.ToSlash(path), "internal/broker/writers.go") && - !strings.HasSuffix(filepath.ToSlash(path), "internal/broker/composed.go") { + !strings.HasSuffix(filepath.ToSlash(path), "internal/broker/composed.go") && + !strings.HasSuffix(filepath.ToSlash(path), "internal/broker/userlist_switch.go") { found = append(found, path+": "+lit) } } diff --git a/internal/broker/nats.go b/internal/broker/nats.go index 774ead64..43682ee0 100644 --- a/internal/broker/nats.go +++ b/internal/broker/nats.go @@ -214,6 +214,9 @@ type Principal struct { // StatesItsKeyToTheVault is a machine principal whose node-engine may ask the vault's `node-key` in its own name // (issue 786, novox/hq ADR 0372): set while the vault's holder serves it. That one verb, and nothing else new. StatesItsKeyToTheVault bool + // FollowsGrantsUserList is the machine principal of the node holding mesh-broker: its node-engine reads the grants + // part's sealed user list, one key and an object by name (novox/hq ADR 0547 clause 7, GrantsUserListFollows). + FollowsGrantsUserList bool // BoundBuckets are the providers' buckets of entries, `_bound`, a provisioner client reads its own // machine's keys of (mesh-sdk go/provider, issue 679): every bucket named bound of a module the mesh places. BoundBuckets []string @@ -913,6 +916,11 @@ func PermissionsFor(p Principal) (Permissions, error) { if p.StatesItsKeyToTheVault { pub = append(pub, "mesh.seat."+VaultNodeKey.Seat+"."+CallKind+"."+VaultNodeKey.Verb+"."+CallerToken(p.Username())) } + // 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 { + pub = append(pub, GrantsUserListFollows()...) + } case KindModule: // 0. The watchman alone hears who connects and disconnects, and asks who is connected (ADR 0431 §2). @@ -928,6 +936,8 @@ func PermissionsFor(p Principal) (Permissions, error) { // And creating each stream a manifest declares, by its own name (the operator, 2026-10-26 08:54; issue // 1790): the create and the read of that one stream, never an update, a delete or a stream not declared. pub = append(pub, CreateStreamGrants(p.CreatesStreams)...) + // And its own object bucket, where it writes the user list sealed to the bus node (ADR 0547 clause 7). + pub = append(pub, GrantsUserListWrites()...) } // The view alone reads the build seats' workers for the backlog of asks (issue 1887 A8a). if p.Module == ViewModule { diff --git a/internal/broker/userlist_switch.go b/internal/broker/userlist_switch.go new file mode 100644 index 00000000..4fc43eed --- /dev/null +++ b/internal/broker/userlist_switch.go @@ -0,0 +1,55 @@ +package broker + +// **The grants part's user list, sealed to the bus node** (novox/hq ADR 0547 clause 7, issue 1945): the operator's +// second exception to the frozen controller (the first, memberships, 2026-10-25 22:31). The controller's copy of the bus +// composer grants what mesh-sdk's copy grants, byte for byte, and nothing else: the node-engine of the node holding +// mesh-broker reads the grants part's one key `user-list.sealed`, watched, and an object of its bucket by name; the +// grants part writes that bucket. Without it the bus node could never read the first list the grants part writes, since +// until take-over the list the bus loads is this one (mesh-sdk go/buscompose/userlist_switch.go keeps the other copy). + +// The grants part's user list on the bus (mesh-sdk go/delivery userlist.go names them). +const ( + GrantsUserListBucket = "mesh-grants_user-list" + GrantsUserListKey = "sealed" + GrantsUserListObjects = "mesh-grants-user-list" + GrantsUserListObjectStream = "OBJ_" + GrantsUserListObjects +) + +// GrantsUserListFollows is what the node-engine of the node holding mesh-broker may publish to follow the grants part's +// list: the one key read and watched, and an object of the bucket by its name (as ADR 0486 §2 reads a declaration's +// files: never the object stream's info, which lists every name, never a message by sequence). +func GrantsUserListFollows() []string { + kv := "KV_" + GrantsUserListBucket + key := "$KV." + GrantsUserListBucket + "." + GrantsUserListKey + obj, s := GrantsUserListObjects, GrantsUserListObjectStream + return []string{ + "$JS.API.DIRECT.GET." + kv + "." + key, + "$JS.API.CONSUMER.CREATE." + kv + ".*." + key, + "$JS.API.CONSUMER.DELETE." + kv + ".*", + "$JS.API.STREAM.INFO." + kv, + "$JS.FC." + kv + ".>", + "$JS.API.DIRECT.GET." + s + ".$O." + obj + ".M.*", + "$JS.API.CONSUMER.CREATE." + s + ".*.$O." + obj + ".C.*", + "$JS.API.CONSUMER.DELETE." + s + ".*", + "$JS.FC." + s + ".>", + } +} + +// GrantsUserListWrites is what the grants part may publish to keep its object bucket. +func GrantsUserListWrites() []string { + obj, s := GrantsUserListObjects, GrantsUserListObjectStream + return []string{ + "$JS.API.STREAM.CREATE." + s, + "$JS.API.STREAM.INFO." + s, + "$JS.API.STREAM.PURGE." + s, + "$JS.API.DIRECT.GET." + s + ".>", + "$JS.API.STREAM.MSG.GET." + s, + "$O." + obj + ".>", + } +} + +// grantsOwnObjects is the grants part writing its own object bucket, and nothing of another. +func grantsOwnObjects(p Principal, pattern string) bool { + return p.Kind == KindModule && p.Module == GrantsModule && + (pattern == "$O."+GrantsUserListObjects+".>" || pattern == "$JS.API.STREAM.CREATE."+GrantsUserListObjectStream) +} diff --git a/internal/broker/users.go b/internal/broker/users.go index 3225a432..c48b1662 100644 --- a/internal/broker/users.go +++ b/internal/broker/users.go @@ -147,14 +147,19 @@ func Users(r Records) ([]Principal, error) { for _, node := range sortedCopy(r.Nodes) { witness := false var checks []string + bus := false for _, d := range r.Assigned[node] { if d.Module == controllerModule { witness = true } + if d.SnapshotsTheBus { + bus = true + } checks = append(checks, d.Checks...) } out = append(out, Principal{Kind: KindNode, Node: node, WitnessesController: witness, Checks: checks, - MovesIntoTheVault: servesMoveIn(r), StatesItsKeyToTheVault: servesVerb(r, VaultNodeKey)}) + MovesIntoTheVault: servesMoveIn(r), StatesItsKeyToTheVault: servesVerb(r, VaultNodeKey), + FollowsGrantsUserList: bus}) // **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. for _, d := range r.Assigned[node] { diff --git a/internal/broker/writers.go b/internal/broker/writers.go index f8e08ec6..b3a7b4e7 100644 --- a/internal/broker/writers.go +++ b/internal/broker/writers.go @@ -44,6 +44,9 @@ func createsDeclared(p Principal, pattern string) bool { return true } name, ok := strings.CutPrefix(pattern, "$JS.API.STREAM.CREATE.") + if ok && p.Kind == KindModule && p.Module == GrantsModule && name == GrantsUserListObjectStream { + return true + } return ok && p.Kind == KindModule && p.Module == GrantsModule && slices.Contains(p.CreatesStreams, name) && safeSubject.MatchString(name) } @@ -249,7 +252,10 @@ var WritersTable = []WriterRow{ KeptIn: "the object bucket " + DeclarationFilesBucket + "- of each node, an object removed a day after the " + "node's latest declaration no longer names it", Others: "a node-engine reads an object of its own bucket by its name and may neither list the bucket nor write it", - Subjects: []string{"$O.*.>"}, Writes: isControllerOrTheHandPath}, + Subjects: []string{"$O.*.>"}, Writes: func(p Principal, pattern string) bool { + return isControllerOrTheHandPath(p, pattern) || grantsOwnObjects(p, pattern) + }, + Shared: "the grants part writes its own object bucket " + GrantsUserListObjects + ", the user list sealed to the bus node, and no other (novox/hq ADR 0547 clause 7)"}, {State: "the declared-config mirror", Writer: "controller (lease holder), from its store, only on a change", KeptIn: "key-value " + DeclaredConfigBucket, Others: "read through `reads` as mesh-controller." + MirrorDeclaredConfig, Subjects: kvOf(DeclaredConfigBucket), Writes: isController},