Trust the vault's key alone and write the grants part's user list alone

The controller ended by its unassignment and its lease is never handed, so
the engine's lease gate kept trusting the controller's key and the bus node
kept the controller's list as a fallback nobody writes (issue 1126, A6).
The engine now trusts no controller signature, its hand-over line names the
node seat's hand-over, and the user-list follower writes only the grants
part's list: a key unreadable or deleted keeps the list loaded, and with no
key heard nothing is written. Compositions in the node's bucket name the
file's place alone. The guard no longer asks for the controller's user.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
This commit is contained in:
2026-11-02 10:37:27 +01:00
parent 14a12cc7b6
commit 5cfc4ff4a7
10 changed files with 161 additions and 525 deletions
+14 -21
View File
@@ -12,7 +12,6 @@ import (
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/buscompose"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/overlay"
@@ -41,21 +40,24 @@ func TestTheAppliedAuthorityIsKeptForTheRecord(t *testing.T) {
t.Errorf("the record's keys read the authority %q", got.Authority)
}
// L3 s6: before the vault's signers are read the node trusts the controller's key alone; once read, a message the
// vault signed is accepted and the record names both; once the lease is handed away the controller's key is refused
// and the record names the vault's alone. Each change tells the record's writer.
// Issue 1126, A6 (ADR 0550 §2; the controller ends by its unassignment): a node enrolled with the controller's key
// trusts it never, whatever the lease says; before the vault's signers are read it trusts nothing, once read the
// vault's key alone, and the record names the vault's alone. Each change tells the record's writer.
controllerPub, controller, _ := ed25519.GenerateKey(nil)
vaultPub, vault, _ := ed25519.GenerateKey(nil)
trust := newMeshTrust(controllerPub)
trust := newMeshTrust()
told := 0
trust.onChange(func() { told++ })
keys := recordKeys(state, trust.Trusts)
ctrlFP, vaultFP := delivery.SignerFingerprint(controllerPub), delivery.SignerFingerprint(vaultPub)
if got := keys().Trusts; !delivery.TrustsKey(got, delivery.SignerController, ctrlFP) || delivery.TrustsKey(got, delivery.SignerVault, vaultFP) {
t.Errorf("before the signers are read the record trusts %v", got)
}
enrol, _ := delivery.SignContext(delivery.KindEnrolment)
msg := []byte("a reply")
if got := keys().Trusts; len(got) != 0 {
t.Errorf("before the signers are read the record trusts %v", got)
}
if _, err := trust.Verify(delivery.KindEnrolment, msg, delivery.SignMessage(controller, enrol, msg)); err == nil {
t.Error("a controller-signed message is accepted")
}
if _, err := trust.Verify(delivery.KindEnrolment, msg, delivery.SignMessage(vault, enrol, msg)); err == nil {
t.Error("a vault-signed message is accepted before the vault's key is read")
}
@@ -68,22 +70,13 @@ func TestTheAppliedAuthorityIsKeptForTheRecord(t *testing.T) {
if s, err := trust.Verify(delivery.KindEnrolment, msg, delivery.SignMessage(vault, enrol, msg)); err != nil || s.Name != delivery.SignerVault {
t.Errorf("a vault-signed message on a node that read signers: %+v, %v", s, err)
}
if got := keys().Trusts; !delivery.TrustsKey(got, delivery.SignerController, ctrlFP) || !delivery.TrustsKey(got, delivery.SignerVault, vaultFP) ||
if _, err := trust.Verify(delivery.KindEnrolment, msg, delivery.SignMessage(controller, enrol, msg)); err == nil {
t.Error("a controller-signed message is accepted once the vault's key is read")
}
if got := keys().Trusts; delivery.TrustsKey(got, delivery.SignerController, ctrlFP) || !delivery.TrustsKey(got, delivery.SignerVault, vaultFP) ||
trust.VaultAuthority() != vaultCA || told != 2 {
t.Errorf("after the signers are read the record trusts %v (told %d)", got, told)
}
handed, _ := json.Marshal(buscompose.LeaseHolder{HandedTo: "mesh-vault", HandedBy: "person.operator"})
trust.heardLease(handed, true, nil)
if _, err := trust.Verify(delivery.KindEnrolment, msg, delivery.SignMessage(controller, enrol, msg)); err == nil || trust.Controller() != nil {
t.Error("after the lease is handed away the controller's key is still trusted")
}
if got := keys().Trusts; delivery.TrustsKey(got, delivery.SignerController, ctrlFP) || !delivery.TrustsKey(got, delivery.SignerVault, vaultFP) || told != 3 {
t.Errorf("after the lease is handed away the record trusts %v (told %d)", got, told)
}
trust.heardLease(handed, true, nil)
if told != 3 {
t.Error("a lease heard again unchanged wrote the record again")
}
// A signers' key naming another fingerprint is not trusted.
bad, _ := json.Marshal(delivery.SignerKey{Key: base64.StdEncoding.EncodeToString(controllerPub), Fingerprint: vaultFP})
trust.heardSigner(delivery.SignersKeyKey, bad, false, func(string) {})
+5 -14
View File
@@ -64,27 +64,18 @@ func foundDirectories(known store.State) []store.Applied {
return out
}
// handOverLine is the operator's line that hands a directory to the mesh, with this node's name when it is
// known (novox/hq ADR 0272): typed on the control-node, from the operator's own account. The directory's
// path is the resource's target beside the reason, never in it: the controller says a reason on the
// operator's channel, which withholds a path (ADR 0234 §6), and keeps the target as evidence.
//
// From the lease write, heard as the engine hears it (meshTrust), the line has no answerer: the way out is the node
// seat's hand-over with the directory's path, which asks the operator on their channel (novox/hq ADR 0563 §5).
// handOverLine is the way out of a directory used as found, with this node's name when it is known: the node seat's
// hand-over with the directory's path, which asks the operator on their channel (novox/hq ADR 0563 §5). The controller
// that answered `nox node hand-over` ended by its unassignment (issue 1126), so that line has no answerer. The path is
// the resource's target beside the reason, never in it: the operator's channel withholds a path (ADR 0234 §6).
func handOverLine() string {
node, _ := foundNode.Load().(string)
if node == "" {
node = "<node>"
}
if t := foundTrust.Load(); t != nil && t.HandedAway() {
return fmt.Sprintf("`%s/node-engine.hand-over` with the directory's path, then your yes on your channel", node)
}
return fmt.Sprintf("`nox node hand-over %s <directory>` on the control-node", node)
return fmt.Sprintf("`%s/node-engine.hand-over` with the directory's path, then your yes on your channel", node)
}
// foundTrust is what the engine trusts, whose lease decides the way out a directory used as found names.
var foundTrust atomic.Pointer[meshTrust]
// withFoundDirectories adds to a statement every directory used as found, as its module's resource of kind
// directory, unhealthy: the reason names its owner and mode as they are now, what was declared, and the line
// that hands it over. The path is the target, which the controller keeps as evidence.
+6 -20
View File
@@ -15,7 +15,7 @@ import (
// Every directory used as found is its module's resource of kind directory, unhealthy, naming its owner, on
// every statement (novox/hq issue 339): the controller raises that as the module's condition. The way out it
// names is the operator's line, with this node's name (novox/hq ADR 0272, issue 356).
// names is the node seat's hand-over, with this node's name (novox/hq ADR 0563 §5).
func TestAFoundDirectoryIsStatedUnhealthy(t *testing.T) {
dir := t.TempDir()
known := store.State{Resources: []store.Applied{
@@ -35,29 +35,15 @@ func TestAFoundDirectoryIsStatedUnhealthy(t *testing.T) {
t.Fatalf("stated as: %+v", r)
}
// The path is the target, kept as evidence, and never in the reason: the operator's channel withholds a path
// (novox/hq ADR 0234 §6), and the controller says the reason there.
if !strings.Contains(r.Reason, "`nox node hand-over laptop <directory>` on the control-node") ||
// (novox/hq ADR 0234 §6). The way out is the node seat's hand-over (ADR 0563 §5), never the controller's terminal line,
// which nothing answers since the controller ended (issue 1126).
if !strings.Contains(r.Reason, "`laptop/node-engine.hand-over`") || strings.Contains(r.Reason, "nox node hand-over") ||
strings.Contains(r.Reason, "mesh-host") || strings.Contains(r.Reason, dir) {
t.Fatalf("the way out is not the operator's line, or carries the path: %q", r.Reason)
t.Fatalf("the way out is not the node seat's hand-over, or carries the path: %q", r.Reason)
}
// ADR 0563 §5: from the lease write the remedy is the node seat's hand-over, answered on the operator's channel, and
// no longer the controller's terminal line; the lease is the one the engine hears.
handed := newMeshTrust(nil)
handed.handedAway = true
foundTrust.Store(handed)
if r := withFoundDirectories(&link.Health{}, foundDirectories(known)).Resources[0]; !strings.Contains(r.Reason,
"`laptop/node-engine.hand-over`") || strings.Contains(r.Reason, "nox node hand-over") || strings.Contains(r.Reason, dir) {
t.Fatalf("after the lease write the way out is %q", r.Reason)
}
foundTrust.Store(newMeshTrust(nil))
if r := withFoundDirectories(&link.Health{}, foundDirectories(known)).Resources[0]; !strings.Contains(r.Reason,
"`nox node hand-over laptop <directory>`") {
t.Fatalf("while the controller holds its lease the way out is %q", r.Reason)
}
foundTrust.Store((*meshTrust)(nil))
foundNode.Store("")
if r := withFoundDirectories(&link.Health{}, foundDirectories(known)).Resources[0]; !strings.Contains(r.Reason,
"`nox node hand-over <node> <directory>`") {
"`<node>/node-engine.hand-over`") {
t.Fatalf("before enrolment the line names a placeholder: %q", r.Reason)
}
}
+6 -7
View File
@@ -1113,17 +1113,16 @@ func runLink(ctx context.Context, opts options) error {
// the bus, answer what the controller asks of this machine, and say its facts: when a link opens, and when the
// controller asks.
proof := &proving{statePath: opts.state, version: runningVersion(), say: say}
// What this engine trusts a mesh signature from: the controller's key until its lease is handed away, and the vault's
// once read from its signers (novox/hq ADR 0550 §2).
trust := newMeshTrust(mine.Membership.Signer)
// And the way out a directory used as found names follows the lease it hears (novox/hq ADR 0563 §5).
foundTrust.Store(trust)
// What this engine trusts a mesh signature from: the vault's key alone, once read from its signers (novox/hq ADR 0550
// §2; issue 1126: the controller's key never, the controller ended by its unassignment).
trust := newMeshTrust()
queue := &link.Session{
Membership: membership,
// And the public halves and certificate its record says (novox/hq ADR 0555 §3), read at each facts report.
Keys: recordKeys(opts.state, trust.Trusts),
// And the controller's key, read per signed ask: none once its lease is handed away (novox/hq ADR 0563 §7).
ControllerKey: trust.Controller,
// And no controller's key: every ask the controller would sign is refused, never verified with the membership's
// signer (novox/hq ADR 0563 §7; issue 1126, the controller ended by its unassignment).
ControllerKey: noControllerKey,
// And the first have this build writes is what proves it to the launcher (ADR 0539 §9, ADR 0227 §8).
HaveWritten: proof.haveWritten,
// And the operator's hand-over of a directory used as found, carried by the controller from the
+11 -10
View File
@@ -380,10 +380,11 @@ func resourcesOf(cs []delivery.Composed) []declaration.Resource {
// controller's key the node was enrolled with (novox/hq ADR 0539 §3, §11); a node adopted later has it at its adoption.
const operatorKeysFile = "operator-keys"
// trustedKeys are the keys a composition may be signed by: the controller's, which the node was enrolled with, while the
// controller holds its lease (ADR 0550 §2: trusted no more once it is handed away), and the operator's, read from
// operatorKeysFile each time it is asked, so a key that arrives while the agent runs is trusted at the next composition.
// A file that cannot be read trusts the controller's alone, and says so.
// trustedKeys are the keys a composition in the node's composition bucket may be signed by: the one given (none for the
// agent; the enrolment's signer for the user-list follower, which reads only where the bus's user-list file is from
// such a composition, never its list) and the operator's, read from operatorKeysFile each time it is asked, so a key
// that arrives while the agent runs is trusted at the next composition. A file that cannot be read trusts the one given
// alone, and says so.
func trustedKeys(opts options, controllerNow func() ed25519.PublicKey) func() []ed25519.PublicKey {
return func() []ed25519.PublicKey {
keys := []ed25519.PublicKey{}
@@ -509,9 +510,11 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus
}
}
}
// The bus's user list, written alone from the newest composition of the bus module (ADR 0547 §8): on every
// node, and a node whose compositions carry no list writes nothing.
users := userListFollower(opts, session, composedBucket, trustedKeys(opts, trust.Controller), say, func() {
// The bus's user list, the grants part's alone (ADR 0547 §8, clause 11; issue 1126), written into the file the
// bus module's composition in the node's bucket declares: on every node, and a node whose compositions carry no
// list writes nothing. Those compositions were signed with the enrolment's signer and nobody writes them now; they
// are read for the file's place alone, never for a list, until the place is read elsewhere (milestone B17).
users := userListFollower(opts, session, composedBucket, trustedKeys(opts, func() ed25519.PublicKey { return signer }), say, func() {
// This engine wrote the list, so the bus reloads its accounts and answers no JetStream request for a few
// seconds: what failed in them is tried again once that is over, not at its next wait.
time.AfterFunc(reloadSettles, func() {
@@ -520,8 +523,6 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus
}
})
})
// A vault key heard (or heard again changed) looks at the list again, so its hashes are handed (ADR 0547 §9).
vaultKey.following(users.Again)
go func() {
if err := users.Run(linked); err != nil && linked.Err() == nil {
say("the bus's user list is not followed on this link: " + err.Error())
@@ -551,7 +552,7 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus
}
return out, nil
},
Trusted: trustedKeys(opts, trust.Controller),
Trusted: trustedKeys(opts, noControllerKey),
Backup: backupNode(session),
// What the filling refused before this apply may be named now: the provisioner's client is told to fill
// again (ace, 2026-10-25 21:17).
+1 -1
View File
@@ -160,7 +160,7 @@ func (b *itBed) start(trustOperator bool) (stop func()) {
go func() {
defer close(done)
controller := b.controll.Public().(ed25519.PublicKey)
nodeAgent(ctx, b.opts, controller, newMeshTrust(controller), b.session, nil, b.say, func() {},
nodeAgent(ctx, b.opts, controller, newMeshTrust(), b.session, nil, b.say, func() {},
&agentNow{}, func(context.Context, *agent.Agent) {})
}()
return func() { cancel(); <-done }
+20 -80
View File
@@ -13,7 +13,6 @@ import (
"sync"
"time"
"git.novox.be/novox/mesh-sdk/go/buscompose"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/overlay"
@@ -21,30 +20,25 @@ import (
"github.com/novox/mesh-host/internal/link"
)
// **What this engine trusts a mesh signature from** (novox/hq ADR 0550 §2; design 47's L3 item d). Two signers: the
// controller's key, which the node was enrolled with (its membership's signer), and the vault's key, read from the vault's
// state `signers`, which only the vault may write: the bus grants are its anchor, as the wants' and the placement's (the
// lead's design limit, 2026-10-28). The controller's key is trusted until its lease is handed away, heard on the lease key
// (buscompose.LeaseHandedAway) and never on a clock; from then the vault's alone. What it trusts now the node states in its
// record (NodeRecord.Trusts), so the gate before the lease write reads every node's from the bus.
// **What this engine trusts a mesh signature from** (novox/hq ADR 0550 §2; design 47's L3 item d; issue 1126): the
// vault's key alone, read from the vault's state `signers`, which only the vault may write: the bus grants are its
// anchor, as the wants' and the placement's. The controller's key, which the node was enrolled with (its membership's
// signer), is trusted never: the controller ended by its unassignment (the operator, 2026-11-02) and its lease is never
// handed, so no gate waits on it. What it trusts now the node states in its record (NodeRecord.Trusts).
type meshTrust struct {
mu sync.Mutex
controller ed25519.PublicKey
handedAway bool
vault ed25519.PublicKey
authority string
mu sync.Mutex
vault ed25519.PublicKey
authority string
// changed is told when what it trusts changed, so the record is written again.
changed func()
}
// newMeshTrust trusts the controller's key alone until the vault's is read.
func newMeshTrust(controller ed25519.PublicKey) *meshTrust {
t := &meshTrust{}
if len(controller) == ed25519.PublicKeySize {
t.controller = controller
}
return t
}
// newMeshTrust trusts nothing until the vault's key is read.
func newMeshTrust() *meshTrust { return &meshTrust{} }
// noControllerKey is the controller's key as this engine trusts it: none. A link told it refuses every ask signed as
// the controller's, where a link told nothing would verify it with the membership's signer (link.HandOverCheck).
func noControllerKey() ed25519.PublicKey { return nil }
// onChange names who is told when what it trusts changed.
func (t *meshTrust) onChange(f func()) {
@@ -53,38 +47,11 @@ func (t *meshTrust) onChange(f func()) {
t.mu.Unlock()
}
// Controller is the controller's key while the controller holds its lease, and nil once the lease is handed away.
func (t *meshTrust) Controller() ed25519.PublicKey {
if t == nil {
return nil
}
t.mu.Lock()
defer t.mu.Unlock()
if t.handedAway {
return nil
}
return t.controller
}
// HandedAway says the controller's lease was handed away, as heard on the lease key.
func (t *meshTrust) HandedAway() bool {
if t == nil {
return false
}
t.mu.Lock()
defer t.mu.Unlock()
return t.handedAway
}
// Signers are the keys a mesh signature is verified against now: the controller's while it holds the lease, and the
// vault's once read.
// Signers are the keys a mesh signature is verified against now: the vault's once read, and none before.
func (t *meshTrust) Signers() []delivery.Signer {
t.mu.Lock()
defer t.mu.Unlock()
var out []delivery.Signer
if t.controller != nil && !t.handedAway {
out = append(out, delivery.Signer{Name: delivery.SignerController, Key: t.controller})
}
if t.vault != nil {
out = append(out, delivery.Signer{Name: delivery.SignerVault, Key: t.vault})
}
@@ -165,21 +132,11 @@ func (t *meshTrust) heardSigner(key string, value []byte, deleted bool, say link
}
}
// heardLease takes the controller's lease key as read: once handed away to a holder that is no controller, the
// controller's key is trusted no more. A lease that cannot be read changes nothing.
func (t *meshTrust) heardLease(raw []byte, found bool, readErr error) {
if readErr != nil {
return
}
handed, _ := buscompose.LeaseHandedAway(raw, found, nil)
t.change(func() { t.handedAway = handed })
}
// trustFollowAgain bounds the wait before a watch that could not be made is tried again on the same link: the vault may
// not serve `sign` yet, and until it does this node is not granted the reads.
var trustFollowAgain = 2 * time.Minute
// followTrust follows the vault's `signers` and the controller's lease on every link, as watches (never a poll), for as
// followTrust follows the vault's `signers` on every link, as watches (never a poll), for as
// long as ctx lasts. A watch that cannot be made is said once per reason and tried again.
func followTrust(ctx context.Context, session *link.Session, trust *meshTrust, say link.Announce) {
said := ""
@@ -198,33 +155,16 @@ func followTrust(ctx context.Context, session *link.Session, trust *meshTrust, s
}
watching, stop := context.WithCancel(linked)
signers, serr := session.KVWatch(watching, delivery.SignersBucket, ">")
lease, lerr := session.KVWatch(watching, buscompose.LeaseBucket, buscompose.ControllerLeaseKey)
if serr != nil || lerr != nil {
if serr != nil {
stop()
sayOnce(fmt.Sprintf("what this node trusts a mesh signature from is not followed yet (the vault's signers: %v; "+
"the controller's lease: %v): it trusts what it last read, and looks again in %s", serr, lerr, trustFollowAgain))
signers, lease = nil, nil
sayOnce(fmt.Sprintf("what this node trusts a mesh signature from is not followed yet (the vault's signers: %v): "+
"it trusts what it last read, and looks again in %s", serr, trustFollowAgain))
} else {
sayOnce("")
}
for signers != nil || lease != nil {
select {
case u, ok := <-signers:
if !ok {
signers = nil
continue
}
for u := range signers {
if !u.Caught {
trust.heardSigner(u.Key, u.Value, u.Deleted, say)
}
case u, ok := <-lease:
if !ok {
lease = nil
continue
}
if !u.Caught {
trust.heardLease(u.Value, !u.Deleted && len(u.Value) > 0, nil)
}
}
}
stop()
+4 -24
View File
@@ -20,8 +20,8 @@ import (
"github.com/novox/mesh-host/internal/userlist"
)
// The bridge of novox/hq ADR 0547 §8, wired: the bus node's engine writes the bus's user list alone from the newest
// composition of the bus module, under the apply's two locks as a filled file is written (filling.go), and says what
// The bridge of novox/hq ADR 0547 §8, wired: the bus node's engine writes the grants part's user list alone into the file
// the bus module's composition declares, under the apply's two locks as a filled file is written (filling.go), and says what
// it wrote in the bus module's have.
// userListFollower is the follower of the bus's user list for one link: the node's composition bucket, the keys the
@@ -32,10 +32,7 @@ func userListFollower(opts options, session *link.Session, composedBucket string
Node: session.Membership.Node,
Composed: bucketWatch{session, composedBucket},
Trusted: trusted,
Resolve: func(ctx context.Context, raw json.RawMessage) ([]byte, error) {
return resolveUserList(session.WithLink(ctx), raw)
},
OnDisk: userListOnDisk,
OnDisk: userListOnDisk,
Write: func(ctx context.Context, module string, raw json.RawMessage, body []byte) (string, bool, error) {
digest, changed, err := writeUserListAlone(session.WithLink(ctx), opts, raw, body, say)
if changed && wrote != nil {
@@ -75,10 +72,7 @@ func userListFollower(opts options, session *link.Session, composedBucket string
Kept: func() ([]byte, uint64) { return keptGrantsKey(opts) },
Keep: func(value []byte, rev uint64) { keepGrantsKey(opts, value, rev, say) },
Backoff: link.Backoff,
// The vault's key from its answer to node-key: the loaded list's hashes are handed sealed to it (ADR 0547
// clause 9).
VaultKey: vaultKey.get,
Say: announceOr(say),
Say: announceOr(say),
})
}
@@ -98,20 +92,6 @@ func userListFile(raw []byte) (*declaration.File, error) {
return f, nil
}
// resolveUserList is the list a user-list resource holds: its content, or the object its digest names in this node's
// file bucket, fetched and verified (apply.ResolveFileReferences, ADR 0486 §1).
func resolveUserList(ctx context.Context, raw []byte) ([]byte, error) {
f, err := userListFile(raw)
if err != nil {
return nil, err
}
d := &declaration.Declaration{Version: declaration.Version, Resources: []declaration.Resource{f}}
if err := apply.ResolveFileReferences(ctx, d, link.FilesFrom(ctx)); err != nil {
return nil, fmt.Errorf("the user list could not be had by its digest: %w", err)
}
return []byte(d.Resources[0].(*declaration.File).Content), nil
}
// userListOnDisk is what the user list's file holds on this machine now; nil when there is none.
func userListOnDisk(raw json.RawMessage) ([]byte, error) {
f, err := userListFile(raw)
+58 -220
View File
@@ -1,37 +1,25 @@
// Package userlist is the bridge of novox/hq ADR 0547 §8: the node agent of the node holding mesh-broker applies the
// bus's user list from the newest composition of the bus module the controller writes for its node, alone and with no
// want, so a new account is in force on the bus without anybody's hand.
// Package userlist is the bridge of novox/hq ADR 0547 §8: the node agent of the node holding mesh-broker writes the bus's
// user list alone, with no want, so a new account is in force on the bus without anybody's hand.
//
// **Why it exists.** The list reached the bus only through a want of the bus module, and ADR 0539 §15 bars the assigner
// from writing one: on 2026-10-25 it stood from 11:23 to 20:50 and every new account was refused until the operator put
// it back by hand (issue 1945). The bus module's entrypoint already reloads the server in place when the file changes;
// what was missing is a writer of the file that does not wait on a want.
//
// **It watches, it never polls.** It follows the node's composition bucket. Each composition is verified as the agent
// verifies it (signed by the controller's or the operator's key) before anything of it is read. Of those that carry the
// bus's user list (a file resource `bus-users`), the newest by revision is the source; its list is written alone
// (apply.WriteAlone) when it differs from the file on the machine, and what was written is reported in the bus
// module's have. It looks again after every apply of the bus module, since an apply of an older composition writes an
// older list, and when asked (Again).
// **The grants part's list alone** (ADR 0547 clauses 7, 8 and 11, ADR 0571 clause 8; issue 1126: the controller ended by
// its unassignment and its list is written by nobody). It watches the grants part's one key `user-list.sealed`: the list
// is the object the key names, read by its name, opened with this node's sealing key and checked against the key's
// digest, and written alone (mode 0600, apply.WriteAlone) only when the file's digest differs. A key that cannot be read
// or is deleted is said, the key last heard stands, and the list loaded stays; with no key ever heard nothing is
// written. The list a composition carries is never written.
//
// It reads no want and writes none, moves no fingerprint, and touches nothing of the bus module but its user list.
//
// **It hands the loaded list's hashes to the vault** (ADR 0547 clause 9, issue 1945): once the vault's key is known
// (VaultKey), the have of the bus module carries the bcrypt hash of every user of the list on the machine, sealed to
// that key (delivery.AccountsHanded), so the vault adopts the accounts it holds no value for. It is sealed again only
// when the list on the machine or the vault's key changes.
//
// **From take-over it follows the grants part's list instead** (ADR 0547 clauses 7 and 8, issue 1945). On the node whose
// compositions carry the list, it also watches the grants part's one key `user-list.sealed`. While that key says
// FollowGrants, the list is the object the key names, read by its name, opened with this node's sealing key and checked
// against the key's digest, and written to the same file (mode 0600) only when the file's digest differs; otherwise the
// controller's compositions are the source, as before. Never both. The have says which it follows and this node's key,
// **Where it writes.** The file is the user-list resource (`bus-users`) of the bus module's composition in the node's
// composition bucket, verified as the agent verified it before anything of it is read: what tells the bus node it is
// one, and the file's place. The have of that module says what was written, which list it follows and this node's key,
// which the grants part seals to.
//
// **From the lease write it never follows the controller's list again** (ADR 0571 clause 8, ADR 0547 clause 11). The
// grants part's key then says the lease is handed (delivery.GrantsUserList.LeaseHanded): a key that later cannot be read or
// is deleted is said, the key last heard stands, and the list loaded stays; no list is written from a composition of the
// controller.
// **It watches, it never polls**, and looks again after every apply of the bus module and when asked (Again). It reads no
// want and writes none, moves no fingerprint, and touches nothing of the bus module but its user list.
package userlist
import (
@@ -42,7 +30,6 @@ import (
"encoding/json"
"fmt"
"regexp"
"slices"
"strings"
"sync"
"time"
@@ -64,11 +51,8 @@ type Written = delivery.UserListWritten
type Env struct {
Node string
Composed agent.Watcher
// Trusted is the keys a composition may be signed by, as the agent trusts them.
// Trusted is the keys a composition may be signed by: one any other key signed declares no file.
Trusted func() []ed25519.PublicKey
// Resolve is the list a user-list resource holds: its content, or the object its digest names, fetched and
// verified (the controller sends a file over 64 KiB by digest, ADR 0486 §1, and the live list is over it).
Resolve func(ctx context.Context, resource json.RawMessage) ([]byte, error)
// OnDisk is what the resource's file holds on the machine now; nil when there is none.
OnDisk func(resource json.RawMessage) ([]byte, error)
// Write writes the user list's file resource of a module's composition alone, with body as its content, when it
@@ -77,7 +61,7 @@ type Env struct {
Write func(ctx context.Context, module string, resource json.RawMessage, body []byte) (digest string, changed bool, err error)
// Report says what was written, in the module's have.
Report func(ctx context.Context, module string, w Written)
// Grants watches the grants part's state (its key delivery.GrantsUserListKey); nil follows the controller's alone.
// Grants watches the grants part's state (its key delivery.GrantsUserListKey); nil writes nothing.
Grants agent.Watcher
// Fetch reads an object of the grants part's bucket by its name; Open opens a box sealed to this node's key; Key is
// this node's sealing key's public half (SPKI PEM), said in the have so the grants part seals to it ("" unknown).
@@ -90,11 +74,8 @@ type Env struct {
Kept func() ([]byte, uint64)
Keep func(value []byte, rev uint64)
Backoff func(try int) time.Duration
// VaultKey is the vault's own public key (SPKI PEM) as its answer to node-key gave it, "" while none is known: the
// key the loaded list's hashes are handed sealed to. Nil hands nothing.
VaultKey func() string
Now func() time.Time
Say func(string)
Now func() time.Time
Say func(string)
}
type source struct {
@@ -117,11 +98,9 @@ type Follower struct {
state Written
mod string
said map[string]bool
// grants is the grants part's key as last heard (nil when none), and following the source last taken: FollowGrants
// or FollowController.
// grants is the grants part's key as last heard (nil when none).
grants *delivery.GrantsUserList
grantsRev uint64
following string
// unwatched is why the grants part's key is not watched now; empty while it is, or where it need not be.
unwatched string
}
@@ -174,8 +153,8 @@ func (f *Follower) Run(ctx context.Context) error {
caught := false
// The grants part's key, on the node whose compositions carry the list: the bus node alone is granted it (mesh-sdk
// buscompose.GrantsUserListFollows). Until its watch caught up nothing is written, so a new follower (one per link)
// never writes the controller's list after take-over before it heard the switch; a watch refused or ended is tried
// again on the backoff, and meanwhile the follow last heard (kept on the machine) holds.
// never writes a list older than the key; a watch refused or ended is tried again on the backoff, and meanwhile the
// key last heard (kept on the machine) holds.
var grants <-chan agent.Update
var retry <-chan time.Time
opened, grantsCaught, tries := false, false, 0
@@ -254,34 +233,18 @@ func (f *Follower) busNode() bool {
return f.cur.key != "" || len(f.srcs) > 0
}
// takeGrants keeps the grants part's key as heard; a value it cannot read is said and leaves the controller's list
// followed (no list is followed that the node cannot check), unless the key last heard says the lease is handed: then
// the controller's list is never followed again (novox/hq ADR 0571 clause 8, ADR 0547 clause 11), the key as last heard
// stands, and the list loaded stays.
// takeGrants keeps the grants part's key as heard. A key deleted, or a value it cannot read, is said and changes nothing:
// the key as last heard stands and the list loaded stays (novox/hq ADR 0571 clause 8, ADR 0547 clause 11; issue 1126).
func (f *Follower) takeGrants(u agent.Update) {
f.mu.Lock()
defer f.mu.Unlock()
handed := f.grants != nil && f.grants.LeaseHanded
if u.Deleted {
if handed {
f.sayOnceLocked("the grants part's user-list key is deleted, and the lease is handed, so the list loaded is kept")
return
}
f.grantsRev = u.Revision
f.grants = nil
f.keep(nil, 0)
f.sayOnceLocked("the grants part's user-list key is deleted, so the list loaded is kept")
return
}
g, err := delivery.DecodeGrantsUserList(u.Value)
if err != nil {
if handed {
f.sayOnceLocked("the grants part's user-list key cannot be read, and the lease is handed, so the list loaded is kept: " + err.Error())
return
}
f.grantsRev = u.Revision
f.grants = nil
f.keep(nil, 0)
f.sayOnceLocked("the grants part's user-list key cannot be read, so the controller's list is followed: " + err.Error())
f.sayOnceLocked("the grants part's user-list key cannot be read, so the list loaded is kept: " + err.Error())
return
}
f.grantsRev = u.Revision
@@ -296,21 +259,6 @@ func (f *Follower) keep(value []byte, rev uint64) {
}
}
// Following is which list the follower takes now: the grants part's while its key says so or says the lease is handed, the
// controller's otherwise.
func (f *Follower) Following() string {
f.mu.Lock()
defer f.mu.Unlock()
return f.followingLocked()
}
func (f *Follower) followingLocked() string {
if f.grants != nil && (f.grants.Follow == delivery.FollowGrants || f.grants.LeaseHanded) {
return delivery.FollowGrants
}
return delivery.FollowController
}
func (f *Follower) unwatchedNow() string {
f.mu.Lock()
defer f.mu.Unlock()
@@ -369,103 +317,38 @@ func (f *Follower) take(u agent.Update) {
f.srcs[u.Key] = source{key: u.Key, module: module, rev: u.Revision, resource: raw, wanted: wanted}
}
// settle writes the newest source's list when it is newer than the one written, or the one written again when asked.
// A source never goes back: a key deleted, or a newer one refused, leaves the list written as it is, since an older
// list would take away accounts a newer one gave.
func (f *Follower) settle(ctx context.Context, again bool) {
f.mu.Lock()
follow := f.followingLocked()
if follow != f.following {
// The switch turned: the other source is written, though neither changed.
again = true
}
f.mu.Unlock()
if follow == delivery.FollowGrants {
f.settleGrants(ctx, again)
return
}
f.mu.Lock()
newest := f.cur
for _, s := range f.srcs {
if s.rev > newest.rev {
newest = s
}
}
if newest.key == "" || (newest.rev == f.cur.rev && f.written && !again) {
f.mu.Unlock()
return
}
f.cur = newest
at := fmt.Sprintf("%s@%d", newest.key, newest.rev)
f.mu.Unlock()
w := Written{Source: newest.key, Revision: newest.rev, Wanted: newest.wanted, At: f.env.Now().UTC(),
Follows: delivery.FollowController, Key: f.key(), Unwatched: f.unwatchedNow()}
var changed bool
body, err := f.env.Resolve(ctx, newest.resource)
if err == nil {
w.Users = Users(body)
err = f.guard(newest.resource, body)
if err != nil {
// Not written: what is on the machine stays, and the have says why, loudly.
if disk, derr := f.env.OnDisk(newest.resource); derr == nil && disk != nil {
w.Digest = Digest(disk)
}
}
}
var loaded []byte
if err == nil {
w.Digest, changed, err = f.env.Write(ctx, newest.module, newest.resource, body)
if err == nil {
loaded = body
}
} else if body != nil && f.env.OnDisk != nil {
// Refused: the list on the machine stays, and it is the one the bus has loaded.
loaded, _ = f.env.OnDisk(newest.resource)
}
if err != nil {
w.Error = err.Error()
}
f.mu.Lock()
prev := f.state
f.mu.Unlock()
w.Handed = f.hand(loaded, prev.Handed)
f.mu.Lock()
f.mod, f.state = newest.module, w
f.written = err == nil
f.following = delivery.FollowController
f.mu.Unlock()
if err != nil {
f.sayOnce(fmt.Sprintf("the bus's user list of %s could not be written: %v", at, err))
} else if changed {
f.env.Say(fmt.Sprintf("the bus's user list from %s is written alone (%d users, %s); the bus reloads it itself",
at, w.Users, short(w.Digest)))
}
// Reported when what it says changed: a look again that found the file as it was says nothing new.
if f.env.Report != nil && (changed || reportable(prev, w)) {
f.env.Report(ctx, newest.module, w)
}
}
// settle writes the grants part's list: when its key moved, or when asked.
func (f *Follower) settle(ctx context.Context, again bool) { f.settleGrants(ctx, again) }
// reportable says a have's user list says something new: a look again that found the file as it was says nothing.
func reportable(prev, w Written) bool {
return prev.Source != w.Source || prev.Revision != w.Revision || prev.Digest != w.Digest || prev.Error != w.Error ||
prev.Follows != w.Follows || prev.Key != w.Key || handedKey(prev.Handed) != handedKey(w.Handed) ||
prev.Unwatched != w.Unwatched
prev.Follows != w.Follows || prev.Key != w.Key || prev.Unwatched != w.Unwatched
}
// settleGrants writes the grants part's list, the object its key names, into the file the controller's composition of
// the bus module declares, made 0600 (ADR 0547 clause 7): when the key moved, when the switch turned, or when asked.
// settleGrants writes the grants part's list, the object its key names, into the file the bus module's composition
// declares, made 0600 (ADR 0547 clause 7): when the key moved, or when asked. With no key heard it writes nothing and
// says why in the have.
func (f *Follower) settleGrants(ctx context.Context, again bool) {
f.mu.Lock()
g, rev := *f.grants, f.grantsRev
var g delivery.GrantsUserList
heard := f.grants != nil
if heard {
g = *f.grants
}
rev := f.grantsRev
file := f.cur
for _, s := range f.srcs {
if s.rev > file.rev {
file = s
}
}
if !again && f.following == delivery.FollowGrants && f.state.Revision == rev && f.written {
if file.key == "" {
f.mu.Unlock()
return // no composition of this node declares the file: not the bus node
}
f.cur = file
if !again && f.state.Source != "" && f.state.Revision == rev && f.written {
f.mu.Unlock()
return
}
@@ -477,8 +360,10 @@ func (f *Follower) settleGrants(ctx context.Context, again bool) {
var changed bool
resource, rerr := mode0600(file.resource)
switch {
case file.key == "" || rerr != nil:
err = fmt.Errorf("no composition of this node declares the bus's user-list file, so the grants part's list has nowhere to be written")
case rerr != nil:
err = fmt.Errorf("the bus's user-list file this node's composition declares cannot be read: %w", rerr)
case !heard:
err = fmt.Errorf("the grants part's user-list key is not heard yet; the list on the machine is kept")
case g.Digest == "":
err = fmt.Errorf("the grants part's key names no list yet; the list on the machine is kept")
case f.env.Fetch == nil || f.env.Open == nil:
@@ -506,7 +391,7 @@ func (f *Follower) settleGrants(ctx context.Context, again bool) {
}
if err == nil {
w.Digest, changed, err = f.env.Write(ctx, file.module, resource, body)
} else if w.Digest == "" && file.key != "" && f.env.OnDisk != nil {
} else if w.Digest == "" && f.env.OnDisk != nil {
if disk, derr := f.env.OnDisk(resource); derr == nil && disk != nil {
w.Digest = Digest(disk)
}
@@ -516,11 +401,10 @@ func (f *Follower) settleGrants(ctx context.Context, again bool) {
}
f.mu.Lock()
prev := f.state
// The grants part's list is not handed to the vault: the handing is of what the controller's list loaded (clause 9).
// The grants part's list is not handed to the vault: a handing written before stands (clause 9).
w.Handed = prev.Handed
f.mod, f.state = file.module, w
f.written = err == nil
f.following = delivery.FollowGrants
f.mu.Unlock()
at := fmt.Sprintf("%s@%d", delivery.GrantsUserListSource, rev)
if err != nil {
@@ -529,21 +413,17 @@ func (f *Follower) settleGrants(ctx context.Context, again bool) {
f.env.Say(fmt.Sprintf("the bus's user list from the grants part (%s, revision %d) is written alone (%d users, %s); the bus reloads it itself",
at, g.Revision, w.Users, short(w.Digest)))
}
if f.env.Report != nil && file.module != "" && (changed || reportable(prev, w)) {
if f.env.Report != nil && (changed || reportable(prev, w)) {
f.env.Report(ctx, file.module, w)
}
}
// Hold is c as the apply may write it (C3 of the switch's review): while the grants part's list is followed, the bus
// module's composition from the controller carries the controller's list, so its user-list resource holds the list on the
// machine instead (made 0600), or is left out when the machine holds none. A list on the machine that cannot be read
// is an error: left out, the apply would remove the file as an orphan and the bus would refuse every connection at its
// next reload, so the bus module's apply waits instead. Any other composition, or the controller's list followed, is c
// unchanged.
// Hold is c as the apply may write it (C3 of the switch's review): the list a composition carries is never written, so
// its user-list resource holds the list on the machine instead (made 0600), or is left out when the machine holds none.
// A list on the machine that cannot be read is an error: left out, the apply would remove the file as an orphan and the
// bus would refuse every connection at its next reload, so the bus module's apply waits instead. A composition without
// a user list is c unchanged.
func (f *Follower) Hold(c delivery.Composed) (delivery.Composed, error) {
if f.Following() != delivery.FollowGrants {
return c, nil
}
out := c
out.Resources = nil
for _, raw := range c.Resources {
@@ -585,54 +465,12 @@ func mode0600(raw json.RawMessage) (json.RawMessage, error) {
return json.Marshal(r)
}
// hand is the handing of the list on the machine to the vault: the one before when it is of the same list and key, a
// new one sealed now otherwise, and none while the vault's key is not known or the list holds no hash.
func (f *Follower) hand(loaded []byte, before *delivery.AccountsHanded) *delivery.AccountsHanded {
if f.env.VaultKey == nil || loaded == nil {
return before
}
key := f.env.VaultKey()
if key == "" {
return before
}
from := Digest(loaded)
if before != nil && before.From == from && before.To == delivery.KeyFingerprint(key) {
return before
}
accounts := delivery.HandedAccountsOf(loaded)
if len(accounts) == 0 {
return nil
}
h, err := delivery.HandAccounts(accounts, key, from, f.env.Now())
if err != nil {
f.sayOnce(fmt.Sprintf("the loaded list's hashes are not handed to the vault: %v", err))
return before
}
f.env.Say(fmt.Sprintf("the hashes of the %d users of the bus's loaded list (%s) are handed to the vault, sealed to "+
"its key %s, in the bus module's have", h.Users, short(from), short(h.To)))
return &h
}
// handedKey is what a handing says that a report compares: its list and key.
func handedKey(h *delivery.AccountsHanded) string {
if h == nil {
return ""
}
return h.From + " " + h.To
}
// ControllerUser is the controller's own user in the list: a list without it would lock the controller out of the bus.
const ControllerUser = "controller"
// guard refuses a list that would lock the mesh out of its own bus: one without the controller's own user, or one that
// drops more than half the users the file on the machine holds. A list that shrinks that far is a composer that read
// half the mesh, not half the mesh gone; it waits for a person, and the bus keeps the list it has.
// guard refuses a list that would lock the mesh out of its own bus: one that drops more than half the users the file on
// the machine holds. A list that shrinks that far is a composer that read half the mesh, not half the mesh gone; it
// waits for a person, and the bus keeps the list it has. (A list without the controller's user is no longer refused:
// the controller ended, issue 1126.)
func (f *Follower) guard(resource json.RawMessage, body []byte) error {
names := Names(body)
if !slices.Contains(names, ControllerUser) {
return fmt.Errorf("refused: the list holds no user %q, and written it would lock the controller out of the "+
"bus; the list on the machine is kept", ControllerUser)
}
if f.env.OnDisk == nil {
return nil
}
+36 -128
View File
@@ -59,19 +59,6 @@ func (f *feed) Watch(context.Context, string) (<-chan agent.Update, error) {
return f.ch, nil
}
// hashed is a list whose users carry a hash of bcrypt's form, as the composer writes them.
func hashed(users ...string) string {
var b strings.Builder
b.WriteString("authorization {\n users = [\n")
for _, u := range users {
b.WriteString(` { user: "` + u + `", password: "$2a$11$` + strings.Repeat("h", 53) + `", permissions: {` + "\n")
}
b.WriteString(" ]\n}\n")
return b.String()
}
var vault, _ = seal.GenerateKeyPair()
func list(users ...string) string {
var b strings.Builder
b.WriteString("authorization {\n users = [\n")
@@ -132,9 +119,8 @@ type gput struct {
otherKey, badDigest bool
// after puts it once the grants part's list was written, as a hand-back comes after a take-over.
after bool
// handed says the controller's lease is handed (ADR 0582 clause 3); unreadable and deleted make the key a value that
// does not read, or a deleted key, instead of a list (774/S31).
handed, unreadable, deleted bool
// unreadable and deleted make the key a value that does not read, or a deleted key, instead of a list (774/S31).
unreadable, deleted bool
}
type put struct {
@@ -145,13 +131,12 @@ type put struct {
}
// Defends novox/hq ADR 0547 §8: the bus node writes the newest signed list alone, only when it changed, and reports it.
func TestTheBusNodeWritesTheNewestSignedUserListAlone(t *testing.T) {
func TestTheBusNodeWritesTheGrantsPartsUserListAlone(t *testing.T) {
one, two, three := list("controller", "a"), list("controller", "a", "b"), list("controller", "a", "b", "c")
five := list("controller", "a", "b", "c", "d")
for _, c := range []struct {
name string
before, after []put
again bool
failWrite bool
onDisk string
wantWrites []string // contents written, in order
@@ -166,83 +151,48 @@ func TestTheBusNodeWritesTheNewestSignedUserListAlone(t *testing.T) {
wantUnwatched bool
// wantSource and wantFollows are what the last have says it took the list from and follows.
wantSource, wantFollows string
// vault gives the follower the vault's key; wantHanded is how many users it then hands from the list on the
// machine (ADR 0547 clause 9).
vault bool
wantHanded int
}{
{name: "the list there is written once", before: []put{{module: "nats", fp: fpOf("a"), content: two}},
wantWrites: []string{two}, wantUsers: 3},
{name: "a newer revision is written, the older fingerprint is not",
before: []put{{module: "nats", fp: fpOf("a"), content: one}, {module: "nats", fp: fpOf("b"), content: two}},
after: []put{{module: "nats", fp: fpOf("a"), content: three}},
wantWrites: []string{two, three}, wantUsers: 4},
{name: "a composition signed by a stranger is no source",
before: []put{{module: "nats", fp: fpOf("a"), content: one}},
after: []put{{module: "nats", fp: fpOf("b"), content: three, stranger: true}},
wantWrites: []string{one}, wantUsers: 2},
{name: "a composition without the list is ignored", before: []put{{module: "web", fp: fpOf("a"), content: ""}}},
{name: "a deleted key is no source",
before: []put{{module: "nats", fp: fpOf("a"), content: one}, {module: "nats", fp: fpOf("b"), content: two}},
after: []put{{module: "nats", fp: fpOf("b"), content: "", deleted: true}, {module: "nats", fp: fpOf("a"), content: three}},
wantWrites: []string{two, three}, wantUsers: 4},
{name: "asked again, the same source is written again", before: []put{{module: "nats", fp: fpOf("a"), content: two}},
again: true, wantWrites: []string{two, two}, wantUsers: 3},
{name: "a list sent by digest counts the users of what it names",
before: []put{{module: "nats", fp: fpOf("a"), content: three, byDigest: true}},
wantWrites: []string{three}, wantUsers: 4},
{name: "another node's composition under this node's key is no source",
before: []put{{module: "nats", fp: fpOf("a"), content: two, asNode: "ace"}}},
{name: "another module's composition under the bus module's key is no source",
before: []put{{module: "nats", fp: fpOf("a"), content: two, asModule: "web"}}},
{name: "a list without the controller is refused and the file kept",
before: []put{{module: "nats", fp: fpOf("a"), content: list("a", "b")}},
onDisk: two, wantUsers: 2, wantErr: "holds no user \"controller\"", wantRefused: true},
{name: "a list that drops more than half is refused and the file kept",
before: []put{{module: "nats", fp: fpOf("a"), content: one}},
onDisk: five, wantUsers: 2, wantErr: "drops more than half", wantRefused: true},
{name: "a list that drops half is written", before: []put{{module: "nats", fp: fpOf("a"), content: two}},
onDisk: list("controller", "a", "b", "c", "d", "e"), wantWrites: []string{two}, wantUsers: 3},
{name: "the loaded list's hashes are handed sealed to the vault's key",
before: []put{{module: "nats", fp: fpOf("a"), content: hashed("controller", "node.ace")}}, vault: true,
wantWrites: []string{hashed("controller", "node.ace")}, wantUsers: 2, wantHanded: 2},
{name: "a refused list hands the list on the machine",
before: []put{{module: "nats", fp: fpOf("a"), content: list("a", "b")}}, vault: true,
onDisk: hashed("controller", "a", "b"), wantUsers: 2, wantErr: "holds no user", wantRefused: true, wantHanded: 3},
{name: "a failed write is said and kept", before: []put{{module: "nats", fp: fpOf("a"), content: two}}, failWrite: true,
wantWrites: []string{two}, wantUsers: 3, wantErr: "disk full"},
// ADR 0547 clauses 7, 8: never both lists; the grants part's from take-over, the controller's otherwise.
{name: "from take-over the grants part's list is written into the same file",
// Issue 1126, A6 (ADR 0547 clauses 7, 8, 11): the grants part's list alone, written into the file the bus module's
// composition declares; the list a composition carries is never written, whatever becomes of the grants key.
{name: "the grants part's list is written into the file a composition declares",
before: []put{{module: "nats", fp: fpOf("a"), content: two}}, grants: []gput{{follow: delivery.FollowGrants, content: three}},
wantWrites: []string{three}, wantUsers: 4, wantSource: delivery.GrantsUserListSource, wantFollows: delivery.FollowGrants},
{name: "a hand-back writes the newest controller composition once",
{name: "a key that says follow the controller still writes the grants part's list",
before: []put{{module: "nats", fp: fpOf("a"), content: two}}, grants: []gput{{follow: delivery.FollowController, content: three}},
wantWrites: []string{three}, wantUsers: 4, wantSource: delivery.GrantsUserListSource, wantFollows: delivery.FollowGrants},
{name: "a newer composition's list is not written",
before: []put{{module: "nats", fp: fpOf("a"), content: two}},
grants: []gput{{follow: delivery.FollowGrants, content: three}, {follow: delivery.FollowController, content: three, after: true}},
wantWrites: []string{three, two}, wantUsers: 3, wantSource: "novox.nats." + fpOf("a")[len(delivery.SourcePrefix2):], wantFollows: delivery.FollowController},
{name: "a refused watch with no key kept follows the controller's list and says it is not watched",
after: []put{{module: "nats", fp: fpOf("b"), content: five}},
grants: []gput{{follow: delivery.FollowGrants, content: three}},
wantWrites: []string{three}, wantUsers: 4, wantSource: delivery.GrantsUserListSource, wantFollows: delivery.FollowGrants},
{name: "a composition without the list is ignored", before: []put{{module: "web", fp: fpOf("a"), content: ""}},
grants: []gput{{follow: delivery.FollowGrants, content: three}}},
{name: "a composition signed by a stranger declares no file",
before: []put{{module: "nats", fp: fpOf("a"), content: two, stranger: true}}, grants: []gput{{follow: delivery.FollowGrants, content: three}}},
{name: "an unreadable grants key with none heard before writes nothing, never the composition's list",
before: []put{{module: "nats", fp: fpOf("a"), content: two}}, grants: []gput{{unreadable: true}},
onDisk: two, wantErr: "key is not heard", wantRefused: true, wantSource: delivery.GrantsUserListSource},
{name: "a refused watch with no key kept writes nothing and says it is not watched",
before: []put{{module: "nats", fp: fpOf("a"), content: two}}, grantsRefused: true, wantUnwatched: true,
wantWrites: []string{two}, wantUsers: 3, wantSource: "novox.nats." + fpOf("a")[len(delivery.SourcePrefix2):], wantFollows: delivery.FollowController},
{name: "a refused watch keeps the follow last heard, never the controller's list",
onDisk: two, wantErr: "key is not heard", wantRefused: true, wantSource: delivery.GrantsUserListSource},
{name: "a refused watch keeps the key last heard",
before: []put{{module: "nats", fp: fpOf("a"), content: two}}, grantsRefused: true, wantUnwatched: true,
kept: &gput{follow: delivery.FollowGrants, content: three},
wantWrites: []string{three}, wantUsers: 4, wantSource: delivery.GrantsUserListSource, wantFollows: delivery.FollowGrants},
{name: "in shadow the grants part's list is not taken",
before: []put{{module: "nats", fp: fpOf("a"), content: two}}, grants: []gput{{follow: delivery.FollowController, content: three}},
wantWrites: []string{two}, wantUsers: 3, wantSource: "novox.nats." + fpOf("a")[len(delivery.SourcePrefix2):], wantFollows: delivery.FollowController},
// 774/S31 (ADR 0571 clause 8, ADR 0547 clause 11): once the key says the lease is handed, the controller's list is never
// followed again, whatever becomes of the key; without the flag the controller's list is still the fallback.
{name: "a handed lease: a grants key that turns unreadable keeps the loaded list and never writes the controller's",
before: []put{{module: "nats", fp: fpOf("a"), content: two}},
grants: []gput{{follow: delivery.FollowGrants, content: three, handed: true}, {unreadable: true, after: true}},
wantWrites: []string{three}, wantUsers: 4, wantSource: delivery.GrantsUserListSource, wantFollows: delivery.FollowGrants},
{name: "a handed lease: a grants key that is deleted keeps the loaded list and never writes the controller's",
before: []put{{module: "nats", fp: fpOf("a"), content: two}},
grants: []gput{{follow: delivery.FollowGrants, content: three, handed: true}, {deleted: true, after: true}},
wantWrites: []string{three}, wantUsers: 4, wantSource: delivery.GrantsUserListSource, wantFollows: delivery.FollowGrants},
{name: "no handed lease: a grants key that turns unreadable falls back to the controller's list",
{name: "a grants key that turns unreadable keeps the loaded list",
before: []put{{module: "nats", fp: fpOf("a"), content: two}},
grants: []gput{{follow: delivery.FollowGrants, content: three}, {unreadable: true, after: true}},
wantWrites: []string{three, two}, wantUsers: 3, wantSource: "novox.nats." + fpOf("a")[len(delivery.SourcePrefix2):], wantFollows: delivery.FollowController},
wantWrites: []string{three}, wantUsers: 4, wantSource: delivery.GrantsUserListSource, wantFollows: delivery.FollowGrants},
{name: "a grants key that is deleted keeps the loaded list",
before: []put{{module: "nats", fp: fpOf("a"), content: two}},
grants: []gput{{follow: delivery.FollowGrants, content: three}, {deleted: true, after: true}},
wantWrites: []string{three}, wantUsers: 4, wantSource: delivery.GrantsUserListSource, wantFollows: delivery.FollowGrants},
{name: "a grants list that drops more than half is refused and the file kept",
before: []put{{module: "nats", fp: fpOf("a"), content: two}}, grants: []gput{{follow: delivery.FollowGrants, content: one}},
onDisk: five, wantUsers: 2, wantErr: "drops more than half", wantRefused: true, wantSource: delivery.GrantsUserListSource},
{name: "a failed write is said and kept", before: []put{{module: "nats", fp: fpOf("a"), content: two}},
grants: []gput{{follow: delivery.FollowGrants, content: three}}, failWrite: true,
wantWrites: []string{three}, wantUsers: 4, wantErr: "disk full", wantSource: delivery.GrantsUserListSource},
{name: "a grants list that does not open to its digest is refused and the file kept",
before: []put{{module: "nats", fp: fpOf("a"), content: two}}, grants: []gput{{follow: delivery.FollowGrants, content: three, badDigest: true}},
onDisk: two, wantErr: "could not be had", wantRefused: true, wantSource: delivery.GrantsUserListSource},
@@ -293,7 +243,7 @@ func TestTheBusNodeWritesTheNewestSignedUserListAlone(t *testing.T) {
}
sealed[delivery.UserListObjectName(digest)] = object
raw, err := delivery.EncodeGrantsUserList(delivery.GrantsUserList{Follow: g.follow, Digest: digest,
Object: delivery.UserListObjectName(digest), SealedTo: delivery.SealingKeyID(to), Node: node, Revision: uint64(i + 1), LeaseHanded: g.handed})
Object: delivery.UserListObjectName(digest), SealedTo: delivery.SealingKeyID(to), Node: node, Revision: uint64(i + 1)})
if err != nil {
t.Fatal(err)
}
@@ -323,18 +273,6 @@ func TestTheBusNodeWritesTheNewestSignedUserListAlone(t *testing.T) {
},
Key: func() string { return nodeKey.PublicKey },
Trusted: func() []ed25519.PublicKey { return []ed25519.PublicKey{controllerPub} },
Resolve: func(_ context.Context, r json.RawMessage) ([]byte, error) {
var res struct{ Content, Digest string }
_ = json.Unmarshal(r, &res)
if res.Digest != "" {
body, ok := objects[res.Digest]
if !ok {
return nil, errors.New("no object " + res.Digest)
}
return []byte(body), nil
}
return []byte(res.Content), nil
},
OnDisk: func(json.RawMessage) ([]byte, error) {
if c.onDisk == "" {
return nil, nil
@@ -357,12 +295,6 @@ func TestTheBusNodeWritesTheNewestSignedUserListAlone(t *testing.T) {
reported <- struct{}{}
},
Now: func() time.Time { return time.Date(2026, 10, 25, 21, 50, 0, 0, time.UTC) },
VaultKey: func() string {
if c.vault {
return vault.PublicKey
}
return ""
},
})
update := func(p put) agent.Update {
f.rev++
@@ -411,14 +343,6 @@ func TestTheBusNodeWritesTheNewestSignedUserListAlone(t *testing.T) {
gf.ch <- u
}
}
if c.again {
// After the first write is reported, so the look again is not folded into it.
select {
case <-reported:
case <-time.After(5 * time.Second):
}
fl.Again()
}
deadline := time.After(5 * time.Second)
for {
mu.Lock()
@@ -489,22 +413,6 @@ func TestTheBusNodeWritesTheNewestSignedUserListAlone(t *testing.T) {
(c.wantFollows != "" && st.Follows != c.wantFollows)) {
t.Fatalf("the have says source %q follows %q key %t; want %q %q", st.Source, st.Follows, st.Key == nodeKey.PublicKey, c.wantSource, c.wantFollows)
}
switch {
case c.wantHanded == 0 && st.Handed != nil:
t.Fatalf("handed with no vault key or no hash: %+v", st.Handed)
case c.wantHanded > 0:
loaded := c.onDisk
if !c.wantRefused {
loaded = c.wantWrites[len(c.wantWrites)-1]
}
if st.Handed == nil || st.Handed.From != Digest([]byte(loaded)) {
t.Fatalf("handed %+v, want from the list on the machine", st.Handed)
}
opened, err := delivery.OpenHanded(*st.Handed, vault.PublicKey, vault.PrivateKey)
if err != nil || len(opened) != c.wantHanded || opened[0].User != "controller" {
t.Fatalf("opened %+v, %v", opened, err)
}
}
if c.wantRefused {
if !strings.Contains(st.Error, c.wantErr) || st.Digest != Digest([]byte(c.onDisk)) || st.Users != c.wantUsers {
t.Fatalf("refused list's state %+v", st)