diff --git a/checks/noplacement/assignments-readers b/checks/noplacement/assignments-readers index 3472ca33..38daeb29 100644 --- a/checks/noplacement/assignments-readers +++ b/checks/noplacement/assignments-readers @@ -4,6 +4,5 @@ # controller (novox/hq ADR 0549 §3, §6; issue 774). gitea the provisioner's plan of a module (go/provider) rides only in an assignment's value; its home after the lease is issue 774's provisioner-plan subtask keycloak the provisioner's plan of a module (go/provider), as gitea -mesh-vault the provisioner's plan, which only an assignment's value carries; its home is issue 774's provisioner-plan subtask postgres the provisioner's plan of a module (go/provider), as gitea route-adapter the provisioner's plan of a module (go/provider), as gitea diff --git a/modules/mesh-vault/cmd/mesh-vault/askers.go b/modules/mesh-vault/cmd/mesh-vault/askers.go index e5267120..cad8a1de 100644 --- a/modules/mesh-vault/cmd/mesh-vault/askers.go +++ b/modules/mesh-vault/cmd/mesh-vault/askers.go @@ -1,6 +1,8 @@ package main import ( + "git.novox.be/novox/mesh-sdk/go/provisions" + "encoding/json" "fmt" "strings" @@ -72,8 +74,23 @@ func (l AllowList) May(caller, name string) (bool, string) { // internal/broker, KindProvisioner). const ProvisionerPrefix = "provisioner." -// AssignmentsRead is the controller's assignments mirror as this module reads it (its manifest's `reads`). -const AssignmentsRead = "mesh-controller.assignments" +// AssignmentsRead is where the vault reads each module's provisioning plan on each node (its manifest's `reads`): the +// seat mesh-provisioner's state `plans`, `.` (novox/hq ADR 0570 clause 2), no longer the controller's +// assignments. A plan is read as the assignment's value carried it (asAssignment), so the rules that read a plan are +// unchanged. +const AssignmentsRead = provisions.PlansRead + +// asAssignment is a plan's key in the form the vault's rules read: `{node, module, provisioning: plan}`, nil for none. +func asAssignment(key string, raw []byte, err error) ([]byte, error) { + if err != nil || raw == nil { + return raw, err + } + p, err := provisions.DecodePlan(raw) + if err != nil { + return nil, fmt.Errorf("the plan %s: %w", key, err) + } + return json.Marshal(map[string]any{"node": p.Node, "module": p.Module, "provisioning": p.Plan}) +} // AssignmentReader reads one value of the assignments mirror, `.`: nil when there is none. type AssignmentReader func(key string) ([]byte, error) diff --git a/modules/mesh-vault/cmd/mesh-vault/intake.go b/modules/mesh-vault/cmd/mesh-vault/intake.go index 99a272a7..b887916b 100644 --- a/modules/mesh-vault/cmd/mesh-vault/intake.go +++ b/modules/mesh-vault/cmd/mesh-vault/intake.go @@ -120,6 +120,15 @@ func fromTheController(caller, verb string) error { return nil } +// fromAGrantedCaller refuses a caller the runtime did not prove: `rotate` and `issued` answer every caller whose +// manifest invokes them, which the bus grants it (novox/hq ADR 0570, Consequences), no longer the controller alone. +func fromAGrantedCaller(caller, verb string) error { + if strings.TrimSpace(caller) == "" || caller == stdio.Unproven { + return fmt.Errorf("%s answers a caller the runtime proves, and it proved none", verb) + } + return nil +} + // openCall makes a key for one call and keeps its private half here until the call is taken or two minutes // have passed. func (v *Vault) openCall(verb, secret, by string, ask *AskRecord) (string, string, error) { @@ -495,7 +504,7 @@ func askedByName(caller string) string { // 10, ADR 0305 with the vault in the controller's place): the controller asks the provider's tool, on the // provider's machine, with the call's key, and passes on what it answered. func (v *Vault) IssuedOpen(caller, provision, provider, module string) (Opened, error) { - if err := fromTheController(caller, "issued"); err != nil { + if err := fromAGrantedCaller(caller, "issued"); err != nil { return Opened{}, err } return v.openIssued(provision, provider, module) @@ -520,7 +529,7 @@ func (v *Vault) openIssued(provision, provider, module string) (Opened, error) { // IssuedTake opens what the provider's tool sealed to the call's key and keeps it as the provision's one // credential. The same value again writes nothing (ADR 0305 §5): every holder that has it is left as it is. func (v *Vault) IssuedTake(caller, call, sealed string) (Changed, error) { - if err := fromTheController(caller, "issued"); err != nil { + if err := fromAGrantedCaller(caller, "issued"); err != nil { return Changed{}, err } c, err := v.takeCall("issued", call) @@ -643,7 +652,7 @@ func (v *Vault) TakeIssued(caller, provision, provider, module, tool string) (Is // credential, as before) or one per holder (`holders`: node, module and sealed each, kept as that holder's own and // handed to it alone, perholder.go). Never both. func (v *Vault) TakeIssuedFor(caller, provision, provider, module, tool string, holders []Holder) (Issued, error) { - if err := fromTheController(caller, "issued"); err != nil { + if err := fromAGrantedCaller(caller, "issued"); err != nil { return Issued{}, err } return v.takeIssuedFor(caller, provision, provider, module, tool, holders) @@ -747,9 +756,11 @@ func (n Narrow) String() string { // value shared by every holder (ADR 0158) is one value, so it is never rotated for one consumer alone: refused, and // nothing changes. A module's own secret names no consumer. func (v *Vault) RotateFor(caller, secret string, one Narrow, by, why string) (Changed, error) { + if err := fromAGrantedCaller(caller, "rotate"); err != nil { + return Changed{}, err + } if caller != ControllerCaller { - return Changed{}, fmt.Errorf("the vault rotates for the controller alone, which knows whether the module's "+ - "secrets are filled from the vault yet; %s is not the controller: `secret rotate` at the controller", caller) + by = "" // only the controller relays who asked at its terminal; any other caller is who asked } if strings.TrimSpace(why) == "" { return Changed{}, errors.New("not rotated: a rotation says why it was asked (`why`)") diff --git a/modules/mesh-vault/cmd/mesh-vault/intake_test.go b/modules/mesh-vault/cmd/mesh-vault/intake_test.go index d9701db6..6d844cf0 100644 --- a/modules/mesh-vault/cmd/mesh-vault/intake_test.go +++ b/modules/mesh-vault/cmd/mesh-vault/intake_test.go @@ -307,9 +307,12 @@ func TestAnIssuingProviderAnswersOnlyTheVaultsCallSealedToTheVault(t *testing.T) t.Errorf("the provider's tool answered %q", caller) } } - // The vault takes the steps from the controller alone. - if _, err := v.IssuedOpen("anchor.node-tools", "plex-api", "anchor", "plex"); err == nil { - t.Error("the vault opened an issued call for another caller") + // The vault takes the steps from a caller the runtime proved, whose manifest the bus granted the verb (ADR 0570), + // and from no unproven one. + for _, caller := range []string{"unproven", ""} { + if _, err := v.IssuedOpen(caller, "plex-api", "anchor", "plex"); err == nil { + t.Errorf("the vault opened an issued call for %q", caller) + } } changed, err := issue(t, v, provider) @@ -437,17 +440,17 @@ func TestRotateKeepsWhoAskedAndWhoCarriedIt(t *testing.T) { } } -// Rotate is the controller's alone, and says why (the review of issue 675): only the controller knows whether a -// module is filled from the vault yet, so a rotation asked by any other caller — a proven one included — would -// drift from the value the controller holds; and a rotation without a reason is refused. Neither changes anything. -func TestRotateIsTheControllersAloneAndSaysWhy(t *testing.T) { +// Rotate answers a caller the runtime proved, whose manifest the bus granted it (novox/hq ADR 0570 Consequences), and +// says why: an unproven caller, and a rotation without a reason, are refused and change nothing; a proven caller +// other than the controller is who asked, whatever `by` it says. +func TestRotateIsAProvenCallersAndSaysWhy(t *testing.T) { v := newVault(t, nil) if _, err := v.Mint("own/g14/dunst/token", "the test"); err != nil { t.Fatal(err) } - for _, caller := range []string{"g14.claude-code", "g14.node-provisioner", "unproven", ""} { + for _, caller := range []string{"unproven", ""} { if _, err := v.Rotate(caller, "own/g14/dunst/token", "jochen", "leaked"); err == nil || - !strings.Contains(err.Error(), "controller alone") { + !strings.Contains(err.Error(), "proves") { t.Errorf("%q rotated: %v", caller, err) } } @@ -460,6 +463,12 @@ func TestRotateIsTheControllersAloneAndSaysWhy(t *testing.T) { if rec, _, _ := v.Store.Read("own/g14/dunst/token"); rec.Generation != 1 { t.Fatalf("a refused rotation changed the secret: generation %d", rec.Generation) } + if _, err := v.Rotate("g14.claude-code", "own/g14/dunst/token", "jochen", "leaked in a log"); err != nil { + t.Fatal(err) + } + if rec, _, _ := v.Store.Read("own/g14/dunst/token"); rec.Generation != 2 || rec.ChangedBy != "g14.claude-code" { + t.Errorf("a session's rotation is recorded by %q at generation %d", rec.ChangedBy, rec.Generation) + } } // The names the vault keeps them under are names, never paths. diff --git a/modules/mesh-vault/cmd/mesh-vault/main.go b/modules/mesh-vault/cmd/mesh-vault/main.go index abd38dfb..089c2abc 100644 --- a/modules/mesh-vault/cmd/mesh-vault/main.go +++ b/modules/mesh-vault/cmd/mesh-vault/main.go @@ -8,6 +8,10 @@ package main import ( + "git.novox.be/novox/mesh-sdk/go/provisions" + + "encoding/json" + "errors" "fmt" "os" @@ -50,12 +54,35 @@ func build() *Env { // Who may ask (askers.go): a node's provisioner client, for what its module's plan there names. // Where it sees a consumer bind a provider (pair.go): the same mirror, every key. // The assignments as the vault's watch keeps them (watchAssignments), read over the bus until it has caught up. - assigned = &WatchedAssignments{Fallback: readAssignment} - env.Vault = &Vault{Store: store, Own: own, Askers: Declared{Read: assigned.Read, Confirm: readAssignment, Store: store}, + assigned = &WatchedAssignments{Fallback: readPlanRaw} + watchedPlan := func(key string) ([]byte, error) { raw, err := assigned.Read(key); return asAssignment(key, raw, err) } + env.Vault = &Vault{Store: store, Own: own, Askers: Declared{Read: watchedPlan, Confirm: 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, WantRead: readWant, HaveKeys: func() ([]string, error) { return stdio.State(HaveRead).Keys() }, Placement: placement, Signers: busSigners{}, - AskCallers: askCallers(os.Getenv("MESH_VAULT_SECRET_ASK_CALLERS"))} + AskCallers: askCallers(os.Getenv("MESH_VAULT_SECRET_ASK_CALLERS")), + Bound: Bound{ + Get: func(key string) ([]byte, error) { + e, err := stdio.State(BoundState).Get(key) + if err != nil || e == nil { + return nil, err + } + return e.Value, nil + }, + Put: func(key string, value []byte) error { + _, err := stdio.State(BoundState).Put(key, json.RawMessage(value)) + return err + }, + Delete: func(key string) error { return stdio.State(BoundState).Delete(key) }, + Keys: func() ([]string, error) { return stdio.State(BoundState).Keys() }, + Provision: func(key string) ([]byte, error) { + e, err := stdio.State(provisions.ProvisionsRead).Get(key) + if err != nil || e == nil { + return nil, err + } + return e.Value, nil + }, + }} return env } @@ -222,6 +249,12 @@ func adoptOperatorKey(env *Env) error { // readAssignment is one value of the controller's assignments mirror, read through the runtime under the vault's // own account (its manifest `reads` it), or nil when there is none. func readAssignment(key string) ([]byte, error) { + raw, err := readPlanRaw(key) + return asAssignment(key, raw, err) +} + +// readPlanRaw is one key of the seat mesh-provisioner's plans as it is kept, nil when there is none. +func readPlanRaw(key string) ([]byte, error) { e, err := stdio.State(AssignmentsRead).Get(key) if err != nil || e == nil { return nil, err diff --git a/modules/mesh-vault/cmd/mesh-vault/mint_integration_test.go b/modules/mesh-vault/cmd/mesh-vault/mint_integration_test.go index 81f503b1..133c3131 100644 --- a/modules/mesh-vault/cmd/mesh-vault/mint_integration_test.go +++ b/modules/mesh-vault/cmd/mesh-vault/mint_integration_test.go @@ -317,7 +317,7 @@ func TestTheVaultWholeMintsOnTheBusOnlyFromTheLeaseWrite(t *testing.T) { t.Fatal(err) } for _, b := range []string{"mesh-vault_bus-accounts", "mesh-grants_accounts", "mesh-controller_lease", - "mesh-controller_assignments", "node-engine_have", "mesh-delivery-assigner_placement"} { + "mesh-provisioner_plans", "node-engine_have", "mesh-delivery-assigner_placement"} { if _, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: b, History: 2}); err != nil { t.Fatal(err) } diff --git a/modules/mesh-vault/cmd/mesh-vault/outcomes.go b/modules/mesh-vault/cmd/mesh-vault/outcomes.go new file mode 100644 index 00000000..8f4db79a --- /dev/null +++ b/modules/mesh-vault/cmd/mesh-vault/outcomes.go @@ -0,0 +1,174 @@ +package main + +// The vault's outcome for each credential it provides (novox/hq ADR 0570 clauses 3, 6, 7): an entry in its own state +// `bound`, one per credential provision `..secret-[.]`, which the holder of +// mesh-provisioner reads to set that key's state. The entry names the value by the vault's own name for it +// (provisions.VaultName, as its verb `own` names it) and its generation, never the value. A value the vault holds is +// provided; one only the provider's software issues, or only a person gives, waits for them; one the vault has not +// made yet has no entry, and the key stays asked. An offer whose form is none has no credential and no entry. An entry +// is let go only once its key is retiring with the operator's warrant, or gone. + +import ( + "bytes" + "encoding/json" + "fmt" + "sort" + + "git.novox.be/novox/mesh-sdk/go/provisions" +) + +// BoundState is the vault's state of outcome entries. +const BoundState = "bound" + +// Bound is the vault's own state `bound`, and what it reads of the seat's provisions. +type Bound struct { + Get func(key string) ([]byte, error) + Put func(key string, value []byte) error + Delete func(key string) error + Keys func() ([]string, error) + // Provision is one key of mesh-provisioner's provisions, nil for none. + Provision func(key string) ([]byte, error) +} + +// BoundOutcome is one entry of `bound`, in the provider helper's entry layout with the generation beside it. +type BoundOutcome struct { + Provider string `json:"provider"` + ProviderModule string `json:"provider-module"` + Provision string `json:"provision"` + Consumer string `json:"consumer"` + Module string `json:"module"` + Local string `json:"local,omitempty"` + VaultName string `json:"vault-name,omitempty"` + State string `json:"state"` + Why string `json:"why,omitempty"` + Generation int `json:"generation,omitempty"` +} + +// OutcomePass writes each credential's outcome that differs and lets go each entry no plan names whose key is retiring +// with the operator's warrant or gone. It answers what it wrote and removed. +func (v *Vault) OutcomePass() ([]string, error) { + if v.Bound.Get == nil || v.Assignments.Keys == nil { + return nil, nil + } + want, err := v.outcomes() + if err != nil { + return nil, err + } + var said []string + for _, key := range sortedOutcomeKeys(want) { + raw, _ := json.Marshal(want[key]) + if have, err := v.Bound.Get(key); err == nil && bytes.Equal(have, raw) { + continue + } + if err := v.Bound.Put(key, raw); err != nil { + return said, fmt.Errorf("the outcome %s: %w", key, err) + } + said = append(said, key+" "+want[key].State) + } + keys, err := v.Bound.Keys() + if err != nil { + return said, err + } + for _, key := range keys { + if _, still := want[key]; still { + continue + } + if !v.letGo(key) { + continue + } + if err := v.Bound.Delete(key); err != nil { + return said, err + } + said = append(said, key+" let go") + } + return said, nil +} + +// letGo says an entry no plan names may go: its key is gone, or retiring with the operator's Approve. +func (v *Vault) letGo(key string) bool { + if v.Bound.Provision == nil { + return false + } + raw, err := v.Bound.Provision(key) + if err != nil { + return false + } + if raw == nil { + return true + } + p, err := provisions.Decode(raw) + return err == nil && p.State == provisions.Retiring && p.Warrant != nil && p.Warrant.Option == "approve" +} + +// outcomes is every credential's outcome the plans call for, by its key. +func (v *Vault) outcomes() (map[string]BoundOutcome, error) { + keys, err := v.Assignments.Keys() + if err != nil { + return nil, fmt.Errorf("the plans' keys: %w", err) + } + out := map[string]BoundOutcome{} + for _, k := range keys { + raw, err := v.Assignments.Read(k) + if err != nil { + return nil, fmt.Errorf("the plan %s: %w", k, err) + } + if raw == nil { + continue + } + var a plannedProvisions + if err := json.Unmarshal(raw, &a); err != nil || a.Provisioning == nil { + continue + } + consumer := provisions.End{Module: a.Module, Node: a.Node} + for _, p := range a.Provisioning.Provisions { + provider := provisions.End{Module: p.ProviderModule, Node: p.Provider} + if p.ProviderModule == provisions.VaultModule && p.Provision == "secret" { + // The vault's own offer: a value it hands the consumer's client on request (`get`), which it serves as + // soon as it runs. + key, err := provisions.Key(a.Node, a.Module, p.Provision, p.Local) + if err == nil { + out[key] = BoundOutcome{Provider: p.Provider, ProviderModule: provisions.VaultModule, Provision: p.Provision, + Consumer: a.Node, Module: a.Module, Local: p.Local, State: provisions.Provided, + Why: "the vault hands it to the consumer's client on request"} + } + continue + } + ck, ok := provisions.CredentialKey(p.CredentialForm, consumer, provider, p.Provision, p.Local) + if !ok { + continue // form none: no credential, nothing minted + } + parent := provisions.Provision{Provision: p.Provision, Local: p.Local, Consumer: consumer, Provider: provider} + name := provisions.VaultName(p.CredentialForm, parent) + cn, cm, cp, cl, err := provisions.ParseKey(ck) + if err != nil || name == "" { + continue + } + o := BoundOutcome{Provider: p.Provider, ProviderModule: provisions.VaultModule, Provision: cp, Consumer: cn, + Module: cm, Local: cl, VaultName: name} + rec, held, err := v.Store.Read(name) + switch { + case err != nil: + o.State, o.Why = provisions.Failed, "the vault cannot read "+name+": "+err.Error() + case held: + o.State, o.Generation = provisions.Provided, rec.Generation + case p.CredentialForm == FormIssued: + o.State, o.Why = provisions.Waits, "the provider's software issues it (ADR 0305): `issued` brings it in" + case p.CredentialForm == FormGiven: + o.State, o.Why = provisions.Waits, "a person gives it (ADR 0337): `accept` brings it in" + default: + continue // not made yet: the key stays asked until the pair pass makes it + } + out[ck] = o + } + } + return out, nil +} + +func sortedOutcomeKeys(m map[string]BoundOutcome) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} diff --git a/modules/mesh-vault/cmd/mesh-vault/outcomes_test.go b/modules/mesh-vault/cmd/mesh-vault/outcomes_test.go new file mode 100644 index 00000000..29bae5f9 --- /dev/null +++ b/modules/mesh-vault/cmd/mesh-vault/outcomes_test.go @@ -0,0 +1,122 @@ +package main + +import ( + "encoding/json" + "slices" + "sort" + "strings" + "testing" +) + +// The vault says each credential's outcome in its own state `bound`, by its own name for the value and its generation, +// never the value: held is provided, an issued or given value not yet held waits, one not made yet has no entry, an +// offer of form none has none, the vault's own offer is provided; an entry no plan names goes only once its key is gone +// or retiring with the operator's Approve (novox/hq ADR 0570 clauses 3, 5-7; issue 1954 V1). +func TestTheVaultSaysEachCredentialsOutcome(t *testing.T) { + v := newVault(t, NoAsker{}) + plan := func(node, module string, provisions ...map[string]any) []byte { + raw, _ := json.Marshal(map[string]any{"node": node, "module": module, "provisioning": map[string]any{"provisions": provisions}}) + return raw + } + entry := func(provision, provider, module, form string) map[string]any { + return map[string]any{"provision": provision, "provider": provider, "provider-module": module, "credential-form": form} + } + plans := map[string][]byte{ + "g14.gitea": plan("g14", "gitea", + entry("postgres-database", "ace", "postgres", "pair"), + entry("oidc-client", "novox", "keycloak", "shared"), + entry("media-token", "ace", "jellyfin", "issued"), + entry("bot-token", "ace", "telegram", "given"), + entry("route", "novox", "route-adapter", "none"), + entry("secret", "novox", "mesh-vault", "pair")), + } + v.Assignments = Mirror{ + Keys: func() ([]string, error) { + var k []string + for key := range plans { + k = append(k, key) + } + return k, nil + }, + Read: func(key string) ([]byte, error) { return plans[key], nil }} + bound := map[string][]byte{} + keys := map[string]string{} // the seat's provisions, by key: a state, or a raw value + v.Bound = Bound{ + Get: func(key string) ([]byte, error) { return bound[key], nil }, + Put: func(key string, value []byte) error { bound[key] = value; return nil }, + Delete: func(key string) error { delete(bound, key); return nil }, + Keys: func() ([]string, error) { + var out []string + for k := range bound { + out = append(out, k) + } + sort.Strings(out) + return out, nil + }, + Provision: func(key string) ([]byte, error) { + if raw, ok := keys[key]; ok { + return []byte(raw), nil + } + return nil, nil + }, + } + if _, err := v.Put("pair/ace/postgres-database/g14/gitea", "pw", "the test"); err != nil { + t.Fatal(err) + } + if _, err := v.OutcomePass(); err != nil { + t.Fatal(err) + } + read := func(key string) BoundOutcome { + var o BoundOutcome + _ = json.Unmarshal(bound[key], &o) + return o + } + for _, row := range []struct { + key, state, name string + generation int + }{ + {"g14.gitea.secret-postgres-database", "provided", "pair/ace/postgres-database/g14/gitea", 1}, + {"g14.gitea.secret-media-token", "waits", "provision/ace/media-token/g14/gitea", 0}, + {"g14.gitea.secret-bot-token", "waits", "provision/ace/bot-token/g14/gitea", 0}, + {"g14.gitea.secret", "provided", "", 0}, + } { + o := read(row.key) + if o.State != row.state || o.VaultName != row.name || o.Generation != row.generation || o.ProviderModule != "mesh-vault" { + t.Errorf("%s: %+v", row.key, o) + } + } + var all []string + for k := range bound { + all = append(all, k) + } + sort.Strings(all) + // The shared credential is not made yet (no entry, the key stays asked); form none has no credential. + if want := []string{"g14.gitea.secret", "g14.gitea.secret-bot-token", "g14.gitea.secret-media-token", + "g14.gitea.secret-postgres-database"}; !slices.Equal(all, want) { + t.Errorf("entries %v, want %v", all, want) + } + for _, raw := range bound { + if strings.Contains(string(raw), `"pw"`) { + t.Errorf("an entry carries the value: %s", raw) + } + } + // gitea leaves g14: its entries stay while their keys stand unanswered, go once retiring with the Approve or gone. + delete(plans, "g14.gitea") + keys["g14.gitea.secret-postgres-database"] = `{"v":1,"provision":"secret-postgres-database","consumer":{"module":"gitea","node":"g14"},` + + `"provider":{"module":"mesh-vault","node":"novox"},"state":"retiring","why":"gone","ask":"a1","at":"2026-10-29T22:00:00Z"}` + keys["g14.gitea.secret-bot-token"] = keys["g14.gitea.secret-postgres-database"] + keys["g14.gitea.secret-media-token"] = strings.Replace(keys["g14.gitea.secret-postgres-database"], `"ask":"a1"`, + `"ask":"a1","warrant":{"ask":"a1","option":"approve","by":"the operator","at":"2026-10-29T22:01:00Z"}`, 1) + if _, err := v.OutcomePass(); err != nil { + t.Fatal(err) + } + all = all[:0] + for k := range bound { + all = append(all, k) + } + sort.Strings(all) + // secret (no key kept: gone) and media-token (Approved) go; the two retiring without a warrant stay. + if want := []string{"g14.gitea.secret-bot-token", "g14.gitea.secret-postgres-database"}; !slices.Equal(all, want) { + t.Errorf("after the plan went: %v, want %v", all, want) + } +} diff --git a/modules/mesh-vault/cmd/mesh-vault/owncalls_test.go b/modules/mesh-vault/cmd/mesh-vault/owncalls_test.go index 5f303955..5629bc5d 100644 --- a/modules/mesh-vault/cmd/mesh-vault/owncalls_test.go +++ b/modules/mesh-vault/cmd/mesh-vault/owncalls_test.go @@ -205,7 +205,7 @@ func TestTheVaultAsksTheIssuingToolItselfAndOnlyASealedValueCrosses(t *testing.T } // Only the controller asks the vault to take it; a tool's name that is not one is refused; a tool that is not // answered, or answers no sealed value, keeps nothing. - for _, caller := range []string{"anchor.node-tools", "g14.claude-code", "unproven", ""} { + for _, caller := range []string{"unproven", ""} { if _, err := v.TakeIssued(caller, "plex-api", "anchor", "plex", "plex_issued_credential"); err == nil { t.Errorf("%q had the vault ask a provider", caller) } diff --git a/modules/mesh-vault/cmd/mesh-vault/pair.go b/modules/mesh-vault/cmd/mesh-vault/pair.go index 933fd895..ca0293a2 100644 --- a/modules/mesh-vault/cmd/mesh-vault/pair.go +++ b/modules/mesh-vault/cmd/mesh-vault/pair.go @@ -155,10 +155,12 @@ const ( FormIssued = "issued" // FormGiven is a value only a person gives (ADR 0337): the mesh never makes it. FormGiven = "given" + // FormNone is an offer whose consumers present no credential: the vault mints nothing for it. + FormNone = "none" ) // credentialForms are every form a plan entry may name. -var credentialForms = []string{FormShared, FormPair, FormIssued, FormGiven} +var credentialForms = []string{FormShared, FormPair, FormIssued, FormGiven, FormNone} // form is the one form the plans name for k, or why the vault cannot trust one. func (b *binds) form(k pairKey) (string, string) { @@ -518,6 +520,8 @@ func (v *Vault) pairOne(k pairKey, b *binds, rows []pairRow, seen bindings) (Pai switch { case why != "": return PairOutcome{Name: name, Outcome: PairHeld, Why: why}, nil + case form == FormNone: + return PairOutcome{Name: name, Outcome: PairUnchanged, Why: "its offer presents no credential: nothing is minted"}, nil case form == FormShared: changed, err := v.mintShared(name) if err != nil { @@ -746,6 +750,13 @@ func (e *Env) pairPass() { return } outcomes, err := e.Vault.PairPass() + if said, oerr := e.Vault.OutcomePass(); oerr != nil { + say("the credentials' outcomes are not all written: %v", oerr) + } else { + for _, s := range said { + say("outcome %s", s) + } + } e.mu.Lock() defer e.mu.Unlock() if err != nil && err.Error() != e.lastPairFailure { diff --git a/modules/mesh-vault/cmd/mesh-vault/tools.go b/modules/mesh-vault/cmd/mesh-vault/tools.go index b6fc006a..416e63cf 100644 --- a/modules/mesh-vault/cmd/mesh-vault/tools.go +++ b/modules/mesh-vault/cmd/mesh-vault/tools.go @@ -171,7 +171,7 @@ func definitions(env *Env) []toolDef { Description: "Make a secret anew (ADR 0114, ADR 0228): node, module and name for a module's own, or provider " + "and provision for a provision's credential. Refused for a value a provider's software issued, which " + "the software rotates, and for an accepted credential, which the vault does not remake. Never answers " + - "the value. From the controller alone, and with a why.", + "the value. For a caller the runtime proves, its manifest granted the verb, and with a why.", Input: map[string]any{"type": "object", "properties": map[string]any{ "node": str("the machine, for a module's own secret"), "module": str("the module"), "name": str("the secret's name in the module"), "provider": str("the provider's machine"), diff --git a/modules/mesh-vault/cmd/mesh-vault/vault.go b/modules/mesh-vault/cmd/mesh-vault/vault.go index 7d4aeec0..9b5634ad 100644 --- a/modules/mesh-vault/cmd/mesh-vault/vault.go +++ b/modules/mesh-vault/cmd/mesh-vault/vault.go @@ -35,7 +35,10 @@ type Vault struct { // Assignments is the controller's assignments mirror, where the vault sees a consumer bind a provider and // unbind (pair.go). Empty sees nothing, and makes no provision's credential. Assignments Mirror - Emit func(event string, body map[string]any) error + // Bound is the vault's own state of outcome entries, one per credential it provides, and its read of the seat's + // provisions (outcomes.go, novox/hq ADR 0570 clauses 3, 6). + Bound Bound + Emit func(event string, body map[string]any) error // Ask calls a tool on the mesh as the vault, through the runtime that launched it, under the vault's own bus // account (ADR 0259 §8's runs-as, issue 723): the desks' secret prompt and each issuing provider's tool, which // the bus lets the vault's account alone call in its own name. Nil asks nobody. diff --git a/modules/mesh-vault/cmd/mesh-vault/vault_test.go b/modules/mesh-vault/cmd/mesh-vault/vault_test.go index 0eeb517e..c3f22821 100644 --- a/modules/mesh-vault/cmd/mesh-vault/vault_test.go +++ b/modules/mesh-vault/cmd/mesh-vault/vault_test.go @@ -132,7 +132,7 @@ func filesUnder(t *testing.T, dir string) map[string]string { // The manifest declares one bucket, and no secret is in it: the vault's only place for a secret is its own store on its // own disk (ADR 0349 §7, ADR 0201 §13). The one bucket is bus-accounts, the bcrypt hashes of the bus accounts it holds // (ADR 0547 clause 9), opened in accounts.go alone. It reads five buckets of others', and only reads them: the -// controller's assignments, for what each node's plan names (askers.go, design 57 V4), the node-engines' haves, where +// seat mesh-provisioner's plans, for what each node's plan names (ADR 0570 clause 2), (askers.go, design 57 V4), the node-engines' haves, where // the bus node hands the loaded list's hashes sealed to the vault (adopt.go, ADR 0547 clause 9), the grants part's wanted // accounts and the controller's lease (mint.go, lease.go, ADR 0561 §2, §3), and the seat's placement (placed.go), in // main.go alone. @@ -145,13 +145,14 @@ func TestNoBucketHoldsASecret(t *testing.T) { if err := json.Unmarshal(raw, &manifest); err != nil { t.Fatal(err) } - if s, _ := json.Marshal(manifest["state"]); string(s) != `[{"history":2,"name":"bus-accounts"},"signers"]` { + if s, _ := json.Marshal(manifest["state"]); string(s) != `[{"history":2,"name":"bus-accounts"},"signers","bound"]` { t.Errorf("the manifest declares buckets %s: a secret is in no bucket, sealed or not (ADR 0201 §13), and the "+ - "buckets are the accounts' hashes (ADR 0547 clause 9) and the signing keys' public halves (ADR 0550 §2)", s) + "buckets are the accounts' hashes (ADR 0547 clause 9), the signing keys' public halves (ADR 0550 §2) and the "+ + "credentials' outcomes, names and generations alone (ADR 0570 clauses 3, 6)", s) } - if r, _ := json.Marshal(manifest["reads"]); string(r) != `["mesh-controller.assignments","node-engine.have","mesh-grants.accounts","mesh-controller.lease","mesh-delivery-assigner.placement"]` { - t.Errorf("the manifest reads %s: the vault reads the controller's assignments, the haves and the grants part's "+ - "wanted accounts alone", r) + if r, _ := json.Marshal(manifest["reads"]); string(r) != `["node-engine.have","mesh-grants.accounts","mesh-controller.lease","mesh-delivery-assigner.placement","mesh-provisioner.plans","mesh-provisioner.provisions"]` { + t.Errorf("the manifest reads %s: the vault reads the seat mesh-provisioner's plans and provisions, the haves, the "+ + "grants part's wanted accounts, the lease and the placement alone", r) } sources, _ := filepath.Glob("*.go") bucket := regexp.MustCompile(`stdio\.State\(|nats-io|jetstream|KeyValue`) @@ -192,6 +193,12 @@ func TestNoBucketHoldsASecret(t *testing.T) { // And the seat's placement, where modules stand (placed.go): its keys and one key, read. code = []byte(strings.Replace(string(code), "stdio.State(PlacementRead).Keys(", "", 1)) code = []byte(strings.Replace(string(code), "stdio.State(PlacementRead).Get(", "", 1)) + // And its outcome entries (novox/hq ADR 0570 clauses 3, 6): a value's name and generation, never a value; and + // one read of the seat's provisions, which hold none. + for _, call := range []string{"Get(", "Put(", "Delete(", "Keys("} { + code = []byte(strings.Replace(string(code), "stdio.State(BoundState)."+call, "", 1)) + } + code = []byte(strings.Replace(string(code), "stdio.State(provisions.ProvisionsRead).Get(", "", 1)) } if m := bucket.Find(code); m != nil { t.Errorf("%s opens a bucket (%s): the vault keeps every secret in its own store only", f, m) diff --git a/modules/mesh-vault/go.mod b/modules/mesh-vault/go.mod index 469e01b7..d59bf835 100644 --- a/modules/mesh-vault/go.mod +++ b/modules/mesh-vault/go.mod @@ -3,7 +3,7 @@ module mesh-vault go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029200316-22c9ddd32b6a + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029214732-6b8c6943811a github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 golang.org/x/crypto v0.57.0 diff --git a/modules/mesh-vault/go.sum b/modules/mesh-vault/go.sum index 043e05f0..9ca8e0a6 100644 --- a/modules/mesh-vault/go.sum +++ b/modules/mesh-vault/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029200316-22c9ddd32b6a h1:nhRFmtdcI4VkghGUdJhpt1BnPuTQcekioOIRzW8fcG0= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029200316-22c9ddd32b6a/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029214732-6b8c6943811a h1:elC2IjB+qbeXG+pWItLz71DenegbrdsqbcH5kyGo56Q= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029214732-6b8c6943811a/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-vault/module.json b/modules/mesh-vault/module.json index 8447511f..efea0cb1 100644 --- a/modules/mesh-vault/module.json +++ b/modules/mesh-vault/module.json @@ -5,7 +5,9 @@ "provides": [ { "name": "secret", - "credential": {"form": "pair"}, + "credential": { + "form": "pair" + }, "scope": "mesh", "identity": { "in": "a vault entry" @@ -22,18 +24,20 @@ "signed" ], "reads": [ - "mesh-controller.assignments", "node-engine.have", "mesh-grants.accounts", "mesh-controller.lease", - "mesh-delivery-assigner.placement" + "mesh-delivery-assigner.placement", + "mesh-provisioner.plans", + "mesh-provisioner.provisions" ], "state": [ { "name": "bus-accounts", "history": 2 }, - "signers" + "signers", + "bound" ], "receives": { "secret": "${dir:grants}/mesh.json"