From cadd7b1b2f8fa3499e40287c69972a1880f6f6a5 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 27 Oct 2026 02:43:10 +0100 Subject: [PATCH] =?UTF-8?q?Ask=20the=20vault=20in=20a=20candidate=20answer?= =?UTF-8?q?=20only=20for=20credentials=20of=20modules=20the=20node=20runs,?= =?UTF-8?q?=20and=20refuse=20a=20stand-in=20of=20a=20running=20module,=20s?= =?UTF-8?q?o=20a=20placement=20that=20adds=20a=20module=20is=20answered=20?= =?UTF-8?q?as=20added=20(issue=201126,=20ADR=200559=20=C2=A74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go/compose/frombus/answer.go | 21 ++++++++++++ go/compose/frombus/frombus_test.go | 54 ++++++++++++++++++++++++++---- 2 files changed, 69 insertions(+), 6 deletions(-) diff --git a/go/compose/frombus/answer.go b/go/compose/frombus/answer.go index 1cadb44..20115be 100644 --- a/go/compose/frombus/answer.go +++ b/go/compose/frombus/answer.go @@ -40,6 +40,23 @@ func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]st if err != nil { return refuse(err) } + // What the node runs, by module. A consumer's credential is asked only for a module it runs: the vault refuses the + // whole ask for a consuming module not assigned to the node (mesh-vault's own), and a module the candidate adds is + // assigned only after the merge (ADR 0549 ยง5), so its credential is left missing, a stand-in answered as not yet + // issued under Added (review of L2b, round 1, finding 1). + runs := map[string]bool{} + for _, c := range running { + if c.Module != "" { + runs[c.Module] = true + } + } + var consumed []compose.CredentialKey + for _, k := range asConsumer { + if runs[k.ConsumerModule] { + consumed = append(consumed, k) + } + } + asConsumer = consumed if len(asConsumer)+len(asProvider) > 0 { if in.Credentials, in.Issued, err = r.Credentials(ctx, s.Node, asConsumer, asProvider); err != nil { return refuse(fmt.Errorf("frombus: the credentials of %s: %w", s.Node, err)) @@ -86,6 +103,10 @@ func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]st for _, x := range stood { node, module := standsInFor(x, s.Node) switch { + case node == s.Node && runs[module]: + // A value missing for a module the node runs is a refusal, whatever the trunk places (the seed's trunk is + // empty, and every module counts as the group's). + refusals[module] = append(refusals[module], x) case module != "" && added[node+"/"+module]: if node == s.Node { unissued[module] = append(unissued[module], x) diff --git a/go/compose/frombus/frombus_test.go b/go/compose/frombus/frombus_test.go index 0914062..86eda02 100644 --- a/go/compose/frombus/frombus_test.go +++ b/go/compose/frombus/frombus_test.go @@ -7,6 +7,7 @@ import ( "encoding/base64" "encoding/json" "errors" + "fmt" "os" "reflect" "strings" @@ -28,6 +29,9 @@ type bus struct { valuesRead []string own []compose.OwnSecretRow ownErr error + // asked is every consumer credential asked; the fake refuses the whole ask, as the vault's own does, for a consuming + // module its placement does not put on the node. + asked []compose.CredentialKey } func (b *bus) Placements(context.Context) (map[string]delivery.Placement, error) { @@ -46,7 +50,17 @@ func (b *bus) OwnSecrets(context.Context, string) ([]compose.OwnSecretRow, error return b.own, b.ownErr } -func (b *bus) Credentials(context.Context, string, []compose.CredentialKey, []compose.CredentialKey) ([]compose.Credential, []compose.Issued, error) { +func (b *bus) Credentials(_ context.Context, node string, asConsumer, _ []compose.CredentialKey) ([]compose.Credential, []compose.Issued, error) { + b.asked = append(b.asked, asConsumer...) + for _, k := range asConsumer { + placed := false + for _, m := range b.placements[node].Modules { + placed = placed || m.Module == k.ConsumerModule + } + if !placed { + return nil, nil, fmt.Errorf("%s is not on %s: neither assigned there nor required by a module there", k.ConsumerModule, node) + } + } return nil, nil, nil } @@ -212,18 +226,27 @@ func TestInput(t *testing.T) { keeper := []byte(`{"module":"keeper","version":"1","own-secrets":{"password":"/var/lib/mesh/keeper/password"},` + `"resources":[{"id":"state","type":"directory","path":"/var/lib/mesh/keeper","mode":"0700"}]}`) lemurs := []byte(`{"module":"lemurs","version":"1","capabilities":["systemd"],"resources":[{"id":"service","type":"service","unit":"lemurs.service","state":"running","boot":"enabled"}]}`) + // A provider and a consumer of a pair credential on the one node (compose's objects and files). + objects, _ := json.Marshal(catalogue.Manifest{Module: "objects", Version: "1", + Provides: []catalogue.Offer{{Name: "s3-bucket", Scope: catalogue.ScopeMesh, Identity: &catalogue.OfferIdentity{Max: 20, In: "an S3 access key"}}}, + Receives: map[string]string{"s3-bucket": "/var/lib/mesh/objects/mesh.json"}}) + files, _ := json.Marshal(catalogue.Manifest{Module: "files", Version: "1", Requires: []string{"s3-bucket"}, + Resources: []map[string]any{{"id": "config", "type": "file", "path": "/etc/files/config.json", "mode": "0600", "content": "{}", "merge": "json"}}}) one := func(placed ...string) *bus { p := delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}} for _, m := range placed { p.Modules = append(p.Modules, delivery.PlacedModule{Module: m}) } return &bus{placements: map[string]delivery.Placement{"n1": p}, - records: map[string]delivery.NodeRecord{"n1": {Node: "n1", TunnelKey: keys["anchor"], SealingKey: "sk", Capabilities: []string{"systemd"}}}, - modules: map[string]delivery.Module{"keeper": {Manifest: delivery.ManifestDigest(keeper)}, "lemurs": {Manifest: delivery.ManifestDigest(lemurs)}}, - manifests: map[string][]byte{delivery.ManifestDigest(keeper): keeper, delivery.ManifestDigest(lemurs): lemurs}, - own: []compose.OwnSecretRow{{Node: "n1", Module: "keeper", Name: "password", Origin: "made", SealedTo: "sk", Sealed: `"box"`}}} + records: map[string]delivery.NodeRecord{"n1": {Node: "n1", TunnelKey: keys["anchor"], SealingKey: "sk", Capabilities: []string{"systemd"}}}, + modules: map[string]delivery.Module{"keeper": {Manifest: delivery.ManifestDigest(keeper)}, "lemurs": {Manifest: delivery.ManifestDigest(lemurs)}, + "objects": {Manifest: delivery.ManifestDigest(objects)}, "files": {Manifest: delivery.ManifestDigest(files)}}, + manifests: map[string][]byte{delivery.ManifestDigest(keeper): keeper, delivery.ManifestDigest(lemurs): lemurs, + delivery.ManifestDigest(objects): objects, delivery.ManifestDigest(files): files}, + own: []compose.OwnSecretRow{{Node: "n1", Module: "keeper", Name: "password", Origin: "made", SealedTo: "sk", Sealed: `"box"`}}} } - oneFps := map[string]string{"keeper": "src2:" + strings.Repeat("d", 64), "lemurs": "src2:" + strings.Repeat("e", 64)} + oneFps := map[string]string{"keeper": "src2:" + strings.Repeat("d", 64), "lemurs": "src2:" + strings.Repeat("e", 64), + "objects": "src2:" + strings.Repeat("f", 64), "files": "src2:" + strings.Repeat("0", 64)} runs, _, err := Compose(ctx, one("keeper", "lemurs"), Mesh{}, Scope{Node: "n1"}, oneFps) if err != nil { t.Fatal(err) @@ -232,6 +255,14 @@ func TestInput(t *testing.T) { for _, c := range runs { runsNow = append(runsNow, c) } + objectsRun, _, err := Compose(ctx, one("objects"), Mesh{}, Scope{Node: "n1"}, oneFps) + if err != nil { + t.Fatal(err) + } + var consumerRuns []delivery.Composed + for _, c := range objectsRun { + consumerRuns = append(consumerRuns, c) + } withoutOwn := func(cs []delivery.Composed) []delivery.Composed { var out []delivery.Composed for _, c := range cs { @@ -271,6 +302,17 @@ func TestInput(t *testing.T) { runsNow, delivery.CandidateRefused, func(a delivery.CandidateAnswer) bool { return len(a.Refusals) == 1 && a.Refusals[0].Module == "keeper" && strings.Contains(a.Refusals[0].Why, "keeper/password") }}, + // Review of L2b, round 1, finding 1: a consumer the candidate adds has no credential yet, and the vault refuses an + // ask for a module not on the node; it is not asked, and the module is added, its credential not yet issued. + "a consumer added, the vault not asked for it": {one("objects"), placedAs("objects", "files"), consumerRuns, delivery.CandidateDiffers, + func(a delivery.CandidateAnswer) bool { + return len(a.Added) == 1 && a.Added[0].Module == "files" && len(a.Added[0].Unissued) > 0 && len(a.Refusals) == 0 + }}, + // And with no trunk at all (the seed), a stand-in of a module the node runs is a refusal, not an addition. + "the seed, a value missing for a module that runs": {func() *bus { b := one(); b.own = nil; return b }(), placedAs("keeper", "lemurs"), + runsNow, delivery.CandidateRefused, func(a delivery.CandidateAnswer) bool { + return len(a.Refusals) == 1 && a.Refusals[0].Module == "keeper" && len(a.Added) == 0 + }}, "cannot compose": {one("keeper"), map[string]*delivery.Placement{"n1": {Node: "n2"}}, runsNow, delivery.CandidateRefused, func(a delivery.CandidateAnswer) bool { return a.Refused != "" }}, } {