diff --git a/docs/solution-host-bindings.md b/docs/solution-host-bindings.md index 9112cc98d..6cd77e09f 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,32 @@ 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. 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 module's repository, so a principal it names is self-asserted, and a module diff --git a/docs/supported-matrix.md b/docs/supported-matrix.md index 37204efd4..d3d9f5c10 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/go.mod b/go.mod index ac8df79ec..65a31cb21 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.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 cb176399c..f5d209d06 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.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= diff --git a/pkg/conformance/matrix.json b/pkg/conformance/matrix.json index 893a1f848..68749f480 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", diff --git a/pkg/gitops/authority.go b/pkg/gitops/authority.go index 306c483e9..76b847a5e 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)) @@ -165,10 +166,13 @@ func authorityInstancesOf( 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}) + 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 // the module's repository: a module claiming another module's principal @@ -197,6 +201,116 @@ 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 + // 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, 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. +// +// 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 { + case errors.Is(err, os.ErrNotExist): + contract = nil + case err != nil: + return authorityRequest{}, err + } + var consumption *modulecontract.Contract + var consumed []solutionmanifest.APIDeclaration + 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.LoadConsumption(data) + if loadErr != nil { + 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. + if consumption, err = declared.ConsumptionContract(module.Name); err != nil { + return authorityRequest{}, fmt.Errorf("module %s: %w", module.Name, err) + } + for index := range declared.API.Consumes { + if len(declared.API.Consumes[index].Scopes) > 0 { + consumed = append(consumed, declared.API.Consumes[index]) + } + } + } + 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{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 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) + } + 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/orchestrate.go b/pkg/gitops/orchestrate.go index 0309bade6..9cca1b989 100644 --- a/pkg/gitops/orchestrate.go +++ b/pkg/gitops/orchestrate.go @@ -2,15 +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" "github.com/codefly-dev/cli/pkg/internal/selectionguard" @@ -378,13 +375,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 000000000..3bc57a1f9 --- /dev/null +++ b/pkg/gitops/solution_consumption_authority_test.go @@ -0,0 +1,303 @@ +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 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 + 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 + 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] + - 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, 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)) + 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 — 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) { + 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(context.Background(), 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") + 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) { + unscoped := strings.Replace(consumingSolution, + " 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) + + 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 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) { + 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(context.Background(), workspace, module, services, env, nil, nil) + + require.ErrorContains(t, err, "asks for authority from one declaration") + }) + } +} + +// What the authority read DOES read, it reads strictly: an invalid scope fails +// the render. +func TestAnInvalidConsumedScopeFailsTheRender(t *testing.T) { + _, _, 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) { + 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(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 000000000..211ce7760 --- /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 000000000..66d8eec3b --- /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 000000000..eb6c4416e --- /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 ae6b0a889..a9fd986b0 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) } }