mesh-vault: serve issue-account, the hand path that mints one wanted account now, and record a rotation at most once per loaded list's revision; a covered account still never rotates in place (issue 774, ADR 0561 §4, §9)

This commit is contained in:
2026-10-27 22:42:57 +01:00
parent 0754c2c3ae
commit 6bc56a4ce3
10 changed files with 241 additions and 2 deletions
@@ -53,6 +53,8 @@ type AccountHash struct {
Adopted bool `json:"adopted,omitempty"`
From string `json:"from,omitempty"`
Node string `json:"node,omitempty"`
// Rotating is a rotation under way over a second login (rotation.go, ADR 0561 §4): absent when none is.
Rotating *Rotating `json:"rotating,omitempty"`
}
// AccountStore is the vault's state of bus-account hashes: read and written by the vault's instances alone.
@@ -143,6 +143,9 @@ func (v *Vault) adopt(node string, raw []byte, by string, force bool) (Adoption,
if err != nil {
return Adoption{}, fmt.Errorf("the have of %s on %s is not a have: %w", BusModule, node, err)
}
if have.UserList != nil {
v.LoadedListHeard(have.UserList.Revision)
}
if have.UserList == nil || have.UserList.Handed == nil {
return Adoption{}, fmt.Errorf("the have of %s on %s hands no hashes yet (its node-engine hands them once it "+
"heard the vault's key in its answer to node-key, and writes the list)", BusModule, node)
+10 -1
View File
@@ -51,7 +51,7 @@ func build() *Env {
// Where it sees a consumer bind a provider (pair.go): the same mirror, every key.
env.Vault = &Vault{Store: store, Own: own, Askers: Declared{Read: readAssignment, Store: store},
Assignments: Mirror{Keys: assignmentKeys, Read: readAssignment}, Emit: emit, Ask: stdio.Ask,
Now: time.Now, Log: say, Accounts: busAccounts{}, Haves: readHave, LeaseRead: readLease}
Now: time.Now, Log: say, Accounts: busAccounts{}, Haves: readHave, LeaseRead: readLease, WantRead: readWant}
return env
}
@@ -114,6 +114,15 @@ func watchWants(env *Env) bool {
return true
}
// readWant is one wanted account of the grants part, read afresh (issue-account).
func readWant(user string) ([]byte, bool, error) {
e, err := stdio.State(WantedRead).Get(user)
if err != nil || e == nil {
return nil, false, err
}
return e.Value, true, nil
}
// readLease is the controller's lease key, read through the runtime under the vault's own account (its manifest reads it).
func readLease() ([]byte, bool, error) {
e, err := stdio.State(LeaseRead).Get(LeaseKey)
+40
View File
@@ -56,6 +56,8 @@ type minting struct {
heldBack map[string]string
// minted are the accounts this vault minted since it started, by user (vault_status).
minted map[string]Minted
// loadedRevision is the highest revision of the user list the bus node said it loaded (rotation.go).
loadedRevision uint64
// retiring is what the passes keep of the accounts no longer wanted (retire.go).
retiring retiring
// mintMu keeps one mint at a time: a want heard, the switch turning on, and issue-account.
@@ -265,6 +267,44 @@ func heldBackOrMinted(m Minted, err error) (any, error) {
return map[string]any{"minted": true, "account": m}, nil
}
// IssueAccount is the seat's verb issue-account (ADR 0561 §9): one wanted user minted now, its key read afresh from the
// grants part's state. The recovery when a want was not heard: it mints nothing a want would not, and is refused while
// minting is off, for a node's account and the controller's (never minted here, §4), and for a user nobody wants.
func (v *Vault) IssueAccount(caller, user string) (any, error) {
user = strings.TrimSpace(user)
if user == "" {
return nil, errors.New("issue-account names a bus user (`user`), e.g. ace.mesh-build-go")
}
if nodeOrController(user) {
return nil, fmt.Errorf("%s is never minted by the vault: a node's account and the controller's keep the hash they "+
"have (ADR 0561 §4)", user)
}
v.leaseAfresh()
if on, why := v.mintingOn(); !on {
return nil, fmt.Errorf("not minted: %s", why)
}
raw, found, err := []byte(nil), false, error(nil)
if v.WantRead != nil {
raw, found, err = v.WantRead(user)
} else if w, ok := v.wanted(user); ok {
raw, err = json.Marshal(w)
found = err == nil
}
if err != nil {
return nil, fmt.Errorf("the wanted account %s could not be read: %w", user, err)
}
if !found {
return nil, fmt.Errorf("the grants part holds no wanted account %s: the vault mints only what placement composes "+
"(grants_accounts lists them)", user)
}
w, err := buscompose.ReadWantedAccount(user, raw)
if err != nil {
return nil, err
}
v.log("issue-account %s, asked by %s", user, someoneOr("", caller))
return heldBackOrMinted(v.mintWanted(w, "issue-account by "+someoneOr("", caller)))
}
// wantedUsers is every wanted user heard, sorted.
func (v *Vault) wantedUsers() []string {
v.minting.mu.Lock()
@@ -148,3 +148,76 @@ func TestNothingIsMintedUntilTheLeaseIsHandedAway(t *testing.T) {
t.Errorf("after the key expired vault_status says %+v", st)
}
}
// 774/AC10 issue-account (novox/hq ADR 0561 §9): the verb mints one wanted user now, read afresh; it is refused while
// minting is off, for a node's account and the controller's, and for a user nobody wants; a user held already is said so.
func TestIssueAccountMintsOneWantedUserNowAndRefusesTheRest(t *testing.T) {
v, _, _ := mintingVault(t, false)
w := aWant("mod-x")
raw := wantRaw(t, w)
v.WantRead = func(user string) ([]byte, bool, error) {
if user == w.User {
return raw, true, nil
}
return nil, false, nil
}
for user, says := range map[string]string{
"ace.mod-x": "controller holds the lease",
"node.ace": "never minted",
"controller": "never minted",
} {
if _, err := v.IssueAccount("person.jochen", user); err == nil || !strings.Contains(err.Error(), says) {
t.Errorf("%s: %v, want a refusal saying %q", user, err, says)
}
}
v.LeaseHeard([]byte(`{"handed-to":"the operator's hand-over"}`), true, nil)
if _, err := v.IssueAccount("person.jochen", "ace.nobody"); err == nil || !strings.Contains(err.Error(), "no wanted account") {
t.Errorf("a user nobody wants: %v", err)
}
got, err := v.IssueAccount("person.jochen", w.User)
if err != nil || got.(map[string]any)["minted"] != true {
t.Fatalf("issue-account: %v, %v", got, err)
}
if again, err := v.IssueAccount("person.jochen", w.User); err != nil || again.(map[string]any)["minted"] != false ||
!strings.Contains(again.(map[string]any)["why"].(string), "held already") {
t.Errorf("issue-account again: %v, %v", again, err)
}
}
// 774/AC10 the rotation's rule (novox/hq ADR 0561 §4): at most one rotation begins per revision of the loaded user list,
// over the second login `<user>--b`, never of a node's account, the controller's or an adopted hash, and `rotate` of a
// covered account stays refused, so nothing rotates in place.
func TestOneRotationBeginsPerRevisionOfTheLoadedList(t *testing.T) {
v, accounts, _ := mintingVault(t, true)
for _, m := range []string{"mod-x", "mod-y"} {
w := aWant(m)
v.WantHeard(w.User, wantRaw(t, w), false)
}
if _, err := v.BeginRotation("ace.mod-x"); err == nil || !strings.Contains(err.Error(), "not known yet") {
t.Errorf("began with no loaded revision known: %v", err)
}
v.LoadedListHeard(570)
r, err := v.BeginRotation("ace.mod-x")
if err != nil || r.Login != "ace.mod-x--b" || r.Revision != 570 || r.Generation != 2 || accounts.keys["ace.mod-x"].Rotating == nil {
t.Fatalf("the first rotation: %+v, %v", r, err)
}
if _, err := v.BeginRotation("ace.mod-y"); err == nil || !strings.Contains(err.Error(), "one rotation begins per revision") {
t.Errorf("a second rotation in one revision: %v", err)
}
v.LoadedListHeard(571)
if _, err := v.BeginRotation("ace.mod-y"); err != nil {
t.Errorf("the next revision's rotation: %v", err)
}
if _, err := v.BeginRotation("ace.mod-x"); err == nil || !strings.Contains(err.Error(), "rotating already") {
t.Errorf("an account rotating began again: %v", err)
}
accounts.keys["node.ace"] = AccountHash{User: "node.ace", Hash: "$2a$04$n", Adopted: true}
for _, u := range []string{"node.ace", "controller"} {
if _, err := v.BeginRotation(u); err == nil {
t.Errorf("%s began a rotation", u)
}
}
if _, err := v.RotateFor(ControllerCaller, "own/ace/mod-x/broker", Narrow{}, "the test", "a rotation in place"); err == nil {
t.Error("a covered account rotated in place")
}
}
@@ -0,0 +1,94 @@
package main
// The record of a bus account's rotation, and its one rule (novox/hq ADR 0561 §4; issue 774, the lead's decision of
// 2026-10-27). A module's, a process's or a provisioner client's account rotates over two credentials (ADR 0114 §10):
// a second login, `<user>--b`, carrying the first login's permissions verbatim, which the bus composer composes only while
// the account's key in `bus-accounts` names it in `rotating`. The vault starts at most one rotation per revision of the
// user list the bus loaded. What composes and delivers the second login is a subtask of its own, before the lease write;
// until it is built nothing begins a rotation, and `rotate` of a covered account stays refused, so nothing rotates in
// place.
import (
"fmt"
"strings"
"time"
)
// SecondLoginSuffix makes an account's second login: reserved, so no module or process may be named to end in it.
const SecondLoginSuffix = "--b"
// Rotating is a rotation under way, as the account's key in bus-accounts says it: the second login, the generation of
// the value it carries, since when, and the revision of the loaded user list it began at.
type Rotating struct {
Login string `json:"login"`
Generation int `json:"generation"`
Since time.Time `json:"since"`
Revision uint64 `json:"revision"`
}
// LoadedListHeard keeps the revision of the user list the bus node says it loaded (its have of the bus module).
func (v *Vault) LoadedListHeard(revision uint64) {
v.minting.mu.Lock()
defer v.minting.mu.Unlock()
if revision > v.minting.loadedRevision {
v.minting.loadedRevision = revision
}
}
// BeginRotation records a rotation of user's account at the loaded list's revision, under the one rule: none for a
// node's account or the controller's, none while minting is off, none of an account the vault did not make or was not
// handed, none twice for one account, and none when another began at this revision.
func (v *Vault) BeginRotation(user string) (Rotating, error) {
if nodeOrController(user) {
return Rotating{}, fmt.Errorf("%s does not rotate here: a node's account and the controller's keep the hash they have (ADR 0561 §4)", user)
}
if on, why := v.mintingOn(); !on {
return Rotating{}, fmt.Errorf("no rotation begins: %s", why)
}
v.minting.mu.Lock()
revision := v.minting.loadedRevision
v.minting.mu.Unlock()
if revision == 0 {
return Rotating{}, fmt.Errorf("no rotation begins: the revision of the user list the bus loaded is not known yet")
}
users, err := v.Accounts.Users()
if err != nil {
return Rotating{}, err
}
var mine *AccountHash
began := ""
for _, u := range users {
a, err := v.Accounts.Get(u)
if err != nil {
return Rotating{}, err
}
if a == nil {
continue
}
if a.Rotating != nil && a.Rotating.Revision == revision {
began = u
}
if u == user {
mine = a
}
}
switch {
case mine == nil || mine.Adopted || mine.Of == "":
return Rotating{}, fmt.Errorf("the vault holds no account of %s it made or was handed, so it rotates none", user)
case mine.Rotating != nil:
return Rotating{}, fmt.Errorf("%s is rotating already, since %s (second login %s)", user, mine.Rotating.Since.Format(time.RFC3339), mine.Rotating.Login)
case strings.HasSuffix(user, SecondLoginSuffix):
return Rotating{}, fmt.Errorf("%s is a second login, which never rotates of its own", user)
case began != "":
return Rotating{}, fmt.Errorf("no rotation of %s begins: %s's began at the loaded list's revision %d, and one "+
"rotation begins per revision (ADR 0561 §4)", user, began, revision)
}
r := Rotating{Login: user + SecondLoginSuffix, Generation: mine.Generation + 1, Since: v.Now().UTC(), Revision: revision}
next := *mine
next.Rotating = &r
if err := v.Accounts.Put(next); err != nil {
return Rotating{}, err
}
v.log("a rotation of %s begins at the loaded list's revision %d: second login %s", user, revision, r.Login)
return r, nil
}
@@ -251,6 +251,20 @@ func definitions(env *Env) []toolDef {
// clause 9, adopt.go): a public half, never the private one.
return map[string]any{"recorded": true, "vault_key": v.Own.PublicKey}, nil
}},
{Name: Seat + ".issue-account",
Description: "Mint one bus account the grants part wants, now (novox/hq ADR 0561 §9): the recovery when the vault did not " +
"hear a want. Reads the wanted key afresh from mesh-grants.accounts and mints it as a want would: only while " +
"the controller's lease is handed away, never for a node's account or the controller's, never a user nobody " +
"wants, and never anew for one whose value the vault holds or whose hash it adopted (it says so). Answers " +
"whether it minted, the secret and generation, never the value or the hash.",
Input: map[string]any{"user": str("the bus user, e.g. ace.mesh-build-go")},
run: func(caller string, a map[string]any) (any, error) {
v, err := env.ready()
if err != nil {
return nil, err
}
return v.IssueAccount(caller, arg(a, "user"))
}},
{Name: Seat + ".own",
Description: "The own secrets of this machine's modules, each sealed to the machine's sealing key (novox/hq ADR 0547 §2, " +
"ADR 0550 §5, issue 1126): from the node-engine of the machine alone, about its own machine (the bus proves which), " +
@@ -56,6 +56,8 @@ type Vault struct {
hashes hashing
// adoption is the last adoption (adopt.go), under mu.
adoption adoptionKept
// WantRead reads one wanted account afresh (issue-account): nil reads the wants as the watch handed them.
WantRead func(user string) ([]byte, bool, error)
// LeaseRead reads the controller's lease afresh before a mint (lease.go): nil reads it only as the watch hands it.
LeaseRead func() ([]byte, bool, error)
// minting is the switch on the lease and what the vault knows of the wanted bus accounts (mint.go).
@@ -176,6 +176,7 @@ func TestNoBucketHoldsASecret(t *testing.T) {
code = []byte(strings.Replace(string(code), "stdio.State(HaveRead).Get(", "", 1))
code = []byte(strings.Replace(string(code), "stdio.State(HaveRead).Watch(", "", 1))
code = []byte(strings.Replace(string(code), "stdio.State(WantedRead).Watch(", "", 1))
code = []byte(strings.Replace(string(code), "stdio.State(WantedRead).Get(", "", 1))
// And the controller's lease, one read and one watch (ADR 0561 §3).
code = []byte(strings.Replace(string(code), "stdio.State(LeaseRead).Get(", "", 1))
code = []byte(strings.Replace(string(code), "stdio.State(LeaseRead).Watch(", "", 1))
+2 -1
View File
@@ -182,7 +182,8 @@
"move-in",
"node-key",
"move-asks",
"own"
"own",
"issue-account"
]
}
]