Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion docs/solution-host-bindings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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: <group>/<key>}`) 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 `<presence binding>:<entry id>: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
Expand Down
7 changes: 6 additions & 1 deletion docs/supported-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
2 changes: 1 addition & 1 deletion pkg/conformance/matrix.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"schema_version": 1,
"cli": "source",
"core": "v0.19.1",
"core": "v0.19.2",
"rows": [
{
"id": "linux-amd64-source",
Expand Down
128 changes: 121 additions & 7 deletions pkg/gitops/authority.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -133,25 +134,25 @@ 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 {
identities = append(identities, service)
}
}
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))
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
20 changes: 10 additions & 10 deletions pkg/gitops/orchestrate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading