From 288364f28d9dd37b37184e8557ff263a5a148b5a Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 00:28:02 +0100 Subject: [PATCH] Compose each enrol. user from the seat mesh-enrolment's keys, so a token issued by the seat can be redeemed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 0587 §4: the grants part composes enrol. from a key at live and the vault's hash, granted only the seat's redeem and its own inbox; §5: when the key ends the user goes. The part reads mesh-enrolment.enrolments as its named reader and follows it by watch: a key reaching live composes the user, and its end (by the holder, or by the bus at its expiry) is a change of the key that takes it out, so no consume of the seat's ending events is added beside the watch (the subtask named one; the watch already hears every end, the bus's expiry included). From the lease write the user loses the controller's enrolment subject (§6) and the standing gap of the controller's tokens is dropped; before it the gap stands. Tested as a subtest of TestGrants, no new test function. Built against the group's mesh-sdk head 5000774 through a go.work outside the worktree; the pin is the lead's. (issue 774, 774/S52) Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- modules/mesh-grants/grants_test.go | 54 +++++++++++++++++ modules/mesh-grants/internal/grants/follow.go | 6 +- modules/mesh-grants/internal/grants/inputs.go | 58 ++++++++++++++++++- .../mesh-grants/internal/grants/records.go | 4 +- modules/mesh-grants/module.json | 3 +- 5 files changed, 119 insertions(+), 6 deletions(-) diff --git a/modules/mesh-grants/grants_test.go b/modules/mesh-grants/grants_test.go index 9ae4a82..de39f9d 100644 --- a/modules/mesh-grants/grants_test.go +++ b/modules/mesh-grants/grants_test.go @@ -358,6 +358,11 @@ const ( xManifest = `{"module":"mod-x","version":"1","own-secrets":{"broker":"/var/lib/x/broker"},"state":[{"name":"kept","history":3}],` + `"streams":[{"name":"things","events":["thing.added"],"max-age-days":2,"max-mib":5}],"logs":[{"name":"history","max-age-days":30}]}` yManifest = `{"module":"mod-y","version":"1","own-secrets":{"broker":"/var/lib/y/broker"},"reads":["mod-x.kept"]}` + // enrolmentManifest is the holder of mesh-enrolment as the composition needs it (ADR 0587 §1): the claim, its six verbs + // and its bucket. + enrolmentManifest = `{"module":"mesh-enrolment","version":"1","own-secrets":{"broker":"/var/lib/e/broker"},` + + `"claims":[{"name":"mesh-enrolment","serves":["issue","redeem","confirm","revoke","tokens","again"]}],` + + `"tools":["issue","redeem","confirm","revoke","tokens","again"],"state":[{"name":"enrolments","key-ttl":true}]}` ) // brokerSeat is one verb of the bus's seat as a membership serves it: nats claims the seat in fixture and no membership @@ -2404,6 +2409,55 @@ func TestGrants(t *testing.T) { // 774/AC5 (novox/hq ADR 0561 §1): the vault's account-issued wakes a pass, so the next list carries the new user's hash // within one pass; an event of another kind does not. + // 774/S52/AC1 (novox/hq ADR 0587 §4, §5): a key of the seat mesh-enrolment at live, with the vault's hash, composes + // enrol., which from the lease write may publish the seat's redeem in its own name and subscribe its own inbox, + // nothing else; a key at asked composes none; when the key ends the user is gone. + t.Run("an enrolment key at live composes its enrolment user, and its end takes it out", func(t *testing.T) { + b := fixture() + b.manifestOf("mesh-enrolment", enrolmentManifest) + b.assign("a", "mesh-enrolment") + b.put(grants.ReadLease, grants.LeaseKey, map[string]any{"handed-to": "the operator's hand-over"}) + issued := time.Date(2026, 11, 2, 1, 0, 0, 0, time.UTC) + for node, stage := range map[string]string{"lab": delivery.EnrolmentLive, "pi": delivery.EnrolmentAsked} { + raw, err := delivery.EncodeEnrolment(delivery.Enrolment{Node: node, Address: "10.0.0.7", Why: "joins", + Caller: "a.mesh-enrolment", Stage: stage, Issued: issued, Expires: issued.Add(time.Hour)}) + if err != nil { + t.Fatal(err) + } + b.put(grants.ReadEnrolments, delivery.EnrolmentNodeKey(node), json.RawMessage(raw)) + } + b.put(grants.ReadAccounts, "enrol.lab", map[string]any{"user": "enrol.lab", "hash": "$2a$10$x", "generation": 1}) + in := grants.ReadInputs(b, &grants.Manifests{}) + if !slices.Equal(in.Enrolling, []string{"lab"}) || gapNamed(in.Gaps, "enrolment tokens") { + t.Fatalf("enrolling %v, gaps %v", in.Enrolling, in.Gaps) + } + users := shadowOf(t, b) + var lab *grants.User + for i := range users { + if users[i].User == "enrol.lab" { + lab = &users[i] + } + if users[i].User == "enrol.pi" { + t.Error("a key at asked composed enrol.pi") + } + } + if lab == nil || !slices.Equal(lab.Publish, []string{"mesh.seat.mesh-enrolment.call.redeem.enrol~lab"}) || + !slices.Equal(lab.Subscribe, []string{"_INBOX.enrol.lab.>"}) || lab.AllowResponses { + t.Fatalf("enrol.lab: %+v", lab) + } + if c := grants.CredentialsOf(users, nil, in.Hashes); !slices.Contains(c.Adds, "enrol.lab") { + t.Errorf("enrol.lab's hash is not carried: %+v", c) + } + b.mu.Lock() + delete(b.states[grants.ReadEnrolments], delivery.EnrolmentNodeKey("lab")) + b.mu.Unlock() + if slices.Contains(names(shadowOf(t, b)), "enrol.lab") { + t.Error("enrol.lab stays after its key ended") + } + if !slices.Contains(grants.Followed, grants.ReadEnrolments) { + t.Error("a key's change composes nothing again") + } + }) t.Run("the vault's account-issued composes the list again", func(t *testing.T) { b := fixture() var handle func(string, []byte) diff --git a/modules/mesh-grants/internal/grants/follow.go b/modules/mesh-grants/internal/grants/follow.go index 3831219..065d1be 100644 --- a/modules/mesh-grants/internal/grants/follow.go +++ b/modules/mesh-grants/internal/grants/follow.go @@ -21,9 +21,11 @@ type Watcher interface { // config values, the node agents' haves (a machine's first have, and the bus node's loaded list) and the assigner's // wants (the manifest each machine is wanted to run), and the vault's hashes of the bus accounts. // -// And its own state lease, whose key removal-due's expiry marker starts the day-later removal (ADR 0582 clause 6). +// And its own state lease, whose key removal-due's expiry marker starts the day-later removal (ADR 0582 clause 6), and +// the seat mesh-enrolment's keys (ADR 0587 §4, §5): a key at live composes enrol., and its end, by the holder or by +// the bus's expiry, is a change of the key that takes the user out; no event is needed beside the watch. var Followed = []string{ReadModules, ReadResolved, ReadHave, ReadWants, ReadAccounts, ReadPlacement, ReadLease, ReadTakeover, - OwnLease} + ReadEnrolments, OwnLease} // OwnLease is the part's own state lease as the follower watches it, `.`. const OwnLease = Module + "." + StateLease diff --git a/modules/mesh-grants/internal/grants/inputs.go b/modules/mesh-grants/internal/grants/inputs.go index afe87f7..62c0742 100644 --- a/modules/mesh-grants/internal/grants/inputs.go +++ b/modules/mesh-grants/internal/grants/inputs.go @@ -35,12 +35,15 @@ const ( // ReadPlacement is the seat mesh-placement's placement (ADR 0567 §1, §9): each node's modules and the seats // they hold. ReadPlacement = delivery.PlacementRead + // ReadEnrolments is the seat mesh-enrolment's open enrolments, read as a named reader (ADR 0587 §2, §4): a key at + // live or redeeming composes the user enrol.. + ReadEnrolments = delivery.EnrolmentsRead ) // Reads is every state the part reads, in the manifest's order. Not the controller's assignments: the seat's placement // alone places modules (novox/hq ADR 0549 §5, ADR 0571 §10; issue 774). var Reads = []string{ReadModules, ReadManifests, ReadResolved, ReadHave, ReadWants, ReadBuilds, - ReadAccounts, ReadPlacement, ReadLease, ReadTakeover} + ReadAccounts, ReadPlacement, ReadLease, ReadTakeover, ReadEnrolments} // The bus module's tools the part calls, as its manifest's invokes name them: read-only, each. const ( @@ -75,7 +78,8 @@ type Gap struct { // Standing gaps: what the controller composes from its own store, which no bus value carries in this milestone. var standingGaps = []Gap{ - {Input: "enrolment tokens", Why: "the live enrolment tokens are in the controller's store alone: no enrolment user is composed"}, + {Input: "enrolment tokens", Why: "while the controller holds the lease, its live enrolment tokens are in its store alone: " + + "an enrolment user is composed only from the seat mesh-enrolment's keys"}, {Input: "people", Why: "the people and the tools they may invoke are in the controller's store alone: no person user is composed"}, {Input: "seat holdings", Why: "who holds each seat held once for the mesh is in the controller's store alone: a mesh seat claimed on two machines is composed as held on both"}, {Input: "announced states", Why: "the controller does not announce its states-announced readable, so every read is composed from `reads`, as when announcements cannot be read"}, @@ -118,6 +122,12 @@ type Inputs struct { Loaded *delivery.UserListWritten // Hashes are the vault's hashes of the bus accounts, by bus user (ReadAccounts); kept in memory, never written. Hashes map[string]HeldHash + // Enrolling is every node whose enrolment key is at live or redeeming (ReadEnrolments, ADR 0587 §4, §5), sorted: one + // user enrol. each. A key at asked has no token yet; an ended or expired key is gone from the bucket. + Enrolling []string + // LeaseAway says the controller's lease is handed away, as ReadInputs read it: an enrolment user then enrols through + // the seat alone (ADR 0587 §6). + LeaseAway bool // Refused is every manifest the indexer holds that the part cannot read or mesh-sdk's reader refuses, assigned or // not, each with why: its module counts as placed nowhere, so it is said loudly, never passed over (issue 1945). Refused []Gap @@ -264,6 +274,7 @@ func ReadInputs(r Reader, cache *Manifests) Inputs { in.Placed[node] = p } } + in.LeaseAway, _ = leaseHandedAway(r) for node, p := range in.Placed { nodes[node] = true for _, m := range p.Assigned() { @@ -431,6 +442,12 @@ func ReadInputs(r Reader, cache *Manifests) Inputs { in.Gaps = append(in.Gaps, Gap{Input: "wanted manifests", Why: "no build record names the manifest at the wanted fingerprint, so the trunk's is used: " + strings.Join(fromTrunk, ", ")}) } in.Hashes = readHashes(r, &in.Gaps) + // The controller's tokens stand as a gap while it holds the lease; from the write the seat's keys are every token. + var enrolmentsRead bool + in.Enrolling, enrolmentsRead = readEnrolling(r, &in.Gaps) + if enrolmentsRead && in.LeaseAway { + in.Gaps = slices.DeleteFunc(in.Gaps, func(g Gap) bool { return g.Input == "enrolment tokens" }) + } for _, raw := range each(r, ReadResolved, &in.Gaps) { var v resolved if json.Unmarshal(raw, &v) != nil || v.Node == "" || v.Module == "" || v.Key == "" || len(v.Value) == 0 { @@ -445,6 +462,43 @@ func ReadInputs(r Reader, cache *Manifests) Inputs { return in } +// readEnrolling is every node with an enrolment key at live or redeeming (ADR 0587 §4, §5), and whether the seat's state +// was read. Not on the bus yet is a standing gap, never a failed read: the list is still composed and sealed, with no +// enrolment user. A key that does not read is named and composes no user. +func readEnrolling(r Reader, gaps *[]Gap) ([]string, bool) { + keys, err := r.Keys(ReadEnrolments) + if err != nil { + *gaps = append(*gaps, Gap{Input: ReadEnrolments, Why: "not read yet: no enrolment user is composed (" + err.Error() + ")"}) + return nil, false + } + var out []string + for _, k := range keys { + node, isNode := delivery.EnrolmentKeyNode(k) + if !isNode { + continue + } + raw, found, err := r.Get(ReadEnrolments, k) + if err != nil { + *gaps = append(*gaps, Gap{Input: ReadEnrolments + " " + k, Why: err.Error(), Failed: true}) + continue + } + if !found { + continue + } + e, err := delivery.DecodeEnrolment(raw) + if err != nil || e.Node != node { + *gaps = append(*gaps, Gap{Input: ReadEnrolments + " " + k, Why: "does not read as the enrolment of " + node + + ", so it composes no user"}) + continue + } + if e.Stage == delivery.EnrolmentLive || e.Stage == delivery.EnrolmentRedeeming { + out = append(out, node) + } + } + sort.Strings(out) + return out, true +} + func gapNamed(gaps []Gap, input string) bool { for _, g := range gaps { if g.Input == input { diff --git a/modules/mesh-grants/internal/grants/records.go b/modules/mesh-grants/internal/grants/records.go index cf2556e..c76f9ca 100644 --- a/modules/mesh-grants/internal/grants/records.go +++ b/modules/mesh-grants/internal/grants/records.go @@ -36,8 +36,10 @@ func records(in Inputs, union bool) (buscompose.Records, []Gap) { for _, own := range catalogue.SeatsWithAProtocol() { seats[own.Name] = catalogue.SeatDeclaration{Name: own.Name, Scope: own.Scope, Accepts: own.Accepts, Emits: own.Emits, Serves: own.Serves} } + // The enrolment users, from the seat mesh-enrolment's keys, and from the lease write through its redeem alone (ADR 0587 + // §4, §6). out := buscompose.Records{Assigned: map[string][]buscompose.Declared{}, People: map[string][]string{}, - Interchangeable: map[string]bool{}} + Interchangeable: map[string]bool{}, Enrolling: slices.Clone(in.Enrolling), LeaseHandedAway: in.LeaseAway} values := func(provider, setting string) []any { var vs []any for _, node := range in.Nodes { diff --git a/modules/mesh-grants/module.json b/modules/mesh-grants/module.json index ba6515d..fb7e5a1 100644 --- a/modules/mesh-grants/module.json +++ b/modules/mesh-grants/module.json @@ -22,7 +22,8 @@ "node-engine.have", "mesh-vault.bus-accounts", "mesh-controller.lease", - "mesh-conditions.takeover" + "mesh-conditions.takeover", + "mesh-enrolment.enrolments" ], "consumes": [ "mesh-conditions.condition-raised",