From 6927b02d4d5806eb3b2253b40c430c7d2f698db8 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 26 Oct 2026 09:02:44 +0100 Subject: [PATCH] Grant the grants part creating the streams manifests declare, so a new module state no longer waits for a controller start The operator's one more grant in the frozen controller's composer (2026-10-26 08:54), beside the memberships grant of 22:31 (ADR 0547 clause 1 and 5): mesh-grants' principal may publish $JS.API.STREAM.CREATE. and $JS.API.STREAM.INFO. for each bucket (KV__), log (LOG__) and module stream a placed module's manifest declares, each by its own name, and nothing wider. The writers table lets it create, never update or delete. Byte-equal with mesh-sdk's buscompose (fix/1790-grants-create-buckets), whose expected lists this code wrote; the same test file holds both copies. Nothing else changes, and the mesh-sdk pin stays. Research 072: moves the grants piece (the grants part split out of the controller) one step, the creation of a module's declared bus objects; the controller's raise at start stays until it loses its lease. Issue: 1790 --- internal/broker/creates_streams_test.go | 77 +++++++++++++++++++++++++ internal/broker/nats.go | 22 +++++++ internal/broker/users.go | 31 ++++++++++ internal/broker/writers.go | 14 ++++- 4 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 internal/broker/creates_streams_test.go diff --git a/internal/broker/creates_streams_test.go b/internal/broker/creates_streams_test.go new file mode 100644 index 00000000..226efaa6 --- /dev/null +++ b/internal/broker/creates_streams_test.go @@ -0,0 +1,77 @@ +package broker + +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. +func TestTheGrantsPartCreatesOnlyDeclaredStreams(t *testing.T) { + records := Records{ + Nodes: []string{"novox"}, + Assigned: map[string][]Declared{"novox": { + {Module: "mesh-delivery-builder", + State: []Bucket{{Module: "mesh-delivery-builder", Name: "steps", History: 5}}, + Logs: []Log{{Module: "mesh-delivery-builder", Name: "builds"}}, + Streams: []ModuleStream{{Module: "mesh-delivery-builder", Name: "BUILDASKS", Events: []string{"ask"}}}}, + {Module: GrantsModule, State: []Bucket{{Module: GrantsModule, Name: "assertions"}}}, + }}, + } + users, err := Users(records) + if err != nil { + t.Fatal(err) + } + perms := map[string]Permissions{} + for _, u := range users { + if u.Kind != KindModule { + continue + } + p, err := PermissionsFor(u) + if err != nil { + t.Fatalf("%s: %v", u.Username(), err) + } + perms[u.Module] = p + } + for _, row := range []struct { + who, subject string + may bool + }{ + {GrantsModule, "$JS.API.STREAM.CREATE.KV_mesh-delivery-builder_steps", true}, + {GrantsModule, "$JS.API.STREAM.INFO.KV_mesh-delivery-builder_steps", true}, + {GrantsModule, "$JS.API.STREAM.CREATE.LOG_mesh-delivery-builder_builds", true}, + {GrantsModule, "$JS.API.STREAM.CREATE.BUILDASKS", true}, + {GrantsModule, "$JS.API.STREAM.CREATE.KV_mesh-grants_assertions", true}, + {GrantsModule, "$JS.API.STREAM.UPDATE.KV_mesh-delivery-builder_steps", false}, + {GrantsModule, "$JS.API.STREAM.DELETE.KV_mesh-delivery-builder_steps", false}, + {GrantsModule, "$JS.API.STREAM.PURGE.KV_mesh-delivery-builder_steps", false}, + {GrantsModule, "$JS.API.STREAM.CREATE.KV_mesh-delivery-builder_undeclared", false}, + {GrantsModule, "$JS.API.STREAM.CREATE.EVENTS", false}, + {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}, + } { + 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) + } + } + // A name that is no plain token is never granted, and a module other than the grants part is never granted one. + if got := CreateStreamGrants([]string{"*", ">", "KV_a.b", "KV_a_b"}); len(got) != 2 { + t.Errorf("grants for wildcard names: %v", got) + } + other, err := PermissionsFor(Principal{Kind: KindModule, Node: "novox", Module: "shop", CreatesStreams: []string{"KV_shop_x"}}) + if err != nil { + t.Fatal(err) + } + if MayPublish(other, "$JS.API.STREAM.CREATE.KV_shop_x") { + t.Error("a module other than the grants part was granted a create") + } + // The writers table refuses the grants part an update, and a create of a stream it was not given. + g := Principal{Kind: KindModule, Node: "novox", Module: GrantsModule, CreatesStreams: []string{"KV_a_b"}} + if err := CheckWriters(g, []string{"$JS.API.STREAM.CREATE.KV_a_b"}); err != nil { + t.Errorf("the declared create refused: %v", err) + } + for _, refused := range []string{"$JS.API.STREAM.UPDATE.KV_a_b", "$JS.API.STREAM.CREATE.KV_a_c", "$JS.API.STREAM.CREATE.*"} { + if CheckWriters(g, []string{refused}) == nil { + t.Errorf("%s was not refused", refused) + } + } +} diff --git a/internal/broker/nats.go b/internal/broker/nats.go index 8d51e7c3..774ead64 100644 --- a/internal/broker/nats.go +++ b/internal/broker/nats.go @@ -217,6 +217,11 @@ type Principal struct { // 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 + // CreatesStreams are the streams the grants part's principal alone may create (novox/hq ADR 0547 §5; the operator, + // 2026-10-26 08:54): the stream of every bucket, log and stream a manifest of a module placed anywhere declares, + // by name. Created only, never updated or deleted: a new module state is on the bus when the grants part hears its + // manifest, not at the controller's next start (issue 1790). + CreatesStreams []string // PasswordHash is the bcrypt hash the mesh minted. The plaintext is sealed to the principal // and never appears here: this file is written to a node's disk and read by a server, and a @@ -920,6 +925,9 @@ func PermissionsFor(p Principal) (Permissions, error) { // first so it publishes only what changed, and no stream it defines. if p.Module == GrantsModule { pub = append(pub, "mesh.assignment.*.*", "$JS.API.DIRECT.GET."+AssignmentsStream+".mesh.assignment.*.*") + // 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)...) } // The view alone reads the build seats' workers for the backlog of asks (issue 1887 A8a). if p.Module == ViewModule { @@ -1587,6 +1595,20 @@ func ComposeAccounts(principals []Principal) (string, error) { // from them. No other principal is granted the subjects below, and nothing is a wildcard over them. const WatchmanModule = "mesh-delivery-watchman" +// CreateStreamGrants is what creating each named stream needs, and nothing wider (novox/hq ADR 0547 §5; the operator, +// 2026-10-26 08:54): its create, and its information to read back what it holds. Each by its own name: a name that +// is not one plain subject token is left out, since a wildcard there would be every stream. +func CreateStreamGrants(streams []string) []string { + var out []string + for _, s := range streams { + if !safeSubject.MatchString(s) { + continue + } + out = append(out, "$JS.API.STREAM.CREATE."+s, "$JS.API.STREAM.INFO."+s) + } + return out +} + // GrantsModule is the one module whose principal publishes the modules' memberships (novox/hq ADR 0547 §5): the grants // part, which composes them from the manifests and placement. It defines no stream and is granted no other subject here. const GrantsModule = "mesh-grants" diff --git a/internal/broker/users.go b/internal/broker/users.go index 482207df..3225a432 100644 --- a/internal/broker/users.go +++ b/internal/broker/users.go @@ -207,6 +207,11 @@ func Users(r Records) ([]Principal, error) { PerMachine: perMachineNames(d.State), KeyedReads: d.KeyedReads, Logs: logNames(d.Logs), Streams: d.Streams, Hears: ComposedHears(d), Queues: d.Queues, }) + // **The grants part creates what a manifest declares** (novox/hq ADR 0547 §5; the operator, 2026-10-26 + // 08:54): every placed module's buckets, logs and streams, each by its stream's name. + if d.Module == GrantsModule { + out[len(out)-1].CreatesStreams = declaredStreamsOf(r) + } // **The vault's holder asks the desks and the issuing providers itself** (issue 723), under the // account its own runtime runs on (ADR 0259 §8's runs-as): the one module granted it. if holdsSeat(d, VaultSeat) { @@ -418,3 +423,29 @@ func boundBucketsOf(r Records) []string { func runnerServes(d Declared) bool { return d.Module == RuntimeModule || d.NoAccount || d.Loads || len(d.Serves) > 0 || len(d.Holds) > 0 || len(d.Waits) > 0 || len(d.Checks) > 0 } + +// declaredStreamsOf is the stream of every bucket, log and stream a module placed anywhere declares, once each, in name +// order (novox/hq ADR 0547 §5, issue 1790): `KV__`, `LOG__` and each module stream's own +// name. What the grants part may create, and nothing beside. +func declaredStreamsOf(r Records) []string { + seen := map[string]bool{} + for _, mods := range r.Assigned { + for _, d := range mods { + for _, b := range d.State { + seen["KV_"+b.Bucket()] = true + } + for _, l := range d.Logs { + seen[l.Stream()] = true + } + for _, s := range d.Streams { + seen[s.Name] = true + } + } + } + out := make([]string, 0, len(seen)) + for s := range seen { + out = append(out, s) + } + sort.Strings(out) + return out +} diff --git a/internal/broker/writers.go b/internal/broker/writers.go index 31437f66..f8e08ec6 100644 --- a/internal/broker/writers.go +++ b/internal/broker/writers.go @@ -37,6 +37,17 @@ type WriterRow struct { // isController, and the other writers' tests, are who a row's writer is as a principal. func isController(p Principal, _ string) bool { return p.Kind == KindController } +// createsDeclared is the controller, or the grants part creating one stream a manifest declares, by its own name: the +// create alone, never an update or a delete (novox/hq ADR 0547 §5; the operator, 2026-10-26 08:54). +func createsDeclared(p Principal, pattern string) bool { + if isController(p, pattern) { + return true + } + name, ok := strings.CutPrefix(pattern, "$JS.API.STREAM.CREATE.") + return ok && p.Kind == KindModule && p.Module == GrantsModule && slices.Contains(p.CreatesStreams, name) && + safeSubject.MatchString(name) +} + // ownMachine is a node writing a subject whose third token is its own name, and no wildcard there. func ownMachine(p Principal, pattern string) bool { tokens := strings.Split(pattern, ".") @@ -170,7 +181,8 @@ var WritersTable = []WriterRow{ // bucket's stream (ADR 0201), which defines nothing the mesh keeps. Subjects: []string{"$JS.API.STREAM.CREATE.>", "$JS.API.STREAM.UPDATE.>", "$JS.API.STREAM.DELETE.>", "$JS.API.CONSUMER.DURABLE.CREATE.>"}, - Writes: isController}, + Writes: createsDeclared, + Shared: "the grants part creates a stream a manifest declares, by its name, and never updates or deletes one (novox/hq ADR 0547 §5; the operator, 2026-10-26 08:54)"}, {State: "builds and their outcomes", Writer: "the build seat's holder", KeptIn: "its own state", Others: "the controller asks", Subjects: []string{"mesh.seat.node-build-agent.event.built", "mesh.seat.mesh-build-machine.event.built"},