diff --git a/modules/mesh-grants/grantsstate/accounts.go b/modules/mesh-grants/grantsstate/accounts.go new file mode 100644 index 00000000..bb6e92c2 --- /dev/null +++ b/modules/mesh-grants/grantsstate/accounts.go @@ -0,0 +1,173 @@ +package grantsstate + +import ( + "encoding/json" + "fmt" + "regexp" + "slices" + "strings" + "time" + + "git.novox.be/novox/mesh-sdk/go/buscompose" +) + +// **The accounts the grants part wants** (novox/hq ADR 0561 §1, §2; ADR 0634 §1, ADR 0635 §4: the layout of a state is its +// owner's, published with it). For each account the mesh mints (a module's, a module's process's, a machine's provisioner +// client's, a ledger'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. The vault reads 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. 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 in `reads` (`.`). +const GrantsAccounts = "mesh-grants." + AccountsState + +// LedgersSecret is the tool runner's own secret holding its machine's ledger accounts, a map of module to credential +// (ADR 0597 §9). +const LedgersSecret = "ledgers" + +// mintedKinds are the kinds of account the vault mints (ADR 0561 §1). +var mintedKinds = []buscompose.Kind{buscompose.KindModule, buscompose.KindNodeTools, buscompose.KindProcess, buscompose.KindProvisioner, buscompose.KindLedger} + +// safeSubject refuses a name that would change the meaning of a subject rather than sit inside one. +var safeSubject = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) + +// 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 buscompose.KindModule (buscompose.KindNodeTools for the runtime, named as a module is), buscompose.KindProcess, buscompose.KindProvisioner or + // buscompose.KindLedger. + Kind buscompose.Kind `json:"kind"` + Node string `json:"node"` + Module string `json:"module"` + // Process is a buscompose.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"` +} + +// principal is the account as the composer names it. +func (w WantedAccount) principal() buscompose.Principal { + return buscompose.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 == buscompose.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 != buscompose.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 != buscompose.KindModule && w.Kind != buscompose.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); a ledger account in the tool runner's `ledgers`, which keeps every +// ledger account of its machine, by module (ADR 0597 §9). +func (w WantedAccount) OwnSecret() string { + switch w.Kind { + case buscompose.KindLedger: + return LedgersSecret + case buscompose.KindProcess: + return "broker-" + w.Process + case buscompose.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() +} + +// HeldCredential is the value the account's holder reads, exactly as the controller builds it today (issueWith, +// sealProcess and sealProvisionerClient): a module's `{url, fingerprint, node, module, user, password, claims}`, a +// process's without claims, a provisioner client's without module or claims; an empty fingerprint or claims left out. +func HeldCredential(w WantedAccount, password string) ([]byte, error) { + if err := w.Valid(); err != nil { + return nil, err + } + if password == "" { + return nil, fmt.Errorf("%s: an account is held with its password", w.User) + } + switch w.Kind { + case buscompose.KindProcess: + return json.Marshal(struct { + URL string `json:"url"` + Fingerprint string `json:"fingerprint,omitempty"` + Node string `json:"node"` + Module string `json:"module"` + User string `json:"user"` + Password string `json:"password"` + }{URL: w.URL, Fingerprint: w.Fingerprint, Node: w.Node, Module: w.Module, User: w.User, Password: password}) + case buscompose.KindProvisioner: + return json.Marshal(struct { + URL string `json:"url"` + Fingerprint string `json:"fingerprint,omitempty"` + Node string `json:"node"` + User string `json:"user"` + Password string `json:"password"` + }{URL: w.URL, Fingerprint: w.Fingerprint, Node: w.Node, User: w.User, Password: password}) + } + return json.Marshal(struct { + URL string `json:"url"` + Fingerprint string `json:"fingerprint,omitempty"` + Node string `json:"node"` + Module string `json:"module"` + User string `json:"user"` + Password string `json:"password"` + Claims []ClaimedSeat `json:"claims,omitempty"` + }{URL: w.URL, Fingerprint: w.Fingerprint, Node: w.Node, Module: w.Module, User: w.User, Password: password, + Claims: w.Claims}) +} diff --git a/modules/mesh-grants/grantsstate/accounts_test.go b/modules/mesh-grants/grantsstate/accounts_test.go new file mode 100644 index 00000000..6b57623f --- /dev/null +++ b/modules/mesh-grants/grantsstate/accounts_test.go @@ -0,0 +1,56 @@ +package grantsstate + +import ( + "encoding/json" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/buscompose" +) + +// The wanted account is the grants part's state layout: a state `accounts` read as mesh-grants.accounts, each key the +// bus user, valid only when the user is the one the composer derives, held in the module's own secret (a ledger's in the +// tool runner's `ledgers`), and given to its holder in the shape the controller's credential had. +func TestAWantedAccountIsTheStatesLayout(t *testing.T) { + if AccountsState != "accounts" || GrantsAccounts != "mesh-grants.accounts" || LedgersSecret != "ledgers" { + t.Errorf("the state is %q read as %q, the ledgers' secret %q", AccountsState, GrantsAccounts, LedgersSecret) + } + url := "nats://10.0.0.1:4222" + for name, c := range map[string]struct { + w WantedAccount + secret string + bad string + }{ + "module": {WantedAccount{User: "a.m", Kind: buscompose.KindModule, Node: "a", Module: "m", URL: url}, "broker", ""}, + "ledger": {WantedAccount{User: "ledger.a.m", Kind: buscompose.KindLedger, Node: "a", Module: "m", URL: url}, LedgersSecret, ""}, + "process": {WantedAccount{User: "a.m.p", Kind: buscompose.KindProcess, Node: "a", Module: "m", Process: "p", URL: url}, "broker-p", ""}, + "provisioner": {WantedAccount{User: "provisioner.a", Kind: buscompose.KindProvisioner, Node: "a", Module: "m", URL: url}, "provisioner-broker", ""}, + "other user": {WantedAccount{User: "x.m", Kind: buscompose.KindModule, Node: "a", Module: "m", URL: url}, "", "is not the user"}, + "a node": {WantedAccount{User: "node.a", Kind: buscompose.KindNode, Node: "a", Module: "m", URL: url}, "", "never mints"}, + "no address": {WantedAccount{User: "a.m", Kind: buscompose.KindModule, Node: "a", Module: "m"}, "", "no bus address"}, + } { + err := c.w.Valid() + if c.bad == "" { + if err != nil || c.w.OwnSecret() != c.secret { + t.Errorf("%s: %v, held in %q, want %q", name, err, c.w.OwnSecret(), c.secret) + } + raw, _ := json.Marshal(c.w) + if back, err := ReadWantedAccount(c.w.User, raw); err != nil || !back.Same(c.w) { + t.Errorf("%s: read back %+v, %v", name, back, err) + } + if _, err := ReadWantedAccount("someone.else", raw); err == nil { + t.Errorf("%s: a key of another user was read", name) + } + if held, err := HeldCredential(c.w, "pw"); err != nil || !strings.Contains(string(held), `"password":"pw"`) { + t.Errorf("%s: held %s, %v", name, held, err) + } + continue + } + if err == nil || !strings.Contains(err.Error(), c.bad) { + t.Errorf("%s: %v, want a refusal saying %q", name, err, c.bad) + } + } + if _, err := HeldCredential(WantedAccount{User: "a.m", Kind: buscompose.KindModule, Node: "a", Module: "m", URL: url}, ""); err == nil { + t.Error("an account was held with no password") + } +} diff --git a/modules/mesh-grants/internal/buscompose/accounts.go b/modules/mesh-grants/internal/buscompose/accounts.go index 9fcbe630..923b3056 100644 --- a/modules/mesh-grants/internal/buscompose/accounts.go +++ b/modules/mesh-grants/internal/buscompose/accounts.go @@ -1,11 +1,9 @@ package buscompose import ( - "encoding/json" - "fmt" - "slices" - "strings" "time" + + "git.novox.be/novox/mesh-delivery/modules/mesh-grants/grantsstate" ) // **The accounts the grants part wants, and the one shape a held account takes** (novox/hq ADR 0561 §1, §2; issue 774). @@ -21,11 +19,23 @@ import ( // 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" +// The layout of the state is the grants part's published package (grantsstate; ADR 0634 §1): the vault imports it from there, and +// this composer names it. +const ( + AccountsState = grantsstate.AccountsState + GrantsAccounts = grantsstate.GrantsAccounts + LedgersSecret = grantsstate.LedgersSecret +) -// GrantsAccounts is that state as a reader names it (`.`). -const GrantsAccounts = GrantsModule + "." + AccountsState +type ( + ClaimedSeat = grantsstate.ClaimedSeat + WantedAccount = grantsstate.WantedAccount +) + +var ( + ReadWantedAccount = grantsstate.ReadWantedAccount + HeldCredential = grantsstate.HeldCredential +) // AccountsBucket is the bucket the state lives in. var AccountsBucket = BucketName(GrantsModule, AccountsState) @@ -39,110 +49,6 @@ 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, KindProvisioner or - // KindLedger. - 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"` -} - -// LedgersSecret is the tool runner's own secret holding its machine's ledger accounts, a map of module to credential -// (ADR 0597 §9). -const LedgersSecret = "ledgers" - -// mintedKinds are the kinds of account the vault mints (ADR 0561 §1). -var mintedKinds = []Kind{KindModule, KindNodeTools, KindProcess, KindProvisioner, KindLedger} - -// 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); a ledger account in the tool runner's `ledgers`, which keeps every -// ledger account of its machine, by module (ADR 0597 §9). -func (w WantedAccount) OwnSecret() string { - switch w.Kind { - case KindLedger: - return LedgersSecret - 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"` @@ -153,44 +59,3 @@ type AccountIssued struct { Generation int `json:"generation"` At time.Time `json:"at"` } - -// HeldCredential is the value the account's holder reads, exactly as the controller builds it today (issueWith, -// sealProcess and sealProvisionerClient): a module's `{url, fingerprint, node, module, user, password, claims}`, a -// process's without claims, a provisioner client's without module or claims; an empty fingerprint or claims left out. -func HeldCredential(w WantedAccount, password string) ([]byte, error) { - if err := w.Valid(); err != nil { - return nil, err - } - if password == "" { - return nil, fmt.Errorf("%s: an account is held with its password", w.User) - } - switch w.Kind { - case KindProcess: - return json.Marshal(struct { - URL string `json:"url"` - Fingerprint string `json:"fingerprint,omitempty"` - Node string `json:"node"` - Module string `json:"module"` - User string `json:"user"` - Password string `json:"password"` - }{URL: w.URL, Fingerprint: w.Fingerprint, Node: w.Node, Module: w.Module, User: w.User, Password: password}) - case KindProvisioner: - return json.Marshal(struct { - URL string `json:"url"` - Fingerprint string `json:"fingerprint,omitempty"` - Node string `json:"node"` - User string `json:"user"` - Password string `json:"password"` - }{URL: w.URL, Fingerprint: w.Fingerprint, Node: w.Node, User: w.User, Password: password}) - } - return json.Marshal(struct { - URL string `json:"url"` - Fingerprint string `json:"fingerprint,omitempty"` - Node string `json:"node"` - Module string `json:"module"` - User string `json:"user"` - Password string `json:"password"` - Claims []ClaimedSeat `json:"claims,omitempty"` - }{URL: w.URL, Fingerprint: w.Fingerprint, Node: w.Node, Module: w.Module, User: w.User, Password: password, - Claims: w.Claims}) -} diff --git a/modules/mesh-grants/internal/buscompose/nats.go b/modules/mesh-grants/internal/buscompose/nats.go index 29e806ff..18e1e655 100644 --- a/modules/mesh-grants/internal/buscompose/nats.go +++ b/modules/mesh-grants/internal/buscompose/nats.go @@ -15,49 +15,51 @@ package buscompose import ( "errors" "fmt" - "git.novox.be/novox/mesh-contracts/go/seats/meshbroker" - "git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshnodeenroller" "regexp" "slices" "sort" "strings" "time" + "git.novox.be/novox/mesh-contracts/go/seats/meshbroker" + "git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshnodeenroller" + mv "git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault" + sdkbc "git.novox.be/novox/mesh-sdk/go/buscompose" "git.novox.be/novox/mesh-sdk/go/catalogue" ) // A Kind is what a principal is, which decides the shape of its authority rather than its // contents: a module's comes from its declaration, a host's from its node, and the controller's // and the enrolment user's are fixed. -type Kind string +type Kind = sdkbc.Kind const ( - KindModule Kind = "module" - KindNode Kind = "node" - KindEnrolment Kind = "enrolment" + KindModule = sdkbc.KindModule + KindNode = sdkbc.KindNode + KindEnrolment = sdkbc.KindEnrolment // KindPerson is somebody reaching the mesh's tools from a workstation (design 25 §7). Its // authority is a list of tools and nothing else — not control, not declarations, not builds, // and no ability to answer anything, because a person asks. - KindPerson Kind = "person" + KindPerson = sdkbc.KindPerson // KindNodeTools is a machine's tool runtime (novox/hq ADR 0175, to-be 38): one process per // node, on the host side, serving every assigned module's tools and every held seat's verbs. // Its authority is the union of what the modules it carries would each have had for their // tools — and nothing of what they consume, because tools are what it runs, not reactions. - KindNodeTools Kind = "node-tools" + KindNodeTools = sdkbc.KindNodeTools // KindProvisioner is a machine's provisioner client (novox/hq ADR 0349 clauses 6 and 11, design 57 P3): a // service of the module holding NodeProvisionerSeat, outside the tool runner, under an account of its own so // the vault can tell it from an agent. Its authority is provisionerGrants and nothing else. - KindProvisioner Kind = "provisioner" + KindProvisioner = sdkbc.KindProvisioner // KindProcess is one process of a module that declares bus (novox/hq ADR 0436 §1, §2): a service of that // module, outside the tool runner and outside the module's own account, under a bus account scoped to // exactly what its bus says — never root, never the operator account, and never able to answer a tool // call or a seat's verb (§3). One per process and machine. - KindProcess Kind = "process" + KindProcess = sdkbc.KindProcess // KindLedger is one module assignment's ledger credential (novox/hq ADR 0597 §9, design 25 § 12.4): held by that // machine's tool runner, never by the module's own code, and the only account that publishes on that module's ledger // histories and snapshots. One per module assignment, whose module asks for a ledger in `requires`. - KindLedger Kind = "ledger" + KindLedger = sdkbc.KindLedger ) // The node's tool runner (novox/hq ADR 0175) is the module whose manifest says it is (`tool-runner`, Declared.ToolRunner;