From 5a00596908182730955ddb37439526911f5d085c Mon Sep 17 00:00:00 2001 From: "fix-vault-pas@novox" Date: Fri, 16 Oct 2026 19:34:11 +0200 Subject: [PATCH] Make the vault ask a software-issuing provider's tool itself for holders that hold nothing (issue 928) --- modules/mesh-vault/cmd/mesh-vault/intake.go | 16 +++- .../cmd/mesh-vault/own_issue_test.go | 84 +++++++++++++++++ modules/mesh-vault/cmd/mesh-vault/ownissue.go | 89 +++++++++++++++++++ modules/mesh-vault/cmd/mesh-vault/pair.go | 14 ++- .../mesh-vault/cmd/mesh-vault/pair_test.go | 10 ++- 5 files changed, 207 insertions(+), 6 deletions(-) create mode 100644 modules/mesh-vault/cmd/mesh-vault/own_issue_test.go create mode 100644 modules/mesh-vault/cmd/mesh-vault/ownissue.go diff --git a/modules/mesh-vault/cmd/mesh-vault/intake.go b/modules/mesh-vault/cmd/mesh-vault/intake.go index d7898574..329b2c8e 100644 --- a/modules/mesh-vault/cmd/mesh-vault/intake.go +++ b/modules/mesh-vault/cmd/mesh-vault/intake.go @@ -444,6 +444,11 @@ func (v *Vault) IssuedOpen(caller, provision, provider, module string) (Opened, if err := fromTheController(caller, "issued"); err != nil { return Opened{}, err } + return v.openIssued(provision, provider, module) +} + +// openIssued is IssuedOpen for a call the vault makes in its own name (ownIssue, issue 928). +func (v *Vault) openIssued(provision, provider, module string) (Opened, error) { secret, err := ProvisionSecret(provider, provision) if err != nil { return Opened{}, err @@ -584,6 +589,15 @@ 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 { + return Issued{}, err + } + return v.takeIssuedFor(caller, provision, provider, module, tool, holders) +} + +// takeIssuedFor is TakeIssuedFor for whoever asks: the controller's verb, or the vault's own pass (ownIssue). caller +// is who the kept values are recorded as taken by. +func (v *Vault) takeIssuedFor(caller, provision, provider, module, tool string, holders []Holder) (Issued, error) { if v.Ask == nil { return Issued{}, errors.New("nothing was taken: this vault reaches no provider") } @@ -593,7 +607,7 @@ func (v *Vault) TakeIssuedFor(caller, provision, provider, module, tool string, if err := checkHolders(holders); err != nil { return Issued{}, fmt.Errorf("nothing was asked: %w", err) } - opened, err := v.IssuedOpen(caller, provision, provider, module) + opened, err := v.openIssued(provision, provider, module) if err != nil { return Issued{}, err } diff --git a/modules/mesh-vault/cmd/mesh-vault/own_issue_test.go b/modules/mesh-vault/cmd/mesh-vault/own_issue_test.go new file mode 100644 index 00000000..84bacaac --- /dev/null +++ b/modules/mesh-vault/cmd/mesh-vault/own_issue_test.go @@ -0,0 +1,84 @@ +package main + +import ( + "fmt" + "strings" + "testing" +) + +// Issue 928 (ADR 0349 clause 10): the vault takes a software-issued value by itself. With claude-code's need +// recorded on two machines and the forge's tool answering, one pass asks the tool for both holders and keeps a +// value for each; the next pass leaves them be, and a holder bound later is the only one asked. +func TestThePassAsksAnIssuingProvidersToolForTheHoldersItHoldsNothingFor(t *testing.T) { + mirror := assignments{ + "novox.gitea": handedWith("novox", "gitea"), + "anchor.claude-code": handedWith("anchor", "claude-code", "gitea-read@novox/gitea=gitea-read+issued~gitea_agent_token"), + } + v := pairVault(t, mirror) + var asked []map[string]any + v.Ask = forgeMesh(perHolderProvider(agentTokens, &asked)).ask + + o := pass(t, v, "provision/novox/gitea-read") + if o.Outcome != PairPerHolder { + t.Fatalf("outcome %+v", o) + } + if len(asked) != 1 || !strings.Contains(toString(asked[0]["holders"]), "anchor") { + t.Fatalf("the tool was asked %v, want once for anchor", asked) + } + if _, held, _ := v.Store.Read(anchorsToken); !held { + t.Fatal("the vault took no value for claude-code on anchor by itself") + } + if got := handedTo(t, v, "provisioner.anchor", anchorsToken); got != agentTokens["anchor/claude-code"] { + t.Errorf("anchor got %q", got) + } + + // Held is left untouched: no second ask, so the forge does not mint anew (rotation is the software's). + pass(t, v, "provision/novox/gitea-read") + if len(asked) != 1 { + t.Errorf("the tool was asked again for a holder that holds its value (%d asks)", len(asked)) + } + + // A holder bound since is the only one asked. + mirror["laptop.claude-code"] = handedWith("laptop", "claude-code", "gitea-read@novox/gitea=gitea-read+issued~gitea_agent_token") + pass(t, v, "provision/novox/gitea-read") + if len(asked) != 2 || strings.Contains(toString(asked[1]["holders"]), "anchor") { + t.Fatalf("asks %v, want a second naming laptop alone", asked) + } + if _, held, _ := v.Store.Read(laptopsToken); !held { + t.Error("no value for the holder bound later") + } +} + +// A failed ask keeps nothing, is said as a held outcome, and is asked again at the next pass; a plan naming no +// tool is said, not guessed. +func TestAFailedAskOfTheToolIsAskedAgainAndNoToolIsSaid(t *testing.T) { + mirror := assignments{ + "novox.gitea": handedWith("novox", "gitea"), + "anchor.claude-code": handedWith("anchor", "claude-code", "gitea-read@novox/gitea=gitea-read+issued~gitea_agent_token"), + } + v := pairVault(t, mirror) + v.Ask = forgeMesh(func(string, map[string]any) (map[string]any, error) { return nil, errTest }).ask + o := pass(t, v, "provision/novox/gitea-read") + if o.Outcome != PairHeld || !strings.Contains(o.Why, "could not be asked") { + t.Fatalf("a failed ask is %+v", o) + } + var asked []map[string]any + v.Ask = forgeMesh(perHolderProvider(agentTokens, &asked)).ask + pass(t, v, "provision/novox/gitea-read") + if _, held, _ := v.Store.Read(anchorsToken); !held || len(asked) != 1 { + t.Fatal("the next pass did not ask again and keep the value") + } + + noTool := pairVault(t, assignments{ + "novox.gitea": handedWith("novox", "gitea"), + "anchor.claude-code": handedWith("anchor", "claude-code", "gitea-read@novox/gitea=gitea-read+issued"), + }) + noTool.Ask = v.Ask + if o := pass(t, noTool, "provision/novox/gitea-read"); o.Outcome != PairHeld || !strings.Contains(o.Why, "name no tool") { + t.Errorf("a plan with no tool is %+v", o) + } +} + +var errTest = errString("nats: no responders available for request") + +func toString(v any) string { return strings.TrimSpace(strings.ReplaceAll(fmt.Sprint(v), "map", "")) } diff --git a/modules/mesh-vault/cmd/mesh-vault/ownissue.go b/modules/mesh-vault/cmd/mesh-vault/ownissue.go new file mode 100644 index 00000000..aebf9bc4 --- /dev/null +++ b/modules/mesh-vault/cmd/mesh-vault/ownissue.go @@ -0,0 +1,89 @@ +package main + +// The vault asks a software-issuing provider's tool itself (novox/hq ADR 0349 clause 10, issue 928). +// +// A provider whose offer says `issued-by: software` has its value taken from its tool, sealed to the vault, "when the +// provider starts, when the software's value changes and at least hourly". Only the controller's `secret issued` made +// the vault ask until now, so a holder that bound after the last such act waited for ever. The pass (once a minute and +// when the vault starts) now asks the tool for the holders the plans name that hold nothing yet, in the vault's own +// name and through the same path as the controller's verb. A holder that holds its value is left untouched: the +// forge mints anew on every ask, and a rotation is the software's (ADR 0305 §5, §9). A failed ask is said, keeps +// nothing, and is asked again at the next pass. + +import ( + "fmt" + "sort" + "strings" +) + +// ownCaller is who a value the vault asked for itself is recorded as taken by. +const ownCaller = "mesh-vault" + +// perHolderWhy is what a pass says of a provision whose plans name a credential per holder. +const perHolderWhy = "a plan names a credential per holder for it (issue 757): there is no one shared value" + +// ownIssue is the pass's part for a provision whose plans name a credential per holder: when its offer is issued by +// the provider's software and the plans name the tool, the holders that hold nothing yet are asked of it. +func (v *Vault) ownIssue(k pairKey, b *binds) PairOutcome { + name := k.name() + said := PairOutcome{Name: name, Outcome: PairPerHolder, Why: perHolderWhy} + if v.Ask == nil || len(b.forms) != 1 || !b.forms[FormIssued] { + return said + } + held := func(why string) PairOutcome { + return PairOutcome{Name: name, Outcome: PairHeld, Why: why} + } + var tools []string + for t := range b.tools { + if t != "" { + tools = append(tools, t) + } + } + sort.Strings(tools) + switch { + case len(tools) == 0: + return held(fmt.Sprintf("the plans name %s from %s as issued by its software and name no tool that answers it; "+ + "the vault cannot ask for it (issue 928)", k.provision, k.provider)) + case len(tools) > 1 || b.tools[""]: + return held(fmt.Sprintf("the plans naming %s from %s disagree on its tool (%s); the vault asks none", k.provision, + k.provider, strings.Join(tools, ", "))) + case len(b.providerModules) != 1: + return held(fmt.Sprintf("no one plan names the module serving %s on %s, so the vault cannot tell whose tool to ask", + k.provision, k.provider)) + } + var module string + for m := range b.providerModules { + module = m + } + var missing []Holder + for h := range b.holders { + secret, err := HolderSecret(k.provider, k.provision, h) + if err != nil { + continue + } + if _, have, err := v.Store.Read(secret); err == nil && !have { + missing = append(missing, h) + } + } + if len(missing) == 0 { + return said + } + sort.Slice(missing, func(i, j int) bool { return missing[i].String() < missing[j].String() }) + got, err := v.takeIssuedFor(ownCaller, k.provision, k.provider, module, tools[0], missing) + if err != nil { + return held(err.Error()) + } + if len(got.NotAnswered) > 0 { + var who []string + for _, h := range got.NotAnswered { + who = append(who, h.String()) + } + return held(fmt.Sprintf("its tool %s gave no value for %s", tools[0], strings.Join(who, ", "))) + } + var who []string + for _, h := range got.Holders { + who = append(who, h.Holder.String()) + } + return PairOutcome{Name: name, Outcome: PairPerHolder, Why: fmt.Sprintf("the vault asked its tool %s and keeps a value "+ + "for %s (issue 928)", tools[0], strings.Join(who, ", "))} +} diff --git a/modules/mesh-vault/cmd/mesh-vault/pair.go b/modules/mesh-vault/cmd/mesh-vault/pair.go index 2417df12..40221732 100644 --- a/modules/mesh-vault/cmd/mesh-vault/pair.go +++ b/modules/mesh-vault/cmd/mesh-vault/pair.go @@ -139,6 +139,10 @@ type binds struct { consumers map[string]bool perHolder bool forms map[string]bool + // holders are the modules on a machine whose plan names a credential per holder for it, and tools the + // `credential-tool` those entries name (issue 928): what the vault asks the provider's tool with. + holders map[Holder]bool + tools map[string]bool } // The credential forms a plan entry names (issue 768), as the provider's offer declares them. @@ -203,6 +207,9 @@ type plannedProvisions struct { Provider string `json:"provider"` ProviderModule string `json:"provider-module"` CredentialForm string `json:"credential-form"` + // CredentialTool is the provider's tool that answers the credential of an offer issued by its software + // (issue 928): the vault asks it itself. + CredentialTool string `json:"credential-tool"` // Contributes is what the consumer tells its provider (issue 776); the vault does not act on it. Contributes map[string]any `json:"contributes"` } `json:"provisions"` @@ -244,7 +251,7 @@ func readBindings(m Mirror) (bindings, error) { k := pairKey{p.Provider, p.Provision} if b.bound[k] == nil { b.bound[k] = &binds{providerModules: map[string]bool{}, consumers: map[string]bool{}, - forms: map[string]bool{}} + forms: map[string]bool{}, holders: map[Holder]bool{}, tools: map[string]bool{}} } b.bound[k].forms[p.CredentialForm] = true if p.ProviderModule != "" { @@ -254,6 +261,8 @@ func readBindings(m Mirror) (bindings, error) { // for one holder: the provider still gets its logins as rows. if p.Name != "" && p.CredentialForm != FormPair { b.bound[k].perHolder = true + b.bound[k].holders[Holder{Node: a.Node, Module: a.Module}] = true + b.bound[k].tools[p.CredentialTool] = true } b.bound[k].consumers[consumerOf(a.Node, a.Module, p.Local)] = true } @@ -428,8 +437,7 @@ func (v *Vault) PairPass() ([]PairOutcome, error) { func (v *Vault) pairOne(k pairKey, b *binds, rows []pairRow, seen bindings) (PairOutcome, error) { name := k.name() if b != nil && b.perHolder { - return PairOutcome{Name: name, Outcome: PairPerHolder, - Why: "a plan names a credential per holder for it (issue 757): there is no one shared value"}, nil + return v.ownIssue(k, b), nil } rec, held, err := v.Store.Read(name) if err != nil { diff --git a/modules/mesh-vault/cmd/mesh-vault/pair_test.go b/modules/mesh-vault/cmd/mesh-vault/pair_test.go index 4ec4ad0f..f241624a 100644 --- a/modules/mesh-vault/cmd/mesh-vault/pair_test.go +++ b/modules/mesh-vault/cmd/mesh-vault/pair_test.go @@ -27,11 +27,14 @@ func (a assignments) keys() ([]string, error) { } // handedWith is an assignment handed to the provisioner, its plan naming each provision given as -// provision@provider/provider-module[#local][=name][+credential-form]. +// provision@provider/provider-module[#local][=name][+credential-form][~credential-tool] (the tool last). func handedWith(node, module string, provisions ...string) string { var entries []string for _, p := range provisions { - form := "" + form, tool := "", "" + if i := strings.Index(p, "~"); i >= 0 { + p, tool = p[:i], p[i+1:] + } if i := strings.Index(p, "+"); i >= 0 { p, form = p[:i], p[i+1:] } @@ -55,6 +58,9 @@ func handedWith(node, module string, provisions ...string) string { if form != "" { e += `,"credential-form":"` + form + `"` } + if tool != "" { + e += `,"credential-tool":"` + tool + `"` + } entries = append(entries, e+"}") } return `{"node":"` + node + `","module":"` + module + `","provisioning":{"templates":{"source":"x","digest":"y"},` +