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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user