From 04276bfda1131d08e14b6698936bec605f170584 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 27 Oct 2026 22:24:06 +0100 Subject: [PATCH] =?UTF-8?q?mesh-grants:=20write=20the=20bus=20accounts=20t?= =?UTF-8?q?he=20mesh=20wants=20to=20a=20state=20only=20the=20vault=20reads?= =?UTF-8?q?,=20so=20the=20vault=20can=20mint=20them;=20grants=5Faccounts?= =?UTF-8?q?=20reads=20it=20(issue=20774,=20ADR=200561=20=C2=A72,=20=C2=A79?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- modules/mesh-grants/cmd/mesh-grants/bus.go | 4 + modules/mesh-grants/cmd/mesh-grants/main.go | 14 +- modules/mesh-grants/cmd/mesh-grants/tools.go | 11 + modules/mesh-grants/grants_test.go | 86 +++++++ .../mesh-grants/internal/grants/accounts.go | 212 ++++++++++++++++++ modules/mesh-grants/internal/grants/part.go | 20 +- modules/mesh-grants/internal/grants/tools.go | 4 +- modules/mesh-grants/module.json | 7 +- 8 files changed, 343 insertions(+), 15 deletions(-) create mode 100644 modules/mesh-grants/internal/grants/accounts.go diff --git a/modules/mesh-grants/cmd/mesh-grants/bus.go b/modules/mesh-grants/cmd/mesh-grants/bus.go index e2ac265..7f839ea 100644 --- a/modules/mesh-grants/cmd/mesh-grants/bus.go +++ b/modules/mesh-grants/cmd/mesh-grants/bus.go @@ -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 } diff --git a/modules/mesh-grants/cmd/mesh-grants/main.go b/modules/mesh-grants/cmd/mesh-grants/main.go index d971edc..b8595cd 100644 --- a/modules/mesh-grants/cmd/mesh-grants/main.go +++ b/modules/mesh-grants/cmd/mesh-grants/main.go @@ -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. diff --git a/modules/mesh-grants/cmd/mesh-grants/tools.go b/modules/mesh-grants/cmd/mesh-grants/tools.go index 822fb2b..9bbeb16 100644 --- a/modules/mesh-grants/cmd/mesh-grants/tools.go +++ b/modules/mesh-grants/cmd/mesh-grants/tools.go @@ -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), diff --git a/modules/mesh-grants/grants_test.go b/modules/mesh-grants/grants_test.go index 3d44661..9a6e5d6 100644 --- a/modules/mesh-grants/grants_test.go +++ b/modules/mesh-grants/grants_test.go @@ -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 { diff --git a/modules/mesh-grants/internal/grants/accounts.go b/modules/mesh-grants/internal/grants/accounts.go new file mode 100644 index 0000000..236c321 --- /dev/null +++ b/modules/mesh-grants/internal/grants/accounts.go @@ -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) +} diff --git a/modules/mesh-grants/internal/grants/part.go b/modules/mesh-grants/internal/grants/part.go index 7756720..257ccb2 100644 --- a/modules/mesh-grants/internal/grants/part.go +++ b/modules/mesh-grants/internal/grants/part.go @@ -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, `: 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()) diff --git a/modules/mesh-grants/internal/grants/tools.go b/modules/mesh-grants/internal/grants/tools.go index 09700dc..25b95ce 100644 --- a/modules/mesh-grants/internal/grants/tools.go +++ b/modules/mesh-grants/internal/grants/tools.go @@ -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.. before ADR 0547's third milestone (clause 5); the controller's are beside" diff --git a/modules/mesh-grants/module.json b/modules/mesh-grants/module.json index 65d06b0..705c002 100644 --- a/modules/mesh-grants/module.json +++ b/modules/mesh-grants/module.json @@ -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