mesh-grants: hold a list that changes several loaded users' hashes at once with no rotation by the vault naming them, and raise user-list-held naming them (issue 774, ADR 0561 §7)

This commit is contained in:
2026-10-27 22:50:28 +01:00
parent 3f60eb95a0
commit afed658c69
3 changed files with 130 additions and 3 deletions
+62
View File
@@ -1883,6 +1883,68 @@ func TestGrants(t *testing.T) {
t.Errorf("once matched: %+v, said %+v", res.Consumers, said)
}
})
// 774/AC13 (novox/hq ADR 0561 §7): after take-over a list in which more than one loaded user's hash changes, with no
// rotation by the vault naming each, is held: the list in force stays, user-list-held is raised naming those users. One
// user's change, or changes the vault's rotation names, are written.
t.Run("a list changing several loaded users' hashes is held unless the vault's rotation names each", func(t *testing.T) {
b := fixture()
b.loaded = shadowOf(t, b)
hash := func(user, h string, generation int, rotating bool) {
v := map[string]any{"user": user, "hash": h, "generation": generation, "fingerprint": fmt.Sprintf("sha256:%d", generation)}
if rotating {
v["rotating"] = map[string]any{"login": user + "--b", "generation": generation, "revision": 7}
}
b.put(grants.ReadAccounts, user, v)
}
for _, u := range shadowOf(t, b) {
hash(u.User, "$2a$10$first", 1, false)
}
busKey, err := seal.GenerateKeyPair()
if err != nil {
t.Fatal(err)
}
b.put(grants.ReadHave, "a.nats", delivery.Have{V: delivery.Version, Phase: delivery.PhaseRunning, Health: delivery.HealthOK,
UserList: &delivery.UserListWritten{Source: "a.nats.src2", Revision: 7, Digest: "sha256:aa", Users: 6, Key: busKey.PublicKey}})
p := partOn(b)
p.Objects = &fakeObjects{}
var said []asks.Condition
p.Say = func(c asks.Condition) error { said = append(said, c); return nil }
sw, _ := json.Marshal(grants.Switch{V: 1, State: grants.TookOver, Acts: []grants.Act{}})
if _, err := (ownStore{b}).Put(grants.StateTakeover, grants.KeySwitch, sw); err != nil {
t.Fatal(err)
}
p.Compose()
inForce, _, _ := p.Sealed()
if inForce.Digest == "" {
t.Fatal("no list written after take-over")
}
// One user's hash changes: written.
hash("a.mod-x", "$2a$10$second", 2, false)
p.Compose()
if now, _, _ := p.Sealed(); now.Digest == inForce.Digest {
t.Fatal("a list changing one loaded user's hash was not written")
}
inForce, _, _ = p.Sealed()
// Two change at once, no rotation names them: held, said naming both.
hash("a.mod-x", "$2a$10$third", 3, false)
hash("a.nats", "$2a$10$third", 2, false)
said = nil
res := p.Compose()
if now, _, _ := p.Sealed(); now.Digest != inForce.Digest || res.Sealed == nil || !strings.Contains(res.Sealed.Why, "a.mod-x, a.nats") {
t.Fatalf("a list changing two loaded users' hashes: %+v", res.Sealed)
}
if len(said) != 1 || said[0].Key != grants.HeldCondition || said[0].State != asks.StateRaised || !strings.Contains(said[0].Summary, "a.mod-x, a.nats") {
t.Errorf("the hold was said as %+v", said)
}
// The vault's rotation names each: written, the warning cleared.
hash("a.mod-x", "$2a$10$third", 3, true)
hash("a.nats", "$2a$10$third", 2, true)
p.Compose()
if now, _, _ := p.Sealed(); now.Digest == inForce.Digest || said[len(said)-1].State != asks.StateCleared {
t.Errorf("changes the vault's rotation names were held: %+v", said)
}
})
}
type fakeAsker struct {
@@ -139,6 +139,22 @@ type HeldHash struct {
Generation int `json:"generation,omitempty"`
At time.Time `json:"at"`
Adopted bool `json:"adopted,omitempty"`
// Fingerprint is the vault's fingerprint of the value the hash is of: with the generation, what says the password
// changed, never the hash itself (a hash of the same password differs by its salt).
Fingerprint string `json:"fingerprint,omitempty"`
// Rotating is the vault's rotation of the account under way: a change of its hash it announces (ADR 0561 §4, §7).
Rotating *struct {
Login string `json:"login"`
} `json:"rotating,omitempty"`
}
// credentialOf is what says which password a held hash is of, never the hash: adopted, or the vault's generation and the
// fingerprint of its value.
func credentialOf(h HeldHash) string {
if h.Adopted {
return "adopted"
}
return fmt.Sprintf("generation %d %s", h.Generation, h.Fingerprint)
}
// readHashes reads the vault's bus-accounts; a refusal is a gap, and every user then has no hash held.
+52 -3
View File
@@ -133,7 +133,7 @@ func (p *Part) writeSealed(res *Result, list []byte, users int, leftOut []string
return
}
if next.Digest == digest && cur.Digest != digest {
p.keepWrittenUsers(res, list)
p.keepWrittenUsers(res, list, in.Hashes)
}
// The object the key named before is taken away once the key names the new one: the bucket keeps the list in force.
if had && cur.Object != "" && cur.Object != next.Object {
@@ -279,11 +279,50 @@ func (p *Part) followReasons(res Result, in Inputs) []string {
// composed after take-over must keep.
const KeyWrittenUsers = "sealed-users"
func (p *Part) keepWrittenUsers(res *Result, list []byte) {
data, _ := json.Marshal(listUsers(list))
// KeyWrittenCredentials is the key of the state user-list naming, per user of the list in force, which password its hash
// is of (credentialOf: adopted, or the vault's generation and fingerprint), never the hash: what tells a list that changes
// several loaded users' hashes at once (ADR 0561 §7).
const KeyWrittenCredentials = "sealed-credentials"
func (p *Part) keepWrittenUsers(res *Result, list []byte, hashes map[string]HeldHash) {
users := listUsers(list)
data, _ := json.Marshal(users)
if _, err := p.Store.Put(StateUserList, KeyWrittenUsers, data); err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("its own %s %s cannot be written: %v", StateUserList, KeyWrittenUsers, err))
}
creds := map[string]string{}
for _, u := range users {
if h, ok := hashes[u]; ok {
creds[u] = credentialOf(h)
}
}
data, _ = json.Marshal(creds)
if _, err := p.Store.Put(StateUserList, KeyWrittenCredentials, data); err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("its own %s %s cannot be written: %v", StateUserList, KeyWrittenCredentials, err))
}
}
// 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) {
raw, _, found, err := p.Store.Get(StateUserList, KeyWrittenCredentials)
if err != nil || !found {
return nil, err
}
var had map[string]string
if err := json.Unmarshal(raw, &had); err != nil {
return nil, err
}
var changed []string
for user, was := range had {
h, ok := hashes[user]
if !ok || credentialOf(h) == was || h.Rotating != nil {
continue
}
changed = append(changed, user)
}
slices.Sort(changed)
return changed, nil
}
var listUser = regexp.MustCompile(`\{\s*user:\s*"([^"]+)"`)
@@ -333,5 +372,15 @@ func (p *Part) holdsBack(in Inputs, leftOut, bus, lost []string) string {
if len(unhashed) > 0 {
return "held: the list in force is kept, since " + strings.Join(unhashed, ", ") + " would be left out for want of a hash in the vault"
}
// 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)
if err != nil {
return "held: the credentials of the list in force cannot be read: " + err.Error()
}
if len(changed) > 1 {
return "held: the list in force is kept, since the hashes of " + fmt.Sprint(len(changed)) + " loaded users would change at " +
"once with no rotation by the vault naming them: " + strings.Join(changed, ", ")
}
return ""
}