diff --git a/go/catalogue/declaration.go b/go/catalogue/declaration.go index c0d9564..8360b98 100644 --- a/go/catalogue/declaration.go +++ b/go/catalogue/declaration.go @@ -846,6 +846,11 @@ func (r Resolution) compose(with Rendering, owner map[string]string, // and nothing would say so. continue } + if g.Sealed == "" { + // Its offer presents no credential (form none, issue 1126): nothing to write, and an empty credential + // file would be read as one that failed. Who asked still reaches the provider in its contributions. + continue + } first = append(first, ownedBy(r.provisionsAs(m), map[string]any{ // One file per holder — the consumer's module with its local name after it // where it keeps several (ADR 0094); the lab found two files with one id. diff --git a/go/catalogue/grant_secret_owner_test.go b/go/catalogue/grant_secret_owner_test.go index 5fdf7fe..e66202f 100644 --- a/go/catalogue/grant_secret_owner_test.go +++ b/go/catalogue/grant_secret_owner_test.go @@ -66,6 +66,23 @@ func TestAGrantSecretIsOwnedByTheAccountThatProvisions(t *testing.T) { "root-owned 0600 file is one it cannot read — which is silent, because the harness "+ "calls it \"not readable yet\"", fmtPath(secret), got, "operator") } + // A grant whose offer presents no credential (form none, issue 1126) carries nothing sealed: no secret file is + // written for it, as none is for a need with no credential, and the provider still learns who asked. + out, err = r.Declaration(Rendering{Grants: []Grant{{ + Provision: "mongodb-database", Consumer: "workstation", From: "photos", Slug: "photos", Values: map[string]any{}}}}) + if err != nil { + t.Fatal(err) + } + asked := false + for _, res := range out { + if res["type"] == "file" && strings.HasSuffix(fmtPath(res), ".secret") { + t.Errorf("a grant with no credential writes %v", res) + } + asked = asked || fmtPath(res) == "/var/lib/mongodb/grants/mesh.json" + } + if !asked { + t.Errorf("the provider no longer learns who asked: %v", out) + } } // And a provider whose code still runs in a container keeps the owner it declares, so this diff --git a/go/catalogue/runtime_test.go b/go/catalogue/runtime_test.go index ee4c3d8..e0620fc 100644 --- a/go/catalogue/runtime_test.go +++ b/go/catalogue/runtime_test.go @@ -372,7 +372,7 @@ func TestWhatAModuleOfItsOwnAccountReadsIsItsAccounts(t *testing.T) { with := Rendering{ArtifactStore: "anchor.internal:5101", Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}, "vault": {"broker": "own"}}, Kept: &KeptExport{Export: 1, OperatorKey: "k"}, - Grants: []Grant{{Provision: "secret", Consumer: "anchor", From: "telegram", + Grants: []Grant{{Provision: "secret", Consumer: "anchor", From: "telegram", Sealed: "sealed-to-anchor", Values: map[string]any{"name": "telegram"}}}} goRuntime := Manifest{Module: RuntimeModule, Version: "1", OwnSecrets: OwnSecrets{"broker": {Path: "/var/lib/mesh/" + RuntimeModule + "/broker"}}, diff --git a/go/compose/credentials.go b/go/compose/credentials.go index 3d7d1e9..480c988 100644 --- a/go/compose/credentials.go +++ b/go/compose/credentials.go @@ -105,7 +105,12 @@ func (c *composer) grantsFor(node string) ([]catalogue.Grant, []catalogue.Overfl } } sealed := s.ForProvider - if sealed == "" { + switch { + case credentiallessRow(plan, s, node): + // Its offer presents no credential (form none): the grant carries the consumer and nothing sealed, and + // nothing stands in for it, so neither end waits for a value nobody makes (issue 1126). + sealed = "" + case sealed == "": sealed = catalogue.ForeseenSealed foreseen = append(foreseen, fmt.Sprintf("the grant of %s for %s on %s", s.Provision, s.ConsumerModule, s.Consumer)) } @@ -116,6 +121,18 @@ func (c *composer) grantsFor(node string) ([]catalogue.Grant, []catalogue.Overfl return out, withheld, unbound, foreseen, nil } +// credentialless is whether a need's provider offers it with no credential (catalogue.CredentialFormNone): the vault +// mints nothing for it, so nothing is asked of the vault, sealed into a grant or a file, or stood in for (issue 1126). +func credentialless(n catalogue.Needed) bool { return n.CredentialForm == catalogue.CredentialFormNone } + +// credentiallessRow is whether the consumer's resolution answers row's need from provider with an offer of form none. +func credentiallessRow(consumer catalogue.Resolution, row Issued, provider string) bool { + return slices.ContainsFunc(consumer.Needs, func(n catalogue.Needed) bool { + return n.Name == row.Provision && n.For == row.ConsumerModule && n.Local == row.Local && n.From == provider && + credentialless(n) + }) +} + // rowsFor is every pair credential this node's declaration must carry a grant for, in the order the controller reads them // (SecretsFrom: by provision, consumer, consumer module and local name): the ones the Input says were made, and the ones the same send // would make before it composes this node -- every need of a node that resolves (this one too), whose provider is this node, that is not diff --git a/go/compose/credentials_needed.go b/go/compose/credentials_needed.go index a3a9bc1..ec36c71 100644 --- a/go/compose/credentials_needed.go +++ b/go/compose/credentials_needed.go @@ -19,7 +19,8 @@ type CredentialKey struct { // as a consumer, each need of its resolution a provider's credential answers (a need answered by a record is a licence's, // not a pair's); as a provider, each credential it must carry a grant for (rowsFor). A node that composes its own asks the // vault for exactly these, sealed to its key, and composes with them; one it does not get is stood in for, which the -// node's own apply refuses (CompositionsApplied). +// node's own apply refuses (CompositionsApplied). A need whose provider offers it with no credential (form none) is +// in neither list: the vault mints nothing for it (issue 1126). func CredentialsNeeded(in Input, node string) (asConsumer, asProvider []CredentialKey, err error) { c := newComposer(in) resolved, err := c.resolution(node) @@ -27,7 +28,7 @@ func CredentialsNeeded(in Input, node string) (asConsumer, asProvider []Credenti return nil, nil, err } for _, n := range resolved.Needs { - if n.ByRecord || n.From == "" { + if n.ByRecord || n.From == "" || credentialless(n) { continue } asConsumer = append(asConsumer, CredentialKey{Provision: n.Name, Consumer: node, ConsumerModule: n.For, @@ -38,6 +39,10 @@ func CredentialsNeeded(in Input, node string) (asConsumer, asProvider []Credenti return nil, nil, fmt.Errorf("compose: the credentials %s provides: %w", node, err) } for _, r := range rows { + // A grant whose offer presents no credential (form none) has none to ask for (issue 1126). + if consumer, err := c.planFor(r.Consumer); err == nil && credentiallessRow(consumer.Plan, r, node) { + continue + } asProvider = append(asProvider, CredentialKey{Provision: r.Provision, Consumer: r.Consumer, ConsumerModule: r.ConsumerModule, Provider: node, Local: r.Local}) } diff --git a/go/compose/credentials_test.go b/go/compose/credentials_test.go index f56900d..94cd354 100644 --- a/go/compose/credentials_test.go +++ b/go/compose/credentials_test.go @@ -384,30 +384,74 @@ func TestNoSecretValueOfTheInputIsInWhatTheCompositionSays(t *testing.T) { } } -// Round 1, finding 2: a consumer the same send adds has no credential in the Input yet, and the provider's declaration of that send -// carries its grant, composed with a stand-in and named foreseen. -func TestAConsumerNoCredentialWasMadeForYetIsGrantedWithAStandInAndNamedForeseen(t *testing.T) { - in := aGrantMesh(t) - in.Issued = nil - got := rendered(t, in, "anchor", Foreseeing) - if len(got.Rendering.Grants) != 1 { - t.Fatalf("grants are %+v", got.Rendering.Grants) - } - g := got.Rendering.Grants[0] - if g.Provision != "s3-bucket" || g.Consumer != "laptop" || g.From != "files" || g.Sealed != catalogue.ForeseenSealed { - t.Errorf("the grant is %+v", g) - } - if !has(got.Foreseen, "the grant of s3-bucket for files on laptop") { - t.Errorf("foreseen %v", got.Foreseen) - } - // Only the provider's own: the consumer is granted nothing, and nothing in the Input changed. - if len(rendered(t, in, "laptop", Foreseeing).Rendering.Grants) != 0 { - t.Error("the consumer is granted its own credential") - } - if len(in.Issued) != 0 { - t.Error("a row was added to the Input") +// A consumer no credential was made for yet is granted with a stand-in and named foreseen, for every form whose credential +// the vault mints or takes in; an offer of form none has no credential (issue 1126, 2026-11-02: mesh-cli on ace held for +// "the grant of mcp-endpoint", whose provider node-tools offers form none): its grant carries the consumer and no +// credential, nothing stands in on either side, and neither end asks the vault for one. +func TestAConsumerNoCredentialWasMadeForYetIsGrantedWithAStandInUnlessItsOfferHasNone(t *testing.T) { + for _, tc := range []struct { + form string + cred *catalogue.OfferCredential + }{ + {"undeclared", nil}, + {catalogue.CredentialFormPair, &catalogue.OfferCredential{Form: catalogue.CredentialFormPair}}, + {catalogue.CredentialFormShared, &catalogue.OfferCredential{Own: "access-key"}}, + {catalogue.CredentialFormNone, &catalogue.OfferCredential{Form: catalogue.CredentialFormNone}}, + } { + in := aGrantMesh(t) + in.Issued = nil + provider := in.Catalogue["objects"] + provider.Provides[0].Credential = tc.cred + if tc.cred != nil && tc.cred.Own != "" { + provider.OwnSecrets = catalogue.OwnSecrets{"access-key": {Path: "/var/lib/mesh/objects/access-key"}} + } + in.Catalogue["objects"] = provider + none := tc.form == catalogue.CredentialFormNone + got := rendered(t, in, "anchor", Foreseeing) + if len(got.Rendering.Grants) != 1 { + t.Fatalf("%s: grants are %+v", tc.form, got.Rendering.Grants) + } + g, wantSealed := got.Rendering.Grants[0], catalogue.ForeseenSealed + if none { + wantSealed = "" + } + if g.Provision != "s3-bucket" || g.Consumer != "laptop" || g.From != "files" || g.Sealed != wantSealed { + t.Errorf("%s: the grant is %+v", tc.form, g) + } + if has(got.Foreseen, "the grant of s3-bucket for files on laptop") == none { + t.Errorf("%s: foreseen %v", tc.form, got.Foreseen) + } + consumer, asProvider, err := CredentialsNeeded(in, "laptop") + _, anchorProvides, err2 := CredentialsNeeded(in, "anchor") + if err != nil || err2 != nil || len(asProvider) != 0 || (len(consumer) == 0) != none || (len(anchorProvides) == 0) != none { + t.Errorf("%s: laptop asks %v, anchor provides %v (%v %v)", tc.form, consumer, anchorProvides, err, err2) + } + resolved, err := Resolve(in, "laptop") + if err != nil { + t.Fatalf("%s: %v", tc.form, err) + } + if n := theFilesNeed(t, resolved); (n.Sealed == "") != none || (len(resolved.Foreseen) == 0) != none { + t.Errorf("%s: the consumer's credential is %q, foreseen %v", tc.form, n.Sealed, resolved.Foreseen) + } + if lap := rendered(t, in, "laptop", Foreseeing); none && len(lap.Foreseen) != 0 { + t.Errorf("%s: the consumer stands in for %v", tc.form, lap.Foreseen) + } + // Only the provider's own: the consumer is granted nothing, and nothing in the Input changed. + if len(rendered(t, in, "laptop", Foreseeing).Rendering.Grants) != 0 { + t.Errorf("%s: the consumer is granted its own credential", tc.form) + } + if len(in.Issued) != 0 { + t.Errorf("%s: a row was added to the Input", tc.form) + } + // A row made long ago for an offer of form none (the controller's) carries no credential either. + in.Issued = []Issued{{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", ForProvider: "sealed-to-anchor"}} + if g := rendered(t, in, "anchor", Foreseeing).Rendering.Grants; none && (len(g) != 1 || g[0].Sealed != "") { + t.Errorf("%s: an old row's grant is %+v", tc.form, g) + } } // A credential only a person gives and nobody has is no grant. + in := aGrantMesh(t) + in.Issued = nil in.Credentials = []Credential{{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", State: CredentialNotGiven}} if got := rendered(t, in, "anchor", Foreseeing); len(got.Rendering.Grants) != 0 { t.Errorf("a credential nobody gave is granted: %+v", got.Rendering.Grants) diff --git a/go/compose/resolve.go b/go/compose/resolve.go index 4fd5de1..6931adc 100644 --- a/go/compose/resolve.go +++ b/go/compose/resolve.go @@ -118,6 +118,10 @@ func (c *composer) planForNow(nodeName string) (Resolved, error) { } continue } + if credentialless(n) { + // Its provider's offer presents no credential (form none): nothing is sealed, made or stood in for (issue 1126). + continue + } cred, held := c.credential(n, nodeName) _, outside := issuedOutsideBy(shelf, n) switch {