From 3ae2d6e17c942a0965ff0ab7eb4c41306451b918 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 24 Oct 2026 18:14:45 +0200 Subject: [PATCH 1/4] Grant release 6's compositions, wants and backups ahead of its switch (issue 1945) The switch to release 6 is today's controller's last plan, and the bus runs the user list it composes until the new controller writes one: so the new controller's writes of each node's compositions and their digests, each new node-engine's reads of its own compositions and wants and its backup holder's verbs and word, and the assigner's read of the digests must be granted by this controller already (ADR 0539 clauses 2, 6, 9 and 16; the review's A1). --- internal/broker/announced.go | 2 +- internal/broker/announced_test.go | 2 +- internal/broker/composed.go | 96 ++++++++++++++++++++++ internal/broker/composed_test.go | 60 ++++++++++++++ internal/broker/controller_buckets.go | 9 +- internal/broker/controller_buckets_test.go | 3 +- internal/broker/nats.go | 24 ++++++ internal/broker/testdata/composed.conf | 6 +- internal/broker/writers.go | 4 + internal/broker/writers_test.go | 2 + 10 files changed, 200 insertions(+), 8 deletions(-) create mode 100644 internal/broker/composed.go create mode 100644 internal/broker/composed_test.go diff --git a/internal/broker/announced.go b/internal/broker/announced.go index dd0ec5f9..e05f7835 100644 --- a/internal/broker/announced.go +++ b/internal/broker/announced.go @@ -20,7 +20,7 @@ const StatesAnnouncedBucket = announce.KeptBucket // among them, which the pacman module reads, ADR 0507 §5), the chain's buckets and the grants issued, which the assigner // reads (ADR 0528 clause 2). var controllerReadable = []string{MirrorAssignments, MirrorDeclaredConfig, MirrorPredefined, ChainBuilds, ChainRollouts, - MirrorDeclaredPackages, ControllerGrants} + MirrorDeclaredPackages, ControllerGrants, ControllerComposed} // ControllerAnnouncement is what the controller announces it keeps (ADR 0495 clause 2): every bucket of its own by its local // name, the ones others may read marked readable. It announces itself, as any module does from its code. diff --git a/internal/broker/announced_test.go b/internal/broker/announced_test.go index f538c557..ee4a600a 100644 --- a/internal/broker/announced_test.go +++ b/internal/broker/announced_test.go @@ -39,7 +39,7 @@ func TestTheControllerAnnouncesWhatItKeeps(t *testing.T) { } } want := []string{ChainBuilds, MirrorDeclaredConfig, MirrorPredefined, ChainRollouts, - MirrorAssignments, MirrorDeclaredPackages, ControllerGrants} + MirrorAssignments, MirrorDeclaredPackages, ControllerGrants, ControllerComposed} slices.Sort(want) if !slices.Equal(readable, want) { t.Fatalf("readable %v, want %v", readable, want) diff --git a/internal/broker/composed.go b/internal/broker/composed.go new file mode 100644 index 00000000..2053307e --- /dev/null +++ b/internal/broker/composed.go @@ -0,0 +1,96 @@ +package broker + +import ( + "context" + "fmt" + + "git.novox.be/novox/mesh-sdk/go/delivery" + "github.com/nats-io/nats.go/jetstream" +) + +// Release 6's grants, carried ahead of it (novox/hq ADR 0539 clauses 2, 6, 9 and 16; issue 1945, the review's A1). The +// switch to release 6 is the last delivery plan of today's controller, and the bus's user list after it is the one +// today's controller composes: so the subjects the new controller, every new node-engine and the assigner use after the +// switch are granted by this controller already, before the group merges. Unused until then: nothing writes or reads +// these buckets before release 6. +// +// Names as mesh-sdk's release 6 gives them (delivery.ComposedBucketFor, ControllerComposedBucket); written out here +// because this controller's mesh-sdk predates them, and held equal by release 6's own tests. + +// ComposedBucketPrefix starts a node's bucket of compositions, `composed-`. +const ComposedBucketPrefix = "composed-" + +// ControllerComposed is the local name of the compositions' digests, `mesh-controller_composed`, which the assigner reads. +const ControllerComposed = "composed" + +// ControllerComposedBucket is the bucket of the compositions' digests. +var ControllerComposedBucket = BucketName(ControllerSeat, ControllerComposed) + +// composedBucketOf is a node's bucket of compositions; a node name a subject would widen is refused. +func composedBucketOf(node string) (string, error) { + if !safeSubject.MatchString(node) { + return "", fmt.Errorf("%q cannot be part of a subject: a permission is a subject pattern, and this would widen it", node) + } + return ComposedBucketPrefix + node, nil +} + +// ComposedWrites is what the controller publishes to write a node's compositions: the bucket's own subjects. +func ComposedWrites(node string) (string, error) { + bucket, err := composedBucketOf(node) + if err != nil { + return "", err + } + return "$KV." + bucket + ".>", nil +} + +// ComposedReads is what a node's node-engine publishes to follow its wants and compositions: every key of its own +// composition bucket, and its own wants (`want..`, `want..node`) in the assigner's bucket; nothing +// of another node's, nor the digests. +func ComposedReads(node string) ([]string, error) { + bucket, err := composedBucketOf(node) + if err != nil { + return nil, err + } + return append(readGrants(bucket, ""), readGrants(delivery.AssignerWantsBucket, "want."+node+".>")...), nil +} + +// VerbsTheNodeAgentAsks are the seat verbs a node's node-engine calls of its own machine while it applies a want: its +// backup holder backs a data module up before it moves, and says when it was (ADR 0539 clause 16). +var VerbsTheNodeAgentAsks = []SeatVerb{{Seat: "node-backup", Verb: "now"}, {Seat: "node-backup", Verb: "backed-up"}} + +// NodeAgentAsks are the subjects of VerbsTheNodeAgentAsks for one node: `mesh.seat..tool..`. +func NodeAgentAsks(node string) ([]string, error) { + if !safeSubject.MatchString(node) { + return nil, fmt.Errorf("%q cannot be part of a subject: a permission is a subject pattern, and this would widen it", node) + } + var out []string + for _, v := range VerbsTheNodeAgentAsks { + out = append(out, "mesh.seat."+v.Seat+".tool."+v.Verb+"."+node) + } + return out, nil +} + +// NodeAgentHears are the events a node's node-engine subscribes while it applies a want: the backup holder's word that a +// backup is taken, from whichever module holds node-backup on its machine. +var NodeAgentHears = []string{"mesh.mod.*.event.backed-up"} + +// ComposedDigestsConfig is the bucket of the compositions' digests as the controller asserts it: five revisions a key. +func ComposedDigestsConfig() jetstream.KeyValueConfig { + return jetstream.KeyValueConfig{ + Bucket: ControllerComposedBucket, + Description: "each composition's digest and the judgement of its move from what the node runs (novox/hq ADR 0539 " + + "clauses 2 and 14): written by the controller alone, read by the assigner; empty until release 6", + History: 5, + MaxValueSize: 4 << 10, + MaxBytes: 64 << 20, + Storage: jetstream.FileStorage, + } +} + +// EnsureComposedDigests asserts the bucket of the compositions' digests. +func EnsureComposedDigests(ctx context.Context, js jetstream.JetStream) error { + if _, err := js.CreateOrUpdateKeyValue(ctx, ComposedDigestsConfig()); err != nil { + return fmt.Errorf("asserting bucket %s: %w", ControllerComposedBucket, err) + } + return nil +} diff --git a/internal/broker/composed_test.go b/internal/broker/composed_test.go new file mode 100644 index 00000000..b0cc5b37 --- /dev/null +++ b/internal/broker/composed_test.go @@ -0,0 +1,60 @@ +package broker + +import ( + "slices" + "strings" + "testing" + + "github.com/nats-io/nats.go/jetstream" +) + +// 1945 (the review's A1): the bus's user list today's controller composes, which is the one the bus runs after release +// 6's switch, already grants every subject the new controller, every new node-engine and the assigner use: the +// controller writes each node's compositions and the digests; a node-engine reads its own compositions and wants, asks +// its own backup holder and hears its word, and nothing of another node's; the assigner reads the digests, announced +// readable. +func TestTheUserListBeforeTheSwitchGrantsWhatReleaseSixUses(t *testing.T) { + controller, err := PermissionsFor(Principal{Kind: KindController, PasswordHash: "x", FilesFor: []string{"ace", "g14"}}) + if err != nil { + t.Fatal(err) + } + for _, s := range []string{"$KV.composed-ace.>", "$KV.composed-g14.>", "$KV.mesh-controller_composed.>"} { + has(t, controller.Publish, s) + } + node, err := PermissionsFor(Principal{Kind: KindNode, Node: "ace", PasswordHash: "x"}) + if err != nil { + t.Fatal(err) + } + for _, s := range []string{ + "$JS.API.STREAM.INFO.KV_composed-ace", "$JS.API.DIRECT.GET.KV_composed-ace.>", "$JS.API.CONSUMER.CREATE.KV_composed-ace.>", + "$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_wants.$KV.mesh-delivery-assigner_wants.want.ace.>", + "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_wants.*.$KV.mesh-delivery-assigner_wants.want.ace.>", + "mesh.seat.node-backup.tool.now.ace", "mesh.seat.node-backup.tool.backed-up.ace", + } { + has(t, node.Publish, s) + } + has(t, node.Subscribe, "mesh.mod.*.event.backed-up") + for _, p := range node.Publish { + if strings.Contains(p, "composed-g14") || strings.Contains(p, "mesh-controller_composed") || strings.Contains(p, "want.g14") || + strings.HasPrefix(p, "$KV.composed-") || strings.HasSuffix(p, "node-backup.tool.now.*") { + t.Errorf("a node-engine of ace may publish %s", p) + } + } + if !slices.Contains(controllerReadableStates(), ControllerComposed) || + !slices.Contains(ControllerBuckets(), ControllerComposedBucket) || !IsControllerBucket(ControllerComposedBucket) { + t.Fatalf("the digests are not announced readable among the controller's buckets") + } + if !ControllerAnnouncement("novox").Readable(ControllerComposed) { + t.Fatalf("the controller does not announce %s readable", ControllerComposed) + } + assigner, err := PermissionsFor(Principal{Kind: KindModule, Node: "novox", Module: "mesh-delivery-assigner", PasswordHash: "x", + Reads: []string{"mesh-controller.composed"}}) + if err != nil { + t.Fatal(err) + } + has(t, assigner.Publish, "$JS.API.STREAM.INFO.KV_mesh-controller_composed") + has(t, assigner.Publish, "$JS.API.CONSUMER.CREATE.KV_mesh-controller_composed.>") + if c := ComposedDigestsConfig(); c.Bucket != "mesh-controller_composed" || c.History != 5 || c.Storage != jetstream.FileStorage { + t.Errorf("the digests' bucket is %+v", c) + } +} diff --git a/internal/broker/controller_buckets.go b/internal/broker/controller_buckets.go index f7dd0a2b..f6f00782 100644 --- a/internal/broker/controller_buckets.go +++ b/internal/broker/controller_buckets.go @@ -196,14 +196,15 @@ func IsControllerBucket(bucket string) bool { bucket == MergeWindowBucket || bucket == RolloutSilentBucket || bucket == DeliveryMergesBucket || bucket == BuildsBucket || bucket == RolloutsBucket || bucket == delivery.HaveBucket || bucket == StatesAnnouncedBucket || - bucket == GrantsBucket + bucket == GrantsBucket || bucket == ControllerComposedBucket } // ControllerBuckets are the controller's own buckets, in the order they are asserted. func ControllerBuckets() []string { return []string{LeaseBucket, CallsBucket, HandActsBucket, ConditionsBucket, ConditionHistoryBucket, AskedBucket, AssignmentsBucket, DeclaredConfigBucket, PredefinedBucket, DeclaredPackagesBucket, MergeWindowBucket, RolloutSilentBucket, - DeliveryMergesBucket, BuildsBucket, RolloutsBucket, delivery.HaveBucket, StatesAnnouncedBucket, GrantsBucket} + DeliveryMergesBucket, BuildsBucket, RolloutsBucket, delivery.HaveBucket, StatesAnnouncedBucket, GrantsBucket, + ControllerComposedBucket} } // ControllerBucketsAsserter is what raising the controller's buckets needs of a connection. @@ -331,6 +332,10 @@ func (j *JetStream) EnsureControllerBuckets() error { }); err != nil { return fmt.Errorf("asserting bucket %s: %w", RolloutSilentBucket, err) } + // **The compositions' digests** (ADR 0539 clauses 2 and 6), asserted ahead of release 6: what its assigner reads. + if err := EnsureComposedDigests(ctx, js); err != nil { + return err + } // **The states each module announced** (novox/hq ADR 0495 clauses 2 and 3, issue 1790): one key a module, its last word. if _, err := js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{ Bucket: StatesAnnouncedBucket, diff --git a/internal/broker/controller_buckets_test.go b/internal/broker/controller_buckets_test.go index 799a46d7..0c7441cd 100644 --- a/internal/broker/controller_buckets_test.go +++ b/internal/broker/controller_buckets_test.go @@ -115,7 +115,8 @@ func TestTheMirrorsListAndTheWritersTableAgree(t *testing.T) { module := Principal{Kind: KindModule, Module: ControllerSeat, Node: "one"} rowed := map[string]bool{} for _, row := range WritersTable { - if !strings.HasSuffix(row.State, " mirror") && !strings.HasSuffix(row.State, " of the chain") && row.State != "the grants issued" { + if !strings.HasSuffix(row.State, " mirror") && !strings.HasSuffix(row.State, " of the chain") && row.State != "the grants issued" && + row.State != "the compositions' digests" { continue } for _, subject := range row.Subjects { diff --git a/internal/broker/nats.go b/internal/broker/nats.go index e55ba60f..dbac4676 100644 --- a/internal/broker/nats.go +++ b/internal/broker/nats.go @@ -427,6 +427,11 @@ var DegradableGrants = []DegradableGrant{ Reason: "carried only at genesis: the controller composes its own permissions and pushes them to the bus at its " + "first push, and writes the chain only after that, holding the lease (ADR 0408 §2 and §8, issue 1089)", }, + { + Subject: "$KV." + ControllerComposedBucket + ".>", + Reason: "carried only at genesis: nothing writes a composition's digest before release 6, whose controller " + + "composes its own permissions into the bus module's composition (novox/hq ADR 0539 clauses 2 and 7)", + }, { Subject: "$KV." + GrantsBucket + ".>", Reason: "carried only at genesis: the controller composes its own permissions and pushes them to the bus at its " + @@ -738,6 +743,12 @@ func PermissionsFor(p Principal) (Permissions, error) { return Permissions{}, err } pub = append(pub, writes) + // And each node's compositions, which it alone writes from release 6 on (novox/hq ADR 0539 clause 2). + composed, err := ComposedWrites(node) + if err != nil { + return Permissions{}, err + } + pub = append(pub, composed) } // **And what the bus says about itself, read-only** (novox/hq to-be 45 §3, S9): a durable // consumer that gave up on a message, or one that was deleted. The server already publishes @@ -845,6 +856,19 @@ func PermissionsFor(p Principal) (Permissions, error) { return Permissions{}, err } pub = append(pub, fileReads...) + // And, from release 6 on, its own compositions and wants, its own backup holder's verbs and word (novox/hq ADR + // 0539 clauses 2, 9 and 16): granted ahead, since today's controller composes the user list after the switch. + composedReads, err := ComposedReads(p.Node) + if err != nil { + return Permissions{}, err + } + pub = append(pub, composedReads...) + backups, err := NodeAgentAsks(p.Node) + if err != nil { + return Permissions{}, err + } + pub = append(pub, backups...) + sub = append(sub, NodeAgentHears...) // And, while the vault serves it, the vault's move-in in its own name and nobody else's (issue 676): the // values sealed to this machine, which only it opens, handed in sealed to the vault's key for the call. if p.MovesIntoTheVault { diff --git a/internal/broker/testdata/composed.conf b/internal/broker/testdata/composed.conf index a6a80775..99e433b1 100644 --- a/internal/broker/testdata/composed.conf +++ b/internal/broker/testdata/composed.conf @@ -29,7 +29,7 @@ accounts { ] users = [ { user: "controller", password: "$2a$11$cccccccccccccccccccccc", permissions: { - publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.DEAD_LETTER_NOTICES.controller.>", "$JS.ACK.EVENTS.controller-merges.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "$KV.mesh-controller_asked.>", "$KV.mesh-controller_assignments.>", "$KV.mesh-controller_builds.>", "$KV.mesh-controller_calls.>", "$KV.mesh-controller_condition-history.>", "$KV.mesh-controller_conditions.>", "$KV.mesh-controller_declared-config.>", "$KV.mesh-controller_declared-packages.>", "$KV.mesh-controller_delivery-merges.>", "$KV.mesh-controller_grants.>", "$KV.mesh-controller_hand-acts.>", "$KV.mesh-controller_lease.>", "$KV.mesh-controller_merge-window.>", "$KV.mesh-controller_predefined.>", "$KV.mesh-controller_rollout-silent.>", "$KV.mesh-controller_rollouts.>", "$KV.mesh-controller_states-announced.>", "$SRV.INFO", "_INBOX.enrol.>", "mesh.again.>", "mesh.assignment.>", "mesh.events.dead.>", "mesh.mod.*.tool.>", "mesh.mod.mesh-controller.event.states", "mesh.node.>", "mesh.seat.mesh-controller.event.applied", "mesh.seat.mesh-controller.event.artifacts-gone", "mesh.seat.mesh-controller.event.assignment-changed", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.checked", "mesh.seat.mesh-controller.event.condition-changed", "mesh.seat.mesh-controller.event.condition-cleared", "mesh.seat.mesh-controller.event.condition-raised", "mesh.seat.mesh-controller.event.declared-config-changed", "mesh.seat.mesh-controller.event.doctor-heartbeat", "mesh.seat.mesh-controller.event.healer-acted", "mesh.seat.mesh-controller.event.plan-moved", "mesh.seat.mesh-controller.event.predefined-changed", "mesh.seat.mesh-controller.event.refused", "mesh.seat.mesh-controller.event.rolled-back", "mesh.seat.mesh-controller.event.secret-replaced", "mesh.seat.mesh-controller.event.sent", "mesh.seat.mesh-controller.tool.plans", "mesh.seat.mesh-controller.tool.push", "mesh.seat.mesh-controller.tool.upgrade", "mesh.seat.mesh-delivery.tool.close", "mesh.seat.mesh-delivery.tool.groups", "mesh.seat.mesh-delivery.tool.release", "mesh.seat.mesh-delivery.tool.stalled", "mesh.seat.mesh-delivery.tool.stop", "mesh.seat.mesh-delivery.tool.times", "mesh.seat.node-backup.tool.backed-up.*", "mesh.seat.node-backup.tool.now.*", "mesh.seat.node-intrusion-prevention.tool.banned.*", "mesh.seat.node-launcher.tool.secret.*", "mesh.seat.node-service-manager.tool.restart.*", "mesh.seat.operator-channel.tool.tell"] } + publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.DEAD_LETTER_NOTICES.controller.>", "$JS.ACK.EVENTS.controller-merges.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "$KV.mesh-controller_asked.>", "$KV.mesh-controller_assignments.>", "$KV.mesh-controller_builds.>", "$KV.mesh-controller_calls.>", "$KV.mesh-controller_composed.>", "$KV.mesh-controller_condition-history.>", "$KV.mesh-controller_conditions.>", "$KV.mesh-controller_declared-config.>", "$KV.mesh-controller_declared-packages.>", "$KV.mesh-controller_delivery-merges.>", "$KV.mesh-controller_grants.>", "$KV.mesh-controller_hand-acts.>", "$KV.mesh-controller_lease.>", "$KV.mesh-controller_merge-window.>", "$KV.mesh-controller_predefined.>", "$KV.mesh-controller_rollout-silent.>", "$KV.mesh-controller_rollouts.>", "$KV.mesh-controller_states-announced.>", "$SRV.INFO", "_INBOX.enrol.>", "mesh.again.>", "mesh.assignment.>", "mesh.events.dead.>", "mesh.mod.*.tool.>", "mesh.mod.mesh-controller.event.states", "mesh.node.>", "mesh.seat.mesh-controller.event.applied", "mesh.seat.mesh-controller.event.artifacts-gone", "mesh.seat.mesh-controller.event.assignment-changed", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.checked", "mesh.seat.mesh-controller.event.condition-changed", "mesh.seat.mesh-controller.event.condition-cleared", "mesh.seat.mesh-controller.event.condition-raised", "mesh.seat.mesh-controller.event.declared-config-changed", "mesh.seat.mesh-controller.event.doctor-heartbeat", "mesh.seat.mesh-controller.event.healer-acted", "mesh.seat.mesh-controller.event.plan-moved", "mesh.seat.mesh-controller.event.predefined-changed", "mesh.seat.mesh-controller.event.refused", "mesh.seat.mesh-controller.event.rolled-back", "mesh.seat.mesh-controller.event.secret-replaced", "mesh.seat.mesh-controller.event.sent", "mesh.seat.mesh-controller.tool.plans", "mesh.seat.mesh-controller.tool.push", "mesh.seat.mesh-controller.tool.upgrade", "mesh.seat.mesh-delivery.tool.close", "mesh.seat.mesh-delivery.tool.groups", "mesh.seat.mesh-delivery.tool.release", "mesh.seat.mesh-delivery.tool.stalled", "mesh.seat.mesh-delivery.tool.stop", "mesh.seat.mesh-delivery.tool.times", "mesh.seat.node-backup.tool.backed-up.*", "mesh.seat.node-backup.tool.now.*", "mesh.seat.node-intrusion-prevention.tool.banned.*", "mesh.seat.node-launcher.tool.secret.*", "mesh.seat.node-service-manager.tool.restart.*", "mesh.seat.operator-channel.tool.tell"] } subscribe: { allow: ["$JS.API.>", "$JS.EVENT.ADVISORY.CONSUMER.DELETED.>", "$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.>", "$SRV.INFO", "$SRV.INFO.mesh-controller", "$SRV.INFO.mesh-controller.>", "$SRV.PING", "$SRV.PING.mesh-controller", "$SRV.PING.mesh-controller.>", "$SRV.STATS", "$SRV.STATS.mesh-controller", "$SRV.STATS.mesh-controller.>", "_DELIVER.controller", "_DELIVER.controller.>", "_INBOX.controller.>", "mesh.control.>", "mesh.mod.*.event.provisioner.failing", "mesh.mod.*.event.provisioner.recovered", "mesh.mod.*.event.provisioner.retirement", "mesh.mod.gitea.event.pull.merged", "mesh.mod.gitea.event.pull.updated", "mesh.mod.mesh-catalog.event.catching-up", "mesh.mod.mesh-catalog.event.upgraded", "mesh.seat.mesh-build-machine.event.built", "mesh.seat.mesh-controller.tool.>", "mesh.seat.node-build-agent.event.built", "mesh.seat.operator-channel.event.decided.mesh-controller"] } allow_responses: { max: 1, ttl: "1m" } } } @@ -38,8 +38,8 @@ accounts { subscribe: { allow: ["_INBOX.enrol.one.>"] } } } { user: "node.one", password: "$2a$11$nnnnnnnnnnnnnnnnnnnnnn", permissions: { - publish: { allow: ["$JS.ACK.NODES.one.>", "$JS.API.CONSUMER.CREATE.KV_node-engine_have.*.$KV.node-engine_have.one.>", "$JS.API.CONSUMER.CREATE.OBJ_declaration-files-one.*.$O.declaration-files-one.C.*", "$JS.API.CONSUMER.DELETE.KV_node-engine_have.>", "$JS.API.CONSUMER.DELETE.OBJ_declaration-files-one.*", "$JS.API.CONSUMER.INFO.NODES.one", "$JS.API.DIRECT.GET.KV_node-engine_have.$KV.node-engine_have.one.>", "$JS.API.DIRECT.GET.OBJ_declaration-files-one.$O.declaration-files-one.M.*", "$JS.API.STREAM.INFO.KV_node-engine_have", "$JS.FC.KV_node-engine_have.>", "$JS.FC.OBJ_declaration-files-one.>", "$KV.node-engine_have.one.>", "$SRV.PING.node-tools.one", "mesh.control.one.>"] } - subscribe: { allow: ["_DELIVER.one", "_DELIVER.one.>", "_INBOX.node.one.>", "mesh.node.one.ask.hand-over", "mesh.node.one.ask.judge-directories", "mesh.node.one.ask.move-secret", "mesh.node.one.ask.report", "mesh.node.one.ask.setuid-search", "mesh.node.one.declare"] } + publish: { allow: ["$JS.ACK.NODES.one.>", "$JS.API.CONSUMER.CREATE.KV_composed-one.>", "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_wants.*.$KV.mesh-delivery-assigner_wants.want.one.>", "$JS.API.CONSUMER.CREATE.KV_node-engine_have.*.$KV.node-engine_have.one.>", "$JS.API.CONSUMER.CREATE.OBJ_declaration-files-one.*.$O.declaration-files-one.C.*", "$JS.API.CONSUMER.DELETE.KV_composed-one.>", "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_wants.>", "$JS.API.CONSUMER.DELETE.KV_node-engine_have.>", "$JS.API.CONSUMER.DELETE.OBJ_declaration-files-one.*", "$JS.API.CONSUMER.INFO.NODES.one", "$JS.API.DIRECT.GET.KV_composed-one.>", "$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_wants.$KV.mesh-delivery-assigner_wants.want.one.>", "$JS.API.DIRECT.GET.KV_node-engine_have.$KV.node-engine_have.one.>", "$JS.API.DIRECT.GET.OBJ_declaration-files-one.$O.declaration-files-one.M.*", "$JS.API.STREAM.INFO.KV_composed-one", "$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_wants", "$JS.API.STREAM.INFO.KV_node-engine_have", "$JS.FC.KV_composed-one.>", "$JS.FC.KV_mesh-delivery-assigner_wants.>", "$JS.FC.KV_node-engine_have.>", "$JS.FC.OBJ_declaration-files-one.>", "$KV.node-engine_have.one.>", "$SRV.PING.node-tools.one", "mesh.control.one.>", "mesh.seat.node-backup.call.backed-up.one.node~one", "mesh.seat.node-backup.call.now.one.node~one", "mesh.seat.node-backup.tool.backed-up.one", "mesh.seat.node-backup.tool.now.one"] } + subscribe: { allow: ["_DELIVER.one", "_DELIVER.one.>", "_INBOX.node.one.>", "mesh.mod.*.event.backed-up", "mesh.node.one.ask.hand-over", "mesh.node.one.ask.judge-directories", "mesh.node.one.ask.move-secret", "mesh.node.one.ask.report", "mesh.node.one.ask.setuid-search", "mesh.node.one.declare"] } allow_responses: { max: 1, ttl: "1m" } } } { user: "one.mesh-delivery-watchman", password: "$2a$11$wwwwwwwwwwwwwwwwwwwwww", permissions: { diff --git a/internal/broker/writers.go b/internal/broker/writers.go index b904ed1e..78e27196 100644 --- a/internal/broker/writers.go +++ b/internal/broker/writers.go @@ -152,6 +152,10 @@ var WritersTable = []WriterRow{ DeliveryMergesBucket, Others: "read through plans", Subjects: append(append(kvOf(MergeWindowBucket), kvOf(RolloutSilentBucket)...), kvOf(DeliveryMergesBucket)...), Writes: isController}, + // The compositions' digests, granted and asserted ahead of release 6, which writes them (ADR 0539 clauses 2 and 6). + {State: "the compositions' digests", Writer: "controller (lease holder), compare-and-set, from release 6", + KeptIn: "key-value " + ControllerComposedBucket, Others: "the assigner reads them", + Subjects: kvOf(ControllerComposedBucket), Writes: isController}, {State: "conditions", Writer: "controller", KeptIn: "key-value " + ConditionsBucket + " (and its history, " + ConditionHistoryBucket + "); how often each was observed, when last and its newest evidence in the " + "controller's store, so a look that changes nothing writes no bucket", Others: "raise or clear only through observations the controller reads", diff --git a/internal/broker/writers_test.go b/internal/broker/writers_test.go index f1e55584..9614f3ba 100644 --- a/internal/broker/writers_test.go +++ b/internal/broker/writers_test.go @@ -21,6 +21,8 @@ var designRows = []string{ "a machine's applied state and its report", "the controller lease", "plans and their tiers", + // Release 6's compositions' digests, granted ahead of it (ADR 0539 clauses 2 and 6, issue 1945). + "the compositions' digests", "conditions", "calls and their outcomes", "the hand-act log", From 7f8e5a821c76c92939aec1b955445c3d9363f886 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 24 Oct 2026 18:21:58 +0200 Subject: [PATCH 2/4] Grant the controller's bucket-deleted, narrow a node's backup word to restic's, and grant the operator release 6's hand path ahead of the switch (issue 1945; the review of A1) The new controller says its first start's deletions as an event, a node-engine hears only its backup holder, and the way back right after the switch, a want and a composition written at the terminal, runs on the user list today's controller composes (ADR 0539 clauses 2, 10 and 13). The operator is the administrator person, the one second writer ADR 0539 names. --- internal/broker/composed.go | 40 +++++++++++++++++++++++++- internal/broker/composed_test.go | 30 +++++++++++++++++-- internal/broker/nats.go | 15 ++++++++++ internal/broker/nats_test.go | 8 +++++- internal/broker/streams.go | 5 +++- internal/broker/testdata/composed.conf | 4 +-- internal/broker/users.go | 7 ++++- internal/broker/writers.go | 16 ++++++++--- 8 files changed, 113 insertions(+), 12 deletions(-) diff --git a/internal/broker/composed.go b/internal/broker/composed.go index 2053307e..8631849a 100644 --- a/internal/broker/composed.go +++ b/internal/broker/composed.go @@ -72,7 +72,11 @@ func NodeAgentAsks(node string) ([]string, error) { // NodeAgentHears are the events a node's node-engine subscribes while it applies a want: the backup holder's word that a // backup is taken, from whichever module holds node-backup on its machine. -var NodeAgentHears = []string{"mesh.mod.*.event.backed-up"} +var NodeAgentHears = []string{"mesh.mod." + BackupHolderModule + ".event.backed-up"} + +// BackupHolderModule is the module that holds node-backup on every node, restic: the one whose word a node-engine hears. +// A second holder of the seat is a change of this grant. +const BackupHolderModule = "restic" // ComposedDigestsConfig is the bucket of the compositions' digests as the controller asserts it: five revisions a key. func ComposedDigestsConfig() jetstream.KeyValueConfig { @@ -94,3 +98,37 @@ func EnsureComposedDigests(ctx context.Context, js jetstream.JetStream) error { } return nil } + +// administers says a person may invoke every tool (`*`): the operator. +func administers(invokes []string) bool { + for _, i := range invokes { + if i == "*" { + return true + } + } + return false +} + +// HandPathWrites is what the operator's account publishes for release 6's hand path (ADR 0539 clauses 2 and 10): a want +// of any node, written and read back, in the assigner's bucket; each named node's compositions; the digests; and each +// node's object bucket, for a file over 64 KiB. +func HandPathWrites(nodes []string) ([]string, error) { + out := append([]string{"$KV." + delivery.AssignerWantsBucket + ".want.>"}, readGrants(delivery.AssignerWantsBucket, "want.>")...) + out = append(out, "$KV."+ControllerComposedBucket+".>") + out = append(out, readGrants(ControllerComposedBucket, "")...) + for _, node := range nodes { + w, err := ComposedWrites(node) + if err != nil { + return nil, err + } + bucket, _ := composedBucketOf(node) + out = append(out, w) + out = append(out, readGrants(bucket, "")...) + files, err := DeclarationFilesWrites(node) + if err != nil { + return nil, err + } + out = append(out, files, "$JS.API.STREAM.INFO.OBJ_declaration-files-"+node) + } + return out, nil +} diff --git a/internal/broker/composed_test.go b/internal/broker/composed_test.go index b0cc5b37..a5d5a309 100644 --- a/internal/broker/composed_test.go +++ b/internal/broker/composed_test.go @@ -18,7 +18,8 @@ func TestTheUserListBeforeTheSwitchGrantsWhatReleaseSixUses(t *testing.T) { if err != nil { t.Fatal(err) } - for _, s := range []string{"$KV.composed-ace.>", "$KV.composed-g14.>", "$KV.mesh-controller_composed.>"} { + for _, s := range []string{"$KV.composed-ace.>", "$KV.composed-g14.>", "$KV.mesh-controller_composed.>", + "mesh.seat.mesh-controller.event.bucket-deleted"} { has(t, controller.Publish, s) } node, err := PermissionsFor(Principal{Kind: KindNode, Node: "ace", PasswordHash: "x"}) @@ -33,7 +34,7 @@ func TestTheUserListBeforeTheSwitchGrantsWhatReleaseSixUses(t *testing.T) { } { has(t, node.Publish, s) } - has(t, node.Subscribe, "mesh.mod.*.event.backed-up") + has(t, node.Subscribe, "mesh.mod.restic.event.backed-up") for _, p := range node.Publish { if strings.Contains(p, "composed-g14") || strings.Contains(p, "mesh-controller_composed") || strings.Contains(p, "want.g14") || strings.HasPrefix(p, "$KV.composed-") || strings.HasSuffix(p, "node-backup.tool.now.*") { @@ -58,3 +59,28 @@ func TestTheUserListBeforeTheSwitchGrantsWhatReleaseSixUses(t *testing.T) { t.Errorf("the digests' bucket is %+v", c) } } + +// 1945 (the review of A1, (b)): the operator's account, a person who may invoke every tool, writes release 6's hand +// path: a want of any node, each node's compositions and the digests; a person with fewer tools writes none of it. +func TestTheOperatorWritesTheHandPathAheadOfTheSwitch(t *testing.T) { + users, err := Users(Records{Nodes: []string{"ace", "novox"}, People: map[string][]string{"jochen": {"*"}, "guest": {"mesh-controller.status"}}}) + if err != nil { + t.Fatal(err) + } + for _, u := range users { + if u.Kind != KindPerson { + continue + } + perms, err := PermissionsFor(u) + if err != nil { + t.Fatal(err) + } + hand := []string{"$KV.mesh-delivery-assigner_wants.want.>", "$KV.composed-ace.>", "$KV.composed-novox.>", + "$KV.mesh-controller_composed.>", "$O.declaration-files-ace.>"} + for _, s := range hand { + if got := slices.Contains(perms.Publish, s); got != (u.Module == "jochen") { + t.Errorf("%s may publish %s: %v", u.Module, s, got) + } + } + } +} diff --git a/internal/broker/nats.go b/internal/broker/nats.go index dbac4676..f605f6f5 100644 --- a/internal/broker/nats.go +++ b/internal/broker/nats.go @@ -427,6 +427,11 @@ var DegradableGrants = []DegradableGrant{ Reason: "carried only at genesis: the controller composes its own permissions and pushes them to the bus at its " + "first push, and writes the chain only after that, holding the lease (ADR 0408 §2 and §8, issue 1089)", }, + { + Subject: "mesh.seat." + ControllerSeat + ".event.bucket-deleted", + Reason: "refused: nothing is deleted before release 6, whose controller composes its own permissions into the bus " + + "module's composition before its first start's deletions (novox/hq ADR 0539 clause 13)", + }, { Subject: "$KV." + ControllerComposedBucket + ".>", Reason: "carried only at genesis: nothing writes a composition's digest before release 6, whose controller " + @@ -784,6 +789,16 @@ func PermissionsFor(p Principal) (Permissions, error) { // And may ask what answers (novox/hq ADR 0197): a question every service answers about // itself, its replies to the asker's own inbox. pub = append(pub, discovering()...) + // And, for an administrator (`*`, the operator), release 6's hand path (ADR 0539 clauses 2 and 10): a want written + // at the terminal, and a composition made by hand with mesh-compose. Granted ahead of the switch, since the way back + // right after it runs on the user list today's controller composes. + if administers(p.Invokes) { + hand, err := HandPathWrites(p.FilesFor) + if err != nil { + return Permissions{}, err + } + pub = append(pub, hand...) + } case KindEnrolment: // A leaked token is useless for anything but enrolling: it cannot read a declaration, hear diff --git a/internal/broker/nats_test.go b/internal/broker/nats_test.go index 62ec6620..33479446 100644 --- a/internal/broker/nats_test.go +++ b/internal/broker/nats_test.go @@ -237,11 +237,17 @@ func TestAnAdministratorMayAskAnyTool(t *testing.T) { } // **Nothing but tools.** A person who could publish an event would be able to claim a module -// said something; one who could publish control traffic would be a second controller. +// said something; one who could publish control traffic would be a second controller. The one exception is the +// operator's (an administrator's) release 6 hand path, a want and a composition written by hand (novox/hq ADR 0539 +// clauses 2 and 10). func TestAPersonReachesNothingButTools(t *testing.T) { perms, _ := PermissionsFor(Principal{Kind: KindPerson, Module: "jo", Invokes: []string{"*"}, PasswordHash: "x"}) + hand, _ := HandPathWrites(nil) for _, p := range perms.Publish { + if slices.Contains(hand, p) { + continue + } // A tool call — named by its caller or not (novox/hq issue 365) — or asking what answers (novox/hq // ADR 0197), a question every service answers about itself, which claims nothing and controls nothing. if !strings.Contains(p, ".tool.") && !strings.Contains(p, ".call.") && !strings.HasPrefix(p, "$SRV.") { diff --git a/internal/broker/streams.go b/internal/broker/streams.go index d3eec6d9..87cb916f 100644 --- a/internal/broker/streams.go +++ b/internal/broker/streams.go @@ -381,7 +381,10 @@ var ControllerStates = []string{"applied", "refused", "built-before", // each registered module's declared config (design 57 F3). "assignment-changed", "declared-config-changed", "predefined-changed", // And a builder's key not taken in because the artifact store does not hold what it names (novox/hq issue 1937). - "artifacts-gone"} + "artifacts-gone", + // And, from release 6 on, a bucket of the plans' state deleted at the new controller's first start, and the + // declarations' stream once no node reads one (novox/hq ADR 0539 clauses 11 and 13): granted ahead of the switch. + "bucket-deleted"} // BusAdvisories are what the bus server says about the mesh's own account that the controller // reads (novox/hq to-be 45 §3, S9): a durable consumer that handed a message over as often as it diff --git a/internal/broker/testdata/composed.conf b/internal/broker/testdata/composed.conf index 99e433b1..52e6ed88 100644 --- a/internal/broker/testdata/composed.conf +++ b/internal/broker/testdata/composed.conf @@ -29,7 +29,7 @@ accounts { ] users = [ { user: "controller", password: "$2a$11$cccccccccccccccccccccc", permissions: { - publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.DEAD_LETTER_NOTICES.controller.>", "$JS.ACK.EVENTS.controller-merges.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "$KV.mesh-controller_asked.>", "$KV.mesh-controller_assignments.>", "$KV.mesh-controller_builds.>", "$KV.mesh-controller_calls.>", "$KV.mesh-controller_composed.>", "$KV.mesh-controller_condition-history.>", "$KV.mesh-controller_conditions.>", "$KV.mesh-controller_declared-config.>", "$KV.mesh-controller_declared-packages.>", "$KV.mesh-controller_delivery-merges.>", "$KV.mesh-controller_grants.>", "$KV.mesh-controller_hand-acts.>", "$KV.mesh-controller_lease.>", "$KV.mesh-controller_merge-window.>", "$KV.mesh-controller_predefined.>", "$KV.mesh-controller_rollout-silent.>", "$KV.mesh-controller_rollouts.>", "$KV.mesh-controller_states-announced.>", "$SRV.INFO", "_INBOX.enrol.>", "mesh.again.>", "mesh.assignment.>", "mesh.events.dead.>", "mesh.mod.*.tool.>", "mesh.mod.mesh-controller.event.states", "mesh.node.>", "mesh.seat.mesh-controller.event.applied", "mesh.seat.mesh-controller.event.artifacts-gone", "mesh.seat.mesh-controller.event.assignment-changed", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.checked", "mesh.seat.mesh-controller.event.condition-changed", "mesh.seat.mesh-controller.event.condition-cleared", "mesh.seat.mesh-controller.event.condition-raised", "mesh.seat.mesh-controller.event.declared-config-changed", "mesh.seat.mesh-controller.event.doctor-heartbeat", "mesh.seat.mesh-controller.event.healer-acted", "mesh.seat.mesh-controller.event.plan-moved", "mesh.seat.mesh-controller.event.predefined-changed", "mesh.seat.mesh-controller.event.refused", "mesh.seat.mesh-controller.event.rolled-back", "mesh.seat.mesh-controller.event.secret-replaced", "mesh.seat.mesh-controller.event.sent", "mesh.seat.mesh-controller.tool.plans", "mesh.seat.mesh-controller.tool.push", "mesh.seat.mesh-controller.tool.upgrade", "mesh.seat.mesh-delivery.tool.close", "mesh.seat.mesh-delivery.tool.groups", "mesh.seat.mesh-delivery.tool.release", "mesh.seat.mesh-delivery.tool.stalled", "mesh.seat.mesh-delivery.tool.stop", "mesh.seat.mesh-delivery.tool.times", "mesh.seat.node-backup.tool.backed-up.*", "mesh.seat.node-backup.tool.now.*", "mesh.seat.node-intrusion-prevention.tool.banned.*", "mesh.seat.node-launcher.tool.secret.*", "mesh.seat.node-service-manager.tool.restart.*", "mesh.seat.operator-channel.tool.tell"] } + publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.DEAD_LETTER_NOTICES.controller.>", "$JS.ACK.EVENTS.controller-merges.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "$KV.mesh-controller_asked.>", "$KV.mesh-controller_assignments.>", "$KV.mesh-controller_builds.>", "$KV.mesh-controller_calls.>", "$KV.mesh-controller_composed.>", "$KV.mesh-controller_condition-history.>", "$KV.mesh-controller_conditions.>", "$KV.mesh-controller_declared-config.>", "$KV.mesh-controller_declared-packages.>", "$KV.mesh-controller_delivery-merges.>", "$KV.mesh-controller_grants.>", "$KV.mesh-controller_hand-acts.>", "$KV.mesh-controller_lease.>", "$KV.mesh-controller_merge-window.>", "$KV.mesh-controller_predefined.>", "$KV.mesh-controller_rollout-silent.>", "$KV.mesh-controller_rollouts.>", "$KV.mesh-controller_states-announced.>", "$SRV.INFO", "_INBOX.enrol.>", "mesh.again.>", "mesh.assignment.>", "mesh.events.dead.>", "mesh.mod.*.tool.>", "mesh.mod.mesh-controller.event.states", "mesh.node.>", "mesh.seat.mesh-controller.event.applied", "mesh.seat.mesh-controller.event.artifacts-gone", "mesh.seat.mesh-controller.event.assignment-changed", "mesh.seat.mesh-controller.event.bucket-deleted", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.checked", "mesh.seat.mesh-controller.event.condition-changed", "mesh.seat.mesh-controller.event.condition-cleared", "mesh.seat.mesh-controller.event.condition-raised", "mesh.seat.mesh-controller.event.declared-config-changed", "mesh.seat.mesh-controller.event.doctor-heartbeat", "mesh.seat.mesh-controller.event.healer-acted", "mesh.seat.mesh-controller.event.plan-moved", "mesh.seat.mesh-controller.event.predefined-changed", "mesh.seat.mesh-controller.event.refused", "mesh.seat.mesh-controller.event.rolled-back", "mesh.seat.mesh-controller.event.secret-replaced", "mesh.seat.mesh-controller.event.sent", "mesh.seat.mesh-controller.tool.plans", "mesh.seat.mesh-controller.tool.push", "mesh.seat.mesh-controller.tool.upgrade", "mesh.seat.mesh-delivery.tool.close", "mesh.seat.mesh-delivery.tool.groups", "mesh.seat.mesh-delivery.tool.release", "mesh.seat.mesh-delivery.tool.stalled", "mesh.seat.mesh-delivery.tool.stop", "mesh.seat.mesh-delivery.tool.times", "mesh.seat.node-backup.tool.backed-up.*", "mesh.seat.node-backup.tool.now.*", "mesh.seat.node-intrusion-prevention.tool.banned.*", "mesh.seat.node-launcher.tool.secret.*", "mesh.seat.node-service-manager.tool.restart.*", "mesh.seat.operator-channel.tool.tell"] } subscribe: { allow: ["$JS.API.>", "$JS.EVENT.ADVISORY.CONSUMER.DELETED.>", "$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.>", "$SRV.INFO", "$SRV.INFO.mesh-controller", "$SRV.INFO.mesh-controller.>", "$SRV.PING", "$SRV.PING.mesh-controller", "$SRV.PING.mesh-controller.>", "$SRV.STATS", "$SRV.STATS.mesh-controller", "$SRV.STATS.mesh-controller.>", "_DELIVER.controller", "_DELIVER.controller.>", "_INBOX.controller.>", "mesh.control.>", "mesh.mod.*.event.provisioner.failing", "mesh.mod.*.event.provisioner.recovered", "mesh.mod.*.event.provisioner.retirement", "mesh.mod.gitea.event.pull.merged", "mesh.mod.gitea.event.pull.updated", "mesh.mod.mesh-catalog.event.catching-up", "mesh.mod.mesh-catalog.event.upgraded", "mesh.seat.mesh-build-machine.event.built", "mesh.seat.mesh-controller.tool.>", "mesh.seat.node-build-agent.event.built", "mesh.seat.operator-channel.event.decided.mesh-controller"] } allow_responses: { max: 1, ttl: "1m" } } } @@ -39,7 +39,7 @@ accounts { } } { user: "node.one", password: "$2a$11$nnnnnnnnnnnnnnnnnnnnnn", permissions: { publish: { allow: ["$JS.ACK.NODES.one.>", "$JS.API.CONSUMER.CREATE.KV_composed-one.>", "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_wants.*.$KV.mesh-delivery-assigner_wants.want.one.>", "$JS.API.CONSUMER.CREATE.KV_node-engine_have.*.$KV.node-engine_have.one.>", "$JS.API.CONSUMER.CREATE.OBJ_declaration-files-one.*.$O.declaration-files-one.C.*", "$JS.API.CONSUMER.DELETE.KV_composed-one.>", "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_wants.>", "$JS.API.CONSUMER.DELETE.KV_node-engine_have.>", "$JS.API.CONSUMER.DELETE.OBJ_declaration-files-one.*", "$JS.API.CONSUMER.INFO.NODES.one", "$JS.API.DIRECT.GET.KV_composed-one.>", "$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_wants.$KV.mesh-delivery-assigner_wants.want.one.>", "$JS.API.DIRECT.GET.KV_node-engine_have.$KV.node-engine_have.one.>", "$JS.API.DIRECT.GET.OBJ_declaration-files-one.$O.declaration-files-one.M.*", "$JS.API.STREAM.INFO.KV_composed-one", "$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_wants", "$JS.API.STREAM.INFO.KV_node-engine_have", "$JS.FC.KV_composed-one.>", "$JS.FC.KV_mesh-delivery-assigner_wants.>", "$JS.FC.KV_node-engine_have.>", "$JS.FC.OBJ_declaration-files-one.>", "$KV.node-engine_have.one.>", "$SRV.PING.node-tools.one", "mesh.control.one.>", "mesh.seat.node-backup.call.backed-up.one.node~one", "mesh.seat.node-backup.call.now.one.node~one", "mesh.seat.node-backup.tool.backed-up.one", "mesh.seat.node-backup.tool.now.one"] } - subscribe: { allow: ["_DELIVER.one", "_DELIVER.one.>", "_INBOX.node.one.>", "mesh.mod.*.event.backed-up", "mesh.node.one.ask.hand-over", "mesh.node.one.ask.judge-directories", "mesh.node.one.ask.move-secret", "mesh.node.one.ask.report", "mesh.node.one.ask.setuid-search", "mesh.node.one.declare"] } + subscribe: { allow: ["_DELIVER.one", "_DELIVER.one.>", "_INBOX.node.one.>", "mesh.mod.restic.event.backed-up", "mesh.node.one.ask.hand-over", "mesh.node.one.ask.judge-directories", "mesh.node.one.ask.move-secret", "mesh.node.one.ask.report", "mesh.node.one.ask.setuid-search", "mesh.node.one.declare"] } allow_responses: { max: 1, ttl: "1m" } } } { user: "one.mesh-delivery-watchman", password: "$2a$11$wwwwwwwwwwwwwwwwwwwwww", permissions: { diff --git a/internal/broker/users.go b/internal/broker/users.go index 123e436e..3f30f312 100644 --- a/internal/broker/users.go +++ b/internal/broker/users.go @@ -230,7 +230,12 @@ func Users(r Records) ([]Principal, error) { out = append(out, Principal{Kind: KindEnrolment, Node: node}) } for _, person := range sortedNames(r.People) { - out = append(out, Principal{Kind: KindPerson, Module: person, Invokes: r.People[person]}) + p := Principal{Kind: KindPerson, Module: person, Invokes: r.People[person]} + if administers(p.Invokes) { + // The operator's hand path from release 6 on writes each node's compositions (ADR 0539 clause 10). + p.FilesFor = sortedCopy(r.Nodes) + } + out = append(out, p) } // Refused here rather than discovered by the server. Two users with one name is a file the diff --git a/internal/broker/writers.go b/internal/broker/writers.go index 78e27196..7894f5fe 100644 --- a/internal/broker/writers.go +++ b/internal/broker/writers.go @@ -153,9 +153,10 @@ var WritersTable = []WriterRow{ Subjects: append(append(kvOf(MergeWindowBucket), kvOf(RolloutSilentBucket)...), kvOf(DeliveryMergesBucket)...), Writes: isController}, // The compositions' digests, granted and asserted ahead of release 6, which writes them (ADR 0539 clauses 2 and 6). - {State: "the compositions' digests", Writer: "controller (lease holder), compare-and-set, from release 6", + {State: "the compositions' digests", Writer: "controller (lease holder), compare-and-set, from release 6; the operator's " + + "account on the hand path alone (ADR 0539 clauses 2 and 10)", KeptIn: "key-value " + ControllerComposedBucket, Others: "the assigner reads them", - Subjects: kvOf(ControllerComposedBucket), Writes: isController}, + Subjects: kvOf(ControllerComposedBucket), Writes: isControllerOrTheHandPath}, {State: "conditions", Writer: "controller", KeptIn: "key-value " + ConditionsBucket + " (and its history, " + ConditionHistoryBucket + "); how often each was observed, when last and its newest evidence in the " + "controller's store, so a look that changes nothing writes no bucket", Others: "raise or clear only through observations the controller reads", @@ -236,11 +237,12 @@ var WritersTable = []WriterRow{ Others: "read through `reads` as node-engine.have", Subjects: kvOf(delivery.HaveBucket), Writes: ownNodeKeys}, // The contents of a declaration's files over 64 KiB (novox/hq ADR 0486 §2): the controller alone writes the bucket, each // object under the digest of its content; a node-engine reads one by its name, with no list and no write. - {State: "a declaration's large files", Writer: "controller, each object under the sha256 digest of its content", + {State: "a declaration's large files", Writer: "controller, each object under the sha256 digest of its content; the " + + "operator's account on release 6's hand path alone (ADR 0539 clause 10)", 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: isController}, + Subjects: []string{"$O.*.>"}, Writes: isControllerOrTheHandPath}, {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}, @@ -303,3 +305,9 @@ func SubjectsOverlap(a, b string) bool { } } } + +// isControllerOrTheHandPath is the controller, or the operator's account (a person who may invoke every tool) writing +// release 6's hand path: the one second writer ADR 0539 clauses 2 and 10 name, for a composition made by hand. +func isControllerOrTheHandPath(p Principal, pattern string) bool { + return isController(p, pattern) || (p.Kind == KindPerson && administers(p.Invokes)) +} From 1350c428eda87edb378b63f07342537a06543b44 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 24 Oct 2026 18:25:46 +0200 Subject: [PATCH 3/4] Let the operator's hand path replace a file over 64 KiB in a node's object bucket (issue 1945; the review of A1, H1) The object store reads an object's metadata and purges its old chunks when it replaces one, so the hand path is granted the direct and message gets and the purge of each node's object stream; a test on a bus running the composed user list walks the hand path end to end. --- internal/broker/composed.go | 5 +- internal/broker/composed_live_test.go | 142 ++++++++++++++++++++++++++ internal/broker/composed_test.go | 4 +- internal/broker/log_test.go | 7 +- 4 files changed, 154 insertions(+), 4 deletions(-) create mode 100644 internal/broker/composed_live_test.go diff --git a/internal/broker/composed.go b/internal/broker/composed.go index 8631849a..8bfb0634 100644 --- a/internal/broker/composed.go +++ b/internal/broker/composed.go @@ -128,7 +128,10 @@ func HandPathWrites(nodes []string) ([]string, error) { if err != nil { return nil, err } - out = append(out, files, "$JS.API.STREAM.INFO.OBJ_declaration-files-"+node) + stream := "OBJ_" + DeclarationFilesBucket + node + // The object store's put reads an object's metadata already there and, replacing it, purges its old chunks. + out = append(out, files, "$JS.API.STREAM.INFO."+stream, "$JS.API.DIRECT.GET."+stream+".>", + "$JS.API.STREAM.MSG.GET."+stream, "$JS.API.STREAM.PURGE."+stream) } return out, nil } diff --git a/internal/broker/composed_live_test.go b/internal/broker/composed_live_test.go new file mode 100644 index 00000000..b7de6fce --- /dev/null +++ b/internal/broker/composed_live_test.go @@ -0,0 +1,142 @@ +//go:build integration + +package broker + +import ( + "bytes" + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/nats-io/nats-server/v2/server" + "github.com/nats-io/nats.go" + "github.com/nats-io/nats.go/jetstream" + "golang.org/x/crypto/bcrypt" +) + +// 1945 (the review of A1, H1): on a bus running the user list this controller composes, the operator's account (a +// person who may invoke every tool) walks release 6's hand path end to end: it writes a node's want, that node's +// composition and its digest, and stores a file over 64 KiB in the node's object bucket and replaces it; a person with +// fewer tools may do none of it. +func TestNatsTheOperatorWritesTheHandPathOnTheComposedUserList(t *testing.T) { + hash, err := bcrypt.GenerateFromPassword([]byte("pw"), bcrypt.MinCost) + if err != nil { + t.Fatal(err) + } + users, err := Users(Records{Nodes: []string{"ace"}, + People: map[string][]string{"jochen": {"*"}, "guest": {"mesh-controller.status"}}}) + if err != nil { + t.Fatal(err) + } + for i := range users { + users[i].PasswordHash = string(hash) + } + accounts, err := ComposeAccounts(users) + if err != nil { + t.Fatal(err) + } + conf := filepath.Join(t.TempDir(), "bus.conf") + if err := os.WriteFile(conf, []byte("listen: 127.0.0.1:-1\njetstream { store_dir: "+`"`+t.TempDir()+`"`+" }\n"+accounts), 0o600); err != nil { + t.Fatal(err) + } + opts, err := server.ProcessConfigFile(conf) + if err != nil { + t.Fatalf("the composed accounts do not parse: %v", err) + } + opts.NoLog, opts.NoSigs = true, true + s, err := server.NewServer(opts) + if err != nil { + t.Fatal(err) + } + go s.Start() + if !s.ReadyForConnections(10 * time.Second) { + t.Fatal("the bus did not come up") + } + t.Cleanup(s.Shutdown) + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + connect := func(user string) jetstream.JetStream { + t.Helper() + nc, err := nats.Connect(s.ClientURL(), nats.UserInfo(user, "pw"), nats.CustomInboxPrefix("_INBOX."+user)) + if err != nil { + t.Fatalf("%s cannot connect: %v", user, err) + } + t.Cleanup(nc.Close) + js, err := jetstream.New(nc) + if err != nil { + t.Fatal(err) + } + return js + } + + // The buckets as the controller and the assigner make them. + cjs := connect(ControllerName) + if err := EnsureDeclarationFiles(ctx, cjs, "ace"); err != nil { + t.Fatal(err) + } + if err := EnsureComposedDigests(ctx, cjs); err != nil { + t.Fatal(err) + } + if _, err := cjs.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: "composed-ace", History: 5}); err != nil { + t.Fatal(err) + } + // The assigner's bucket, made here by the controller's account, which may create any stream. + if _, err := cjs.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: "mesh-delivery-assigner_wants", History: 20}); err != nil { + t.Fatal(err) + } + + short := func(c context.Context) (context.Context, context.CancelFunc) { return context.WithTimeout(c, 5*time.Second) } + for _, who := range []struct { + user string + may bool + }{{"person.jochen", true}, {"person.guest", false}} { + js := connect(who.user) + try := func(what string, act func(context.Context) error) { + t.Helper() + c, stop := short(ctx) + defer stop() + err := act(c) + if (err == nil) != who.may { + t.Errorf("%s: %s: %v (may: %v)", who.user, what, err, who.may) + } + } + try("write ace's want", func(c context.Context) error { + kv, err := js.KeyValue(c, "mesh-delivery-assigner_wants") + if err != nil { + return err + } + _, err = kv.Put(c, "want.ace.mesh-controller", []byte(`{"v":1}`)) + return err + }) + try("write ace's composition", func(c context.Context) error { + kv, err := js.KeyValue(c, "composed-ace") + if err != nil { + return err + } + _, err = kv.Put(c, "ace.mesh-controller.aaaa", []byte(`{"v":1}`)) + return err + }) + try("write its digest", func(c context.Context) error { + kv, err := js.KeyValue(c, ControllerComposedBucket) + if err != nil { + return err + } + _, err = kv.Put(c, "ace.mesh-controller.aaaa", []byte(`{"v":1}`)) + return err + }) + try("store and replace a file over 64 KiB", func(c context.Context) error { + store, err := js.ObjectStore(c, "declaration-files-ace") + if err != nil { + return err + } + body := bytes.Repeat([]byte("x"), 200<<10) + if _, err := store.PutBytes(c, "f00d", body); err != nil { + return err + } + _, err = store.PutBytes(c, "f00d", append(body, 'y')) + return err + }) + } +} diff --git a/internal/broker/composed_test.go b/internal/broker/composed_test.go index a5d5a309..19e95265 100644 --- a/internal/broker/composed_test.go +++ b/internal/broker/composed_test.go @@ -76,7 +76,9 @@ func TestTheOperatorWritesTheHandPathAheadOfTheSwitch(t *testing.T) { t.Fatal(err) } hand := []string{"$KV.mesh-delivery-assigner_wants.want.>", "$KV.composed-ace.>", "$KV.composed-novox.>", - "$KV.mesh-controller_composed.>", "$O.declaration-files-ace.>"} + "$KV.mesh-controller_composed.>", "$O.declaration-files-ace.>", + "$JS.API.DIRECT.GET.OBJ_declaration-files-ace.>", "$JS.API.STREAM.MSG.GET.OBJ_declaration-files-ace", + "$JS.API.STREAM.PURGE.OBJ_declaration-files-ace"} for _, s := range hand { if got := slices.Contains(perms.Publish, s); got != (u.Module == "jochen") { t.Errorf("%s may publish %s: %v", u.Module, s, got) diff --git a/internal/broker/log_test.go b/internal/broker/log_test.go index b1988cdd..1d8ac164 100644 --- a/internal/broker/log_test.go +++ b/internal/broker/log_test.go @@ -206,8 +206,11 @@ func TestNoPathDeletesALogOrABucket(t *testing.T) { }) for _, lit := range stringsIn(f) { if strings.Contains(lit, "$JS.API.STREAM.DELETE") || strings.Contains(lit, "$JS.API.STREAM.PURGE") { - // Only the writers table names it, as a subject no one but the controller may publish. - if !strings.HasSuffix(filepath.ToSlash(path), "internal/broker/writers.go") { + // 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 the chunks of an object it replaces in a node's + // object bucket (novox/hq ADR 0539 clause 10): 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") { found = append(found, path+": "+lit) } } From bd8ea8ad4f30846633b4b7d800490df4e08e7c14 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 24 Oct 2026 18:25:54 +0200 Subject: [PATCH 4/4] Format the hand path's live test (issue 1945) --- internal/broker/composed_live_test.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/broker/composed_live_test.go b/internal/broker/composed_live_test.go index b7de6fce..5f5066ef 100644 --- a/internal/broker/composed_live_test.go +++ b/internal/broker/composed_live_test.go @@ -87,7 +87,9 @@ func TestNatsTheOperatorWritesTheHandPathOnTheComposedUserList(t *testing.T) { t.Fatal(err) } - short := func(c context.Context) (context.Context, context.CancelFunc) { return context.WithTimeout(c, 5*time.Second) } + short := func(c context.Context) (context.Context, context.CancelFunc) { + return context.WithTimeout(c, 5*time.Second) + } for _, who := range []struct { user string may bool