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)) +}