ADR 0593 §1: buscompose's engine moves from mesh-sdk into mesh-grants/internal/buscompose, its golden user lists unchanged; the controller's raise and assert code is left behind, and the merged switch test runs inside the part's whole integration test. Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
466 lines
19 KiB
Go
466 lines
19 KiB
Go
package grants
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"regexp"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/asks"
|
|
"git.novox.be/novox/mesh-sdk/go/delivery"
|
|
"mesh-grants/internal/buscompose"
|
|
)
|
|
|
|
// **The list the bus node loads** (novox/hq ADR 0547 clause 7, issue 1945). Each pass composes the bus's user list with
|
|
// the vault's hashes (the accounts block, buscompose.ComposeAccounts, as the bus module's file holds it), seals it to the
|
|
// bus node's key (the key the node says in its have of the bus module) and puts it as one object of the part's object
|
|
// bucket, named by its digest; then writes the key `user-list.sealed` naming it, with the revision, the user count and
|
|
// the users left out for want of a hash. The key always says FollowGrants: the bus node follows this list alone since the
|
|
// controller ended by its unassignment (ADR 0590, issue 1126). Only the process writes them (Objects); a tool asks it.
|
|
|
|
// KeySealed is the key of the state user-list the bus node reads (delivery.GrantsUserListKey).
|
|
const KeySealed = delivery.GrantsUserListKey
|
|
|
|
// Objects is the part's object bucket of sealed lists: the process's connection; nil in the tools.
|
|
type Objects interface {
|
|
PutObject(name string, data []byte) error
|
|
DeleteObject(name string) error
|
|
}
|
|
|
|
// ComposeList is the bus's user list as the file holds it, with each user's hash from the vault; the users left out
|
|
// for want of one; and the refused principals (refused, by user) the list carries with their grants kept from the list
|
|
// in force (kept), or leaves out where kept holds none (dropped, sorted). Pure.
|
|
func ComposeList(r buscompose.Records, hashes map[string]HeldHash, refused map[string]string, kept map[string]User) (list []byte, users int, leftOut, dropped []string, err error) {
|
|
principals, err := buscompose.Users(r)
|
|
if err != nil {
|
|
return nil, 0, nil, nil, err
|
|
}
|
|
held := map[string]string{}
|
|
for user, h := range hashes {
|
|
held[user] = h.Hash
|
|
}
|
|
composing := slices.DeleteFunc(slices.Clone(principals), func(p buscompose.Principal) bool { _, no := refused[p.Username()]; return no })
|
|
filled, missing := buscompose.WithPasswords(composing, held)
|
|
text, err := buscompose.ComposeAccounts(filled)
|
|
if err != nil {
|
|
return nil, 0, nil, nil, err
|
|
}
|
|
var entries []string
|
|
for _, user := range sortedKeys(refused) {
|
|
u, ok := kept[user]
|
|
switch {
|
|
case !ok:
|
|
dropped = append(dropped, user)
|
|
case held[user] == "":
|
|
missing = append(missing, user)
|
|
default:
|
|
entries = append(entries, keptEntry(u, held[user]))
|
|
}
|
|
}
|
|
if text, err = withEntries(text, entries); err != nil {
|
|
return nil, 0, nil, nil, err
|
|
}
|
|
slices.Sort(missing)
|
|
return []byte(text), len(filled) + len(entries), missing, dropped, nil
|
|
}
|
|
|
|
// Sealed is the part's key `user-list.sealed` as written, and whether there is one.
|
|
func (p *Part) Sealed() (delivery.GrantsUserList, uint64, bool) {
|
|
raw, rev, found, err := p.Store.Get(StateUserList, KeySealed)
|
|
if err != nil || !found {
|
|
return delivery.GrantsUserList{}, 0, false
|
|
}
|
|
g, err := delivery.DecodeGrantsUserList(raw)
|
|
if err != nil {
|
|
return delivery.GrantsUserList{}, 0, false
|
|
}
|
|
return g, rev, true
|
|
}
|
|
|
|
// writeSealed puts the list sealed to the bus node and writes the key, each only when what it says changed. It says in
|
|
// res what it wrote, or why it wrote nothing.
|
|
func (p *Part) writeSealed(res *Result, list []byte, users int, leftOut, dropped []string, in Inputs, bus []string, lost []string, composed []User) {
|
|
cur, _, had := p.Sealed()
|
|
next := cur
|
|
// The bus node follows this list alone (ADR 0571 clause 8): always since the controller ended by its unassignment
|
|
// (novox/hq ADR 0590, issue 1126).
|
|
next.Follow = delivery.FollowGrants
|
|
key := ""
|
|
if in.Loaded != nil {
|
|
key = in.Loaded.Key
|
|
}
|
|
digest := delivery.UserListDigest(list)
|
|
why, held := "", ""
|
|
switch {
|
|
case p.Objects == nil:
|
|
// The tools: the process writes.
|
|
res.Sealed = sealedSays(cur, had, "")
|
|
return
|
|
case in.BusNode == "":
|
|
why = "no node runs the bus's module, so the list is sealed to nobody"
|
|
case key == "":
|
|
why = "the bus node's have of the bus module names no key yet (its node-engine says it from this release)"
|
|
case cur.Digest != digest && p.holdsBack(in, leftOut, dropped, bus, lost, digest) != "":
|
|
// The list in force is never replaced by one composed past a failed read, or one that drops a user of it for want
|
|
// of a hash: that user's module would be locked out of the bus (review of the switch).
|
|
why = p.holdsBack(in, leftOut, dropped, bus, lost, digest)
|
|
held = why
|
|
p.keepHashHold(res, digest)
|
|
case cur.Digest != digest || cur.SealedTo != delivery.SealingKeyID(key) || cur.Node != in.BusNode:
|
|
object, err := delivery.SealUserList(list, key)
|
|
if err == nil {
|
|
err = p.Objects.PutObject(delivery.UserListObjectName(digest), object)
|
|
}
|
|
if err != nil {
|
|
res.Errors = append(res.Errors, "the sealed user list could not be put: "+err.Error())
|
|
res.Sealed = sealedSays(cur, had, "")
|
|
return
|
|
}
|
|
if cur.Digest != digest {
|
|
next.Revision++
|
|
}
|
|
next.Digest, next.Object, next.SealedTo, next.Node = digest, delivery.UserListObjectName(digest), delivery.SealingKeyID(key), in.BusNode
|
|
}
|
|
p.sayHeld(held)
|
|
if next.Digest == digest {
|
|
next.Users, next.LeftOut = users, leftOut
|
|
}
|
|
if next.Digest == "" {
|
|
next.Users, next.LeftOut = 0, nil
|
|
}
|
|
if had && sameSealed(cur, next) {
|
|
res.Sealed = sealedSays(cur, had, why)
|
|
return
|
|
}
|
|
next.At = p.now()
|
|
data, err := delivery.EncodeGrantsUserList(next)
|
|
if err == nil {
|
|
_, err = p.Store.Put(StateUserList, KeySealed, data)
|
|
}
|
|
if err != nil {
|
|
res.Errors = append(res.Errors, fmt.Sprintf("its own %s %s cannot be written: %v", StateUserList, KeySealed, err))
|
|
res.Sealed = sealedSays(cur, had, "")
|
|
return
|
|
}
|
|
if next.Digest == digest && cur.Digest != digest {
|
|
p.keepWrittenUsers(res, list, in.Hashes)
|
|
p.keepSealedGrants(res, digest, list, composed)
|
|
p.acceptedWritten(res, digest)
|
|
}
|
|
// 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 {
|
|
if err := p.Objects.DeleteObject(cur.Object); err != nil {
|
|
res.Errors = append(res.Errors, "the sealed list before could not be taken away: "+err.Error())
|
|
}
|
|
}
|
|
res.Sealed = sealedSays(next, true, why)
|
|
p.say(res, EventUserList, map[string]any{"digest": next.Digest, "revision": next.Revision, "users": next.Users,
|
|
"left_out": len(next.LeftOut), "follow": next.Follow, "sealed_to": next.Node})
|
|
}
|
|
|
|
// sayHeld raises the warning condition mesh-grants.user-list-held while the list in force is kept (why, which words the pass's
|
|
// `held: ...`), and clears it on the first pass that holds nothing back: the list advanced, or the reason went. The refusal
|
|
// condition's way (sayRefused): said once per reason, again when the reason changes, and tried again when a say failed.
|
|
func (p *Part) sayHeld(why string) {
|
|
if p.Say == nil {
|
|
return
|
|
}
|
|
p.holdMu.Lock()
|
|
defer p.holdMu.Unlock()
|
|
if p.heldKnown && why == p.heldSaid {
|
|
return
|
|
}
|
|
c := asks.Condition{Key: HeldCondition, Subject: asks.Scope{Scope: "module", ID: "mesh-grants"}}
|
|
if why == "" {
|
|
c.State = asks.StateCleared
|
|
} else {
|
|
reason := strings.TrimPrefix(why, "held: ")
|
|
c.Severity, c.State = asks.SeverityWarning, asks.StateRaised
|
|
c.Summary, c.Headline, c.Needs = "the user list is held: "+reason, "The user list in force is held back", "a look at why"
|
|
c.Explanation = asks.NeedsYou + "a look at why. The bus node keeps following the list written before, not the one composed now: " + reason
|
|
c.Resolved = "a pass finds nothing to hold back and the list advances"
|
|
}
|
|
if p.Say(c) == nil {
|
|
p.heldSaid, p.heldKnown = why, true
|
|
}
|
|
}
|
|
|
|
// HeldCondition is the key of the warning raised while a list in force is held back.
|
|
const HeldCondition = "mesh-grants.user-list-held"
|
|
|
|
func sameSealed(a, b delivery.GrantsUserList) bool {
|
|
// Its time is no change, and no user left out is the same written as [] or null: the key read back says [] for the
|
|
// nil a pass composes, which read as a change wrote the key again at every pass, and each write moved the bus node's
|
|
// report and woke the next pass (issue 1126: the shadow list's revision 99270 to 99357 in 90 s, 2026-10-28).
|
|
a.At, b.At = b.At, a.At
|
|
if len(a.LeftOut) == 0 {
|
|
a.LeftOut = nil
|
|
}
|
|
if len(b.LeftOut) == 0 {
|
|
b.LeftOut = nil
|
|
}
|
|
x, _ := json.Marshal(a)
|
|
y, _ := json.Marshal(b)
|
|
return string(x) == string(y)
|
|
}
|
|
|
|
// SealedState is what a pass says of the sealed list: the key as written, and why nothing was written when nothing was.
|
|
type SealedState struct {
|
|
Written *delivery.GrantsUserList `json:"written,omitempty"`
|
|
Why string `json:"why,omitempty"`
|
|
}
|
|
|
|
func sealedSays(g delivery.GrantsUserList, had bool, why string) *SealedState {
|
|
s := &SealedState{Why: why}
|
|
if had {
|
|
s.Written = &g
|
|
}
|
|
return s
|
|
}
|
|
|
|
func short(d string) string {
|
|
d = strings.TrimPrefix(d, "sha256:")
|
|
if len(d) > 12 {
|
|
d = d[:12]
|
|
}
|
|
return d
|
|
}
|
|
|
|
// KeyWrittenUsers is the key of the state user-list naming the users of the list the key `sealed` names: what a list
|
|
// composed must keep.
|
|
const KeyWrittenUsers = "sealed-users"
|
|
|
|
// 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 (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
|
|
}
|
|
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 || rotating[user] {
|
|
continue
|
|
}
|
|
changed = append(changed, user)
|
|
}
|
|
slices.Sort(changed)
|
|
return changed, nil
|
|
}
|
|
|
|
var listUser = regexp.MustCompile(`\{\s*user:\s*"([^"]+)"`)
|
|
|
|
// listUsers names the users of a list as the composer writes it.
|
|
func listUsers(list []byte) []string {
|
|
out := []string{}
|
|
for _, m := range listUser.FindAllSubmatch(list, -1) {
|
|
out = append(out, string(m[1]))
|
|
}
|
|
return out
|
|
}
|
|
|
|
// holdsBack says why a list composed now may not replace the one in force: a read failed this pass, a
|
|
// user of the list in force is left out for want of a hash, or the list would leave a seat the list in force holds with
|
|
// no holder while a placed module still claims it (lost, Holdings.LastHolders: the membership's guard, issue 1945; the
|
|
// loaded list not read holds it too, since that cannot be ruled out). The list in force is the one the part wrote, by the users
|
|
// it kept of it; where it kept none (a list written before this release), the users the bus loaded (bus, nil when
|
|
// they could not be read). Empty when it may.
|
|
func (p *Part) holdsBack(in Inputs, leftOut, dropped, bus, lost []string, digest string) string {
|
|
p.hashHold = nil
|
|
if f := in.ReadFailures(); len(f) > 0 {
|
|
return "held: the list in force is kept while a read of this pass failed (" + strings.Join(f, "; ") + ")"
|
|
}
|
|
if bus == nil {
|
|
return "held: the grants of the list in force cannot be read, so a loss of seats cannot be ruled out"
|
|
}
|
|
if len(lost) > 0 {
|
|
return "held, " + ShadowDifference + "the last holder of " + strings.Join(lost, ", ")
|
|
}
|
|
raw, _, found, err := p.Store.Get(StateUserList, KeyWrittenUsers)
|
|
if err != nil {
|
|
return "held: the users of the list in force cannot be read: " + err.Error()
|
|
}
|
|
var had []string
|
|
switch {
|
|
case found:
|
|
_ = json.Unmarshal(raw, &had)
|
|
default:
|
|
had = bus
|
|
}
|
|
var unhashed []string
|
|
for _, u := range leftOut {
|
|
if slices.Contains(had, u) {
|
|
unhashed = append(unhashed, u)
|
|
}
|
|
}
|
|
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"
|
|
}
|
|
// A refused principal the list in force carries and whose grants there the part did not keep: leaving it out would lock
|
|
// it out of the bus (refused.go), so the list in force stands until its grants compose or are kept.
|
|
var unkept []string
|
|
for _, u := range dropped {
|
|
if slices.Contains(had, u) {
|
|
unkept = append(unkept, u)
|
|
}
|
|
}
|
|
if len(unkept) > 0 {
|
|
return "held: the list in force is kept, since " + strings.Join(unkept, ", ") + " would be left out: its grants are refused and the part kept none of the list in force"
|
|
}
|
|
// 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, in.Rotating())
|
|
if err != nil {
|
|
return "held: the credentials of the list in force cannot be read: " + err.Error()
|
|
}
|
|
if len(changed) > 1 && !p.accepted(digest, changed) {
|
|
p.hashHold = changed
|
|
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 ""
|
|
}
|
|
|
|
// **A person accepts a held list's hash changes** (ADR 0561 §7, §9; tools-are-how-a-module-is-spoken-to): the hold of
|
|
// several loaded users' hashes changing at once is released only by grants_accept_hashes, naming exactly the users held,
|
|
// with why, bound to the held list's digest. The acceptance releases this guard alone, once: the other holds (a failed
|
|
// read, a user dropped for want of a hash, a lost right) stand whatever it says; and a list composed otherwise since is
|
|
// held again, for a new acceptance.
|
|
|
|
// KeyHashHold names the list held for its hash changes (its digest and the users), and KeyHashAccept a person's acceptance.
|
|
const (
|
|
KeyHashHold = "hash-hold"
|
|
KeyHashAccept = "hash-accept"
|
|
)
|
|
|
|
// EventHashesAccepted is said when a list held for its hash changes is written on a person's acceptance: a hand act.
|
|
const EventHashesAccepted = "hashes-accepted"
|
|
|
|
// HashHold is the value of KeyHashHold, and HashAccept of KeyHashAccept.
|
|
type HashHold struct {
|
|
Digest string `json:"digest"`
|
|
Users []string `json:"users"`
|
|
At time.Time `json:"at"`
|
|
}
|
|
|
|
type HashAccept struct {
|
|
Digest string `json:"digest"`
|
|
Users []string `json:"users"`
|
|
By string `json:"by"`
|
|
Why string `json:"why"`
|
|
At time.Time `json:"at"`
|
|
}
|
|
|
|
// keepHashHold writes which list is held for its hash changes, when that is the hold.
|
|
func (p *Part) keepHashHold(res *Result, digest string) {
|
|
if p.hashHold == nil {
|
|
return
|
|
}
|
|
data, _ := json.Marshal(HashHold{Digest: digest, Users: p.hashHold, At: p.now()})
|
|
if raw, _, found, err := p.Store.Get(StateUserList, KeyHashHold); err == nil && found {
|
|
var was HashHold
|
|
if json.Unmarshal(raw, &was) == nil && was.Digest == digest && slices.Equal(was.Users, p.hashHold) {
|
|
return
|
|
}
|
|
}
|
|
if _, err := p.Store.Put(StateUserList, KeyHashHold, data); err != nil {
|
|
res.Errors = append(res.Errors, fmt.Sprintf("its own %s %s cannot be written: %v", StateUserList, KeyHashHold, err))
|
|
}
|
|
}
|
|
|
|
// accepted says a person accepted exactly these users' hash changes of the list of this digest.
|
|
func (p *Part) accepted(digest string, users []string) bool {
|
|
raw, _, found, err := p.Store.Get(StateUserList, KeyHashAccept)
|
|
if err != nil || !found {
|
|
return false
|
|
}
|
|
var a HashAccept
|
|
return json.Unmarshal(raw, &a) == nil && a.Digest == digest && slices.Equal(a.Users, users)
|
|
}
|
|
|
|
// acceptedWritten says, once, that the list a person accepted is written, and forgets the acceptance and the hold.
|
|
func (p *Part) acceptedWritten(res *Result, digest string) {
|
|
raw, _, found, err := p.Store.Get(StateUserList, KeyHashAccept)
|
|
if err != nil || !found {
|
|
return
|
|
}
|
|
var a HashAccept
|
|
if json.Unmarshal(raw, &a) != nil || a.Digest != digest {
|
|
return
|
|
}
|
|
p.say(res, EventHashesAccepted, map[string]any{"digest": digest, "users": a.Users, "by": a.By, "why": a.Why})
|
|
for _, k := range []string{KeyHashAccept, KeyHashHold} {
|
|
if err := p.Store.Delete(StateUserList, k); err != nil {
|
|
res.Errors = append(res.Errors, fmt.Sprintf("its own %s %s cannot be taken away: %v", StateUserList, k, err))
|
|
}
|
|
}
|
|
}
|
|
|
|
// AcceptHashes is the tool accept-hashes: a person accepts the hash changes of the list held for them, naming exactly its
|
|
// users, with why. Refused when no list is held for its hashes, or for another set of users. Bound to the held list's
|
|
// digest: a list composed otherwise since is held again.
|
|
func (p *Part) AcceptHashes(caller string, users []string, why string) (any, error) {
|
|
if strings.TrimSpace(why) == "" {
|
|
return nil, errors.New("accept-hashes says why (`why`)")
|
|
}
|
|
raw, _, found, err := p.Store.Get(StateUserList, KeyHashHold)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("the held list cannot be read: %w", err)
|
|
}
|
|
var h HashHold
|
|
if !found || json.Unmarshal(raw, &h) != nil || h.Digest == "" {
|
|
return nil, errors.New("no list is held for its users' hashes changing: there is nothing to accept")
|
|
}
|
|
named := slices.Sorted(slices.Values(users))
|
|
if !slices.Equal(named, h.Users) {
|
|
return nil, fmt.Errorf("the list held (%s) changes the hashes of %s: an acceptance names exactly those, and %s is not that",
|
|
short(h.Digest), strings.Join(h.Users, ", "), strings.Join(named, ", "))
|
|
}
|
|
data, _ := json.Marshal(HashAccept{Digest: h.Digest, Users: h.Users, By: someoneOr(caller), Why: strings.TrimSpace(why), At: p.now()})
|
|
rev, err := p.Store.Put(StateUserList, KeyHashAccept, data)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("the acceptance could not be written: %w", err)
|
|
}
|
|
return map[string]any{"accepted": rev, "digest": h.Digest, "users": h.Users, "says": "the part's process writes the held " +
|
|
"list at its next pass, once, unless another hold stands or the list composed then is another; it says hashes-accepted"}, nil
|
|
}
|
|
|
|
func someoneOr(caller string) string {
|
|
if caller == "" {
|
|
return "a caller the runtime did not prove"
|
|
}
|
|
return caller
|
|
}
|