diff --git a/go/buscompose/accounts.go b/go/buscompose/accounts.go new file mode 100644 index 0000000..a08bdbf --- /dev/null +++ b/go/buscompose/accounts.go @@ -0,0 +1,147 @@ +package buscompose + +import ( + "encoding/json" + "fmt" + "slices" + "strings" + "time" +) + +// **The accounts the grants part wants, and the one shape a held account takes** (novox/hq ADR 0561 §1, §2; issue 774). +// +// The bus composer derives every user from placement, the manifests and the wants. For each account the mesh mints (a +// module's, a module's process's, a machine's provisioner client's), the grants part writes one key of its state +// `accounts`, keyed by the bus user, holding the account's fields that are not secret: what the controller's credential +// carries today beside the password. The vault watches that state, mints the user it holds no value for, and keeps the +// value as the module's own secret on that node, built by HeldCredential, which gives byte for byte the controller's +// shape, so a runtime reads it unchanged. The state is read by the vault alone (NamedReaders), as `bus-accounts` is read +// by the grants part alone. +// +// The node accounts, the controller's own, an enrolment user and a person are never wanted here: the vault mints none +// of them (ADR 0561 §4, §5). + +// AccountsState is the grants part's state of wanted accounts, by bus user. +const AccountsState = "accounts" + +// GrantsAccounts is that state as a reader names it (`.`). +const GrantsAccounts = GrantsModule + "." + AccountsState + +// AccountsBucket is the bucket the state lives in. +var AccountsBucket = BucketName(GrantsModule, AccountsState) + +// AccountsSubject is the subject of one wanted account's key: `$KV.mesh-grants_accounts.`. +func AccountsSubject(user string) string { return "$KV." + AccountsBucket + "." + user } + +// AccountIssuedEvent is what the vault says when it minted an account: AccountIssued, never a value or a hash. +const AccountIssuedEvent = "account-issued" + +// AccountIssuedSubject is where it says it, as the runtime addresses mesh-vault's events. +const AccountIssuedSubject = "mesh.mod." + VaultSeat + ".event." + AccountIssuedEvent + +// ClaimedSeat is one seat a module claims, as its runtime needs it (the controller's seatClaimed): the name, the scope (a +// node-scoped seat's verb carries the machine) and the verbs the runtime serves for it. +type ClaimedSeat struct { + Seat string `json:"seat"` + Scope string `json:"scope"` + Serves []string `json:"serves,omitempty"` +} + +// WantedAccount is one key of the grants part's state `accounts`: an account the mesh needs, without its password. +type WantedAccount struct { + User string `json:"user"` + // Kind is KindModule (KindNodeTools for the runtime, named as a module is), KindProcess or KindProvisioner. + Kind Kind `json:"kind"` + Node string `json:"node"` + Module string `json:"module"` + // Process is a KindProcess account's process id. + Process string `json:"process,omitempty"` + // URL is the bus's address as that node reaches it, `nats://:`; Fingerprint the bus certificate's. + URL string `json:"url"` + Fingerprint string `json:"fingerprint,omitempty"` + // Claims are a module's claims with the verbs each seat promises: none for a process or a provisioner client. + Claims []ClaimedSeat `json:"claims,omitempty"` + // At is when the grants part wrote the key: not compared (Same). + At time.Time `json:"at"` +} + +// mintedKinds are the kinds of account the vault mints (ADR 0561 §1). +var mintedKinds = []Kind{KindModule, KindNodeTools, KindProcess, KindProvisioner} + +// principal is the account as the composer names it. +func (w WantedAccount) principal() Principal { + return Principal{Kind: w.Kind, Node: w.Node, Module: w.Module, Process: w.Process} +} + +// Valid says why w is not an account the vault may mint, or nil: a kind it mints, every name a subject token, the user +// the one the composer derives from them, and an address to reach the bus at. +func (w WantedAccount) Valid() error { + if !slices.Contains(mintedKinds, w.Kind) { + return fmt.Errorf("%s is a %s account, which the vault never mints (ADR 0561 §4)", w.User, w.Kind) + } + for what, v := range map[string]string{"node": w.Node, "module": w.Module} { + if !safeSubject.MatchString(v) { + return fmt.Errorf("%s: the %s %q is not a name the mesh writes", w.User, what, v) + } + } + if w.Kind == KindProcess && !safeSubject.MatchString(w.Process) { + return fmt.Errorf("%s: the process %q is not a name the mesh writes", w.User, w.Process) + } + if w.Kind != KindProcess && w.Process != "" { + return fmt.Errorf("%s: only a process's account names a process", w.User) + } + if want := w.principal().Username(); w.User != want { + return fmt.Errorf("%s is not the user of a %s account of %s on %s (that is %s)", w.User, w.Kind, w.Module, w.Node, want) + } + if !strings.HasPrefix(w.URL, "nats://") || len(w.URL) == len("nats://") { + return fmt.Errorf("%s names no bus address (url, nats://:)", w.User) + } + if len(w.Claims) > 0 && w.Kind != KindModule && w.Kind != KindNodeTools { + return fmt.Errorf("%s: a %s account holds no seat, so it carries no claims", w.User, w.Kind) + } + return nil +} + +// Same says whether two wanted accounts want the same account: every field but when it was written. +func (w WantedAccount) Same(o WantedAccount) bool { + a, b := w, o + a.At, b.At = time.Time{}, time.Time{} + ra, _ := json.Marshal(a) + rb, _ := json.Marshal(b) + return string(ra) == string(rb) +} + +// OwnSecret is the module's own secret the account is held in: `broker`, `broker-` or `provisioner-broker` +// (the controller's names, ADR 0436 §2, ADR 0349 §11). +func (w WantedAccount) OwnSecret() string { + switch w.Kind { + case KindProcess: + return "broker-" + w.Process + case KindProvisioner: + return "provisioner-broker" + } + return "broker" +} + +// ReadWantedAccount reads the key user of the state: refused when it is not a valid wanted account of that user. +func ReadWantedAccount(user string, raw []byte) (WantedAccount, error) { + var w WantedAccount + if err := json.Unmarshal(raw, &w); err != nil { + return WantedAccount{}, fmt.Errorf("%s %s is not a wanted account: %w", GrantsAccounts, user, err) + } + if w.User != user { + return WantedAccount{}, fmt.Errorf("%s %s names the user %q", GrantsAccounts, user, w.User) + } + return w, w.Valid() +} + +// AccountIssued is the vault's account-issued: which account it minted, and its generation. Never a value or a hash. +type AccountIssued struct { + User string `json:"user"` + Kind Kind `json:"kind"` + Node string `json:"node"` + Module string `json:"module"` + Process string `json:"process,omitempty"` + Generation int `json:"generation"` + At time.Time `json:"at"` +} diff --git a/go/buscompose/buscompose_test.go b/go/buscompose/buscompose_test.go index 52a7d8a..69cb306 100644 --- a/go/buscompose/buscompose_test.go +++ b/go/buscompose/buscompose_test.go @@ -293,4 +293,55 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) { } grantsAsTheControllers(t, got, string(want)) }) + // 774/AC2 (novox/hq ADR 0561 §2): a wanted account round-trips through the grants part's state `accounts`, keyed by + // its user, at the subject `$KV.mesh-grants_accounts.`, read by the vault alone; and the vault's account-issued + // names the account, never a value. + t.Run("a wanted account is kept by its user and read by the vault alone", func(t *testing.T) { + for _, w := range []WantedAccount{ + {User: "ace.mesh-build-go", Kind: KindModule, Node: "ace", Module: "mesh-build-go", URL: "nats://novox.mesh:4222", + Fingerprint: "ab", Claims: []ClaimedSeat{{Seat: "node-build-go", Scope: "node", Serves: []string{"build"}}}}, + {User: "novox.mesh-mail.send", Kind: KindProcess, Node: "novox", Module: "mesh-mail", Process: "send", + URL: "nats://novox.mesh:4222"}, + {User: "provisioner.g14", Kind: KindProvisioner, Node: "g14", Module: "mesh-provisioner-client", + URL: "nats://novox.mesh:4222", Fingerprint: "ab"}, + } { + if err := w.Valid(); err != nil { + t.Fatalf("%s: %v", w.User, err) + } + raw, err := json.Marshal(w) + if err != nil { + t.Fatal(err) + } + back, err := ReadWantedAccount(w.User, raw) + if err != nil || !back.Same(w) { + t.Errorf("%s does not round-trip: %+v, %v", w.User, back, err) + } + if got := AccountsSubject(w.User); got != "$KV.mesh-grants_accounts."+w.User { + t.Errorf("%s is kept at %s", w.User, got) + } + if _, err := ReadWantedAccount("someone.else", raw); err == nil { + t.Errorf("%s was read under another user's key", w.User) + } + } + for _, bad := range []WantedAccount{ + {User: "ace.x", Kind: KindModule, Node: "ace", Module: "y", URL: "nats://a:1"}, + {User: "ace.x.p", Kind: KindProcess, Node: "ace", Module: "x", URL: "nats://a:1"}, + {User: "node.ace", Kind: KindNode, Node: "ace", URL: "nats://a:1"}, + {User: "controller", Kind: KindController, URL: "nats://a:1"}, + {User: "ace.x", Kind: KindModule, Node: "ace", Module: "x"}, + } { + if bad.Valid() == nil { + t.Errorf("%+v is taken as a wanted account", bad) + } + } + if ReaderMay("mesh-delivery-assigner", GrantsAccounts) || !ReaderMay(VaultSeat, GrantsAccounts) { + t.Errorf("%s is read by %v alone, want the vault's", GrantsAccounts, NamedReaders[GrantsAccounts]) + } + e := AccountIssued{User: "ace.x", Kind: KindModule, Node: "ace", Module: "x", Generation: 1} + raw, _ := json.Marshal(e) + if strings.Contains(string(raw), "password") || strings.Contains(string(raw), "hash") || + AccountIssuedSubject != "mesh.mod.mesh-vault.event.account-issued" { + t.Errorf("account-issued says %s at %s", raw, AccountIssuedSubject) + } + }) } diff --git a/go/buscompose/userlist_switch.go b/go/buscompose/userlist_switch.go index a827741..f03427c 100644 --- a/go/buscompose/userlist_switch.go +++ b/go/buscompose/userlist_switch.go @@ -77,8 +77,9 @@ func grantsOwnObjects(p Principal, pattern string) bool { // readers too (announce.State.Readers); both are honoured. const VaultBusAccounts = "mesh-vault.bus-accounts" -// NamedReaders are the states only the modules named may read, by `.`. -var NamedReaders = map[string][]string{VaultBusAccounts: {GrantsModule}} +// NamedReaders are the states only the modules named may read, by `.`: the grants part's wanted accounts +// are read by the vault alone (novox/hq ADR 0561 §2). +var NamedReaders = map[string][]string{VaultBusAccounts: {GrantsModule}, GrantsAccounts: {VaultSeat}} // ReaderMay says whether module may read the state read (`.`) by the named readers: always, for a state // that names none.