From 1a1d3a1ecc9317cded84f4377b360a53f23a4b8b Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 00:29:04 +0100 Subject: [PATCH] Compose both of a rotating node's logins from the vault's node-logins, so its engine can test-dial the new one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 0583 §1 (insight 2026-11-01) and §2: the grants part reads the vault's node-logins as its named reader and composes a node user per login, both while the node rotates and the new one alone after rotation-done; each node's engine reads its own node's key alone (composed by mesh-sdk while the vault serves rotate-node). The part follows the state by watch, so a rotation begun or ended composes again at once. A second login is judged its node's when the list is compared (Placed), and a hash changing by a rotation node-logins names is not counted as a writer changing several hashes at once (sealed.go). The state not on the bus yet is a standing gap: every node logs in as node., as today. 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/S53) Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- modules/mesh-grants/grants_test.go | 52 +++++++++++++++++++ modules/mesh-grants/internal/grants/follow.go | 2 +- modules/mesh-grants/internal/grants/inputs.go | 52 ++++++++++++++++++- .../mesh-grants/internal/grants/records.go | 4 +- modules/mesh-grants/internal/grants/sealed.go | 9 ++-- .../mesh-grants/internal/grants/userlist.go | 4 +- modules/mesh-grants/module.json | 3 +- 7 files changed, 117 insertions(+), 9 deletions(-) diff --git a/modules/mesh-grants/grants_test.go b/modules/mesh-grants/grants_test.go index de39f9d..d99bcee 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"]}` + // vaultManifest is the vault as the composition needs it for a rotation (ADR 0583 §2, §3): its claim serving the verbs. + vaultManifest = `{"module":"mesh-vault","version":"1","own-secrets":{"broker":"/var/lib/v/broker"},` + + `"provides":[{"name":"secret","scope":"mesh"}],` + + `"claims":[{"name":"mesh-vault","serves":["rotate-node","node-account-confirm","abandon"]}],` + + `"tools":["rotate-node","node-account-confirm","abandon"],"state":[{"name":"node-logins"}]}` // 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"},` + @@ -2458,6 +2463,53 @@ func TestGrants(t *testing.T) { t.Error("a key's change composes nothing again") } }) + // 774/S53 (novox/hq ADR 0583 §1, §2): while node-logins says b rotates to node.b--b, with its hash, both logins are in + // the list and b's machine stays placed under either; after rotation-done (b on node.b--b, nothing rotating) the old + // login is not (AC1). Each node's engine may read its own node-logins key alone (AC2). + t.Run("a node rotating is composed under both its logins, then the new one alone", func(t *testing.T) { + b := fixture() + b.manifestOf("mesh-vault", vaultManifest) + b.assign("a", "mesh-vault") + since := time.Date(2026, 11, 2, 1, 0, 0, 0, time.UTC) + login := func(l delivery.NodeLogin) { + raw, err := delivery.EncodeNodeLogin(l) + if err != nil { + t.Fatal(err) + } + b.put(grants.ReadNodeLogins, l.Node, json.RawMessage(raw)) + } + login(delivery.NodeLogin{Node: "b", Login: "node.b", Rotating: "node.b--b", Stage: delivery.RotationBegun, Since: since}) + b.put(grants.ReadAccounts, "node.b--b", map[string]any{"user": "node.b--b", "hash": "$2a$10$x", "generation": 1}) + users := shadowOf(t, b) + in := grants.ReadInputs(b, &grants.Manifests{}) + if got := names(users); !slices.Contains(got, "node.b") || !slices.Contains(got, "node.b--b") || !slices.Contains(got, "node.a") { + t.Fatalf("the node users are %v", got) + } + if c := grants.CredentialsOf(users, nil, in.Hashes); !slices.Contains(c.Adds, "node.b--b") { + t.Errorf("node.b--b's hash is not carried: %+v", c) + } + if placed := grants.Placed(in); !placed("node.b--b") || placed("node.c--b") || !in.Rotating()["node.b--b"] { + t.Error("the second login is not judged its node's, or not rotating") + } + get := func(key string) string { + return "$JS.API.DIRECT.GET.KV_" + delivery.NodeLoginsBucket + ".$KV." + delivery.NodeLoginsBucket + "." + key + } + for _, u := range users { + if !strings.HasPrefix(u.User, "node.") { + continue + } + node, _ := delivery.LoginNode(u.User) + for _, of := range []string{"a", "b"} { + if slices.Contains(u.Publish, get(of)) != (of == node) { + t.Errorf("%s reads %s's node-logins key: %v", u.User, of, of != node) + } + } + } + login(delivery.NodeLogin{Node: "b", Login: "node.b--b", Since: since.Add(time.Hour)}) + if got := names(shadowOf(t, b)); slices.Contains(got, "node.b") || !slices.Contains(got, "node.b--b") { + t.Errorf("after rotation-done the node users are %v", got) + } + }) 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 065d1be..9770249 100644 --- a/modules/mesh-grants/internal/grants/follow.go +++ b/modules/mesh-grants/internal/grants/follow.go @@ -25,7 +25,7 @@ type Watcher interface { // 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, - ReadEnrolments, OwnLease} + ReadEnrolments, ReadNodeLogins, 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 62c0742..ac8e8e1 100644 --- a/modules/mesh-grants/internal/grants/inputs.go +++ b/modules/mesh-grants/internal/grants/inputs.go @@ -38,12 +38,15 @@ const ( // 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 + // ReadNodeLogins is the vault's node-logins, read as its named reader (ADR 0583 §1 insight): each node's login in use + // and the one rotating, both composed as users of the node's permissions. + ReadNodeLogins = delivery.NodeLoginsRead ) // 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, ReadEnrolments} + ReadAccounts, ReadPlacement, ReadLease, ReadTakeover, ReadEnrolments, ReadNodeLogins} // The bus module's tools the part calls, as its manifest's invokes name them: read-only, each. const ( @@ -125,6 +128,9 @@ type Inputs struct { // 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 + // NodeLogins is each node's key of the vault's node-logins (ReadNodeLogins, ADR 0583 §1); a node absent logs in as + // node. alone. + NodeLogins map[string]delivery.NodeLogin // 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 @@ -448,6 +454,7 @@ func ReadInputs(r Reader, cache *Manifests) Inputs { if enrolmentsRead && in.LeaseAway { in.Gaps = slices.DeleteFunc(in.Gaps, func(g Gap) bool { return g.Input == "enrolment tokens" }) } + in.NodeLogins = readNodeLogins(r, &in.Gaps) 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 { @@ -499,6 +506,49 @@ func readEnrolling(r Reader, gaps *[]Gap) ([]string, bool) { return out, true } +// readNodeLogins is every node's key of the vault's node-logins (ADR 0583 §1). Not on the bus yet is a standing gap, +// never a failed read: every node then logs in as node. alone, as before any rotation. A key that does not read is +// named and leaves its node at node.; a read that fails is a failed read, since a rotating node's new login would be +// left out. +func readNodeLogins(r Reader, gaps *[]Gap) map[string]delivery.NodeLogin { + keys, err := r.Keys(ReadNodeLogins) + if err != nil { + *gaps = append(*gaps, Gap{Input: ReadNodeLogins, Why: "not read yet: every node logs in as node. (" + err.Error() + ")"}) + return nil + } + out := map[string]delivery.NodeLogin{} + for _, node := range keys { + raw, found, err := r.Get(ReadNodeLogins, node) + if err != nil { + *gaps = append(*gaps, Gap{Input: ReadNodeLogins + " " + node, Why: err.Error(), Failed: true}) + continue + } + if !found { + continue + } + l, err := delivery.DecodeNodeLogin(raw) + if err != nil || l.Node != node { + *gaps = append(*gaps, Gap{Input: ReadNodeLogins + " " + node, Why: "does not read as the logins of " + node + + ", so it logs in as node." + node}) + continue + } + out[node] = l + } + return out +} + +// Rotating are the logins the vault's node-logins names in a rotation under way, as a set: a hash of one changes by the +// vault's rotation (ADR 0583 §2), never counted as a writer changing several hashes at once. +func (in Inputs) Rotating() map[string]bool { + out := map[string]bool{} + for _, l := range in.NodeLogins { + if l.Rotates() { + out[l.Login], out[l.Rotating] = true, true + } + } + return out +} + 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 c76f9ca..eaba280 100644 --- a/modules/mesh-grants/internal/grants/records.go +++ b/modules/mesh-grants/internal/grants/records.go @@ -39,7 +39,9 @@ func records(in Inputs, union bool) (buscompose.Records, []Gap) { // 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{}, Enrolling: slices.Clone(in.Enrolling), LeaseHandedAway: in.LeaseAway} + Interchangeable: map[string]bool{}, Enrolling: slices.Clone(in.Enrolling), LeaseHandedAway: in.LeaseAway, + // And each node's logins (ADR 0583 §1): both while it rotates. + NodeLogins: in.NodeLogins} values := func(provider, setting string) []any { var vs []any for _, node := range in.Nodes { diff --git a/modules/mesh-grants/internal/grants/sealed.go b/modules/mesh-grants/internal/grants/sealed.go index a20e816..dbe2c83 100644 --- a/modules/mesh-grants/internal/grants/sealed.go +++ b/modules/mesh-grants/internal/grants/sealed.go @@ -336,8 +336,9 @@ func (p *Part) keepWrittenUsers(res *Result, list []byte, hashes map[string]Held } // changedHashes are the users of the list in force whose hash the list composed now would change with no rotation by the -// vault naming them, sorted; none when the part kept no credentials of the list in force (a list written before). -func (p *Part) changedHashes(hashes map[string]HeldHash) ([]string, error) { +// vault naming them (its announcement on the hash, or a node's login rotating in node-logins, ADR 0583 §2), sorted; none +// when the part kept no credentials of the list in force (a list written before). +func (p *Part) changedHashes(hashes map[string]HeldHash, rotating map[string]bool) ([]string, error) { raw, _, found, err := p.Store.Get(StateUserList, KeyWrittenCredentials) if err != nil || !found { return nil, err @@ -349,7 +350,7 @@ func (p *Part) changedHashes(hashes map[string]HeldHash) ([]string, error) { var changed []string for user, was := range had { h, ok := hashes[user] - if !ok || credentialOf(h) == was || h.Rotating != nil { + if !ok || credentialOf(h) == was || h.Rotating != nil || rotating[user] { continue } changed = append(changed, user) @@ -419,7 +420,7 @@ func (p *Part) holdsBack(in Inputs, leftOut, dropped, bus, lost []string, digest } // More than one loaded user's hash changing at once, with no rotation by the vault naming each (ADR 0561 §7): a new // writer of hashes must not lock several holders out in one list, as nine seats were cut on 2026-10-27 at 09:21. - changed, err := p.changedHashes(in.Hashes) + changed, err := p.changedHashes(in.Hashes, in.Rotating()) if err != nil { return "held: the credentials of the list in force cannot be read: " + err.Error() } diff --git a/modules/mesh-grants/internal/grants/userlist.go b/modules/mesh-grants/internal/grants/userlist.go index b83a6a5..ae76b45 100644 --- a/modules/mesh-grants/internal/grants/userlist.go +++ b/modules/mesh-grants/internal/grants/userlist.go @@ -12,6 +12,7 @@ import ( "git.novox.be/novox/mesh-sdk/go/buscompose" "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/outward" ) @@ -614,7 +615,8 @@ func Placed(in Inputs) func(user string) bool { case len(parts) < 2: return true case parts[0] == "node" || parts[0] == "provisioner": - return slices.Contains(in.Nodes, parts[1]) + // A node's second login, node.--b, is its node's (ADR 0583 §1). + return slices.Contains(in.Nodes, strings.TrimSuffix(parts[1], delivery.SecondLoginSuffix)) case parts[0] == "person" || parts[0] == "enrol": return true } diff --git a/modules/mesh-grants/module.json b/modules/mesh-grants/module.json index fb7e5a1..dc309fc 100644 --- a/modules/mesh-grants/module.json +++ b/modules/mesh-grants/module.json @@ -23,7 +23,8 @@ "mesh-vault.bus-accounts", "mesh-controller.lease", "mesh-conditions.takeover", - "mesh-enrolment.enrolments" + "mesh-enrolment.enrolments", + "mesh-vault.node-logins" ], "consumes": [ "mesh-conditions.condition-raised",