Files
mesh-delivery/modules/mesh-grants/internal/grants/sealed.go
T

513 lines
21 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/buscompose"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// **The list the bus node loads from take-over** (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, the users left out for want of a hash, and which list the bus node follows (the switch, clause 8). The object
// and the key are written in shadow too: the bus node reads the key and follows the controller's list until the key says
// FollowGrants, so a take-over has a list to load at once. 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, and the users left out
// for want of one. Pure.
func ComposeList(r buscompose.Records, hashes map[string]HeldHash) ([]byte, int, []string, error) {
principals, err := buscompose.Users(r)
if err != nil {
return nil, 0, nil, err
}
held := map[string]string{}
for user, h := range hashes {
held[user] = h.Hash
}
filled, missing := buscompose.WithPasswords(principals, held)
list, err := buscompose.ComposeAccounts(filled)
if err != nil {
return nil, 0, nil, err
}
slices.Sort(missing)
return []byte(list), len(filled), missing, 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
}
// followOf is which list the switch says the bus node follows.
func followOf(state string) string {
if state == TookOver {
return delivery.FollowGrants
}
return delivery.FollowController
}
// 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 []string, in Inputs, bus []string, lost []string) {
cur, _, had := p.Sealed()
next := cur
next.Follow = followOf(p.SwitchState())
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; the follow alone may change here, from a tool's view of the switch, and is not.
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 next.Follow == delivery.FollowGrants && cur.Digest != digest && p.holdsBack(in, leftOut, bus, lost, digest) != "":
// After take-over 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, 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.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 after take-over.
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
}
// syncFollow writes the switch into the key at once, so the bus node turns on the operator's yes, not at the next pass.
func (p *Part) syncFollow(state string) error {
cur, _, had := p.Sealed()
if had && cur.Follow == followOf(state) {
return nil
}
if !had {
cur = delivery.GrantsUserList{}
}
cur.Follow, cur.At = followOf(state), p.now()
data, err := delivery.EncodeGrantsUserList(cur)
if err != nil {
return err
}
_, err = p.Store.Put(StateUserList, KeySealed, data)
return err
}
// Effect is where the switch stands on the bus node: "switched" only once the bus node's loaded list is the grants part's
// written one (its source the part's key, its digest the written digest); otherwise what it waits for.
func (p *Part) Effect(state string, loaded *delivery.UserListWritten) string {
g, _, had := p.Sealed()
fromGrants := loaded != nil && loaded.Source == delivery.GrantsUserListSource
switch {
case state == TookOver && had && fromGrants && g.Digest != "" && loaded.Digest == g.Digest && loaded.Error == "":
return "switched: the bus node loaded the grants part's list " + short(g.Digest) + " (revision " + fmt.Sprint(g.Revision) + ")"
case state == TookOver && !had || state == TookOver && g.Digest == "":
return "recorded, not in effect: the grants part has written no sealed list for the bus node yet"
case state == TookOver && loaded == nil:
return "recorded, not in effect: the bus module's have names no loaded list"
case state == TookOver && loaded.Unwatched != "" && !fromGrants:
return "recorded, not in effect: the bus node does not watch the grants part's key: " + loaded.Unwatched
case state == TookOver && loaded.Error != "":
return "recorded, not in effect: the bus node could not load the grants part's list: " + loaded.Error
case state == TookOver:
return "recorded, not in effect yet: the bus node loaded " + short(loaded.Digest) + " from " + loaded.Source + ", the grants part wrote " + short(g.Digest)
case fromGrants:
return "handed back, not in effect yet: the bus node still follows the grants part's list"
}
return "shadow: the bus node follows the controller's list"
}
func short(d string) string {
d = strings.TrimPrefix(d, "sha256:")
if len(d) > 12 {
d = d[:12]
}
return d
}
// followReasons is what a take-over waits for on the sealed list itself: a list written for the bus node, of the list
// composed now, and the loaded list granting the bus node its read (the controller's composer carries ADR 0547 clause 7's
// grants from the operator's second exception; until its delivery the bus node could not read the first list).
func (p *Part) followReasons(res Result, in Inputs) []string {
var out []string
g, _, had := p.Sealed()
switch {
case !had || g.Digest == "":
why := "no list is written"
if res.Sealed != nil && res.Sealed.Why != "" {
why = res.Sealed.Why
}
out = append(out, "the grants part has written no sealed list for the bus node: "+why)
case in.Loaded == nil || in.Loaded.Key == "" || g.SealedTo != delivery.SealingKeyID(in.Loaded.Key):
out = append(out, "the sealed list is not sealed to the key the bus node says now")
}
if in.BusNode != "" {
node := "node." + in.BusNode
for _, d := range res.UserList.Compare.Differ {
if d.User == node && slices.ContainsFunc(d.PublishAdded, func(s string) bool { return strings.Contains(s, delivery.GrantsUserListBucket) }) {
out = append(out, "the loaded list does not grant "+node+" reading the grants part's sealed list (ADR 0547 clause 7): "+
"the controller's composer carries it from its delivery of the operator's second exception")
}
}
}
return out
}
// KeyWrittenUsers is the key of the state user-list naming the users of the list the key `sealed` names: what a list
// composed after take-over 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, 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*"([^"]+)"`)
// 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 after take-over: 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, 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"
}
// 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 && !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
}