From 7f8e5a821c76c92939aec1b955445c3d9363f886 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 24 Oct 2026 18:21:58 +0200 Subject: [PATCH] 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)) +}