Compose both of a rotating node's logins from the vault's node-logins, so its engine can test-dial the new one

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.<n>,
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
This commit is contained in:
2026-11-02 01:45:44 +01:00
parent 288364f28d
commit 1a1d3a1ecc
7 changed files with 117 additions and 9 deletions
+52
View File
@@ -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)
@@ -25,7 +25,7 @@ type Watcher interface {
// the seat mesh-enrolment's keys (ADR 0587 §4, §5): a key at live composes enrol.<node>, 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, `<module>.<state>`.
const OwnLease = Module + "." + StateLease
+51 -1
View File
@@ -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.<node>.
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.<node> 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.<n> 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.<n> alone, as before any rotation. A key that does not read is
// named and leaves its node at node.<n>; 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.<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 {
@@ -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 {
@@ -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()
}
@@ -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.<n>--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
}
+2 -1
View File
@@ -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",