From fbc201a9f7c14b19e07eba56ee86b2e6c74611f2 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:01:02 -0400 Subject: [PATCH 1/6] feat(gitops): derive a composed solution's authority document from its scoped api.consumes The render derived an authority document only from module.contract.codefly.yaml. A composed solution declares what it consumes in solution.codefly.yaml and never in a contract, so it got no authority document, and a host that bounds a delegated person's capability by the consumer's declaration had nothing for it. A module's authority request now comes from exactly one source: its contract, or its solution manifest's scoped api.consumes, derived through core's Manifest.ConsumptionAuthority. Each scoped entry becomes an invoke unit addressed to the consumed module. The manifest is read strictly here, unlike the run path's lenient read, because an authority document is derived from it. Asking from both sources is refused. The existing rules apply unchanged: one module-identity service, a presence, and the solution's composed name as its principal. A solution that asks for scopes with no presence is refused instead of being dropped. Pins core at codefly-dev/core#784's commit until that is tagged. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/solution-host-bindings.md | 20 ++- go.mod | 2 +- go.sum | 4 +- pkg/gitops/authority.go | 87 ++++++++-- pkg/gitops/orchestrate.go | 19 +- .../solution_consumption_authority_test.go | 164 ++++++++++++++++++ 6 files changed, 270 insertions(+), 26 deletions(-) create mode 100644 pkg/gitops/solution_consumption_authority_test.go diff --git a/docs/solution-host-bindings.md b/docs/solution-host-bindings.md index 9112cc98..270e5d5d 100644 --- a/docs/solution-host-bindings.md +++ b/docs/solution-host-bindings.md @@ -22,7 +22,7 @@ nothing straddles them. The render produces the static half: | record | writer | what it carries | |---|---|---| | presence document | the render; settled and signed at publish | kind, binding ID, generation, ownership domain, envelope revision, host, release digest, rendered artifacts and their digests, the workloads with the build each runs and the identity each presents | -| authority document | derived from the module's published contract; signed at publish | the principal and the units of authority it holds, approved for one build, effective from one presence generation | +| authority document | derived from the module's published contract, or from a composed solution's scoped `api.consumes`; signed at publish | the principal and the units of authority it holds, approved for one build, effective from one presence generation | The dynamic half is the host's: the **envelope** (the ceiling a platform administrator writes), an organisation's installation, team exposure. The @@ -269,6 +269,24 @@ may run with is the platform's admission (the authority Job runs as the platform's `delivery` account, whose only reach is the delivery API, which admits nothing unsigned) — the one boundary this render cannot enforce. +**A composed solution asks from its manifest, not from a contract.** A solution +declares what it consumes once, in `solution.codefly.yaml`'s `api.consumes`, which +also drives its generated client and its runtime's upstream map. An entry that +carries `scopes` — `{resource_kind, actions}` in the consumed module's permission +vocabulary — asks for authority there, and the render derives the solution's +authority document from those entries through core's +`manifest.ConsumptionAuthority`: one **invoke** unit per scoped entry, +`::invoke`, addressed to the consumed module's name +(its principal) and bounded by exactly the declared scopes, with the document's +own `queues`, `namespaces` and `scope_ceilings` written `[]`. The principal is +the name the solution is composed under. The manifest is read **strictly** here — +the run path's lenient read stops at the upstream map, and this is what an +authority document is derived from — so an invalid scope fails the render. The +rest is the module rules above, unchanged: one `module-identity` service presents +it, a presence is required, and the host holds it against the envelope. A module +asks from **one** declaration: a contract and scoped `api.consumes` together are +refused, and entries without scopes ask for nothing. + The contract's **principal is the module's own name** — the contract says so of itself, and it is held to that at render: the contract is written in the module's repository, so a principal it names is self-asserted, and a module diff --git a/go.mod b/go.mod index ac8df79e..fdf62505 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( github.com/asottile/dockerfile v3.1.0+incompatible github.com/blang/semver v3.5.1+incompatible github.com/briandowns/spinner v1.23.2 - github.com/codefly-dev/core v0.19.1 + github.com/codefly-dev/core v0.19.2-0.20261012175743-4b58bf6d0e81 github.com/codefly-dev/golor v0.1.3 github.com/codefly-dev/llm v0.1.7 github.com/codefly-dev/sdk-go/workcontext v0.0.0-20261007181327-535084e1872d diff --git a/go.sum b/go.sum index cb176399..76afe636 100644 --- a/go.sum +++ b/go.sum @@ -101,8 +101,8 @@ github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJ github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cloudflare/circl v1.6.5 h1:O64F26HEqNhznd/hrC5KZXVKYuKM2rx4deZDTc4ihQA= github.com/cloudflare/circl v1.6.5/go.mod h1:h5LNyxAc5nTue9DS5jT+48en2PSDYt3zdGnz5OstK6c= -github.com/codefly-dev/core v0.19.1 h1:DLzrbgQu5Io6cnhD2QD9dAqNfh1HVwCs+G3BHP+JQFE= -github.com/codefly-dev/core v0.19.1/go.mod h1:FKBqn6GMGIsx9b3LNY6kKCRxCEjjeswvqt2CxSZWq20= +github.com/codefly-dev/core v0.19.2-0.20261012175743-4b58bf6d0e81 h1:bXt3mzZ6IVYGeXS+x9e/nDYV/pxdXCTjPTnvSpOtM5g= +github.com/codefly-dev/core v0.19.2-0.20261012175743-4b58bf6d0e81/go.mod h1:FKBqn6GMGIsx9b3LNY6kKCRxCEjjeswvqt2CxSZWq20= github.com/codefly-dev/golor v0.1.3 h1:xmo+ceyJFRYZdvpWE2fNd0jeaadp/Ibm1BnganiGKOc= github.com/codefly-dev/golor v0.1.3/go.mod h1:sl/u/K1l7J0Pr3xyVZp8fOJYQItKKst1No9JqgzLLoY= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= diff --git a/pkg/gitops/authority.go b/pkg/gitops/authority.go index 306c483e..ff3df668 100644 --- a/pkg/gitops/authority.go +++ b/pkg/gitops/authority.go @@ -13,6 +13,7 @@ import ( "github.com/codefly-dev/core/configurations" modulecontract "github.com/codefly-dev/core/contracts/module" "github.com/codefly-dev/core/resources" + solutionmanifest "github.com/codefly-dev/core/solution/manifest" "github.com/codefly-dev/core/solutionhost" "gopkg.in/yaml.v3" ) @@ -133,13 +134,13 @@ func authorityInstancesOf( units []SolutionArtifactUnit, endpoints []SolutionEndpoint, ) ([]AuthorityInstance, string, error) { - contract, err := modulecontract.Load(module.Dir()) - if errors.Is(err, os.ErrNotExist) { - return nil, fmt.Sprintf("module %s publishes no %s, so it asks for no authority", module.Name, modulecontract.FileName), nil - } + request, err := authorityRequestOf(module) if err != nil { return nil, "", err } + if request.undeclared != "" { + return nil, request.undeclared, nil + } var identities []*resources.Service for _, service := range services { if service.ModuleIdentity { @@ -147,11 +148,11 @@ func authorityInstancesOf( } } if len(identities) == 0 { - // The contract is the module's request for authority. With no service + // The request is the module's ask for authority. With no service // declaring module-identity nothing would present it, and the request // would be dropped rather than refused: the composition is inconsistent // and is told so here, before slot resolution or any document. - return nil, "", fmt.Errorf("module %s publishes %s, which asks for authority, but no service declares module-identity, so nothing would present it; declare module-identity on the service that presents the module's identity, or withdraw the contract", module.Name, modulecontract.FileName) + return nil, "", fmt.Errorf("module %s %s, which asks for authority, but no service declares module-identity, so nothing would present it; declare module-identity on the service that presents the module's identity, or withdraw the request", module.Name, request.source) } if len(identities) > 1 { names := make([]string, 0, len(identities)) @@ -161,13 +162,15 @@ func authorityInstancesOf( sort.Strings(names) return nil, "", fmt.Errorf("module %s declares module-identity on the services %s; a module presents its authority from one service, because its one authority document approves one build and the host keeps one authority record per binding, so keep module-identity on the service that authenticates as the module", module.Name, strings.Join(names, ", ")) } - provided, err := configurations.ReadWorkspaceConfigurations(ctx, workspace, env.Runtime()) - if err != nil { - return nil, "", fmt.Errorf("read the workspace configurations the contract of %s resolves against: %w", module.Name, err) - } - resolved, err := contract.Resolve(workspaceValues{provided: provided}) - if err != nil { - return nil, "", fmt.Errorf("module %s: %w", module.Name, err) + resolved := request.consumption + if request.contract != nil { + provided, err := configurations.ReadWorkspaceConfigurations(ctx, workspace, env.Runtime()) + if err != nil { + return nil, "", fmt.Errorf("read the workspace configurations the contract of %s resolves against: %w", module.Name, err) + } + if resolved, err = request.contract.Resolve(workspaceValues{provided: provided}); err != nil { + return nil, "", fmt.Errorf("module %s: %w", module.Name, err) + } } // The principal is the module's own name — the contract says so of // itself — and it is held to that here, because the contract is written in @@ -197,6 +200,64 @@ func authorityInstancesOf( return instances, "", nil } +// authorityRequest is what a module asks for authority with, from exactly one +// source: its published module contract, or — for a composed solution, which +// declares what it consumes in its solution manifest — the scopes its +// api.consumes entries ask for. undeclared carries the reason when it asks for +// none; source says which file asked, for a refusal. +type authorityRequest struct { + contract *modulecontract.Contract + consumption *modulecontract.Resolved + source string + undeclared string +} + +// authorityRequestOf reads a module's request for authority. +// +// A solution's consumption is read STRICTLY, through the manifest's own Load: +// the run path decodes the manifest leniently because it needs only the +// upstream map, but this is what an authority document is derived from, so a +// field this core does not know, or an invalid scope, is refused here rather +// than ignored. Both sources at once is refused: a module that asked twice +// would have two declarations of one request to keep in agreement, and the +// render would have to pick one. +func authorityRequestOf(module *resources.Module) (authorityRequest, error) { + contract, err := modulecontract.Load(module.Dir()) + switch { + case errors.Is(err, os.ErrNotExist): + contract = nil + case err != nil: + return authorityRequest{}, err + } + var consumption *modulecontract.Resolved + data, err := os.ReadFile(filepath.Join(module.Dir(), solutionmanifest.FileName)) + switch { + case module.Dir() == "": + // A module constructed rather than loaded has nowhere to ship a + // manifest; reading the working directory instead would be a guess. + case errors.Is(err, os.ErrNotExist): + case err != nil: + return authorityRequest{}, fmt.Errorf("read the solution manifest of module %s: %w", module.Name, err) + default: + declared, loadErr := solutionmanifest.Load(data) + if loadErr != nil { + return authorityRequest{}, fmt.Errorf("module %s: the solution manifest an authority document is derived from: %w", module.Name, loadErr) + } + // A composed solution is a module, and its principal is the name it is + // composed under, exactly as a module contract's principal must be. + consumption = declared.ConsumptionAuthority(module.Name) + } + switch { + case contract != nil && consumption != nil: + return authorityRequest{}, fmt.Errorf("module %s publishes %s and its %s asks for scopes on api.consumes; a module asks for authority from one declaration, so declare the consumption in one of them", module.Name, modulecontract.FileName, solutionmanifest.FileName) + case contract != nil: + return authorityRequest{contract: contract, source: "publishes " + modulecontract.FileName}, nil + case consumption != nil: + return authorityRequest{consumption: consumption, source: "asks for scopes on api.consumes in its " + solutionmanifest.FileName}, nil + } + return authorityRequest{undeclared: fmt.Sprintf("module %s publishes no %s and asks for no scopes on api.consumes, so it asks for no authority", module.Name, modulecontract.FileName)}, nil +} + // renderAuthorityDocuments renders the module's authority document into the // staged tree, approving the build its presenting service's workloads run, and // returns the tree-relative paths written. A render is one module, and a module diff --git a/pkg/gitops/orchestrate.go b/pkg/gitops/orchestrate.go index 0309bade..b8974ece 100644 --- a/pkg/gitops/orchestrate.go +++ b/pkg/gitops/orchestrate.go @@ -2,14 +2,12 @@ package gitops import ( "context" - "errors" "fmt" "os" "path/filepath" "sort" "strings" - modulecontract "github.com/codefly-dev/core/contracts/module" "github.com/codefly-dev/cli/pkg/builder" "github.com/codefly-dev/cli/pkg/environments" @@ -378,13 +376,16 @@ func (r *moduleRender) declareInstances(ctx context.Context, services []*resourc } r.options.UndeclaredPresence = undeclared if instance == nil { - // A module that publishes a contract asks for authority, and - // authority is effective from a presence generation: with no - // presence to declare, the request would be dropped silently. - if _, loadErr := modulecontract.Load(r.module.Dir()); loadErr == nil { - return fmt.Errorf("module %s publishes %s but declares no presence (%s); authority is effective from a presence generation, so a module with a contract must declare presence", r.module.Name, modulecontract.FileName, undeclared) - } else if !errors.Is(loadErr, os.ErrNotExist) { - return loadErr + // A module that asks for authority — a published contract, or a + // solution's scoped consumption — needs a presence, because authority + // is effective from a presence generation: with no presence to + // declare, the request would be dropped silently. + request, requestErr := authorityRequestOf(r.module) + if requestErr != nil { + return requestErr + } + if request.undeclared == "" { + return fmt.Errorf("module %s %s but declares no presence (%s); authority is effective from a presence generation, so a module that asks for authority must declare presence", r.module.Name, request.source, undeclared) } r.options.UndeclaredAuthority = "the module declares no presence, so no authority can be effective from it" return nil diff --git a/pkg/gitops/solution_consumption_authority_test.go b/pkg/gitops/solution_consumption_authority_test.go new file mode 100644 index 00000000..901a8b99 --- /dev/null +++ b/pkg/gitops/solution_consumption_authority_test.go @@ -0,0 +1,164 @@ +package gitops + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + modulecontract "github.com/codefly-dev/core/contracts/module" + "github.com/codefly-dev/core/resources" + solutionmanifest "github.com/codefly-dev/core/solution/manifest" + "github.com/codefly-dev/core/solutionhost" + "github.com/stretchr/testify/require" +) + +// consumingSolution is a solution that consumes the host's accounts with the +// viewer's own credential, a documents module it calls on a person's behalf, +// and a chat module it does not: the shape of a knowledge-base solution whose +// tools read passages for the asking person. +const consumingSolution = `schema_version: codefly.solution-manifest/v0 +protocol_version: codefly.solution/v0 +agent: + publisher: codefly.dev + kind: codefly:solution + name: solution-generic + version: 0.0.1 +api: + exposes: + - id: http + protocol: http + consumes: + - id: accounts + protocol: connect + module: saas + service: accounts + endpoint: connect + as: accounts + - id: documents + protocol: rest + module: documents + service: documents + endpoint: rest + as: documents + scopes: + - resource_kind: documents.passages + actions: [read] + - resource_kind: documents.collections + actions: [read] + - id: chat + protocol: http + module: assistant + service: chat + endpoint: http + as: assistant +lifecycle: + package: true + render: true +` + +// writeConsumingSolutionWorkspace is writeAuthorityWorkspace with the module's +// contract replaced by a solution manifest: the only declaration a solution +// makes of what it consumes. +func writeConsumingSolutionWorkspace(t *testing.T, solution string) (*resources.Workspace, *resources.Module) { + t.Helper() + workspace, module := writeAuthorityWorkspace(t) + require.NoError(t, os.Remove(filepath.Join(module.Dir(), modulecontract.FileName))) + require.NoError(t, os.WriteFile(filepath.Join(module.Dir(), solutionmanifest.FileName), []byte(solution), 0o644)) + return workspace, module +} + +// THE DELIVERY HALF OF A SOLUTION'S CEILING: its declared consumption becomes +// the signed authority document's invoke unit for the module it calls on a +// person's behalf — addressed to that module, bounded by exactly the scopes it +// declared — and the consumptions that ask for no scopes become nothing. +func TestRenderDerivesAuthorityFromASolutionsDeclaredConsumption(t *testing.T) { + ctx := context.Background() + workspace, module := writeConsumingSolutionWorkspace(t, consumingSolution) + env := selectedEnvironment(t, workspace, "staging") + services := loadServices(t, workspace, "shop", "api") + units := []SolutionArtifactUnit{{Name: "api", Path: "services/api", Subject: "shop-api@example.iam.test"}} + + instances, undeclared, err := authorityInstancesOf(ctx, workspace, module, services, env, units, nil) + require.NoError(t, err) + require.Empty(t, undeclared) + require.Len(t, instances, 1) + + destination, _ := renderShopAuthority(t, workspace, env, units, instances) + document, _ := readDeliveredAuthority(t, destination, "staging", "acme.staging.shop-authority.yaml") + require.Len(t, document.Principals, 1) + require.Equal(t, "shop", document.Principals[0].Principal, "a composed solution's principal is the name it is composed under") + require.Equal(t, []solutionhost.AuthorityBinding{ + {ID: "acme.staging.shop:documents:invoke", Revision: 1, Audience: "documents", Scope: "documents.collections:read,documents.passages:read"}, + }, document.Principals[0].Bindings, "one invoke unit, for the one consumption that asks for scopes") + // Consumption contributes none of a module's own declarations, and says so. + require.Empty(t, document.Queues) + require.Empty(t, document.Namespaces) + require.Empty(t, document.ScopeCeilings) +} + +// A solution that consumes modules and asks for no scopes asks for no +// authority, and the render says why. +func TestASolutionAskingForNoScopesAsksForNoAuthority(t *testing.T) { + ctx := context.Background() + unscoped := strings.Replace(consumingSolution, + " scopes:\n - resource_kind: documents.passages\n actions: [read]\n - resource_kind: documents.collections\n actions: [read]\n", "", 1) + require.NotEqual(t, consumingSolution, unscoped) + workspace, module := writeConsumingSolutionWorkspace(t, unscoped) + env := selectedEnvironment(t, workspace, "staging") + services := loadServices(t, workspace, "shop", "api") + + instances, undeclared, err := authorityInstancesOf(ctx, workspace, module, services, env, nil, nil) + + require.NoError(t, err) + require.Nil(t, instances) + require.Contains(t, undeclared, "asks for no scopes on api.consumes") +} + +// One request, from one declaration: a contract AND scoped consumption is +// refused rather than one silently chosen. +func TestASolutionCannotAskForAuthorityTwice(t *testing.T) { + ctx := context.Background() + workspace, module := writeConsumingSolutionWorkspace(t, consumingSolution) + require.NoError(t, os.WriteFile(filepath.Join(module.Dir(), modulecontract.FileName), []byte(authorityContract), 0o644)) + env := selectedEnvironment(t, workspace, "staging") + services := loadServices(t, workspace, "shop", "api") + + _, _, err := authorityInstancesOf(ctx, workspace, module, services, env, nil, nil) + + require.ErrorContains(t, err, "asks for authority from one declaration") +} + +// The manifest an authority document is derived from is read strictly: a scope +// the manifest's own rules refuse fails the render, it is not ignored the way +// the run path's lenient read ignores what it does not need. +func TestAnInvalidConsumedScopeFailsTheRender(t *testing.T) { + ctx := context.Background() + workspace, module := writeConsumingSolutionWorkspace(t, strings.Replace(consumingSolution, "actions: [read]\n - resource_kind: documents.collections", "actions: [\"*\"]\n - resource_kind: documents.collections", 1)) + env := selectedEnvironment(t, workspace, "staging") + services := loadServices(t, workspace, "shop", "api") + + _, _, err := authorityInstancesOf(ctx, workspace, module, services, env, nil, nil) + + require.ErrorContains(t, err, "is not an action") +} + +// Scoped consumption with no module-identity service is a request nothing would +// present: refused naming where it was asked, never dropped. +func TestScopedConsumptionWithoutAnIdentityIsRefused(t *testing.T) { + ctx := context.Background() + workspace, module := writeConsumingSolutionWorkspace(t, consumingSolution) + service := filepath.Join(workspace.Dir(), "modules", "shop", "services", "api", resources.ServiceConfigurationName) + data, err := os.ReadFile(service) + require.NoError(t, err) + require.NoError(t, os.WriteFile(service, []byte(strings.Replace(string(data), "module-identity: true\n", "", 1)), 0o644)) + env := selectedEnvironment(t, workspace, "staging") + services := loadServices(t, workspace, "shop", "api") + + instances, _, err := authorityInstancesOf(ctx, workspace, module, services, env, nil, nil) + + require.Nil(t, instances) + require.ErrorContains(t, err, "asks for scopes on api.consumes") + require.ErrorContains(t, err, "module-identity") +} From 1f4d6ae249b5e517af3790448894c27ae27670e9 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:03:21 -0400 Subject: [PATCH 2/6] style(gitops): drop the blank import line the contract import left Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/gitops/orchestrate.go | 1 - 1 file changed, 1 deletion(-) diff --git a/pkg/gitops/orchestrate.go b/pkg/gitops/orchestrate.go index b8974ece..9cca1b98 100644 --- a/pkg/gitops/orchestrate.go +++ b/pkg/gitops/orchestrate.go @@ -8,7 +8,6 @@ import ( "sort" "strings" - "github.com/codefly-dev/cli/pkg/builder" "github.com/codefly-dev/cli/pkg/environments" "github.com/codefly-dev/cli/pkg/internal/selectionguard" From d8d4b1dff0135c7121bdf7419864c96c449199ae Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:35:50 -0400 Subject: [PATCH 3/6] fix(gitops): read only api.consumes for a solution's authority, and resolve its audience like a contract From review: - The render reads a solution manifest through core's LoadConsumption: the schema version and api.consumes, strictly, and nothing else. A solution whose manifest the strict loader refuses for an unrelated section (no agent block, a section core does not model), and which asks for no authority, renders as before. - A solution's consumption is a module contract (core's ConsumptionContract), resolved through the same Resolve as a published one. Its audience is the composition's, not the composed module name. - The render refuses a scoped entry for a module the workspace does not compose, and one addressed to the solution itself, by module or by a resolved audience equal to its principal. The host could never install either. - A contract plus scoped consumption is still refused by name, including a destinations-only contract, until merging the two is decided. - Fixtures use structural names. Adds the review's tests. Pins core at codefly-dev/core#784's head as a pseudo-version. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/solution-host-bindings.md | 38 +-- go.mod | 2 +- go.sum | 4 +- pkg/gitops/authority.go | 104 ++++++-- .../solution_consumption_authority_test.go | 227 ++++++++++++++---- .../testdata/consumption/agentless.yaml | 18 ++ .../testdata/consumption/scoped-consumer.yaml | 38 +++ .../consumption/undeclared-section.yaml | 22 ++ pkg/solutionrun/federation_test.go | 4 +- 9 files changed, 367 insertions(+), 90 deletions(-) create mode 100644 pkg/gitops/testdata/consumption/agentless.yaml create mode 100644 pkg/gitops/testdata/consumption/scoped-consumer.yaml create mode 100644 pkg/gitops/testdata/consumption/undeclared-section.yaml diff --git a/docs/solution-host-bindings.md b/docs/solution-host-bindings.md index 270e5d5d..6cd77e09 100644 --- a/docs/solution-host-bindings.md +++ b/docs/solution-host-bindings.md @@ -271,21 +271,29 @@ admits nothing unsigned) — the one boundary this render cannot enforce. **A composed solution asks from its manifest, not from a contract.** A solution declares what it consumes once, in `solution.codefly.yaml`'s `api.consumes`, which -also drives its generated client and its runtime's upstream map. An entry that -carries `scopes` — `{resource_kind, actions}` in the consumed module's permission -vocabulary — asks for authority there, and the render derives the solution's -authority document from those entries through core's -`manifest.ConsumptionAuthority`: one **invoke** unit per scoped entry, -`::invoke`, addressed to the consumed module's name -(its principal) and bounded by exactly the declared scopes, with the document's -own `queues`, `namespaces` and `scope_ceilings` written `[]`. The principal is -the name the solution is composed under. The manifest is read **strictly** here — -the run path's lenient read stops at the upstream map, and this is what an -authority document is derived from — so an invalid scope fails the render. The -rest is the module rules above, unchanged: one `module-identity` service presents -it, a presence is required, and the host holds it against the envelope. A module -asks from **one** declaration: a contract and scoped `api.consumes` together are -refused, and entries without scopes ask for nothing. +also drives its generated client and its runtime's upstream map. Under schema +`codefly.solution-manifest/v1` an entry that carries `scopes` (`{resource_kind, +actions}` in the consumed module's permission vocabulary) and an `audience` slot +(`{from: /}`) asks for authority there. Core's `ConsumptionContract` +turns those entries into a **module contract** — one invoke binding per entry — +and the render resolves it through the same `Resolve` a published contract goes +through, so the audience is whatever the composition says that module verifies, +and the unit is `::invoke` with the document's own +`queues`, `namespaces` and `scope_ceilings` written `[]`. The principal is the name +the solution is composed under. + +The manifest is read through core's `LoadConsumption`: the schema version and +`api.consumes`, strictly — an invalid or unknown field in a consumption fails the +render — and nothing else, so a section the strict loader refuses for reasons +unrelated to consumption never fails the render of a solution that asks for no +authority. On top of the module rules below (one `module-identity` service, a +presence, the envelope at the host), the render refuses a scoped entry for a +module the workspace does not compose, and one addressed to the solution itself +(by module, or by a resolved audience equal to its principal), because the host +could never install either. A module asks from **one** declaration: a contract and +scoped `api.consumes` together are refused by name — including a contract that +declares only destinations — until merging two declarations of one request is +decided. Entries without scopes ask for nothing. The contract's **principal is the module's own name** — the contract says so of itself, and it is held to that at render: the contract is written in the diff --git a/go.mod b/go.mod index fdf62505..51d07c5e 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( github.com/asottile/dockerfile v3.1.0+incompatible github.com/blang/semver v3.5.1+incompatible github.com/briandowns/spinner v1.23.2 - github.com/codefly-dev/core v0.19.2-0.20261012175743-4b58bf6d0e81 + github.com/codefly-dev/core v0.19.2-0.20261012182850-03d6e853a640 github.com/codefly-dev/golor v0.1.3 github.com/codefly-dev/llm v0.1.7 github.com/codefly-dev/sdk-go/workcontext v0.0.0-20261007181327-535084e1872d diff --git a/go.sum b/go.sum index 76afe636..b2f5cf0a 100644 --- a/go.sum +++ b/go.sum @@ -101,8 +101,8 @@ github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJ github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cloudflare/circl v1.6.5 h1:O64F26HEqNhznd/hrC5KZXVKYuKM2rx4deZDTc4ihQA= github.com/cloudflare/circl v1.6.5/go.mod h1:h5LNyxAc5nTue9DS5jT+48en2PSDYt3zdGnz5OstK6c= -github.com/codefly-dev/core v0.19.2-0.20261012175743-4b58bf6d0e81 h1:bXt3mzZ6IVYGeXS+x9e/nDYV/pxdXCTjPTnvSpOtM5g= -github.com/codefly-dev/core v0.19.2-0.20261012175743-4b58bf6d0e81/go.mod h1:FKBqn6GMGIsx9b3LNY6kKCRxCEjjeswvqt2CxSZWq20= +github.com/codefly-dev/core v0.19.2-0.20261012182850-03d6e853a640 h1:W79AMTn4GfWHKgIoIyun1phKQpKsvj8ikeXqFmb4ARw= +github.com/codefly-dev/core v0.19.2-0.20261012182850-03d6e853a640/go.mod h1:FKBqn6GMGIsx9b3LNY6kKCRxCEjjeswvqt2CxSZWq20= github.com/codefly-dev/golor v0.1.3 h1:xmo+ceyJFRYZdvpWE2fNd0jeaadp/Ibm1BnganiGKOc= github.com/codefly-dev/golor v0.1.3/go.mod h1:sl/u/K1l7J0Pr3xyVZp8fOJYQItKKst1No9JqgzLLoY= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= diff --git a/pkg/gitops/authority.go b/pkg/gitops/authority.go index ff3df668..b270cfad 100644 --- a/pkg/gitops/authority.go +++ b/pkg/gitops/authority.go @@ -162,15 +162,16 @@ func authorityInstancesOf( sort.Strings(names) return nil, "", fmt.Errorf("module %s declares module-identity on the services %s; a module presents its authority from one service, because its one authority document approves one build and the host keeps one authority record per binding, so keep module-identity on the service that authenticates as the module", module.Name, strings.Join(names, ", ")) } - resolved := request.consumption - if request.contract != nil { - provided, err := configurations.ReadWorkspaceConfigurations(ctx, workspace, env.Runtime()) - if err != nil { - return nil, "", fmt.Errorf("read the workspace configurations the contract of %s resolves against: %w", module.Name, err) - } - if resolved, err = request.contract.Resolve(workspaceValues{provided: provided}); err != nil { - return nil, "", fmt.Errorf("module %s: %w", module.Name, err) - } + provided, err := configurations.ReadWorkspaceConfigurations(ctx, workspace, env.Runtime()) + if err != nil { + return nil, "", fmt.Errorf("read the workspace configurations the contract of %s resolves against: %w", module.Name, err) + } + resolved, err := request.contract.Resolve(workspaceValues{provided: provided}) + if err != nil { + return nil, "", fmt.Errorf("module %s: %w", module.Name, err) + } + if err := refuseUnaddressableConsumption(workspace, module, request, resolved); err != nil { + return nil, "", err } // The principal is the module's own name — the contract says so of // itself — and it is held to that here, because the contract is written in @@ -206,21 +207,33 @@ func authorityInstancesOf( // api.consumes entries ask for. undeclared carries the reason when it asks for // none; source says which file asked, for a refusal. type authorityRequest struct { - contract *modulecontract.Contract - consumption *modulecontract.Resolved - source string - undeclared string + contract *modulecontract.Contract + // consumed is the scoped api.consumes entries the contract was built + // from, when the request is a solution's consumption; nil for a module's + // own contract. + consumed []solutionmanifest.APIDeclaration + source string + undeclared string } -// authorityRequestOf reads a module's request for authority. +// authorityRequestOf reads a module's request for authority, as a module +// contract either way: the one it publishes, or the one a composed solution's +// scoped api.consumes amounts to (core's ConsumptionContract). The caller +// resolves both through the same Resolve, so a solution's audience comes from +// the composition exactly as a module's does. +// +// The solution manifest is read through core's LoadConsumption: the schema +// version and api.consumes, strictly, and nothing else. An invalid or unknown +// field in a consumption fails the render; a section with nothing to do with +// consumption — an agent block, a section this core does not model — never +// does, because a render of a solution that asks for no authority must not fail +// on what an authority read does not read. The run path's lenient read is +// unchanged. // -// A solution's consumption is read STRICTLY, through the manifest's own Load: -// the run path decodes the manifest leniently because it needs only the -// upstream map, but this is what an authority document is derived from, so a -// field this core does not know, or an invalid scope, is refused here rather -// than ignored. Both sources at once is refused: a module that asked twice -// would have two declarations of one request to keep in agreement, and the -// render would have to pick one. +// Both sources at once is refused, by name. A solution that is also consumed +// may publish a contract for its destinations; merging its bindings with a +// derived consumption is a decision about two declarations of one request that +// nothing has made yet, so for now it declares its consumption in one place. func authorityRequestOf(module *resources.Module) (authorityRequest, error) { contract, err := modulecontract.Load(module.Dir()) switch { @@ -229,7 +242,8 @@ func authorityRequestOf(module *resources.Module) (authorityRequest, error) { case err != nil: return authorityRequest{}, err } - var consumption *modulecontract.Resolved + var consumption *modulecontract.Contract + var consumed []solutionmanifest.APIDeclaration data, err := os.ReadFile(filepath.Join(module.Dir(), solutionmanifest.FileName)) switch { case module.Dir() == "": @@ -239,13 +253,20 @@ func authorityRequestOf(module *resources.Module) (authorityRequest, error) { case err != nil: return authorityRequest{}, fmt.Errorf("read the solution manifest of module %s: %w", module.Name, err) default: - declared, loadErr := solutionmanifest.Load(data) + declared, loadErr := solutionmanifest.LoadConsumption(data) if loadErr != nil { - return authorityRequest{}, fmt.Errorf("module %s: the solution manifest an authority document is derived from: %w", module.Name, loadErr) + return authorityRequest{}, fmt.Errorf("module %s: the api.consumes an authority document is derived from: %w", module.Name, loadErr) } // A composed solution is a module, and its principal is the name it is // composed under, exactly as a module contract's principal must be. - consumption = declared.ConsumptionAuthority(module.Name) + if consumption, err = declared.ConsumptionContract(module.Name); err != nil { + return authorityRequest{}, fmt.Errorf("module %s: %w", module.Name, err) + } + for _, declaration := range declared.API.Consumes { + if len(declaration.Scopes) > 0 { + consumed = append(consumed, declaration) + } + } } switch { case contract != nil && consumption != nil: @@ -253,11 +274,42 @@ func authorityRequestOf(module *resources.Module) (authorityRequest, error) { case contract != nil: return authorityRequest{contract: contract, source: "publishes " + modulecontract.FileName}, nil case consumption != nil: - return authorityRequest{consumption: consumption, source: "asks for scopes on api.consumes in its " + solutionmanifest.FileName}, nil + return authorityRequest{contract: consumption, consumed: consumed, source: "asks for scopes on api.consumes in its " + solutionmanifest.FileName}, nil } return authorityRequest{undeclared: fmt.Sprintf("module %s publishes no %s and asks for no scopes on api.consumes, so it asks for no authority", module.Name, modulecontract.FileName)}, nil } +// refuseUnaddressableConsumption refuses a solution's request the host could +// never install, here, where its author reads the reason: a scoped entry for a +// module the composition does not compose, and one addressed to the solution +// itself — by name, or by a resolved audience that is its own principal — which +// the host omits from its registry with only a logged reason. +func refuseUnaddressableConsumption(workspace *resources.Workspace, module *resources.Module, request authorityRequest, resolved *modulecontract.Resolved) error { + if request.consumed == nil { + return nil + } + composed := map[string]bool{} + for _, reference := range workspace.Modules { + if reference != nil { + composed[reference.Name] = true + } + } + for _, declaration := range request.consumed { + if declaration.Module == module.Name { + return fmt.Errorf("module %s asks for scopes at itself on api.consumes entry %s; a credential is never addressed to its own principal, and the host would never install the binding", module.Name, declaration.ID) + } + if !composed[declaration.Module] { + return fmt.Errorf("module %s asks for scopes at module %s on api.consumes entry %s, which this workspace does not compose; a binding for a module nothing composes can never be installed", module.Name, declaration.Module, declaration.ID) + } + } + for _, binding := range resolved.Bindings { + if binding.Audience == resolved.Principal { + return fmt.Errorf("module %s asks for scopes on api.consumes entry %s at audience %q, which is its own principal; a credential is never addressed to its own principal, and the host would never install the binding", module.Name, binding.ID, binding.Audience) + } + } + return nil +} + // renderAuthorityDocuments renders the module's authority document into the // staged tree, approving the build its presenting service's workloads run, and // returns the tree-relative paths written. A render is one module, and a module diff --git a/pkg/gitops/solution_consumption_authority_test.go b/pkg/gitops/solution_consumption_authority_test.go index 901a8b99..3bc57a1f 100644 --- a/pkg/gitops/solution_consumption_authority_test.go +++ b/pkg/gitops/solution_consumption_authority_test.go @@ -14,11 +14,10 @@ import ( "github.com/stretchr/testify/require" ) -// consumingSolution is a solution that consumes the host's accounts with the -// viewer's own credential, a documents module it calls on a person's behalf, -// and a chat module it does not: the shape of a knowledge-base solution whose -// tools read passages for the asking person. -const consumingSolution = `schema_version: codefly.solution-manifest/v0 +// consumingSolution consumes a platform module with the viewer's own +// credential, a documents module it calls on a person's behalf, and a chat +// module it does not. +const consumingSolution = `schema_version: codefly.solution-manifest/v1 protocol_version: codefly.solution/v0 agent: publisher: codefly.dev @@ -30,18 +29,19 @@ api: - id: http protocol: http consumes: - - id: accounts + - id: identity protocol: connect - module: saas - service: accounts + module: platform + service: identity endpoint: connect - as: accounts + as: identity - id: documents protocol: rest module: documents service: documents endpoint: rest as: documents + audience: {from: documents/documents-prefix} scopes: - resource_kind: documents.passages actions: [read] @@ -59,28 +59,63 @@ lifecycle: ` // writeConsumingSolutionWorkspace is writeAuthorityWorkspace with the module's -// contract replaced by a solution manifest: the only declaration a solution -// makes of what it consumes. +// contract replaced by a solution manifest, composing the modules a scoped +// consumption asks for authority at (documents, model) and supplying the +// audience each verifies. func writeConsumingSolutionWorkspace(t *testing.T, solution string) (*resources.Workspace, *resources.Module) { t.Helper() workspace, module := writeAuthorityWorkspace(t) + root := workspace.Dir() require.NoError(t, os.Remove(filepath.Join(module.Dir(), modulecontract.FileName))) require.NoError(t, os.WriteFile(filepath.Join(module.Dir(), solutionmanifest.FileName), []byte(solution), 0o644)) - return workspace, module + config := filepath.Join(root, resources.WorkspaceConfigurationName) + data, err := os.ReadFile(config) + require.NoError(t, err) + composed := strings.Replace(string(data), " - name: billing\n", " - name: billing\n - name: documents\n - name: model\n", 1) + require.NotEqual(t, string(data), composed) + require.NoError(t, os.WriteFile(config, []byte(composed), 0o644)) + for _, name := range []string{"documents", "model"} { + dir := filepath.Join(root, "modules", name) + require.NoError(t, os.MkdirAll(dir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, resources.ModuleConfigurationName), []byte("kind: module\nname: "+name+"\n"), 0o644)) + } + require.NoError(t, os.WriteFile(filepath.Join(root, "configurations", "staging", "documents.env"), []byte("DOCUMENTS_PREFIX=documents\n"), 0o644)) + ctx := context.Background() + reloaded, err := resources.LoadWorkspaceFromDir(ctx, root) + require.NoError(t, err) + reloadedModule, err := reloaded.LoadModuleFromName(ctx, "shop") + require.NoError(t, err) + return reloaded, reloadedModule +} + +func consumptionFixture(t *testing.T, name string) string { + t.Helper() + data, err := os.ReadFile(filepath.Join("testdata", "consumption", name)) + require.NoError(t, err) + return string(data) +} + +func deriveShopAuthority(t *testing.T, solution string) ([]AuthorityInstance, string, error) { + t.Helper() + workspace, module := writeConsumingSolutionWorkspace(t, solution) + env := selectedEnvironment(t, workspace, "staging") + services := loadServices(t, workspace, "shop", "api") + units := []SolutionArtifactUnit{{Name: "api", Path: "services/api", Subject: "shop-api@example.iam.test"}} + return authorityInstancesOf(context.Background(), workspace, module, services, env, units, nil) } // THE DELIVERY HALF OF A SOLUTION'S CEILING: its declared consumption becomes // the signed authority document's invoke unit for the module it calls on a -// person's behalf — addressed to that module, bounded by exactly the scopes it -// declared — and the consumptions that ask for no scopes become nothing. +// person's behalf — its audience resolved from the composition, bounded by +// exactly the declared scopes — and the consumptions that ask for no scopes +// become nothing. func TestRenderDerivesAuthorityFromASolutionsDeclaredConsumption(t *testing.T) { - ctx := context.Background() workspace, module := writeConsumingSolutionWorkspace(t, consumingSolution) env := selectedEnvironment(t, workspace, "staging") services := loadServices(t, workspace, "shop", "api") units := []SolutionArtifactUnit{{Name: "api", Path: "services/api", Subject: "shop-api@example.iam.test"}} - instances, undeclared, err := authorityInstancesOf(ctx, workspace, module, services, env, units, nil) + instances, undeclared, err := authorityInstancesOf(context.Background(), workspace, module, services, env, units, nil) require.NoError(t, err) require.Empty(t, undeclared) require.Len(t, instances, 1) @@ -92,62 +127,145 @@ func TestRenderDerivesAuthorityFromASolutionsDeclaredConsumption(t *testing.T) { require.Equal(t, []solutionhost.AuthorityBinding{ {ID: "acme.staging.shop:documents:invoke", Revision: 1, Audience: "documents", Scope: "documents.collections:read,documents.passages:read"}, }, document.Principals[0].Bindings, "one invoke unit, for the one consumption that asks for scopes") - // Consumption contributes none of a module's own declarations, and says so. require.Empty(t, document.Queues) require.Empty(t, document.Namespaces) require.Empty(t, document.ScopeCeilings) } +// The scoped consumer fixture renders one documents binding end to end, and +// deterministically: a second render is byte-identical. +func TestAScopedConsumerRendersDeterministically(t *testing.T) { + workspace, module := writeConsumingSolutionWorkspace(t, consumptionFixture(t, "scoped-consumer.yaml")) + env := selectedEnvironment(t, workspace, "staging") + services := loadServices(t, workspace, "shop", "api") + units := []SolutionArtifactUnit{{Name: "api", Path: "services/api", Subject: "shop-api@example.iam.test"}} + instances, _, err := authorityInstancesOf(context.Background(), workspace, module, services, env, units, nil) + require.NoError(t, err) + + destination, _ := renderShopAuthority(t, workspace, env, units, instances) + document, _ := readDeliveredAuthority(t, destination, "staging", "acme.staging.shop-authority.yaml") + require.Equal(t, []solutionhost.AuthorityBinding{ + {ID: "acme.staging.shop:documents:invoke", Revision: 1, Audience: "documents", Scope: "documents:read"}, + }, document.Principals[0].Bindings) + + path := filepath.Join(filepath.FromSlash(solutionAuthorityOverlay("staging")), "acme.staging.shop-authority.yaml") + first, err := os.ReadFile(filepath.Join(destination, path)) + require.NoError(t, err) + again, _ := renderShopAuthority(t, workspace, env, units, instances) + second, err := os.ReadFile(filepath.Join(again, path)) + require.NoError(t, err) + require.Equal(t, string(first), string(second)) +} + +// The audience follows the composition, as a contract's does: the composition +// says the model module verifies model-gateway, and that is the audience. +func TestTheConsumptionAudienceFollowsTheComposition(t *testing.T) { + model := strings.Replace(consumingSolution, " - id: chat\n", + " - id: model\n protocol: grpc\n module: model\n service: gateway\n endpoint: grpc\n audience: {from: assistant/model-audience}\n scopes:\n - resource_kind: modelservice.profiles\n actions: [invoke]\n - id: chat\n", 1) + require.NotEqual(t, consumingSolution, model) + + instances, _, err := deriveShopAuthority(t, model) + + require.NoError(t, err) + audiences := map[string]string{} + for _, binding := range instances[0].Contract.Bindings { + audiences[binding.ID] = binding.Audience + } + require.Equal(t, map[string]string{"documents": "documents", "model": "model-gateway"}, audiences) +} + +// BLAST RADIUS: a solution whose manifest Load refuses on a section that has +// nothing to do with consumption, and which asks for no authority, renders as +// before. +func TestAnUnscopedSolutionStillRenders(t *testing.T) { + for _, name := range []string{"agentless.yaml", "undeclared-section.yaml"} { + t.Run(name, func(t *testing.T) { + instances, undeclared, err := deriveShopAuthority(t, consumptionFixture(t, name)) + require.NoError(t, err, "a solution that asks for no scopes asks for no authority and renders as before") + require.Nil(t, instances) + require.Contains(t, undeclared, "asks for no scopes on api.consumes") + }) + } +} + // A solution that consumes modules and asks for no scopes asks for no // authority, and the render says why. func TestASolutionAskingForNoScopesAsksForNoAuthority(t *testing.T) { - ctx := context.Background() unscoped := strings.Replace(consumingSolution, - " scopes:\n - resource_kind: documents.passages\n actions: [read]\n - resource_kind: documents.collections\n actions: [read]\n", "", 1) + " audience: {from: documents/documents-prefix}\n scopes:\n - resource_kind: documents.passages\n actions: [read]\n - resource_kind: documents.collections\n actions: [read]\n", "", 1) require.NotEqual(t, consumingSolution, unscoped) - workspace, module := writeConsumingSolutionWorkspace(t, unscoped) - env := selectedEnvironment(t, workspace, "staging") - services := loadServices(t, workspace, "shop", "api") - instances, undeclared, err := authorityInstancesOf(ctx, workspace, module, services, env, nil, nil) + instances, undeclared, err := deriveShopAuthority(t, unscoped) require.NoError(t, err) require.Nil(t, instances) require.Contains(t, undeclared, "asks for no scopes on api.consumes") } +// A scoped entry for a module the composition does not compose is refused at +// render, where its author reads it. +func TestScopesAtAModuleNotComposedAreRefused(t *testing.T) { + elsewhere := strings.Replace(consumingSolution, " module: documents\n service: documents\n", " module: archive\n service: documents\n", 1) + require.NotEqual(t, consumingSolution, elsewhere) + + _, _, err := deriveShopAuthority(t, elsewhere) + + require.ErrorContains(t, err, "at module archive") + require.ErrorContains(t, err, "does not compose") +} + +// A solution asking for scopes at itself is refused by name — whether it names +// itself as the module or the composition resolves the audience to its own +// principal — rather than rendered for the host to omit with a logged reason. +func TestScopesAtTheSolutionItselfAreRefused(t *testing.T) { + t.Run("by module", func(t *testing.T) { + self := strings.Replace(consumingSolution, " module: documents\n service: documents\n endpoint: rest\n", + " module: shop\n service: api\n endpoint: http\n", 1) + require.NotEqual(t, consumingSolution, self) + _, _, err := deriveShopAuthority(t, self) + require.ErrorContains(t, err, "asks for scopes at itself") + }) + t.Run("by resolved audience", func(t *testing.T) { + workspace, module := writeConsumingSolutionWorkspace(t, consumingSolution) + require.NoError(t, os.WriteFile(filepath.Join(workspace.Dir(), "configurations", "staging", "documents.env"), []byte("DOCUMENTS_PREFIX=shop\n"), 0o644)) + env := selectedEnvironment(t, workspace, "staging") + services := loadServices(t, workspace, "shop", "api") + _, _, err := authorityInstancesOf(context.Background(), workspace, module, services, env, nil, nil) + require.ErrorContains(t, err, `at audience "shop", which is its own principal`) + }) +} + // One request, from one declaration: a contract AND scoped consumption is -// refused rather than one silently chosen. +// refused by name — even a contract that declares only destinations and no +// bindings. Merging the two is a decision nothing has made yet. func TestASolutionCannotAskForAuthorityTwice(t *testing.T) { - ctx := context.Background() - workspace, module := writeConsumingSolutionWorkspace(t, consumingSolution) - require.NoError(t, os.WriteFile(filepath.Join(module.Dir(), modulecontract.FileName), []byte(authorityContract), 0o644)) - env := selectedEnvironment(t, workspace, "staging") - services := loadServices(t, workspace, "shop", "api") + for name, contract := range map[string]string{ + "a contract with bindings": authorityContract, + "a destinations-only contract": "schema: codefly/module-contract/v1\nprincipal: shop\nnamespaces: []\nqueues: []\nscope_ceilings: []\nbindings: []\ndestinations:\n - id: api-http\n service: api\n endpoint: http\n kind: module\n", + } { + t.Run(name, func(t *testing.T) { + workspace, module := writeConsumingSolutionWorkspace(t, consumingSolution) + require.NoError(t, os.WriteFile(filepath.Join(module.Dir(), modulecontract.FileName), []byte(contract), 0o644)) + env := selectedEnvironment(t, workspace, "staging") + services := loadServices(t, workspace, "shop", "api") - _, _, err := authorityInstancesOf(ctx, workspace, module, services, env, nil, nil) + _, _, err := authorityInstancesOf(context.Background(), workspace, module, services, env, nil, nil) - require.ErrorContains(t, err, "asks for authority from one declaration") + require.ErrorContains(t, err, "asks for authority from one declaration") + }) + } } -// The manifest an authority document is derived from is read strictly: a scope -// the manifest's own rules refuse fails the render, it is not ignored the way -// the run path's lenient read ignores what it does not need. +// What the authority read DOES read, it reads strictly: an invalid scope fails +// the render. func TestAnInvalidConsumedScopeFailsTheRender(t *testing.T) { - ctx := context.Background() - workspace, module := writeConsumingSolutionWorkspace(t, strings.Replace(consumingSolution, "actions: [read]\n - resource_kind: documents.collections", "actions: [\"*\"]\n - resource_kind: documents.collections", 1)) - env := selectedEnvironment(t, workspace, "staging") - services := loadServices(t, workspace, "shop", "api") - - _, _, err := authorityInstancesOf(ctx, workspace, module, services, env, nil, nil) - - require.ErrorContains(t, err, "is not an action") + _, _, err := deriveShopAuthority(t, strings.Replace(consumingSolution, "actions: [read]\n - resource_kind: documents.collections", "actions: [\"*\"]\n - resource_kind: documents.collections", 1)) + require.ErrorContains(t, err, "the api.consumes an authority document is derived from") } // Scoped consumption with no module-identity service is a request nothing would // present: refused naming where it was asked, never dropped. func TestScopedConsumptionWithoutAnIdentityIsRefused(t *testing.T) { - ctx := context.Background() workspace, module := writeConsumingSolutionWorkspace(t, consumingSolution) service := filepath.Join(workspace.Dir(), "modules", "shop", "services", "api", resources.ServiceConfigurationName) data, err := os.ReadFile(service) @@ -156,9 +274,30 @@ func TestScopedConsumptionWithoutAnIdentityIsRefused(t *testing.T) { env := selectedEnvironment(t, workspace, "staging") services := loadServices(t, workspace, "shop", "api") - instances, _, err := authorityInstancesOf(ctx, workspace, module, services, env, nil, nil) + instances, _, err := authorityInstancesOf(context.Background(), workspace, module, services, env, nil, nil) require.Nil(t, instances) require.ErrorContains(t, err, "asks for scopes on api.consumes") require.ErrorContains(t, err, "module-identity") } + +// Two solutions in one composition: each is its own principal, and every unit +// is scoped by its own presence binding, so two solutions consuming the same +// module never share a unit ID. +func TestTwoSolutionsInOneComposition(t *testing.T) { + m, err := solutionmanifest.Load([]byte(consumingSolution)) + require.NoError(t, err) + resolve := func(principal string) *modulecontract.Resolved { + contract, err := m.ConsumptionContract(principal) + require.NoError(t, err) + resolved, err := contract.Resolve(modulecontract.MapValues{Public: map[string]map[string]string{"documents": {"DOCUMENTS_PREFIX": "documents"}}}) + require.NoError(t, err) + return resolved + } + a := authorityBindings("acme.staging.first", resolve("first")) + b := authorityBindings("acme.staging.second", resolve("second")) + require.Len(t, a, 1) + require.Len(t, b, 1) + require.NotEqual(t, a[0].ID, b[0].ID) + require.Equal(t, a[0].Audience, b[0].Audience) +} diff --git a/pkg/gitops/testdata/consumption/agentless.yaml b/pkg/gitops/testdata/consumption/agentless.yaml new file mode 100644 index 00000000..211ce776 --- /dev/null +++ b/pkg/gitops/testdata/consumption/agentless.yaml @@ -0,0 +1,18 @@ +# A composed solution that names no executor: Load refuses it (agent is +# required), and it asks for no authority. +schema_version: codefly.solution-manifest/v0 +protocol_version: codefly.solution/v0 +api: + exposes: + - id: http + protocol: http + consumes: + - id: assistant + protocol: http + module: assistant + service: chat + endpoint: http + as: assistant +lifecycle: + package: true + render: true diff --git a/pkg/gitops/testdata/consumption/scoped-consumer.yaml b/pkg/gitops/testdata/consumption/scoped-consumer.yaml new file mode 100644 index 00000000..66d8eec3 --- /dev/null +++ b/pkg/gitops/testdata/consumption/scoped-consumer.yaml @@ -0,0 +1,38 @@ +schema_version: codefly.solution-manifest/v1 +protocol_version: codefly.solution/v0 +agent: + publisher: codefly.dev + kind: codefly:solution + name: solution-generic + version: 0.0.1 +api: + exposes: + - id: http + protocol: http + consumes: + - id: identity + protocol: connect + module: platform + service: identity + endpoint: connect + services: [DirectoryService] + as: identity + - id: assistant + protocol: http + module: assistant + service: chat + endpoint: http + as: assistant + - id: documents + protocol: rest + module: documents + service: documents + endpoint: rest + as: documents + audience: {from: documents/documents-prefix} + scopes: + - resource_kind: documents + actions: [read] +lifecycle: + package: true + render: true diff --git a/pkg/gitops/testdata/consumption/undeclared-section.yaml b/pkg/gitops/testdata/consumption/undeclared-section.yaml new file mode 100644 index 00000000..eb6c4416 --- /dev/null +++ b/pkg/gitops/testdata/consumption/undeclared-section.yaml @@ -0,0 +1,22 @@ +# A composed solution carrying a top-level section this core does not model: +# Load refuses it (unknown field), and it asks for no authority. +schema_version: codefly.solution-manifest/v0 +protocol_version: codefly.solution/v0 +sources: + - provider: git + repo: example/handbook + paths: [pages/] +api: + exposes: + - id: http + protocol: http + consumes: + - id: documents + protocol: rest + module: documents + service: documents + endpoint: rest + as: documents +lifecycle: + package: true + render: true diff --git a/pkg/solutionrun/federation_test.go b/pkg/solutionrun/federation_test.go index ae6b0a88..a9fd986b 100644 --- a/pkg/solutionrun/federation_test.go +++ b/pkg/solutionrun/federation_test.go @@ -105,14 +105,14 @@ func TestEntryConsumes(t *testing.T) { ID: "documents", Module: "documents", Service: "api", Endpoint: "connect", Protocol: "connect", As: "documents", } - if len(consumed) != 1 || consumed[0] != want { + if len(consumed) != 1 || !reflect.DeepEqual(consumed[0], want) { t.Fatalf("consumed APIs mismatch: got %+v, want [%+v]", consumed, want) } decoded, err := manifest.ParseConsumedAPIs(value) if err != nil { t.Fatalf("cannot decode %s value %q: %v", manifest.APIConsumesEnvironmentVariable, value, err) } - if len(decoded) != 1 || decoded[0] != want { + if len(decoded) != 1 || !reflect.DeepEqual(decoded[0], want) { t.Fatalf("env value does not round-trip: got %+v", decoded) } } From 31804eddc2145893ad2c9b91496bec0763f17ea7 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:46:42 -0400 Subject: [PATCH 4/6] style(gitops): index the consumption loops instead of copying each entry Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/gitops/authority.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/pkg/gitops/authority.go b/pkg/gitops/authority.go index b270cfad..76b847a5 100644 --- a/pkg/gitops/authority.go +++ b/pkg/gitops/authority.go @@ -262,9 +262,9 @@ func authorityRequestOf(module *resources.Module) (authorityRequest, error) { if consumption, err = declared.ConsumptionContract(module.Name); err != nil { return authorityRequest{}, fmt.Errorf("module %s: %w", module.Name, err) } - for _, declaration := range declared.API.Consumes { - if len(declaration.Scopes) > 0 { - consumed = append(consumed, declaration) + for index := range declared.API.Consumes { + if len(declared.API.Consumes[index].Scopes) > 0 { + consumed = append(consumed, declared.API.Consumes[index]) } } } @@ -294,7 +294,8 @@ func refuseUnaddressableConsumption(workspace *resources.Workspace, module *reso composed[reference.Name] = true } } - for _, declaration := range request.consumed { + for index := range request.consumed { + declaration := &request.consumed[index] if declaration.Module == module.Name { return fmt.Errorf("module %s asks for scopes at itself on api.consumes entry %s; a credential is never addressed to its own principal, and the host would never install the binding", module.Name, declaration.ID) } From 38029d9f59136bc1a2b24323c01e673208bc5c09 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:57:21 -0400 Subject: [PATCH 5/6] chore(deps): pin core at main's merge of codefly-dev/core#784 The PR-head pseudo-version named a commit the squash merge does not keep. This one is the commit core main carries, with an identical tree. Co-Authored-By: Claude Opus 5.5 (1M context) --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 51d07c5e..65a31cb2 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( github.com/asottile/dockerfile v3.1.0+incompatible github.com/blang/semver v3.5.1+incompatible github.com/briandowns/spinner v1.23.2 - github.com/codefly-dev/core v0.19.2-0.20261012182850-03d6e853a640 + github.com/codefly-dev/core v0.19.2-0.20261012184621-7ab1214c1f03 github.com/codefly-dev/golor v0.1.3 github.com/codefly-dev/llm v0.1.7 github.com/codefly-dev/sdk-go/workcontext v0.0.0-20261007181327-535084e1872d diff --git a/go.sum b/go.sum index b2f5cf0a..f5d209d0 100644 --- a/go.sum +++ b/go.sum @@ -101,8 +101,8 @@ github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJ github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cloudflare/circl v1.6.5 h1:O64F26HEqNhznd/hrC5KZXVKYuKM2rx4deZDTc4ihQA= github.com/cloudflare/circl v1.6.5/go.mod h1:h5LNyxAc5nTue9DS5jT+48en2PSDYt3zdGnz5OstK6c= -github.com/codefly-dev/core v0.19.2-0.20261012182850-03d6e853a640 h1:W79AMTn4GfWHKgIoIyun1phKQpKsvj8ikeXqFmb4ARw= -github.com/codefly-dev/core v0.19.2-0.20261012182850-03d6e853a640/go.mod h1:FKBqn6GMGIsx9b3LNY6kKCRxCEjjeswvqt2CxSZWq20= +github.com/codefly-dev/core v0.19.2-0.20261012184621-7ab1214c1f03 h1:4PvKRnRBbF1cfhtSvp7QbGR4he9wq9JIdvCoG1DqZrY= +github.com/codefly-dev/core v0.19.2-0.20261012184621-7ab1214c1f03/go.mod h1:FKBqn6GMGIsx9b3LNY6kKCRxCEjjeswvqt2CxSZWq20= github.com/codefly-dev/golor v0.1.3 h1:xmo+ceyJFRYZdvpWE2fNd0jeaadp/Ibm1BnganiGKOc= github.com/codefly-dev/golor v0.1.3/go.mod h1:sl/u/K1l7J0Pr3xyVZp8fOJYQItKKst1No9JqgzLLoY= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= From 592e91df022aef5aa2439eee3264c86e32f4ede0 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:08:30 -0400 Subject: [PATCH 6/6] chore(conformance): declare the v0.19.2 core line the pin is on go.mod pins core main at codefly-dev/core#784 by pseudo-version, which is the v0.19.2 release line, so the matrix declares that line in the same change (docs/supported-matrix.md, "Updating the core pin"). The line has no release yet, and the docs say so. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/supported-matrix.md | 7 ++++++- pkg/conformance/matrix.json | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/supported-matrix.md b/docs/supported-matrix.md index 37204efd..d3d9f5c1 100644 --- a/docs/supported-matrix.md +++ b/docs/supported-matrix.md @@ -27,7 +27,12 @@ When `go.mod` moves to a new core release line, update the `core` field in `go test ./pkg/conformance/...` before the full coverage and race gates. `TestMatrixCoreMatchesGoMod` refuses a stale declared version. -The current pin is released core `v0.19.1`, which verifies delivered carriers +The current pin is core `main` at codefly-dev/core#784's merge commit +(`v0.19.2-0.20261012184621-7ab1214c1f03`), on the **unreleased** `v0.19.2` line: +no core `v0.19.2` tag exists yet, so this matrix declares a line ahead of its +release until one is cut. On top of released core `v0.19.1`, it adds a solution +manifest's scoped `api.consumes` and its consumption contract (#784). `v0.19.1` +verifies delivered carriers against pinned platform keys (#773), projects a service account entirely on a caller-supplied filesystem (#774, #777), and carries ToolProjection and slot-only authority (#755), ciguard (#750), caller tokens, image-lock/publish evidence (#759), and configuration-origin evidence (#746), diff --git a/pkg/conformance/matrix.json b/pkg/conformance/matrix.json index 893a1f84..68749f48 100644 --- a/pkg/conformance/matrix.json +++ b/pkg/conformance/matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "cli": "source", - "core": "v0.19.1", + "core": "v0.19.2", "rows": [ { "id": "linux-amd64-source",