mesh-grants: write the bus accounts the mesh wants to a state only the vault reads, so the vault can mint them; grants_accounts reads it (issue 774, ADR 0561 §2, §9)

This commit is contained in:
2026-10-27 22:24:06 +01:00
parent 68bdda1a4d
commit 04276bfda1
8 changed files with 343 additions and 15 deletions
@@ -214,6 +214,10 @@ func (s ownStore) Put(state, key string, value []byte) (uint64, error) {
return s.c.State(state).Put(key, json.RawMessage(value))
}
func (s ownStore) Keys(state string) ([]string, error) { return s.c.State(state).Keys() }
func (s ownStore) Delete(state, key string) error { return s.c.State(state).Delete(key) }
// emitting is the process connection as the part's emitter: its events, with no options.
type emitting struct{ c *process.Conn }
+5 -9
View File
@@ -62,14 +62,9 @@ func main() {
}
}
// keptStates is what the part keeps, each readable (ADR 0495 §2-§3): the view and a session read them.
func keptStates() []announce.State {
var out []announce.State
for _, s := range grants.States {
out = append(out, announce.State{Name: s, Readable: true})
}
return out
}
// keptStates is what the part keeps (ADR 0495 §2-§3): each readable, for the view and a session, but the wanted accounts,
// by the vault alone (ADR 0561 §2).
func keptStates() []announce.State { return grants.Announced() }
// run is the process: it returns when ctx ends, or when it has no credential or cannot dial the bus at all. A bucket or
// read the bus refuses never ends it.
@@ -118,7 +113,8 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
bus := newBusReader(ctx, js)
f.Part = &grants.Part{Read: bus, Call: conn, Store: ownStore{conn}, Emit: emitting{conn},
Publish: memberships{ctx: ctx, nc: nc, js: js}, Create: creating{ctx: ctx, js: js},
Objects: sealedObjects{ctx: ctx, js: js}, Logf: logger.Printf}
Objects: sealedObjects{ctx: ctx, js: js}, Logf: logger.Printf,
Bus: grants.BusAddress{URL: account.URL, Fingerprint: account.Fingerprint}}
f.Watch = bus
// The operator's word on take-over and hand-back is their yes on their channel: the part asks it itself, and acts on
// the warrant once (mesh-sdk asker), after its refusals.
@@ -39,6 +39,10 @@ func (runtimeStore) Put(state, key string, value []byte) (uint64, error) {
return stdio.State(state).Put(key, json.RawMessage(value))
}
func (runtimeStore) Keys(state string) ([]string, error) { return stdio.State(state).Keys() }
func (runtimeStore) Delete(state, key string) error { return stdio.State(state).Delete(key) }
// runtimeBus calls tools and says events through the runtime.
type runtimeBus struct{}
@@ -113,6 +117,13 @@ func toolsOf(p *grants.Part) []stdio.Tool {
Run: func(a map[string]any) (any, error) {
return p.HandBack(stdio.CallerOf(a), grants.Answer(a, "why"))
}},
{Name: grants.ToolAccounts, Description: "The bus accounts the mesh wants (novox/hq ADR 0561 §2, §9): every account the vault " +
"mints that placement composes (a module's, a process's, a machine's provisioner client's), as the grants part last " +
"wrote it to its state accounts, which the vault alone reads: user, kind, node, module, process, the bus's address and " +
"fingerprint, a module's claims with the verbs each seat promises, and when the key was written; one user's or all; and " +
"what the last pass wrote, deleted or kept. Never a password. Read-only: grants_compose_now writes them again.",
Input: schema(map[string]string{"user": "one bus user, e.g. ace.mesh-build-go"}),
Run: func(a map[string]any) (any, error) { return p.Accounts(grants.Answer(a, "user")) }},
{Name: grants.ToolTakeOverState, Description: "Where the switch stands: the list the bus node follows, the ask open now, the request, and " +
"every act with what became of it (asked, switched, declined, expired, refused and why).",
Input: schema(nil),
+86
View File
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"io/fs"
"maps"
"os"
"path/filepath"
"slices"
@@ -216,6 +217,27 @@ func (s ownStore) Put(state, key string, value []byte) (uint64, error) {
return b.revs[state+"/"+key], nil
}
func (s ownStore) Keys(state string) ([]string, error) {
b := s.b
b.mu.Lock()
defer b.mu.Unlock()
var out []string
for k := range b.own[state] {
out = append(out, k)
}
slices.Sort(out)
return out, nil
}
func (s ownStore) Delete(state, key string) error {
b := s.b
b.mu.Lock()
defer b.mu.Unlock()
b.writes = append(b.writes, state+" delete")
delete(b.own[state], key)
return nil
}
func (b *fakeBus) Emit(event string, _ any) error {
b.mu.Lock()
defer b.mu.Unlock()
@@ -1649,6 +1671,70 @@ func TestGrants(t *testing.T) {
t.Errorf("the refusal was not said: %v", logged)
}
})
// 774/AC4 (novox/hq ADR 0561 §2): every account the vault mints that the records compose is written to the state
// accounts once, with the bus's address and a module's claims, read by the vault alone; a key is deleted when placement
// no longer names its user, and never in a pass whose reads failed.
t.Run("wanted accounts: written once each, read by the vault alone, retired only when placement drops them", func(t *testing.T) {
b := fixture()
b.loaded = shadowOf(t, b)
p := partOn(b)
p.Bus = grants.BusAddress{URL: "nats://a.mesh:4222", Fingerprint: "ab"}
p.Compose()
p.Compose()
wrote := 0
for _, w := range b.writes {
if w == grants.StateAccounts {
wrote++
}
}
held := slices.Sorted(maps.Keys(b.own[grants.StateAccounts]))
if !slices.Equal(held, []string{"a.mod-x", "a.nats", "b.mod-y"}) || wrote != 3 {
t.Fatalf("accounts %v written %d times in two passes, want the three modules' once each", held, wrote)
}
nats, err := buscompose.ReadWantedAccount("a.nats", b.own[grants.StateAccounts]["a.nats"])
if err != nil || nats.URL != "nats://a.mesh:4222" || nats.Fingerprint != "ab" || len(nats.Claims) != 1 ||
nats.Claims[0].Seat != catalogue.BrokerSeat || nats.At.IsZero() {
t.Errorf("a.nats is wanted as %+v (%v)", nats, err)
}
var readers []string
for _, s := range grants.Announced() {
if s.Name == grants.StateAccounts {
if s.Readable {
t.Error("the wanted accounts are announced readable by anyone")
}
readers = s.Readers
}
}
if !slices.Equal(readers, []string{"mesh-vault"}) || buscompose.ReaderMay("mesh-delivery-view", buscompose.GrantsAccounts) {
t.Errorf("the wanted accounts are read by %v", readers)
}
// A read that fails retires nothing; once read, the user placement dropped is retired.
delete(b.states[grants.ReadAssignments], "b.mod-y")
b.refused[grants.ReadResolved] = true
if res := p.Compose(); res.Accounts == nil || len(res.Accounts.Deleted) != 0 || b.own[grants.StateAccounts]["b.mod-y"] == nil {
t.Fatalf("a pass whose read failed deleted %+v", res.Accounts)
}
delete(b.refused, grants.ReadResolved)
if res := p.Compose(); res.Accounts == nil || !slices.Equal(res.Accounts.Deleted, []string{"b.mod-y"}) {
t.Fatalf("the user placement dropped is not retired: %+v", res.Accounts)
}
// The tool reads them, one and all, each with when it was written.
one, err := p.Accounts("a.nats")
all, err2 := p.Accounts("")
if err != nil || err2 != nil || one.(map[string]any)["count"] != 1 || all.(map[string]any)["count"] != 2 ||
one.(map[string]any)["accounts"].([]buscompose.WantedAccount)[0].At.IsZero() {
t.Errorf("the tool reads %v and %v (%v %v)", one, all, err, err2)
}
if _, err := p.Accounts("b.mod-y"); err == nil {
t.Error("the tool reads an account no longer wanted")
}
// With no bus address no account is written, and the pass says why.
bare := partOn(fixture())
if res := bare.Compose(); res.Accounts != nil || !strings.Contains(strings.Join(res.Errors, " "), "no bus address") {
t.Errorf("a part with no bus address wrote %+v: %v", res.Accounts, res.Errors)
}
})
}
type fakeAsker struct {
@@ -0,0 +1,212 @@
package grants
import (
"encoding/json"
"fmt"
"slices"
"sort"
"git.novox.be/novox/mesh-sdk/go/announce"
"git.novox.be/novox/mesh-sdk/go/buscompose"
"git.novox.be/novox/mesh-sdk/go/catalogue"
)
// **The accounts the mesh wants, said to the vault** (novox/hq ADR 0561 §2, issue 774). The bus composer derives every
// user from placement, the manifests and the wants; for each account the vault mints (a module's, a process's, a
// machine's provisioner client's), the part writes one key of its state `accounts`, keyed by the bus user, with the
// fields the controller's credential carries beside the password. A key is written only when it differs, and deleted
// only when placement no longer names its user and every input of the pass was read: a refused read never retires an
// account. The vault alone reads the state (buscompose.NamedReaders); it mints each user it holds no value for.
// StateAccounts is the part's state of wanted accounts.
const StateAccounts = buscompose.AccountsState
// AccountsReaders are the only modules that may read StateAccounts.
var AccountsReaders = buscompose.NamedReaders[buscompose.GrantsAccounts]
// Announced is what the part says it keeps: every state readable by any reader, but the wanted accounts, by the vault
// alone (ADR 0495 §2-§3, ADR 0561 §2).
func Announced() []announce.State {
var out []announce.State
for _, s := range States {
if s == StateAccounts {
out = append(out, announce.State{Name: s, Readers: slices.Clone(AccountsReaders)})
continue
}
out = append(out, announce.State{Name: s, Readable: true})
}
return out
}
// BusAddress is where every node on the private network reaches the bus, and its certificate's fingerprint: the part's
// own credential says both, since the controller gave every node's account the bus holder's overlay name (its
// brokerReachableAt).
type BusAddress struct {
URL string
Fingerprint string
}
// WantedAccounts is every account the vault mints that the records compose, keyed by user, with the bus's address. A
// node, the controller, an enrolment user or a person is never one (ADR 0561 §4, §5). Pure.
func WantedAccounts(in Inputs, recs buscompose.Records, bus BusAddress) (map[string]buscompose.WantedAccount, error) {
principals, err := buscompose.Users(recs)
if err != nil {
return nil, err
}
out := map[string]buscompose.WantedAccount{}
for _, p := range principals {
w := buscompose.WantedAccount{User: p.Username(), Kind: p.Kind, Node: p.Node, Module: p.Module, Process: p.Process,
URL: bus.URL, Fingerprint: bus.Fingerprint}
switch p.Kind {
case buscompose.KindModule, buscompose.KindNodeTools:
if m, ok := manifestOn(in, p.Node, p.Module); ok {
w.Claims = ClaimsOf(m)
}
case buscompose.KindProcess, buscompose.KindProvisioner:
default:
continue
}
out[w.User] = w
}
return out, nil
}
// manifestOn is the manifest a node runs of module: the build its want names, else the trunk's.
func manifestOn(in Inputs, node, module string) (catalogue.Manifest, bool) {
if m, ok := in.Running[node+"."+module]; ok {
return m, true
}
m, ok := in.Manifests[module]
return m, ok
}
// ClaimsOf is a module's claims as its credential carries them: the controller's claimsFor, over the mesh's own seats
// (its seat table is seeded from them): each claim's seat and scope, and the verbs the runtime serves for it, the claim's
// own or every verb the seat promises; a seat the mesh does not define keeps the claim's scope and no verbs.
func ClaimsOf(m catalogue.Manifest) []buscompose.ClaimedSeat {
if len(m.Claims) == 0 {
return nil
}
byName := map[string]catalogue.Seat{}
for _, s := range catalogue.DefaultSeats() {
byName[s.Name] = s
}
var out []buscompose.ClaimedSeat
for _, c := range m.Claims {
claimed := buscompose.ClaimedSeat{Seat: c.Name, Scope: c.At()}
if s, known := byName[c.Name]; known {
claimed.Scope = s.Scope
claimed.Serves = c.ServesFor(catalogue.Manifest{Tools: catalogue.VerbNames(s.Serves)})
}
out = append(out, claimed)
}
return out
}
// Accounts answers the tool accounts (ADR 0561 §9): the wanted accounts as the part last wrote them, one user's or all,
// each with when it was written; and what the last pass did to them. Never a password: the state holds none.
func (p *Part) Accounts(user string) (any, error) {
users := []string{user}
if user == "" {
keys, err := p.Store.Keys(StateAccounts)
if err != nil {
return nil, fmt.Errorf("its own %s cannot be listed: %w", StateAccounts, err)
}
users = keys
}
out := []buscompose.WantedAccount{}
for _, u := range users {
raw, _, found, err := p.Store.Get(StateAccounts, u)
if err != nil {
return nil, fmt.Errorf("its own %s %s cannot be read: %w", StateAccounts, u, err)
}
if !found {
if user != "" {
return nil, fmt.Errorf("no account %s is wanted: placement composes no module, process or provisioner client of that user", user)
}
continue
}
w, err := buscompose.ReadWantedAccount(u, raw)
if err != nil {
return nil, err
}
out = append(out, w)
}
answer := map[string]any{"accounts": out, "count": len(out), "read_by": AccountsReaders}
if raw, _, found, err := p.Store.Get(StateUserList, KeyPass); err == nil && found {
var res Result
if json.Unmarshal(raw, &res) == nil && res.Accounts != nil {
answer["last_pass"] = res.Accounts
}
}
return answer, nil
}
// AccountsWritten is what one pass did to the state of wanted accounts.
type AccountsWritten struct {
Wanted int `json:"wanted"`
Written []string `json:"written,omitempty"`
Deleted []string `json:"deleted,omitempty"`
// Kept are keys placement no longer names that this pass did not delete, with why.
Kept []string `json:"kept,omitempty"`
}
// writeAccounts writes each wanted account whose key differs, and deletes the key of a user no longer placed, but only
// when every input of the pass was read.
func (p *Part) writeAccounts(res *Result, in Inputs, recs buscompose.Records) {
if p.Bus.URL == "" {
res.Errors = append(res.Errors, "the wanted accounts are not written: the part knows no bus address to give them (its own credential names none)")
return
}
wanted, err := WantedAccounts(in, recs, p.Bus)
if err != nil {
res.Errors = append(res.Errors, "the wanted accounts could not be composed: "+err.Error())
return
}
done := &AccountsWritten{Wanted: len(wanted)}
res.Accounts = done
for _, user := range sortedKeys(wanted) {
w := wanted[user]
if raw, _, found, err := p.Store.Get(StateAccounts, user); err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("its own %s %s cannot be read: %v", StateAccounts, user, err))
continue
} else if found {
if was, err := buscompose.ReadWantedAccount(user, raw); err == nil && was.Same(w) {
continue
}
}
w.At = p.now()
data, _ := json.Marshal(w)
if _, err := p.Store.Put(StateAccounts, user, data); err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("its own %s %s cannot be written: %v", StateAccounts, user, err))
continue
}
done.Written = append(done.Written, user)
}
held, err := p.Store.Keys(StateAccounts)
if err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("its own %s cannot be listed, so no key of a user gone is deleted: %v", StateAccounts, err))
return
}
failed := in.ReadFailures()
placed := Placed(in)
for _, user := range held {
if _, still := wanted[user]; still {
continue
}
switch {
case placed(user):
done.Kept = append(done.Kept, user+": placement still names it (composed with no account this pass)")
continue
case len(failed) > 0:
done.Kept = append(done.Kept, user+": a read of this pass failed, so no account is retired")
continue
}
if err := p.Store.Delete(StateAccounts, user); err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("its own %s %s cannot be deleted: %v", StateAccounts, user, err))
continue
}
done.Deleted = append(done.Deleted, user)
}
sort.Strings(done.Kept)
}
+16 -4
View File
@@ -31,7 +31,7 @@ const (
)
// States is every state the part keeps.
var States = []string{StateAssertions, StateMemberships, StateTakeover, StateUserList}
var States = []string{StateAccounts, StateAssertions, StateMemberships, StateTakeover, StateUserList}
// The events the part says.
const (
@@ -48,6 +48,9 @@ var Emits = []string{EventAsserted, EventMembership, EventUserList}
type Store interface {
Get(state, key string) ([]byte, uint64, bool, error)
Put(state, key string, value []byte) (uint64, error)
// Keys and Delete are for the wanted accounts alone (accounts.go): every other state is only put.
Keys(state string) ([]string, error)
Delete(state, key string) error
}
// Emitter says one of the part's events.
@@ -104,9 +107,11 @@ type Result struct {
// free, <why>`: a verified sender is composed as from no root-free machine there (issue 1945).
RootNotFree []string `json:"root_not_free,omitempty"`
// Sealed is the list sealed to the bus node as the key `user-list.sealed` names it (ADR 0547 clause 7).
Sealed *SealedState `json:"sealed,omitempty"`
Errors []string `json:"errors,omitempty"`
PublishedOrAsserted string `json:"published_or_asserted"`
Sealed *SealedState `json:"sealed,omitempty"`
// Accounts is what the pass wrote of the wanted accounts the vault mints from (ADR 0561 §2).
Accounts *AccountsWritten `json:"accounts,omitempty"`
Errors []string `json:"errors,omitempty"`
PublishedOrAsserted string `json:"published_or_asserted"`
}
// NothingTakesEffect is what every composition says it did on the bus beyond its own state and the memberships.
@@ -129,6 +134,9 @@ type Part struct {
Logf func(string, ...any)
// Objects is the part's object bucket of sealed lists: the process's connection; nil in the tools.
Objects Objects
// Bus is where the nodes reach the bus, which each wanted account carries (accounts.go): the process's own credential's;
// empty in the tools, which write no account.
Bus BusAddress
// Say says a condition to the operator: the process's; nil in the tools.
Say func(asks.Condition) error
// Forget takes away the record the asker kept of an ask it failed to publish; Nonce is a test's id suffix.
@@ -233,6 +241,10 @@ func (p *Part) Compose() Result {
}
}
}
// The accounts the vault mints from, said in the state only it reads (ADR 0561 §2).
if p.Store != nil {
p.writeAccounts(&res, in, recs)
}
// The list itself, sealed to the bus node and named in `user-list.sealed` (ADR 0547 clause 7).
if list, n, leftOut, err := ComposeList(recs, in.Hashes); err != nil {
res.Errors = append(res.Errors, "the user list could not be composed whole: "+err.Error())
+3 -1
View File
@@ -21,10 +21,12 @@ const (
ToolTakeOver = "grants_take_over"
ToolTakeOverState = "grants_take_over_state"
ToolHandBack = "grants_hand_back"
ToolAccounts = "grants_accounts"
)
// Tools is every tool, in the manifest's order.
var Tools = []string{ToolGrants, ToolMembership, ToolUserList, ToolComposeNow, ToolAssert, ToolTakeOver, ToolHandBack, ToolTakeOverState}
var Tools = []string{ToolGrants, ToolMembership, ToolUserList, ToolComposeNow, ToolAssert, ToolTakeOver, ToolHandBack, ToolTakeOverState,
ToolAccounts}
// WritesNoGrant is what grants says of the part's own grants in this milestone.
const WritesNoGrant = "none: the grants part writes no grants.<module>.<fp> before ADR 0547's third milestone (clause 5); the controller's are beside"
+6 -1
View File
@@ -40,9 +40,14 @@
"grants_assert",
"grants_take_over",
"grants_take_over_state",
"grants_hand_back"
"grants_hand_back",
"grants_accounts"
],
"state": [
{
"name": "accounts",
"history": 5
},
{
"name": "assertions",
"history": 5