diff --git a/modules/mesh-vault/cmd/mesh-vault/ownnode.go b/modules/mesh-vault/cmd/mesh-vault/ownnode.go index 7f602a99..ac97f4aa 100644 --- a/modules/mesh-vault/cmd/mesh-vault/ownnode.go +++ b/modules/mesh-vault/cmd/mesh-vault/ownnode.go @@ -348,9 +348,23 @@ func (p *planReader) handed(module string) (bool, error) { if raw == nil { return false, nil } - var a plannedSecrets + var a struct { + Provisioning *struct { + Templates struct { + Source string `json:"source"` + } `json:"templates"` + Files []json.RawMessage `json:"files"` + Secrets []json.RawMessage `json:"secrets"` + Items []json.RawMessage `json:"items"` + } `json:"provisioning"` + } if err := json.Unmarshal(raw, &a); err != nil { return false, fmt.Errorf("the assignment %s.%s is not the controller's: %w", node, module, err) } - return a.Provisioning != nil, nil + // **A plan that names provisions alone hands nothing** (catalogue.Keyed, issue 774): the seat keys a provision its + // provider's helper answers for a module not handed (grafana's route), and that module's own secrets are still + // composed by its node-engine from this answer. Handed is a plan that names something the client fills. + hands := a.Provisioning + return hands != nil && (hands.Templates.Source != "" || len(hands.Files) > 0 || len(hands.Secrets) > 0 || + len(hands.Items) > 0), nil } diff --git a/modules/mesh-vault/cmd/mesh-vault/ownnode_test.go b/modules/mesh-vault/cmd/mesh-vault/ownnode_test.go index 5eb7a682..ad21bb31 100644 --- a/modules/mesh-vault/cmd/mesh-vault/ownnode_test.go +++ b/modules/mesh-vault/cmd/mesh-vault/ownnode_test.go @@ -236,3 +236,38 @@ func TestTheWatchedAssignmentsAnswerWithoutTheBusOnceCaughtUp(t *testing.T) { t.Errorf("caught up, the mirror asked the bus %d times", asked) } } + +// Issue 774, 2026-10-30 23:33: the seat mesh-provisioner keys a provision its provider's helper answers for a module that +// is not handed (catalogue.Keyed), so such a module has a plan naming provisions alone (grafana's route). That plan hands +// none of its files or secrets: `own` still answers the module's own secrets, or the node-engine composing it finds none +// made (fifteen modules on ace and five on novox failed so). A plan that names a template, a file, a secret or an item +// hands the module, and its secrets are left to the client. +func TestOwnLeavesOutOnlyAModuleWhosePlanHandsIt(t *testing.T) { + plans := assignments{ + "anchor.grafana": `{"node":"anchor","module":"grafana","provisioning":{"templates":{"digest":"","source":""},` + + `"files":null,"provisions":[{"provision":"route","provider":"anchor","provider-module":"route-proxy"}]}}`, + "anchor.web": `{"node":"anchor","module":"web","provisioning":{"templates":{"digest":"sha256:1","source":"web.tar"},` + + `"files":[{"path":"/etc/web.conf"}],"secrets":[{"name":"password"}]}}`, + "anchor.db": `{"node":"anchor","module":"db","provisioning":{"templates":{"digest":"","source":""},"files":null,` + + `"secrets":[{"name":"root"}]}}`, + } + v := newVault(t, nil) + v.Askers = Declared{Read: plans.read, Store: v.Store} + pub, _ := aSealingKey(t) + for _, m := range []MoveOf{ + {Node: "anchor", Module: "grafana", Name: "admin", Origin: "made", Generation: "g-1", SealingKey: pub}, + {Node: "anchor", Module: "web", Name: "password", Origin: "made", Generation: "g-1", SealingKey: pub}, + {Node: "anchor", Module: "db", Name: "root", Origin: "made", Generation: "g-1", SealingKey: pub}, + } { + if _, err := moveIn(t, v, "node.anchor", m, "v-"+m.Module); err != nil { + t.Fatal(err) + } + } + got, err := v.OwnOfNode("node.anchor") + if err != nil { + t.Fatal(err) + } + if len(got.Own) != 1 || got.Own[0].Module != "grafana" || got.Own[0].Name != "admin" { + t.Errorf("anchor was answered %+v; want grafana's admin alone (its plan keys a route and hands nothing)", got.Own) + } +}