diff --git a/modules/mesh-vault/cmd/mesh-vault/form_test.go b/modules/mesh-vault/cmd/mesh-vault/form_test.go index 7c7d6e1d..7deb4f0f 100644 --- a/modules/mesh-vault/cmd/mesh-vault/form_test.go +++ b/modules/mesh-vault/cmd/mesh-vault/form_test.go @@ -123,15 +123,15 @@ func TestAProviderWithRowsIsUnchangedByTheFormItsPlansName(t *testing.T) { } // A form the vault does not make (issued by the provider's software, or given by a person), a form it does not know, -// or plans that disagree: nothing is made, and the pass says why. The rule that no holder is left behind still holds. +// or plans that disagree: nothing is made, and the pass says why. (A first value for a provider not handed is made since +// issue 1126: TestAFirstValueIsMadeForEveryHolderAndARemakeWaitsForOneFilledTheOldWay.) func TestTheVaultMakesNothingForAFormItDoesNotMakeOrCannotTrust(t *testing.T) { for form, said := range map[string]string{ - "issued": "issued by the provider's software", - "given": "given by a person", - "one-for-all": "which is not a credential form", - "shared|pair": "disagree", - "shared|": "no plan says whether", - "shared@unmoved": "stand-in on anchor", + "issued": "issued by the provider's software", + "given": "given by a person", + "one-for-all": "which is not a credential form", + "shared|pair": "disagree", + "shared|": "no plan says whether", } { t.Run(form, func(t *testing.T) { provider := handedWith("anchor", "stand-in") @@ -139,9 +139,6 @@ func TestTheVaultMakesNothingForAFormItDoesNotMakeOrCannotTrust(t *testing.T) { if a, b, ok := strings.Cut(form, "|"); ok { first, second = a, b } - if a, _, ok := strings.Cut(form, "@"); ok { - first, second, provider = a, a, unmoved("anchor", "stand-in") - } entry := func(f string) string { if f == "" { return "stand-in-api@anchor/stand-in" diff --git a/modules/mesh-vault/cmd/mesh-vault/main.go b/modules/mesh-vault/cmd/mesh-vault/main.go index 253dd233..e4e06e00 100644 --- a/modules/mesh-vault/cmd/mesh-vault/main.go +++ b/modules/mesh-vault/cmd/mesh-vault/main.go @@ -208,6 +208,30 @@ func watchWants(env *Env) bool { return true } +// watchCredentialAsks starts a pass over the providers' credentials as soon as a credential is asked of the vault in +// the seat mesh-provisioner's provisions (ADR 0570 §1, §6; issue 1126: a new consumer's login waited for nothing but +// the minute): a watch, never a poll. Asks heard while a pass runs are one pass after it. It answers whether the watch +// began; one that did not is begun again at the next minute pass, which makes the same values meanwhile. +func watchCredentialAsks(env *Env, kick chan<- struct{}) bool { + if env.Vault == nil { + return true + } + err := stdio.State(provisions.ProvisionsRead).Watch("", func(c stdio.StateChange) error { + if !c.Current && credentialAsked(c.Value) { + select { + case kick <- struct{}{}: + default: // a pass is already asked: it reads every ask + } + } + return nil + }) + if err != nil { + say("the credentials asked of the vault are not watched, so each waits for the minute's pass; begun again then: %v", err) + return false + } + return true +} + // placement reads the seat mesh-placement's placement through the runtime under the vault's own account (its manifest reads it). var placement = PlacementReader{ Keys: func() ([]string, error) { return stdio.State(PlacementRead).Keys() }, @@ -293,7 +317,17 @@ func main() { watched, watching := watchHandings(env), watchAssignments() wants := watchWants(env) leavers := watchLeavers(env) + kick := make(chan struct{}, 1) + go func() { + for range kick { + env.pairPass() + } + }() + asks := watchCredentialAsks(env, kick) for { + if !asks { + asks = watchCredentialAsks(env, kick) + } if !leavers { leavers = watchLeavers(env) } diff --git a/modules/mesh-vault/cmd/mesh-vault/pair.go b/modules/mesh-vault/cmd/mesh-vault/pair.go index b155d487..3208e138 100644 --- a/modules/mesh-vault/cmd/mesh-vault/pair.go +++ b/modules/mesh-vault/cmd/mesh-vault/pair.go @@ -10,14 +10,18 @@ package main // client records its needs from. A consumer binds when such an entry appears and unbinds when it goes, or when its // assignment goes. The vault reads every plan on each pass (once a minute, and when it starts). // -// **Every holder can get it, or nothing is made** (the lead's rule on issue 677, note 10). The holders are the -// provider on its machine and every consumer bound to it. Only a module handed to the provisioner gets a value from -// the vault; one still filled the old way gets the controller's (ADR 0349 clause 17). So the vault mints or remakes -// a provider's value only when the provider's assignment is handed to the provisioner, and no consumer the vault -// knows to hold it is still filled the old way: a consumer whose pair row the move brought in (V3) and whose -// assignment is still there with no plan. Otherwise it makes nothing, and says why on each pass that changes what -// it says. A consumer filled the old way that bound after the move names no provider on the bus at all; it is the -// provider's move (design 57 B2, B3) that ends that case. +// **A first value is made for every holder; a remake waits for one filled the old way** (the lead's rule on issue +// 677, note 10, as issue 1126 left it). The holders are the provider on its machine and every consumer bound to it. A +// module handed to the provisioner gets its value through its client (`get`); one that is not gets it through its +// machine's node-engine, which composes its own and asks the vault for exactly the credentials its composition reads +// (`own`, ADR 0547 §2, ADR 0550 §5), now that no controller makes them (ADR 0570 §6). So a provider or consumer the +// vault holds no value for gets its first one made whether or not it is handed: a login of its own for each new +// consumer of a per-login provider, or the provider's one shared value. That is create-only: a value made is never +// made again by a bind. A remake (a bind or unbind changing who holds the shared value) and a rotation still wait +// while the provider, or a consumer whose pair row the move brought in, is not handed: a node composing its own would +// carry the old value until it composes again. The vault says why on each pass that changes what it says. A +// credential asked of the vault in the seat mesh-provisioner's provisions (ADR 0570 §1, §6) starts a pass at once +// (main.go watchCredentialAsks), not only at the minute's. // // **What the move brought in is adopted, and nothing is rotated early.** The move (V3) copied each pair row as // `pair////[/]`. Rows a provider shares (ADR 0158: its own secret and @@ -56,6 +60,7 @@ import ( "time" "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/provisions" "git.novox.be/novox/mesh-sdk/go/seal" ) @@ -333,12 +338,21 @@ func sharedHolders(k pairKey, b *binds) (string, []string) { return strings.Join(modules, ","), consumers } -// heldBack says why the vault may not make a value for k now, or "": a holder that would never get it. -func heldBack(k pairKey, b *binds, rows []pairRow, seen bindings) string { +// unnamedProvider says why the vault may not make a first value for k, or "": no plan names the provider's module. +func unnamedProvider(k pairKey, b *binds) string { if b == nil || len(b.providerModules) == 0 { return fmt.Sprintf("no plan names the module serving %s on %s, so the vault cannot tell the provider gets "+ "a value it makes", k.provision, k.provider) } + return "" +} + +// heldBack says why the vault may not remake or rotate a value for k now, or "": a holder filled the old way, which +// would carry the old value until its node composes again. +func heldBack(k pairKey, b *binds, rows []pairRow, seen bindings) string { + if why := unnamedProvider(k, b); why != "" { + return why + } for m := range b.providerModules { if !seen.handed[k.provider+"."+m] { return fmt.Sprintf("the provider %s on %s is not handed to the provisioner, so it would never get a "+ @@ -482,9 +496,7 @@ func (v *Vault) pairOne(k pairKey, b *binds, rows []pairRow, seen bindings) (Pai } } if len(missing) > 0 { - if held := heldBack(k, b, rows, seen); held != "" { - return PairOutcome{Name: name, Outcome: PairHeld, Why: held}, nil - } + // Each one's first login, create-only: whoever composes it, its node asks the vault for it. return v.firstLogins(k, missing) } } @@ -512,7 +524,8 @@ func (v *Vault) pairOne(k pairKey, b *binds, rows []pairRow, seen bindings) (Pai if len(consumers) == 0 { return PairOutcome{Name: name, Outcome: PairUnchanged}, nil } - if why := heldBack(k, b, rows, seen); why != "" { + // A first value, create-only: every holder gets it through its client or its node's own composition. + if why := unnamedProvider(k, b); why != "" { return PairOutcome{Name: name, Outcome: PairHeld, Why: why}, nil } form, why := b.form(k) @@ -748,6 +761,9 @@ func (e *Env) pairPass() { if e.Vault == nil { return } + // One pass at a time: the minute's and one an ask of the vault started (watchCredentialAsks) never race a mint. + e.pairing.Lock() + defer e.pairing.Unlock() outcomes, err := e.Vault.PairPass() if said, oerr := e.Vault.OutcomePass(); oerr != nil { say("the credentials' outcomes are not all written: %v", oerr) @@ -765,6 +781,7 @@ func (e *Env) pairPass() { if err != nil { e.lastPairFailure = err.Error() } + e.pairOutcomes = outcomes if e.pairsSaid == nil { e.pairsSaid = map[string]string{} } @@ -836,3 +853,19 @@ func pairRowRefusal(read AssignmentReader, node string, parts []string) string { } return fmt.Sprintf("%s's plan on %s does not name %s from %s under that name", module, consumer, provision, provider) } + +// credentialsState is what the last pass did, or did not do, for each provider and provision, and why: what +// `vault_status` answers as `credentials`, so a provision whose credential is held back is read, not guessed. +func (e *Env) credentialsState() []PairOutcome { + e.mu.Lock() + defer e.mu.Unlock() + return slices.Clone(e.pairOutcomes) +} + +// credentialAsked is whether a key of the seat mesh-provisioner's provisions asks the vault for a credential (ADR 0570 +// §1, §6): a provision `secret-` whose provider is the vault, in state asked. +func credentialAsked(raw []byte) bool { + p, err := provisions.Decode(raw) + return err == nil && p.State == provisions.Asked && p.Provider.Module == provisions.VaultModule && + strings.HasPrefix(p.Provision, catalogue.CredentialProvisionPrefix) +} diff --git a/modules/mesh-vault/cmd/mesh-vault/pair_test.go b/modules/mesh-vault/cmd/mesh-vault/pair_test.go index f241624a..1c823a8b 100644 --- a/modules/mesh-vault/cmd/mesh-vault/pair_test.go +++ b/modules/mesh-vault/cmd/mesh-vault/pair_test.go @@ -398,24 +398,72 @@ func TestAProviderTheVaultHoldsNoRowOfIsNotGuessed(t *testing.T) { } } -// The vault makes no value a holder would never get (the lead's rule on issue 677): a provider not handed to the -// provisioner, or a consumer the move brought in that is still filled the old way, holds every make and remake -// back, and a rotation too. -func TestNoValueIsMadeThatAHolderFilledTheOldWayWouldNeverGet(t *testing.T) { - t.Run("an unmoved provider", func(t *testing.T) { +// keyedWith is a plan naming provisions alone: its module is not handed to the provisioner, and its node composes its +// own and asks the vault for its credentials (`own`, ADR 0547 §2, ADR 0550 §5). +func keyedWith(node, module string, provisions ...string) string { + return strings.Replace(handedWith(node, module, provisions...), `"templates":{"source":"x","digest":"y"},`, "", 1) +} + +// After the controller, a node composing its own gets its credentials from the vault (`own`), so a first value is made +// for a new consumer whether or not its ends are handed to the provisioner, and never remade (issue 1126, 2026-11-02: +// nothing made the row of a new consumer once the controller's perform was gone; ADR 0570 §6). A remake, and a +// rotation, still wait while a holder is filled the old way (the lead's rule on issue 677). +func TestAFirstValueIsMadeForEveryHolderAndARemakeWaitsForOneFilledTheOldWay(t *testing.T) { + t.Run("a first value for holders that compose their own", func(t *testing.T) { mirror := assignments{ - "anchor.stand-in": unmoved("anchor", "stand-in"), - "g14.app": handedWith("g14", "app", "stand-in-api@anchor/stand-in"), + "anchor.stand-in": keyedWith("anchor", "stand-in"), + "ace.cli": keyedWith("ace", "cli", "stand-in-api@anchor/stand-in+pair"), } v := pairVault(t, mirror) - if o := pass(t, v, standIn); o.Outcome != PairHeld || !strings.Contains(o.Why, "stand-in on anchor") { - t.Fatalf("an unmoved provider: %+v", o) + if o := pass(t, v, standIn); o.Outcome != PairMade { + t.Fatalf("a new consumer's login: %+v", o) } - if _, held, _ := v.Store.Read(standIn); held { - t.Fatal("a value was made that the provider would never get") + login := "pair/anchor/stand-in-api/ace/cli" + first, held, _ := v.Store.Read(login) + if !held { + t.Fatal("no login was made for the new consumer") + } + if o := pass(t, v, standIn); o.Outcome != PairPerPair { + t.Fatalf("the pass after: %+v", o) + } + if again, _, _ := v.Store.Read(login); again.Generation != first.Generation || again.AtRest != first.AtRest { + t.Fatal("a login made once was made again") + } + env := &Env{Vault: v.Vault} + env.pairPass() + if got := env.credentialsState(); len(got) != 1 || got[0].Name != standIn || got[0].Outcome != PairPerPair { + t.Fatalf("the credentials' state reads %+v", got) + } + + mirror = assignments{ + "anchor.stand-in": keyedWith("anchor", "stand-in"), + "ace.cli": keyedWith("ace", "cli", "stand-in-api@anchor/stand-in+shared"), + } + v = pairVault(t, mirror) + if o := pass(t, v, standIn); o.Outcome != PairMade { + t.Fatalf("a new consumer's shared value: %+v", o) + } + mirror["g14.app"] = keyedWith("g14", "app", "stand-in-api@anchor/stand-in+shared") + if o := pass(t, v, standIn); o.Outcome != PairHeld || !strings.Contains(o.Why, "stand-in on anchor") { + t.Fatalf("a second consumer remakes the value its provider composes from: %+v", o) + } + if rec, _, _ := v.Store.Read(standIn); rec.Generation != 1 { + t.Fatalf("the shared value was remade: generation %d", rec.Generation) } if _, err := v.Rotate(ControllerCaller, standIn, "person.jochen", "why not"); err == nil { - t.Fatal("a rotation made a value the provider would never get") + t.Fatal("a rotation remade a value a holder filled the old way holds") + } + }) + t.Run("an ask of the vault for a credential", func(t *testing.T) { + for raw, want := range map[string]bool{ + `{"v":1,"provision":"secret-stand-in-api","consumer":{"module":"cli","node":"ace"},"provider":{"module":"mesh-vault","node":"novox"},"state":"asked"}`: true, + `{"v":1,"provision":"secret-stand-in-api","consumer":{"module":"cli","node":"ace"},"provider":{"module":"mesh-vault","node":"novox"},"state":"provided"}`: false, + `{"v":1,"provision":"stand-in-api","consumer":{"module":"cli","node":"ace"},"provider":{"module":"stand-in","node":"anchor"},"state":"asked"}`: false, + `not a provision`: false, + } { + if credentialAsked([]byte(raw)) != want { + t.Errorf("%s is an ask of the vault: %v", raw, !want) + } } }) t.Run("an unmoved consumer", func(t *testing.T) { diff --git a/modules/mesh-vault/cmd/mesh-vault/tools.go b/modules/mesh-vault/cmd/mesh-vault/tools.go index 9cdcff77..f709a59f 100644 --- a/modules/mesh-vault/cmd/mesh-vault/tools.go +++ b/modules/mesh-vault/cmd/mesh-vault/tools.go @@ -43,6 +43,10 @@ type Env struct { // did not finish (pair.go): each said once, and again when it changes. pairsSaid map[string]string lastPairFailure string + // pairOutcomes is the last pass's outcome for each provider and provision (vault_status `credentials`); pairing + // keeps one pass at a time. + pairOutcomes []PairOutcome + pairing sync.Mutex } func (e *Env) keyRefused() string { @@ -622,7 +626,9 @@ func definitions(env *Env) []toolDef { Description: "What needs a person's eye in the vault: operator copies still sealed to an earlier key, the operator " + "key the mesh's export holds if the vault refused it, how often the record of hand-outs was rolled, and " + "minting (novox/hq ADR 0561 §9, issue 774): the vault mints every wanted bus account it holds no value for, " + - "every wanted account it holds back with why (never one whose value it holds), and those it minted.", + "every wanted account it holds back with why (never one whose value it holds), and those it minted; and " + + "`credentials`: for each provider and provision, what the last pass did for its credential (made, held " + + "and why, per pair) (issue 1126).", run: func(string, map[string]any) (any, error) { if env.Vault == nil { return map[string]any{"ready": false, "error": fmt.Sprint(env.Broken)}, nil @@ -634,7 +640,7 @@ func definitions(env *Env) []toolDef { key, _ := env.Vault.Store.OperatorKey() return map[string]any{"ready": true, "operator_key": key, "copies_on_an_earlier_key": left, "operator_key_refused": env.keyRefused(), "handed_rolled": env.Vault.Store.Rolled(), - "minting": env.Vault.MintState(), "retiring": env.Vault.RetireState(), "signing": env.Vault.SigningState(), "ok": left == 0 && env.keyRefused() == "" && key != "", "rotation": env.Vault.RotationState()}, nil + "minting": env.Vault.MintState(), "credentials": env.credentialsState(), "retiring": env.Vault.RetireState(), "signing": env.Vault.SigningState(), "ok": left == 0 && env.keyRefused() == "" && key != "", "rotation": env.Vault.RotationState()}, nil }}, }...) } diff --git a/modules/mesh-vault/cmd/mesh-vault/vault_test.go b/modules/mesh-vault/cmd/mesh-vault/vault_test.go index 13f75496..fd113fba 100644 --- a/modules/mesh-vault/cmd/mesh-vault/vault_test.go +++ b/modules/mesh-vault/cmd/mesh-vault/vault_test.go @@ -206,6 +206,8 @@ func TestNoBucketHoldsASecret(t *testing.T) { code = []byte(strings.Replace(string(code), "stdio.State(BoundState)."+call, "", 1)) } code = []byte(strings.Replace(string(code), "stdio.State(provisions.ProvisionsRead).Get(", "", 1)) + // And one watch of them, for a credential asked of the vault (issue 1126). + code = []byte(strings.Replace(string(code), "stdio.State(provisions.ProvisionsRead).Watch(", "", 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)