Make the vault ask a software-issuing provider's tool itself for holders that hold nothing (issue 928)

This commit is contained in:
fix-vault-pas@novox
2026-10-16 19:34:11 +02:00
parent 044a548cee
commit 5a00596908
5 changed files with 207 additions and 6 deletions
+15 -1
View File
@@ -444,6 +444,11 @@ func (v *Vault) IssuedOpen(caller, provision, provider, module string) (Opened,
if err := fromTheController(caller, "issued"); err != nil { if err := fromTheController(caller, "issued"); err != nil {
return Opened{}, err 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) secret, err := ProvisionSecret(provider, provision)
if err != nil { if err != nil {
return Opened{}, err 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 // 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. // handed to it alone, perholder.go). Never both.
func (v *Vault) TakeIssuedFor(caller, provision, provider, module, tool string, holders []Holder) (Issued, error) { 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 { if v.Ask == nil {
return Issued{}, errors.New("nothing was taken: this vault reaches no provider") 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 { if err := checkHolders(holders); err != nil {
return Issued{}, fmt.Errorf("nothing was asked: %w", err) 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 { if err != nil {
return Issued{}, err return Issued{}, err
} }
@@ -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", "")) }
@@ -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, ", "))}
}
+11 -3
View File
@@ -139,6 +139,10 @@ type binds struct {
consumers map[string]bool consumers map[string]bool
perHolder bool perHolder bool
forms map[string]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. // 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"` Provider string `json:"provider"`
ProviderModule string `json:"provider-module"` ProviderModule string `json:"provider-module"`
CredentialForm string `json:"credential-form"` 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 is what the consumer tells its provider (issue 776); the vault does not act on it.
Contributes map[string]any `json:"contributes"` Contributes map[string]any `json:"contributes"`
} `json:"provisions"` } `json:"provisions"`
@@ -244,7 +251,7 @@ func readBindings(m Mirror) (bindings, error) {
k := pairKey{p.Provider, p.Provision} k := pairKey{p.Provider, p.Provision}
if b.bound[k] == nil { if b.bound[k] == nil {
b.bound[k] = &binds{providerModules: map[string]bool{}, consumers: map[string]bool{}, 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 b.bound[k].forms[p.CredentialForm] = true
if p.ProviderModule != "" { if p.ProviderModule != "" {
@@ -254,6 +261,8 @@ func readBindings(m Mirror) (bindings, error) {
// for one holder: the provider still gets its logins as rows. // for one holder: the provider still gets its logins as rows.
if p.Name != "" && p.CredentialForm != FormPair { if p.Name != "" && p.CredentialForm != FormPair {
b.bound[k].perHolder = true 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 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) { func (v *Vault) pairOne(k pairKey, b *binds, rows []pairRow, seen bindings) (PairOutcome, error) {
name := k.name() name := k.name()
if b != nil && b.perHolder { if b != nil && b.perHolder {
return PairOutcome{Name: name, Outcome: PairPerHolder, return v.ownIssue(k, b), nil
Why: "a plan names a credential per holder for it (issue 757): there is no one shared value"}, nil
} }
rec, held, err := v.Store.Read(name) rec, held, err := v.Store.Read(name)
if err != nil { if err != nil {
@@ -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 // 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 { func handedWith(node, module string, provisions ...string) string {
var entries []string var entries []string
for _, p := range provisions { 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 { if i := strings.Index(p, "+"); i >= 0 {
p, form = p[:i], p[i+1:] p, form = p[:i], p[i+1:]
} }
@@ -55,6 +58,9 @@ func handedWith(node, module string, provisions ...string) string {
if form != "" { if form != "" {
e += `,"credential-form":"` + form + `"` e += `,"credential-form":"` + form + `"`
} }
if tool != "" {
e += `,"credential-tool":"` + tool + `"`
}
entries = append(entries, e+"}") entries = append(entries, e+"}")
} }
return `{"node":"` + node + `","module":"` + module + `","provisioning":{"templates":{"source":"x","digest":"y"},` + return `{"node":"` + node + `","module":"` + module + `","provisioning":{"templates":{"source":"x","digest":"y"},` +