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",