diff --git a/module/SOLUTION_REGISTRATION.md b/module/SOLUTION_REGISTRATION.md index ea973f359..25ef54fd4 100644 --- a/module/SOLUTION_REGISTRATION.md +++ b/module/SOLUTION_REGISTRATION.md @@ -1,15 +1,15 @@ # Solution registration: trust model, authority, and compatibility A **solution** is an independently deployed module this host has no build-time -knowledge of. Delivery declares its presence through a signed -`SolutionHostBinding`; the host reconciles that declaration into durable state. +knowledge of. Delivery declares its presence through one signed module manifest +per binding; the host reconciles that declaration into durable state. The gateway reads that state for `/solutions/{id}/…`, and the frontend reads it for `/s/{id}`. This document states who may declare presence, what the host checks, and how presence differs from a tenant's authority to use a solution. An optional **solution group** organises the catalogue and member navigation. The composing platform supplies its stable `id`, display `title` and signed -32-bit `order` on the solution's signed presence, independently of its frontend. +32-bit `order` on the solution's signed manifest, independently of its frontend. Headless solutions therefore appear in catalogue groups without declaring a page. The host copies this metadata only when that generation applies, and clears it when omitted or withdrawn. A group owns no deployment, installation, consent, @@ -48,27 +48,14 @@ Three mechanisms are routinely mistaken for a sandbox. None of them is one: ## Live route delivery -The applied presence names the endpoints a component owns. Codefly supplies -endpoint values separately through the signed delivery inbox. The internal -applied-state read carries those bytes opaquely; the gateway passes them to -`ResolveDeliveredNetworkInstance(applied, raw)`. Core validates the complete -presence digest and the SDK resolves the selected endpoint. Neither Accounts -nor the gateway interprets configuration carrier keys. Endpoint admission and -subsequent applied-state reads use the host's existing signer-to-domain policy; -a valid signature alone does not authorise the asserted ownership domain. - -The delivery inbox retains the signed bytes; the identity registry gains no -address column. An exact historical lookup keeps a withheld desired generation -from replacing the endpoint value of the applied generation. Missing, withdrawn -or mismatched values cannot fall back to startup carriers. The existing registry -refresh and guarded transport consume the result without a second registry or -a configuration mount. - -This replacement is being integrated across the delivery producer, Core, SDK -and host. It is not yet qualified end to end. Credential delivery remains a -separate production requirement; endpoint values do not replace pod/build -admission. Generation-versioned asset URLs still do not establish immutable -JavaScript or retained lazy-loaded chunks. +The applied manifest names every endpoint a component owns, with its address: +resolved at render in a cell, at delivery in a native run. The internal +applied-state read carries the applied manifest, and the gateway routes an +alias to the one HTTP-family endpoint the manifest declares for its own +workloads, at the address the signed manifest carries. Neither Accounts nor the +gateway reads a startup carrier for it, and the registry gains no address +column; a withheld desired generation never replaces the applied generation's +addresses. ## 2. Declared authority @@ -82,8 +69,8 @@ using the cluster-internal credential. Registry identity and the immutable targe are host facts, never assertions accepted from a browser or remote component. An upstream — a solution's backend or a module's endpoint, reached over plain -HTTP inside the cell's mesh at the address the Codefly SDK resolves for the -endpoint its applied presence declares — receives forwarded viewer credentials. +HTTP inside the cell's mesh at the address its applied manifest declares for +the endpoint — receives forwarded viewer credentials. The transport therefore checks resolved addresses at dial time, refusing public, link-local and metadata addresses. Composition-local addresses and the explicit loopback development case remain supported. A changed DNS answer cannot bypass @@ -308,152 +295,170 @@ installation's authority-root scope node to the intended people or teams. Deployment presence supplies neither consent nor a scope grant. No migration creates those grants implicitly. See §4 for projections and traffic admission. -## 7. Declared presence +## 7. Declared presence: one signed module manifest per binding -A `SolutionHostBinding` records which solution delivery intends to run on this -host. The host records desired and applied generations separately, including the -reason an unaccepted generation remains pending. +A module or composed solution is present on this host because a signed +**module manifest** declared it — never because its process announced itself. +One `codefly/module-manifest/v1` per binding (core's +`solutionhost.ModuleManifest`, documented in core's `docs/module-manifest.md`) +says which instance it is, where it runs, which workloads run as it, where its +endpoints are and what authority it asks for. It replaced the presence, +authority and endpoint-delivery documents, which this host no longer reads in +any version; a document of that family is refused with core's instruction to +re-render it with a current Codefly CLI. -### The document, and who owns which field - -`SolutionHostBinding` is defined by Core (`github.com/codefly-dev/core/solutionhost`, -schema `codefly/solution-host-binding/v1`), rendered per solution instance by -`codefly-dev/cli`, delivered by the GitOps bundle, and reconciled here. It is -desired state and nothing else: it carries no observation, no health and no -credential. The split is the point. - -| Fact | Owner | Where it lives | +| Fact | Manifest field | What the host derives from the applied generation | +| --- | --- | --- | +| That the instance is present, at which generation | `binding`, `generation`, `removed` | `solution_host_bindings.applied_*`, the installable target, the registration | +| The route it answers on | `alias` (one lowercase segment) | the registry key (`/solutions/`, `/v1/`) | +| Solution or module | `kind` | the registration's declared kind | +| The release it deploys | `release` | `applied_release` | +| The host and ownership domain it speaks for | `host.coordinate`, `host.domain` | `applied_domain`; who may change the binding | +| Its presentation | `group`, `frontend` | the catalogue group and frontend block | +| The workloads that run as its principal | `workloads[] {service, container}` | the module registry entry's workloads, by the cell's rule | +| Where its endpoints are | `endpoints[] {name, service, api, address}` | the gateway's route upstream | +| What authority it asks for | `authority` | intersected with the administrator's envelope grant | + +The manifest carries no credential, no observation, no health and **no build +approval**: no image digest is compared when a module mints. "Only approved +images run" is deploy-time admission, outside this host. + +### The delivery endpoint + +`POST /platform/_delivery/manifest` takes one carrier — core's +`MarshalSigned` output, `{"document": , "envelope": }` — +and answers, in this order: + +1. **Parse** the carrier with core's strict `ParseSigned`. +2. **Verify** it with `VerifyDelivered` against the pinned keys of the trust + anchor: the envelope's one signature under a pinned key id, the payload's + canonical encoding of a valid manifest, and the signer's pin for the + manifest's `host.domain`. +3. **Authorise the carrier**: a TokenReview of the bearer token (audience + `accounts`) — or, in a native run, core's native attester under the run's + anchor — must name the `delivery` ServiceAccount of the namespace the cell + runs the manifest's module in — for a withdrawal, the module of the binding's + previously applied manifest. +4. **Admit** it with core's `Admit` against the binding's applied manifest, and + its alias with core's `CheckAlias` against every other binding as it stands + once its newest delivered generation applies. +5. **Hold it to the host's own rules** (below) against every other binding as + it stands once its newest delivered generation applies. +6. **Persist** it in the append-only inbox and ask for a reconcile pass. + +| Answer | Body | Meaning | | --- | --- | --- | -| That a solution should be present at all | the declaration | `solution_host_bindings.applied_*` | -| Which release runs | the declaration | `release`, recorded as `applied_release` | -| Which route alias it answers on | the declaration | `routes[].alias` | -| The workload identity to expect | the declaration | `workload` (the SPIFFE ID is the workload's mesh principal) | -| The page a module or solution serves — title, order, exposed module, host contract, dashboard, client surfaces | the declaration | `frontend`, copied onto the record as `frontend_manifest` when the generation applies | -| Where its backend is reached | the composition, through the Codefly SDK | nowhere: the gateway resolves it from the endpoints the document declares | - -The document carries no address. An address written into a delivery document is -a resolution result that was true on one cluster until something moved, so the -document declares endpoints by NAME and the Codefly CLI writes this gateway a -`CODEFLY__ENDPOINT__…` carrier for each one a composed workload is reached -through, locally and in a render. **Nothing is reported by a running process**: -the mint carries no origin and no manifest (the two fields are reserved on -`ModuleMintWorkContextRequest`), and the registry holds no address of any kind. - -**The gateway resolves where every declared alias is reached, solution and -module alike.** On each refresh it reads the endpoints the APPLIED presence -declares for the service that runs its workload — the presence's own endpoints, -HTTP-family only — and asks the Codefly SDK for each; exactly one answers, and -that is where `/solutions//*` and `/v1//*` go, over plain HTTP. -None, or more than one, leaves the alias unrouted (`503 … not active`). The -install step is delivered to the same address over the same transport. A -module or solution is **active** once its presence is applied, independently of -its optional `frontend` block (`SolutionRegistrationStatus`). Accounts copies -that block in the same apply transaction; the gateway builds the common manifest -from it plus the host's constants — asset path, backend alias and capability -path. Either kind can serve a page, and either can be headless. Each -organisation's installation readiness and consent remain separate checks. - - -**The registry stops authorising routes it cannot confirm.** A replica serves -from its snapshot and refreshes it every 10 s; a snapshot the registry has not -confirmed for 120 s — the revocation lease — authorises no solution or module -route (`503 solution registry snapshot is stale`), so an outage never serves a -withdrawn presence indefinitely. A tombstone already held keeps answering as -the registry would. While a module's declaration is live its alias is also a member of the -host's closed audience vocabulary, so a solution can mint a viewer's Work -Context for it; the tombstone that withdraws the module withdraws the audience. -Addressability is all a presence grants: authority for the module's own calls is -still the authority document's alone. - -### The registry key is the route alias - -A binding ID identifies one **deployment instance** — the renderer's is -`..`, dotted — and a second instance of the same -solution inherits nothing from the first, its alias included. It is therefore not -the registry key: the key is the binding's route alias, because the alias is what -this host routes on (`/solutions//*`, and the key a remote loads under). - -This host needs **exactly one** route alias on a present generation, and it must -be a single lowercase path segment. Core's alias vocabulary is wider — it admits -dots and slashes — so a binding that declares none, several, or a dotted one is -refused with a reason naming the route rather than having one picked for it. The -renderer commits to exactly one single-segment lowercase alias per binding, so -this refusal is a guard, not a workflow. - -A tombstone declares no route, which is why a binding's row keeps the key it last -applied: it is the only way a removal knows what to withdraw. +| 200 | `{"disposition":"accepted","binding","generation","digest","signer"}` | new and durable; a pass is requested | +| 200 | `{"disposition":"replayed",…}` | this exact generation is already durable; nothing written | +| 400 | `{"error":…}` | not a carrier, not a canonical valid manifest, or a replaced document schema | +| 401 | `{"error":…}` | no key this host pins signed it, or the API server refused the token | +| 403 | `{"error":…}` | not the module's `delivery` account, the signer or binding belongs to another ownership domain, or it claims a principal or publisher another domain's binding holds | +| 409 | `{"error":…}` | a stale generation, a rewritten one (applied or only delivered), a withdrawn binding brought back, or a workload or principal another binding holds (named) | +| 422 | `{"error":…}` | another host's manifest, a reserved or held route alias, or an endpoint address that is not its workload's in-cluster Service | +| 503 | `{"error":…}` | the API server could not be asked, or this host's applied record is unusable: retry | + +**The signing input is the canonical JSON** (`SignedPayload`, the manifest's +`CanonicalBytes`), never the YAML a delivery repository holds: core refuses a +payload that is not the canonical encoding of the manifest it decodes to, even +under a genuine signature. **An envelope must be present and be a JSON object** +carrying exactly one signature under a pinned key id; No carrier may nominate +its own key. **What the host stores is derived from the attested bytes**, never +from a value read earlier, so the generation and digest in a row are over the +bytes the signature covered. + +Every refusal names its cause in the body and in the host's log. Verification +comes before carrier authorisation, so a forged carrier is reported as a signing +problem and a captured carrier replayed by an authorised caller as what it is. + +### The namespace rule + +A manifest declares no namespace. A cell runs a module's workloads in the +namespace named after the module and as the ServiceAccount named after the +service (core's `workloadidentity` rule); a cell composing several modules +prefixes the module with its environment's namespace, +`-`. The host reads which spelling applies off its +**own** placement — the namespace it runs in and the module it was composed as +(`CODEFLY__MODULE`) — and refuses to boot, naming both, when that placement +follows neither. The module of a manifest is its `alias`. + +So a carrier for module `reports` on a host running as `saas` in +`platform-acme-staging-saas` is `platform-acme-staging-reports/delivery`, and the +workload `{service: api, container: api}` mints from +`platform-acme-staging-reports/api`. + +### The host's own rules + +Core judges a manifest on its own and against its binding's history. Three +rules need what only this host knows — the other bindings it holds and where its +cell runs things — and are checked at receipt, so a second claim is refused with +the binding that holds it, and again by every pass against the bindings as they +stand: + +- **One workload, one manifest.** A workload is one (namespace, ServiceAccount) + by the namespace rule. Two live manifests on this host never both declare it, + so a reviewed token matches at most one manifest. The second is refused + (409) naming the binding that holds the workload; of two first deliveries + admitted by one pass, the first in delivery order keeps it. A withdrawal holds + nothing. +- **A principal and a publisher stay with their domain.** A signer pinned for + domain D speaks for D's bindings only: a manifest may not ask for an + `authority.principal`, or deploy a `release.publisher`, that a live binding of + another ownership domain holds (403, naming the holder). The first domain to + hold one keeps it; a binding keeps its first domain (`Admit`). Within a domain + a principal is still one live binding: the registry would omit both, so a + second claim is refused (409) naming the holder. +- **An endpoint is its workload's in-cluster Service.** Every + `endpoints[].address` is `http(s)://..svc.cluster.local:`, + where `` is the endpoint's own service and `` the module's + by the rule — no path, query or user info, never a free host. The metadata + server (`169.254.169.254`), another module's namespace and another workload's + Service are refused (422). In a native run an endpoint is the run's own + process on the loopback (`127.0.0.1`, `::1`, `localhost`) and nothing else. ### Prepare, then activate -Every pass reads the durable inbox, then asks Core about the **whole** desired set — -route-alias uniqueness and "declared once per set" are properties of the set, not -of a document. Only then is anything written, and desired state is recorded for -every delivered binding before any generation is applied, so what delivery wants -and the reason it was refused are visible even when nothing passed. - -A refused document leaves the running generation untouched. The refusal is -attributed to its binding rather than failing the pass, because the common -refusal is a stale render: the renderer derives a generation from the previously -delivered document, so a render from a checkout that cannot see the prior tree -emits generation 1 and is correctly refused as stale. If that held back the whole -set, one bad pipeline run would freeze every solution on the host. A refusal Core -raises that this host cannot attribute to a document withholds the **whole** set — -failing closed on a rule it does not recognise, rather than applying a subset Core -never approved. +Every pass reads the newest generation per binding from the durable inbox and +verifies each carrier again under the current pins, so a key unpinned since +stops keeping its manifests in force. Each manifest is admitted with `Admit` +against its binding's applied record; aliases are judged with `CheckAlias` +against the applied manifests, an applied manifest whose binding this pass +admits a withdrawal or a new alias for holding nothing, so an alias moves between +bindings in one pass. Desired state is recorded for every delivered binding +before any generation is applied, so what delivery wants and why it was withheld +are visible even when nothing passed. A refusal is attributed to its binding and +leaves the running generation untouched. Two replicas converge without applying a generation twice: each apply re-decides -under the binding row's lock, and `DecisionCurrent` — this generation is already -applied — writes nothing. A restart resumes from the recorded applied generation -rather than deriving it again. - -An unreadable inbox is an **error**, never an empty desired set, and an empty -one removes nothing. Removal is a tombstone generation precisely so that a store -this host cannot read can never be reconciled as "withdraw every solution". -Nothing deletes from the inbox either, which is what keeps "delivery stopped -talking" and "delivery said remove it" different facts. - -### Reading the three states apart - -`SolutionRegistryService/ListSolutionHostBindings` (`EXPOSURE_INTERNAL`) answers -with desired, applied and observed state separately. That is the question the -registry snapshot alone cannot answer: a binding whose first generation was -refused has no registration record at all, so it is invisible there, and -"declared but never observed" and "never declared" look identical. - -### The ownership domain, and why the host persists it - -A document declares the **ownership domain** it speaks for: the slice of this -host's binding space that delivery may add to, change and remove within. It is -what lets a module-scoped render express a removal without "remove everything -else" being expressible at all. - -Core requires the host to declare the domains it accepts whenever it knows its -own coordinate, and refuses a document from an unstated one — **first generation -included**. That is not belt-and-braces: the applied record cannot bound a -binding's *first* generation, because there is nothing yet to compare against, so -without the declaration any accepted delivery could claim an unseen binding ID -under a domain of its own choosing and own it from then on. - -The host therefore **persists the domain each generation applied under** -(`solution_host_bindings.applied_domain`) and hands it back to Core on every -pass. Core refuses a later generation for a binding that arrives under a -different domain, which is what stops one delivery taking over a binding another -delivery owns. Two consequences worth stating, because neither is obvious: - -- **It is not re-derivable from configuration.** The host's configured list - answers "a domain this host accepts", not "the domain this binding was claimed - under", and those differ exactly when it matters — a host accepting two. -- **An applied record without one is unusable, and unusable means the whole - set.** Core refuses an applied state with no domain, and a pass judges the - desired set as a whole, so a single domainless record does not degrade one - binding: the reconciler applies its first generation and then refuses - everything on every pass afterwards. The applied group's whole-or-absent CHECK - and a non-empty constraint keep that state out of the table. - -The core-owned half of the applied record is built by `solutionhost.AppliedFrom` -rather than assembled here, so the next field Core makes required is a loud -refusal at the apply instead of a silent omission that surfaces as a frozen -reconciler. +under the binding row's lock, and a replay writes nothing. An unreadable inbox is +an **error**, never an empty desired set. Removal is a withdrawal generation +(`removed: true`), and a withdrawal is **terminal**: a later generation of the +binding is refused, so installations and grants never re-attach to a new instance +that reused the id. A replacement instance gets a new binding. + +### The ownership domain + +`host.domain` is the slice of this host's binding space a manifest speaks for. +The signer must be pinned for it (verification), and a binding keeps the domain +its first applied generation declared (`Admit`). The host persists that domain +as `first_domain`, written once and kept when the live applied manifest is not — +migration 41 cleared every live binding's applied state and kept it — so a +manifest for the binding under another domain is refused (403) even while no +applied manifest remains, at receipt and by the pass. `SOLUTION_HOST_OWNERSHIP_DOMAINS` bounds the domains any pin +may grant: a pin naming another refuses the boot by name. + +### Module authority: the manifest asks, the envelope grants + +The module registry every capability decision reads is derived each pass from +the applied, live manifests whose stored carrier still verifies and the +administrator's module envelope. A manifest's `authority.principal` is the +module's principal; its entry is the envelope's grant for that principal +intersected with what the manifest asks for — queues, namespaces, resource kinds, +and per operation binding the `invoke`, `lookup` and `headless` scopes — never +more than either. A binding the envelope does not allow, or allows on another +audience, is omitted by name. Two manifests asking for one principal, or two +principals sharing a workload, are omitted. The envelope document is +`{"revision": N, "principals": {"": {"grant": {…}}}}`; it names no +workload and approves no build. ### Configuration @@ -461,213 +466,30 @@ Every key is in the `federation` group. | Key | Meaning | | --- | --- | -| `SOLUTION_HOST_COORDINATE` | the coordinate this host answers for, exactly as the operator declared it on the environment the renderer read. Never derived here — an invented coordinate matches nothing delivery wrote, so every document would be refused. It is also the one declaration that turns the surface **on**: empty means no reconciler and no delivery endpoint, and no new declared presence can be reconciled. | -| `SOLUTION_HOST_OWNERSHIP_DOMAINS` | the ownership domains this host accepts delivery from. Required with the coordinate and refusing to boot when empty, for the same reason the coordinate is: it is the only thing bounding a binding's first generation. | -| *(no setting in a pod)* | WHERE the pinned-key verification policy is read from is **not configurable in a deployment**: it is a constant, `infra.SolutionHostTrustAnchorPath`. In a pod there is no environment value, no flag and no overridable default; the one relocation, for a host started as a process, is refused in a pod and described below. Workspace environment is delivered by the **composition**, so an overridable path would let a composition point this verifier at a policy it wrote itself, sign its own presence documents, and pass every downstream check — against an anchor it chose. A deployment needing a different path changes the pod spec that mounts it, which the platform renders and the composition cannot supply. That document also carries the signer-to-domain mapping, keyed by `keyId`, so workspace environment cannot widen a key's authority independently of its pin. A signer granted a domain `SOLUTION_HOST_OWNERSHIP_DOMAINS` does not accept is refused **by name**. | -| `SOLUTION_HOST_TRUST_POLICY` | how a delivered carrier's envelope is checked. `keys` is the only value. Required with the coordinate, with **no default** — every default is wrong somewhere. The trust anchor's **absence** is checked first and refuses the boot naming the anchor, so an unmounted anchor is never reported as a missing setting. | -| `SOLUTION_HOST_BINDING_INTERVAL` | optional; how often the inbox is re-read. Empty uses 30s. | - -Declaring the coordinate without the ownership domains refuses to boot: this -host would be unable to refuse a document claiming a binding it has never seen. -So does declaring it with no trust anchor mounted at -`infra.SolutionHostTrustAnchorPath`, and so does declaring it where no -TokenReview can be performed — a delivered carrier is authorised by TokenReview, -so a host that cannot reach its API server has no input at all and starting -would leave it polling an inbox nothing can write to. - -**A process on a developer machine may relocate both, and a pod never can.** -`codefly run` starts the host as a process, where the kubelet's paths do not -exist and writing them needs root. Two settings name a local stand-in, and each -is **refused at boot when the process runs in a pod** (`infra.InCluster()`), so -neither is a way for a composition to move a deployed host's trust: - -- **`SOLUTION_HOST_LOCAL_CLUSTER`** — an absolute directory holding `server` - (the https origin of the local stand-in for the API server), `ca.crt`, - `token` and `namespace`. TokenReview and pod reads go there. It does not make - `InCluster()` true: the process is still not in a pod, and everything keyed - on that stays local. -- **`SOLUTION_HOST_LOCAL_TRUST_ANCHOR`** — an absolute directory holding - `verification_policy.json`. A relocated anchor must be a **local** profile — - one bound to a cluster CA by its `local` block — so it can only ever admit a - developer's key for the one local cluster whose CA it names. - -Neither is a workspace setting and neither is in the table above: both are -read from the process environment by the code that would otherwise read the -kubelet's paths, never through a workspace configuration group. - -In a pod both constants stand exactly as described above. - -**There is no mount setting.** It was `SOLUTION_HOST_BINDINGS_DIR`, a projected -ConfigMap volume, and both it and the directory reader are deleted. A mount is -the CURRENT desired set and nothing more, so a document that arrived, was -recorded as desired, failed to apply and then disappeared from the mount was -never retried — the host had recorded that delivery wanted something and had no -way to want it again — and the mount dropped the carrier, so a restore could not -re-verify what it had accepted. While the setting existed the durable inbox was -dead in every configuration: unset meant no reconciler at all, and set meant the -source was the directory, so the table the delivery endpoint writes was read by -nothing. - -### The authority half, and the one shape in which it is active - -Delivery has two kinds and the host serves both on their own paths: -`POST /platform/_delivery/presence`, `POST /platform/_delivery/authority`, and -`POST /platform/_delivery/endpoints`. Endpoint delivery follows committed -presence: the producer retries the signed endpoint value if reconciliation is -still pending (503). It needs no access to the separate protected observer. Its storage identity is Core's host/binding key plus the applied -presence generation. Different endpoint bytes at that generation are a rewrite, -not a refresh; a route address change requires a new presence generation. -Each is verified with the reader for **its** kind, and the kind in the path is -checked against the schema inside the attested bytes — which is a check rather -than a convention, because the schema is part of the canonical encoding the -signature covers, so a document's type is attested rather than asserted by the -carrier. A document sent to the other kind's path is **400**: the bytes are -wrong, not the signer, and a 403 would send an operator to look at a signing -identity that signed the document perfectly well. - -An authority row is keyed by its **own** authority id, never by the presence -binding it is granted over. One binding may be granted authority under successive -authority ids, and the withdrawal fold is keyed on the binding precisely so that -re-signing under a new id cannot reinstate a withdrawal — which only works if the -inbox keeps the id each document was delivered as. - -**Neither half activates alone.** Authority is active only as a matched -(authority, presence, build) tuple, which is what makes "approved for one exact -build" a property of the system rather than of a field: an authority document for -a build the host is not running activates nothing, and a presence document with -no authority behind it activates nothing. The build is asked **about** rather -than read out of either document — reading it out of the document that approves -it would make the question answer itself. - -The **ceiling** is the module envelope: a durable, append-only, revisioned -record (`module_envelopes`) a platform super_admin writes at runtime through -`PlatformAdminService/PutModuleEnvelope`, audited and never through an -impersonated session. Per module principal it admits whole authority units, -the workload identities the principal may run as, scope ceilings and the grant -ceiling, and lists the approved builds. Every reconcile -pass and every activation reads the CURRENT revision and hands Core's -`ValidateAgainst` its projection for the document being judged, so containment -is Core's exact rule and a narrowing takes effect at the next check with no -restart. It is never read out of a delivered document — "an envelope a document -carried would be a document declaring its own ceiling". - -**Only a pure grant skips the policy-log witness.** A revision is a pure grant -when, against the revision in force, every approved build is still approved, -every principal is still there with a section that is the same JSON value, -every other top-level key is the same value, and no installation loses its -organization. What may differ is the revision, approved builds that were -added, and whole principals that were added. Everything else is written under -`WithPolicyLoggedNarrowing`, so a host restored from an older database cannot -silently give it back. - -A principal's section is compared for equality and never for "at least as -much", because in this schema more JSON does not mean more authority: a -resource list that appears restricts an audience that was unrestricted, a -required kind that appears refuses artifacts that passed, a tenant that is set -removes every other organization, and a unit that gains a field is a different -unit. So a changed section needs the witness, whatever changed. The -classification reads installations on the control plane, and an apply that was -classified a pure grant and then finds an installation to revoke stops and is -taken again under the witness. A host with no witness configured therefore -accepts pure grants and refuses everything else. Absence and damage are -different answers: no revision written is a complete deployment that grants no -module anything, while a revision that cannot be read leaves what the last pass -installed serving, because reading a damaged ceiling as "no ceiling" withdraws -every module on a transient error and reading it permissively widens them. - -The **module principal registry** every capability decision reads is derived -from this ceiling each pass: for every live authority document inside it, each -principal's entry is the envelope's grant bounded by what the document grants, -with the workload read from the applied presence the document names -([services/accounts/AGENTS.md](services/accounts/AGENTS.md#the-composed-module-service-principal)). - -**This host applies authority to nothing**, and that is stated rather than -defaulted. Core requires either the applied authority record or an explicit -marker that none exists, because "nothing applied" is the most permissive input -the call takes; this host holds no such record, so the marker is the truth. The -replay protection the fold would otherwise give comes from the inbox instead — -the desired set is the **newest** generation per document, so a genuinely signed -older generation re-delivered never becomes the answer. The one part the inbox -cannot do by itself is refuse an authority withdrawn under one id and re-signed -under another, so the host reads **every** delivered authority document over the -binding and refuses when a tombstone is among them. Nothing deletes from the -inbox, so a withdrawal stays visible forever. - -Two live authority documents over one binding are refused by name rather than -resolved: whichever one a host picked would be a choice nobody reviewed. - -### What the host admits, and why it is now a signed carrier - -Core `cd443989` removed every path from unattested bytes to a host judgement. -`Host.Admit` takes `*solutionhost.Delivered`, whose fields are unexported and -whose only constructor is `VerifyDelivered(ctx, carrier, verifier)`. So "this -document was attested" is held by the compiler rather than by a naming -convention, and a host that forgot to verify cannot compile rather than -discovering it in production. - -Core `67ee7220` closed the gap that left: a `Delivered` used to hand out the -parsed document it would itself admit, so a caller that adjusted a field changed -what was admitted while the attestation still covered the original bytes. It now -stores the attested payload and re-derives per read. - -The host side of that: - -- **Delivery carries signed carriers**, `{document, envelope}`, not bare - documents. A bare document is refused — it is not a carrier — and that refusal - is pinned by a test. -- **The signing input is the canonical JSON** (`CanonicalBytes()`), not the YAML - `Marshal` writes for a delivery repository. Core refuses a payload that is not - the canonical encoding of the document it decodes to *even when the attestation - over those bytes is genuine*, because a signer and a host that disagree about - which bytes represent the document disagree about what was approved. -- **The verifier returns a signer identity, not a yes.** The host maps that - identity to the domains it may speak for, and Core refuses a document whose - asserted domain its attested signer may not. This closes the hole that a - document states its own ownership domain: without the policy, any signer the - host accepted at all could deliver under any domain it accepted and take over - bindings in it. -- **An envelope must be present and be a JSON object.** Core verifies the DSSE - envelope with exactly one signature, a pinned `keyid`, a `payloadType` matching - the document's schema, and decoded payload bytes identical to `document`. - Missing, empty or `null` envelopes, extra signatures and carrier-supplied trust - material are refused. No carrier may nominate its own key. -- **What the host stores is derived from the attested bytes**, every time, and - never from a document the reconcile pass has been holding. The generation in a - row and the digest beside it are therefore over the bytes the signature - covered, which is the property that makes the digest worth storing at all. A - carrier whose attested payload will not re-read is refused like any other, - rather than admitted from a value read earlier. +| `SOLUTION_HOST_COORDINATE` | the coordinate this host answers for, exactly as the operator declared it. It is the one declaration that turns the surface **on**: empty means no reconciler and no delivery endpoint. | +| `SOLUTION_HOST_OWNERSHIP_DOMAINS` | the ownership domains this host accepts delivery under. Required with the coordinate. | +| *(no setting in a pod)* | the pinned-key policy is read from the constant `infra.SolutionHostTrustAnchorPath`; a composition cannot move it. | +| `SOLUTION_HOST_TRUST_POLICY` | `keys`, the only value; required with the coordinate. | +| `SOLUTION_HOST_BINDING_INTERVAL` | optional; how often the inbox is re-read without a trigger. Empty uses 30s. | + +Declaring the coordinate refuses to boot without the ownership domains, without +a trust anchor, where no TokenReview can be performed, or where the host's own +placement follows no namespace rule. A process on a developer machine may +relocate the API server and the anchor (`SOLUTION_HOST_LOCAL_CLUSTER`, +`SOLUTION_HOST_LOCAL_TRUST_ANCHOR`); both are refused in a pod (§8). #### The one trust policy | Policy | What it checks | Where it is usable | | --- | --- | --- | -| `keys` | a DSSE envelope over canonical document bytes, verified with the platform-owned pinned public key; core also checks the key's ownership domains. | deployed hosts and local clusters use the same verifier. A person key requires the cluster-bound local profile in §8. | - -The former signing mechanism was removed under the handbook decision -`delivery-signed-with-platform-owned-keys`. An obsolete policy value is refused -at boot with the instruction to use `keys`; there is no fallback or default. - -The anchor contains one document, **`verification_policy.json`**, provisioned -independently of both delivery writers. A missing or invalid policy refuses boot: -"this host cannot verify" and "delivery supplied an invalid signature" are -separate facts an operator must be able to distinguish. The host never reads -additional trust material beside this document. +| `keys` | a DSSE envelope over canonical manifest bytes, verified with a pinned public key whose pin also names the ownership domains it may deliver under | deployed hosts and local clusters use the same verifier. A person key requires the cluster-bound local profile in §8. | -Each signer declares `name`, required `kind` (`release` or `person`), `keyId`, -`publicKey` (PKIX PUBLIC KEY PEM, Ed25519 or ECDSA P-256), and `domains`. Names and -key IDs are unique. Unknown fields are refused. Core validates the keys, kinds, -IDs and domain lists; the host additionally requires a local profile for a person. -The attested identity is **the key ID**, never the host's descriptive label. - -A refusal recorded against a binding is deliberately **coarse** — not a carrier, -an envelope that did not verify, and a verified payload Core will not parse all read -the same in the row. Which of the three it is tells an attacker which half of the -door it got past; the full reason goes to the log. - -The host reads delivery from its durable inbox, preserving the canonical payload -and DSSE envelope for verification on every reconciliation. The existing `bundle` -storage column holds the envelope; it does not change the carrier's wire shape. +The anchor holds one document, **`verification_policy.json`**. Each signer +declares `name`, `kind` (`release` or `person`), `keyId`, `publicKey` (PKIX PEM, +Ed25519 or ECDSA P-256) and `domains`. Unknown fields are refused. The attested +identity is **the key ID**, never the descriptive label. Verification +is offline: the host holds public keys only. Rotation means pin the new key id, +deliver with it, then remove the old pin. ## 7b. The host's own control paths, and which part a consumer may hardcode @@ -753,13 +575,12 @@ document, and every registrant hardcodes those paths today. | Path | Status | What it is | | --- | --- | --- | | `POST /platform/_credential` | **Settled, NOT YET LIVE** | the module/solution credential mint. On the **gateway**, which forwards `Authorization` verbatim; accounts performs the `TokenReview` on the pod's own token. | -| `POST /platform/_delivery/presence` | **LIVE** | a signed presence document, `{schema, document, bundle}` | -| `POST /platform/_delivery/authority` | **LIVE** | a signed authority document, same carrier | -| `POST /platform/_delivery/endpoints` | Integration in progress | a signed Codefly endpoint value for the exact applied presence; no configuration mount | -| `GET /platform/_delivery/applied/{binding}` | Implemented; deployment qualification pending | committed APPLIED presence, readable only by the protected endpoint publisher | +| `POST /platform/_delivery/manifest` | Implemented; deployment qualification pending | one signed module manifest, `{document, envelope}` (§7) | +| `GET /platform/_delivery/applied/{binding}` | Implemented; deployment qualification pending | the binding's committed APPLIED manifest, readable only by the protected applied-state observer | -**The two delivery paths are now served by accounts**, which performs the -`TokenReview` on the carrier's own token. The credential mint remains **NOT YET +**The delivery path is served by accounts**, which performs the `TokenReview` +on the carrier's own token. The presence, authority and endpoints paths are +gone with the documents they carried, and answer 404. The credential mint remains **NOT YET LIVE** and is brokered by the gateway when it lands; a consumer calling it today gets a 404 on every deployment, not just one, which is the one mercy in the situation. Keep an override until this table says live for it, and do not treat @@ -768,12 +589,13 @@ a 404 as "my path is wrong". The applied-state read uses the same Accounts delivery listener and reviews an `accounts`-audience projected token. Only ServiceAccount `endpoint-publisher` in namespace `istio-presenter` may read it. The ordinary delivery writer cannot use -that read; the publisher cannot write presence or authority. Mesh policy must +that read; the publisher cannot deliver a manifest. Mesh policy must independently admit only this GET from the protected publisher. -A `200` read returns `{host: {coordinate, component}, binding, generation, -presenceDigest, release, removed, appliedAt}`. The digest covers the complete -canonical APPLIED presence; the time is RFC3339. Identity comes from the saved +A `200` read returns `{host: {coordinate, domain}, binding, generation, digest, +release, removed, appliedAt, pending?}`. The digest is the APPLIED manifest's +(`ModuleManifest.Digest`); the time is RFC3339; `pending`, when present, is a +newer generation this host received and withheld, `{generation, reason}`. Identity comes from the saved applied document, even when a newer desired generation is withheld. No applied generation is `404`, including a desired-only binding; a committed tombstone is `200` with `removed: true`. Missing/refused credentials are `401`, a different @@ -843,92 +665,36 @@ generic solution routing and answer `502`. Published here because three repositories implement against it — the renderer that signs and posts, the infrastructure that provisions the identities, and this -host that verifies — and because every item below was something at least one of -them had wrong at some point. +host that verifies. -**The carrier is three keys**, exactly the renderer's `MarshalSigned` output: -`{"schema":"codefly/solution-host-signed/v1","document":,"bundle":}`. -Parse it with core's `ParseSigned`, which requires `schema` — not by unmarshalling -two of the three, which is how a hand-built carrier claiming any schema used to -reach the bundle verifier before core's `Signed.validate()` closed it. +**The carrier is two keys**, exactly core's `MarshalSigned` output: +`{"document": , "envelope": }`, parsed with core's +strict `ParseSigned`. -**Carrier authorisation is the SA and the namespace, and the two halves differ:** +**The carrier is the `delivery` ServiceAccount of the module's namespace** — the +namespace the cell runs the manifest's module in, by the rule in §7; for a +withdrawal, the module of the binding's previously applied manifest. One delivery +Job runs per module tree in that namespace. The namespace is derived from the +signed manifest's alias and this host's own placement, never from configuration a +deployer can edit. -| half | ServiceAccount | namespace | -| --- | --- | --- | -| authority | `delivery` | `platform-authority` — a fixed pair | -| presence | `delivery` | **the namespace the document's own workloads declare** | - -The presence namespace is deliberately not a constant. A presence Job runs one -per module tree in that module's own namespace, so a fixed value would refuse -every genuine carrier. It is read out of each workload's `identity.spiffe_id` -(`spiffe:///ns//sa/`), which makes it a property -of the **signed document** rather than of host configuration a deployer can edit -— the same reason the ownership domain lives inside the canonical bytes. A -presence document whose workloads declare two namespaces is refused by name: the -renderer uses one namespace per render and cannot emit such a document, so one -that exists was hand-built. - -A presence generation that declares **no** workload declares no namespace, and -there are two of those: a tombstone, which declares absence and carries nothing, -and a present generation that renders only a frontend surface. Such a carrier is -authorised against the namespace this host **last applied** for that binding — -its own recorded state, from a generation it already verified and admitted, -rather than anything the arriving document asserts. Refusing the empty case -outright made **removal through this endpoint impossible**: removal is a -generation, so the one document delivery must be able to hand over to withdraw a -binding was the one document the check could never authorise. It is still refused -when neither answers — a first generation for a binding nothing has applied, -declaring no workload — because then there is nothing to authorise it against at -all. - -Authority is the **only** half with a hardcoded pair, which is why it is written -down rather than derived: get it wrong and the `TokenReview` *succeeds*, the -identity is *genuine*, and the host refuses the real carrier for a name — which -reads as an attack rather than a typo. - -**The projected token's audience is `accounts`, exactly that literal.** It is -published here because the renderer renders whatever this document publishes, and -an audience the two sides guess at separately is a `TokenReview` that succeeds for -a token minted for something else. It is the same audience the credential mint -reviews against, deliberately: accounts is the service performing the review in -both cases, and a second audience would be another projected token to mis-mount. - -It is a constant in this host rather than a setting, and that is the point. A -configurable audience is another composition-supplied input to a trust decision — -whoever could set it could have a token minted for a service of their choosing -reviewed here as if it had been minted for this one. - -**The perimeter is not this check.** A manifest claiming a namespace other than -the one its delivery tree declares is refused by the delivery controller's own -destination list, at apply. The host's SA-and-namespace check is the second -layer. Both are worth having; neither is described as the only one. - -**Verification is OFFLINE**, using core's standard-library cryptography and -only the host's pinned public keys. The verifier has no HTTP client and calls no -third-party signing service. That matters in default-deny namespaces: an external -lookup would **hang rather than fail fast**, although the signature is entirely -checkable from the local pins and envelope. - -The release signer uses the platform's Vault Transit through the CLI; a developer -uses an Ed25519 key held locally by the CLI. The host holds public keys only and -never reaches either signer during verification. Rotation means pin the new key -ID, deliver with it, then remove the old pin. Reconciliation verifies stored -carriers under the current pins, so removing a pin also affects older deliveries. - -**The authenticating container is derived, not declared.** The renderer names the -container matching the service, or the sole container, and refuses at render a -multi-container workload where neither holds — rather than guessing. -`non_authenticating` is always a non-nil list of every other container, init -containers included, so "there are none" and "nobody said" stay distinguishable. - -What neither the renderer nor this host can check is that the projected -`accounts`-audience token is mounted by **exactly** the authenticating container: -a pod-bound token is the pod's. That is closed by the deployment's -execution-admission policy, not by a document field, and until it is in place this -host's container designation is a **record** rather than attestation — stated that -way deliberately, because "the host knows which container may authenticate" would -be a claim the mechanism does not yet support. +**The projected token's audience is `accounts`, exactly that literal**, the same +audience the module mint reviews against. It is a constant, not a setting: a +configurable audience would be another composition-supplied input to a trust +decision. + +**The perimeter is not this check.** The delivery controller's destination list +refuses a manifest's objects outside the namespaces it may write, at apply. The +host's SA-and-namespace check is the second layer. + +**Verification is offline**, using core's standard-library cryptography and the +host's pinned public keys only. That matters in default-deny namespaces: an +external lookup would **hang rather than fail fast**. + +**The authenticating container is the manifest's.** Each workload names the one +container that authenticates; a pod holding any other container that could read +the projected token — a sidecar, a running or restartable init container, an +ephemeral container — mints nothing. ### Why the mint is brokered by the gateway rather than called on accounts @@ -954,18 +720,17 @@ the native services to loopback. Native Accounts uses its explicit non-pod stand-in profile; this provides no mesh presenter and cannot be selected inside a real pod. No application serves or presents a local SVID. -Binding a credential to an approved execution means reading what the workload -actually runs: `TokenReview` on its projected service-account token, then the pod -by the name that review returns, then the application container's `imageID`. That -is Kubernetes. `codefly run` on a laptop has none of it, and the adversarial -review is right that the delivery API does not preserve the old laptop behaviour — -a host that reads pods is not a Kubernetes-free host any more. +Binding a credential to an execution means asking the environment's mechanism +which workload presented it: in a cell, `TokenReview` on its projected +service-account token, then the pod by the name that review returns (no image is +read: the mint is bound by rule, not by build); in a native run, core's native +attester under the run's anchor (below). The `local` trust policy is deleted. A coordinate is an operator-declared string and cannot justify skipping a signature check. Local runs use Kubernetes and the same pinned-key verification as deployed hosts: one verifier, one policy shape, with a release key in a deployed policy and a person's key admitted only -by a cluster-bound local policy. +by a local policy bound to one cluster or one native run. ### The local profile's trust anchor @@ -1101,74 +866,40 @@ creates TokenReviews. ### What a local run must therefore supply The host's requirements do not change for local; what changes is who provides -them. A local run needs: a presence document whose `host.coordinate` is the -coordinate the local host is configured with; an `execution.image.manifest_digest` -read from the **locally built** image rather than a registry, which moves on every -build and is fine because the generation moves with it; workloads running as Pods -with projected service-account tokens for audience `accounts`; and the documents -signed with the developer's pinned key and POSTed to the same -`/platform/_delivery/*` endpoints. The local host is configured with its local -coordinate and the local profile above: a policy bound to its cluster that lists that developer as a person. - -Nothing on the host is conditional on any of it but the policy document on its -trust anchor, which the composition does not write. That is the point. - -### The three fields a local run looks unable to fill, and why it can - -The renderer's objection to this decision was precise and worth recording with its -answer, because it is the objection anyone will raise again: a present generation -must declare at least one rendered artifact with a SHA-256 digest, and v2 adds a -required OCI manifest digest and a SPIFFE ID — so a laptop appears to have to -**invent digests**, which it must not. - -It does not, once the local run uses pods: - -- **`artifacts[].digest`** — to run pods the renderer must produce Kubernetes - manifests, and those are real bytes with a real digest. That is a rendered - artifact in the honest sense; it lives in a local tree rather than a delivery - repository, which the document never contradicts because there is deliberately - no artifact URI. -- **`execution.image.manifest_digest`** — the locally built image's manifest - digest. Real, and it moves on every rebuild, which is correct rather than - awkward: the generation moves with it, so each rebuild is an ordinary apply. -- **`execution.identity.spiffe_id`** — the one that looks impossible and is not. - Core validates this field for **well-formedness only**: the parsed scheme must - be `spiffe` and nothing more. It names the workload's **mesh principal** — the - SPIFFE ID the cell's mesh issues to its ServiceAccount — never an SVID the - workload serves or presents, so a local run populates it with the - service-account-derived identity, which *is* the identity the pod runs as, - and nothing on the host verifies a certificate against it. No local SPIRE, no - invention, and no schema exception. - -So presence v2 **can** describe a local run honestly. What it cannot describe -honestly is a run of bare *processes* — which is an argument for running pods -locally, not an argument for a second trust model. - -### A run of processes: the CLI answers as the cluster - -The argument above ends at "run pods locally". A run of processes is still the -first loop a developer has, so `codefly run` supplies what the host reads from -Kubernetes instead of the host learning a second path. The host's code is the -same in both: TokenReview on the projected token, the pod by the name the review -returns, the build that pod runs, pinned-key verification of every delivered -carrier, the envelope as an independent ceiling. - -What the CLI stands in for, per run: - -- **the API server** — TokenReview and pod reads over TLS under a CA created for - that run, with projected tokens issued for the same audiences and rotated as - the kubelet rotates them. The host reaches it through - `SOLUTION_HOST_LOCAL_CLUSTER` (§7, Configuration). -- **the trust anchor** — a local profile whose `local` block pins that run's CA, - read through `SOLUTION_HOST_LOCAL_TRUST_ANCHOR`. - -What is weaker than a cluster, stated rather than implied: the build a pod is -said to run is the SHA-256 of the executable the CLI started, and the CLI is -both the party that says so and the party that signs the presence naming it. No -kubelet is a second witness. That is the authority a developer already has over -their own machine, and it is bounded to it — the profile admits a person's key -only for the one CA it names, and both relocations are refused in a pod, so no -deployed host can be pointed at a stand-in. +them. A local run delivers the same module manifest — signed with the developer's +pinned key, its `host.coordinate` the local host's — to the same +`POST /platform/_delivery/manifest`, and its endpoints carry the addresses the +run allocated. The local host is configured with its local coordinate and the +local profile above. + +**A native run is attested by its launcher.** The Codefly CLI that renders +module manifests (codefly-dev/cli#1014) starts a native run as the launcher of +its processes and signs each one a credential of core's +`workloadidentity/native` mechanism, naming the declared workload it started, +instead of standing in for an API server. The host verifies those credentials +against the run's anchor, the way a cell's host reviews a projected token, and +the anchor is bound the way a cluster is: the CLI hands the host the anchor in +`CODEFLY_NATIVE_IDENTITY_ANCHOR` (core refuses it inside a pod) and pins its key +fingerprint in the local profile, + +```json +{ "local": { "nativeAnchorSha256": "" }, "signers": [ … ] } +``` + +and the host refuses to boot unless the anchor it was handed is the one the +profile pins. A profile binds to a cluster or to a native run, never both; a +deployed policy, or one bound to a cluster, refuses a native anchor. Under a +native run: + +- the carrier is the `delivery` workload of the manifest's module, and the + applied observer is core's `AppliedObserver`; +- a minting process is the workload its credential names: its module is the + namespace, its service the account, its instance the pod, and the containers + are what the launcher vouched for about that instance; +- a module's namespace is its name (the bare rule), and an endpoint's address is + the run's loopback. + +A local run on a cluster (pods with projected tokens) works as a cell does. ### Two rules that do not bend locally @@ -1178,7 +909,7 @@ running. It is not. Removal is a tombstone generation, delivered, locally includ A reconciler that inverted this under a local flag would hold two opposite answers to "the desired set is empty" selected by configuration, and one misdetection would withdraw every solution on a deployed host silently, with the audit trail recording -that it was asked to. `TestSolutionHostBinding_AnEmptyMountRemovesNothing` and its +that it was asked to. A local run withdraws with a withdrawal manifest. `TestSolutionHostBinding_AnEmptyMountRemovesNothing` and its unreadable-mount sibling exist for that reason. **The envelope stays an independent upper bound.** Locally there is no reviewed @@ -1271,12 +1002,11 @@ artifact acquisition or production mountless credential delivery. ### Admission with no consumed permissions -A signed authority document can explicitly declare `bindings: []`. The operator -envelope must then admit the exact principal and presence binding using -`admitted_bindings`. Omitted or null binding lists are refused. This admission -uses the existing principal registry and installation path; it grants no -consumed operation. Applied presence, workload identity, container, approved -build, envelope revision and live organisation consent still have to agree. +A manifest's `authority` can ask for no operation binding at all. The envelope +must still grant its principal; the registry entry then holds no consumed +operation, and uses the existing installation path. The applied manifest, its +workloads, the envelope revision and live organisation consent still have to +agree. Installation-use consent and team grants remain required even for a no-op installation. An empty permission list does not make a page public. diff --git a/module/WORK_CONTEXTS.md b/module/WORK_CONTEXTS.md index ff3620faf..dcbe38a35 100644 --- a/module/WORK_CONTEXTS.md +++ b/module/WORK_CONTEXTS.md @@ -152,23 +152,13 @@ admins already hold every permission. See `AUTHZ.md` "Built-in role catalog import". A platform super_admin then allows the binding for the **calling** principal in -its `grant` in the module envelope, beside the whole authority units the host -admits for it and the workload it may run as: +its `grant` in the module envelope: ```json { "revision": 4, - "approved_builds": ["sha256:"], "principals": { "example-consumer": { - "bindings": [ - {"id": ":run:invoke", "revision": 1, "scope": "example-producer.profiles:invoke,example-producer.profiles:read"}, - {"id": ":run:lookup", "revision": 1, "scope": "example-producer.profiles:read"} - ], - "workload_identities": [ - {"spiffe_id": "spiffe:///ns//sa/", "container": ""} - ], - "scope_ceilings": [], "grant": { "tenant": "019f6bf7-5b4b-74e5-8c17-092259bb1661", "operation_audiences": { @@ -198,11 +188,11 @@ admits for it and the workload it may run as: The producer's own principal lists `example-producer.profiles` under its grant's `resources`. The consumer's registry entry is this grant bounded by the -authority document delivery signed for it: the `run` audience exists only while -the document grants the `invoke` unit, each unit the document grants must be the -admitted one field for field (a wider scope puts the whole document outside the -envelope), and each operation keeps only the actions its unit grants — -`resource_ids` stay the envelope's, because a unit cannot name one. +`authority` its applied module manifest asks for: the `run` audience exists only +while the manifest asks for the `run` binding on audience `example-producer` with +an `invoke` operation, and each operation keeps only the actions the manifest +asks for (`example-producer.profiles:read`, …) — `resource_ids` stay the +envelope's, because a manifest cannot name one. `example-consumer` redeems it with `ExchangeDelegatedOperationAudience`, supplying its retained parent, the binding id `run`, and whether this is an @@ -235,10 +225,8 @@ rejected with the reason rather than one call failing later. Name the resource by its **stable identity**, not by a digest of its current definition. The module principal registry is derived, each reconcile pass, from -the delivered authority documents inside the module envelope, so re-issuing this -entry is an envelope revision — and, because a delivered authority document is -inside only the envelope revision it was rendered against, a redelivery of every -module's authority at that revision. A grant that has to be re-issued whenever +the applied module manifests and the module envelope, so re-issuing this entry is +an envelope revision. A grant that has to be re-issued whenever the producer's definition moves leaves the consumer without access from the moment that definition moves until the new revision and its deliveries are applied, and each accounts replica derives its own registry on its own pass, so @@ -275,9 +263,9 @@ answered by one set of functions for minting and verifying alike - **Epoch.** The owner's authorization revision in the organization. Membership and role changes advance a person's; installing a module and every change to its installation's revision advance a module principal's (migration 35). -- **Execution.** A person and a registered agent bear none. A module principal - always bears one: its mint attests the digest `BindExecution` read off the - requesting pod, and core holds it against the approved build. +- **No build.** A Work Context seals no build (core #812): a module's mint is + bound by `BindExecution` to a workload its applied manifest lists, and what a + workload runs is deploy-time admission. - **Revision.** The greater of the organization's and the owner's revision for a person; a module principal's is `ModuleOperationContextRevision` of its installed grant. Core keys its revision source by tenant, so the host binds it @@ -332,7 +320,6 @@ serves it on the endpoint a module already depends on: | --- | --- | --- | | seal | the live installation and its revision, for the capability's owner | `UNHELD` — not active in the organization, or the principal is not a member | | principal epoch | one principal's live epoch, for the owner and every actor hop alike | `UNKNOWN` — no authorization record here | -| approved build | the execution the issuer approves for one principal | `BEARS_NO_EXECUTION` — a person's session, which is correct | | operation binding | the binding the capability is sealed to, exactly | `ABSENT` — no such delegation, or one this organization does not entitle | | authorization revision | the revision a capability owned by that principal carries | — | | approval grant | the approval the capability's grant hop claims | `NOT_RECORDED` — this host records none | @@ -356,8 +343,8 @@ That shape is the correction an independent review forced, and the reasoning is worth keeping. The first revision took the tenant and the audience as request fields and compared the audience to the caller's prefix. No **answer** depended on either field, so the comparison pinned nothing: a module could label the call -with its own prefix and still read any principal's epoch, approved build or -authorization revision in its tenant, which made the strongest check in the model +with its own prefix and still read any principal's epoch or authorization +revision in its tenant, which made the strongest check in the model a label. A presented, authenticated capability has nothing left to label. Consequences worth stating: @@ -369,7 +356,7 @@ Consequences worth stating: grant to ask about. The arm exists so that the day it records approvals, the answer has a shape and a client that does not understand it refuses. - A module addressed by a name that is neither its prefix nor its alias — a - binding's declared `audience` is a name the authority document chooses — cannot + binding's declared `audience` is a name the module manifest chooses — cannot verify at full strength. That is deliberate: accepting a third name would mean believing a caller's claim that an audience is its own. @@ -385,8 +372,7 @@ seal's whole value is that a callee's verdict and an issuance cannot disagree. - `FAILED_PRECONDITION` — the issuer **decided**, and the decision is no: the owner is no longer a member, the principal has no authorization record, a - module's installation is inactive, the delegation behind a binding is stale, - the approved build cannot be named. The capability is revoked; its holder mints + module's installation is inactive, the delegation behind a binding is stale. The capability is revoked; its holder mints again and does not retry. - `UNAVAILABLE` — nothing was decided. A lookup failed; a retry may succeed. - The three absence states above are neither: a verifier turns each into a @@ -469,19 +455,18 @@ relaxation of the delegated exchange. The module authenticates exactly as for its own Work Context — its pod's projected ServiceAccount token (audience `accounts`), which `BindExecution` holds -against the workload its principal declares and the build its authority document -approves (#1056) — and names one of its installed operation bindings. What the +against the workloads its principal's applied manifest lists (#1056) — and names one of its installed operation bindings. What the binding requires (one principal per ServiceAccount, every container of the pod approved, a running regular container in a running pod, a token of at most 600 seconds) and the replay it does NOT close are stated in -[services/accounts/AGENTS.md](services/accounts/AGENTS.md#establishing-what-a-caller-is-running). Nothing else is requestable. +[services/accounts/AGENTS.md](services/accounts/AGENTS.md#establishing-which-workload-a-caller-is). Nothing else is requestable. The host mints a child Work Context with: - `aud` = the binding's `audience`; - authority scopes = **exactly** the binding's `headless_scopes`; - owner = the module's own service principal (kind `service`), acting directly: no actor hop, as core models an owner acting, with the seal naming its - installation and the build its pod runs; + installation; - tenant = the tenant the module envelope's grant declares for that principal; - a 60-second lifetime and the idempotent replay policy; - an authorization revision that is a digest of the module's whole derived @@ -653,7 +638,7 @@ tenant) exactly; a read exchange always does. | Missing | `FAILED_PRECONDITION` | `DELEGATION_MISSING` | `412`, body `DELEGATION_MISSING` | The source has no active delegation to this module. A person must connect or reconnect it. | | Revoked | `PERMISSION_DENIED` | `DELEGATION_REVOKED` | `403`, body `DELEGATION_REVOKED` | The delegation was revoked, or was just found unsupported and revoked. | | Invalid | `PERMISSION_DENIED` | `DELEGATION_INVALID` | `403`, body `DELEGATION_INVALID` | No such delegation for this module — another module's is deliberately indistinguishable from one that does not exist. | -| Unproven | `UNAUTHENTICATED` | — | `401` | The module's projected token was refused, belongs to another workload, or its pod runs an unapproved build. | +| Unproven | `UNAUTHENTICATED` | — | `401` | The module's projected token was refused, or belongs to a workload no applied manifest of the principal lists. | | Unbound | `UNAVAILABLE` | — | `503` | The host could not establish what the module's pod is running; nothing was decided. | The gateway's refusal body is the plain-text reason, read from the diff --git a/module/services/accounts/AGENTS.md b/module/services/accounts/AGENTS.md index 8af85802e..c96e32e30 100644 --- a/module/services/accounts/AGENTS.md +++ b/module/services/accounts/AGENTS.md @@ -27,77 +27,81 @@ is read by the capability checks and does not create synthetic directory rows. ## Declared solution presence and the registry -`solution_host_bindings` stores desired and applied `SolutionHostBinding` -generations and pending reasons. Core owns document admission; each apply -re-decides under the binding row lock. A refused or unreadable delivery does not -withdraw the last applied generation. Withdrawal requires a tombstone generation. -A replay after removal authenticates against the last workload-bearing applied -presence, recovered from immutable generation decisions joined to exact signed -inbox bytes. Desired-only or refused history cannot supply a carrier namespace; -this lookup does not use the catalogue's diagnostic history limit. - -`GET /platform/_delivery/applied/{binding}` reads only that committed generation, -including tombstones, after an accounts-audience TokenReview of the protected -endpoint publisher. A desired delivery receipt is not proof of apply. The -observer's identity has no delivery write permission; other delivery identities -have no observer permission. The wire and response taxonomy are documented in -[../../SOLUTION_REGISTRATION.md](../../SOLUTION_REGISTRATION.md). - -`solution_registrations` is the durable read projection, keyed by the route alias. -Every row names its declared binding, generation, release and immutable target. -A binding ID identifies a deployment instance, while an alias identifies a route; -consent names the target and cannot follow an alias to a replacement instance. -Only observations for the same declared target may survive a re-declaration. - -New frontend candidates require complete signed payload preparation before apply. -A failed candidate preserves the applied predecessor. Migration 40 indexes new -applied frontend identities; the targeted internal retained-frontend read joins -exact signed history to a still-live target and keeps last ten or the configured -lazy-load window. Pre-migration history is not backfilled. Full history is never -broadcast in the route snapshot. See the prepared-payload section in +A module or solution is present because one signed **module manifest** +(`codefly/module-manifest/v1`, core's `solutionhost.ModuleManifest`) declares it, +per binding. `POST /platform/_delivery/manifest` verifies the carrier against the +pinned keys, reviews the carrier's token — the `delivery` ServiceAccount of the +module's namespace by the cell's rule (`business.ModuleNamespaceRule`, read off +this host's own namespace and `CODEFLY__MODULE`), for a withdrawal the previously +applied manifest's module — admits it with core's `Admit` and `CheckAlias`, +holds it to the host's own rules (`checkManifestAgainstHost`: one workload runs +as one manifest; a principal and a release publisher stay with the ownership +domain that holds them; an endpoint address is its workload's in-cluster Service +by the rule, or the loopback natively), and persists it in the append-only inbox +(migration 41: one kind, `manifest`). It answers 200 `accepted`/`replayed`, 400, +401, 403 (also a principal or publisher another domain holds), 409 (stale, +rewritten, withdrawn, or a workload another binding holds, named), 422 (another +host, a reserved or held alias, an endpoint off its Service) or 503, with the +reason in the body and the log. + +Under a native `codefly run` the CLI hands the host the run's anchor +(`CODEFLY_NATIVE_IDENTITY_ANCHOR`) and pins its fingerprint in the local +profile (`local.nativeAnchorSha256`); the host refuses to boot unless they +match, and then `infra.NativeWorkloadIdentity` — core's native attester — +answers the carrier, applied-observer and mint reviews TokenReview answers in a +cell, under the bare namespace rule. The full contract is in +[../../SOLUTION_REGISTRATION.md](../../SOLUTION_REGISTRATION.md#7-declared-presence-one-signed-module-manifest-per-binding). + +`solution_host_bindings` stores desired and applied manifests and pending +reasons. Each pass re-verifies the newest delivered manifest per binding, admits +it against the applied one under the binding row lock, and applies it into the +same targets, registrations and generation history as before. A refused or +unreadable delivery does not withdraw the last applied generation; withdrawal is +a `removed: true` generation and is terminal. A withdrawal recorded before +migration 41 cannot be read as a manifest and still refuses every later +generation (core's `ErrWithdrawn`). + +`GET /platform/_delivery/applied/{binding}` reads only the committed manifest, +including withdrawals, after an accounts-audience TokenReview of the protected +observer: `{host, binding, generation, digest, release, removed, appliedAt, +pending?}`. A desired delivery receipt is not proof of apply. + +`solution_registrations` is the durable read projection, keyed by the route +alias. Every row names its declared binding, generation, release and immutable +target. A binding ID identifies a deployment instance, while an alias identifies +a route; consent names the target and cannot follow an alias to a replacement +instance. + +New frontend candidates require complete signed payload preparation before +apply. A failed candidate preserves the applied predecessor. The targeted +retained-frontend read joins exact signed inbox history to a still-live target. +See the prepared-payload section in [../../SOLUTION_REGISTRATION.md](../../SOLUTION_REGISTRATION.md). The internal `SolutionRegistryService` exposes the registry and host-binding -reads. The gateway reads the snapshot and brokers it to the frontend. Both cache -briefly: gateway reconciliation is approximately 10 seconds with a refresh on -cache miss; frontend snapshots expire after 5 seconds and a failed refresh -reports unavailable. An undeclared record stays pending and is not served. -Applied modules and solutions are active independently of their optional -frontend block; headless backends remain usable. Registry availability does not -replace the separate organisation installation status and consent checks. - -The internal APPLIED read also carries opaque endpoint delivery bytes for the -Codefly SDK. They come from the exact signed inbox generation, not a registry -address column or the newest desired document. Accounts retains delivery -provenance and applied-state authority; Codefly owns carrier interpretation. -Migration 39 permits endpoint documents in the existing append-only inbox. -Producer and consumer integration is in progress; this does not qualify the -replacement transport or relax pod/build admission. - -An optional top-level `group` on an applied solution declaration is copied to -the registration as presentation only, including for headless solutions. -Migration 38 adds that nullable projection; modules and tombstones cannot carry -it. Moving or clearing a group preserves the installation target and grants. -The catalogue exposes it without backend topology; authority never reads it. - -Migration 26 destructively removes runtime-only state and makes declaration -ownership mandatory. Its from-zero proof is `TestMigration25FromZero` in the -store module; `TestMigration22FromZero` separately proves immutable installation -targets and withdrawal serialization using a freshly created package database. +reads. The gateway reads the snapshot, parses each applied manifest and routes an +alias to the HTTP endpoint the manifest declares for its own workloads, at the +address the manifest carries. The `endpoint_delivery` and `host_component` proto +fields are no longer populated; their removal is a contract-package change of its +own. Applied modules and solutions are active independently of their optional +frontend block; registry availability does not replace installation status and +consent checks. + +An optional `group` on an applied solution manifest is copied to the +registration as presentation only (migration 38). Moving or clearing a group +preserves the installation target and grants; authority never reads it. Audit type admission remains available through -`ModuleCapabilitiesService.DeclareAuditEventTypes`. Its validator and namespace -admission use declared module authority. Namespace ownership, additive schema -changes, immutable visibility and the operator's `external_namespaces` grant -remain enforced. `ModuleEmitAuditEvent` can emit only a type owned by the caller's -solution scope and an authorized namespace. `business.AuditEventResolver` resolves -stored schemas for payload validation and PII redaction. +`ModuleCapabilitiesService.DeclareAuditEventTypes`, under the derived module +authority below. -The principal directory lists stored identities. The module principal registry -below supplies declared authority to capability checks only. - -See [../../SOLUTION_REGISTRATION.md](../../SOLUTION_REGISTRATION.md#7-declared-presence) -for the trust model, field ownership and delivery configuration. +The module registry every capability check reads is **derived** each pass +(`business.ModulePrincipalReconciler`) from the applied, live manifests whose +stored carrier still verifies and the module envelope a platform super_admin +writes: the manifest's `authority` is the request, the envelope's per-principal +`grant` the ceiling, the entry their intersection, and its workloads the +manifest's `(service, container)` spelled by the namespace rule. The envelope +names no workload and approves no build. ## The solution's runtime boundary is derived from its delivered binding @@ -207,13 +211,11 @@ record of intent this host holds today. **Absent facts are gaps, never values.** Every fact is a `oneof` of its value and a `CatalogueGap{reason, detail}`. Today no record on this host carries a -declaration or an applied generation (they arrive with applied presence -documents), an approval or a withdrawal (the deployment contract's signed +declaration or an applied generation (they arrive with applied module +manifests), an approval or a withdrawal (the deployment contract's signed approval, not built yet), an observation or a retirement (nothing observes the cluster or reports stop/fence evidence), an installation revision, or a build -size (the presence document's `build_size` section, codefly-dev/core#708, -computed by the CLI at build, codefly-dev/cli#901): the host counts no lines -itself. So each reads `NOT_RECORDED` or `NOT_OBSERVED`. +size (a module manifest carries none, and the host counts no lines itself). So each reads `NOT_RECORDED` or `NOT_OBSERVED`. **Authorization has three cases, not two**: an approval, `not_authorized` — a known absence the approval record states — and a gap, where the host cannot @@ -310,48 +312,28 @@ registry on every reconcile pass (and on a delivery or an envelope write, without waiting for the tick) and installs it tagged with the envelope revision it was derived from — the only production writer — so installing, narrowing or withdrawing a module takes effect with no restart. An entry exists only while -three inputs agree: +two inputs agree: - **the module envelope** — the authority ceiling a platform super_admin writes through `PlatformAdminService/PutModuleEnvelope` (`requirePlatformRole`, so never through an impersonated session; audited as `saas.platform.module_envelope_written`; append-only and revisioned in - `module_envelopes`; fresh MFA required, an unenrolled administrator refused). - Per module prefix it lists the WHOLE authority units the host admits (id - `::`, revision, scope, queue, namespace, - binding key, lookup method — the audience is the grant's), the workload - identities the principal may run as (SPIFFE ID and container), explicit - `admitted_bindings` for a principal with no consumed units, the scope - ceilings, and the grant ceiling below. -- **a live authority document inside that envelope** — signed, delivered, - re-verified on every pass against the host's CURRENT signer-to-domain policy - (a document that no longer verifies is refused by name), over a presence - binding no ADMITTED tombstone in the whole inbox history ever withdrew — one - addressed to this host and speaking for the binding's OWNER, the domain of - the presence generation this host admitted and applied (never a delivered - row core's admission refused); a withdrawal is terminal for the - binding, whatever later generation or authority id follows, and a foreign - tombstone is refused at receipt and ignored in history — and held by core's - `ValidateAgainst` to the envelope's - own units (exact containment: a unit that differs from the admitted one in any - field puts the whole document outside, and it grants nothing). -- **the applied presence document** the authority is granted over — the workload - running the approved build gives the entry's `workload` (namespace and service - account from its SPIFFE ID, read by the one strict parser - `ParseKubernetesSPIFFEID`, container from the presence), and only when the - envelope allows that exact identity, so a later presence generation cannot - move the principal to another workload. One workload (namespace and service - account) maps to exactly one principal: the envelope refuses an overlap when - written and the derivation omits it. - -The entry is the envelope's grant bounded by the document: queues and namespaces -the document also declares, resources its scope ceilings name, and each -operation audience only for a binding whose `invoke` unit the document grants, -every operation's scopes intersected with the unit's scope. A principal granted -by two live documents, whose workload is not allowed or has no single answer, -or whose entry fails validation is **omitted by name, never widened**. Within an -authority id the newest generation is the answer (the inbox is append-only); -across ids a principal moves only to a document inside the envelope. + `module_envelopes`; fresh MFA required, an unenrolled administrator refused): + `{"revision", "principals": {"": {"grant": {…}}}}`. It names no + workload, no unit and no build; a document that does is refused by name. +- **an applied, live module manifest asking for the principal** — its + `authority.principal` is the module's principal, and its stored carrier is + re-verified on every pass against the CURRENT pins (one that no longer + verifies grants nothing). Its workloads, spelled by the namespace rule, are + the entry's `workloads`. A withdrawal is terminal for the binding. One + workload (namespace and service account) maps to exactly one principal, and + two manifests asking for one principal are both omitted. + +The entry is the envelope's grant bounded by the manifest: queues, namespaces +and resource kinds the manifest also asks for, and each operation binding only +when the manifest asks for it on the envelope's audience with an `invoke` +operation, every operation's scopes intersected with what the manifest asks. +A principal whose entry fails validation is **omitted by name, never widened**. **The registry is fenced by the committed envelope revision.** Every module authority decision — `AuthorizeModuleCapability`, the work-context and @@ -411,7 +393,7 @@ to act on who is not a member of the named tenant is `FAILED_PRECONDITION`. That The split is what lets a module drive the surface from a journal with retries. A departed person is final for that person and nothing a retry resolves, while a composition this host does not admit as a module principal, or whose workload -it cannot bind to an approved build, is an operator's to fix and its work is +it cannot bind to a workload its manifest lists, is an operator's to fix and its work is worth holding; conflated under one code a consumer has to choose which of the two it silently gets wrong. @@ -473,12 +455,11 @@ module presents its pod's **projected ServiceAccount token** (audience `accounts`) as the request's bearer, and `ModuleAuthorizeWorkContext` refuses a prefix that is not a declared module principal, then runs `BindExecution` (see "Establishing what a caller is running" below): the token is reviewed, the pod it -names is read, and the image that pod is RUNNING must equal the build the -principal's authority document approves. Only then is a capability minted, owned -and actored by the module principal (`aud=module-capabilities`), and a -`saas.module.work_context_minted` audit event (v2) records it with the execution -it was bound to — namespace, ServiceAccount, pod, pod UID, container, running -image, approved incarnation. The response carries +names is read, and its namespace and ServiceAccount must be a workload the +principal's applied manifest lists. Only then is a capability minted, owned and +actored by the module principal (`aud=module-capabilities`), and a +`saas.module.work_context_minted` audit event records it with the workload it +was bound to — namespace, ServiceAccount, pod, pod UID, container. The response carries `{token, expiresAt, principalId, tenant}`. - **No secret proves a module's identity (#1056).** `MODULE_IDENTITY_SECRETS`, @@ -487,11 +468,10 @@ image, approved incarnation. The response carries - A refusal is a **verdict** (`PermissionDenied`/`Unauthenticated`, the same fixed message whichever check failed): a token the API server refused — another audience, another cluster, expired — a token for another ServiceAccount - or namespace than the principal declares, a replaced pod, or a pod running an - unapproved build. The cause is logged here, never returned. + or namespace than the principal's manifest lists, or a replaced pod. The cause is logged here, never returned. - A host that could not establish the execution answers **`Unavailable`**: the API server is unreachable, no execution binding is wired, or the container - reports no image yet. Execution binding is wired only with delivered authority + has not started yet. Execution binding is wired only with delivery (`configuredSolutionHostBindingReconciler`), so **a host with no delivery mints for no module**, and a module run outside Kubernetes has no token to present. - A tenant-pinned module keeps the envelope's `tenant`; requesting a different @@ -502,9 +482,9 @@ image, approved incarnation. The response carries - **Every module credential is sealed.** Accounts mints and verifies Work Contexts with `core/workcontext` (`pkg/adapters/work_context_core.go`): `StartModuleTask` seals the installation the business mint resolved, its - revision, the principal's producer epoch and the build the requesting pod is - running, and `VerifyModuleWorkContext` returns a `ModuleCaller` whose `Sealed` - carries the first three. So an uninstall revokes a module capability at its + revision and the principal's producer epoch — no build — and + `VerifyModuleWorkContext` returns a `ModuleCaller` whose `Sealed` carries + them. So an uninstall revokes a module capability at its next use — core refuses it at verification, because its sealed installation is no longer active — and so does a reinstall under a new installation id, which an unsealed credential could not do. `checkAgainstSealed` still compares the @@ -529,11 +509,8 @@ image, approved incarnation. The response carries organization** (migration 35). Installing a module and every change to its installation's revision advance it, so a capability sealed before a scope or status change is refused after it. -- Every mint calls `BindExecution`: the projected token is reviewed and its - running pod/build checked against delivered approval, and the digest it read is - the execution the capability attests — core holds it against the approved - build again at the mint. The approved-build view is keyed by the module's - PREFIX, the name the authority document gives it, never by its registry UUID. +- Every mint calls `BindExecution`: the projected token is reviewed and its pod + held to a workload the principal's applied manifest lists. - **The exact binding lookup exists; `moduleOperationContextStale` still searches** the current operation bindings for a compatible scope superset. Replacing this with `ExactOperationBinding` requires the sealed binding id. @@ -558,180 +535,48 @@ job lifecycle regressions live in `pkg/infra/installation_review_test.go`; the actual signer/verifier regression is `pkg/adapters/installation_credential_test.go`. -## Establishing what a caller is running - -`Service.BindExecution` answers what a caller IS RUNNING, from sources the -caller does not control. The mint used to authenticate with a **shared secret**, -which is a bearer token — whoever holds it is the module — so it could not answer -this at all; that secret is deleted (#1056) and every module mint calls this. - -The approval is looked up by the name the authority document gives the -principal — its **prefix** — never by the registry key -`ModulePrincipalID(prefix)`, a UUID no document carries. A token the API server -reviewed and refused is `ErrExecutionTokenRejected`, a verdict; a review that -could not run is `ErrExecutionUnbound`, not one. - -**What a mint requires, in full** (review round 1 on #1056 found each missing): - -- **The workload identifies exactly one principal, and it is the one asked - for.** A token authenticates a ServiceAccount in a namespace, never a prefix. - The reviewed `(namespace, ServiceAccount)` is resolved over the WHOLE captured - registry, the same snapshot that supplied the container and tenant; - zero or several owners is `ErrExecutionWorkloadAmbiguous` / - `ErrExecutionIdentityMismatch`. Two principals sharing a ServiceAccount mint as - neither — before, either one's pod minted as both. -- **The pod is one security boundary.** Every container in it can read the - projected token, so the pod may hold only the authenticating container plus - init containers that ran to completion. Any other regular container, an init - container still running or restartable (a native sidecar), and an ephemeral - (debug) container in ANY state is `ErrExecutionUnapprovedContainer`. - **The stronger rule this falls short of, and why:** admit every container the - applied, signed presence document declares for the workload, each RUNNING its - declared digest. Presence cannot express it today: core's - `solutionhost.Workload` (v0.17.2 through main) carries the authenticating - container with its digest and every other container as a NAME only - (`non_authenticating`), and the CLI renders exactly that. Admitting a sidecar - by name would let it run any image, so every other container is refused. - **The cost:** a module pod with a sidecar — a Cloud SQL Auth Proxy, or a - namespace still on istio sidecar injection — mints nothing until presence - carries a digest per container (a core field, the CLI render, this check). - In the cell today the Cloud SQL proxy sidecars are on accounts (the host, which - mints for no one) and on database Jobs, and delivered namespaces are ambient, - so no module serving pod is known to carry one. -- **Only a live regular container supplies the digest.** The pod must be - `Running` with no `deletionTimestamp`, and the declared container must be a - regular container in state `running`; a stopped container keeps its approved - `imageID` in status and a completed init container still reports one, and - neither counts (`ErrExecutionNotRunning`). Not started yet — pod `Pending`, - container `waiting` — is `ErrExecutionUnbound`: retryable, not a verdict. -- **The token lives at most 600 seconds** (`MaxExecutionTokenLifetime`, - Kubernetes' minimum `expirationSeconds`), read from the iat/exp of a token the - API server has just authenticated. A longer one, or one whose lifetime cannot - be read, is refused. - -**Presenter binding.** Production exchanges require the gateway-authenticated -workload presenter in private request context. The internal gRPC adapter accepts -`x-codefly-workload-presenter` only with the gateway-only provenance credential, -refuses duplicate or malformed values, and removes the raw assertion before -calling business code. `BindExecution` compares the complete SPIFFE ID against -applied presence and its namespace/ServiceAccount against TokenReview. The -protected waypoint must overwrite caller XFCC and be the only network peer -admitted to the workload listener; a trusted-looking header is not that proof. - -**Replay, stated precisely.** Reusing a valid projection for legitimate work is -allowed. A stolen token presented under a different mesh principal is refused; -a pod UID, approved running build and current organisation installation are still -required. Mesh identity names a ServiceAccount, so two pods sharing that identity -remain indistinguishable as presenters. TokenReview, the 600-second lifetime -bound, pod UID and build checks remain essential. This change does not establish -pod-specific proof of possession or live mesh qualification. - -**Native development.** Only constructing the Kubernetes client from the local -stand-in outside a real pod selects the native exemption. In-cluster construction -requires presenter binding, and setting a local stand-in inside a pod fails -startup. Business tests using an in-memory cluster model the native trust profile; -production presenter tests use the real Kubernetes decoder and business mint path. - -**Three digest types, and conflating any two defeats the check.** They are -separate Go types rather than three strings for exactly that reason: - -| | what it says | who can write it | -| --- | --- | --- | -| `ApprovedDigest` | what the authority document approves | signed, out of band — the caller has no influence | -| `DeclaredDigest` | what the pod **spec** asks to run | whoever can create the pod | -| `RunningDigest` | what the container **status** reports, as `imageID` | the kubelet | - -The check is **approved == running**. Comparing approved against declared is the -classic defeat: a pod spec can name an approved image and run something else if -the tag moved, and the comparison passes while the workload is unapproved. -Comparing declared against running proves only that the pod got what it asked -for, which says nothing about whether it was allowed to ask. `DeclaredDigest` -exists with nothing compared against it so that a reader reaching for "the pod's -image" has a name for the thing they must not use. - -**Three independent sources, so no one of them can satisfy the check.** Filling -both sides from the same place — approved digest and running digest both out of -the authority document — compares a value to itself and passes for every caller. -Here the approved digest comes from the signed document, the running digest from -the Kubernetes API, keyed by a pod UID that came from a TokenReview of a token -the caller could not forge. - -**The order is load-bearing:** authenticate, then read the pod *the token named*, -then compare. Reading the pod first would mean reading a pod the **caller** -named, which is a caller-supplied input; the token is what makes the pod -reference trustworthy. The audience is `accounts`, so a token minted for the API -server's own audience authenticates the same service account while having been -issued for something else. - -**The UID is what is compared, not the name.** A pod name is reused across -generations of a workload and the UID is not, so a replacement pod at the same -name is refused. - -**Two answers that must not be confused.** `ErrExecutionNotApproved` is a verdict -about the caller. `ErrExecutionUnbound` means the host could not *tell* — an -unreachable API, a container still pulling, no reviewer wired — and **nothing is -issued**, because a mint that proceeded would issue an unbound capability exactly -when binding was impossible. A legacy non-bound token is **refused**, not -skipped: skipping would make the check opt-out by presenting an older token, -which is the easiest possible bypass. - -**A tag is never an approval.** The comparison is on the digest portion and is an -exact match on it — not a suffix test (`HasSuffix` passes when the approved -digest is a suffix of a longer hex string) and not a `sha256:` prefix check -(which would let any repository satisfy an approval granted for one image). A -value with no digest never matches, because an approval is only ever a statement -about immutable content. - -### The monotonicity contract, which is this host's to keep - -Core's `SealSource.ApprovedBuild` states the rule and says plainly that it -compares values for **equality** and cannot detect a source that moves -backwards. `MonotonicApprovedBuilds` enforces both clauses: - -1. for one principal, the incarnation never **decreases**; -2. a change of **digest** comes with an **increase** in the incarnation. - -Clause 2 is the one core's own `MemorySealSource` missed: approve B at -incarnation 5, then approve A again at 5, and every capability sealed to A at 5 — -which the move to B revoked — verifies again. A swap-back at a fixed counter is -the rewind the rule exists to prevent, reached through the field clause 1 does -not cover. A violating write is **refused**, not clamped: clamping would serve a -value the writer did not intend and the writer would never learn. A source that -genuinely must rewind is reconstructing state rather than recording it, and -belongs behind a fresh instance. - -**Three states, and collapsing two of them is the dangerous direction:** a record -→ the approved pair; known but no record → `ErrNoApprovedBuild`, and the caller -may act unbound; unknown → `ErrUnknownExecutionPrincipal`, refused. Collapsing -unknown into "bears none" would mint an unbound capability for an identity the -host has never heard of, so an **empty** authority refuses everyone rather than -minting unbound capabilities for everyone. - -A principal bearing no approved build binds with **neither** field set, because -core's seal makes `build_incarnation` and `image_digest` optional *and paired* — -a digest without an incarnation is refused by its schema. - -**Wired (#1056):** every module mint calls `BindExecution`, and the approved -digests come from the delivered authority documents through -`ApprovedBuildReconciler`. `TestEveryModuleMintIsExecutionBound` (module/tools) -holds the call chain. `module_operation_context.go`'s binding search -still waits on a sealed operation binding: this host seals none into any -capability, and its seal source answers every binding id as absent. -- The module presents that token in `x-codefly-work-context` on every capability - call; accounts takes the calling principal and its bound tenant **from the - verified token, never from request metadata**. Work Contexts cap at 15 minutes, - so a long-running worker re-runs the exchange rather than holding one open. - -See [../../WORK_CONTEXTS.md](../../WORK_CONTEXTS.md) and -[../../MODULE_INSTALLATION.md](../../MODULE_INSTALLATION.md) for the other -exchanges that produce one. +## Establishing which workload a caller is + +`BindExecution` (`pkg/business/module_execution_binding.go`) takes the projected +token and the principal the caller names, and establishes the rest +independently: + +1. **Review** the token (TokenReview, audience `accounts`; natively, core's + native attester under the run's anchor): it names a namespace, + a ServiceAccount, a pod and a pod UID. A token the API server refused is a + verdict (`ErrExecutionTokenRejected`); an API server that could not answer is + `ErrExecutionUnbound`. A token naming no pod, or valid for longer than the + 600-second projection, is refused. +2. **Resolve** the reviewed `(namespace, ServiceAccount)` over the WHOLE + registry: exactly one principal must declare a workload with that pair, and it + must be the one asked for (`ErrExecutionWorkloadAmbiguous`, + `ErrExecutionIdentityMismatch`). The pair is the applied manifest's workload + by rule — the namespace is the module's, the ServiceAccount the service's — so + a principal runs as every workload its manifest lists. In a pod, the mesh + presenter's SPIFFE ID must name the same namespace and ServiceAccount. +3. **Read** the pod the TOKEN named, never one the caller named: its UID must + be the token's, it must be Running and not being deleted, the manifest's + container for that workload must be a running regular container, and nothing + else that could read the token may be in the pod (a sidecar, a running or + restartable init container, an ephemeral container: + `ErrExecutionUnapprovedContainer`). A container not started yet is + `ErrExecutionUnbound`; one stopped is a verdict. + +**No image is read or compared.** Build approval left the runtime path with the +module manifest: what a workload runs is deploy-time admission, and a Work +Context seals no build. So containerd's `status.imageID` aliasing (#1145) cannot +refuse an approved build or admit a withdrawn one at mint. + +The presenter's trust domain is not compared: the manifest declares no SPIFFE ID +and this host is given no trust domain, so the presenter is held to the reviewed +namespace and ServiceAccount only. Delivering the cell's trust domain to the host +is the follow-up that restores that term. ## Serving the live authority a callee verifies against `ModuleCapabilitiesService/ResolveWorkContextAuthority` answers the live values a composed module's verifier holds a presented capability's seal against: the -installation and its revision, a principal's epoch, the approved build, the -operation binding, the owner's authorization revision, and an approval grant +installation and its revision, a principal's epoch, the operation binding, the owner's authorization revision, and an approval grant (this host records none, and says so). Without it a module can only run core's forwarding-hop `Inspector`, which checks no seal, epoch or revision and consumes no single-use token — so a revoked installation or an advanced epoch stays diff --git a/module/services/accounts/code/go.mod b/module/services/accounts/code/go.mod index 19de7b867..512faa34e 100644 --- a/module/services/accounts/code/go.mod +++ b/module/services/accounts/code/go.mod @@ -8,11 +8,11 @@ require ( connectrpc.com/connect v1.21.0 connectrpc.com/otelconnect v0.9.0 github.com/codefly-dev/cli/contracts/deployment v0.0.0-20261006151622-5824cf5342fc - github.com/codefly-dev/core v0.19.5-0.20261014051947-55747e621a7b + github.com/codefly-dev/core v0.19.6-0.20261018051341-b81d92e6feda github.com/codefly-dev/interface-cache/go/cache v0.2.2-0.20260926205419-327a6e1d9aba github.com/codefly-dev/module-saas-starter/module/frontendpayload v0.0.0-20261014024718-5858903a66c8 - github.com/codefly-dev/sdk-go v0.4.0 - github.com/codefly-dev/sdk-go/workcontext v0.1.0 + github.com/codefly-dev/sdk-go v0.4.1-0.20261018052022-70dc7a546458 + github.com/codefly-dev/sdk-go/workcontext v0.1.1-0.20261018052022-70dc7a546458 github.com/codefly-dev/service-postgres/libs/go v0.0.0-20260929200903-bae6c31c7211 github.com/codefly-dev/service-redis/cache v0.2.0 github.com/go-webauthn/webauthn v0.18.0 diff --git a/module/services/accounts/code/go.sum b/module/services/accounts/code/go.sum index 74dd47ec5..9dd7f0dc7 100644 --- a/module/services/accounts/code/go.sum +++ b/module/services/accounts/code/go.sum @@ -60,18 +60,18 @@ github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/codefly-dev/cli/contracts/deployment v0.0.0-20261006151622-5824cf5342fc h1:olDnVJ1A1Retn0z9A59YMwDRmDLgkV2y1lbl+HzNrj8= github.com/codefly-dev/cli/contracts/deployment v0.0.0-20261006151622-5824cf5342fc/go.mod h1:VWPYY3FH4te7SzdN2xl9odNpWidJ1Grl/Hjs1SNoEF8= -github.com/codefly-dev/core v0.19.5-0.20261014051947-55747e621a7b h1:ayhuRhAHa72A0NYMS4hkbHPA8Xrb3Iw7HvYUV35UIkI= -github.com/codefly-dev/core v0.19.5-0.20261014051947-55747e621a7b/go.mod h1:0uX/7lTBVaGV0RI2Mb3mpEr/PPdx/jZqXHym/U8FVDI= +github.com/codefly-dev/core v0.19.6-0.20261018051341-b81d92e6feda h1:ytTihtIEfVhQ7llQl+ls5swIL8JgJu89X0wlDMeqM+o= +github.com/codefly-dev/core v0.19.6-0.20261018051341-b81d92e6feda/go.mod h1:0uX/7lTBVaGV0RI2Mb3mpEr/PPdx/jZqXHym/U8FVDI= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= github.com/codefly-dev/gortk v0.2.0/go.mod h1:dDWUMFgAP063OCGhTEpV7FFUj9+zTcxxO/nV80VKEwg= github.com/codefly-dev/interface-cache/go/cache v0.2.2-0.20260926205419-327a6e1d9aba h1:6nFaDYsLyA5x3WiGAk6pMI3c0naqxiPeaTHMqRPvbVs= github.com/codefly-dev/interface-cache/go/cache v0.2.2-0.20260926205419-327a6e1d9aba/go.mod h1:OQDNwz4rpEGfq7VC/FjvmQGlo9H8gH8fIDcZhKIe8iQ= github.com/codefly-dev/module-saas-starter/module/frontendpayload v0.0.0-20261014024718-5858903a66c8 h1:oq3ISoHhFUQOIJKT+KXuQjCyR24Ktiso04tpTw0VOZo= github.com/codefly-dev/module-saas-starter/module/frontendpayload v0.0.0-20261014024718-5858903a66c8/go.mod h1:OxvwxMdo3HD190xFiMOPHtUsBC7/7Jt1evsEts/2HRs= -github.com/codefly-dev/sdk-go v0.4.0 h1:3Jm0bP+UsLI4iZYp3wqVa42bcXBumMIIF/aeweKOAhY= -github.com/codefly-dev/sdk-go v0.4.0/go.mod h1:vExvoK8jqWNiUjmphgaK/ir1+Tz717mNS6bc4FAHVks= -github.com/codefly-dev/sdk-go/workcontext v0.1.0 h1:oFU/Lg1xnlibRPqyKR0KGdgGttwH8EdZIuErqBaRZxo= -github.com/codefly-dev/sdk-go/workcontext v0.1.0/go.mod h1:FeKytaeqEXRigN5UflAjVeoMoV2gEN2oJLgBBgpr0Jg= +github.com/codefly-dev/sdk-go v0.4.1-0.20261018052022-70dc7a546458 h1:hi+KhotTLMOJ/PUSzVO/Axfho7yZ2UOjrCFfrLI1EKU= +github.com/codefly-dev/sdk-go v0.4.1-0.20261018052022-70dc7a546458/go.mod h1:Qg8SMzZxKSllwvZwShMrWgSetBVMjxP0s17mwWHWjk0= +github.com/codefly-dev/sdk-go/workcontext v0.1.1-0.20261018052022-70dc7a546458 h1:wBOL7QsuoC0HsS43gsH2+tpsipk4os077C2VUDkMUHg= +github.com/codefly-dev/sdk-go/workcontext v0.1.1-0.20261018052022-70dc7a546458/go.mod h1:hwtrdGtgh8i2xzjgihyd4wY2u+UK7nHQz30OGKL0n+M= github.com/codefly-dev/service-postgres/libs/go v0.0.0-20260929200903-bae6c31c7211 h1:4KAPx8haCA/h4Kx5mB7D0rMriwLLKiOW9G9mMWuiQDw= github.com/codefly-dev/service-postgres/libs/go v0.0.0-20260929200903-bae6c31c7211/go.mod h1:5mwz8oM8INcukJLc6C84zdRnmDQ/gkMNI8GxWMwgk+8= github.com/codefly-dev/service-redis/cache v0.2.0 h1:MKeyXFTIiZMnz5bdndb6YTsahws8DjmLTldtz/nH6EQ= @@ -367,8 +367,8 @@ golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba h1:Ck8QetSgk912qxWLMCKxd0in+aiyBQyDSMae6e/xmpU= golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba/go.mod h1:50RgIsmK7OwqzTTeqcSXQW8SswW0o8fRcDxmqGluJ8E= -golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= -golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= +golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg= +golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs= golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= diff --git a/module/services/accounts/code/internal/deliverytest/signing.go b/module/services/accounts/code/internal/deliverytest/signing.go index 71473a2b2..d7373d4c7 100644 --- a/module/services/accounts/code/internal/deliverytest/signing.go +++ b/module/services/accounts/code/internal/deliverytest/signing.go @@ -15,9 +15,6 @@ import ( func Signer(t testing.TB, keyID string) *solutionhost.InMemorySigner { t.Helper() - if keyID == solutionhost.FixtureDeliveredBy { - return solutionhost.FixtureSigner() - } seed := sha256.Sum256([]byte("delivery test key: " + keyID)) signer, err := solutionhost.NewInMemorySigner(keyID, ed25519.NewKeyFromSeed(seed[:])) if err != nil { diff --git a/module/services/accounts/code/pkg/adapters/delegated_read_audience.go b/module/services/accounts/code/pkg/adapters/delegated_read_audience.go index f93ce8ee6..3de51d8b0 100644 --- a/module/services/accounts/code/pkg/adapters/delegated_read_audience.go +++ b/module/services/accounts/code/pkg/adapters/delegated_read_audience.go @@ -182,7 +182,7 @@ func (s *ModuleCapabilitiesServer) exchangeDelegatedAudience(ctx context.Context if delegated { issued, err = authority.exchangeDelegatedParent(ctx, caller, parent, delegation, binding, time.Duration(ttl)*time.Second) } else { - issued, err = authority.exchangeVerifiedParent(ctx, parent, actor, &gen.ExchangeWorkContextAudienceRequest{OrgId: parent.TenantId, Audience: binding.audience, AttenuatedScopes: binding.scopes, ReplayPolicy: gen.WorkContextReplayPolicy_WORK_CONTEXT_REPLAY_POLICY_IDEMPOTENT, TtlSeconds: int32(ttl)}, workcontext.Execution{}, "") + issued, err = authority.exchangeVerifiedParent(ctx, parent, actor, &gen.ExchangeWorkContextAudienceRequest{OrgId: parent.TenantId, Audience: binding.audience, AttenuatedScopes: binding.scopes, ReplayPolicy: gen.WorkContextReplayPolicy_WORK_CONTEXT_REPLAY_POLICY_IDEMPOTENT, TtlSeconds: int32(ttl)}, "") } if err != nil { audit(business.DelegatedAudienceExchangeRefused, binding.audience, err) @@ -468,8 +468,7 @@ func sourceDelegationHops(actors []*basev0.WorkActorV1) []business.SourceDelegat // the caller's own binding. The caller — the module the delegation's binding // names as its audience — is the module that USES what is issued, so the // capability is minted afresh: the person's session, then the caller's hop -// exercising the delegation as its operation binding, attesting the build its -// verified credential was sealed to. Core resolves that binding live and refuses +// exercising the delegation as its operation binding. Core resolves that binding live and refuses // it for any module the delegation does not entitle. func (s *WorkContextAuthorityServer) exchangeDelegatedParent( ctx context.Context, caller business.ModuleCaller, parent *basev0.WorkContextV1, @@ -490,7 +489,7 @@ func (s *WorkContextAuthorityServer) exchangeDelegatedParent( authority := business.SourceOperationContextAuthority{ ModuleWorkContextAuthority: business.ModuleWorkContextAuthority{ PrincipalID: caller.PrincipalID, Tenant: parent.GetTenantId(), - InstallationID: *caller.Sealed.InstallationID, Execution: caller.SealedExecution(), + InstallationID: *caller.Sealed.InstallationID, }, ModulePrefix: grant.Prefix, OwnerPrincipalID: parent.GetOwnerPrincipalId(), diff --git a/module/services/accounts/code/pkg/adapters/delegated_record_access_test.go b/module/services/accounts/code/pkg/adapters/delegated_record_access_test.go index c0416b212..b11fffb18 100644 --- a/module/services/accounts/code/pkg/adapters/delegated_record_access_test.go +++ b/module/services/accounts/code/pkg/adapters/delegated_record_access_test.go @@ -124,8 +124,7 @@ func TestExactRecordOracleRefusesModuleIdentityCapability(t *testing.T) { // The same identity re-minted at the module's own prefix, so the declared // vocabulary gate passes and only the empty scope set can refuse it. prefixed, _ := mintTestCapability(t, workContextSingleton, testCapability{Audience: "rows", Tenant: readOrg, Owner: module, - OwnerKind: business.PrincipalKindService, Task: "019f6bf7-3333-7333-8333-333333333333", Session: "019f6bf7-4444-7444-8444-444444444444", - Execution: testBuildFor(module)}) + OwnerKind: business.PrincipalKindService, Task: "019f6bf7-3333-7333-8333-333333333333", Session: "019f6bf7-4444-7444-8444-444444444444"}) for _, capability := range []struct{ name, token, refusal string }{ {"module-capability audience", identity, "declared resource scope required"}, {"module's own prefix", prefixed, "record scope required"}, diff --git a/module/services/accounts/code/pkg/adapters/installation_credential_test.go b/module/services/accounts/code/pkg/adapters/installation_credential_test.go index 526cee59e..e90a4080e 100644 --- a/module/services/accounts/code/pkg/adapters/installation_credential_test.go +++ b/module/services/accounts/code/pkg/adapters/installation_credential_test.go @@ -30,11 +30,8 @@ func (s *credentialInstallationStore) LiveModuleAuthority(context.Context, strin func TestSealedModuleCredentialIsHeldToItsInstallation(t *testing.T) { installModuleWorkContextAuthority(t) id := business.ModulePrincipalID("example") - build := testBuildFor(id) - workContextSingleton.seals.(*sealAnswersFake).approve(id, build) minted := business.ModuleWorkContextAuthority{ PrincipalID: id, Tenant: moduleWorkContextTenant, InstallationID: "installation-a", InstallationRevision: 1, - Execution: business.ExecutionIdentity{Running: business.RunningDigest(build.ImageDigest), Incarnation: build.BuildIncarnation}, } token, _, err := WorkContextSingleton().StartModuleTask(context.Background(), minted) require.NoError(t, err) diff --git a/module/services/accounts/code/pkg/adapters/module_authority_contract_host_test.go b/module/services/accounts/code/pkg/adapters/module_authority_contract_host_test.go index 3b1a74cc7..ad7fd4f60 100644 --- a/module/services/accounts/code/pkg/adapters/module_authority_contract_host_test.go +++ b/module/services/accounts/code/pkg/adapters/module_authority_contract_host_test.go @@ -17,7 +17,6 @@ import ( "accounts/pkg/auth" ed25519minter "accounts/pkg/auth/ed25519" "accounts/pkg/business" - "accounts/pkg/business/executiontest" "accounts/pkg/business/moduleregistrytest" gen "accounts/pkg/gen/saas/accounts/v1" @@ -324,9 +323,9 @@ func installModuleAuthorityContract(t *testing.T, input moduleAuthorityContractI for _, prefix := range []string{contractSourceModule, contractRuntimeModule} { tokens[prefix] = moduleToken(t, prefix) } - tokens["unapproved"] = cluster.Run( - moduleWorkload(registry[business.ModulePrincipalID(contractSourceModule)]), - executiontest.Image(contractSourceModule+"-unreviewed")) + foreign := moduleWorkload(registry[business.ModulePrincipalID(contractSourceModule)]) + foreign.ServiceAccount = "unreviewed" + tokens["unapproved"] = cluster.Run(foreign) svc.SetAuditEmitter(&validatingAudit{}) WithService(svc) // Keyed as work.go keys production: the cluster key signs access tokens and diff --git a/module/services/accounts/code/pkg/adapters/module_execution_fixture_test.go b/module/services/accounts/code/pkg/adapters/module_execution_fixture_test.go index 88b947107..95c826526 100644 --- a/module/services/accounts/code/pkg/adapters/module_execution_fixture_test.go +++ b/module/services/accounts/code/pkg/adapters/module_execution_fixture_test.go @@ -18,35 +18,30 @@ var moduleExecution struct { registry business.ModulePrincipalRegistry } -// bindModuleExecution wires svc to a fresh fake cluster and approves, for every -// declared principal, the build its pods run by default (named for its prefix). +// bindModuleExecution wires svc to a fresh fake cluster. func bindModuleExecution(t *testing.T, svc *business.Service, registry business.ModulePrincipalRegistry) *executiontest.Cluster { t.Helper() cluster := executiontest.New() - svc.SetExecutionBinding(cluster, cluster) - for _, grant := range registry { - cluster.Approve(grant.Prefix, executiontest.Digest(grant.Prefix), 1) - } + svc.SetExecutionBinding(cluster) previous := moduleExecution t.Cleanup(func() { moduleExecution = previous }) moduleExecution.cluster, moduleExecution.registry = cluster, registry return cluster } -// moduleToken schedules a pod for prefix's declared workload, running its -// approved build, and returns the token Kubernetes projects into it for -// accounts. +// moduleToken schedules a pod for prefix's declared workload and returns the +// token Kubernetes projects into it for accounts. func moduleToken(t *testing.T, prefix string) string { t.Helper() require.NotNil(t, moduleExecution.cluster, "no module execution is bound for this test") grant, declared := moduleExecution.registry[business.ModulePrincipalID(prefix)] require.True(t, declared, "%s is not a declared principal", prefix) - return moduleExecution.cluster.Run(moduleWorkload(grant), executiontest.Image(prefix)) + return moduleExecution.cluster.Run(moduleWorkload(grant)) } func moduleWorkload(grant business.ModulePrincipalGrant) executiontest.Workload { + workload := grant.Workloads[0] return executiontest.Workload{ - Namespace: grant.Workload.Namespace, ServiceAccount: grant.Workload.ServiceAccount, - Container: grant.Workload.Container, + Namespace: workload.Namespace, ServiceAccount: workload.ServiceAccount, Container: workload.Container, } } diff --git a/module/services/accounts/code/pkg/adapters/module_installations_http_test.go b/module/services/accounts/code/pkg/adapters/module_installations_http_test.go index 06e57d893..3d89eac9b 100644 --- a/module/services/accounts/code/pkg/adapters/module_installations_http_test.go +++ b/module/services/accounts/code/pkg/adapters/module_installations_http_test.go @@ -69,11 +69,9 @@ func TestInstallerHTTPRejectsExpiredForeignAndWrongAudienceCapabilities(t *testi post(token, 401) post("not-a-token", 401) // A signed capability for another consumer is not a module capability. - build := testBuildFor(caller.PrincipalID) - workContextSingleton.seals.(*sealAnswersFake).approve(caller.PrincipalID, build) wrong, _ := mintTestCapability(t, WorkContextSingleton(), testCapability{ Audience: "other-consumer", Tenant: caller.BoundOrg, Owner: caller.PrincipalID, - OwnerKind: business.PrincipalKindService, Execution: build, + OwnerKind: business.PrincipalKindService, }) post(wrong, 401) require.Equal(t, 1, svc.calls) diff --git a/module/services/accounts/code/pkg/adapters/module_mint_entrypoints_test.go b/module/services/accounts/code/pkg/adapters/module_mint_entrypoints_test.go index abd2ce9d1..421e38ee4 100644 --- a/module/services/accounts/code/pkg/adapters/module_mint_entrypoints_test.go +++ b/module/services/accounts/code/pkg/adapters/module_mint_entrypoints_test.go @@ -13,7 +13,6 @@ import ( "testing" "accounts/pkg/business" - "accounts/pkg/business/executiontest" gen "accounts/pkg/gen/saas/accounts/v1" "github.com/stretchr/testify/require" @@ -105,23 +104,23 @@ func mintEntrypoints() []mintEntrypoint { } } -func TestEveryModuleMintIsBoundToTheRunningBuild(t *testing.T) { +func TestEveryModuleMintIsBoundToADeclaredWorkload(t *testing.T) { refusals := map[string]func(t *testing.T, prefix string) string{ "a token the API server refuses": func(*testing.T, string) string { return "not-a-projected-token" }, "a pod of another workload": func(_ *testing.T, prefix string) string { declared := moduleWorkload(moduleExecution.registry[business.ModulePrincipalID(prefix)]) declared.ServiceAccount = "intruder" - return moduleExecution.cluster.Run(declared, executiontest.Image(prefix)) + return moduleExecution.cluster.Run(declared) }, - "a pod on an unapproved build": func(_ *testing.T, prefix string) string { - return moduleExecution.cluster.Run(moduleWorkload(moduleExecution.registry[business.ModulePrincipalID(prefix)]), - executiontest.Image(prefix+"-unreviewed")) + "a pod in another module's namespace": func(_ *testing.T, prefix string) string { + declared := moduleWorkload(moduleExecution.registry[business.ModulePrincipalID(prefix)]) + declared.Namespace = "elsewhere" + return moduleExecution.cluster.Run(declared) }, "a pod with a debug container beside the app": func(t *testing.T, prefix string) string { token := moduleToken(t, prefix) moduleExecution.cluster.AddContainer(token, business.ContainerExecution{ Name: "debug", Kind: business.ContainerEphemeral, State: business.ContainerRunning, - ImageID: executiontest.Image("debugger"), }) return token }, diff --git a/module/services/accounts/code/pkg/adapters/module_operation_context_test.go b/module/services/accounts/code/pkg/adapters/module_operation_context_test.go index 2bdf07c45..860e1910c 100644 --- a/module/services/accounts/code/pkg/adapters/module_operation_context_test.go +++ b/module/services/accounts/code/pkg/adapters/module_operation_context_test.go @@ -110,11 +110,9 @@ func TestMintModuleOperationContextSealsExactlyTheHeadlessScopes(t *testing.T) { require.LessOrEqual(t, signed.GetExpiresAtUnix()-signed.GetIssuedAtUnix(), int64(business.ModuleOperationContextTTL/time.Second)) want := []*basev0.WorkScopeV1{{ResourceKind: "modelservice.profiles", Actions: []string{"invoke", "read"}, ResourceIds: []string{"profile-digest"}}} requireScopes(t, want, signed.GetAuthorityScopes()) - // The module acts as itself: owned by its service principal, no actor hop, - // and sealed to the build its requesting pod runs. + // The module acts as itself: owned by its service principal, no actor hop. require.Empty(t, signed.GetActorChain()) require.Equal(t, business.PrincipalKindService, signed.GetOwnerPrincipalKind()) - require.Equal(t, executiontest.Digest("documents"), signed.GetSeal().GetImageDigest()) // It is not a module identity: the capability surface refuses it. _, err = WorkContextSingleton().VerifyModuleWorkContext(context.Background(), resp.GetToken()) @@ -131,14 +129,12 @@ func TestMintModuleOperationContextSealsExactlyTheHeadlessScopes(t *testing.T) { require.Equal(t, "model", payload["binding_id"]) require.Equal(t, "modelservice", payload["audience"]) require.Equal(t, []string{"modelservice.profiles:invoke:profile-digest", "modelservice.profiles:read:profile-digest"}, payload["scopes"]) - // Attribution: the record names the execution the mint was bound to. - require.Equal(t, executiontest.Image("documents"), payload["running_digest"]) + // Attribution: the record names the workload the mint was bound to. require.Equal(t, "acme-prod", payload["namespace"]) require.Equal(t, "module", payload["service_account"]) require.Equal(t, "app", payload["container"]) require.NotEmpty(t, payload["pod"]) require.NotEmpty(t, payload["pod_uid"]) - require.EqualValues(t, 1, payload["build_incarnation"]) require.Equal(t, 1, store.tenantChecks) require.Equal(t, moduleWorkContextTenant, store.tenantCheckedID) } @@ -173,9 +169,8 @@ func TestMintModuleOperationContextRefusals(t *testing.T) { "not a projected token": {headlessPrincipals, "documents", func(*testing.T) string { return "guessed" }, "model", codes.Unauthenticated}, "undeclared module": {headlessPrincipals, "billing", own, "model", codes.Unauthenticated}, "another module's pod": {twoModules, "documents", func(t *testing.T) string { return moduleToken(t, "billing") }, "model", codes.Unauthenticated}, - "a pod on an unapproved build": {headlessPrincipals, "documents", func(t *testing.T) string { - return moduleExecution.cluster.Run(executiontest.Workload{Namespace: "acme-prod", ServiceAccount: "module", Container: "app"}, - executiontest.Image("documents-unreviewed")) + "a pod of an undeclared account": {headlessPrincipals, "documents", func(t *testing.T) string { + return moduleExecution.cluster.Run(executiontest.Workload{Namespace: "acme-prod", ServiceAccount: "intruder", Container: "app"}) }, "model", codes.Unauthenticated}, "invalid binding name": {headlessPrincipals, "documents", own, "", codes.InvalidArgument}, } @@ -194,9 +189,9 @@ func TestMintModuleOperationContextRefusals(t *testing.T) { } } -// The module Work Context mint records the execution it was bound to, in a +// The module Work Context mint records the workload it was bound to, in a // payload the registry accepts. -func TestMintModuleWorkContextRecordsTheExecutionItWasBoundTo(t *testing.T) { +func TestMintModuleWorkContextRecordsTheWorkloadItWasBoundTo(t *testing.T) { _, audit := installModuleOperationContextService(t, documentsPrincipals) installModuleWorkContextAuthority(t) @@ -206,12 +201,11 @@ func TestMintModuleWorkContextRecordsTheExecutionItWasBoundTo(t *testing.T) { entries := audit.of(business.EventModuleWorkContextMint) require.Len(t, entries, 1) payload := entries[0].Payload - require.Equal(t, executiontest.Image("documents"), payload["running_digest"]) require.Equal(t, "acme-prod", payload["namespace"]) require.Equal(t, "module", payload["service_account"]) require.Equal(t, "app", payload["container"]) require.NotEmpty(t, payload["pod_uid"]) - require.EqualValues(t, 1, payload["build_incarnation"]) + require.NotContains(t, payload, "running_digest", "no image is read at mint") } func TestMintModuleOperationContextRefusesATenantThatNamesNoOrganization(t *testing.T) { diff --git a/module/services/accounts/code/pkg/adapters/module_work_context_authority.go b/module/services/accounts/code/pkg/adapters/module_work_context_authority.go index cd37c425e..be247b614 100644 --- a/module/services/accounts/code/pkg/adapters/module_work_context_authority.go +++ b/module/services/accounts/code/pkg/adapters/module_work_context_authority.go @@ -137,8 +137,6 @@ func (s *WorkContextAuthorityServer) resolveWorkContextAuthority( return s.answerSeal(ctx, tenant, claims, question.Seal) case *gen.ModuleResolveWorkContextAuthorityRequest_PrincipalEpoch: return s.answerPrincipalEpoch(ctx, tenant, claims, question.PrincipalEpoch) - case *gen.ModuleResolveWorkContextAuthorityRequest_ApprovedBuild: - return s.answerApprovedBuild(ctx, tenant, claims, question.ApprovedBuild) case *gen.ModuleResolveWorkContextAuthorityRequest_OperationBinding: return s.answerOperationBinding(ctx, tenant, claims, question.OperationBinding) case *gen.ModuleResolveWorkContextAuthorityRequest_AuthorizationRevision: @@ -277,36 +275,6 @@ func (s *WorkContextAuthorityServer) answerPrincipalEpoch( }), nil } -func (s *WorkContextAuthorityServer) answerApprovedBuild( - ctx context.Context, tenant string, claims *basev0.WorkContextV1, question *gen.WorkContextApprovedBuildQuestion, -) (*gen.ModuleResolveWorkContextAuthorityResponse, error) { - if !slices.Contains(chainPrincipals(claims), question.GetPrincipalId()) { - return nil, status.Error(codes.InvalidArgument, errUnaddressedWorkContextAuthority) - } - seals, _, _ := s.moduleAuthoritySources(tenant, question.GetPrincipalId()) - digest, incarnation, err := seals.ApprovedBuild(ctx, question.GetPrincipalId()) - switch { - // BEARS NO EXECUTION is the answer for a person's session, and it is not a - // failure. It stays distinct from an unknown principal, which the source - // refuses: answering "bears none" for an identity the host never heard of - // would hand a service principal a credential nothing can revoke by - // replacing its build. - case errors.Is(err, workcontext.ErrNoApprovedBuild): - return approvedBuildAnswer(&gen.WorkContextApprovedBuildAnswer{ - State: gen.WorkContextApprovedBuildState_WORK_CONTEXT_APPROVED_BUILD_STATE_BEARS_NO_EXECUTION, - PrincipalId: question.GetPrincipalId(), - }), nil - case err != nil: - return nil, unansweredWorkContextAuthority(ctx, "approved build", err) - } - return approvedBuildAnswer(&gen.WorkContextApprovedBuildAnswer{ - State: gen.WorkContextApprovedBuildState_WORK_CONTEXT_APPROVED_BUILD_STATE_APPROVED, - PrincipalId: question.GetPrincipalId(), - ImageDigest: digest, - BuildIncarnation: incarnation, - }), nil -} - func (s *WorkContextAuthorityServer) answerOperationBinding( ctx context.Context, tenant string, claims *basev0.WorkContextV1, question *gen.WorkContextOperationBindingQuestion, ) (*gen.ModuleResolveWorkContextAuthorityResponse, error) { @@ -417,12 +385,6 @@ func principalEpochAnswer(answer *gen.WorkContextPrincipalEpochAnswer) *gen.Modu } } -func approvedBuildAnswer(answer *gen.WorkContextApprovedBuildAnswer) *gen.ModuleResolveWorkContextAuthorityResponse { - return &gen.ModuleResolveWorkContextAuthorityResponse{ - Answer: &gen.ModuleResolveWorkContextAuthorityResponse_ApprovedBuild{ApprovedBuild: answer}, - } -} - func operationBindingAnswer(answer *gen.WorkContextOperationBindingAnswer) *gen.ModuleResolveWorkContextAuthorityResponse { return &gen.ModuleResolveWorkContextAuthorityResponse{ Answer: &gen.ModuleResolveWorkContextAuthorityResponse_OperationBinding{OperationBinding: answer}, @@ -453,7 +415,6 @@ func decidedWorkContextAuthority(err error) bool { business.ErrSourceDelegationContextStale, business.ErrNoOperationBinding, business.ErrUnknownExecutionPrincipal, - business.ErrExecutionNotApproved, } { if errors.Is(err, decided) { return true diff --git a/module/services/accounts/code/pkg/adapters/module_work_context_authority_test.go b/module/services/accounts/code/pkg/adapters/module_work_context_authority_test.go index 52b6d36e8..1eca1dd8c 100644 --- a/module/services/accounts/code/pkg/adapters/module_work_context_authority_test.go +++ b/module/services/accounts/code/pkg/adapters/module_work_context_authority_test.go @@ -17,7 +17,6 @@ import ( "google.golang.org/grpc/status" "accounts/pkg/business" - "accounts/pkg/business/executiontest" gen "accounts/pkg/gen/saas/accounts/v1" ) @@ -157,25 +156,6 @@ func (s servedSources) PrincipalEpoch(ctx context.Context, principalID string) ( } } -func (s servedSources) ApprovedBuild(ctx context.Context, principalID string) (string, uint64, error) { - response, err := s.ask(ctx, &gen.WorkContextApprovedBuildQuestion{PrincipalId: principalID}) - if err != nil { - return "", 0, err - } - answer := response.GetApprovedBuild() - if answer == nil || answer.GetPrincipalId() != principalID { - return "", 0, fmt.Errorf("the host answered no approved build for principal %q", principalID) - } - switch answer.GetState() { - case gen.WorkContextApprovedBuildState_WORK_CONTEXT_APPROVED_BUILD_STATE_APPROVED: - return answer.GetImageDigest(), answer.GetBuildIncarnation(), nil - case gen.WorkContextApprovedBuildState_WORK_CONTEXT_APPROVED_BUILD_STATE_BEARS_NO_EXECUTION: - return "", 0, workcontext.ErrNoApprovedBuild - default: - return "", 0, fmt.Errorf("the host answered no approved-build state") - } -} - func (s servedSources) OperationBinding(ctx context.Context, bindingID string) (workcontext.OperationBinding, error) { response, err := s.ask(ctx, &gen.WorkContextOperationBindingQuestion{BindingId: bindingID}) if err != nil { @@ -547,23 +527,8 @@ func TestAnUnknownPrincipalIsRefusedThroughTheServedSources(t *testing.T) { _, err := sources.PrincipalEpoch(context.Background(), authorityPerson) require.ErrorIs(t, err, workcontext.ErrNoSeal) - _, _, err = sources.ApprovedBuild(context.Background(), authorityPerson) - require.Equal(t, codes.FailedPrecondition, status.Code(err), - "an unknown principal is a decision, and not 'bears no execution'") - _, err = verifier.Verify(context.Background(), fixture.token) require.Error(t, err) - require.NotErrorIs(t, err, workcontext.ErrNoApprovedBuild, - "an unknown principal must not read as one that bears no execution") -} - -// A person's session bears no execution, which is the answer and not a failure. -func TestAPersonBearsNoExecutionThroughTheServedSources(t *testing.T) { - fixture := installWorkContextAuthorityFixture(t) - - _, _, err := fixture.sources(t, fixture.caller).ApprovedBuild(context.Background(), authorityPerson) - - require.ErrorIs(t, err, workcontext.ErrNoApprovedBuild) } // THE CAPABILITY IS THE AUTHORIZATION. A module may resolve the live authority @@ -584,8 +549,6 @@ func TestAModuleCannotResolveAuthorityForACapabilityAddressedElsewhere(t *testin // No answer leaks through another question either. _, err = foreign.PrincipalEpoch(context.Background(), authorityPerson) require.Equal(t, codes.PermissionDenied, status.Code(err)) - _, _, err = foreign.ApprovedBuild(context.Background(), authorityPerson) - require.Equal(t, codes.PermissionDenied, status.Code(err)) _, err = foreign.AuthorizationRevision(context.Background(), authorityOrg) require.Equal(t, codes.PermissionDenied, status.Code(err)) @@ -637,8 +600,6 @@ func TestAQuestionMustBeAboutThePresentedCapability(t *testing.T) { // this surface must not be. _, err := sources.PrincipalEpoch(context.Background(), authorityStranger) require.Equal(t, codes.InvalidArgument, status.Code(err)) - _, _, err = sources.ApprovedBuild(context.Background(), authorityStranger) - require.Equal(t, codes.InvalidArgument, status.Code(err)) // Another installation, and another owner's revision. _, err = sources.Seal(context.Background(), authorityPerson, authorityStranger) @@ -842,44 +803,6 @@ func TestAWithdrawnOperationBindingIsRefusedThroughTheServedSources(t *testing.T require.ErrorIs(t, err, workcontext.ErrRevoked) } -// A module principal's APPROVED BUILD, the arm a person's capability never -// reaches: the answer is the digest and incarnation the issuer approves for the -// hop's principal, which is what core holds the sealed execution against. -func TestAModulePrincipalsApprovedBuildIsServed(t *testing.T) { - installDelegationBindingService(t) - sources, _ := delegationSources(t) - using := business.ModulePrincipalID("ingestservice") - - digest, incarnation, err := sources.ApprovedBuild(context.Background(), using) - - require.NoError(t, err) - require.Equal(t, executiontest.Digest("ingestservice"), digest) - require.Equal(t, uint64(1), incarnation) -} - -// A REPLACED BUILD is refused, which is the second revocation the issue names. -// The issuer approves another image for the module exercising the capability, so -// the execution it is sealed to is no longer the one the issuer approves. -func TestAReplacedBuildIsRefusedThroughTheServedSources(t *testing.T) { - installDelegationBindingService(t) - sources, _ := delegationSources(t) - verifier := verifierOver(t, "reports", sources) - require.NoError(t, mustVerify(t, verifier, sources.token)) - using := business.ModulePrincipalID("ingestservice") - - replaced := testBuildFor("a replacement image") - replaced.BuildIncarnation = 2 - workContextSingleton.seals.(*sealAnswersFake).approve(using, replaced) - - digest, incarnation, err := sources.ApprovedBuild(context.Background(), using) - require.NoError(t, err) - require.Equal(t, replaced.ImageDigest, digest) - require.Equal(t, uint64(2), incarnation) - - _, err = verifier.Verify(context.Background(), sources.token) - require.ErrorIs(t, err, workcontext.ErrRevoked) -} - // mustVerify is the "it verified a moment ago" half of every refusal test above. // Without it a test that refuses for the wrong reason — a misconfigured // verifier, an unreachable key set — reads as the revocation working. diff --git a/module/services/accounts/code/pkg/adapters/module_work_context_test.go b/module/services/accounts/code/pkg/adapters/module_work_context_test.go index 7c6f0767f..7fbe0a92e 100644 --- a/module/services/accounts/code/pkg/adapters/module_work_context_test.go +++ b/module/services/accounts/code/pkg/adapters/module_work_context_test.go @@ -12,7 +12,6 @@ import ( "accounts/pkg/business/moduleregistrytest" gen "accounts/pkg/gen/saas/accounts/v1" - "github.com/codefly-dev/core/workcontext" "github.com/stretchr/testify/require" "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" @@ -157,7 +156,7 @@ func TestMintModuleWorkContextRoundTripsToTheCallerIdentity(t *testing.T) { func TestMintModuleWorkContextIsUnavailableWithoutExecutionBinding(t *testing.T) { store := installModuleWorkContextService(t, documentsPrincipals) token := moduleToken(t, "documents") - service.SetExecutionBinding(nil, nil) + service.SetExecutionBinding(nil) installModuleWorkContextAuthority(t) resp, err := mintModuleWorkContext(t, "documents", token) @@ -167,22 +166,19 @@ func TestMintModuleWorkContextIsUnavailableWithoutExecutionBinding(t *testing.T) require.Zero(t, store.controlPlaneCalls, "nothing may be checked, signed or recorded") } -// The capability seals identity, tenant, installation and execution — never the -// grant. Sealing the grant as well would put the same authority in two places +// The capability seals identity, tenant and installation — never the grant, and +// no build: what a workload runs is deploy-time admission. Sealing the grant as well would put the same authority in two places // that disagree for a token lifetime, and an empty scope set denies at any // consumer that reads one. The module acts as itself: core carries no actor hop // for an owner acting directly. func TestStartModuleTaskSealsIdentityWithoutSealingAuthority(t *testing.T) { installModuleWorkContextAuthority(t) principalID := business.ModulePrincipalID("documents") - build := testBuildFor(principalID) - workContextSingleton.seals.(*sealAnswersFake).approve(principalID, build) _, signed, err := WorkContextSingleton().StartModuleTask(context.Background(), business.ModuleWorkContextAuthority{ PrincipalID: principalID, Tenant: moduleWorkContextTenant, InstallationID: "installation-documents", - Execution: business.ExecutionIdentity{Running: business.RunningDigest(build.ImageDigest), Incarnation: build.BuildIncarnation}, }) require.NoError(t, err) @@ -193,28 +189,6 @@ func TestStartModuleTaskSealsIdentityWithoutSealingAuthority(t *testing.T) { require.Empty(t, signed.GetAuthorityScopes()) require.Empty(t, signed.GetActorChain()) require.Equal(t, "installation-documents", signed.GetSeal().GetInstallationId()) - require.Equal(t, build.ImageDigest, signed.GetSeal().GetImageDigest()) - require.Equal(t, build.BuildIncarnation, signed.GetSeal().GetBuildIncarnation()) -} - -// A module that attests a build other than the one approved for it is minted -// nothing: core compares the attested execution against the issuer's approved -// build at the mint, so a superseded pod cannot obtain a capability sealed to the -// current one. -func TestStartModuleTaskRefusesASupersededBuild(t *testing.T) { - installModuleWorkContextAuthority(t) - principalID := business.ModulePrincipalID("documents") - workContextSingleton.seals.(*sealAnswersFake).approve(principalID, testBuildFor(principalID)) - stale := testBuildFor("a superseded generation") - - _, _, err := WorkContextSingleton().StartModuleTask(context.Background(), business.ModuleWorkContextAuthority{ - PrincipalID: principalID, - Tenant: moduleWorkContextTenant, - InstallationID: "installation-documents", - Execution: business.ExecutionIdentity{Running: business.RunningDigest(stale.ImageDigest), Incarnation: 1}, - }) - - require.ErrorIs(t, err, workcontext.ErrRevoked) } // The audit spine must never carry an issuance that did not happen: the record is @@ -312,33 +286,23 @@ func TestMintModuleWorkContextBindsTheTokenToItsOwnPrefix(t *testing.T) { // Every way a caller can fail to be the module it names, at the mint itself: // each mints nothing, records nothing, and answers a verdict — except an // unreachable API server, which is not one. -func TestMintModuleWorkContextIsBoundToTheRunningBuild(t *testing.T) { +func TestMintModuleWorkContextIsBoundToADeclaredWorkload(t *testing.T) { declared := executiontest.Workload{Namespace: "acme-prod", ServiceAccount: "module", Container: "app"} tests := map[string]struct { token func(cluster *executiontest.Cluster) string want codes.Code }{ - "a pod on an unapproved build": {func(c *executiontest.Cluster) string { - return c.Run(declared, executiontest.Image("documents-unreviewed")) - }, codes.PermissionDenied}, - "a moved tag: asked for the approved image, running another": {func(c *executiontest.Cluster) string { - token := c.Run(declared, executiontest.Image("documents")) - c.Retag(token, executiontest.Image("documents-unreviewed")) - return token - }, codes.PermissionDenied}, "a token for another ServiceAccount": {func(c *executiontest.Cluster) string { - return c.Run(executiontest.Workload{Namespace: "acme-prod", ServiceAccount: "intruder", Container: "app"}, - executiontest.Image("documents")) + return c.Run(executiontest.Workload{Namespace: "acme-prod", ServiceAccount: "intruder", Container: "app"}) }, codes.PermissionDenied}, "a token from another namespace": {func(c *executiontest.Cluster) string { - return c.Run(executiontest.Workload{Namespace: "other-tenant", ServiceAccount: "module", Container: "app"}, - executiontest.Image("documents")) + return c.Run(executiontest.Workload{Namespace: "other-tenant", ServiceAccount: "module", Container: "app"}) }, codes.PermissionDenied}, "a token minted for another audience": {func(c *executiontest.Cluster) string { - return c.RunWithAudience(declared, executiontest.Image("documents"), "vault") + return c.RunWithAudience(declared, "vault") }, codes.PermissionDenied}, "a replacement pod at the token's pod name": {func(c *executiontest.Cluster) string { - token := c.Run(declared, executiontest.Image("documents")) + token := c.Run(declared) c.Replace(token) return token }, codes.PermissionDenied}, @@ -346,7 +310,7 @@ func TestMintModuleWorkContextIsBoundToTheRunningBuild(t *testing.T) { return "documents-secret" }, codes.PermissionDenied}, "the api server is unreachable": {func(c *executiontest.Cluster) string { - token := c.Run(declared, executiontest.Image("documents")) + token := c.Run(declared) c.Unreachable = true return token }, codes.Unavailable}, diff --git a/module/services/accounts/code/pkg/adapters/solution_delivery_applied_test.go b/module/services/accounts/code/pkg/adapters/solution_delivery_applied_test.go index 4099d24f3..1e1bcf834 100644 --- a/module/services/accounts/code/pkg/adapters/solution_delivery_applied_test.go +++ b/module/services/accounts/code/pkg/adapters/solution_delivery_applied_test.go @@ -6,6 +6,7 @@ import ( "errors" "net/http" "net/http/httptest" + "strings" "testing" "time" @@ -40,15 +41,12 @@ func (a *appliedReadAuthorizer) AuthorizeAppliedObserver(_ context.Context, cred } func TestAppliedObserverReportsOnlyCommittedGeneration(t *testing.T) { - signed, err := solutionhost.ParseSigned(signedPresenceCarrier(t, "acme", "acme-prod")) - require.NoError(t, err) - document, err := solutionhost.Parse(signed.Document) - require.NoError(t, err) + document := testModuleManifest(t, 3) store := &appliedReadStore{} observer := &appliedReadAuthorizer{} service, err := business.NewService(store) require.NoError(t, err) - service.SetSolutionDelivery(nil, nil, observer, nil) + service.SetSolutionDelivery(nil, nil, observer) handler := NewSolutionDeliveryHTTPHandler(service) read := func(binding string) *httptest.ResponseRecorder { r := httptest.NewRequest(http.MethodGet, SolutionDeliveryPrefix+"applied/"+binding, nil) @@ -69,7 +67,7 @@ func TestAppliedObserverReportsOnlyCommittedGeneration(t *testing.T) { document.Removed = removed if removed { document.Generation++ - document.Workloads, document.Artifacts, document.Routes = nil, nil, nil + document.Workloads, document.Endpoints = nil, nil } wire, marshalErr := solutionhost.Marshal(document) require.NoError(t, marshalErr) @@ -87,9 +85,19 @@ func TestAppliedObserverReportsOnlyCommittedGeneration(t *testing.T) { require.NoError(t, json.Unmarshal(response.Body.Bytes(), &got)) require.Equal(t, business.SolutionDeliveryApplied{ Host: document.Host, Binding: document.Binding, Generation: document.Generation, - PresenceDigest: digest, Release: document.Release.Identity(), Removed: removed, AppliedAt: at, + Digest: digest, Release: document.Release.Identity(), Removed: removed, AppliedAt: at, }, got) } + // A newer generation received and withheld is reported with the host's + // reason: delivered is not applied. + record.Desired = &business.SolutionHostBindingGeneration{Generation: document.Generation + 1, Digest: "sha256:" + strings.Repeat("0", 64)} + record.PendingReason = "module manifest generation was rewritten" + var pending business.SolutionDeliveryApplied + response := read(document.Binding) + require.Equal(t, http.StatusOK, response.Code) + require.NoError(t, json.Unmarshal(response.Body.Bytes(), &pending)) + require.Equal(t, &business.SolutionDeliveryPending{Generation: document.Generation + 1, Reason: record.PendingReason}, pending.Pending) + record.Desired, record.PendingReason = nil, "" valid := *record.Applied for name, mutate := range map[string]func(*business.SolutionHostBindingApplied){ "malformed": func(a *business.SolutionHostBindingApplied) { a.Document = "invalid" }, @@ -130,7 +138,7 @@ func TestAppliedObserverHTTPBoundary(t *testing.T) { observer := &appliedReadAuthorizer{err: errors.New("unavailable")} service, err := business.NewService(store) require.NoError(t, err) - service.SetSolutionDelivery(nil, nil, observer, nil) + service.SetSolutionDelivery(nil, nil, observer) handler := NewSolutionDeliveryHTTPHandler(service) for _, tc := range []struct { method, path string diff --git a/module/services/accounts/code/pkg/adapters/solution_delivery_http.go b/module/services/accounts/code/pkg/adapters/solution_delivery_http.go index e09bc3069..1f0162563 100644 --- a/module/services/accounts/code/pkg/adapters/solution_delivery_http.go +++ b/module/services/accounts/code/pkg/adapters/solution_delivery_http.go @@ -10,17 +10,20 @@ import ( "accounts/pkg/business" "accounts/pkg/infra" + + "github.com/codefly-dev/core/wool" ) -// The delivery endpoint: `POST /platform/_delivery/{presence,authority,endpoints}`. +// The delivery endpoint: `POST /platform/_delivery/manifest`, one signed module +// manifest carrier per request, and `GET /platform/_delivery/applied/`. // // Mounted on ACCOUNTS rather than brokered through the gateway, and the // asymmetry with the credential mint is deliberate. `POST // /platform/_credential` is brokered because a solution runtime is an // independently deployed workload that must not reach accounts' internal -// listener. A delivery Job is not that: it is in-cluster and one of two known -// service accounts, so routing it through the edge would put accounts-audience -// tokens across the perimeter for no gain. +// listener. A delivery Job is not that: it is in-cluster and runs as its +// module's `delivery` ServiceAccount, so routing it through the edge would put +// accounts-audience tokens across the perimeter for no gain. // // `/platform/` is RESERVED for the host, and an unknown `/platform/*` path // answers 404 rather than falling through. That is a contract rather than @@ -32,6 +35,9 @@ import ( // breaks every consumer silently. const SolutionDeliveryPrefix = "/platform/_delivery/" +// SolutionDeliveryManifestPath is the one delivery write: a module manifest. +const SolutionDeliveryManifestPath = SolutionDeliveryPrefix + "manifest" + // solutionDeliveryMaxBytes bounds a carrier body. // // A DSSE envelope carries the document again alongside its signature; 1 MiB is @@ -43,7 +49,7 @@ const solutionDeliveryMaxBytes = 1 << 20 type solutionDeliveryService interface { ReceiveSolutionDelivery( - ctx context.Context, carrier business.SolutionDeliveryCarrier, credential string, + ctx context.Context, raw []byte, credential string, ) (business.SolutionDeliveryDisposition, *business.SolutionDeliveryRecord, error) ReadSolutionDeliveryApplied(context.Context, string, string) (*business.SolutionDeliveryApplied, error) } @@ -58,15 +64,15 @@ func NewSolutionDeliveryHTTPHandler(service *business.Service) http.Handler { return &SolutionDeliveryHTTPHandler{service: service} } -// solutionDeliveryReceipt is what a caller gets back. The disposition is echoed -// so a Job can log WHY it got the code it did — 200 and 202 are both success and -// mean different things, and a retry policy written against the code alone -// cannot tell a replay from a new generation. +// solutionDeliveryReceipt is what a caller gets back: the disposition and the +// generation the host now holds, or the refusal and its reason. Both successes +// answer 200 — a delivery Job and a native run treat any other code as the +// host's verdict — and the disposition says whether anything changed. type solutionDeliveryReceipt struct { - Disposition string `json:"disposition"` - DocumentID string `json:"documentId,omitempty"` + Disposition string `json:"disposition,omitempty"` + Binding string `json:"binding,omitempty"` Generation uint64 `json:"generation,omitempty"` - ContentHash string `json:"contentHash,omitempty"` + Digest string `json:"digest,omitempty"` Signer string `json:"signer,omitempty"` Error string `json:"error,omitempty"` } @@ -77,8 +83,7 @@ func (h *SolutionDeliveryHTTPHandler) ServeHTTP(w http.ResponseWriter, r *http.R return } // An unknown path under the reserved namespace is 404, never a fall-through. - kind := business.SolutionDeliveryKind(strings.TrimPrefix(r.URL.Path, SolutionDeliveryPrefix)) - if !strings.HasPrefix(r.URL.Path, SolutionDeliveryPrefix) || !kind.Valid() { + if r.URL.Path != SolutionDeliveryManifestPath { writeSolutionDeliveryError(w, http.StatusNotFound, "no such delivery endpoint") return } @@ -96,29 +101,24 @@ func (h *SolutionDeliveryHTTPHandler) ServeHTTP(w http.ResponseWriter, r *http.R return } - disposition, record, err := h.service.ReceiveSolutionDelivery( - r.Context(), - business.SolutionDeliveryCarrier{Kind: kind, Raw: body}, - r.Header.Get("Authorization"), - ) + disposition, record, err := h.service.ReceiveSolutionDelivery(r.Context(), body, r.Header.Get("Authorization")) if err != nil { status, message := solutionDeliveryStatus(err) + // Every refusal is logged with its reason as well as answered with it: + // the Job's log and this host's log name the same cause. + wool.Get(r.Context()).In("solution_delivery").Warn("module manifest delivery refused", + wool.Field("status", status), wool.Field("reason", message)) writeSolutionDeliveryError(w, status, message) return } - - // 202 for a new generation now durable, 200 for an exact replay. Both are - // success; the difference is whether anything changed, which is what a - // pipeline's own idempotency depends on knowing. - status := http.StatusAccepted - if disposition == business.SolutionDeliveryReplayed { - status = http.StatusOK - } - writeSolutionDeliveryJSON(w, status, solutionDeliveryReceipt{ + wool.Get(r.Context()).In("solution_delivery").Info("module manifest delivered", + wool.Field("disposition", string(disposition)), wool.Field("binding", record.Binding), + wool.Field("generation", record.Generation), wool.Field("signer", record.SignerIdentity)) + writeSolutionDeliveryJSON(w, http.StatusOK, solutionDeliveryReceipt{ Disposition: string(disposition), - DocumentID: record.DocumentID, + Binding: record.Binding, Generation: record.Generation, - ContentHash: record.ContentHash, + Digest: record.ContentHash, Signer: record.SignerIdentity, }) } @@ -155,56 +155,50 @@ func (h *SolutionDeliveryHTTPHandler) serveApplied(w http.ResponseWriter, r *htt // solutionDeliveryStatus maps a refusal onto its response code. // -// This mapping IS the published contract, and it is written as one function so -// the taxonomy is readable in one place rather than inferred from scattered -// returns. The organising question is not severity but **what a retry would -// change**: +// This mapping IS the published contract, written as one function so the +// taxonomy is readable in one place. The organising question is **what a retry +// would change**: // -// 400 malformed terminal — the bytes are wrong +// 400 malformed terminal — the bytes are not a canonical manifest +// 401 unattested terminal — no key this host pins signed it // 401 carrier refused terminal — the token is kubelet-projected and // re-read per request, so a refusal is a -// configuration fault and a retry asks the same -// question -// 403 not the writer terminal — wrong service account or namespace -// 403 unattested terminal — no accepted signer -// 403 domain not granted terminal — accepted signer, wrong authority -// 409 rewritten generation terminal — publish never rewrites one, so this is -// a hand-edited delivery tree -// 422 invalid document terminal — it parses and Validate refuses it -// 503 presence pending RETRYABLE — the matching presence is not applied +// configuration fault +// 403 not the carrier terminal — wrong account or namespace +// 403 domain not granted terminal — the signer or the binding speaks for +// another ownership domain +// 409 conflict terminal — a stale, rewritten or withdrawn +// generation +// 422 refused terminal — another host's manifest, or a reserved +// or held route alias +// 503 host state RETRYABLE — this host's applied record is unusable // 503 cannot verify RETRYABLE — the host could not reach the API -// server or its trust root, so nothing was checked -// -// The 401/503 split is the one that matters most and the one an earlier draft -// got wrong by grouping on HTTP class: "the review ran and refused" and "the -// review could not run" are different facts with opposite retry answers, and -// they come from different branches of the same call. +// server, so nothing was checked func solutionDeliveryStatus(err error) (int, string) { switch { - case errors.Is(err, business.ErrSolutionDeliveryNotApplied): - return http.StatusServiceUnavailable, err.Error() case errors.Is(err, business.ErrSolutionDeliveryMalformed): return http.StatusBadRequest, err.Error() + case errors.Is(err, business.ErrSolutionDeliveryUnattested): + return http.StatusUnauthorized, err.Error() case errors.Is(err, infra.ErrCarrierUnauthenticated): return http.StatusUnauthorized, err.Error() case errors.Is(err, infra.ErrCarrierNotAuthorized): return http.StatusForbidden, err.Error() - case errors.Is(err, business.ErrSolutionDeliveryUnattested): - return http.StatusForbidden, err.Error() case errors.Is(err, business.ErrSolutionDeliveryDomainNotGranted): return http.StatusForbidden, err.Error() - case errors.Is(err, business.ErrSolutionDeliveryRewritten): + case errors.Is(err, business.ErrSolutionDeliveryConflict): return http.StatusConflict, err.Error() - case errors.Is(err, business.ErrSolutionDeliveryInvalid): + case errors.Is(err, business.ErrSolutionDeliveryRefused): return http.StatusUnprocessableEntity, err.Error() + case errors.Is(err, business.ErrSolutionDeliveryHostState): + return http.StatusServiceUnavailable, err.Error() case errors.Is(err, infra.ErrKubernetesUnavailable): return http.StatusServiceUnavailable, err.Error() } - // Anything unclassified is 503 rather than 500, deliberately. An unmapped - // failure means the host does not know whether it verified the carrier, and - // the honest answer to "did you accept this?" is "ask again" rather than a - // 500 that a Job's retry policy treats as terminal. - return http.StatusServiceUnavailable, "delivery could not be completed" + // Anything unclassified is 503 rather than 500: the host does not know + // whether it verified the carrier, and the honest answer is "ask again". + // The cause is still named, so it is never invisible. + return http.StatusServiceUnavailable, "delivery could not be completed: " + err.Error() } func writeSolutionDeliveryError(w http.ResponseWriter, status int, message string) { diff --git a/module/services/accounts/code/pkg/adapters/solution_delivery_http_test.go b/module/services/accounts/code/pkg/adapters/solution_delivery_http_test.go index 01ae1864a..be2df92f3 100644 --- a/module/services/accounts/code/pkg/adapters/solution_delivery_http_test.go +++ b/module/services/accounts/code/pkg/adapters/solution_delivery_http_test.go @@ -18,30 +18,31 @@ import ( "github.com/stretchr/testify/require" ) -// The delivery endpoint's PUBLISHED contract, exercised through the handler. +// The delivery endpoint's PUBLISHED contract, exercised through the handler: +// `POST /platform/_delivery/manifest`, one signed module manifest. These +// requests go through the real ReceiveSolutionDelivery — core's verification, +// admission and alias rules — with a fake inbox and carrier authorizer, so the +// ordering and the code mapping are both under test. // -// The response-code table in `solution_delivery_http.go` is what three -// repositories agreed on, and a table in a comment is a claim until something -// drives the handler and reads the codes back. These requests go through the -// real `ReceiveSolutionDelivery` with fake collaborators, so the ordering and -// the mapping are both under test rather than the mapping alone. -// -// THE ONE DISTINCTION THESE TESTS EXIST FOR is 401 versus 503: "the review ran -// and refused" against "the review could not run". They are different facts with -// opposite retry answers, and an earlier draft of the mapping got it wrong by -// grouping on HTTP class. A test that only checked "it is an error" would have -// passed against that draft. +// The codes are what the delivery Job and a native run read: 200 for accepted +// and replayed, 400 invalid, 401/403 carrier or signer, 409 stale, rewritten or +// withdrawn, 422 refused, 503 retry. + +const ( + deliveryTestKey = "release-key" + deliveryTestDomain = "acme" + deliveryTestCoordinate = "acme-test" +) type fakeDeliveryStore struct { inserted bool err error calls int payloads [][]byte + newest []*business.SolutionDeliveryRecord } -func (f *fakeDeliveryStore) RecordDeliveredDocument( - _ context.Context, record *business.SolutionDeliveryRecord, -) (bool, error) { +func (f *fakeDeliveryStore) RecordDeliveredDocument(_ context.Context, record *business.SolutionDeliveryRecord) (bool, error) { f.calls++ f.payloads = append(f.payloads, record.Payload) if f.err != nil { @@ -50,57 +51,68 @@ func (f *fakeDeliveryStore) RecordDeliveredDocument( return f.inserted, nil } -func (f *fakeDeliveryStore) ListNewestDeliveredDocuments( - context.Context, business.SolutionDeliveryKind, -) ([]*business.SolutionDeliveryRecord, error) { - return nil, nil +func (f *fakeDeliveryStore) ListNewestDeliveredDocuments(context.Context) ([]*business.SolutionDeliveryRecord, error) { + return f.newest, nil } -func (f *fakeDeliveryStore) ListDeliveredDocuments( - context.Context, business.SolutionDeliveryKind, -) ([]*business.SolutionDeliveryRecord, error) { +func (f *fakeDeliveryStore) ReadDeliveredDocument(context.Context, string, uint64) (*business.SolutionDeliveryRecord, error) { return nil, nil } +// fakeCarrierAuthorizer records the namespace the host asked the carrier to be +// the delivery account of. type fakeCarrierAuthorizer struct { - err error - // seenNamespaces records what the document declared, because a presence - // carrier is authorised against the namespaces in the SIGNED document and - // not against host configuration. - seenNamespaces []string - calls int + err error + namespace string + calls int } -func (f *fakeCarrierAuthorizer) AuthorizeCarrier( - _ context.Context, _ string, _ business.SolutionDeliveryKind, declared []string, -) error { +func (f *fakeCarrierAuthorizer) AuthorizeCarrier(_ context.Context, _ string, namespace string) error { f.calls++ - f.seenNamespaces = declared + f.namespace = namespace return f.err } +// deliveryNoopStore satisfies business.Store with only the reads delivery +// makes: the applied records and an inline control-plane transaction. Any other +// method panics, because the embedded interface is nil. +type deliveryNoopStore struct { + business.Store + records []*business.SolutionHostBindingRecord +} + +func (deliveryNoopStore) WithControlPlane(ctx context.Context, fn func(ctx context.Context) error) error { + return fn(ctx) +} + +func (s deliveryNoopStore) ListSolutionHostBindings(context.Context) ([]*business.SolutionHostBindingRecord, error) { + return s.records, nil +} + // deliveryHarness uses real signatures with a fake inbox and carrier authorizer. type deliveryHarness struct { - handler http.Handler - verifier *solutionhost.Verifier - store *fakeDeliveryStore - carrier *fakeCarrierAuthorizer + handler http.Handler + store *fakeDeliveryStore + carrier *fakeCarrierAuthorizer } -func newDeliveryHarness(t *testing.T, signer string, domains map[string][]string) *deliveryHarness { +func newDeliveryHarness(t *testing.T, pins map[string][]string, applied ...*solutionhost.ModuleManifest) *deliveryHarness { t.Helper() - pinDomains := domains - if len(pinDomains) == 0 { - pinDomains = map[string][]string{signer: {"acme"}} + if pins == nil { + pins = map[string][]string{deliveryTestKey: {deliveryTestDomain}} } - harness := &deliveryHarness{ - verifier: deliverytest.Verifier(t, pinDomains), - store: &fakeDeliveryStore{inserted: true}, - carrier: &fakeCarrierAuthorizer{}, + harness := &deliveryHarness{store: &fakeDeliveryStore{inserted: true}, carrier: &fakeCarrierAuthorizer{}} + var records []*business.SolutionHostBindingRecord + for _, manifest := range applied { + records = append(records, appliedRecord(t, manifest)) } - service, err := business.NewService(deliveryNoopStore{}) + service, err := business.NewService(deliveryNoopStore{records: records}) + require.NoError(t, err) + service.SetSolutionDelivery(deliverytest.Verifier(t, pins), harness.store, harness.carrier) + service.SetSolutionDeliveryCoordinate(deliveryTestCoordinate) + rule, err := business.NewModuleNamespaceRule("platform-acme-staging-saas", "saas") require.NoError(t, err) - service.SetSolutionDelivery(harness.verifier, harness.store, harness.carrier, domains) + service.SetModuleNamespaceRule(rule) harness.handler = NewSolutionDeliveryHTTPHandler(service) return harness } @@ -113,294 +125,261 @@ func (h *deliveryHarness) post(path string, body []byte) *httptest.ResponseRecor return recorder } -// --------------------------------------------------------------------------- -// The surface itself -// --------------------------------------------------------------------------- +func (h *deliveryHarness) deliver(t *testing.T, manifest *solutionhost.ModuleManifest) *httptest.ResponseRecorder { + t.Helper() + return h.post(SolutionDeliveryManifestPath, signedManifest(t, manifest, deliveryTestKey)) +} -// An unknown path under the reserved namespace is 404, never a fall-through to -// some other handler. -func TestDeliveryUnknownKindIs404(t *testing.T) { - harness := newDeliveryHarness(t, "signer", nil) - for _, path := range []string{ - SolutionDeliveryPrefix + "nonsense", - SolutionDeliveryPrefix, - SolutionDeliveryPrefix + "presence/extra", - } { - recorder := harness.post(path, []byte("{}")) - require.Equal(t, http.StatusNotFound, recorder.Code, "path %s", path) +// testModuleManifest is a valid live manifest for module "reports" on the +// test host. +func testModuleManifest(t *testing.T, generation uint64) *solutionhost.ModuleManifest { + t.Helper() + manifest := &solutionhost.ModuleManifest{ + Schema: solutionhost.SchemaModuleManifestV1, Binding: "acme.test.reports", Alias: "reports", + Kind: solutionhost.KindModule, Generation: generation, + Host: solutionhost.HostTarget{Coordinate: deliveryTestCoordinate, Domain: deliveryTestDomain}, + Release: solutionhost.Release{Publisher: "acme", Name: "reports", Version: "1.0.0"}, + Workloads: []solutionhost.Workload{{Service: "reports", Container: "reports"}}, + Endpoints: []solutionhost.Endpoint{{Name: "rest", Service: "reports", API: "rest", Address: "http://reports.platform-acme-staging-reports.svc.cluster.local:8080"}}, } - require.Zero(t, harness.store.payloads, "an unknown kind must not reach the inbox") + require.NoError(t, manifest.Validate()) + return manifest } -// A GET gets 405 WITH Allow, so a probe learns the method rather than reading a -// 404 as "this host has no delivery endpoint at all". -func TestDeliveryGetIs405WithAllow(t *testing.T) { - harness := newDeliveryHarness(t, "signer", nil) - request := httptest.NewRequest(http.MethodGet, SolutionDeliveryPrefix+"presence", nil) - recorder := httptest.NewRecorder() - harness.handler.ServeHTTP(recorder, request) - - require.Equal(t, http.StatusMethodNotAllowed, recorder.Code) - require.Equal(t, http.MethodPost, recorder.Header().Get("Allow"), - "without Allow, a caller cannot tell a wrong method from a missing endpoint") +func withdrawal(t *testing.T, manifest *solutionhost.ModuleManifest, generation uint64) *solutionhost.ModuleManifest { + t.Helper() + removed := *manifest + removed.Generation, removed.Removed = generation, true + removed.Workloads, removed.Endpoints, removed.Authority, removed.Frontend = nil, nil, nil, nil + require.NoError(t, removed.Validate()) + return &removed } -// A body past the bound is 400 and never buffered whole. -func TestDeliveryOversizedBodyIs400(t *testing.T) { - harness := newDeliveryHarness(t, "signer", nil) - recorder := harness.post(SolutionDeliveryPrefix+"presence", - []byte(strings.Repeat("a", (1<<20)+1))) - - require.Equal(t, http.StatusBadRequest, recorder.Code) - require.Zero(t, harness.store.payloads, "an oversized body must not reach the inbox") +// signedManifest is the carrier delivery ships: the canonical payload and a +// DSSE envelope by keyID. +func signedManifest(t *testing.T, manifest *solutionhost.ModuleManifest, keyID string) []byte { + t.Helper() + payload, err := solutionhost.SignedPayload(manifest) + require.NoError(t, err) + return deliverytest.Carrier(t, payload, keyID) } -// --------------------------------------------------------------------------- -// The code table, refusal by refusal -// --------------------------------------------------------------------------- +// appliedRecord is the host's record of having applied manifest. +func appliedRecord(t *testing.T, manifest *solutionhost.ModuleManifest) *business.SolutionHostBindingRecord { + t.Helper() + digest, err := manifest.Digest() + require.NoError(t, err) + encoded, err := solutionhost.Marshal(manifest) + require.NoError(t, err) + return &business.SolutionHostBindingRecord{ + BindingID: manifest.Binding, HostCoordinate: manifest.Host.Coordinate, + Applied: &business.SolutionHostBindingApplied{ + SolutionHostBindingGeneration: business.SolutionHostBindingGeneration{Generation: manifest.Generation, Digest: digest, Document: string(encoded)}, + Removed: manifest.Removed, SolutionID: manifest.Alias, + Release: manifest.Release.Identity(), Domain: manifest.Host.Domain, + }, + } +} -// Malformed bytes are 400: terminal, because the bytes are wrong. -func TestDeliveryMalformedCarrierIs400(t *testing.T) { - harness := newDeliveryHarness(t, "signer", nil) - recorder := harness.post(SolutionDeliveryPrefix+"presence", []byte("not a carrier")) +type deliveryReceipt struct { + Disposition string `json:"disposition"` + Binding string `json:"binding"` + Generation uint64 `json:"generation"` + Digest string `json:"digest"` + Signer string `json:"signer"` + Error string `json:"error"` +} - require.Equal(t, http.StatusBadRequest, recorder.Code) - require.Zero(t, harness.carrier.calls, - "a carrier that does not parse is refused before the caller is authorised") +func receiptOf(t *testing.T, recorder *httptest.ResponseRecorder) deliveryReceipt { + t.Helper() + var receipt deliveryReceipt + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &receipt), recorder.Body.String()) + return receipt } -// THE SPLIT THAT MATTERS. A carrier the host refused is 401 — terminal, because -// the token is kubelet-projected and re-read per request, so a refusal is a -// configuration fault and a retry asks the same question. A carrier the host -// could not CHECK is 503 — retryable, because nothing was decided. -// -// Both in one test, because either alone is satisfied by collapsing them, which -// is exactly the mistake the mapping's comment records. -func TestCarrierRefusedIs401WhileUnreachableIs503(t *testing.T) { - carrier := signedPresenceCarrier(t, "acme", "acme-prod") - - refused := newDeliveryHarness(t, "signer", map[string][]string{"signer": {"acme"}}) - refused.carrier.err = fmt.Errorf("%w: wrong service account", infra.ErrCarrierUnauthenticated) - recorder := refused.post(SolutionDeliveryPrefix+"presence", carrier) - require.Equal(t, http.StatusUnauthorized, recorder.Code, - "the review RAN and refused: a retry asks the same question") - - unreachable := newDeliveryHarness(t, "signer", map[string][]string{"signer": {"acme"}}) - unreachable.carrier.err = fmt.Errorf("%w: dial tcp: connection refused", infra.ErrKubernetesUnavailable) - recorder = unreachable.post(SolutionDeliveryPrefix+"presence", carrier) - require.Equal(t, http.StatusServiceUnavailable, recorder.Code, - "the review COULD NOT RUN: nothing was decided, so the caller must retry") +// The surface: one write path, POST, bounded. +func TestDeliveryHasOneManifestPath(t *testing.T) { + harness := newDeliveryHarness(t, nil) + for _, path := range []string{ + SolutionDeliveryPrefix + "presence", SolutionDeliveryPrefix + "authority", SolutionDeliveryPrefix + "endpoints", + SolutionDeliveryPrefix, SolutionDeliveryManifestPath + "/extra", + } { + require.Equal(t, http.StatusNotFound, harness.post(path, []byte("{}")).Code, "path %s", path) + } + request := httptest.NewRequest(http.MethodGet, SolutionDeliveryManifestPath, nil) + recorder := httptest.NewRecorder() + harness.handler.ServeHTTP(recorder, request) + require.Equal(t, http.StatusMethodNotAllowed, recorder.Code) + require.Equal(t, http.MethodPost, recorder.Header().Get("Allow")) + require.Equal(t, http.StatusBadRequest, harness.post(SolutionDeliveryManifestPath, []byte(strings.Repeat("a", (1<<20)+1))).Code) + require.Zero(t, harness.store.calls) } -// A caller in the wrong namespace or with the wrong service account is 403: -// authenticated, and not the writer. -func TestCarrierNotAuthorizedIs403(t *testing.T) { - harness := newDeliveryHarness(t, "signer", map[string][]string{"signer": {"acme"}}) - harness.carrier.err = fmt.Errorf("%w: namespace not declared", infra.ErrCarrierNotAuthorized) +// A new generation is 200 "accepted" and an exact replay 200 "replayed": the +// delivery Job and a native run read any other code as the host's verdict. +func TestANewGenerationIsAcceptedAndAReplayIsReplayed(t *testing.T) { + manifest := testModuleManifest(t, 3) + digest, err := manifest.Digest() + require.NoError(t, err) - recorder := harness.post(SolutionDeliveryPrefix+"presence", - signedPresenceCarrier(t, "acme", "acme-prod")) - require.Equal(t, http.StatusForbidden, recorder.Code) + fresh := newDeliveryHarness(t, nil) + recorder := fresh.deliver(t, manifest) + require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String()) + receipt := receiptOf(t, recorder) + require.Equal(t, deliveryReceipt{ + Disposition: "accepted", Binding: manifest.Binding, Generation: 3, Digest: digest, Signer: deliveryTestKey, + }, receipt) + require.Equal(t, "platform-acme-staging-reports", fresh.carrier.namespace, + "the carrier is the delivery account of the module's namespace, by the cell's rule") + + replay := newDeliveryHarness(t, nil, manifest) + replay.store.inserted = false + recorder = replay.deliver(t, manifest) + require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String()) + require.Equal(t, "replayed", receiptOf(t, recorder).Disposition) } -// An unattested carrier is 403, and the signature is checked BEFORE the caller. -// -// That order is the contract: otherwise a captured carrier replayed by an -// authorised caller would be refused with a message about credentials, and an -// unauthenticated caller's bad signature with a message about signatures. The -// operator reads the wrong cause in both directions. -func TestUnattestedCarrierIs403AndIsRefusedBeforeTheCaller(t *testing.T) { - harness := newDeliveryHarness(t, "signer", map[string][]string{"signer": {"acme"}}) - *harness.verifier = solutionhost.Verifier{} - - recorder := harness.post(SolutionDeliveryPrefix+"presence", - signedPresenceCarrier(t, "acme", "acme-prod")) - require.Equal(t, http.StatusForbidden, recorder.Code) - require.Zero(t, harness.carrier.calls, - "verification precedes carrier authorisation, so the operator reads the real cause") +// A withdrawal is delivered from where its binding was delivered from: the +// previously applied manifest's module, whatever alias the withdrawal names. +func TestAWithdrawalIsCarriedFromTheAppliedModulesNamespace(t *testing.T) { + applied := testModuleManifest(t, 3) + harness := newDeliveryHarness(t, nil, applied) + removal := withdrawal(t, applied, 4) + removal.Alias = "renamed" + recorder := harness.deliver(t, removal) + require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String()) + require.Equal(t, "platform-acme-staging-reports", harness.carrier.namespace) } -// An accepted signer delivering under a domain it may not speak for is 403 — -// distinct from being unattested, because the signature is perfectly good. -func TestSignerOutsideItsDomainIs403(t *testing.T) { - harness := newDeliveryHarness(t, "signer", map[string][]string{"signer": {"other-domain"}}) - - recorder := harness.post(SolutionDeliveryPrefix+"presence", - signedPresenceCarrier(t, "acme", "acme-prod")) - require.Equal(t, http.StatusForbidden, recorder.Code) - require.Zero(t, harness.store.calls, "nothing is persisted for a domain the signer cannot claim") +// Each refusal, by its code, with the reason in the body, and nothing stored. +func TestEveryRefusalHasItsCodeAndReason(t *testing.T) { + applied := testModuleManifest(t, 3) + rewritten := testModuleManifest(t, 3) + rewritten.Release.Version = "9.9.9" + elsewhere := testModuleManifest(t, 4) + elsewhere.Host.Coordinate = "another-host" + reserved := testModuleManifest(t, 1) + reserved.Binding, reserved.Alias = "acme.test.codefly", "codefly" + tombstone := withdrawal(t, applied, 4) + invalid, err := solutionhost.SignedPayload(testModuleManifest(t, 1)) + require.NoError(t, err) + invalidPayload := strings.Replace(string(invalid), `"generation":1`, `"generation":0`, 1) + + for name, tc := range map[string]struct { + applied []*solutionhost.ModuleManifest + pins map[string][]string + body func(t *testing.T) []byte + carrier error + code int + reason string + }{ + "not a carrier": {body: func(*testing.T) []byte { return []byte("not a carrier") }, code: 400}, + "an invalid manifest, genuinely signed": { + body: func(t *testing.T) []byte { + envelope := deliverytest.Envelope(t, []byte(invalidPayload), deliveryTestKey) + carrier, err := solutionhost.MarshalSigned(&solutionhost.Signed{Document: []byte(invalidPayload), Envelope: envelope}) + require.NoError(t, err) + return carrier + }, + code: 400, reason: "invalid", + }, + "a signer no key pins": { + pins: map[string][]string{"another-key": {deliveryTestDomain}}, + body: func(t *testing.T) []byte { return signedManifest(t, testModuleManifest(t, 1), deliveryTestKey) }, + code: 401, + }, + "a signer pinned for another domain": { + pins: map[string][]string{deliveryTestKey: {"other-domain"}}, + body: func(t *testing.T) []byte { return signedManifest(t, testModuleManifest(t, 1), deliveryTestKey) }, + code: 403, + }, + "a carrier the api server refuses": { + carrier: fmt.Errorf("%w: token expired", infra.ErrCarrierUnauthenticated), code: 401, + body: func(t *testing.T) []byte { return signedManifest(t, testModuleManifest(t, 1), deliveryTestKey) }, + }, + "a carrier that is not the module's delivery account": { + carrier: fmt.Errorf("%w: platform-acme-staging-billing/delivery", infra.ErrCarrierNotAuthorized), code: 403, + body: func(t *testing.T) []byte { return signedManifest(t, testModuleManifest(t, 1), deliveryTestKey) }, + }, + "an api server that cannot answer": { + carrier: fmt.Errorf("%w: connection refused", infra.ErrKubernetesUnavailable), code: 503, + body: func(t *testing.T) []byte { return signedManifest(t, testModuleManifest(t, 1), deliveryTestKey) }, + }, + "a stale generation": { + applied: []*solutionhost.ModuleManifest{applied}, code: 409, reason: "older", + body: func(t *testing.T) []byte { return signedManifest(t, testModuleManifest(t, 2), deliveryTestKey) }, + }, + "a rewritten generation": { + applied: []*solutionhost.ModuleManifest{applied}, code: 409, reason: "rewritten", + body: func(t *testing.T) []byte { return signedManifest(t, rewritten, deliveryTestKey) }, + }, + "a withdrawn binding brought back": { + applied: []*solutionhost.ModuleManifest{tombstone}, code: 409, reason: "withdrawn", + body: func(t *testing.T) []byte { return signedManifest(t, testModuleManifest(t, 5), deliveryTestKey) }, + }, + "another host's manifest": { + applied: []*solutionhost.ModuleManifest{applied}, code: 422, reason: "another-host", + body: func(t *testing.T) []byte { return signedManifest(t, elsewhere, deliveryTestKey) }, + }, + "a reserved route alias": { + code: 422, reason: "codefly", + body: func(t *testing.T) []byte { return signedManifest(t, reserved, deliveryTestKey) }, + }, + } { + t.Run(name, func(t *testing.T) { + harness := newDeliveryHarness(t, tc.pins, tc.applied...) + harness.carrier.err = tc.carrier + recorder := harness.post(SolutionDeliveryManifestPath, tc.body(t)) + require.Equal(t, tc.code, recorder.Code, recorder.Body.String()) + receipt := receiptOf(t, recorder) + require.NotEmpty(t, receipt.Error, "every refusal names its reason") + if tc.reason != "" { + require.Contains(t, receipt.Error, tc.reason) + } + require.Zero(t, harness.store.calls, "a refused manifest is never stored") + }) + } } -// A rewritten generation is 409: publish never rewrites one, so this is a -// hand-edited delivery tree and no retry helps. -func TestRewrittenGenerationIs409(t *testing.T) { - harness := newDeliveryHarness(t, "signer", map[string][]string{"signer": {"acme"}}) - harness.store.err = fmt.Errorf("%w: generation 3", business.ErrSolutionDeliveryRewritten) - - recorder := harness.post(SolutionDeliveryPrefix+"presence", - signedPresenceCarrier(t, "acme", "acme-prod")) - require.Equal(t, http.StatusConflict, recorder.Code) +// The signature is checked before the caller, so an operator reads the real +// cause: a forged carrier is a signing problem, not a credential one. +func TestVerificationPrecedesTheCarrier(t *testing.T) { + harness := newDeliveryHarness(t, map[string][]string{"another-key": {deliveryTestDomain}}) + harness.carrier.err = infra.ErrCarrierUnauthenticated + recorder := harness.deliver(t, testModuleManifest(t, 1)) + require.Equal(t, http.StatusUnauthorized, recorder.Code) + require.Zero(t, harness.carrier.calls) } -// An UNCLASSIFIED failure is 503, not 500, and this is a deliberate choice -// rather than a default. -// -// An unmapped failure means the host does not know whether it verified the -// carrier, and the honest answer to "did you accept this?" is "ask again". A 500 -// is what a Job's retry policy treats as terminal, so it would strand a delivery -// the host might well accept on the next attempt. -func TestUnclassifiedFailureIs503NotServerError(t *testing.T) { - harness := newDeliveryHarness(t, "signer", map[string][]string{"signer": {"acme"}}) - harness.store.err = errors.New("something nobody mapped") +// A generation already in the inbox with other bytes is a rewrite even before +// it is applied, and an unclassified failure is a retry that names its cause. +func TestTheInboxDecidesUnappliedRewritesAndUnclassifiedFailuresAreRetries(t *testing.T) { + harness := newDeliveryHarness(t, nil) + harness.store.err = fmt.Errorf("%w: generation 3", business.ErrSolutionDeliveryConflict) + require.Equal(t, http.StatusConflict, harness.deliver(t, testModuleManifest(t, 3)).Code) - recorder := harness.post(SolutionDeliveryPrefix+"presence", - signedPresenceCarrier(t, "acme", "acme-prod")) + harness = newDeliveryHarness(t, nil) + harness.store.err = errors.New("something nobody mapped") + recorder := harness.deliver(t, testModuleManifest(t, 3)) require.Equal(t, http.StatusServiceUnavailable, recorder.Code) - require.NotEqual(t, http.StatusInternalServerError, recorder.Code, - "a 500 is terminal to a Job's retry policy, and the host does not know this is terminal") -} - -// --------------------------------------------------------------------------- -// Success, and the 202/200 split a pipeline's idempotency depends on -// --------------------------------------------------------------------------- - -// A new generation is 202 with a receipt; an exact replay is 200. Both are -// success, and the difference is whether anything CHANGED — which is what lets a -// publishing pipeline tell "I delivered this" from "this was already here". -func TestNewGenerationIs202AndAnExactReplayIs200(t *testing.T) { - carrier := signedPresenceCarrier(t, "acme", "acme-prod") - - fresh := newDeliveryHarness(t, "signer", map[string][]string{"signer": {"acme"}}) - fresh.store.inserted = true - recorder := fresh.post(SolutionDeliveryPrefix+"presence", carrier) - require.Equal(t, http.StatusAccepted, recorder.Code) - - // The tags are the WIRE CONTRACT, spelled here independently of the - // handler's own struct so this test pins the field names a consuming - // pipeline parses. Declared camelCase because that is what the endpoint - // emits — my first draft of this test guessed snake_case and failed, which - // is the test doing its job: a cross-repo contract that nothing spells out - // twice is a contract one side can rename silently. - var receipt struct { - Disposition string `json:"disposition"` - DocumentID string `json:"documentId"` - Generation uint64 `json:"generation"` - ContentHash string `json:"contentHash"` - Signer string `json:"signer"` - } - require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &receipt)) - require.Equal(t, string(business.SolutionDeliveryAccepted), receipt.Disposition) - require.Equal(t, "signer", receipt.Signer, "the receipt names the ATTESTED signer, not the caller") - require.Regexp(t, `^sha256:[0-9a-f]{64}$`, receipt.ContentHash, - "the receipt carries the content hash a pipeline compares to decide whether to re-deliver") - require.Equal(t, uint64(3), receipt.Generation) - require.Equal(t, "acme.test.delivery-http", receipt.DocumentID) - - replay := newDeliveryHarness(t, "signer", map[string][]string{"signer": {"acme"}}) - replay.store.inserted = false - recorder = replay.post(SolutionDeliveryPrefix+"presence", carrier) - require.Equal(t, http.StatusOK, recorder.Code) - require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &receipt)) - require.Equal(t, string(business.SolutionDeliveryReplayed), receipt.Disposition) + require.Contains(t, receiptOf(t, recorder).Error, "something nobody mapped") } -// The bundle is verified over the carrier's PAYLOAD bytes, and the caller's -// declared namespaces come from the signed document. -// -// Both are properties the handler cannot be trusted on by inspection: a verifier -// handed the whole request body, or an authoriser handed namespaces from host -// configuration, would behave identically on the happy path. -func TestVerificationCoversThePayloadAndNamespacesComeFromTheDocument(t *testing.T) { - harness := newDeliveryHarness(t, "signer", map[string][]string{"signer": {"acme"}}) - raw := signedPresenceCarrier(t, "acme", "acme-prod") - recorder := harness.post(SolutionDeliveryPrefix+"presence", raw) - require.Equal(t, http.StatusAccepted, recorder.Code) - - require.Len(t, harness.store.payloads, 1) - signed, err := solutionhost.ParseSigned(raw) - require.NoError(t, err) - require.Equal(t, []byte(signed.Document), harness.store.payloads[0]) - require.Equal(t, []string{"acme-prod"}, harness.carrier.seenNamespaces, - "the presence namespace is a property of the SIGNED document, not of host configuration") -} +// An alias moves between bindings once the leaving binding's withdrawal is +// delivered: the inbox's newest generation releases it. +func TestADeliveredWithdrawalReleasesItsAlias(t *testing.T) { + leaving := testModuleManifest(t, 3) + claimant := testModuleManifest(t, 1) + claimant.Binding = "acme.test.reports-next" -// signedPresenceCarrier builds a carrier core's ParseSigned accepts, with a -// real envelope the pinned verifier checks. -// -// Built through core's own types and `MarshalSigned` over `CanonicalBytes`, not -// a hand-written JSON literal: the signing input IS the canonical encoding, and -// a literal would drift the moment core's schema moved while still passing. -// `Validate` is asserted here so a malformed fixture fails as a fixture rather -// than as a 422 that looks like the handler refusing correctly. -func signedPresenceCarrier(t *testing.T, domain, namespace string) []byte { - t.Helper() - document := &solutionhost.SolutionHostBinding{ - Schema: solutionhost.SchemaPresenceV1, - Kind: solutionhost.KindSolution, - Binding: "acme.test.delivery-http", - Generation: 3, - OwnershipDomain: domain, - EnvelopeRevision: 1, - Host: solutionhost.HostTarget{ - Coordinate: "acme/test/eu-west-1", Component: "saas-host", - }, - Release: solutionhost.Release{ - Publisher: "acme", Name: "delivery-http", Version: "1.4.0", - Digest: solutionhost.ReleaseDigest("sha256:" + strings.Repeat("ef", 32)), - }, - Routes: []solutionhost.Route{{Alias: "delivery-http", Surface: solutionhost.SurfaceBackend}}, - Artifacts: []solutionhost.Artifact{{ - Surface: solutionhost.SurfaceBackend, - Name: "api", - Release: "acme/delivery-http@1.4.0", - Digest: solutionhost.RenderedDigest("sha256:" + strings.Repeat("ab", 32)), - }}, - Workloads: []solutionhost.Workload{{ - Name: "api", Artifact: "api", Container: "api", - Image: solutionhost.Image{ - Repository: "registry.example/acme/delivery-http", - Digest: solutionhost.ImageDigest("sha256:" + strings.Repeat("cd", 32)), - }, - // The namespace reaches the carrier authoriser from the SPIFFE ID, - // which is why there is no Namespace field to set: a workload's - // namespace is derived from its signed identity rather than - // declared beside it, so it cannot be edited without breaking the - // signature. That is the property - // TestVerificationCoversThePayloadAndNamespacesComeFromTheDocument - // asserts, and it holds because of where the value comes from. - Identity: solutionhost.WorkloadIdentity{ - Audience: "https://test.acme.example/solutions", - Subject: "system:serviceaccount:" + namespace + ":api", - SPIFFEID: "spiffe://acme.test/ns/" + namespace + "/sa/api", - }, - NonAuthenticating: &[]string{"telemetry-sidecar"}, - }}, - } - require.NoError(t, document.Validate(), "the fixture must be a valid binding") + held := newDeliveryHarness(t, nil, leaving) + recorder := held.deliver(t, claimant) + require.Equal(t, http.StatusUnprocessableEntity, recorder.Code, "the alias is still held") - canonical, err := solutionhost.SignedPayload(document) + released := newDeliveryHarness(t, nil, leaving) + payload, err := solutionhost.SignedPayload(withdrawal(t, leaving, 4)) require.NoError(t, err) - return deliverytest.Carrier(t, canonical, "signer") -} - -// deliveryNoopStore satisfies business.Store without implementing it. -// -// The embedded interface is nil, so any method this test does not override -// PANICS rather than returning a zero value — which is what caught that the -// persist path really does open a control-plane transaction. A stub returning -// zeroes would have let the write silently no-op and the 409 test would have -// passed for the wrong reason. -type deliveryNoopStore struct{ business.Store } - -// WithControlPlane runs the function inline. -// -// Overridden because the delivery write is wrapped in a control-plane -// transaction, and the point of these tests is the HTTP contract rather than -// transaction behaviour. Running it inline keeps the store error surfacing -// exactly as the real path surfaces it, which is what the 409 and the -// unclassified-failure cases depend on. -func (deliveryNoopStore) WithControlPlane( - ctx context.Context, fn func(ctx context.Context) error, -) error { - return fn(ctx) + released.store.newest = []*business.SolutionDeliveryRecord{{Binding: leaving.Binding, Generation: 4, Payload: payload}} + recorder = released.deliver(t, claimant) + require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String()) } diff --git a/module/services/accounts/code/pkg/adapters/solution_registry_server.go b/module/services/accounts/code/pkg/adapters/solution_registry_server.go index 97b9fb67b..20536b981 100644 --- a/module/services/accounts/code/pkg/adapters/solution_registry_server.go +++ b/module/services/accounts/code/pkg/adapters/solution_registry_server.go @@ -131,7 +131,6 @@ func solutionHostBindingStateProto( state := &gen.SolutionHostBindingState{ BindingId: record.BindingID, HostCoordinate: record.HostCoordinate, - HostComponent: record.HostComponent, PendingGeneration: record.PendingGeneration(), PendingReason: record.PendingReason, UpdatedAt: timestamppb.New(record.UpdatedAt), @@ -145,12 +144,11 @@ func solutionHostBindingStateProto( } if applied := record.Applied; applied != nil { state.Applied = &gen.SolutionHostBindingAppliedGeneration{ - Generation: solutionHostBindingGenerationProto(applied.SolutionHostBindingGeneration), - Removed: applied.Removed, - Routes: applied.Routes, - SolutionId: applied.SolutionID, - Release: applied.Release, - EndpointDelivery: applied.EndpointDelivery, + Generation: solutionHostBindingGenerationProto(applied.SolutionHostBindingGeneration), + Removed: applied.Removed, + Routes: applied.Routes, + SolutionId: applied.SolutionID, + Release: applied.Release, } // The observed half. A binding whose applied generation is a tombstone // still names the record it withdrew, and that record's tombstone is the diff --git a/module/services/accounts/code/pkg/adapters/source_delegation.go b/module/services/accounts/code/pkg/adapters/source_delegation.go index ad0fac8dc..2d225d659 100644 --- a/module/services/accounts/code/pkg/adapters/source_delegation.go +++ b/module/services/accounts/code/pkg/adapters/source_delegation.go @@ -196,7 +196,6 @@ func (s *WorkContextAuthorityServer) startSourceOperation( PrincipalKind: business.PrincipalKindService, DelegationID: authority.Delegation.ID, GrantedScopes: actorScopes, - Execution: attestedModuleExecution(authority.ModuleWorkContextAuthority), OperationBindingID: authority.BindingID(), }}, Replay: workcontext.ReplayIdempotent, diff --git a/module/services/accounts/code/pkg/adapters/source_delegation_binding_test.go b/module/services/accounts/code/pkg/adapters/source_delegation_binding_test.go index 07151fde0..d99973b03 100644 --- a/module/services/accounts/code/pkg/adapters/source_delegation_binding_test.go +++ b/module/services/accounts/code/pkg/adapters/source_delegation_binding_test.go @@ -13,7 +13,6 @@ import ( "google.golang.org/grpc/status" "accounts/pkg/business" - "accounts/pkg/business/executiontest" "accounts/pkg/business/moduleregistrytest" gen "accounts/pkg/gen/saas/accounts/v1" ) @@ -115,7 +114,6 @@ func TestTheReferenceArmMintsWithTheUsingModulesHopAndTheDelegationsBinding(t *t user := business.ModulePrincipalID("ingestservice") require.Equal(t, user, hop.GetPrincipalId(), "the hop names the module that uses the capability, not the delegating one") require.Equal(t, delegationID, hop.GetDelegationId()) - require.Equal(t, executiontest.Digest("ingestservice"), hop.GetImageDigest(), "core attests the using module's own build") require.Equal(t, business.SourceDelegationBindingID(delegationID, "ingestservice"), claims.GetOperationBinding().GetBindingId()) require.Equal(t, "installation-current", claims.GetSeal().GetInstallationId(), "sealed to the using module's installation") require.Equal(t, uint64(42), claims.GetAuthorizationRevision(), "the person's ordinary revision") @@ -194,6 +192,5 @@ func TestThePlainMintExercisesTheDelegationAsTheDelegatingModule(t *testing.T) { require.NoError(t, err) claims := verified.Context() require.Equal(t, business.ModulePrincipalID("docstore"), claims.GetActorChain()[0].GetPrincipalId()) - require.Equal(t, executiontest.Digest("docstore"), claims.GetActorChain()[0].GetImageDigest()) require.Equal(t, business.SourceDelegationBindingID(delegationID, "docstore"), claims.GetOperationBinding().GetBindingId()) } diff --git a/module/services/accounts/code/pkg/adapters/source_delegation_test.go b/module/services/accounts/code/pkg/adapters/source_delegation_test.go index 11a0d658e..337292716 100644 --- a/module/services/accounts/code/pkg/adapters/source_delegation_test.go +++ b/module/services/accounts/code/pkg/adapters/source_delegation_test.go @@ -10,7 +10,6 @@ import ( "connectrpc.com/connect" "accounts/pkg/business" - "accounts/pkg/business/executiontest" "accounts/pkg/business/moduleregistrytest" gen "accounts/pkg/gen/saas/accounts/v1" @@ -236,10 +235,8 @@ func TestMintSourceOperationContextSealsTheDelegation(t *testing.T) { require.Equal(t, delegationOrg, used[0].OrgID) require.Equal(t, delegationSource, used[0].ResourceID) payload := used[0].Payload - require.Equal(t, executiontest.Image("docstore"), payload["running_digest"], - "the mint names the build that obtained the delegated capability") - require.NotEmpty(t, payload["pod_uid"]) - for _, key := range []string{"namespace", "service_account", "pod", "pod_uid", "container", "running_digest", "build_incarnation"} { + require.NotEmpty(t, payload["pod_uid"], "the mint names the workload that obtained the delegated capability") + for _, key := range []string{"namespace", "service_account", "pod", "pod_uid", "container"} { delete(payload, key) } require.Equal(t, map[string]any{ diff --git a/module/services/accounts/code/pkg/adapters/work_context_core.go b/module/services/accounts/code/pkg/adapters/work_context_core.go index bd50dba35..526f2c061 100644 --- a/module/services/accounts/code/pkg/adapters/work_context_core.go +++ b/module/services/accounts/code/pkg/adapters/work_context_core.go @@ -41,7 +41,6 @@ const workContextDefaultTTL = 5 * time.Minute type workContextSealAnswers interface { WorkContextSeal(ctx context.Context, orgID, principalID, installationID string) (string, uint64, error) WorkContextPrincipalEpoch(ctx context.Context, orgID, principalID string) (uint64, error) - WorkContextApprovedBuild(ctx context.Context, orgID, principalID string) (string, uint64, error) WorkContextRevision(ctx context.Context, orgID, ownerID string) (uint64, error) WorkContextAudienceInstallation(ctx context.Context, orgID, audience string) (string, error) WorkContextOperationBinding(ctx context.Context, orgID, bindingID string) (business.WorkContextOperationBinding, error) @@ -78,17 +77,6 @@ func (s hostSealSource) PrincipalEpoch(ctx context.Context, principalID string) return epoch, err } -// ApprovedBuild translates the host's "bears no execution" into core's sentinel, -// and nothing else into it: every other error, a module principal with no -// approved build included, stays a refusal. -func (s hostSealSource) ApprovedBuild(ctx context.Context, principalID string) (string, uint64, error) { - digest, incarnation, err := s.answers.WorkContextApprovedBuild(ctx, s.tenant, principalID) - if errors.Is(err, business.ErrBearsNoExecution) { - return "", 0, workcontext.ErrNoApprovedBuild - } - return digest, incarnation, err -} - // OperationBinding resolves one operation binding by its id, exactly: today the // only kind is a source delegation used by one module // (business.SourceDelegationBindingID). The binding's principal and installation @@ -296,10 +284,6 @@ type delegationHop struct { AgentID string DelegationID string GrantedScopes []*basev0.WorkScopeV1 - // Execution is what this hop's principal is running, when it bears an - // execution — a module principal, attested from its authenticated workload. - // Empty for a person or a registered agent, which bear none. - Execution workcontext.Execution // OperationBindingID is the unit of authority this hop exercises, when it // exercises one; its revision and incarnation are read live by core. OperationBindingID string @@ -366,7 +350,6 @@ func (s *WorkContextAuthorityServer) mint(ctx context.Context, request mintReque Audience: start.Audience, ReplayPolicy: workcontext.ReplayIdempotent, TTL: ttl, - Execution: hop.Execution, // A hop naming none carries its parent's, which for the owner's // session is none. OperationBindingID: hop.OperationBindingID, @@ -389,13 +372,9 @@ func (s *WorkContextAuthorityServer) mint(ctx context.Context, request mintReque // expressed on core, which derives only by adding a hop and never extends a // window: the capability is minted afresh from its owner, and every hop is // re-derived and re-checked against live state rather than copied. -// -// lastExecution attests the last hop's execution when that hop's principal bears -// one; earlier hops cannot be attested by the caller and must bear none, so a -// chain with a module principal anywhere but last is refused by core. func (s *WorkContextAuthorityServer) remint( ctx context.Context, parent *basev0.WorkContextV1, audience, replay string, ttl time.Duration, - attenuated []*basev0.WorkScopeV1, lastExecution workcontext.Execution, session string, + attenuated []*basev0.WorkScopeV1, session string, ) (string, *basev0.WorkContextV1, error) { // The seal follows the owner. A person's capability is sealed to the // organization's installation of the audience it addresses — what an @@ -431,7 +410,6 @@ func (s *WorkContextAuthorityServer) remint( if attenuated != nil { start.AuthorityScopes = attenuated } - start.Execution = lastExecution return s.mint(ctx, mintRequest{Start: start, Session: session, Replay: replay, TTL: ttl}) } hops := make([]delegationHop, 0, len(chain)) @@ -448,7 +426,6 @@ func (s *WorkContextAuthorityServer) remint( if attenuated != nil { last.GrantedScopes = attenuated } - last.Execution = lastExecution last.OperationBindingID = parent.GetOperationBinding().GetBindingId() return s.mint(ctx, mintRequest{Start: start, Hops: hops, Session: session, Replay: replay, TTL: ttl}) } diff --git a/module/services/accounts/code/pkg/adapters/work_context_core_fixture_test.go b/module/services/accounts/code/pkg/adapters/work_context_core_fixture_test.go index b366b038a..d00de829b 100644 --- a/module/services/accounts/code/pkg/adapters/work_context_core_fixture_test.go +++ b/module/services/accounts/code/pkg/adapters/work_context_core_fixture_test.go @@ -4,8 +4,6 @@ import ( "context" "crypto/ed25519" "crypto/rand" - "crypto/sha256" - "encoding/hex" "sync" "testing" "time" @@ -16,7 +14,6 @@ import ( "github.com/stretchr/testify/require" "accounts/pkg/business" - "accounts/pkg/business/executiontest" ) // sealAnswersFake answers core's live questions — seal, epoch, execution, @@ -34,8 +31,6 @@ type sealAnswersFake struct { revisions map[string]uint64 // epochs overrides the epoch (default 1) per principal. epochs map[string]uint64 - // builds names the principals that bear an execution. - builds map[string]workcontext.Execution // unknown names principals the organization has no record of. unknown map[string]bool // unheld names installations no owner holds any more. @@ -66,7 +61,6 @@ func newSealAnswersFake(revision uint64) *sealAnswersFake { revision: revision, revisions: map[string]uint64{}, epochs: map[string]uint64{}, - builds: map[string]workcontext.Execution{}, unknown: map[string]bool{}, unheld: map[string]bool{}, uninstalled: map[string]bool{}, @@ -100,19 +94,6 @@ func (f *sealAnswersFake) WorkContextPrincipalEpoch(_ context.Context, orgID, pr return 1, nil } -func (f *sealAnswersFake) WorkContextApprovedBuild(_ context.Context, orgID, principalID string) (string, uint64, error) { - f.mu.Lock() - defer f.mu.Unlock() - f.tenants = append(f.tenants, orgID) - if f.unknown[principalID] { - return "", 0, business.ErrWorkContextPrincipalUnknown - } - if build, ok := f.builds[principalID]; ok { - return build.ImageDigest, build.BuildIncarnation, nil - } - return "", 0, business.ErrBearsNoExecution -} - func (f *sealAnswersFake) WorkContextRevision(ctx context.Context, orgID, ownerID string) (uint64, error) { if f.moduleRevision != nil { if revision, handled, err := f.moduleRevision(ctx, orgID, ownerID); handled { @@ -229,7 +210,6 @@ type testCapability struct { TTL time.Duration // Installation defaults to the fake's answer for the audience. Installation string - Execution workcontext.Execution } func mintTestCapability(t *testing.T, server *WorkContextAuthorityServer, c testCapability) (string, *basev0.WorkContextV1) { @@ -262,7 +242,6 @@ func tryMintTestCapability(server *WorkContextAuthorityServer, c testCapability) Audience: c.Audience, AuthorityScopes: c.Scopes, InstallationID: c.Installation, - Execution: c.Execution, }, Hops: c.Actors, Session: c.Session, @@ -283,15 +262,9 @@ func agentHop(principalID, delegationID string, scopes ...*basev0.WorkScopeV1) d } // moduleSealAnswers is sealAnswersFake for a test that mints module -// capabilities: every principal bindModuleExecution approved bears the build its -// pods run by default, at incarnation 1 — the same answer the fake cluster gives -// BindExecution, so core's comparison at the mint is against an independent -// statement of the same fact. +// capabilities: a module principal's revision is the business service's. func moduleSealAnswers(revision uint64) *sealAnswersFake { answers := newSealAnswersFake(revision) - for principal, grant := range moduleExecution.registry { - answers.builds[principal] = workcontext.Execution{ImageDigest: executiontest.Digest(grant.Prefix), BuildIncarnation: 1} - } answers.moduleRevision = func(ctx context.Context, orgID, ownerID string) (uint64, bool, error) { if _, declared := moduleExecution.registry[ownerID]; !declared || service == nil { return 0, false, nil @@ -302,42 +275,13 @@ func moduleSealAnswers(revision uint64) *sealAnswersFake { return answers } -// moduleAuthorityFor is the authority a business mint would resolve for prefix -// in tenant: its installation and the execution its pods run by default. -func moduleAuthorityFor(prefix, tenant string) business.ModuleWorkContextAuthority { - return business.ModuleWorkContextAuthority{ - PrincipalID: business.ModulePrincipalID(prefix), - Tenant: tenant, - InstallationID: testInstallation(prefix), - Execution: business.ExecutionIdentity{ - Running: business.RunningDigest(executiontest.Digest(prefix)), - Incarnation: 1, - }, - } -} - -// approve records that principalID bears execution. -func (f *sealAnswersFake) approve(principalID string, execution workcontext.Execution) { - f.mu.Lock() - defer f.mu.Unlock() - f.builds[principalID] = execution -} - -// testBuildFor is a well-formed image digest naming principalID's build. -func testBuildFor(principalID string) workcontext.Execution { - sum := sha256.Sum256([]byte(principalID)) - return workcontext.Execution{ImageDigest: "sha256:" + hex.EncodeToString(sum[:]), BuildIncarnation: 1} -} - // mintModuleIdentity mints principalID's own identity capability in tenant -// through the shared issuer, approving a build for it first: the token a -// composed module forwards to the capability surface. +// through the shared issuer: the token a composed module forwards to the +// capability surface. func mintModuleIdentity(t *testing.T, principalID, tenant string) string { t.Helper() - answers, ok := workContextSingleton.seals.(*sealAnswersFake) + _, ok := workContextSingleton.seals.(*sealAnswersFake) require.True(t, ok, "the shared issuer is not configured with sealAnswersFake") - build := testBuildFor(principalID) - answers.approve(principalID, build) // Sealed to the installation the live module authority answers, when the // test wires one, so the host's own sealed-versus-live comparison holds the // credential to the installation it was minted for. @@ -351,7 +295,6 @@ func mintModuleIdentity(t *testing.T, principalID, tenant string) string { PrincipalID: principalID, Tenant: tenant, InstallationID: installation, - Execution: business.ExecutionIdentity{Running: business.RunningDigest(build.ImageDigest), Incarnation: build.BuildIncarnation}, }) require.NoError(t, err) return token diff --git a/module/services/accounts/code/pkg/adapters/work_context_mcp_mint_test.go b/module/services/accounts/code/pkg/adapters/work_context_mcp_mint_test.go index ea6a8da3c..7e0366632 100644 --- a/module/services/accounts/code/pkg/adapters/work_context_mcp_mint_test.go +++ b/module/services/accounts/code/pkg/adapters/work_context_mcp_mint_test.go @@ -71,7 +71,6 @@ func TestStartTaskMintsWhatACoreVerifierReadsForAModule(t *testing.T) { require.Equal(t, renewOrgID, claims.GetTenantId()) require.Equal(t, confinedSession, claims.GetSessionId(), "the session the credential authenticated, which the gateway pairs on") require.Equal(t, testInstallation("documents"), claims.GetSeal().GetInstallationId()) - require.Empty(t, claims.GetSeal().GetImageDigest(), "a person bears no execution") require.Empty(t, claims.GetActorChain()) require.Equal(t, []*basev0.WorkScopeV1{{ResourceKind: "documents", Actions: []string{"read"}}}, claims.GetAuthorityScopes()) diff --git a/module/services/accounts/code/pkg/adapters/work_context_rpcs.go b/module/services/accounts/code/pkg/adapters/work_context_rpcs.go index 393bb6809..412931682 100644 --- a/module/services/accounts/code/pkg/adapters/work_context_rpcs.go +++ b/module/services/accounts/code/pkg/adapters/work_context_rpcs.go @@ -1004,27 +1004,15 @@ var ErrWorkContextAuthorityUnconfigured = errors.New("Work Context authority is // how long a superseded pod could hold one at most, not a session length. const moduleWorkContextTTL = 15 * time.Minute -// attestedModuleExecution is the execution a module mint attests: the digest of -// what BindExecution read off the requesting pod's container status, and the -// approved build's incarnation. Core holds it against the approved build again -// at the mint. A status naming no digest attests nothing, which core refuses for -// a principal that bears an execution. -func attestedModuleExecution(authority business.ModuleWorkContextAuthority) workcontext.Execution { - digest, ok := authority.Execution.AttestedDigest() - if !ok { - return workcontext.Execution{} - } - return workcontext.Execution{ImageDigest: digest, BuildIncarnation: authority.Execution.Incarnation} -} - // StartModuleTask mints the capability a composed module presents to the // module-facing surface, owned by the module's service principal and acting as // it directly. // // It is sealed to the installation the business mint resolved, the principal's -// producer epoch, and the execution the requesting pod is running — so a -// revoked installation, an advanced epoch or a superseded build refuses the -// capability at its next use rather than at its expiry. Every use still re-reads +// producer epoch — so a revoked installation or an advanced epoch refuses the +// capability at its next use rather than at its expiry. It seals no build: what +// a workload runs is deploy-time admission, and the mint bound the caller to a +// workload its applied manifest lists. Every use still re-reads // and intersects the live installation under an authority fence. // // This method only signs already-resolved authority. The business layer owns @@ -1041,7 +1029,6 @@ func (s *WorkContextAuthorityServer) StartModuleTask( TaskID: uuid.NewString(), Audience: ModuleWorkContextAudience, InstallationID: authority.InstallationID, - Execution: attestedModuleExecution(authority), }, Replay: workcontext.ReplayIdempotent, TTL: moduleWorkContextTTL, @@ -1088,7 +1075,6 @@ func (s *WorkContextAuthorityServer) StartModuleOperationTask( Audience: authority.Audience, AuthorityScopes: scopes, InstallationID: authority.InstallationID, - Execution: attestedModuleExecution(authority.ModuleWorkContextAuthority), }, Replay: workcontext.ReplayIdempotent, TTL: business.ModuleOperationContextTTL, @@ -1120,10 +1106,6 @@ func (s *WorkContextAuthorityServer) VerifyModuleWorkContext(ctx context.Context InstallationRevision: &revision, ProducerEpoch: &epoch, } - if seal.ImageDigest != nil && seal.BuildIncarnation != nil { - digest, incarnation := seal.GetImageDigest(), seal.GetBuildIncarnation() - sealed.ImageDigest, sealed.BuildIncarnation = &digest, &incarnation - } return business.ModuleCaller{ PrincipalID: principal, BoundOrg: claims.GetTenantId(), @@ -1186,7 +1168,7 @@ func (s *WorkContextAuthorityServer) StartRootSession( } token, signed, err := s.remint(ctx, parent.Context(), req.GetAudience(), workContextReplayPolicy(req.GetReplayPolicy()), workContextTTL(req.GetTtlSeconds()), - nil, workcontext.Execution{}, "") + nil, "") if err != nil { return nil, mapWorkContextError(err) } @@ -1212,19 +1194,16 @@ func (s *WorkContextAuthorityServer) ExchangeAudience( return nil, err } // The owner is exchanging their own capability, so it is re-issued in the - // session they authenticated with. A hop in the chain that bears an - // execution cannot be attested by an owner, so such a chain is exchanged by - // its module through the module capability surface instead. - return s.exchangeVerifiedParent(ctx, parent.Context(), actor, req, workcontext.Execution{}, callerSession(ctx)) + // session they authenticated with. + return s.exchangeVerifiedParent(ctx, parent.Context(), actor, req, callerSession(ctx)) } // exchangeVerifiedParent runs AFTER the caller's authentication and -// current-parent checks. lastExecution attests the chain's last hop when that -// hop's principal bears an execution; session is the issuer-assigned session -// the exchanged capability opens ("" for a fresh one). +// current-parent checks. session is the issuer-assigned session the exchanged +// capability opens ("" for a fresh one). func (s *WorkContextAuthorityServer) exchangeVerifiedParent( ctx context.Context, parent *basev0.WorkContextV1, actor *business.Principal, - req *gen.ExchangeWorkContextAudienceRequest, lastExecution workcontext.Execution, session string, + req *gen.ExchangeWorkContextAudienceRequest, session string, ) (*gen.IssuedWorkContext, error) { if req.GetAudience() == parent.GetAudience() { return nil, status.Error(codes.InvalidArgument, "new audience must differ from parent audience") @@ -1245,7 +1224,7 @@ func (s *WorkContextAuthorityServer) exchangeVerifiedParent( } token, signed, err := s.remint(ctx, parent, req.GetAudience(), workContextReplayPolicy(req.GetReplayPolicy()), workContextTTL(req.GetTtlSeconds()), - attenuated, lastExecution, session) + attenuated, session) if err != nil { return nil, mapWorkContextError(err) } @@ -1402,7 +1381,7 @@ func (s *WorkContextAuthorityServer) RenewWorkContext( replayPolicy = workContextReplayPolicy(req.GetReplayPolicy()) } token, signed, err := s.remint(ctx, parent, audience, replayPolicy, workContextTTL(req.GetTtlSeconds()), - attenuated, workcontext.Execution{}, "") + attenuated, "") if err != nil { return nil, mapWorkContextError(err) } diff --git a/module/services/accounts/code/pkg/adapters/work_context_solution_boundary_test.go b/module/services/accounts/code/pkg/adapters/work_context_solution_boundary_test.go index d615bbe7b..a82164279 100644 --- a/module/services/accounts/code/pkg/adapters/work_context_solution_boundary_test.go +++ b/module/services/accounts/code/pkg/adapters/work_context_solution_boundary_test.go @@ -344,9 +344,7 @@ func TestOrdinaryMintsAreUnchangedByTheBoundary(t *testing.T) { Tenant: renewOrgID, PrincipalID: renewOwnerID, InstallationID: testInstallation("runtime.operations"), - Execution: business.ExecutionIdentity{Running: business.RunningDigest(testBuildFor(renewOwnerID).ImageDigest), Incarnation: 1}, } - server.seals.(*sealAnswersFake).approve(renewOwnerID, testBuildFor(renewOwnerID)) _, moduleFirst, err := server.StartModuleTask(context.Background(), module) require.NoError(t, err) _, moduleSecond, err := server.StartModuleTask(context.Background(), module) diff --git a/module/services/accounts/code/pkg/business/audit_registry.go b/module/services/accounts/code/pkg/business/audit_registry.go index a6ac266ea..08de0ef6b 100644 --- a/module/services/accounts/code/pkg/business/audit_registry.go +++ b/module/services/accounts/code/pkg/business/audit_registry.go @@ -262,7 +262,7 @@ func optionalFields(fields []PayloadField) []PayloadField { return relaxed } -// mintExecutionFields is the execution a module mint record names (#1056), +// mintExecutionFields is the workload a module mint record names (#1056), // declared once so the mints that carry it cannot drift apart. func mintExecutionFields() []PayloadField { return []PayloadField{ @@ -271,8 +271,6 @@ func mintExecutionFields() []PayloadField { {Name: "pod", Kind: FieldString, Required: true}, {Name: "pod_uid", Kind: FieldString, Required: true}, {Name: "container", Kind: FieldString, Required: true}, - {Name: "running_digest", Kind: FieldString, Required: true}, - {Name: "build_incarnation", Kind: FieldInt, Required: true}, } } func enum(name string, values ...string) PayloadField { diff --git a/module/services/accounts/code/pkg/business/consumer_invoke_ceiling_test.go b/module/services/accounts/code/pkg/business/consumer_invoke_ceiling_test.go index 085ef16b5..4b3f0928e 100644 --- a/module/services/accounts/code/pkg/business/consumer_invoke_ceiling_test.go +++ b/module/services/accounts/code/pkg/business/consumer_invoke_ceiling_test.go @@ -4,7 +4,6 @@ import ( "context" "testing" - "github.com/codefly-dev/core/solutionhost" "github.com/stretchr/testify/require" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -124,13 +123,12 @@ func TestConsumerInvokeCeilingIsReadUnderTheNamedInstallation(t *testing.T) { } // derivedCeiling is a consumer's operation audiences DERIVED the way production -// derives them — envelope ∩ delivered authority document — not hand-written. -func derivedCeiling(t *testing.T, units ...solutionhost.AuthorityBinding) map[string]ModuleOperationAudience { +// derives them — envelope ∩ the applied manifest's request — not hand-written. +func derivedCeiling(t *testing.T, operations map[string][]string) map[string]ModuleOperationAudience { t.Helper() envelope := parseTestEnvelope(t) - document := testAuthority(t, units...) - require.NoError(t, document.ValidateAgainst(envelope.authorityEnvelopeFor(document))) - grant, omitted, err := deriveModulePrincipalGrant(testModulePrefix, envelope.Principals[testModulePrefix], document, units, testWorkload) + grant, omitted, err := deriveModulePrincipalGrant(testModulePrefix, envelope.Principals[testModulePrefix], + testManifest(t, operations), []ModuleWorkload{testWorkload}) require.NoError(t, err) require.Empty(t, omitted) return grant.OperationAudiences @@ -141,7 +139,7 @@ func derivedCeiling(t *testing.T, units ...solutionhost.AuthorityBinding) map[st // delivered unit asks profiles:read, and an audience the envelope lists that the // document never asked for is not declared. func TestTheDeclaredCeilingIsBoundedByTheEnvelopeAndTheDocument(t *testing.T) { - ceiling := derivedCeiling(t, testUnit("run", "invoke"), testUnit("run", "lookup")) + ceiling := derivedCeiling(t, map[string][]string{"invoke": {"profiles:read"}, "lookup": {"profiles:read"}}) read := []ModuleOperationScope{{ResourceKind: "profiles", Actions: []string{"read"}, ResourceIDs: []string{"standard"}}} declared, inside := ConsumerCeilingAdmits(ceiling, testProducerAudience, read) @@ -164,7 +162,7 @@ func TestTheDeclaredCeilingIsBoundedByTheEnvelopeAndTheDocument(t *testing.T) { // asking person and has nothing to look up — reaches the ceiling. It used to be // dropped by the reconciler and then refused as undeclared. func TestAnInvokeOnlyDeclarationReachesTheCeiling(t *testing.T) { - ceiling := derivedCeiling(t, testUnit("run", "invoke")) + ceiling := derivedCeiling(t, map[string][]string{"invoke": {"profiles:read"}}) declared, inside := ConsumerCeilingAdmits(ceiling, testProducerAudience, []ModuleOperationScope{{ResourceKind: "profiles", Actions: []string{"read"}, ResourceIDs: []string{"standard"}}}) diff --git a/module/services/accounts/code/pkg/business/executiontest/cluster.go b/module/services/accounts/code/pkg/business/executiontest/cluster.go index 6778a611e..760378f05 100644 --- a/module/services/accounts/code/pkg/business/executiontest/cluster.go +++ b/module/services/accounts/code/pkg/business/executiontest/cluster.go @@ -1,17 +1,14 @@ -// Package executiontest stands in for the two independent sources a module mint -// is bound by: a Kubernetes API that reviews projected ServiceAccount tokens and -// reports what each pod is running, and an authority that approves one build per -// module principal. +// Package executiontest stands in for the independent source a module mint is +// bound by: a Kubernetes API that reviews projected ServiceAccount tokens and +// reports what each pod holds. // -// It is a fake of those SOURCES, never of the check. BindExecution runs exactly +// It is a fake of that SOURCE, never of the check. BindExecution runs exactly // as it does on a host — it is handed this Cluster through SetExecutionBinding — // so a test that mints through it exercises the real comparison: the token's // audience, its ServiceAccount and namespace against the principal's declared -// workload, the live pod's UID against the token's, and the RUNNING image against -// the approved build. +// workloads, and the live pod's UID and containers against the token's. // -// What the fake owns is only what Kubernetes and the authority document would -// answer. In particular a token is valid for exactly the audience it was issued +// What the fake owns is only what Kubernetes would answer. In particular a token is valid for exactly the audience it was issued // for, as the API server holds it: Review refuses any other with // business.ErrExecutionTokenRejected, which is what a TokenReview answers. package executiontest @@ -30,12 +27,11 @@ import ( // Audience is the audience accounts reviews a module's token for. const Audience = "accounts" -// Cluster is the fake API server and approved-build authority. +// Cluster is the fake API server. type Cluster struct { - mu sync.Mutex - tokens map[string]issuedToken - pods map[podKey]*pod - approvals map[string]approval + mu sync.Mutex + tokens map[string]issuedToken + pods map[podKey]*pod // Unreachable makes every review and pod read fail as an outage, the // way an unreachable API server does. Unreachable bool @@ -60,17 +56,11 @@ type pod struct { containers []business.ContainerExecution } -type approval struct { - digest business.ApprovedDigest - incarnation uint64 -} - -// New returns an empty cluster with no pods and no approvals. +// New returns an empty cluster with no pods. func New() *Cluster { return &Cluster{ - tokens: map[string]issuedToken{}, - pods: map[podKey]*pod{}, - approvals: map[string]approval{}, + tokens: map[string]issuedToken{}, + pods: map[podKey]*pod{}, } } @@ -81,22 +71,22 @@ type Workload struct { Container string } -// Run schedules one pod for a workload whose container is RUNNING image, and -// returns the token Kubernetes projects into it for the accounts audience. -func (c *Cluster) Run(workload Workload, image string) string { - return c.RunWithAudience(workload, image, Audience) +// Run schedules one pod for a workload whose container is running, and returns +// the token Kubernetes projects into it for the accounts audience. +func (c *Cluster) Run(workload Workload) string { + return c.RunWithAudience(workload, Audience) } // RunWithAudience is Run with the projected token minted for another audience, // the way a pod's token for some other service would be. -func (c *Cluster) RunWithAudience(workload Workload, image, audience string) string { - return c.run("projected."+random(16), workload, image, audience) +func (c *Cluster) RunWithAudience(workload Workload, audience string) string { + return c.run("projected."+random(16), workload, audience) } // RunWithLifetime is Run with a projected token valid for lifetime, the way a // projection rendered with another expirationSeconds would be. -func (c *Cluster) RunWithLifetime(workload Workload, image string, lifetime time.Duration) string { - token := c.run("projected."+random(16), workload, image, Audience) +func (c *Cluster) RunWithLifetime(workload Workload, lifetime time.Duration) string { + token := c.run("projected."+random(16), workload, Audience) c.mu.Lock() defer c.mu.Unlock() issued := c.tokens[token] @@ -107,11 +97,11 @@ func (c *Cluster) RunWithLifetime(workload Workload, image string, lifetime time // RunWithToken is Run for a test that names the token itself, so a fixture // shared by many tests can hold it as a constant. -func (c *Cluster) RunWithToken(token string, workload Workload, image string) string { - return c.run(token, workload, image, Audience) +func (c *Cluster) RunWithToken(token string, workload Workload) string { + return c.run(token, workload, Audience) } -func (c *Cluster) run(token string, workload Workload, image, audience string) string { +func (c *Cluster) run(token string, workload Workload, audience string) string { c.mu.Lock() defer c.mu.Unlock() name := workload.ServiceAccount + "-" + random(4) @@ -121,7 +111,6 @@ func (c *Cluster) run(token string, workload Workload, image, audience string) s phase: business.PodPhaseRunning, containers: []business.ContainerExecution{{ Name: workload.Container, Kind: business.ContainerRegular, State: business.ContainerRunning, - ImageID: image, DeclaredImage: image, }}, } now := time.Now().UTC().Truncate(time.Second) @@ -149,7 +138,7 @@ func (c *Cluster) AddContainer(token string, container business.ContainerExecuti } // SetContainerState changes the live state of one container of the pod a token -// names, keeping its UID and image id: a stop between review and read. +// names, keeping its UID: a stop between review and read. func (c *Cluster) SetContainerState(token, name string, state business.ContainerState) { c.mu.Lock() defer c.mu.Unlock() @@ -191,26 +180,6 @@ func (c *Cluster) Replace(token string) { } } -// Retag makes the pod a token names RUN image while its spec still ASKS for -// what it asked for: a moved tag. -func (c *Cluster) Retag(token, image string) { - c.mu.Lock() - defer c.mu.Unlock() - if running := c.podOf(token); running != nil { - for i := range running.containers { - running.containers[i].ImageID = image - } - } -} - -// Approve records the build the authority document approves for a principal, -// named as the document names it: by module prefix. -func (c *Cluster) Approve(prefix, digest string, incarnation uint64) { - c.mu.Lock() - defer c.mu.Unlock() - c.approvals[prefix] = approval{digest: business.ApprovedDigest(digest), incarnation: incarnation} -} - // ReviewToken answers as a TokenReview does. func (c *Cluster) ReviewToken(_ context.Context, token, audience string) (*business.ReviewedExecutionToken, error) { c.mu.Lock() @@ -253,27 +222,6 @@ func (c *Cluster) ReadPod(_ context.Context, namespace, name string) (*business. }, nil } -// ApprovedBuild answers as the approved-build view does. -func (c *Cluster) ApprovedBuild(_ context.Context, principal string) (business.ApprovedDigest, uint64, error) { - c.mu.Lock() - defer c.mu.Unlock() - approved, ok := c.approvals[principal] - if !ok { - return "", 0, fmt.Errorf("%w: %s", business.ErrUnknownExecutionPrincipal, principal) - } - return approved.digest, approved.incarnation, nil -} - -// Image is a digest-pinned reference for a fake build, and Digest its digest. -func Image(build string) string { return "registry.example/" + build + "@" + Digest(build) } - -// Digest is a stable sha256-shaped digest for a named fake build. -func Digest(build string) string { - sum := make([]byte, 32) - copy(sum, build) - return "sha256:" + hex.EncodeToString(sum) -} - func random(n int) string { b := make([]byte, n) if _, err := rand.Read(b); err != nil { @@ -282,10 +230,7 @@ func random(n int) string { return hex.EncodeToString(b) } -var ( - _ business.ExecutionReviewer = (*Cluster)(nil) - _ business.ExecutionAuthority = (*Cluster)(nil) -) +var _ business.ExecutionReviewer = (*Cluster)(nil) // NativeDevelopment identifies this in-memory cluster fixture; it models no mesh. func (*Cluster) NativeDevelopment() bool { return true } diff --git a/module/services/accounts/code/pkg/business/installation_dynamic_test.go b/module/services/accounts/code/pkg/business/installation_dynamic_test.go index 11879e72a..7c67cf079 100644 --- a/module/services/accounts/code/pkg/business/installation_dynamic_test.go +++ b/module/services/accounts/code/pkg/business/installation_dynamic_test.go @@ -230,7 +230,7 @@ func TestDynamicInstallationJobStampCannotBeForged(t *testing.T) { func TestDynamicInstallationMintRequiresConsent(t *testing.T) { s, f, caller := dynamicInstallationFixture() grant := s.ModulePrincipals()[caller.PrincipalID] - grant.Workload = ModuleWorkload{Namespace: "example", ServiceAccount: "example", Container: "app"} + grant.Workloads = []ModuleWorkload{ModuleWorkload{Namespace: "example", ServiceAccount: "example", Container: "app"}} id := ModulePrincipalID(grant.Prefix) s.SetModulePrincipals(ModulePrincipalRegistry{id: grant}) f.rows["org-a/"+id] = f.rows["org-a/principal"] diff --git a/module/services/accounts/code/pkg/business/installation_review_test.go b/module/services/accounts/code/pkg/business/installation_review_test.go index 3c350652c..7fc05dd78 100644 --- a/module/services/accounts/code/pkg/business/installation_review_test.go +++ b/module/services/accounts/code/pkg/business/installation_review_test.go @@ -125,7 +125,7 @@ func TestInstallationFenceHoldsThroughPlacement(t *testing.T) { func TestSharedInstallationMintSelectsOnlyConsentingOrganization(t *testing.T) { s, live, caller := dynamicInstallationFixture() grant := s.ModulePrincipals()[caller.PrincipalID] - grant.Workload = ModuleWorkload{Namespace: "example", ServiceAccount: "example", Container: "app"} + grant.Workloads = []ModuleWorkload{ModuleWorkload{Namespace: "example", ServiceAccount: "example", Container: "app"}} binding := grant.OperationAudiences["operate"] binding.HeadlessScopes = cloneOperationScopes(binding.InvokeScopes) grant.OperationAudiences["operate"] = binding diff --git a/module/services/accounts/code/pkg/business/module_approved_build.go b/module/services/accounts/code/pkg/business/module_approved_build.go deleted file mode 100644 index 7d3b3c68a..000000000 --- a/module/services/accounts/code/pkg/business/module_approved_build.go +++ /dev/null @@ -1,181 +0,0 @@ -package business - -import ( - "context" - "errors" - "fmt" - "sync" -) - -// The approved build, and the monotonicity contract core states but cannot -// enforce. -// -// Core's `SealSource.ApprovedBuild` documents the rule and says plainly that it -// compares values for EQUALITY and cannot detect a source that moves backwards — -// so keeping it is the implementer's job, which means this host's. The rule has -// two clauses and the second is the one that is easy to miss: -// -// 1. for one principal, the incarnation never DECREASES; -// 2. a change of DIGEST comes with an INCREASE in the incarnation. -// -// Core's own `MemorySealSource` missed the second: approve B at incarnation 5, -// then approve A again at incarnation 5, and every capability sealed to A at 5 — -// which the move to B revoked — verifies again. A swap-back at a fixed counter -// is the rewind the rule exists to prevent, reached through the field the rule -// did not cover. -// -// WHY A GUARD RATHER THAN CARE. The rule is a property of a sequence of writes, -// so no single write can be checked against the document it came from — only -// against what this host last served. A reviewer cannot verify care, and the -// next person to add a write path does not read this comment. So the guard -// refuses the write. -// -// WHERE THE VALUES COME FROM. The approved digest is the authority document's, -// which is signed and delivered out of band — the one input to the execution -// check that the caller has no influence over. This type does not fetch it; it -// holds what the delivery pipeline resolved, and enforces the ordering rule over -// it. - -// ErrApprovedBuildRewind reports a write that would move a principal's approved -// build backwards. -// -// A source that genuinely must rewind is reconstructing state rather than -// recording it, and belongs behind a fresh instance — which is core's own -// guidance and the reason this is a refusal rather than a clamp. Clamping would -// silently serve a value the writer did not intend, and the writer would never -// learn. -var ErrApprovedBuildRewind = errors.New("approved build would move backwards for this principal") - -// ApprovedBuildRecord is one principal's approved build. -type ApprovedBuildRecord struct { - Digest ApprovedDigest - Incarnation uint64 -} - -// MonotonicApprovedBuilds enforces the contract over whatever it is given. -// -// It is deliberately NOT a cache in front of a store: a cache would serve a -// stale answer when the store moved forward, and the whole point of the -// incarnation is that a capability sealed at an older one stops verifying. It -// holds the authoritative in-process view and refuses writes that violate the -// ordering. -type MonotonicApprovedBuilds struct { - mutex sync.RWMutex - records map[string]ApprovedBuildRecord - // known separates "this principal bears no approved build" from "this - // principal is unknown". Without it the two are the same missing map entry, - // and collapsing them is the dangerous direction: an unknown principal - // would mint an unbound capability, which is a capability for an identity - // the host has never heard of. - known map[string]bool -} - -// NewMonotonicApprovedBuilds returns an empty authority. -// -// Empty means every principal is UNKNOWN, not that every principal bears no -// build — so a host that has resolved nothing refuses rather than minting -// unbound capabilities for everyone. -func NewMonotonicApprovedBuilds() *MonotonicApprovedBuilds { - return &MonotonicApprovedBuilds{ - records: map[string]ApprovedBuildRecord{}, - known: map[string]bool{}, - } -} - -// Declare records that a principal exists, bearing no approved build yet. -// -// Separate from Approve because the two are different facts. A principal -// declared but not approved is a known identity that may act unbound if the -// capability it asks for permits it; an undeclared one is nothing at all. -func (m *MonotonicApprovedBuilds) Declare(principalID string) { - m.mutex.Lock() - defer m.mutex.Unlock() - m.known[principalID] = true -} - -// Approve records a principal's approved build, enforcing both clauses. -// -// The digest is compared as a DIGEST rather than as a string, so the same -// content written as a bare `sha256:…` and as a full reference is one digest and -// not a change — otherwise a re-delivery that merely respelled the reference -// would read as a new image and demand an incarnation bump it has no reason to -// have. -func (m *MonotonicApprovedBuilds) Approve(principalID string, record ApprovedBuildRecord) error { - if principalID == "" { - return fmt.Errorf("%w: no principal named", ErrApprovedBuildRewind) - } - if _, ok := digestPortion(string(record.Digest)); !ok { - // An approval is a statement about immutable content, so a value that - // is not a digest cannot be one. Refused here as well as at the - // comparison, because a non-digest stored now is a non-digest served to - // every later check. - return fmt.Errorf("%w: %q is not a digest, and only immutable content can be approved", - ErrApprovedBuildRewind, record.Digest) - } - if record.Incarnation == 0 { - // Core's schema requires `gte=1` when the pair is present, so a zero - // incarnation would produce a seal its own validation refuses — and it - // would do so at mint time, far from here. - return fmt.Errorf("%w: incarnation must be at least 1", ErrApprovedBuildRewind) - } - - m.mutex.Lock() - defer m.mutex.Unlock() - - previous, had := m.records[principalID] - if had { - // Clause 1: never decreases. - if record.Incarnation < previous.Incarnation { - return fmt.Errorf("%w: incarnation %d is below the %d already served for %s", - ErrApprovedBuildRewind, record.Incarnation, previous.Incarnation, principalID) - } - // Clause 2, the one MemorySealSource missed: a change of digest must - // come with an INCREASE. Equal incarnations with different digests is - // exactly the swap-back, and it is the case a check on clause 1 alone - // lets through. - if record.Incarnation == previous.Incarnation && - !digestsEqual(string(record.Digest), string(previous.Digest)) { - return fmt.Errorf( - "%w: %s moves from %s to %s without advancing incarnation %d — "+ - "every capability sealed to the first digest at that incarnation would verify again", - ErrApprovedBuildRewind, principalID, - previous.Digest, record.Digest, record.Incarnation) - } - } - - m.records[principalID] = record - m.known[principalID] = true - return nil -} - -// ApprovedBuild answers what this host approves for a principal. -// -// Three states, each a different answer: -// -// - a record → the approved digest and its incarnation; -// - known, no record → ErrNoApprovedBuild. Refused by BindExecution: -// execution binding is unconditional, so there is nothing to mint. -// - unknown → ErrUnknownExecutionPrincipal. Refused. -// -// It takes NO attributes of the workload, which is core's rule and the reason it -// holds: resolving by (service account, image digest) would answer "approved" -// for whatever a pod presents, which lets a superseded generation in. The issuer -// answers what IT approves; the caller's running image is established -// independently; a mismatch is a refusal. -func (m *MonotonicApprovedBuilds) ApprovedBuild( - _ context.Context, principalID string, -) (ApprovedDigest, uint64, error) { - m.mutex.RLock() - defer m.mutex.RUnlock() - - record, had := m.records[principalID] - if had { - return record.Digest, record.Incarnation, nil - } - if m.known[principalID] { - return "", 0, ErrNoApprovedBuild - } - return "", 0, ErrUnknownExecutionPrincipal -} - -var _ ExecutionAuthority = (*MonotonicApprovedBuilds)(nil) diff --git a/module/services/accounts/code/pkg/business/module_approved_build_reconcile.go b/module/services/accounts/code/pkg/business/module_approved_build_reconcile.go deleted file mode 100644 index 71d386221..000000000 --- a/module/services/accounts/code/pkg/business/module_approved_build_reconcile.go +++ /dev/null @@ -1,362 +0,0 @@ -package business - -import ( - "context" - "errors" - "fmt" - "sort" - "sync" - - "github.com/codefly-dev/core/solutionhost" -) - -// Reconstructing the approved-build view from the delivered authority inbox, -// which is what makes execution-bound minting answerable in production. -// -// WHAT THIS CLOSES. `MonotonicApprovedBuilds` holds the authoritative view and -// enforces the ordering rule over it, and nothing wrote to it. Every principal -// was therefore UNKNOWN, which `ApprovedBuild` answers with -// ErrUnknownExecutionPrincipal — so `BindExecution` refused every caller and the -// whole execution-binding mechanism was unreachable from a running host. It was -// built, unit-tested and dead, which is the worst of the three states: a -// reviewer reads the tests and the guard and concludes the check is enforced. -// -// WHY RECONSTRUCTION RATHER THAN A WRITE PER DELIVERY. A withdrawal has to -// REMOVE an approved build, and `MonotonicApprovedBuilds` cannot: it has Approve -// and Declare and no third verb, by design — core's own guidance is that "a -// source that genuinely must rewind is reconstructing state rather than -// recording it, and belongs behind a fresh instance". So each pass builds a -// fresh view from the whole inbox and swaps it in. A tombstoned authority simply -// has no entry in the new view, and the principal goes back to UNKNOWN. -// -// AND WHY THE HIGH-WATER MARK SURVIVES THE SWAP. Applying that guidance naively -// loses the thing the guard exists for. A fresh instance holds no previous -// record, so between passes there is nothing for a rewind to be compared -// against: delivery could ship generation 4, then generation 3, and each pass -// would accept its own input as the first thing it had ever seen. The ordering -// rule is a property of the SEQUENCE OF PASSES, not of one pass, so the marks -// outlive the view they are used to build. Reconstruct the SET; keep the -// ORDERING. -// -// A TOMBSTONE MAKES A PRINCIPAL UNKNOWN, NOT UNBOUND. These are the two -// answers it would be easy to conflate, and only one of them is safe. -// `Declare` records "known, bears no execution", which mints a capability -// carrying no execution that every verifier accepts — the correct answer for a -// HUMAN session, and the wrong one for a module whose authority was withdrawn. -// So this reconciler never calls Declare: a principal is either approved for a -// build or absent, and absent is a refusal. The human path is not fed from this -// inbox at all. -// -// THE INCARNATION IS THE AUTHORITY DOCUMENT'S GENERATION, which core documents -// as "strictly monotonic per authority ID and starts at 1". That satisfies both -// clauses of the contract for free — a new approved build arrives as a new -// generation, so a change of digest necessarily advances the counter — and it -// avoids inventing a counter this host would then have to keep. -// -// PER AUTHORITY ID is the catch, and it is why the ambiguity check below exists. -// Two authority documents with different authority IDs have INDEPENDENT -// generation sequences, so if both name one principal their numbers are not -// comparable: the view would flap between them, and each flap either trips the -// rewind guard or — when the numbers happen to ascend — silently reseals the -// principal to the other document's build. A principal named by more than one -// live authority document is refused by name rather than resolved, for the same -// reason two live authority documents over one binding are. - -// ErrApprovedBuildAmbiguous reports a principal that more than one live -// authority document grants to, whose generations are therefore incomparable. -var ErrApprovedBuildAmbiguous = errors.New("more than one live authority document grants to this principal, so its approved build has no single generation") - -// ErrApprovedBuildInboxUnreadable reports that the pass could not establish the -// inbox, so no view was swapped in. -// -// Distinct from an empty view: nothing was judged. The previous view keeps -// serving, which is the same posture the presence reconciler takes for an -// unreadable mount — an unreadable inbox is not an empty desired set. -var ErrApprovedBuildInboxUnreadable = errors.New("the delivered authority inbox or the ceiling could not be established, so the approved-build view is unchanged") - -// ApprovedBuildReconciler rebuilds the approved-build view from the delivered -// authority inbox, keeping the ordering guarantee across passes. -// -// It is the ExecutionAuthority a host wires: reads go to whichever view the last -// successful pass installed, so a caller never observes a half-built one. -type ApprovedBuildReconciler struct { - activation *SolutionAuthorityActivation - - mutex sync.RWMutex - // view is what reads are served from. Never mutated in place: a pass - // builds a replacement and swaps the pointer, so a read either sees the - // whole previous pass or the whole new one. - view *MonotonicApprovedBuilds - // marks is the high-water mark per principal, and it is deliberately NOT - // part of the view. It records the highest incarnation this host has ever - // SERVED for a principal, so a later pass carrying a lower one is a - // refusal rather than a fresh start. It only grows; a principal whose - // authority is withdrawn keeps its mark, because re-delivering the - // withdrawn generation must not become acceptable again. - marks map[string]uint64 - // outcome is what the last pass established, and it affects only how an - // unestablished principal is REPORTED — never whether one is admitted. - outcome approvedBuildOutcome -} - -// NewApprovedBuildReconciler builds the reconciler over the activation reader. -// -// It takes the ACTIVATION reader rather than the delivery store directly, -// because that is the one place that re-verifies a delivered authority document -// against the host's current signer policy. Reading the store itself would make -// this a second answer to "which delivered documents does this host accept", and -// a signer removed from the allowlist would go on approving builds here after it -// had stopped being able to activate anything. -func NewApprovedBuildReconciler(activation *SolutionAuthorityActivation) (*ApprovedBuildReconciler, error) { - if activation == nil { - return nil, errors.New("the approved-build reconciler requires the authority activation reader, which owns document re-verification") - } - return &ApprovedBuildReconciler{ - activation: activation, - // An empty view, which answers ErrUnknownExecutionPrincipal for every - // principal. A host that has reconciled nothing refuses rather than - // minting unbound capabilities for everyone, and that is the state - // between process start and the first successful pass. - view: NewMonotonicApprovedBuilds(), - marks: map[string]uint64{}, - // outcome stays approvedBuildNoPassYet until RunOnce says otherwise, - // which is what lets a cold start be told apart from a principal this - // host has no declaration for. - outcome: approvedBuildNoPassYet, - }, nil -} - -// What the last reconciliation pass established, which decides how an -// unestablished principal is REPORTED (never whether it is admitted — all three -// refuse). -// -// Three states and not two, because "no answer yet" and "no question to answer" -// are different deployments: the first is a host between boot and its first -// pass, the second is a host with no ceiling delivered, which is correct and -// complete and whose principals genuinely are unknown forever. -type approvedBuildOutcome int - -const ( - approvedBuildNoPassYet approvedBuildOutcome = iota - approvedBuildNothingToAnswer - approvedBuildEstablished -) - -// ApprovedBuild serves from the installed view. -// -// The three answers are the view's: a record, ErrNoApprovedBuild for a known -// principal bearing none, ErrUnknownExecutionPrincipal otherwise. -func (r *ApprovedBuildReconciler) ApprovedBuild( - ctx context.Context, principalID string, -) (ApprovedDigest, uint64, error) { - r.mutex.RLock() - view, outcome := r.view, r.outcome - r.mutex.RUnlock() - if outcome == approvedBuildNoPassYet { - // Refused, as the empty view would have refused — but named for what it - // is. See ErrExecutionAuthorityUnreconciled. - return "", 0, fmt.Errorf("%w: %s", ErrExecutionAuthorityUnreconciled, principalID) - } - return view.ApprovedBuild(ctx, principalID) -} - -// RunOnce rebuilds the view from the inbox and installs it. -// -// AN UNREADABLE INBOX INSTALLS NOTHING. Which principals a row grants to is -// only knowable by reading it, so a row this host can no longer verify leaves -// the approved state for EVERY principal unestablished — and the one row whose -// disappearance grants something is a tombstone. Skipping it would resurrect -// the build it withdrew. So verification failure returns before anything is -// built and the previous view keeps serving. -// -// A principal the inbox describes INCOHERENTLY is a narrower failure, and it is -// handled per principal rather than per pass: see the comment at the install. -func (r *ApprovedBuildReconciler) RunOnce(ctx context.Context) error { - // A HOST THAT ANSWERS NO AUTHORITY QUESTION HAS NOTHING TO REFRESH, and - // that is a no-op rather than a failure. - // - // This returned the ceiling-unavailable error as a pass failure, and a test - // found it: every presence reconcile on a host with no envelope — or with no - // delivery inbox wired — started failing. That is precisely the coupling - // this design forbids: a host that reconciles presence with no ceiling - // written is a CORRECT, COMPLETE deployment. - // - // Nothing is hidden by returning early. The view is EMPTIED, so every - // principal is UNKNOWN and every module mint is refused. The envelope is - // append-only, so "none written" is only ever the state before the first - // write; a ceiling that exists and cannot be read is the other case, below. - envelope, err := r.activation.currentEnvelope(ctx) - if err != nil { - // The ceiling could not be established. Nothing was judged, so the - // previous view keeps serving — reading the failure as "no ceiling" - // would withdraw every approval on a transient read error, and reading - // it as the last good ceiling is what keeping the view already does. - return fmt.Errorf("%w: %w", ErrApprovedBuildInboxUnreadable, err) - } - if envelope == nil { - r.recordNothingToAnswer() - return nil - } - - var ( - live []liveVerifiedAuthority - refused []error - ) - inputs, err := r.activation.readRegistryInputs(ctx) - if err == nil { - var withdrawn map[string]string - if withdrawn, err = r.activation.withdrawnIn(inputs); err == nil { - live, refused = r.activation.liveAuthorityIn(ctx, inputs, withdrawn) - } - } - switch { - case errors.Is(err, ErrSolutionAuthorityCeilingUnavailable): - // No delivery inbox wired: the same "answers nothing" posture as no - // envelope, reached through the other half of the configuration. - r.recordNothingToAnswer() - return nil - case err != nil: - // An inbox that EXISTS and could not be read is a real failure, and the - // distinction is the whole point: a row this host can no longer verify - // leaves the approved state for every principal unestablished, and the - // one row whose disappearance grants something is a tombstone. - return fmt.Errorf("%w: %w", ErrApprovedBuildInboxUnreadable, err) - } - - // THE CEILING, before anything is approved. An authority document is - // signed, which makes it authentic and says nothing about whether what it - // claims is inside the ceiling a platform administrator wrote. Core keeps - // those as separate steps and says so — "whether it is inside a ceiling is - // ValidateAgainst" — and the clause that matters here is the last one: the - // build it is approved for must be one THE ENVELOPE approved. Without this - // call a delivery writer could approve any image at all by signing a - // document that names it, which is the entire check this view feeds. - // - inside := make([]*solutionhost.AuthorityDocument, 0, len(live)) - outside := refused - for _, one := range live { - document := one.document - if err := document.ValidateAgainst(envelope.authorityEnvelopeFor(document)); err != nil { - // Per document, not per pass: a document outside the ceiling is one - // delivery writer's problem, and the principals it names simply get - // no entry. Refusing the whole inbox over it would let any - // out-of-ceiling delivery deny every module on the host. - outside = append(outside, fmt.Errorf("authority %q generation %d is outside this host's ceiling: %w", - document.Authority, document.Generation, err)) - continue - } - inside = append(inside, document) - } - documents := inside - - // Which principals more than one live document grants to. Collected over - // the whole inbox before anything is approved, because the second document - // naming a principal is what makes the FIRST one ambiguous too — resolving - // as the rows arrive would approve from whichever happened to be read - // first. - grantedBy := map[string][]string{} - for _, document := range documents { - for _, principal := range document.Principals { - grantedBy[principal.Principal] = append(grantedBy[principal.Principal], document.Authority) - } - } - - next := NewMonotonicApprovedBuilds() - r.mutex.RLock() - // len(r.marks) INSIDE the lock. Sizing the map before taking it reads the - // map header while another pass may be assigning r.marks, which -race - // reports; RunOnce is exported and a test calling it twice concurrently - // would flake with the cause in neither test. - marks := make(map[string]uint64, len(r.marks)) - for principal, mark := range r.marks { - marks[principal] = mark - } - r.mutex.RUnlock() - - failures := outside - for _, document := range documents { - for _, principal := range document.Principals { - id := principal.Principal - if authorities := grantedBy[id]; len(authorities) > 1 { - // A COPY. Sorting in place mutates the slice the map still - // holds, during the loop that reads that map, purely to make a - // message deterministic — harmless only until the map is read - // again. - named := append([]string(nil), authorities...) - sort.Strings(named) - failures = append(failures, fmt.Errorf("%w: principal %q is granted by authorities %v", - ErrApprovedBuildAmbiguous, id, named)) - continue - } - if mark, had := marks[id]; had && document.Generation < mark { - // The rewind a fresh instance cannot see. Reported rather - // than clamped: a clamp would serve a value delivery did not - // intend and delivery would never learn. - failures = append(failures, fmt.Errorf( - "%w: authority %q delivers generation %d for principal %q, below the %d already served", - ErrApprovedBuildRewind, document.Authority, document.Generation, id, mark)) - continue - } - if err := next.Approve(id, ApprovedBuildRecord{ - Digest: ApprovedDigest(document.ApprovedBuild), - Incarnation: document.Generation, - }); err != nil { - failures = append(failures, fmt.Errorf("approve %q from authority %q generation %d: %w", - id, document.Authority, document.Generation, err)) - continue - } - marks[id] = document.Generation - } - } - - // A REFUSED PRINCIPAL IS OMITTED; THE REST INSTALL. The first version of - // this installed nothing when any principal was refused, on the argument - // that installing the coherent part of an incoherent inbox is how a - // withheld document becomes a quietly narrower system. A test found that - // wrong, and the reason is the inbox's shape: it is HOST-WIDE, so an - // authority document delivered for one solution sits in the same inbox as - // every other's. Withholding the whole pass therefore makes one solution's - // bad delivery deny service to every module on the host — a host-wide - // outage manufactured out of one unrelated document. - // - // Omitting the principal is no less fail-closed for the principal that is - // actually in doubt: it has no entry in the new view, so it reads back as - // UNKNOWN and every mint for it is refused. What it does not do is couple - // that refusal to principals whose documents say exactly one thing. The - // reasons are still returned, so an operator sees each refusal rather than - // inferring it from a principal that stopped working. - // - // The one failure that IS whole-pass is an unreadable inbox, and it is - // handled above by returning before any of this: there, nothing was - // established about any principal, so the previous view keeps serving. - r.mutex.Lock() - r.view = next - r.marks = marks - r.outcome = approvedBuildEstablished - r.mutex.Unlock() - return errors.Join(failures...) -} - -// recordNothingToAnswer notes that a pass ran against a host with no ceiling -// delivered, for reporting only. -// -// It takes no argument on purpose. The first draft took an outcome and guarded -// `!= approvedBuildNoPassYet` so a pass could never move the host BACK to -// reporting that nothing had run — and that guard was unreachable, because the -// only two callers pass approvedBuildNothingToAnswer. Mutation-verified: -// removing the guard changed no test, which is the signature of a branch no -// test can establish. It is deleted rather than kept with an untestable claim -// beside it, for the same reason the unbound-mint branch was. Monotonicity is -// now structural: approvedBuildNoPassYet is written at construction and by -// nothing else, so there is no path that regresses to it. -func (r *ApprovedBuildReconciler) recordNothingToAnswer() { - r.mutex.Lock() - defer r.mutex.Unlock() - // The view is emptied as well: a host whose ceiling is gone — or never - // written — approves nothing, and a view kept from an earlier pass would - // go on approving builds no ceiling allows. - r.view = NewMonotonicApprovedBuilds() - r.outcome = approvedBuildNothingToAnswer -} - -var _ ExecutionAuthority = (*ApprovedBuildReconciler)(nil) diff --git a/module/services/accounts/code/pkg/business/module_approved_build_test.go b/module/services/accounts/code/pkg/business/module_approved_build_test.go deleted file mode 100644 index 71854924d..000000000 --- a/module/services/accounts/code/pkg/business/module_approved_build_test.go +++ /dev/null @@ -1,191 +0,0 @@ -//go:build pure - -package business - -import ( - "context" - "testing" - - "github.com/stretchr/testify/require" -) - -// The monotonicity contract, including the clause core's own MemorySealSource -// missed. - -func digestA() ApprovedDigest { return ApprovedDigest(approvedBare) } -func digestB() ApprovedDigest { - return ApprovedDigest("sha256:" + - "3333333333333333333333333333333333333333333333333333333333333333") -} - -// Clause 1: the incarnation never decreases. -func TestApprovedBuildIncarnationNeverDecreases(t *testing.T) { - builds := NewMonotonicApprovedBuilds() - require.NoError(t, builds.Approve("p", ApprovedBuildRecord{Digest: digestA(), Incarnation: 5})) - - err := builds.Approve("p", ApprovedBuildRecord{Digest: digestB(), Incarnation: 4}) - require.ErrorIs(t, err, ErrApprovedBuildRewind) - - // The refusal did not corrupt what is served. - digest, incarnation, err := builds.ApprovedBuild(context.Background(), "p") - require.NoError(t, err) - require.Equal(t, digestA(), digest) - require.Equal(t, uint64(5), incarnation) -} - -// CLAUSE 2, and the case that matters: a swap-back at a fixed counter. -// -// Approve B at 5, then A again at 5. Clause 1 alone permits it — the incarnation -// did not decrease — and every capability sealed to A at 5, which the move to B -// revoked, verifies again. This is the rewind reached through the field the rule -// did not cover, and the reason the rule has two clauses. -func TestApprovedBuildSwapBackAtAFixedIncarnationIsRefused(t *testing.T) { - builds := NewMonotonicApprovedBuilds() - require.NoError(t, builds.Approve("p", ApprovedBuildRecord{Digest: digestA(), Incarnation: 5})) - require.NoError(t, builds.Approve("p", ApprovedBuildRecord{Digest: digestB(), Incarnation: 6})) - - // Back to A, at B's incarnation. Not a decrease, and still a rewind. - err := builds.Approve("p", ApprovedBuildRecord{Digest: digestA(), Incarnation: 6}) - require.ErrorIs(t, err, ErrApprovedBuildRewind) - require.Contains(t, err.Error(), "without advancing incarnation") - - // Back to A WITH an advance is fine: that is a real new approval. - require.NoError(t, builds.Approve("p", ApprovedBuildRecord{Digest: digestA(), Incarnation: 7})) - digest, incarnation, err := builds.ApprovedBuild(context.Background(), "p") - require.NoError(t, err) - require.Equal(t, digestA(), digest) - require.Equal(t, uint64(7), incarnation) -} - -// Re-approving the SAME digest at the same incarnation is idempotent, not a -// rewind — a re-delivery of an unchanged document must not be refused. -func TestReApprovingTheSameBuildIsIdempotent(t *testing.T) { - builds := NewMonotonicApprovedBuilds() - require.NoError(t, builds.Approve("p", ApprovedBuildRecord{Digest: digestA(), Incarnation: 5})) - require.NoError(t, builds.Approve("p", ApprovedBuildRecord{Digest: digestA(), Incarnation: 5})) -} - -// The same content written as a bare digest and as a full reference is ONE -// digest, so a respelled re-delivery is not a change demanding a bump. -func TestTheSameDigestSpelledTwoWaysIsNotAChange(t *testing.T) { - builds := NewMonotonicApprovedBuilds() - require.NoError(t, builds.Approve("p", ApprovedBuildRecord{Digest: ApprovedDigest(approvedBare), Incarnation: 5})) - require.NoError(t, builds.Approve("p", ApprovedBuildRecord{Digest: ApprovedDigest(approvedRef), Incarnation: 5}), - "a respelling of the same content is not a move to a different image") -} - -// The three states are distinct, and the distinction is the safety property. -// -// Collapsing "unknown" into "bears none" would mint an unbound capability for an -// identity the host has never heard of. -func TestApprovedBuildHasThreeDistinctStates(t *testing.T) { - builds := NewMonotonicApprovedBuilds() - - _, _, err := builds.ApprovedBuild(context.Background(), "stranger") - require.ErrorIs(t, err, ErrUnknownExecutionPrincipal) - require.NotErrorIs(t, err, ErrNoApprovedBuild) - - builds.Declare("declared") - _, _, err = builds.ApprovedBuild(context.Background(), "declared") - require.ErrorIs(t, err, ErrNoApprovedBuild) - require.NotErrorIs(t, err, ErrUnknownExecutionPrincipal) - - require.NoError(t, builds.Approve("approved", ApprovedBuildRecord{Digest: digestA(), Incarnation: 2})) - digest, incarnation, err := builds.ApprovedBuild(context.Background(), "approved") - require.NoError(t, err) - require.Equal(t, digestA(), digest) - require.Equal(t, uint64(2), incarnation) -} - -// A COLD START says "no pass has completed", not "I have never heard of this -// principal". -// -// Both refuse, so nothing is admitted either way and this is purely about what -// the operator is told. It matters because the two answers prompt opposite -// actions: "unknown principal" sends them to change a declaration that is -// already correct, while the host was simply still starting. The reconciler -// installs an empty view at construction, and an empty view answers -// ErrUnknownExecutionPrincipal for everyone — so every mint between process -// start and the first pass accused the declaration. -// -// The third state is the one that must NOT be reported this way: a host with no -// ceiling delivered answers no authority question at all, is a correct and -// complete deployment, and its principals genuinely are unknown forever. -// -// Mutation-verified, and one claim I had to withdraw: deleting the -// approvedBuildNoPassYet branch from ApprovedBuild fails the first assertion -// (it reverts to ErrUnknownExecutionPrincipal), and dropping the -// recordNothingToAnswer call from the zero-envelope early return fails the -// second (a host that answers nothing then reports "unreconciled" forever, -// which is the inverse error). The third assertion, that the state never -// regresses, is NOT verified by a mutation: I first wrote a guard in the setter -// and claimed this caught it, then found removing that guard changed no test, -// because both callers pass the same value. The guard was deleted as -// unreachable and monotonicity is structural instead — approvedBuildNoPassYet -// is written at construction and nowhere else. The assertion stays as a -// regression tripwire for a future third caller, and it is honest that nothing -// currently exercises it. -func TestAColdStartIsNotAnUnknownPrincipal(t *testing.T) { - // A zero-revision envelope: no ceiling delivered. Accepted at construction - // deliberately — see newSolutionAuthorityActivation. - reconciler, err := NewApprovedBuildReconciler(&SolutionAuthorityActivation{}) - require.NoError(t, err) - - _, _, err = reconciler.ApprovedBuild(context.Background(), "declared-principal") - require.ErrorIs(t, err, ErrExecutionAuthorityUnreconciled, - "before any pass the host has established nothing, which is not a statement about the principal") - require.NotErrorIs(t, err, ErrUnknownExecutionPrincipal, - "reporting a cold start as an unknown principal sends the operator to a declaration that is correct") - - // A pass runs. This host answers no authority question, so it establishes - // nothing — and from here "unknown" is the ACCURATE answer, because no - // ceiling will ever name this principal. - require.NoError(t, reconciler.RunOnce(context.Background())) - _, _, err = reconciler.ApprovedBuild(context.Background(), "declared-principal") - require.ErrorIs(t, err, ErrUnknownExecutionPrincipal, - "a host with no ceiling delivered is complete, and its principals are unknown rather than pending") - require.NotErrorIs(t, err, ErrExecutionAuthorityUnreconciled, - "it must not report 'no pass has completed' forever: a pass did complete and established nothing") - - // And it never regresses: a second pass cannot take the host back to - // reporting that nothing has run. - require.NoError(t, reconciler.RunOnce(context.Background())) - _, _, err = reconciler.ApprovedBuild(context.Background(), "declared-principal") - require.NotErrorIs(t, err, ErrExecutionAuthorityUnreconciled) -} - -// An empty authority means every principal is UNKNOWN, not that every principal -// bears no build — so a host that resolved nothing refuses rather than minting -// unbound capabilities for everyone. -func TestAnEmptyAuthorityRefusesRatherThanMintingUnbound(t *testing.T) { - builds := NewMonotonicApprovedBuilds() - _, _, err := builds.ApprovedBuild(context.Background(), "anyone") - require.ErrorIs(t, err, ErrUnknownExecutionPrincipal) -} - -// A non-digest cannot be approved: an approval is a statement about immutable -// content, and storing a tag now would serve a tag to every later check. -func TestANonDigestCannotBeApproved(t *testing.T) { - builds := NewMonotonicApprovedBuilds() - for _, value := range []string{"registry.example.com/acme/worker:v1", "", "latest", "sha256:short"} { - err := builds.Approve("p", ApprovedBuildRecord{Digest: ApprovedDigest(value), Incarnation: 1}) - require.ErrorIs(t, err, ErrApprovedBuildRewind, "%q must not be approvable", value) - } -} - -// Incarnation 0 is refused here rather than at mint: core's schema requires -// gte=1 when the pair is present, so a zero would produce a seal its own -// validation rejects, far from where the value was written. -func TestIncarnationZeroIsRefused(t *testing.T) { - builds := NewMonotonicApprovedBuilds() - err := builds.Approve("p", ApprovedBuildRecord{Digest: digestA(), Incarnation: 0}) - require.ErrorIs(t, err, ErrApprovedBuildRewind) -} - -// Principals are independent: one's incarnation says nothing about another's. -func TestPrincipalsAdvanceIndependently(t *testing.T) { - builds := NewMonotonicApprovedBuilds() - require.NoError(t, builds.Approve("a", ApprovedBuildRecord{Digest: digestA(), Incarnation: 9})) - require.NoError(t, builds.Approve("b", ApprovedBuildRecord{Digest: digestB(), Incarnation: 1}), - "a low incarnation for a different principal is not a rewind") -} diff --git a/module/services/accounts/code/pkg/business/module_capabilities.go b/module/services/accounts/code/pkg/business/module_capabilities.go index f7b0e2c25..58c21fac2 100644 --- a/module/services/accounts/code/pkg/business/module_capabilities.go +++ b/module/services/accounts/code/pkg/business/module_capabilities.go @@ -54,33 +54,27 @@ import ( // worker needs to service every tenant's deliveries on its queue. Tenant is the // org a single-tenant module is bound to, which its minted Work Context carries; // a cross-tenant module names the tenant per mint instead. -// ModuleWorkload is the Kubernetes identity this principal's pods run as, and -// the container within them that runs its image. +// ModuleWorkload is one Kubernetes identity this principal's pods run as, and +// the container within them that authenticates. It is the applied manifest's +// (service, container) spelled by the cell's rule: the namespace is the +// module's, the ServiceAccount the service's. // // WHY THE DECLARATION HOLDS THIS AT ALL. Execution binding is unconditional, so // `BindExecution` is the only thing authenticating a module mint — and it takes // the principal as an INPUT. On its own that leaves the principal a bare claim: // a module presenting its own perfectly valid service-account token could name -// ANOTHER module's prefix, and the check would then compare that other -// principal's approved build against this caller's running image. Two modules -// built from one image are interchangeable, and a shared base image makes the -// hole wider than that. The shared secret used to bind the prefix to the -// caller; removing it without this would have removed the binding. +// ANOTHER module's prefix and be minted that module's authority. The shared +// secret used to bind the prefix to the caller; removing it without this would +// have removed the binding. // // So the declaration says which workload a principal IS, the TokenReview says // which workload the caller is, and a mismatch is a refusal. The prefix becomes // a claim the host can check rather than one it has to believe. // -// THE CONTAINER IS DECLARED, NOT CHOSEN. It was a caller-supplied argument, and -// that is the last degree of freedom the caller had over its own identity: a -// pod's containers do not all run the same image, so naming a sibling picks -// which image the approval is tested against. Reading it from the declaration -// means nothing about the identity is the caller's to pick — the prefix is -// checked, the pod comes from the token, the container comes from here, the -// running image comes from the pod's status, and the approval comes from a -// signed document. +// THE CONTAINER IS DECLARED, NOT CHOSEN. Reading it from the manifest means +// nothing about the identity is the caller's to pick — the prefix is checked, +// the pod comes from the token, and the container comes from here. type ModuleWorkload struct { - SPIFFEID string `json:"spiffe_id"` ServiceAccount string `json:"service_account"` Namespace string `json:"namespace"` Container string `json:"container"` @@ -168,10 +162,11 @@ type ModulePrincipalGrant struct { CrossTenant bool Tenant string - // Workload is the Kubernetes identity this principal runs as. Required: - // see ModuleWorkload for why a principal without one cannot be - // authenticated at all. - Workload ModuleWorkload + // Workloads are the Kubernetes identities this principal runs as: every + // workload its applied manifest lists. At least one is required: see + // ModuleWorkload for why a principal without one cannot be authenticated + // at all. + Workloads []ModuleWorkload } func (g ModulePrincipalGrant) allowsQueue(queue string) bool { @@ -401,14 +396,22 @@ func ValidateModulePrincipalGrant(prefix string, grant ModulePrincipalGrant) err if err := validateModulePrincipalCeiling(prefix, grant); err != nil { return err } - if !grant.Workload.declared() { + if len(grant.Workloads) == 0 { return fmt.Errorf( "module principal %q must declare its workload identity (service_account, namespace, container): "+ "execution binding is unconditional, so without it the host cannot tell this principal's pods "+ "from any other caller holding a valid token", prefix) } - return grant.Workload.validateForm(prefix) + for _, workload := range grant.Workloads { + if !workload.declared() { + return fmt.Errorf("module principal %q declares a workload missing its service_account, namespace or container", prefix) + } + if err := workload.validateForm(prefix); err != nil { + return err + } + } + return nil } // ModuleCaller is the authenticated module service principal, as the RPC adapter diff --git a/module/services/accounts/code/pkg/business/module_capabilities_test.go b/module/services/accounts/code/pkg/business/module_capabilities_test.go index 24001ff09..007822a09 100644 --- a/module/services/accounts/code/pkg/business/module_capabilities_test.go +++ b/module/services/accounts/code/pkg/business/module_capabilities_test.go @@ -1733,7 +1733,7 @@ func TestAModulePrincipalMustDeclareItsWorkload(t *testing.T) { t.Fatalf("a fully declared entry must parse: %v", err) } want := business.ModuleWorkload{ServiceAccount: "documents", Namespace: "acme-prod", Container: "app"} - if got := registry[business.ModulePrincipalID("documents")].Workload; got != want { + if got := registry[business.ModulePrincipalID("documents")].Workloads; len(got) != 1 || got[0] != want { t.Fatalf("the parsed workload must be carried through verbatim, or it reads as undeclared later: got %+v want %+v", got, want) } } diff --git a/module/services/accounts/code/pkg/business/module_capability_enforcement.go b/module/services/accounts/code/pkg/business/module_capability_enforcement.go index 5985e9a6d..9e43170a5 100644 --- a/module/services/accounts/code/pkg/business/module_capability_enforcement.go +++ b/module/services/accounts/code/pkg/business/module_capability_enforcement.go @@ -328,24 +328,6 @@ type SealedModuleAuthority struct { // scopes, which is what this host did and what must go. BindingID *string BindingRevision *uint64 - // ImageDigest and BuildIncarnation are the build the credential's pod was - // bound to at its mint, which core holds against the approved build on every - // verification. Set together or not at all. - ImageDigest *string - BuildIncarnation *uint64 -} - -// SealedExecution is the execution a verified module credential was sealed to, -// for a capability derived on that module's behalf without a fresh review of -// its pod: the credential was minted from one, and core has just held its seal -// against the approved build. Empty when the credential seals none. -func (c ModuleCaller) SealedExecution() ExecutionIdentity { - if c.Sealed == nil || c.Sealed.ImageDigest == nil || c.Sealed.BuildIncarnation == nil { - return ExecutionIdentity{} - } - return ExecutionIdentity{ - PrincipalID: c.PrincipalID, Running: RunningDigest(*c.Sealed.ImageDigest), Incarnation: *c.Sealed.BuildIncarnation, - } } // ExactOperationBinding resolves the one binding a credential named. diff --git a/module/services/accounts/code/pkg/business/module_envelope.go b/module/services/accounts/code/pkg/business/module_envelope.go index 76cc9c87a..e20c6e528 100644 --- a/module/services/accounts/code/pkg/business/module_envelope.go +++ b/module/services/accounts/code/pkg/business/module_envelope.go @@ -6,15 +6,11 @@ import ( "encoding/json" "errors" "fmt" - "regexp" "slices" "strconv" - "strings" "sync" "time" - modulecontract "github.com/codefly-dev/core/contracts/module" - "github.com/codefly-dev/core/solutionhost" "github.com/codefly-dev/core/wool" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -30,40 +26,19 @@ import ( // edit and an accounts restart — a static act standing in for what the design // says is the one dynamic record a static delivery is held against. // -// WHAT IT HOLDS. Per module principal — keyed by the module's prefix, the name -// its authority document grants to — three things, and nothing else: -// -// - bindings: the WHOLE authority units this host admits for the principal — -// id (as delivery names it, ::), -// revision, scope, queue, namespace, binding key and lookup method; the -// audience is the grant's for that binding. Core's containment is exact -// element equality against these units, so a document whose unit differs -// in any field — a wider scope, another revision, another key — is OUTSIDE -// the envelope and grants nothing at all. -// - workload_identities: the workloads the principal may run as (SPIFFE ID -// and authenticating container). The workload is read from delivered -// presence, and it binds only when it is one of these, so a later presence -// generation naming another service account or container does not move -// the principal without a new envelope revision. -// - scope_ceilings: the subject module's own vocabulary this host allows it to -// declare, held whole. -// - grant: the ceiling of the registry entry the principal can be given — -// tenant, cross_tenant, queues, namespaces, external namespaces, resources, -// read and operation audiences, artifact policies. It carries NO workload: -// which pods a principal runs as is a delivered fact (the applied presence -// document), never something an administrator types. +// WHAT IT HOLDS. Per module principal — keyed by the module's principal, the +// name its manifest's authority request is made under — the GRANT: the ceiling +// of the registry entry the principal can be given (tenant, cross_tenant, +// queues, namespaces, external namespaces, resources, read and operation +// audiences, artifact policies). It carries NO workload and approves NO build: +// which workloads a principal runs as is the applied module manifest's, and +// "only approved images run" is deploy-time admission, outside this host. // // WHAT IT IS NOT. It is not the registry. The registry is DERIVED each -// reconcile pass from the live authority documents inside this ceiling and the -// applied presence they name (module_principal_reconcile.go), so a principal is -// in it only while both halves say so: the envelope allows it AND a signed, -// delivered document asks for it. -// -// ONE CEILING, NOT TWO. Core's ValidateAgainst still decides whether a document -// is inside the ceiling; it is handed the projection of this record for that -// document (authorityEnvelopeFor), so the containment rule a document is held to -// is core's, and the registry derivation then only intersects what is already -// known to be inside. +// reconcile pass from the applied module manifests and this ceiling +// (module_principal_reconcile.go): a principal is in it only while both say so — +// a signed manifest asks for it AND this envelope allows it — and it holds the +// intersection of the two, never more than either. var ( // ErrModuleEnvelopeInvalid reports a document that is not a ceiling this @@ -87,53 +62,19 @@ const maxModuleEnvelopeBytes = 1 << 20 // ModuleEnvelope is one parsed, validated revision of the ceiling. type ModuleEnvelope struct { - // Revision is this ceiling's revision, from 1. An authority document names - // the revision it was rendered against and is inside only this one. + // Revision is this ceiling's revision, from 1. Revision uint64 - // ApprovedBuilds are the OCI image manifest digests this host approves. - ApprovedBuilds []solutionhost.ImageDigest - // Principals is the ceiling per module principal, keyed by module prefix. + // Principals is the ceiling per module principal, keyed by principal. Principals map[string]ModuleEnvelopePrincipal } // ModuleEnvelopePrincipal is the ceiling for one module principal. type ModuleEnvelopePrincipal struct { - // AdmittedBindings explicitly admits a principal over these presence bindings - // without granting consumed permission units. Supplied only by the operator. - AdmittedBindings []string - // Units are the whole authority units this host admits for the principal, - // built from the envelope alone: the audience is the grant's operation - // audience for the unit's binding. They are what core's exact containment - // compares a delivered unit against, so no field of them may come from the - // document being judged. - Units []solutionhost.AuthorityBinding - // WorkloadIdentities are the workloads the principal may run as. - WorkloadIdentities []ModuleWorkloadIdentity - // ScopeCeilings are the vocabulary entries the subject module may declare. - ScopeCeilings []solutionhost.ScopeCeiling // Grant is the registry entry's ceiling. Prefix is the key it is filed - // under and Workload is always empty: the workload is derived. + // under and Workloads is always empty: the workloads are the manifest's. Grant ModulePrincipalGrant } -// ModuleWorkloadIdentity is one workload a principal may run as: the SPIFFE ID -// the presence document names for it and its authenticating container. -type ModuleWorkloadIdentity struct { - SPIFFEID string `json:"spiffe_id"` - Container string `json:"container"` -} - -// workload is the identity as the registry holds it: namespace and service -// account read from the SPIFFE ID by the one strict parser. A SPIFFE ID it -// refuses yields an empty workload, which validation refuses in turn. -func (w ModuleWorkloadIdentity) workload() ModuleWorkload { - identity, err := ParseKubernetesSPIFFEID(w.SPIFFEID) - if err != nil { - return ModuleWorkload{Container: w.Container} - } - return ModuleWorkload{SPIFFEID: w.SPIFFEID, ServiceAccount: identity.ServiceAccount, Namespace: identity.Namespace, Container: w.Container} -} - // ModuleEnvelopeRecord is one stored revision: the document as written, and who // wrote it when. type ModuleEnvelopeRecord struct { @@ -172,36 +113,18 @@ type ModuleEnvelopeSource interface { // moduleEnvelopeWire is the document's JSON shape. type moduleEnvelopeWire struct { - Revision uint64 `json:"revision"` - ApprovedBuilds []solutionhost.ImageDigest `json:"approved_builds"` - Principals map[string]moduleEnvelopePrincipalWire `json:"principals"` + Revision uint64 `json:"revision"` + Principals map[string]moduleEnvelopePrincipalWire `json:"principals"` } type moduleEnvelopePrincipalWire struct { - AdmittedBindings []string `json:"admitted_bindings,omitempty"` - Bindings []moduleEnvelopeUnitWire `json:"bindings"` - WorkloadIdentities []ModuleWorkloadIdentity `json:"workload_identities"` - ScopeCeilings []solutionhost.ScopeCeiling `json:"scope_ceilings"` - Grant moduleEnvelopeGrantWire `json:"grant"` -} - -// moduleEnvelopeUnitWire is one admitted unit as an administrator writes it. -// The audience is not here: it is the grant's operation audience for the -// unit's binding, so the two can never disagree. -type moduleEnvelopeUnitWire struct { - ID string `json:"id"` - Revision uint64 `json:"revision"` - Scope string `json:"scope"` - Queue string `json:"queue,omitempty"` - Namespace string `json:"namespace,omitempty"` - BindingKey string `json:"binding_key,omitempty"` - LookupMethod string `json:"lookup_method,omitempty"` + Grant moduleEnvelopeGrantWire `json:"grant"` } // moduleEnvelopeGrantWire is a registry entry's ceiling as an administrator // writes it. It has no `workload` key, and the decoder is strict, so an envelope // that tries to name the pods a principal runs as is refused by name rather -// than half-applied: the workload is read from delivered presence. +// than half-applied: the workloads are the applied manifest's. type moduleEnvelopeGrantWire struct { ArtifactPolicies map[string]ExecutableArtifactPolicy `json:"artifact_policies"` ReadAudiences map[string]ModuleReadAudience `json:"read_audiences"` @@ -229,84 +152,6 @@ func (wire moduleEnvelopeGrantWire) grant(prefix string) ModulePrincipalGrant { } } -// workloadOwners refuses two principals allowing one Kubernetes workload -// identity (namespace and service account). -func (e *ModuleEnvelope) workloadOwners() error { - owner := map[ModuleWorkload]string{} - prefixes := make([]string, 0, len(e.Principals)) - for prefix := range e.Principals { - prefixes = append(prefixes, prefix) - } - slices.Sort(prefixes) - for _, prefix := range prefixes { - for _, identity := range e.Principals[prefix].WorkloadIdentities { - workload := identity.workload() - key := ModuleWorkload{Namespace: workload.Namespace, ServiceAccount: workload.ServiceAccount} - if other, taken := owner[key]; taken && other != prefix { - return fmt.Errorf("module principals %q and %q both allow workload %s/%s; one workload identity maps to exactly one principal", - other, prefix, key.Namespace, key.ServiceAccount) - } - owner[key] = prefix - } - } - return nil -} - -// envelopeIDPattern is core's opaque-ID shape for authority, principal and -// binding IDs, restated because an admitted unit that core would refuse to read -// in a document can never be matched, and an entry that can never match reads -// as one that works. -var envelopeIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,254}[A-Za-z0-9]$`) - -// envelopeDigestPattern is the OCI manifest digest shape. A readable stand-in -// for a digest would approve a build that cannot exist. -var envelopeDigestPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) - -// envelopeVocabularyPattern is the module contract's name shape for a resource -// kind, and its action shape, which a scope ceiling is spelled in. -var ( - envelopeKindPattern = regexp.MustCompile(`^[a-z0-9]+(?:[._-][a-z0-9]+)*$`) - envelopeActionPattern = regexp.MustCompile(`^[a-z][a-z0-9_]*$`) -) - -// authorityOperations are the operations a unit may name, in core's vocabulary: -// the delivered unit ID ends in one of them. -var authorityOperations = []string{ - modulecontract.OperationInvoke, modulecontract.OperationLookup, modulecontract.OperationHeadless, -} - -// authorityUnit is an admitted unit ID read back into its three parts. -// -// The ID is opaque to core, which compares it and never parses it. It is NOT -// opaque here: this host is the one the ID is minted for — delivery names each -// unit `::` so the envelope can list it — -// and the host reading its own convention is what lets it refuse a unit filed -// under another presence binding, another operation audience or an operation -// that does not exist. -type authorityUnit struct { - presenceBinding string - binding string - operation string -} - -func parseAuthorityUnitID(id string) (authorityUnit, bool) { - last := strings.LastIndex(id, ":") - if last <= 0 { - return authorityUnit{}, false - } - operation := id[last+1:] - rest := id[:last] - middle := strings.LastIndex(rest, ":") - if middle <= 0 { - return authorityUnit{}, false - } - unit := authorityUnit{presenceBinding: rest[:middle], binding: rest[middle+1:], operation: operation} - if unit.binding == "" || !slices.Contains(authorityOperations, unit.operation) { - return authorityUnit{}, false - } - return unit, true -} - // ParseModuleEnvelope decodes and validates one envelope document. // // Strict at every level: an unknown key is an error, so a field this host does @@ -337,16 +182,6 @@ func ParseModuleEnvelope(raw []byte) (*ModuleEnvelope, error) { Revision: wire.Revision, Principals: make(map[string]ModuleEnvelopePrincipal, len(wire.Principals)), } - for index, build := range wire.ApprovedBuilds { - if !envelopeDigestPattern.MatchString(string(build)) { - return nil, fmt.Errorf("%w: approved build %d is %q, which is not a SHA-256 OCI image manifest digest", - ErrModuleEnvelopeInvalid, index, build) - } - if slices.Contains(envelope.ApprovedBuilds, build) { - return nil, fmt.Errorf("%w: approved build %q is listed twice", ErrModuleEnvelopeInvalid, build) - } - envelope.ApprovedBuilds = append(envelope.ApprovedBuilds, build) - } for prefix, principal := range wire.Principals { parsed, err := parseModuleEnvelopePrincipal(prefix, principal) if err != nil { @@ -354,14 +189,6 @@ func ParseModuleEnvelope(raw []byte) (*ModuleEnvelope, error) { } envelope.Principals[prefix] = parsed } - // ONE WORKLOAD, ONE PRINCIPAL. A pod's token names its namespace and - // service account — never its container — so two principals allowing the - // same (namespace, service account) would let one module's pod token - // authorize the other module's prefix. Refused by name, whatever the - // containers. - if err := envelope.workloadOwners(); err != nil { - return nil, fmt.Errorf("%w: %w", ErrModuleEnvelopeInvalid, err) - } return envelope, nil } @@ -383,215 +210,7 @@ func parseModuleEnvelopePrincipal(prefix string, wire moduleEnvelopePrincipalWir if err := validateModulePrincipalCeiling(prefix, grant); err != nil { return ModuleEnvelopePrincipal{}, err } - if wire.Bindings == nil { - return ModuleEnvelopePrincipal{}, fmt.Errorf("module principal %q must declare bindings explicitly", prefix) - } - if len(wire.Bindings) == 0 && len(wire.AdmittedBindings) == 0 { - return ModuleEnvelopePrincipal{}, fmt.Errorf("module principal %q admits no authority unit and no explicit presence binding", prefix) - } - admitted := make(map[string]bool, len(wire.AdmittedBindings)) - for _, binding := range wire.AdmittedBindings { - if err := (solutionhost.EnvelopeAdmission{Principal: prefix, PresenceBinding: binding}).Validate(); err != nil { - return ModuleEnvelopePrincipal{}, err - } - if admitted[binding] { - return ModuleEnvelopePrincipal{}, fmt.Errorf("module principal %q admits presence binding %q twice", prefix, binding) - } - admitted[binding] = true - } - seen := make(map[string]bool, len(wire.Bindings)) - units := make([]solutionhost.AuthorityBinding, 0, len(wire.Bindings)) - for _, declared := range wire.Bindings { - unit, err := parseEnvelopeUnit(prefix, grant, declared) - if err != nil { - return ModuleEnvelopePrincipal{}, err - } - if seen[unit.ID] { - return ModuleEnvelopePrincipal{}, fmt.Errorf("module principal %q admits unit %q twice", prefix, unit.ID) - } - seen[unit.ID] = true - units = append(units, unit) - } - // The workloads the principal may run as. Required: a principal whose - // workload no administrator approved would take whatever service account - // the next presence generation names. - if len(wire.WorkloadIdentities) == 0 { - return ModuleEnvelopePrincipal{}, fmt.Errorf("module principal %q allows no workload identity, so no delivered presence can ever bind it", prefix) - } - for _, identity := range wire.WorkloadIdentities { - if _, err := ParseKubernetesSPIFFEID(identity.SPIFFEID); err != nil { - return ModuleEnvelopePrincipal{}, fmt.Errorf("module principal %q allows workload identity: %w", prefix, err) - } - if err := identity.workload().validateForm(prefix); err != nil { - return ModuleEnvelopePrincipal{}, fmt.Errorf("module principal %q allows workload identity %q in container %q: %w", - prefix, identity.SPIFFEID, identity.Container, err) - } - } - if err := validateEnvelopeScopeCeilings(prefix, wire.ScopeCeilings); err != nil { - return ModuleEnvelopePrincipal{}, err - } - return ModuleEnvelopePrincipal{ - AdmittedBindings: slices.Clone(wire.AdmittedBindings), - Units: units, - WorkloadIdentities: slices.Clone(wire.WorkloadIdentities), - ScopeCeilings: slices.Clone(wire.ScopeCeilings), - Grant: grant, - }, nil -} - -// parseEnvelopeUnit builds one whole admitted unit from the envelope alone and -// holds it to the grant it sits under: its binding names an operation audience -// the grant declares, its scope is canonical and within that operation's -// scopes, and its queue and namespace are ones the grant allows. A unit the -// grant could never honour is refused here, where the administrator reads the -// reason, instead of being admitted and then clipped away. -func parseEnvelopeUnit(prefix string, grant ModulePrincipalGrant, declared moduleEnvelopeUnitWire) (solutionhost.AuthorityBinding, error) { - refuse := func(format string, args ...any) (solutionhost.AuthorityBinding, error) { - return solutionhost.AuthorityBinding{}, fmt.Errorf("module principal %q admits unit %q: "+format, - append([]any{prefix, declared.ID}, args...)...) - } - if !envelopeIDPattern.MatchString(declared.ID) { - return refuse("not a valid authority binding ID") - } - unit, ok := parseAuthorityUnitID(declared.ID) - if !ok { - return refuse("not :: with an operation of %s", strings.Join(authorityOperations, ", ")) - } - if declared.Revision == 0 { - return refuse("revision starts at 1") - } - audience, defined := grant.OperationAudiences[unit.binding] - if !defined { - return refuse("binding %q has no operation audience in the grant", unit.binding) - } - var allowed []ModuleOperationScope - switch unit.operation { - case modulecontract.OperationInvoke: - allowed = audience.InvokeScopes - case modulecontract.OperationLookup: - allowed = audience.LookupScopes - case modulecontract.OperationHeadless: - if audience.HeadlessScopes == nil { - return refuse("binding %q grants no headless scopes", unit.binding) - } - allowed = audience.HeadlessScopes - } - values := strings.Split(declared.Scope, ",") - if declared.Scope == "" || !slices.IsSorted(values) { - return refuse("scope %q is not the sorted, comma-joined kind:action values delivery writes", declared.Scope) - } - for index, value := range values { - kind, action, found := strings.Cut(value, ":") - if !found || !envelopeKindPattern.MatchString(kind) || !envelopeActionPattern.MatchString(action) || index > 0 && values[index-1] == value { - return refuse("scope value %q is not one kind:action", value) - } - if !slices.ContainsFunc(allowed, func(scope ModuleOperationScope) bool { - return scope.ResourceKind == kind && slices.Contains(scope.Actions, action) - }) { - return refuse("scope %q is wider than binding %q allows for %s", value, unit.binding, unit.operation) - } - } - if declared.Queue != "" && !slices.Contains(grant.Queues, declared.Queue) { - return refuse("queue %q is not one the grant allows", declared.Queue) - } - if declared.Namespace != "" && !slices.Contains(grant.Namespaces, declared.Namespace) { - return refuse("namespace %q is not one the grant allows", declared.Namespace) - } - for _, field := range []string{declared.BindingKey, declared.LookupMethod} { - if field != "" && !validOperationValue(field, 256) { - return refuse("binding key or lookup method %q is not a single-line value", field) - } - } - return solutionhost.AuthorityBinding{ - ID: declared.ID, Revision: declared.Revision, Audience: audience.Audience, Scope: declared.Scope, - Queue: declared.Queue, Namespace: declared.Namespace, - BindingKey: declared.BindingKey, LookupMethod: declared.LookupMethod, - }, nil -} - -func validateEnvelopeScopeCeilings(prefix string, ceilings []solutionhost.ScopeCeiling) error { - kinds := make(map[string]bool, len(ceilings)) - for _, ceiling := range ceilings { - if !envelopeKindPattern.MatchString(ceiling.ResourceKind) { - return fmt.Errorf("module principal %q declares a scope ceiling on %q, which is not a resource kind", prefix, ceiling.ResourceKind) - } - if kinds[ceiling.ResourceKind] { - return fmt.Errorf("module principal %q declares two scope ceilings on %q; a ceiling is held whole, so one kind has one", prefix, ceiling.ResourceKind) - } - kinds[ceiling.ResourceKind] = true - if len(ceiling.Actions) == 0 { - return fmt.Errorf("module principal %q declares a scope ceiling on %q with no action, which bounds nothing while reading as a bound", prefix, ceiling.ResourceKind) - } - actions := make(map[string]bool, len(ceiling.Actions)) - for _, action := range ceiling.Actions { - if !envelopeActionPattern.MatchString(action) || actions[action] { - return fmt.Errorf("module principal %q declares scope ceiling %q action %q, which is malformed or repeated", prefix, ceiling.ResourceKind, action) - } - actions[action] = true - } - } - return nil -} - -// unitsOver are the units this principal's ceiling admits over one presence -// binding: the units filed under it, so a unit admitted for one instance of a -// module is never offered to a document over another. -func (p ModuleEnvelopePrincipal) unitsOver(presenceBinding string) []solutionhost.AuthorityBinding { - var over []solutionhost.AuthorityBinding - for _, unit := range p.Units { - if parsed, ok := parseAuthorityUnitID(unit.ID); ok && parsed.presenceBinding == presenceBinding { - over = append(over, unit) - } - } - return over -} - -// authorityEnvelopeFor projects this ceiling into core's Envelope for ONE -// authority document, which core's ValidateAgainst then holds the document to. -// -// EVERY FIELD OF EVERY GRANT COMES FROM THE ENVELOPE. Core's containment is -// exact element equality between the document's (principal, unit) pairs and -// the envelope's, so the envelope side must be the host's own: the units the -// administrator admitted, whole — id, revision, audience, scope, queue, -// namespace, binding key, lookup method. A document whose unit differs in any -// of those is outside and grants nothing. The first version appended the -// DOCUMENT'S units to this list once their id was admitted, which made the -// ceiling partly the document's own — a wider scope or another revision -// compared equal to itself. -// -// Per document, too: the units are those filed under the document's presence -// binding, for the principals the document grants to, and the queues, -// namespaces and scope ceilings are those principals', never the host's union. -func (e *ModuleEnvelope) authorityEnvelopeFor(document *solutionhost.AuthorityDocument) solutionhost.Envelope { - if e == nil { - return solutionhost.Envelope{} - } - envelope := solutionhost.Envelope{ - Revision: e.Revision, - ApprovedBuilds: slices.Clone(e.ApprovedBuilds), - Queues: []string{}, - Namespaces: []string{}, - ScopeCeilings: []solutionhost.ScopeCeiling{}, - } - if document == nil { - return envelope - } - for _, principal := range document.Principals { - entry, declared := e.Principals[principal.Principal] - if !declared { - continue - } - for _, binding := range entry.AdmittedBindings { - envelope.Admissions = append(envelope.Admissions, solutionhost.EnvelopeAdmission{Principal: principal.Principal, PresenceBinding: binding}) - } - envelope.Queues = append(envelope.Queues, entry.Grant.Queues...) - envelope.Namespaces = append(envelope.Namespaces, entry.Grant.Namespaces...) - envelope.ScopeCeilings = append(envelope.ScopeCeilings, entry.ScopeCeilings...) - for _, unit := range entry.unitsOver(document.PresenceBinding) { - envelope.Grants = append(envelope.Grants, solutionhost.EnvelopeGrant{Principal: principal.Principal, Binding: unit}) - } - } - return envelope + return ModuleEnvelopePrincipal{Grant: grant}, nil } // SetModuleEnvelopeStore wires the envelope's persistence. Boot wiring. @@ -741,7 +360,7 @@ func (s *Service) PutModuleEnvelope( } // The policy log witnesses NARROWINGS — "a narrowing nothing witnessed is // one a restore undoes silently". A write that only grants (a first - // envelope, a build or a principal added, nothing an installation loses) + // envelope, a principal added, nothing an installation loses) // is not one: a restore that undoes it leaves less authority, which is the // fail-closed direction, so it is applied and audited without a witness. // Anything that removes or changes what was granted is witnessed as today. @@ -872,15 +491,6 @@ func (s *Service) committedModuleEnvelopeRevision(ctx context.Context) (uint64, return memo.revision, memo.err } -// currentModuleGrant resolves a principal's grant from ONE registry snapshot -// and fences that snapshot. found is false only for a principal the snapshot -// does not hold and that is not fenced; a fenced or stale registry is an -// error, so a caller never mistakes "not current" for "not a module". -func (s *Service) currentModuleGrant(ctx context.Context, principalID string) (ModulePrincipalGrant, bool, error) { - grant, found, _, err := s.currentModuleGrantIn(ctx, principalID) - return grant, found, err -} - // currentModuleGrantIn is currentModuleGrant that also returns the fenced // snapshot, so a decision that needs a SECOND principal's grant — a delegator // beside its caller — reads it from the same registry the fence passed, never @@ -950,7 +560,7 @@ func (s *Service) deliveredSince(ctx context.Context, snapshot *installedModuleR if delivered, err = counter.CountDeliveredDocuments(ctx); err != nil { return err } - unapplied, err = counter.CountUnappliedPresence(ctx) + unapplied, err = counter.CountUnappliedManifests(ctx) return err }); err != nil { return false @@ -981,18 +591,17 @@ func installationTenancyAllowed(envelope *ModuleEnvelope, principalID, orgID str // policy-log witness, so it recognises a pure grant and calls everything else a // narrowing. // -// A pure grant is exactly this, against the envelope in force: every approved -// build is still approved, every principal is still there with a section that -// is the same JSON value, every other top-level key is the same JSON value, -// and no installation loses its organization. What may differ is the -// revision, builds that were added, and whole principals that were added. +// A pure grant is exactly this, against the envelope in force: every principal +// is still there with a section that is the same JSON value, every other +// top-level key is the same JSON value, and no installation loses its +// organization. What may differ is the revision and whole principals that were +// added. // // A principal's section is compared for equality and never for "at least as // much". The schema does not let more JSON mean more authority: a resource // list that appears narrows an audience that was unrestricted, a required // kind that appears refuses artifacts that passed, a tenant that is set -// removes every other organization, and a unit that gains a field is a -// different unit under core's whole-unit containment. A comparison that tried +// removes every other organization. A comparison that tried // to tell those apart would be a second copy of every rule that reads the // envelope, wrong whenever one of them moved. So a changed section is a // narrowing, whatever changed. @@ -1028,8 +637,8 @@ func (s *Service) moduleEnvelopeNarrows(ctx context.Context, next *ModuleEnvelop return narrows, err } -// moduleEnvelopeOnlyAdds reports that after is before plus approved builds -// and whole principals, and nothing else: see moduleEnvelopeNarrows. Anything +// moduleEnvelopeOnlyAdds reports that after is before plus whole principals, +// and nothing else: see moduleEnvelopeNarrows. Anything // it cannot read as that — a document that does not decode, a key it does not // know — is not an addition. func moduleEnvelopeOnlyAdds(before, after []byte) bool { @@ -1049,10 +658,6 @@ func moduleEnvelopeOnlyAdds(before, after []byte) bool { for key, value := range is { switch key { case "revision": - case "approved_builds": - if !envelopeBuildsKept(was[key], value) { - return false - } case "principals": if !envelopePrincipalsKept(was[key], value) { return false @@ -1075,22 +680,6 @@ func envelopeTopLevel(document []byte) (map[string]json.RawMessage, bool) { return top, true } -func envelopeBuildsKept(before, after json.RawMessage) bool { - var was, is []string - if len(before) > 0 && json.Unmarshal(before, &was) != nil { - return false - } - if len(after) > 0 && json.Unmarshal(after, &is) != nil { - return false - } - for _, build := range was { - if !slices.Contains(is, build) { - return false - } - } - return true -} - func envelopePrincipalsKept(before, after json.RawMessage) bool { var was, is map[string]json.RawMessage if len(before) > 0 && json.Unmarshal(before, &was) != nil { diff --git a/module/services/accounts/code/pkg/business/module_envelope_test.go b/module/services/accounts/code/pkg/business/module_envelope_test.go index cfc65c7e8..4fc88eaab 100644 --- a/module/services/accounts/code/pkg/business/module_envelope_test.go +++ b/module/services/accounts/code/pkg/business/module_envelope_test.go @@ -2,8 +2,6 @@ package business import ( "encoding/json" - "maps" - "slices" "strings" "testing" @@ -11,51 +9,27 @@ import ( "github.com/stretchr/testify/require" ) -// The module envelope and the registry derived inside it, without a database: -// the projection core's ValidateAgainst is handed, and the intersection an -// entry is built from. The DSN-backed half — delivery to registry with no +// The module envelope and the registry entry derived inside it, without a +// database: the administrator's grant intersected with what an applied module +// manifest asks for. The DSN-backed half — delivery to registry with no // restart, narrowing, removal — is in pkg/infra/module_principal_registry_test.go. const ( testEnvelopeTenant = "019f6bf7-5b4b-74e5-8c17-092259bb1661" - testEnvelopeBuild = solutionhost.ImageDigest("sha256:3880ab5504a3f436fead6e19fb23b641443747ab55faa3f63c7b7f91b610e28f") testPresenceBinding = "acme.test.reports" testModulePrefix = "reports" testProducerAudience = "example-producer" ) -func testUnitID(binding, operation string) string { - return testPresenceBinding + ":" + binding + ":" + operation -} - -const ( - testSPIFFEID = "spiffe://acme.test/ns/acme-prod/sa/reports" - testContainer = "app" -) - -// testEnvelopeUnits are the whole units the test envelope admits: the run -// binding's invoke, lookup and headless operations, each narrower than the -// grant's audience allows. -func testEnvelopeUnits() []any { - return []any{ - map[string]any{"id": testUnitID("run", "invoke"), "revision": float64(1), "scope": "profiles:read"}, - map[string]any{"id": testUnitID("run", "lookup"), "revision": float64(1), "scope": "profiles:read", "lookup_method": "directory"}, - map[string]any{"id": testUnitID("run", "headless"), "revision": float64(1), "scope": "profiles:read"}, - } -} +var testWorkload = ModuleWorkload{ServiceAccount: "reports", Namespace: "reports", Container: "app"} -// testEnvelopeDocument is a valid envelope: the `run` binding admitted for -// invoke, lookup and headless, one allowed workload, scope ceilings on the -// module's own kind, and a grant wider than any one delivery asks for. +// testEnvelopeDocument is a valid envelope: one principal's grant, wider than +// any one manifest asks for. func testEnvelopeDocument() map[string]any { return map[string]any{ - "revision": float64(3), - "approved_builds": []any{string(testEnvelopeBuild)}, + "revision": float64(3), "principals": map[string]any{ testModulePrefix: map[string]any{ - "bindings": testEnvelopeUnits(), - "workload_identities": []any{map[string]any{"spiffe_id": testSPIFFEID, "container": testContainer}}, - "scope_ceilings": []any{map[string]any{"resource_kind": "reports", "actions": []any{"read", "write"}}}, "grant": map[string]any{ "tenant": strings.ToUpper(testEnvelopeTenant), "queues": []any{"reports.render", "reports.export"}, @@ -97,60 +71,46 @@ func parseTestEnvelope(t *testing.T) *ModuleEnvelope { return envelope } -// testUnit is the unit delivery would write for one admitted operation — -// identical, field for field, to the one the envelope admits. -func testUnit(binding, operation string) solutionhost.AuthorityBinding { - unit := solutionhost.AuthorityBinding{ - ID: testUnitID(binding, operation), Revision: 1, Audience: testProducerAudience, Scope: "profiles:read", - } - if operation == "lookup" { - unit.LookupMethod = "directory" - } - return unit -} - -// testAuthority is a valid authority document over testPresenceBinding granting -// the given units to the module principal, declaring queues, namespaces and a -// scope ceiling of its own. -func testAuthority(t *testing.T, units ...solutionhost.AuthorityBinding) *solutionhost.AuthorityDocument { +// testManifest is a valid live module manifest over testPresenceBinding: one +// workload, and an authority request for the `run` binding with the given +// scopes per operation, a queue, a namespace and a resource kind of its own. +func testManifest(t *testing.T, operations map[string][]string) *solutionhost.ModuleManifest { t.Helper() - document := &solutionhost.AuthorityDocument{ - Schema: solutionhost.SchemaAuthorityV1, - Authority: testPresenceBinding + "-authority", - Generation: 1, - PresenceBinding: testPresenceBinding, - Host: solutionhost.HostTarget{Coordinate: "acme-test", Component: "saas-host"}, - OwnershipDomain: "acme-test", - EnvelopeRevision: 3, - ApprovedBuild: testEnvelopeBuild, - EffectiveFrom: 1, - Queues: []string{"reports.render"}, - Namespaces: []string{"reports"}, - ScopeCeilings: []solutionhost.ScopeCeiling{{ResourceKind: "reports", Actions: []string{"read", "write"}}}, - Principals: []solutionhost.PrincipalAuthority{{Principal: testModulePrefix, Bindings: units}}, + manifest := &solutionhost.ModuleManifest{ + Schema: solutionhost.SchemaModuleManifestV1, Binding: testPresenceBinding, Alias: testModulePrefix, + Kind: solutionhost.KindModule, Generation: 1, + Host: solutionhost.HostTarget{Coordinate: "acme-test", Domain: "acme-test"}, + Release: solutionhost.Release{Publisher: "acme", Name: testModulePrefix, Version: "1.0.0"}, + Workloads: []solutionhost.Workload{{Service: "reports", Container: "app"}}, + Authority: &solutionhost.Authority{ + Principal: testModulePrefix, + Queues: []string{"reports.render"}, + Namespaces: []string{"reports"}, + ResourceKinds: []string{"reports"}, + }, } - require.NoError(t, document.Validate()) - return document + if operations != nil { + manifest.Authority.Bindings = []solutionhost.AuthorityBinding{{ID: "run", Audience: testProducerAudience, Operations: operations}} + } + require.NoError(t, manifest.Validate()) + return manifest } -var testWorkload = ModuleWorkload{ServiceAccount: "reports", Namespace: "acme-prod", Container: "app"} - -func TestModuleEnvelopeParsesTheCeilingItsAdministratorWrote(t *testing.T) { +func TestModuleEnvelopeParsesTheGrantItsAdministratorWrote(t *testing.T) { envelope := parseTestEnvelope(t) require.Equal(t, uint64(3), envelope.Revision) - require.Equal(t, []solutionhost.ImageDigest{testEnvelopeBuild}, envelope.ApprovedBuilds) entry, ok := envelope.Principals[testModulePrefix] require.True(t, ok) require.Equal(t, testModulePrefix, entry.Grant.Prefix) require.Equal(t, testEnvelopeTenant, entry.Grant.Tenant, "the tenant is canonicalized as the boot parser canonicalized it, so it matches its organization") - require.Empty(t, entry.Grant.Workload, "an envelope names no workload; it is derived from presence") + require.Empty(t, entry.Grant.Workloads, "an envelope names no workload; the manifest does") } -// TestModuleEnvelopeRefusesWhatItCannotMean holds the envelope to the rules -// the boot-time registry parser applied, and to the ones a runtime ceiling adds. -// Each case is a document that decodes and would read as a ceiling while -// bounding less than its author meant. +// TestModuleEnvelopeRefusesWhatItCannotMean: each case decodes and would read +// as a ceiling while bounding less than its author meant — or names what the +// module manifest now carries (workloads, builds, units), which an envelope +// never approves. func TestModuleEnvelopeRefusesWhatItCannotMean(t *testing.T) { principal := func(document map[string]any) map[string]any { return document["principals"].(map[string]any)[testModulePrefix].(map[string]any) @@ -159,14 +119,20 @@ func TestModuleEnvelopeRefusesWhatItCannotMean(t *testing.T) { return principal(document)["grant"].(map[string]any) } for name, mutate := range map[string]func(map[string]any){ - "a workload, which is delivered and never typed": func(d map[string]any) { + "a workload, which the manifest declares": func(d map[string]any) { grant(d)["workload"] = map[string]any{"service_account": "x", "namespace": "y", "container": "z"} }, - "revision zero": func(d map[string]any) { d["revision"] = float64(0) }, - "an unknown top key": func(d map[string]any) { d["grants"] = []any{} }, - "a readable digest": func(d map[string]any) { d["approved_builds"] = []any{"sha256:latest"} }, - "a malformed tenant": func(d map[string]any) { grant(d)["tenant"] = "acme" }, - "a host-sealed content": func(d map[string]any) { grant(d)["resources"] = []any{HostScopeRoles} }, + "approved builds, which leave the runtime": func(d map[string]any) { d["approved_builds"] = []any{} }, + "authority units, which the manifest asks for": func(d map[string]any) { + principal(d)["bindings"] = []any{} + }, + "workload identities": func(d map[string]any) { principal(d)["workload_identities"] = []any{} }, + "revision zero": func(d map[string]any) { d["revision"] = float64(0) }, + "an unknown top key": func(d map[string]any) { d["grants"] = []any{} }, + "a malformed tenant": func(d map[string]any) { grant(d)["tenant"] = "acme" }, + "a host-sealed content": func(d map[string]any) { + grant(d)["resources"] = []any{HostScopeRoles} + }, "an external namespace it does not bind": func(d map[string]any) { grant(d)["external_namespaces"] = []any{"elsewhere"} }, @@ -175,36 +141,6 @@ func TestModuleEnvelopeRefusesWhatItCannotMean(t *testing.T) { principals[ModulePrincipalID(testModulePrefix)] = principals[testModulePrefix] delete(principals, testModulePrefix) }, - "no admitted unit": func(d map[string]any) { principal(d)["bindings"] = []any{} }, - "a unit outside delivery's convention": func(d map[string]any) { - principal(d)["bindings"] = []any{map[string]any{"id": testPresenceBinding + ":run:reconcile", "revision": float64(1), "scope": "profiles:read"}} - }, - "a unit for an audience the grant does not declare": func(d map[string]any) { - principal(d)["bindings"] = []any{map[string]any{"id": testUnitID("other", "invoke"), "revision": float64(1), "scope": "profiles:read"}} - }, - "a headless unit with no headless scopes": func(d map[string]any) { - audience := grant(d)["operation_audiences"].(map[string]any)["run"].(map[string]any) - delete(audience, "headless_scopes") - }, - "a unit scope wider than its operation allows": func(d map[string]any) { - principal(d)["bindings"] = []any{map[string]any{"id": testUnitID("run", "lookup"), "revision": float64(1), "scope": "profiles:read,profiles:write"}} - }, - "a unit scope that is not canonical": func(d map[string]any) { - principal(d)["bindings"] = []any{map[string]any{"id": testUnitID("run", "invoke"), "revision": float64(1), "scope": "profiles:write,profiles:read"}} - }, - "a unit with no revision": func(d map[string]any) { - principal(d)["bindings"] = []any{map[string]any{"id": testUnitID("run", "invoke"), "scope": "profiles:read"}} - }, - "a unit with a queue the grant does not allow": func(d map[string]any) { - principal(d)["bindings"] = []any{map[string]any{"id": testUnitID("run", "invoke"), "revision": float64(1), "scope": "profiles:read", "queue": "payments.settle"}} - }, - "no allowed workload": func(d map[string]any) { principal(d)["workload_identities"] = []any{} }, - "a workload identity that names no service account": func(d map[string]any) { - principal(d)["workload_identities"] = []any{map[string]any{"spiffe_id": "spiffe://acme.test/workload/reports", "container": testContainer}} - }, - "a scope ceiling with no action": func(d map[string]any) { - principal(d)["scope_ceilings"] = []any{map[string]any{"resource_kind": "reports", "actions": []any{}}} - }, } { t.Run(name, func(t *testing.T) { document := testEnvelopeDocument() @@ -215,265 +151,91 @@ func TestModuleEnvelopeRefusesWhatItCannotMean(t *testing.T) { } } -// TestTheProjectionAdmitsOnlyWhatTheEnvelopeListsForThisDocument is the one -// ceiling: core's containment rule, handed the projection of this record — -// built from the envelope alone. -func TestTheProjectionAdmitsOnlyWhatTheEnvelopeListsForThisDocument(t *testing.T) { +// The entry is the envelope's grant intersected with what the manifest asks +// for: every list keeps only what both name, and per operation only the scopes +// both name. +func TestTheDerivedEntryIsTheGrantBoundedByTheManifest(t *testing.T) { envelope := parseTestEnvelope(t) - - inside := testAuthority(t, testUnit("run", "invoke"), testUnit("run", "lookup")) - require.NoError(t, inside.ValidateAgainst(envelope.authorityEnvelopeFor(inside))) - - changed := func(mutate func(*solutionhost.AuthorityBinding)) *solutionhost.AuthorityDocument { - unit := testUnit("run", "invoke") - mutate(&unit) - return testAuthority(t, unit) - } - for name, document := range map[string]*solutionhost.AuthorityDocument{ - // FINDING 7: every field of an admitted unit is the envelope's, so a - // document that keeps the admitted id and changes anything else is - // outside — it cannot supply its own ceiling. - "a wider scope under an admitted id": changed(func(u *solutionhost.AuthorityBinding) { u.Scope = "profiles:delete,profiles:read" }), - "another revision under an admitted id": changed(func(u *solutionhost.AuthorityBinding) { u.Revision = 2 }), - "a binding key the envelope did not admit": changed(func(u *solutionhost.AuthorityBinding) { u.BindingKey = "other-key" }), - "a lookup method the envelope did not admit": changed(func(u *solutionhost.AuthorityBinding) { u.LookupMethod = "scan" }), - "another audience": changed(func(u *solutionhost.AuthorityBinding) { u.Audience = "someone-else" }), - "a queue the envelope did not admit": changed(func(u *solutionhost.AuthorityBinding) { u.Queue = "reports.render" }), - "a unit the envelope does not list": testAuthority(t, testUnit("run", "invoke"), testUnit("audit", "invoke")), - "a queue the grant does not allow": func() *solutionhost.AuthorityDocument { - document := testAuthority(t, testUnit("run", "invoke")) - document.Queues = []string{"payments.settle"} - return document - }(), - "a wider scope ceiling": func() *solutionhost.AuthorityDocument { - document := testAuthority(t, testUnit("run", "invoke")) - document.ScopeCeilings = []solutionhost.ScopeCeiling{{ResourceKind: "reports", Actions: []string{"admin", "read", "write"}}} - return document - }(), - "another principal claiming the module's unit": func() *solutionhost.AuthorityDocument { - document := testAuthority(t, testUnit("run", "invoke")) - document.Principals[0].Principal = "impostor" - return document - }(), - "a build the envelope did not approve": func() *solutionhost.AuthorityDocument { - document := testAuthority(t, testUnit("run", "invoke")) - document.ApprovedBuild = solutionhost.ImageDigest("sha256:" + strings.Repeat("0", 64)) - return document - }(), - "another envelope revision": func() *solutionhost.AuthorityDocument { - document := testAuthority(t, testUnit("run", "invoke")) - document.EnvelopeRevision = 2 - return document - }(), - } { - t.Run(name, func(t *testing.T) { - require.ErrorIs(t, document.ValidateAgainst(envelope.authorityEnvelopeFor(document)), - solutionhost.ErrOutsideEnvelope) - }) - } - - t.Run("a unit filed under another presence binding", func(t *testing.T) { - // Same unit id, delivered over a different presence binding: the unit - // the envelope admitted names one instance of the module, never another. - document := testAuthority(t, testUnit("run", "invoke")) - document.PresenceBinding = "acme.test.reports-copy" - require.ErrorIs(t, document.ValidateAgainst(envelope.authorityEnvelopeFor(document)), - solutionhost.ErrOutsideEnvelope) + manifest := testManifest(t, map[string][]string{ + "invoke": {"profiles:read", "profiles:delete"}, + "lookup": {"profiles:read"}, + "headless": {"profiles:read"}, }) -} - -// TestTheDerivedEntryIsTheEnvelopeBoundedByTheDocument is the intersection: -// never wider than the grant, never wider than the admitted unit. -func TestTheDerivedEntryIsTheEnvelopeBoundedByTheDocument(t *testing.T) { - envelope := parseTestEnvelope(t) - units := []solutionhost.AuthorityBinding{testUnit("run", "invoke"), testUnit("run", "lookup")} - document := testAuthority(t, units...) - require.NoError(t, document.ValidateAgainst(envelope.authorityEnvelopeFor(document))) - - grant, omitted, err := deriveModulePrincipalGrant(testModulePrefix, envelope.Principals[testModulePrefix], document, units, testWorkload) + grant, omitted, err := deriveModulePrincipalGrant(testModulePrefix, envelope.Principals[testModulePrefix], manifest, []ModuleWorkload{testWorkload}) require.NoError(t, err) require.Empty(t, omitted) - - require.Equal(t, testModulePrefix, grant.Prefix) - require.Equal(t, testEnvelopeTenant, grant.Tenant) - require.Equal(t, testWorkload, grant.Workload, "the workload is the one passed in, never read from the grant") - require.Equal(t, []string{"reports.render"}, grant.Queues, "only queues the document declares too") - require.Equal(t, []string{"reports"}, grant.Namespaces, "only namespaces the document declares too") - require.Equal(t, []string{"reports"}, grant.ExternalNamespaces, "external delivery only within the namespaces kept") - require.Equal(t, []string{"reports"}, grant.Resources, "only resources the document's own vocabulary names") - - require.Len(t, grant.OperationAudiences, 1, "an audience the document did not ask for is absent") - run := grant.OperationAudiences["run"] - require.Equal(t, testProducerAudience, run.Audience) - require.Equal(t, []ModuleOperationScope{{ResourceKind: "profiles", Actions: []string{"read"}, ResourceIDs: []string{"standard"}}}, - run.InvokeScopes, "invoke is cut to the admitted unit's scope though the grant allows write, and resource ids stay the grant's") - require.Equal(t, []ModuleOperationScope{{ResourceKind: "profiles", Actions: []string{"read"}, ResourceIDs: []string{"standard"}}}, run.LookupScopes) - require.Nil(t, run.HeadlessScopes, "no headless unit was granted, so nothing may be minted headless") - require.NoError(t, ValidateModulePrincipalGrant(testModulePrefix, grant)) + require.Equal(t, testPresenceBinding, grant.PresenceBinding) + require.Equal(t, []string{"reports.render"}, grant.Queues) + require.Equal(t, []string{"reports"}, grant.Namespaces) + require.Equal(t, []string{"reports"}, grant.ExternalNamespaces) + require.Equal(t, []string{"reports"}, grant.Resources) + require.Equal(t, []ModuleWorkload{testWorkload}, grant.Workloads) + require.Equal(t, map[string]ModuleOperationAudience{"run": { + Audience: testProducerAudience, + InvokeScopes: []ModuleOperationScope{{ResourceKind: "profiles", Actions: []string{"read"}, ResourceIDs: []string{"standard"}}}, + LookupScopes: []ModuleOperationScope{{ResourceKind: "profiles", Actions: []string{"read"}, ResourceIDs: []string{"standard"}}}, + HeadlessScopes: []ModuleOperationScope{{ResourceKind: "profiles", Actions: []string{"read"}, ResourceIDs: []string{"standard"}}}, + }}, grant.OperationAudiences, + "profiles:delete is asked for and not allowed; `unrequested` is allowed and not asked for — neither is granted") } -// TestAnInvokeOnlyAudienceIsKept: a document granting the invoke unit and not -// the lookup one declares an audience that redeems no lookup. It is kept, with -// no lookup scopes — never filled in from the envelope, and never dropped as if -// the module had not declared it (the shape of a module that verifies, as the -// asking person, against a store it has nothing to look up in). -func TestAnInvokeOnlyAudienceIsKept(t *testing.T) { +func TestAnOperationBindingTheEnvelopeDoesNotAllowIsOmittedByName(t *testing.T) { envelope := parseTestEnvelope(t) - units := []solutionhost.AuthorityBinding{testUnit("run", "invoke")} - document := testAuthority(t, units...) - require.NoError(t, document.ValidateAgainst(envelope.authorityEnvelopeFor(document))) - grant, omitted, err := deriveModulePrincipalGrant(testModulePrefix, envelope.Principals[testModulePrefix], document, units, testWorkload) - require.NoError(t, err) - require.Empty(t, omitted) - require.Contains(t, grant.OperationAudiences, "run") - require.NotEmpty(t, grant.OperationAudiences["run"].InvokeScopes) - require.Nil(t, grant.OperationAudiences["run"].LookupScopes, "no lookup unit, no lookup scopes") -} - -// TestAHeadlessUnitGrantsOnlyWhatBothSidesAllow: headless is the module acting -// alone, so it exists only when the document grants the headless unit. -func TestAHeadlessUnitGrantsOnlyWhatBothSidesAllow(t *testing.T) { - envelope := parseTestEnvelope(t) - units := []solutionhost.AuthorityBinding{testUnit("run", "invoke"), testUnit("run", "lookup"), testUnit("run", "headless")} - document := testAuthority(t, units...) - grant, _, err := deriveModulePrincipalGrant(testModulePrefix, envelope.Principals[testModulePrefix], document, units, testWorkload) - require.NoError(t, err) - require.Equal(t, []ModuleOperationScope{{ResourceKind: "profiles", Actions: []string{"read"}, ResourceIDs: []string{"standard"}}}, - grant.OperationAudiences["run"].HeadlessScopes) -} - -// TestAnEntryWithoutAWorkloadIsNeverInstalled: the derived entry is validated -// whole, so a workload the presence could not resolve omits the principal. -func TestAnEntryWithoutAWorkloadIsNeverInstalled(t *testing.T) { - envelope := parseTestEnvelope(t) - units := []solutionhost.AuthorityBinding{testUnit("run", "invoke"), testUnit("run", "lookup")} - document := testAuthority(t, units...) - _, _, err := deriveModulePrincipalGrant(testModulePrefix, envelope.Principals[testModulePrefix], document, units, ModuleWorkload{}) - require.ErrorContains(t, err, "must declare its workload identity") -} - -// TestOnlyAnApprovedWorkloadBindsThePrincipal is finding 6: a presence running -// the approved build under a service account, namespace or container the -// envelope does not allow binds nothing. -func TestOnlyAnApprovedWorkloadBindsThePrincipal(t *testing.T) { - envelope := parseTestEnvelope(t) - entry := envelope.Principals[testModulePrefix] - document := testAuthority(t, testUnit("run", "invoke")) - - workload, err := approvedWorkload(entry, []ModuleWorkloadIdentity{{SPIFFEID: testSPIFFEID, Container: testContainer}}, document) - require.NoError(t, err) - require.Equal(t, ModuleWorkload{SPIFFEID: testSPIFFEID, ServiceAccount: "reports", Namespace: "acme-prod", Container: testContainer}, workload) - - for name, retargeted := range map[string]ModuleWorkloadIdentity{ - "another service account": {SPIFFEID: "spiffe://acme.test/ns/acme-prod/sa/intruder", Container: testContainer}, - "another namespace": {SPIFFEID: "spiffe://acme.test/ns/elsewhere/sa/reports", Container: testContainer}, - "another container": {SPIFFEID: testSPIFFEID, Container: "sidecar"}, + for name, mutate := range map[string]func(*solutionhost.ModuleManifest){ + "an unknown binding": func(m *solutionhost.ModuleManifest) { m.Authority.Bindings[0].ID = "other" }, + "another audience": func(m *solutionhost.ModuleManifest) { m.Authority.Bindings[0].Audience = "elsewhere" }, + "no invoke scope": func(m *solutionhost.ModuleManifest) { + m.Authority.Bindings[0].Operations = map[string][]string{"lookup": {"profiles:read"}} + }, + "nothing in common": func(m *solutionhost.ModuleManifest) { + m.Authority.Bindings[0].Operations = map[string][]string{"invoke": {"profiles:delete"}} + }, } { t.Run(name, func(t *testing.T) { - _, err := approvedWorkload(entry, []ModuleWorkloadIdentity{retargeted}, document) - require.ErrorIs(t, err, ErrModuleWorkloadNotApproved) + manifest := testManifest(t, map[string][]string{"invoke": {"profiles:read"}}) + mutate(manifest) + grant, omitted, err := deriveModulePrincipalGrant(testModulePrefix, envelope.Principals[testModulePrefix], manifest, []ModuleWorkload{testWorkload}) + require.NoError(t, err, "the principal stays; only the binding is omitted") + require.Empty(t, grant.OperationAudiences) + require.Len(t, omitted, 1) + require.ErrorIs(t, omitted[0], ErrModulePrincipalBindingOmitted) }) } } -// TestSPIFFEIDsAreParsedStrictly is round 2's finding 5: the namespace and -// service account come from the exact /ns//sa/ path, never from a scan -// that can read the trust domain as the namespace, and a string that names no -// workload is refused — at envelope write time and wherever else it is read. -func TestSPIFFEIDsAreParsedStrictly(t *testing.T) { - identity, err := ParseKubernetesSPIFFEID("spiffe://ns/ns/acme-prod/sa/reports") - require.NoError(t, err) - require.Equal(t, KubernetesSPIFFEID{TrustDomain: "ns", Namespace: "acme-prod", ServiceAccount: "reports"}, identity, - "a trust domain named like a path segment is the trust domain, not the namespace") - identity, err = ParseKubernetesSPIFFEID("spiffe://sa/ns/sa/sa/ns") - require.NoError(t, err) - require.Equal(t, "sa", identity.Namespace) - require.Equal(t, "ns", identity.ServiceAccount) - - for _, malformed := range []string{ - "spiffe://acme.test:8443/ns/acme-prod/sa/reports", - "spiffe:///ns/acme-prod/sa/reports", - "spiffe://user@acme.test/ns/acme-prod/sa/reports", - "spiffe://Acme.Test/ns/acme-prod/sa/reports", - "spiffe://acme.test/sa/reports/ns/acme-prod", - "spiffe://acme.test/ns/acme-prod/ns/other/sa/reports", - "spiffe://acme.test/ns/acme-prod/sa/reports/extra", - "spiffe://acme.test/ns/acme-prod/sa/", - "spiffe://acme.test/ns/Acme/sa/reports", - "spiffe://acme.test/ns/acme-prod/sa/reports?x=1", - "spiffe://acme.test", - "https://acme.test/ns/acme-prod/sa/reports", - "acme-prod/reports", - } { - t.Run(malformed, func(t *testing.T) { - _, err := ParseKubernetesSPIFFEID(malformed) - require.ErrorIs(t, err, ErrSPIFFEIDMalformed) - document := testEnvelopeDocument() - document["principals"].(map[string]any)[testModulePrefix].(map[string]any)["workload_identities"] = - []any{map[string]any{"spiffe_id": malformed, "container": testContainer}} - _, err = ParseModuleEnvelope(encodeEnvelope(t, document)) - require.ErrorIs(t, err, ErrModuleEnvelopeInvalid, "the envelope refuses it at write time") - }) - } - - // The reviewer's case end to end: the approved string projects to the - // namespace in its path. - document := testEnvelopeDocument() - document["principals"].(map[string]any)[testModulePrefix].(map[string]any)["workload_identities"] = - []any{map[string]any{"spiffe_id": "spiffe://ns/ns/acme-prod/sa/reports", "container": testContainer}} - envelope, err := ParseModuleEnvelope(encodeEnvelope(t, document)) - require.NoError(t, err) - require.Equal(t, ModuleWorkload{SPIFFEID: "spiffe://ns/ns/acme-prod/sa/reports", ServiceAccount: "reports", Namespace: "acme-prod", Container: testContainer}, - envelope.Principals[testModulePrefix].WorkloadIdentities[0].workload()) -} - -// TestOneWorkloadMapsToOnePrincipal: two principals allowing the same -// namespace and service account — whatever their containers — would let one -// module's pod token authorize the other's prefix, so the envelope is refused. -func TestOneWorkloadMapsToOnePrincipal(t *testing.T) { - document := testEnvelopeDocument() - principals := document["principals"].(map[string]any) - other := map[string]any{} - for key, value := range principals[testModulePrefix].(map[string]any) { - other[key] = value - } - other["workload_identities"] = []any{map[string]any{"spiffe_id": testSPIFFEID, "container": "another-container"}} - other["bindings"] = []any{map[string]any{"id": "acme.test.other:run:invoke", "revision": float64(1), "scope": "profiles:read"}} - principals["other"] = other - _, err := ParseModuleEnvelope(encodeEnvelope(t, document)) - require.ErrorIs(t, err, ErrModuleEnvelopeInvalid) - require.ErrorContains(t, err, "one workload identity maps to exactly one principal") - - other["workload_identities"] = []any{map[string]any{"spiffe_id": "spiffe://acme.test/ns/acme-prod/sa/other", "container": testContainer}} - _, err = ParseModuleEnvelope(encodeEnvelope(t, document)) - require.NoError(t, err, "a different service account is a different workload") +func TestAnEntryWithoutAWorkloadIsNeverInstalled(t *testing.T) { + envelope := parseTestEnvelope(t) + _, _, err := deriveModulePrincipalGrant(testModulePrefix, envelope.Principals[testModulePrefix], testManifest(t, nil), nil) + require.Error(t, err) } -// TestTheDerivationOmitsEveryPrincipalSharingAWorkload: the same rule at -// derivation — whatever reached the registry, no workload maps to two -// principals. func TestTheDerivationOmitsEveryPrincipalSharingAWorkload(t *testing.T) { - shared := ModuleWorkload{ServiceAccount: "reports", Namespace: "acme-prod", Container: "app"} published := &publishedModuleRegistry{ registry: ModulePrincipalRegistry{ - "a": {Prefix: "first", Workload: shared}, - "b": {Prefix: "second", Workload: ModuleWorkload{ServiceAccount: "reports", Namespace: "acme-prod", Container: "other"}}, - "c": {Prefix: "third", Workload: ModuleWorkload{ServiceAccount: "third", Namespace: "acme-prod", Container: "app"}}, + "a": {Prefix: "alpha", Workloads: []ModuleWorkload{testWorkload}}, + "b": {Prefix: "beta", Workloads: []ModuleWorkload{{Namespace: testWorkload.Namespace, ServiceAccount: testWorkload.ServiceAccount, Container: "other"}}}, + "c": {Prefix: "gamma", Workloads: []ModuleWorkload{{Namespace: "gamma", ServiceAccount: "gamma", Container: "app"}}}, }, sources: map[string]modulePrincipalSource{}, } refusals := published.oneWorkloadOnePrincipal() require.Len(t, refusals, 1) require.ErrorIs(t, refusals[0], ErrModulePrincipalOmitted) - require.Equal(t, []string{"c"}, slices.Collect(maps.Keys(published.registry))) + require.Equal(t, []string{"c"}, keysOf(published.registry), "the container is not part of the key: a token names the account") +} + +func keysOf(registry ModulePrincipalRegistry) []string { + keys := make([]string, 0, len(registry)) + for key := range registry { + keys = append(keys, key) + } + return keys } func TestSharedEnvelopeTenantIsNotAnOrganizationGrant(t *testing.T) { document := testEnvelopeDocument() document["principals"].(map[string]any)[testModulePrefix].(map[string]any)["grant"].(map[string]any)["tenant"] = "" - raw, err := json.Marshal(document) - require.NoError(t, err) - envelope, err := ParseModuleEnvelope(raw) + envelope, err := ParseModuleEnvelope(encodeEnvelope(t, document)) require.NoError(t, err) require.Empty(t, envelope.Principals[testModulePrefix].Grant.Tenant) tenant, err := canonicalModuleTenant("example", "") @@ -491,8 +253,7 @@ func TestSharedEnvelopeTenantIsNotAnOrganizationGrant(t *testing.T) { // install steps, and a module granted it could claim and complete one. func TestTheInstallStepQueueIsGrantedToNoModule(t *testing.T) { document := testEnvelopeDocument() - principal := document["principals"].(map[string]any)[testModulePrefix].(map[string]any) - grant := principal["grant"].(map[string]any) + grant := document["principals"].(map[string]any)[testModulePrefix].(map[string]any)["grant"].(map[string]any) queues, _ := grant["queues"].([]any) grant["queues"] = append(queues, InstallationStepQueue) _, err := ParseModuleEnvelope(encodeEnvelope(t, document)) @@ -500,43 +261,20 @@ func TestTheInstallStepQueueIsGrantedToNoModule(t *testing.T) { require.ErrorContains(t, err, InstallationStepQueue) } -func TestEmptyPermissionAdmissionIsExactAndGrantsNoConsumedOperations(t *testing.T) { +// A manifest that asks for no operation still installs: the grant's lists are +// what both name, and none are. +func TestAManifestAskingForNoOperationGrantsNoConsumedOperations(t *testing.T) { wire := testEnvelopeDocument() - principal := wire["principals"].(map[string]any)[testModulePrefix].(map[string]any) - principal["bindings"] = []any{} - principal["admitted_bindings"] = []string{testPresenceBinding} - principal["scope_ceilings"] = []any{} - principal["grant"] = map[string]any{"cross_tenant": true} + wire["principals"].(map[string]any)[testModulePrefix] = map[string]any{"grant": map[string]any{"cross_tenant": true}} envelope, err := ParseModuleEnvelope(encodeEnvelope(t, wire)) require.NoError(t, err) - document := testAuthority(t, []solutionhost.AuthorityBinding{}...) - document.Queues, document.Namespaces, document.ScopeCeilings = []string{}, []string{}, []solutionhost.ScopeCeiling{} - require.NoError(t, document.ValidateAgainst(envelope.authorityEnvelopeFor(document))) - grant, _, err := deriveModulePrincipalGrant(testModulePrefix, envelope.Principals[testModulePrefix], document, nil, testWorkload) + grant, omitted, err := deriveModulePrincipalGrant(testModulePrefix, envelope.Principals[testModulePrefix], testManifest(t, nil), []ModuleWorkload{testWorkload}) require.NoError(t, err) + require.Empty(t, omitted) require.Empty(t, grant.OperationAudiences) require.Empty(t, grant.Queues) require.Empty(t, grant.Namespaces) require.Empty(t, grant.Resources) require.Empty(t, grant.ReadAudiences) require.True(t, grant.CrossTenant) - document.PresenceBinding = "acme.test.other" - require.ErrorIs(t, document.ValidateAgainst(envelope.authorityEnvelopeFor(document)), solutionhost.ErrOutsideEnvelope) - document.PresenceBinding = testPresenceBinding - document.Principals[0].Principal = "other" - require.ErrorIs(t, document.ValidateAgainst(envelope.authorityEnvelopeFor(document)), solutionhost.ErrOutsideEnvelope) - for name, mutate := range map[string]func(){ - "omitted units": func() { delete(principal, "bindings") }, - "null units": func() { principal["bindings"] = nil }, - "missing admission": func() { delete(principal, "admitted_bindings") }, - "invalid admission": func() { principal["admitted_bindings"] = []string{"bad:binding"} }, - "duplicate admission": func() { principal["admitted_bindings"] = []string{testPresenceBinding, testPresenceBinding} }, - } { - t.Run(name, func(t *testing.T) { - principal["bindings"], principal["admitted_bindings"] = []any{}, []string{testPresenceBinding} - mutate() - _, err := ParseModuleEnvelope(encodeEnvelope(t, wire)) - require.Error(t, err) - }) - } } diff --git a/module/services/accounts/code/pkg/business/module_execution_binding.go b/module/services/accounts/code/pkg/business/module_execution_binding.go index 59ca0bc1f..2c0d84d1a 100644 --- a/module/services/accounts/code/pkg/business/module_execution_binding.go +++ b/module/services/accounts/code/pkg/business/module_execution_binding.go @@ -9,55 +9,27 @@ import ( "time" ) -// Execution-bound minting: what the caller IS RUNNING, established independently -// of anything the caller says. +// Execution-bound minting: WHICH WORKLOAD the caller is, established +// independently of anything the caller says. // -// WHAT THIS REPLACES. The mint authenticated with a shared secret, which is a -// bearer token: whoever holds it is the module, so the mint could not answer what -// the caller was running at all. A secret cannot be execution-bound in principle -// — it says "I know the secret", never "I am this pod running this image". -// -// THE THREE DIGEST TYPES, and why conflating any two defeats the check. Each -// answers a different question and they are deliberately separate types below -// rather than three strings: -// -// - APPROVED (`ApprovedDigest`) — what the authority document says may run. -// Signed, delivered out of band, and the only one the caller has no influence -// over. -// - DECLARED (`DeclaredDigest`) — what the pod's SPEC asks to run. Writable by -// whoever can create the pod, so it is evidence of intent and never of fact. -// - RUNNING (`RunningDigest`) — what the container's STATUS reports it is -// actually running, as `imageID`. -// -// The check is APPROVED == RUNNING. Comparing approved against declared is the -// classic defeat: a pod spec can name an approved image and run something else -// if the tag moved, and the comparison passes while the workload is unapproved. -// Comparing declared against running proves only that the pod got what it asked -// for, which says nothing about whether it was allowed to ask. -// -// THE TAUTOLOGY THIS AVOIDS. Filling both sides of the comparison from the same -// source — reading the approved digest out of the authority document AND the -// running digest out of a field the same document provides — passes for every -// caller, because it compares a value to itself. The approved digest comes from -// the signed document; the running digest comes from the Kubernetes API, keyed by -// a pod UID that came from a TokenReview of a token the caller could not forge. -// Three independent sources, and no single one of them can satisfy the check. +// A module proves which module it is with its pod's projected ServiceAccount +// token. The host reviews it (TokenReview), which names the namespace, the +// ServiceAccount and the pod; reads that pod; and holds the pair to the +// workloads the module's APPLIED manifest lists, spelled by the cell's rule +// (namespace from the module, ServiceAccount from the service). No image is +// compared: build approval left the runtime path with the module manifest +// (core: "an authority approves the build of every workload it runs" is +// deploy-time admission), and a Work Context carries no build seal. // // NOTHING IS ISSUED WHEN THE API IS UNAVAILABLE. An unreachable control plane -// means the host cannot establish what the caller is running, and a mint that -// proceeded would issue an unbound capability exactly when binding was -// impossible — the one circumstance in which the check matters most. That is not -// the same answer as a refusal, and the two are kept distinct below so a caller -// can tell "you are not approved" from "I could not tell". +// means the host cannot establish which workload is calling, and that is kept +// distinct from a refusal so a caller can tell "you are not this module" from +// "I could not tell". // ErrExecutionUnbound reports that the host could not establish what the caller // is running. NOT a denial: the caller may be perfectly approved. var ErrExecutionUnbound = errors.New("cannot establish the caller's execution, so no capability may be minted") -// ErrExecutionNotApproved reports that the caller IS running something the -// authority document does not approve. A verdict. -var ErrExecutionNotApproved = errors.New("the caller is running an image the authority document does not approve") - // ErrExecutionIdentityMismatch reports that the token and the pod disagree — // the token named a pod whose live UID differs, or whose container is absent. var ErrExecutionIdentityMismatch = errors.New("the presented token's pod does not match the live pod") @@ -106,27 +78,6 @@ var ErrExecutionUnapprovedContainer = errors.New("the presented token's pod runs // token, which is the easiest possible bypass. var ErrExecutionNotBoundToken = errors.New("the presented token names no pod, so its execution cannot be established") -// ApprovedDigest is what the authority document approves. Signed, out of band. -type ApprovedDigest string - -// RunningDigest is what a container's status reports it is running. -type RunningDigest string - -// DeclaredDigest is what a pod's spec asks to run. -// -// It is READ and REPORTED, never compared against an approved digest. Three -// distinct types rather than three strings is what makes the wrong comparison a -// compile error instead of a code-review question. -// -// A previous commit on this branch deleted this type as dead code, because -// nothing read a pod's spec and a type with no referent is decoration. That was -// right about the type and wrong about the gap: the fix is to read the value, not -// to drop the name. It is now populated from the pod's spec, so a refusal can -// report that a pod ASKED for the approved image and is RUNNING something else — -// which is the signature of a moved tag, and the exact defeat that comparing -// approved against declared would wave through. -type DeclaredDigest string - // ExecutionIdentity is what the host established about a caller, independently. type ExecutionIdentity struct { PrincipalID string @@ -138,17 +89,6 @@ type ExecutionIdentity struct { PodName string PodUID string ContainerName string - // Running is what the container's status reports. The authority document's - // approved digest is compared against THIS. - Running RunningDigest - // Declared is what the pod's spec asked for. Carried for diagnosis only; - // comparing the approved digest against it is the defeat this separation - // exists to prevent, and the types make that comparison not compile. - Declared DeclaredDigest - // Incarnation is the approved build's counter, carried so a seal can name - // it. It comes from the authority document, never from the pod: a pod - // cannot be asked which generation of approval it belongs to. - Incarnation uint64 } // ExecutionReviewer is the independent source: a TokenReview, and a pod read. @@ -221,10 +161,6 @@ type ContainerExecution struct { Name string Kind ContainerKind State ContainerState - // ImageID is from the container's STATUS: what it runs. - ImageID string - // DeclaredImage is from the pod's SPEC: what it asked to run. - DeclaredImage string // Restartable is an init container with restartPolicy Always — a sidecar // that runs beside the application rather than before it. Restartable bool @@ -240,111 +176,43 @@ const PodPhaseRunning = "Running" // shortest the platform can issue rather than to whatever a renderer chose. const MaxExecutionTokenLifetime = 600 * time.Second -// ExecutionAuthority answers what a principal's approved build is. -// -// This is the authority document's half, and it takes NO attributes of the -// workload. Resolving by (service account, image digest) is exactly what lets a -// pod from a superseded generation in: it would answer "approved" for whatever -// that pod presents. The issuer answers what IT approves; the caller's running -// image is established separately; a mismatch is a refusal. Core's `SealSource` -// documents the same rule for the same reason. -// -// The principal is named as the authority document names it — the module's -// prefix — never by this host's registry key. -type ExecutionAuthority interface { - ApprovedBuild(ctx context.Context, principal string) (ApprovedDigest, uint64, error) -} - -// ErrNoApprovedBuild reports that a principal bears no approved build. -// -// Distinct from an unknown principal and from an unreadable authority, and the -// three-state distinction is the point: -// -// - no approved build → the principal is known and bears none. REFUSED. -// Execution binding is unconditional: a capability carrying no execution is -// one no verifier can hold against a running image, so it is not minted. -// - unknown principal → refused. Nothing is known about it, so nothing can be -// approved for it. -// - unreadable authority → ErrExecutionUnbound. Not a verdict. -// -// All three refuse; the three sentinels stay separate because they send an -// operator to three different places, not because one of them admits. -// -// This comment previously said a capability "may still be minted, carrying NO -// execution" for a caller permitted to act unbound. That posture does not exist -// here, and stating it in a doc comment is itself the hazard: prose describing a -// permissive path is an instruction to build one. If a principal that may act -// unbound is ever genuinely required, it does not arrive by relaxing this — -// it arrives as a principal that is not a module, through its own surface, with -// its own test. -var ErrNoApprovedBuild = errors.New("principal bears no approved build") - -// ErrUnknownExecutionPrincipal reports a principal the authority has no record -// of at all. +// ErrUnknownExecutionPrincipal reports a principal the registry holds no +// workload for: nothing is known about it, so nothing can be bound to it. var ErrUnknownExecutionPrincipal = errors.New("principal is not known to the execution authority") -// ErrExecutionAuthorityUnreconciled reports that this host has an authority -// question to answer and has not yet established an answer: a ceiling is -// delivered, and no reconciliation pass has installed a view. -// -// It refuses exactly as the others do — nothing is minted — and exists because -// the alternative was to answer ErrUnknownExecutionPrincipal, which is a -// statement ABOUT THE PRINCIPAL ("never heard of it") and was being returned -// for a state that says nothing about the principal at all. An operator -// debugging a cold start was told their principal did not exist. This file -// argues at length that those two answers must not read alike; reusing one for -// the other contradicted that in the one place it is most expensive, because -// the natural response to "unknown principal" is to go and change the -// declaration that was already correct. -// -// It is NOT returned by a host that answers no authority question at all (no -// envelope, or no delivery inbox wired). That host is a correct, complete -// deployment whose every principal genuinely is unknown and always will be, so -// ErrUnknownExecutionPrincipal is the accurate answer there and activation -// already names the configuration with -// ErrSolutionAuthorityCeilingUnavailable. -var ErrExecutionAuthorityUnreconciled = errors.New( - "the execution authority has completed no reconciliation pass") - -// executionTokenAudience is the audience a caller's token must be minted for. -// -// A token minted for the API server's own audience would authenticate the same -// service account while having been issued for something else entirely, so the -// audience is what makes the token a credential FOR this host. +// executionTokenAudience is the audience a module's projected token is minted +// for: this host. const executionTokenAudience = "accounts" -// BindExecution establishes what a caller is running and holds it against what -// the authority document approves. The caller passes its mint snapshot; both -// the declared container and the workload-owner lookup use that same registry. +// BindExecution establishes which workload a caller is and holds it against the +// workloads the principal's applied manifest lists. The caller passes its mint +// snapshot; the workload-owner lookup and the declared container both read +// that same registry. // -// THE ORDER IS LOAD-BEARING, and it is: authenticate, then read the pod the -// token named, then compare. Reading the pod first would mean reading a pod the -// caller named, which is a caller-supplied input; the token is what makes the -// pod reference trustworthy. +// THE ORDER IS LOAD-BEARING, and it is: authenticate, then resolve the +// reviewed workload to its principal, then read the pod the token named. +// Reading the pod first would mean reading a pod the caller named, which is a +// caller-supplied input; the token is what makes the pod reference +// trustworthy. func (s *Service) BindExecution( ctx context.Context, snapshot *installedModuleRegistry, principalID, token string, ) (*ExecutionIdentity, error) { - if s.executionReviewer == nil || s.executionAuthority == nil { + if s.executionReviewer == nil { return nil, fmt.Errorf("%w: this host has no execution reviewer wired", ErrExecutionUnbound) } if token == "" { return nil, fmt.Errorf("%w: no token presented", ErrExecutionNotBoundToken) } - // THE DECLARED WORKLOAD, read before the token is reviewed, because it is - // what the review will be held against. A principal this host has no - // declaration for is refused as unknown rather than as unapproved: nothing - // is known about it, so nothing can be approved for it, and the two must - // not read alike. + // THE DECLARED WORKLOADS, read before the token is reviewed, because they + // are what the review will be held against. A principal this host has no + // declaration for is refused as unknown. + // A workload stated in part would check the parts it has and skip the + // rest, so one is refused like none. grant, declared := snapshot.registry[principalID] - if !declared || !grant.Workload.declared() { - return nil, fmt.Errorf("%w: %s declares no workload identity", ErrUnknownExecutionPrincipal, principalID) + if !declared || len(grant.Workloads) == 0 || slices.ContainsFunc(grant.Workloads, func(w ModuleWorkload) bool { return !w.declared() }) { + return nil, fmt.Errorf("%w: %s declares no complete workload identity", ErrUnknownExecutionPrincipal, principalID) } - // The container comes from the declaration, never from the caller. It used - // to be an argument, which left the caller choosing which of its pod's - // images the approval was tested against — a pod's containers do not all - // run the same image, so naming a sibling picks the comparison. - containerName := grant.Workload.Container modules := snapshot.registry // 1. Authenticate. The API server checks the audience, so a token minted @@ -370,14 +238,13 @@ func (s *Service) BindExecution( if !s.executionReviewer.NativeDevelopment() { presenter := VerifiedWorkloadPresenter(ctx) identity, parseErr := ParseKubernetesSPIFFEID(presenter) - if parseErr != nil || presenter != grant.Workload.SPIFFEID || - identity.Namespace != reviewed.Namespace || identity.ServiceAccount != reviewed.ServiceAccount { + if parseErr != nil || identity.Namespace != reviewed.Namespace || identity.ServiceAccount != reviewed.ServiceAccount { return nil, fmt.Errorf("%w: authenticated presenter does not match the applied and reviewed workload", ErrExecutionIdentityMismatch) } } // Mesh identity identifies a ServiceAccount, not an individual pod. Keep - // the projected-token lifetime, pod UID and running-build checks as well. + // the projected-token lifetime and pod UID checks as well. if reviewed.IssuedAt.IsZero() || reviewed.ExpiresAt.IsZero() { return nil, fmt.Errorf("%w: the review reported no issue or expiry time", ErrExecutionTokenLifetime) } @@ -403,11 +270,18 @@ func (s *Service) BindExecution( len(owners), strings.Join(owners, ", ")) case len(owners) == 0 || owners[0] != principalID: return nil, fmt.Errorf( - "%w: %s is declared to run as %s/%s, the presented token authenticates %s/%s", - ErrExecutionIdentityMismatch, principalID, - grant.Workload.Namespace, grant.Workload.ServiceAccount, + "%w: %s is declared to run as %s, the presented token authenticates %s/%s", + ErrExecutionIdentityMismatch, principalID, describeWorkloads(grant.Workloads), reviewed.Namespace, reviewed.ServiceAccount) } + // The container comes from the manifest's workload, never from the + // caller. + containerName := "" + for _, workload := range grant.Workloads { + if workload.Namespace == reviewed.Namespace && workload.ServiceAccount == reviewed.ServiceAccount { + containerName = workload.Container + } + } // 2. Read the pod the TOKEN named — never one the caller named. pod, err := s.executionReviewer.ReadPod(ctx, reviewed.Namespace, reviewed.PodName) @@ -422,59 +296,9 @@ func (s *Service) BindExecution( ErrExecutionIdentityMismatch, reviewed.PodUID, reviewed.Namespace, reviewed.PodName, pod.UID) } - app, err := authenticatingContainer(pod, containerName, reviewed) - if err != nil { + if _, err := authenticatingContainer(pod, containerName, reviewed); err != nil { return nil, err } - // 3. Compare against what the authority approves. - // - // The approval is looked up by the name the AUTHORITY DOCUMENT gives the - // principal, which is the module's prefix (core's - // PrincipalAuthority.Principal), not by this host's registry key: the - // registry keys a principal by ModulePrincipalID(prefix), a UUID no - // document carries. Asking the approved-build view for the UUID answered - // "unknown principal" for every module the moment the mint first called - // this, which is how the two keys were found to differ (#1056). - approved, incarnation, err := s.executionAuthority.ApprovedBuild(ctx, grant.Prefix) - switch { - case errors.Is(err, ErrUnknownExecutionPrincipal): - return nil, fmt.Errorf("%w: %s", ErrUnknownExecutionPrincipal, principalID) - case errors.Is(err, ErrNoApprovedBuild): - // Known, and bears none: REFUSED. Execution binding is unconditional, - // so there is no such thing as a module capability carrying no - // execution. - // - // This branch used to return an identity with no running digest, for "a - // caller that may act unbound". Nothing could reach it — the only - // production ExecutionAuthority never calls Declare(), so the view - // answers a record or ErrUnknownExecutionPrincipal and never this. It - // was a fully built permissive path waiting for a trigger: adding the - // one Declare() call that the next legitimate requirement (a human - // session, say) obviously wants would have converted this refusal into - // a mintable capability, for a principal whose running build nothing - // checked, with no test failing. Deleting it is the point, not tidiness. - // - // The sentinel stays distinct from ErrUnknownExecutionPrincipal because - // the two are different operator problems — "declared but not yet - // approved" against "never heard of it" — and collapsing them would - // send the operator to the wrong place. Both refuse. - return nil, fmt.Errorf("%w: %s", ErrNoApprovedBuild, principalID) - case err != nil: - return nil, fmt.Errorf("%w: %w", ErrExecutionUnbound, err) - } - - if !digestsEqual(string(approved), app.ImageID) { - // The declared image is named in the refusal precisely because the - // interesting case is when it MATCHES the approval and the running image - // does not: that is a moved tag, and an operator reading only "not - // approved" would go looking at the authority document instead of the - // registry. - return nil, fmt.Errorf( - "%w: principal %s is approved for %s, pod %s/%s asked for %s and is running %s", - ErrExecutionNotApproved, principalID, approved, - reviewed.Namespace, reviewed.PodName, app.DeclaredImage, app.ImageID) - } - return &ExecutionIdentity{ PrincipalID: principalID, Namespace: reviewed.Namespace, @@ -482,12 +306,18 @@ func (s *Service) BindExecution( PodName: reviewed.PodName, PodUID: reviewed.PodUID, ContainerName: containerName, - Running: RunningDigest(app.ImageID), - Declared: DeclaredDigest(app.DeclaredImage), - Incarnation: incarnation, }, nil } +// describeWorkloads spells a principal's workloads for a refusal. +func describeWorkloads(workloads []ModuleWorkload) string { + described := make([]string, 0, len(workloads)) + for _, workload := range workloads { + described = append(described, workload.Namespace+"/"+workload.ServiceAccount) + } + return strings.Join(described, ", ") +} + // principalsDeclaringWorkload returns, sorted, every principal whose declared // workload runs as serviceAccount in namespace. The container is deliberately // not part of the key: a token authenticates the ServiceAccount, and every @@ -495,8 +325,11 @@ func (s *Service) BindExecution( func principalsDeclaringWorkload(modules ModulePrincipalRegistry, namespace, serviceAccount string) []string { var owners []string for id, grant := range modules { - if grant.Workload.Namespace == namespace && grant.Workload.ServiceAccount == serviceAccount { - owners = append(owners, id) + for _, workload := range grant.Workloads { + if workload.Namespace == namespace && workload.ServiceAccount == serviceAccount { + owners = append(owners, id) + break + } } } slices.Sort(owners) @@ -504,8 +337,7 @@ func principalsDeclaringWorkload(modules ModulePrincipalRegistry, namespace, ser } // authenticatingContainer holds a pod read to what an execution-bound mint -// requires, and returns the container whose running image the approval is -// compared against. +// requires, and returns the authenticating container. // // THE POD IS ONE SECURITY BOUNDARY. Every container in it can read the // projected token, so approving one container's image while another runs @@ -521,17 +353,10 @@ func principalsDeclaringWorkload(modules ModulePrincipalRegistry, namespace, ser // present. Init containers that ran to completion before the application // started are the only others tolerated. // -// WHY NOT ADMIT A DECLARED SIDECAR AT ITS DIGEST. That is the stronger-and- -// workable rule — every container the applied, signed presence document -// declares, each running its declared digest — and it cannot be enforced yet, -// because presence does not carry the digests. core's solutionhost.Workload -// (v0.17.2 through main) names the authenticating Container with its Image -// digest, and the others only as NonAuthenticating NAMES; the CLI renders -// exactly that (cli pkg/gitops/presence_workloads.go). Admitting a sidecar by -// name would let it run any image, so every other container is refused instead. -// The cost: a module pod with a sidecar — a Cloud SQL Auth Proxy, an -// istio-injected namespace — mints nothing until presence carries a digest per -// container (a core field, the CLI's render, and this check reading it). +// WHY NOT ADMIT A SIDECAR. The manifest names one authenticating container per +// workload and nothing about the pod's others; every container can read the +// projected token, so a container the manifest does not name is refused rather +// than admitted by name. // // A pod that has not started its container yet is ErrExecutionUnbound — the // host could not establish a live execution, and a retry may — while a pod or @@ -580,79 +405,11 @@ func authenticatingContainer(pod *PodExecution, name string, reviewed *ReviewedE default: return nil, fmt.Errorf("%w: container %s in pod %s is %s, not running", ErrExecutionUnbound, name, where, app.State) } - if app.ImageID == "" { - // A container with no imageID is not running an identifiable image - // (a runtime that does not report one). Nothing has been established. - return nil, fmt.Errorf("%w: container %s in pod %s reports no image id", ErrExecutionUnbound, name, where) - } return app, nil } -// digestsEqual compares an approved digest with a running imageID. -// -// A container's `imageID` is a REFERENCE carrying a digest, not a bare digest: -// Kubernetes reports forms like `registry/repo@sha256:…`, and some runtimes -// report `docker-pullable://registry/repo@sha256:…`. The approved digest may be -// written either as a bare `sha256:…` or as a full reference. -// -// So the comparison is on the digest PORTION, and it is an exact match on that -// portion rather than a suffix test. A suffix test is the trap: `strings. -// HasSuffix(imageID, approved)` passes when the approved digest is a suffix of a -// longer hex string, and a bare `sha256:` prefix check would let any repository -// satisfy an approval granted for one image. -// -// A value with no digest at all never matches, because an approval is only ever -// a statement about immutable content — a tag is not one. -// AttestedDigest is the image-manifest digest the caller is running: the -// `sha256:` portion of the container status BindExecution read, which is -// the value a Work Context mint attests and core holds against the approved -// build. Reports false when the status names no digest at all. -func (e ExecutionIdentity) AttestedDigest() (string, bool) { - return digestPortion(string(e.Running)) -} - -func digestsEqual(approved, imageID string) bool { - left, leftOK := digestPortion(approved) - right, rightOK := digestPortion(imageID) - return leftOK && rightOK && left == right -} - -// digestPortion extracts the `sha256:` portion of a reference. -// -// Reports false when there is none, rather than returning the whole string: a -// fallback to comparing whole strings would make two tags compare equal and so -// turn a tag into an approval. -func digestPortion(reference string) (string, bool) { - index := strings.LastIndex(reference, "@") - candidate := reference - if index >= 0 { - candidate = reference[index+1:] - } - algorithm, hex, found := strings.Cut(candidate, ":") - if !found || algorithm == "" || hex == "" { - return "", false - } - // Only lower-case hex, and only a length a real digest has. Anything else - // is not a digest, and accepting it would let an arbitrary string that - // merely contains a colon pass as content-addressed. - if len(hex) < 32 { - return "", false - } - for i := 0; i < len(hex); i++ { - c := hex[i] - if (c < '0' || c > '9') && (c < 'a' || c > 'f') { - return "", false - } - } - return algorithm + ":" + hex, true -} - -// SetExecutionBinding wires the independent sources. -// -// Leaving them unset means BindExecution answers ErrExecutionUnbound — never a -// mint that skips the check. A host that cannot establish what a caller is -// running must not issue a capability claiming it did. -func (s *Service) SetExecutionBinding(reviewer ExecutionReviewer, authority ExecutionAuthority) { +// SetExecutionBinding wires the workload reviewer every module mint binds +// through. Boot wiring. +func (s *Service) SetExecutionBinding(reviewer ExecutionReviewer) { s.executionReviewer = reviewer - s.executionAuthority = authority } diff --git a/module/services/accounts/code/pkg/business/module_execution_binding_test.go b/module/services/accounts/code/pkg/business/module_execution_binding_test.go index 426e4a607..d391e1d04 100644 --- a/module/services/accounts/code/pkg/business/module_execution_binding_test.go +++ b/module/services/accounts/code/pkg/business/module_execution_binding_test.go @@ -6,41 +6,21 @@ import ( "context" "errors" "fmt" - "strings" "testing" "time" "github.com/stretchr/testify/require" ) -// Execution-bound minting, and every way the binding could be defeated. -// -// The digests here are realistic shapes rather than short strings, because the -// comparison's correctness depends on the shapes: a container's `imageID` is a -// digest-bearing REFERENCE, not a bare digest, and that is where a suffix test -// or a prefix check goes wrong. - -const ( - approvedRef = "registry.example.com/acme/worker@sha256:" + - "1111111111111111111111111111111111111111111111111111111111111111" - // The same digest as a bare value, which is the other legitimate spelling. - approvedBare = "sha256:" + - "1111111111111111111111111111111111111111111111111111111111111111" - // A different image, same registry and repository. - otherRef = "registry.example.com/acme/worker@sha256:" + - "2222222222222222222222222222222222222222222222222222222222222222" - // The approved digest as some runtimes report it. - pullableRef = "docker-pullable://registry.example.com/acme/worker@sha256:" + - "1111111111111111111111111111111111111111111111111111111111111111" -) +// Execution-bound minting, and every way the binding could be defeated. The +// binding is the workload: the reviewed (namespace, ServiceAccount) against the +// workloads the principal's applied manifest lists. No image is compared. // testRunning is the declared container as a pod read reports it: running, // regular, alone in a Running pod unless a test says otherwise. type testRunning struct { - UID string - ImageID string - DeclaredImage string - Found bool + UID string + Found bool } type fakeExecutionReviewer struct { @@ -87,30 +67,11 @@ func (f *fakeExecutionReviewer) ReadPod( } read.Containers = append(read.Containers, ContainerExecution{ Name: name, Kind: ContainerRegular, State: ContainerRunning, - ImageID: f.running.ImageID, DeclaredImage: f.running.DeclaredImage, }) } return read, nil } -type fakeExecutionAuthority struct { - digest ApprovedDigest - incarnation uint64 - err error - // asked records which principal the approval was looked up by. - asked string -} - -func (f *fakeExecutionAuthority) ApprovedBuild( - _ context.Context, principal string, -) (ApprovedDigest, uint64, error) { - f.asked = principal - if f.err != nil { - return "", 0, f.err - } - return f.digest, f.incarnation, nil -} - // boundService wires the two independent sources AND declares the principal's // workload, because the declaration is now part of the check: BindExecution // holds the reviewed service account against the one the principal declares, so @@ -119,19 +80,17 @@ func (f *fakeExecutionAuthority) ApprovedBuild( // The declared workload matches reviewedPod() deliberately, so the tests below // that are about the IMAGE are not also about the identity. The ones about the // identity change one of these on purpose. -func boundService(reviewer ExecutionReviewer, authority ExecutionAuthority) *Service { - return boundServiceFor(reviewer, authority, ModuleWorkload{ +func boundService(reviewer ExecutionReviewer) *Service { + return boundServiceFor(reviewer, ModuleWorkload{ ServiceAccount: "worker", Namespace: "acme-prod", Container: "worker", }) } -func boundServiceFor( - reviewer ExecutionReviewer, authority ExecutionAuthority, workload ModuleWorkload, -) *Service { +func boundServiceFor(reviewer ExecutionReviewer, workload ModuleWorkload) *Service { service := &Service{store: noopControlPlaneStore{}} - service.SetExecutionBinding(reviewer, authority) + service.SetExecutionBinding(reviewer) service.SetModulePrincipals(ModulePrincipalRegistry{ - "principal-1": ModulePrincipalGrant{Prefix: "worker", Tenant: "11111111-1111-4111-8111-111111111111", Workload: workload}, + "principal-1": ModulePrincipalGrant{Prefix: "worker", Tenant: "11111111-1111-4111-8111-111111111111", Workloads: []ModuleWorkload{workload}}, }) return service } @@ -152,21 +111,19 @@ func reviewedPod() *ReviewedExecutionToken { // The happy path, and the independence of the three sources // --------------------------------------------------------------------------- -// A pod running the approved image binds, and the identity carries what was +// A pod of a declared workload binds, and the identity carries what was // ESTABLISHED rather than what was presented. -func TestApprovedRunningImageBinds(t *testing.T) { +func TestADeclaredWorkloadBinds(t *testing.T) { reviewer := &fakeExecutionReviewer{ reviewed: reviewedPod(), - running: &testRunning{UID: "uid-alpha", ImageID: approvedRef, Found: true}, + running: &testRunning{UID: "uid-alpha", Found: true}, } - service := boundService(reviewer, &fakeExecutionAuthority{digest: approvedBare, incarnation: 7}) + service := boundService(reviewer) identity, err := service.BindExecution(context.Background(), service.moduleRegistrySnapshot(), "principal-1", "token") require.NoError(t, err) - require.Equal(t, RunningDigest(approvedRef), identity.Running) - require.Equal(t, uint64(7), identity.Incarnation, - "the incarnation comes from the authority document, never from the pod") require.Equal(t, "uid-alpha", identity.PodUID) + require.Equal(t, "worker", identity.ContainerName) // The pod read used the namespace and name the TOKEN named. A caller cannot // point the host at a different pod, which is why the token comes first. @@ -176,98 +133,19 @@ func TestApprovedRunningImageBinds(t *testing.T) { "the token must have been minted for this host, not for the API server") } -// Approved written as a full reference and running reported as a pullable URI -// still bind: the comparison is on the digest portion, not on the spelling. -func TestDigestComparisonIsOnTheDigestNotTheSpelling(t *testing.T) { - reviewer := &fakeExecutionReviewer{ - reviewed: reviewedPod(), - running: &testRunning{UID: "uid-alpha", ImageID: pullableRef, Found: true}, - } - service := boundService(reviewer, &fakeExecutionAuthority{digest: approvedRef, incarnation: 1}) - - identity, err := service.BindExecution(context.Background(), service.moduleRegistrySnapshot(), "principal-1", "token") - require.NoError(t, err) - require.Equal(t, uint64(1), identity.Incarnation) -} - // --------------------------------------------------------------------------- // Refusals: a verdict about the caller // --------------------------------------------------------------------------- -// THE MOVED TAG, and the case that proves the three digest types are distinct by -// execution rather than by comment. -// -// The pod's SPEC names exactly the approved image. Its STATUS reports something -// else — which is what a moved tag produces, and what a mutable registry -// reference makes possible at any time. Comparing approved against DECLARED -// would pass here and admit an unapproved workload; comparing declared against -// running would prove only that the pod got what it asked for. -// -// Approved == RUNNING is the only comparison that refuses it. -func TestAPodThatAskedForTheApprovedImageAndRunsAnotherIsRefused(t *testing.T) { - reviewer := &fakeExecutionReviewer{ - reviewed: reviewedPod(), - running: &testRunning{ - UID: "uid-alpha", - // Asked for the approved image... - DeclaredImage: approvedRef, - // ...and is running a different one. - ImageID: otherRef, - Found: true, - }, - } - service := boundService(reviewer, &fakeExecutionAuthority{digest: approvedBare, incarnation: 1}) - - _, err := service.BindExecution(context.Background(), service.moduleRegistrySnapshot(), "principal-1", "token") - require.ErrorIs(t, err, ErrExecutionNotApproved) - // The refusal names BOTH, because an operator told only "not approved" would - // go reading the authority document when the registry is what moved. - require.Contains(t, err.Error(), approvedRef, "the refusal must name what was asked for") - require.Contains(t, err.Error(), otherRef, "and what is actually running") -} - -// And the control: the same spec with a RUNNING image that matches is admitted, -// so the test above is not passing because every declared image is refused. -func TestAPodRunningWhatItAskedForAndWhatIsApprovedIsAdmitted(t *testing.T) { - reviewer := &fakeExecutionReviewer{ - reviewed: reviewedPod(), - running: &testRunning{ - UID: "uid-alpha", DeclaredImage: approvedRef, ImageID: approvedRef, Found: true, - }, - } - service := boundService(reviewer, &fakeExecutionAuthority{digest: approvedBare, incarnation: 4}) - - identity, err := service.BindExecution(context.Background(), service.moduleRegistrySnapshot(), "principal-1", "token") - require.NoError(t, err) - require.Equal(t, RunningDigest(approvedRef), identity.Running) - require.Equal(t, DeclaredDigest(approvedRef), identity.Declared, - "the declared image is carried for diagnosis, distinct from the running one") -} - -// A pod running a DIFFERENT image from the same repository is refused. Same -// registry, same repo, different digest — which is what a moved tag produces. -func TestUnapprovedRunningImageIsRefused(t *testing.T) { - reviewer := &fakeExecutionReviewer{ - reviewed: reviewedPod(), - running: &testRunning{UID: "uid-alpha", ImageID: otherRef, Found: true}, - } - service := boundService(reviewer, &fakeExecutionAuthority{digest: approvedBare, incarnation: 1}) - - _, err := service.BindExecution(context.Background(), service.moduleRegistrySnapshot(), "principal-1", "token") - require.ErrorIs(t, err, ErrExecutionNotApproved) - require.NotErrorIs(t, err, ErrExecutionUnbound, - "this is a verdict about the caller, not a failure to establish one") -} - // A replacement pod at the SAME NAME is refused, and this is the check the UID // exists for. A name comparison would admit it silently. func TestReplacementPodAtTheSameNameIsRefused(t *testing.T) { reviewer := &fakeExecutionReviewer{ reviewed: reviewedPod(), // Same name, running the approved image, DIFFERENT uid. - running: &testRunning{UID: "uid-beta", ImageID: approvedRef, Found: true}, + running: &testRunning{UID: "uid-beta", Found: true}, } - service := boundService(reviewer, &fakeExecutionAuthority{digest: approvedBare, incarnation: 1}) + service := boundService(reviewer) _, err := service.BindExecution(context.Background(), service.moduleRegistrySnapshot(), "principal-1", "token") require.ErrorIs(t, err, ErrExecutionIdentityMismatch) @@ -282,9 +160,9 @@ func TestReplacementPodAtTheSameNameIsRefused(t *testing.T) { func TestNonBoundTokenIsRefusedRatherThanSkipped(t *testing.T) { reviewer := &fakeExecutionReviewer{ reviewed: &ReviewedExecutionToken{ServiceAccount: "worker", Namespace: "acme-prod"}, - running: &testRunning{UID: "uid-alpha", ImageID: approvedRef, Found: true}, + running: &testRunning{UID: "uid-alpha", Found: true}, } - service := boundService(reviewer, &fakeExecutionAuthority{digest: approvedBare}) + service := boundService(reviewer) _, err := service.BindExecution(context.Background(), service.moduleRegistrySnapshot(), "principal-1", "token") require.ErrorIs(t, err, ErrExecutionNotBoundToken) @@ -297,9 +175,9 @@ func TestNonBoundTokenIsRefusedRatherThanSkipped(t *testing.T) { func TestUnknownPrincipalIsRefusedNotMintedUnbound(t *testing.T) { reviewer := &fakeExecutionReviewer{ reviewed: reviewedPod(), - running: &testRunning{UID: "uid-alpha", ImageID: approvedRef, Found: true}, + running: &testRunning{UID: "uid-alpha", Found: true}, } - service := boundService(reviewer, &fakeExecutionAuthority{err: ErrUnknownExecutionPrincipal}) + service := boundService(reviewer) _, err := service.BindExecution(context.Background(), service.moduleRegistrySnapshot(), "stranger", "token") require.ErrorIs(t, err, ErrUnknownExecutionPrincipal) @@ -320,29 +198,16 @@ func TestNothingIsIssuedWhenTheApiIsUnavailable(t *testing.T) { // Unreachable at the TokenReview. service := boundService( - &fakeExecutionReviewer{reviewErr: unreachable}, - &fakeExecutionAuthority{digest: approvedBare}) + &fakeExecutionReviewer{reviewErr: unreachable}) _, err := service.BindExecution(context.Background(), service.moduleRegistrySnapshot(), "principal-1", "token") require.ErrorIs(t, err, ErrExecutionUnbound) - require.NotErrorIs(t, err, ErrExecutionNotApproved) // Unreachable at the pod read, after a successful review. service = boundService( - &fakeExecutionReviewer{reviewed: reviewedPod(), runErr: unreachable}, - &fakeExecutionAuthority{digest: approvedBare}) + &fakeExecutionReviewer{reviewed: reviewedPod(), runErr: unreachable}) _, err = service.BindExecution(context.Background(), service.moduleRegistrySnapshot(), "principal-1", "token") require.ErrorIs(t, err, ErrExecutionUnbound) - // Unreadable authority document. - service = boundService( - &fakeExecutionReviewer{ - reviewed: reviewedPod(), - running: &testRunning{UID: "uid-alpha", ImageID: approvedRef, Found: true}, - }, - &fakeExecutionAuthority{err: unreachable}) - _, err = service.BindExecution(context.Background(), service.moduleRegistrySnapshot(), "principal-1", "token") - require.ErrorIs(t, err, ErrExecutionUnbound) - require.NotErrorIs(t, err, ErrExecutionNotApproved) } // A token the API server REVIEWED and refused — minted for another audience, @@ -353,7 +218,7 @@ func TestARejectedTokenIsAVerdictNotAnOutage(t *testing.T) { reviewer := &fakeExecutionReviewer{ reviewErr: fmt.Errorf("%w: token audiences [vault] do not include accounts", ErrExecutionTokenRejected), } - service := boundService(reviewer, &fakeExecutionAuthority{digest: approvedBare}) + service := boundService(reviewer) _, err := service.BindExecution(context.Background(), service.moduleRegistrySnapshot(), "principal-1", "token") @@ -363,24 +228,6 @@ func TestARejectedTokenIsAVerdictNotAnOutage(t *testing.T) { "the review must ask for this host's audience, so the API server refuses any other") } -// The approval is looked up by the name the AUTHORITY DOCUMENT gives the -// principal — its prefix — and never by this host's registry key. The registry -// keys a principal by ModulePrincipalID(prefix), a UUID no document carries, so -// asking the approved-build view for it found nothing for any module (#1056). -func TestTheApprovalIsLookedUpByTheDocumentsNameForThePrincipal(t *testing.T) { - reviewer := &fakeExecutionReviewer{ - reviewed: reviewedPod(), - running: &testRunning{UID: "uid-alpha", ImageID: approvedRef, Found: true}, - } - authority := &fakeExecutionAuthority{digest: approvedBare, incarnation: 2} - service := boundService(reviewer, authority) - - _, err := service.BindExecution(context.Background(), service.moduleRegistrySnapshot(), "principal-1", "token") - - require.NoError(t, err) - require.Equal(t, "worker", authority.asked, "the declared grant's prefix, as the authority document names it") -} - // A host with no reviewer wired cannot establish anything and says so, rather // than minting a capability that claims it did. func TestUnwiredHostCannotBindExecution(t *testing.T) { @@ -389,21 +236,6 @@ func TestUnwiredHostCannotBindExecution(t *testing.T) { require.ErrorIs(t, err, ErrExecutionUnbound) } -// A container still pulling reports no imageID: nothing has been established, so -// it is unbound rather than unapproved. -func TestContainerWithNoImageIdIsUnboundNotUnapproved(t *testing.T) { - service := boundService( - &fakeExecutionReviewer{ - reviewed: reviewedPod(), - running: &testRunning{UID: "uid-alpha", ImageID: "", Found: true}, - }, - &fakeExecutionAuthority{digest: approvedBare}) - - _, err := service.BindExecution(context.Background(), service.moduleRegistrySnapshot(), "principal-1", "token") - require.ErrorIs(t, err, ErrExecutionUnbound) - require.NotErrorIs(t, err, ErrExecutionNotApproved) -} - // A missing container is a mismatch, not a non-match: the host established // nothing about what is running. func TestMissingContainerIsAMismatchNotANonMatch(t *testing.T) { @@ -411,12 +243,10 @@ func TestMissingContainerIsAMismatchNotANonMatch(t *testing.T) { &fakeExecutionReviewer{ reviewed: reviewedPod(), running: &testRunning{UID: "uid-alpha", Found: false}, - }, - &fakeExecutionAuthority{digest: approvedBare}) + }) _, err := service.BindExecution(context.Background(), service.moduleRegistrySnapshot(), "principal-1", "token") require.ErrorIs(t, err, ErrExecutionIdentityMismatch) - require.NotErrorIs(t, err, ErrExecutionNotApproved) } // No container named is refused rather than defaulting to the pod's first, and @@ -437,10 +267,9 @@ func TestMissingContainerIsAMismatchNotANonMatch(t *testing.T) { func TestNoContainerDeclaredReadsNoPodAtAll(t *testing.T) { reviewer := &fakeExecutionReviewer{ reviewed: reviewedPod(), - running: &testRunning{UID: "uid-alpha", ImageID: approvedRef, Found: true}, + running: &testRunning{UID: "uid-alpha", Found: true}, } - service := boundServiceFor(reviewer, &fakeExecutionAuthority{digest: approvedBare}, - ModuleWorkload{ServiceAccount: "worker", Namespace: "acme-prod"}) + service := boundServiceFor(reviewer, ModuleWorkload{ServiceAccount: "worker", Namespace: "acme-prod"}) _, err := service.BindExecution(context.Background(), service.moduleRegistrySnapshot(), "principal-1", "token") require.ErrorIs(t, err, ErrUnknownExecutionPrincipal) @@ -452,138 +281,10 @@ func TestNoContainerDeclaredReadsNoPodAtAll(t *testing.T) { // Bearing no approved build: the third state // --------------------------------------------------------------------------- -// A known principal bearing no approved build binds WITHOUT an execution, and -// the pair stays whole-or-absent. -// -// Both fields absent together is what core's optional-and-paired seal requires: -// a digest with no incarnation, or an incarnation with no digest, is refused by -// its schema. -// A principal the host knows but has approved no build for is REFUSED, and is -// told apart from a principal the host has never heard of. -// -// This test asserted the opposite until the branch was deleted: it required -// NoError and an empty Running digest, locking in "a capability may still be -// minted, carrying NO execution". Nothing in production could reach that answer -// — the only ExecutionAuthority never calls Declare() — so the permissive path -// sat fully built and unit-tested, one Declare() call away from being live. The -// test is ADAPTED rather than deleted, because the input it drives is still a -// real answer from the authority and the question "what happens to it" still -// needs one. -func TestPrincipalBearingNoApprovedBuildIsRefusedNotMintedUnbound(t *testing.T) { - service := boundService( - &fakeExecutionReviewer{ - reviewed: reviewedPod(), - running: &testRunning{UID: "uid-alpha", ImageID: approvedRef, Found: true}, - }, - &fakeExecutionAuthority{err: ErrNoApprovedBuild}) - - identity, err := service.BindExecution(context.Background(), service.moduleRegistrySnapshot(), "principal-1", "token") - require.Error(t, err, "execution binding is unconditional: there is nothing to mint") - require.Nil(t, identity, "no identity is returned, so none can be sealed") - require.ErrorIs(t, err, ErrNoApprovedBuild, - "and NOT ErrUnknownExecutionPrincipal: declared-but-unapproved and never-heard-of are "+ - "different operator problems, so the sentinels stay separate even though both refuse") - require.NotErrorIs(t, err, ErrUnknownExecutionPrincipal) -} - -// THE INVARIANT, over every answer the authority can give: if BindExecution -// returns an identity at all, that identity carries a running digest. -// -// Stated as a property rather than as another example, because the failure this -// guards is someone re-introducing a "may act unbound" path — and a path like -// that arrives with its own passing example test. What it cannot do is satisfy -// this: a mintable identity with an empty Running digest fails here whatever -// route produced it. -// -// Mutation-verified: making the ErrNoApprovedBuild case return -// &ExecutionIdentity{...} with no Running digest (the code exactly as it was -// before this fix) fails this test on that row, and returning one with a -// Running digest copied from the pod's status fails the approved-digest -// comparison instead. Removing the require.NotEmpty makes the whole test pass -// against the old permissive branch, which is how I know the assertion is the -// load-bearing line and not the table. -func TestEveryBoundExecutionCarriesARunningDigest(t *testing.T) { - for _, answer := range []struct { - name string - authority *fakeExecutionAuthority - }{ - {"approved", &fakeExecutionAuthority{digest: approvedBare, incarnation: 3}}, - {"known, no approved build", &fakeExecutionAuthority{err: ErrNoApprovedBuild}}, - {"unknown principal", &fakeExecutionAuthority{err: ErrUnknownExecutionPrincipal}}, - {"authority unreadable", &fakeExecutionAuthority{err: errors.New("authority is unreadable")}}, - } { - t.Run(answer.name, func(t *testing.T) { - service := boundService( - &fakeExecutionReviewer{ - reviewed: reviewedPod(), - running: &testRunning{UID: "uid-alpha", ImageID: approvedRef, Found: true}, - }, - answer.authority) - - identity, err := service.BindExecution(context.Background(), service.moduleRegistrySnapshot(), "principal-1", "token") - if err != nil { - require.Nil(t, identity, - "a refusal returns no identity, or a caller could seal the one it was handed anyway") - return - } - require.NotEmpty(t, identity.Running, - "a minted execution identity with no running digest is a capability no verifier can "+ - "hold against a running image: execution binding is unconditional") - }) - } -} - // --------------------------------------------------------------------------- // The digest comparison itself, where the subtle defeats live // --------------------------------------------------------------------------- -// A tag is never an approval, however it is spelled. -// -// An approval is a statement about immutable content, and a tag is not one, so a -// value with no digest must never match — including when both sides carry the -// same tag, which a whole-string fallback would accept. -func TestATagNeverMatches(t *testing.T) { - for _, pair := range []struct{ approved, running string }{ - {"registry.example.com/acme/worker:v1", "registry.example.com/acme/worker:v1"}, - {approvedBare, "registry.example.com/acme/worker:v1"}, - {"registry.example.com/acme/worker:v1", approvedRef}, - {"", approvedRef}, - {approvedBare, ""}, - } { - require.False(t, digestsEqual(pair.approved, pair.running), - "approved %q must not match running %q", pair.approved, pair.running) - } -} - -// A suffix is not a match, which is the specific trap `strings.HasSuffix` sets: -// an approved digest that is a suffix of a longer hex string would pass. -func TestASuffixIsNotAMatch(t *testing.T) { - longer := "sha256:ff" + strings.TrimPrefix(approvedBare, "sha256:") - require.False(t, digestsEqual(approvedBare, "registry.example.com/acme/worker@"+longer), - "a digest that merely ENDS WITH the approved one is a different image") -} - -// A shared prefix is not a match either: checking only that both start with -// `sha256:` would let any repository satisfy an approval granted for one image. -func TestASharedAlgorithmPrefixIsNotAMatch(t *testing.T) { - require.False(t, digestsEqual(approvedBare, otherRef)) -} - -// A non-hex or too-short value is not a digest, so it never matches — otherwise -// any string containing a colon would pass as content-addressed. -func TestANonDigestIsNotADigest(t *testing.T) { - for _, value := range []string{"sha256:short", "sha256:" + strings.Repeat("z", 64), "notadigest", "sha256:"} { - _, ok := digestPortion(value) - require.False(t, ok, "%q must not read as a digest", value) - } - // And the control: the real shapes DO read as digests, or the test above - // would pass against a function that rejects everything. - for _, value := range []string{approvedBare, approvedRef, pullableRef} { - _, ok := digestPortion(value) - require.True(t, ok, "%q must read as a digest", value) - } -} - // --------------------------------------------------------------------------- // The principal is a CLAIM, and the declared workload is what checks it // --------------------------------------------------------------------------- @@ -605,9 +306,9 @@ func TestACallerCannotNameAnotherPrincipalsPrefix(t *testing.T) { PodName: "intruder-1", PodUID: "uid-intruder", IssuedAt: time.Now(), ExpiresAt: time.Now().Add(time.Minute), }, - running: &testRunning{UID: "uid-intruder", ImageID: approvedRef, Found: true}, + running: &testRunning{UID: "uid-intruder", Found: true}, } - service := boundService(reviewer, &fakeExecutionAuthority{digest: approvedBare, incarnation: 7}) + service := boundService(reviewer) _, err := service.BindExecution(context.Background(), service.moduleRegistrySnapshot(), "principal-1", "token") require.ErrorIs(t, err, ErrExecutionIdentityMismatch, @@ -625,9 +326,9 @@ func TestACallerInAnotherNamespaceIsRefused(t *testing.T) { PodName: "worker-7c9f", PodUID: "uid-alpha", IssuedAt: time.Now(), ExpiresAt: time.Now().Add(time.Minute), }, - running: &testRunning{UID: "uid-alpha", ImageID: approvedRef, Found: true}, + running: &testRunning{UID: "uid-alpha", Found: true}, } - service := boundService(reviewer, &fakeExecutionAuthority{digest: approvedBare, incarnation: 7}) + service := boundService(reviewer) _, err := service.BindExecution(context.Background(), service.moduleRegistrySnapshot(), "principal-1", "token") require.ErrorIs(t, err, ErrExecutionIdentityMismatch) @@ -641,14 +342,12 @@ func TestACallerInAnotherNamespaceIsRefused(t *testing.T) { func TestAnUndeclaredPrincipalIsUnknownRatherThanUnapproved(t *testing.T) { reviewer := &fakeExecutionReviewer{ reviewed: reviewedPod(), - running: &testRunning{UID: "uid-alpha", ImageID: approvedRef, Found: true}, + running: &testRunning{UID: "uid-alpha", Found: true}, } - service := boundService(reviewer, &fakeExecutionAuthority{digest: approvedBare, incarnation: 7}) + service := boundService(reviewer) _, err := service.BindExecution(context.Background(), service.moduleRegistrySnapshot(), "principal-unheard-of", "token") require.ErrorIs(t, err, ErrUnknownExecutionPrincipal) - require.NotErrorIs(t, err, ErrNoApprovedBuild, - "an unknown principal must not read as one that bears no build, which would mint an unbound capability") } // A declaration missing any part of its workload is refused too, rather than @@ -663,10 +362,9 @@ func TestAPartiallyDeclaredWorkloadAuthenticatesNobody(t *testing.T) { t.Run(name, func(t *testing.T) { reviewer := &fakeExecutionReviewer{ reviewed: reviewedPod(), - running: &testRunning{UID: "uid-alpha", ImageID: approvedRef, Found: true}, + running: &testRunning{UID: "uid-alpha", Found: true}, } - service := boundServiceFor(reviewer, - &fakeExecutionAuthority{digest: approvedBare, incarnation: 7}, workload) + service := boundServiceFor(reviewer, workload) _, err := service.BindExecution(context.Background(), service.moduleRegistrySnapshot(), "principal-1", "token") require.ErrorIs(t, err, ErrUnknownExecutionPrincipal) @@ -675,23 +373,19 @@ func TestAPartiallyDeclaredWorkloadAuthenticatesNobody(t *testing.T) { } // THE CONTAINER COMES FROM THE DECLARATION, which is the last degree of freedom -// the caller had over its own identity: a pod's containers do not all run the -// same image, so naming a sibling picks which image the approval is tested -// against. The pod's "worker" container runs the approved image; the principal +// the caller had over its own identity. The pod holds "worker"; the principal // declares "declared-app", which the pod does not hold — so nothing binds, and // "worker" is not even tolerated beside it. func TestTheContainerReadIsTheDeclaredOneNotTheCallersChoice(t *testing.T) { reviewer := &fakeExecutionReviewer{ reviewed: reviewedPod(), - running: &testRunning{UID: "uid-alpha", ImageID: approvedRef, Found: true}, + running: &testRunning{UID: "uid-alpha", Found: true}, } - service := boundServiceFor(reviewer, - &fakeExecutionAuthority{digest: approvedBare, incarnation: 7}, - ModuleWorkload{ServiceAccount: "worker", Namespace: "acme-prod", Container: "declared-app"}) + service := boundServiceFor(reviewer, ModuleWorkload{ServiceAccount: "worker", Namespace: "acme-prod", Container: "declared-app"}) _, err := service.BindExecution(context.Background(), service.moduleRegistrySnapshot(), "principal-1", "token") require.ErrorIs(t, err, ErrExecutionUnapprovedContainer, - "an approved image in a container the principal does not declare must not stand in for the declared one") + "a container the principal does not declare must not stand in for the declared one") reviewer.container = "declared-app" _, err = service.BindExecution(context.Background(), service.moduleRegistrySnapshot(), "principal-1", "token") @@ -700,3 +394,25 @@ func TestTheContainerReadIsTheDeclaredOneNotTheCallersChoice(t *testing.T) { // NativeDevelopment identifies this in-memory cluster fixture; it models no mesh. func (*fakeExecutionReviewer) NativeDevelopment() bool { return true } + +// A principal runs as every workload its manifest lists: the same principal +// binds from each of its services, and the container is the one declared for +// the reviewed account. +func TestEveryWorkloadOfTheManifestBindsItsPrincipal(t *testing.T) { + service := &Service{store: noopControlPlaneStore{}} + for _, account := range []string{"worker", "api"} { + reviewed := reviewedPod() + reviewed.ServiceAccount = account + reviewer := &fakeExecutionReviewer{reviewed: reviewed, running: &testRunning{UID: "uid-alpha", Found: true}, container: account} + service.SetExecutionBinding(reviewer) + service.SetModulePrincipals(ModulePrincipalRegistry{"principal-1": ModulePrincipalGrant{ + Prefix: "worker", Tenant: "11111111-1111-4111-8111-111111111111", Workloads: []ModuleWorkload{ + {ServiceAccount: "worker", Namespace: "acme-prod", Container: "worker"}, + {ServiceAccount: "api", Namespace: "acme-prod", Container: "api"}, + }, + }}) + identity, err := service.BindExecution(context.Background(), service.moduleRegistrySnapshot(), "principal-1", "token") + require.NoError(t, err, account) + require.Equal(t, account, identity.ContainerName) + } +} diff --git a/module/services/accounts/code/pkg/business/module_execution_review_test.go b/module/services/accounts/code/pkg/business/module_execution_review_test.go index 3f4c53ead..553b0ed27 100644 --- a/module/services/accounts/code/pkg/business/module_execution_review_test.go +++ b/module/services/accounts/code/pkg/business/module_execution_review_test.go @@ -17,14 +17,13 @@ import ( // approved image, plus whatever else a test puts beside it. func runningPodWith(others ...ContainerExecution) *PodExecution { return &PodExecution{UID: "uid-alpha", Phase: PodPhaseRunning, Containers: append([]ContainerExecution{{ - Name: "worker", Kind: ContainerRegular, State: ContainerRunning, ImageID: approvedRef, DeclaredImage: approvedRef, + Name: "worker", Kind: ContainerRegular, State: ContainerRunning, }}, others...)} } func bindAgainst(t *testing.T, pod *PodExecution) error { t.Helper() - service := boundService(&fakeExecutionReviewer{reviewed: reviewedPod(), pod: pod}, - &fakeExecutionAuthority{digest: approvedBare, incarnation: 3}) + service := boundService(&fakeExecutionReviewer{reviewed: reviewedPod(), pod: pod}) _, err := service.BindExecution(context.Background(), service.moduleRegistrySnapshot(), "principal-1", "token") return err } @@ -35,12 +34,12 @@ func bindAgainst(t *testing.T, pod *PodExecution) error { // ServiceAccount proves neither — both are refused. func TestAWorkloadSharedByTwoPrincipalsMintsAsNeither(t *testing.T) { shared := ModuleWorkload{ServiceAccount: "worker", Namespace: "acme-prod", Container: "worker"} - reviewer := &fakeExecutionReviewer{reviewed: reviewedPod(), running: &testRunning{UID: "uid-alpha", ImageID: approvedRef, Found: true}} + reviewer := &fakeExecutionReviewer{reviewed: reviewedPod(), running: &testRunning{UID: "uid-alpha", Found: true}} service := &Service{store: noopControlPlaneStore{}} - service.SetExecutionBinding(reviewer, &fakeExecutionAuthority{digest: approvedBare, incarnation: 1}) + service.SetExecutionBinding(reviewer) service.SetModulePrincipals(ModulePrincipalRegistry{ - "principal-alpha": {Prefix: "alpha", Tenant: "11111111-1111-4111-8111-111111111111", Workload: shared}, - "principal-beta": {Prefix: "beta", Tenant: "22222222-2222-4222-8222-222222222222", Workload: shared}, + "principal-alpha": {Prefix: "alpha", Tenant: "11111111-1111-4111-8111-111111111111", Workloads: []ModuleWorkload{shared}}, + "principal-beta": {Prefix: "beta", Tenant: "22222222-2222-4222-8222-222222222222", Workloads: []ModuleWorkload{shared}}, }) for _, principal := range []string{"principal-alpha", "principal-beta"} { _, err := service.BindExecution(context.Background(), service.moduleRegistrySnapshot(), principal, "token") @@ -52,9 +51,9 @@ func TestAWorkloadSharedByTwoPrincipalsMintsAsNeither(t *testing.T) { // The control: given distinct ServiceAccounts, alpha's token mints alpha // and never beta. service.SetModulePrincipals(ModulePrincipalRegistry{ - "principal-alpha": {Prefix: "alpha", Tenant: "11111111-1111-4111-8111-111111111111", Workload: shared}, + "principal-alpha": {Prefix: "alpha", Tenant: "11111111-1111-4111-8111-111111111111", Workloads: []ModuleWorkload{shared}}, "principal-beta": {Prefix: "beta", Tenant: "22222222-2222-4222-8222-222222222222", - Workload: ModuleWorkload{ServiceAccount: "beta", Namespace: "acme-prod", Container: "worker"}}, + Workloads: []ModuleWorkload{ModuleWorkload{ServiceAccount: "beta", Namespace: "acme-prod", Container: "worker"}}}, }) _, err := service.BindExecution(context.Background(), service.moduleRegistrySnapshot(), "principal-alpha", "token") require.NoError(t, err) @@ -67,16 +66,15 @@ func TestAWorkloadSharedByTwoPrincipalsMintsAsNeither(t *testing.T) { // approval does not cover refuses the mint — whatever state it is in, for an // ephemeral (debug) container, a sidecar or a restartable init container. func TestAnyUnapprovedContainerInThePodMintsNothing(t *testing.T) { - other := "registry.example/sidecar@sha256:" + "3333333333333333333333333333333333333333333333333333333333333333" for name, container := range map[string]ContainerExecution{ - "a running sidecar": {Name: "sidecar", Kind: ContainerRegular, State: ContainerRunning, ImageID: other}, - "a stopped sidecar that can restart": {Name: "sidecar", Kind: ContainerRegular, State: ContainerTerminated, ImageID: other}, + "a running sidecar": {Name: "sidecar", Kind: ContainerRegular, State: ContainerRunning}, + "a stopped sidecar that can restart": {Name: "sidecar", Kind: ContainerRegular, State: ContainerTerminated}, "a sidecar not yet reported": {Name: "sidecar", Kind: ContainerRegular, State: ContainerStateUnknown}, - "a running ephemeral container": {Name: "debug", Kind: ContainerEphemeral, State: ContainerRunning, ImageID: other}, - "an exited ephemeral container": {Name: "debug", Kind: ContainerEphemeral, State: ContainerTerminated, ImageID: other}, - "an init container still running": {Name: "setup", Kind: ContainerInit, State: ContainerRunning, ImageID: other}, - "a restartable init (sidecar)": {Name: "proxy", Kind: ContainerInit, State: ContainerTerminated, ImageID: other, Restartable: true}, - "a second container on the approved image": {Name: "twin", Kind: ContainerRegular, State: ContainerRunning, ImageID: approvedRef}, + "a running ephemeral container": {Name: "debug", Kind: ContainerEphemeral, State: ContainerRunning}, + "an exited ephemeral container": {Name: "debug", Kind: ContainerEphemeral, State: ContainerTerminated}, + "an init container still running": {Name: "setup", Kind: ContainerInit, State: ContainerRunning}, + "a restartable init (sidecar)": {Name: "proxy", Kind: ContainerInit, State: ContainerTerminated, Restartable: true}, + "a second container on the approved image": {Name: "twin", Kind: ContainerRegular, State: ContainerRunning}, } { t.Run(name, func(t *testing.T) { err := bindAgainst(t, runningPodWith(container)) @@ -88,20 +86,18 @@ func TestAnyUnapprovedContainerInThePodMintsNothing(t *testing.T) { // The control: an init container that ran to completion before the // application started is tolerated, and never a source of the digest. require.NoError(t, bindAgainst(t, runningPodWith( - ContainerExecution{Name: "migrate", Kind: ContainerInit, State: ContainerTerminated, ImageID: approvedRef}))) + ContainerExecution{Name: "migrate", Kind: ContainerInit, State: ContainerTerminated}))) } // FINDING 3. The authenticating container must be a REGULAR container that is -// RUNNING, in a Running pod that is not being deleted. A stopped container's -// status keeps its approved imageID; a completed init container named like the -// declared one still reports one. Neither supplies the digest. -func TestOnlyALiveRegularContainerSuppliesTheDigest(t *testing.T) { +// RUNNING, in a Running pod that is not being deleted. +func TestOnlyALiveRegularContainerAuthenticates(t *testing.T) { stopped := runningPodWith() stopped.Containers[0].State = ContainerTerminated require.ErrorIs(t, bindAgainst(t, stopped), ErrExecutionNotRunning, "a terminated app") initOnly := &PodExecution{UID: "uid-alpha", Phase: PodPhaseRunning, Containers: []ContainerExecution{{ - Name: "worker", Kind: ContainerInit, State: ContainerTerminated, ImageID: approvedRef, + Name: "worker", Kind: ContainerInit, State: ContainerTerminated, }}} require.ErrorIs(t, bindAgainst(t, initOnly), ErrExecutionIdentityMismatch, "a completed init container named like the app") @@ -130,13 +126,12 @@ func TestOnlyALiveRegularContainerSuppliesTheDigest(t *testing.T) { func TestAContainerThatStopsBetweenReviewAndReadMintsNothing(t *testing.T) { pod := runningPodWith() reviewer := &stoppingReviewer{fakeExecutionReviewer: fakeExecutionReviewer{reviewed: reviewedPod(), pod: pod}} - service := boundService(reviewer, &fakeExecutionAuthority{digest: approvedBare, incarnation: 3}) + service := boundService(reviewer) _, err := service.BindExecution(context.Background(), service.moduleRegistrySnapshot(), "principal-1", "token") require.ErrorIs(t, err, ErrExecutionNotRunning) require.Equal(t, "uid-alpha", pod.UID, "the UID did not change, which is why the UID check alone admitted it") - require.Equal(t, approvedRef, pod.Containers[0].ImageID) } type stoppingReviewer struct{ fakeExecutionReviewer } @@ -152,8 +147,7 @@ func (r *stoppingReviewer) ReviewToken(ctx context.Context, token, audience stri // projection, and none whose lifetime the review did not report. func TestATokenLongerLivedThanTheMinimumProjectionMintsNothing(t *testing.T) { bind := func(reviewed *ReviewedExecutionToken) error { - service := boundService(&fakeExecutionReviewer{reviewed: reviewed, pod: runningPodWith()}, - &fakeExecutionAuthority{digest: approvedBare, incarnation: 1}) + service := boundService(&fakeExecutionReviewer{reviewed: reviewed, pod: runningPodWith()}) _, err := service.BindExecution(context.Background(), service.moduleRegistrySnapshot(), "principal-1", "token") return err } diff --git a/module/services/accounts/code/pkg/business/module_execution_snapshot_test.go b/module/services/accounts/code/pkg/business/module_execution_snapshot_test.go index 89d82e99f..d9dc240cf 100644 --- a/module/services/accounts/code/pkg/business/module_execution_snapshot_test.go +++ b/module/services/accounts/code/pkg/business/module_execution_snapshot_test.go @@ -24,7 +24,7 @@ func TestOwnerLookupUsesGrantSnapshot(t *testing.T) { require.NoError(t, err) reviewed := reviewedPod() reviewed.ServiceAccount = "beta" - service := boundService(&fakeExecutionReviewer{reviewed: reviewed, pod: runningPodWith()}, &fakeExecutionAuthority{digest: approvedBare, incarnation: 1}) + service := boundService(&fakeExecutionReviewer{reviewed: reviewed, pod: runningPodWith()}) ctx := context.Background() id := ModulePrincipalID("alpha") service.SetModulePrincipals(a) @@ -77,7 +77,7 @@ func TestRegistryReplacementDuringReviewCannotAddOperationAuthority(t *testing.T require.NoError(t, err) service := &Service{} reviewer := &replacingExecutionReviewer{fakeExecutionReviewer: fakeExecutionReviewer{reviewed: reviewedPod(), pod: runningPodWith()}} - service.SetExecutionBinding(reviewer, &fakeExecutionAuthority{digest: approvedBare, incarnation: 1}) + service.SetExecutionBinding(reviewer) reviewer.replace = func() {} service.SetModulePrincipals(registry) _, err = service.ModuleAuthorizeOperationContext(context.Background(), "alpha", "old-token", "operate") @@ -94,7 +94,7 @@ func TestRegistryReplacementDuringReviewCannotAddOperationAuthority(t *testing.T // A commit during TokenReview must not reuse the request memo's entry revision. func TestModuleMintFencesARevisionCommittedDuringReview(t *testing.T) { - service := boundService(&fakeExecutionReviewer{reviewed: reviewedPod(), pod: runningPodWith()}, &fakeExecutionAuthority{digest: approvedBare, incarnation: 1}) + service := boundService(&fakeExecutionReviewer{reviewed: reviewedPod(), pod: runningPodWith()}) registry := service.ModulePrincipals() grant := registry["principal-1"] registry = ModulePrincipalRegistry{ModulePrincipalID("worker"): grant} @@ -102,7 +102,7 @@ func TestModuleMintFencesARevisionCommittedDuringReview(t *testing.T) { envelope := &fakeEnvelopeStore{latest: 3} service.SetModuleEnvelopeStore(envelope) reviewer := &replacingExecutionReviewer{fakeExecutionReviewer: fakeExecutionReviewer{reviewed: reviewedPod(), pod: runningPodWith()}, replace: func() { envelope.latest = 4 }} - service.SetExecutionBinding(reviewer, &fakeExecutionAuthority{digest: approvedBare, incarnation: 1}) + service.SetExecutionBinding(reviewer) authority, err := service.ModuleAuthorizeWorkContext(WithModuleEnvelopeRevisionMemo(context.Background()), "worker", "token") require.ErrorIs(t, err, ErrModuleRegistryStale) require.Equal(t, codes.Unavailable, status.Code(err)) diff --git a/module/services/accounts/code/pkg/business/module_identity.go b/module/services/accounts/code/pkg/business/module_identity.go index 824d966c4..78916509b 100644 --- a/module/services/accounts/code/pkg/business/module_identity.go +++ b/module/services/accounts/code/pkg/business/module_identity.go @@ -254,13 +254,11 @@ func (s *Service) RecordModuleWorkContextMint( // auditFields is the execution as a mint record carries it. func (e ExecutionIdentity) auditFields() map[string]any { return map[string]any{ - "namespace": e.Namespace, - "service_account": e.ServiceAccount, - "pod": e.PodName, - "pod_uid": e.PodUID, - "container": e.ContainerName, - "running_digest": string(e.Running), - "build_incarnation": int64(e.Incarnation), + "namespace": e.Namespace, + "service_account": e.ServiceAccount, + "pod": e.PodName, + "pod_uid": e.PodUID, + "container": e.ContainerName, } } diff --git a/module/services/accounts/code/pkg/business/module_identity_helpers_test.go b/module/services/accounts/code/pkg/business/module_identity_helpers_test.go index f6d7e8e66..be7d58447 100644 --- a/module/services/accounts/code/pkg/business/module_identity_helpers_test.go +++ b/module/services/accounts/code/pkg/business/module_identity_helpers_test.go @@ -7,10 +7,9 @@ import ( "time" ) -// testModuleExecution is the two independent sources a module mint is bound by, -// for a test in this package: a token names a pod, a pod runs an image, and a -// principal (named by prefix, as an authority document names it) is approved for -// a build. BindExecution itself runs unchanged against it. +// testModuleExecution is the independent source a module mint is bound by, for +// a test in this package: a token names a pod, and the pod holds the declared +// container. BindExecution itself runs unchanged against it. // // It lives here rather than in executiontest because that package imports this // one, and a test inside it cannot import it back. @@ -19,16 +18,11 @@ type testModuleExecution struct { tokens map[string]ReviewedExecutionToken audiences map[string]string containers map[string]string // token -> the declared container its pod runs - running map[string]string // pod uid -> running image id - approved map[string]ApprovedDigest unreachable bool } -const testModuleImage = "registry.example/module@sha256:" + - "abababababababababababababababababababababababababababababababab" - // bindTestModule wires execution binding on s (once) and returns a projected -// token for the declared workload of prefix, its pod running the approved build. +// token for the first declared workload of prefix. func bindTestModule(s *Service, prefix string) string { if s.moduleAuthority == nil { withCurrentAuthority(s) @@ -37,26 +31,26 @@ func bindTestModule(s *Service, prefix string) string { if !ok { execution = &testModuleExecution{ tokens: map[string]ReviewedExecutionToken{}, audiences: map[string]string{}, containers: map[string]string{}, - running: map[string]string{}, approved: map[string]ApprovedDigest{}, } - s.SetExecutionBinding(execution, execution) + s.SetExecutionBinding(execution) } grant := s.declaredModules()[ModulePrincipalID(prefix)] + var workload ModuleWorkload + if len(grant.Workloads) > 0 { + workload = grant.Workloads[0] + } execution.mu.Lock() defer execution.mu.Unlock() n := len(execution.tokens) + 1 token := fmt.Sprintf("projected-%s-%d", prefix, n) - uid := fmt.Sprintf("uid-%s-%d", prefix, n) now := time.Now().UTC() execution.tokens[token] = ReviewedExecutionToken{ - ServiceAccount: grant.Workload.ServiceAccount, Namespace: grant.Workload.Namespace, - PodName: fmt.Sprintf("%s-%d", prefix, n), PodUID: uid, + ServiceAccount: workload.ServiceAccount, Namespace: workload.Namespace, + PodName: fmt.Sprintf("%s-%d", prefix, n), PodUID: fmt.Sprintf("uid-%s-%d", prefix, n), IssuedAt: now, ExpiresAt: now.Add(MaxExecutionTokenLifetime), } execution.audiences[token] = executionTokenAudience - execution.containers[token] = grant.Workload.Container - execution.running[uid] = testModuleImage - execution.approved[prefix] = ApprovedDigest(testModuleImage) + execution.containers[token] = workload.Container return token } @@ -80,22 +74,11 @@ func (e *testModuleExecution) ReadPod(_ context.Context, _, pod string) (*PodExe if reviewed.PodName == pod { return &PodExecution{UID: reviewed.PodUID, Phase: PodPhaseRunning, Containers: []ContainerExecution{{ Name: e.containers[token], Kind: ContainerRegular, State: ContainerRunning, - ImageID: e.running[reviewed.PodUID], }}}, nil } } return nil, fmt.Errorf("pod %s not found", pod) } -func (e *testModuleExecution) ApprovedBuild(_ context.Context, principal string) (ApprovedDigest, uint64, error) { - e.mu.Lock() - defer e.mu.Unlock() - approved, ok := e.approved[principal] - if !ok { - return "", 0, ErrUnknownExecutionPrincipal - } - return approved, 1, nil -} - // NativeDevelopment identifies this in-memory cluster fixture; it models no mesh. func (*testModuleExecution) NativeDevelopment() bool { return true } diff --git a/module/services/accounts/code/pkg/business/module_manifest_host_rules.go b/module/services/accounts/code/pkg/business/module_manifest_host_rules.go new file mode 100644 index 000000000..df6caf91f --- /dev/null +++ b/module/services/accounts/code/pkg/business/module_manifest_host_rules.go @@ -0,0 +1,130 @@ +package business + +import ( + "fmt" + "net" + "net/url" + "slices" + "strconv" + + "github.com/codefly-dev/core/solutionhost" +) + +// The rules core states about a manifest and leaves to the host, because only +// the host knows the other bindings it holds and where its cell runs things: +// +// 1. A workload is one (namespace, ServiceAccount) by the cell's rule, and +// two live manifests on this host never both declare it — so a reviewed +// token matches at most one manifest. The second is refused as a conflict +// naming the binding that holds the workload. +// 2. A signer speaks for its domain's bindings only: a manifest may not ask +// for an authority principal, or deploy a release publisher, that a live +// binding of another ownership domain holds. The first domain to hold one +// keeps it. And one principal is one live binding even within a domain — +// the registry omits a principal two manifests ask for — so a second +// claim is a conflict naming the holder. +// 3. An endpoint's address is the in-cluster Service of the workload it +// names, by the rule — "://..svc.cluster.local:" +// — never a free host. A native run's endpoints are its own processes, on +// the loopback. +// +// They are checked at receipt and again by each pass, against the bindings as +// they stand. + +// checkManifestAgainstHost holds a live candidate to the three rules against +// holders, the other bindings' manifests as they stand. +func (rule ModuleNamespaceRule) checkManifestAgainstHost(candidate *solutionhost.ModuleManifest, holders []*solutionhost.ModuleManifest) error { + if candidate.Removed { + return nil + } + for _, endpoint := range candidate.Endpoints { + if err := rule.checkEndpointAddress(candidate.Alias, endpoint); err != nil { + return fmt.Errorf("%w: binding %q endpoint %s/%s: %w", ErrSolutionDeliveryRefused, candidate.Binding, endpoint.Service, endpoint.Name, err) + } + } + for _, held := range holders { + if held.Removed || held.Binding == candidate.Binding || held.Host.Coordinate != candidate.Host.Coordinate { + continue + } + if rule.Namespace(held.Alias) == rule.Namespace(candidate.Alias) { + for _, workload := range candidate.Workloads { + if _, taken := held.WorkloadNamed(workload.Service); taken { + return fmt.Errorf("%w: workload %s/%s is held by binding %q, and one workload runs as one manifest", + ErrSolutionDeliveryConflict, rule.Namespace(candidate.Alias), workload.Service, held.Binding) + } + } + } + if candidate.Authority != nil && held.Authority != nil && held.Authority.Principal == candidate.Authority.Principal { + if held.Host.Domain == candidate.Host.Domain { + // The registry would omit both, so the second is refused here + // rather than taking the first's authority down with it. + return fmt.Errorf("%w: principal %q is held by binding %q, and one principal is one live binding", + ErrSolutionDeliveryConflict, candidate.Authority.Principal, held.Binding) + } + return fmt.Errorf("%w: principal %q is held by binding %q of domain %q, and this manifest speaks for domain %q", + ErrSolutionDeliveryDomainNotGranted, candidate.Authority.Principal, held.Binding, held.Host.Domain, candidate.Host.Domain) + } + if held.Host.Domain == candidate.Host.Domain { + continue + } + if held.Release.Publisher == candidate.Release.Publisher { + return fmt.Errorf("%w: release publisher %q is held by binding %q of domain %q, and this manifest speaks for domain %q", + ErrSolutionDeliveryDomainNotGranted, candidate.Release.Publisher, held.Binding, held.Host.Domain, candidate.Host.Domain) + } + } + return nil +} + +// checkEndpointAddress holds one endpoint's address to the cell's rule: an +// http(s) origin with a port and nothing else, whose host is the workload's +// in-cluster Service — or, natively, the loopback. +func (r ModuleNamespaceRule) checkEndpointAddress(module string, endpoint solutionhost.Endpoint) error { + address, err := url.Parse(endpoint.Address) + if err != nil || (address.Scheme != "http" && address.Scheme != "https") || address.User != nil || + (address.Path != "" && address.Path != "/") || address.RawQuery != "" || address.Fragment != "" { + return fmt.Errorf("address %q is not an http(s) origin", endpoint.Address) + } + port, err := strconv.Atoi(address.Port()) + if err != nil || port < 1 || port > 65535 { + return fmt.Errorf("address %q names no port", endpoint.Address) + } + host := address.Hostname() + if r.native { + if ip := net.ParseIP(host); host == "localhost" || (ip != nil && ip.IsLoopback()) { + return nil + } + return fmt.Errorf("address %q is not this run's loopback", endpoint.Address) + } + if want := endpoint.Service + "." + r.Namespace(module) + ".svc.cluster.local"; host != want { + return fmt.Errorf("address %q is not the workload's in-cluster Service %s", endpoint.Address, want) + } + return nil +} + +// standingManifests is every binding's manifest as it stands once the given +// replacements apply: an applied manifest is replaced by its binding's +// replacement, and a replacement for a binding nothing applied yet stands as +// well. A withdrawal stands as withdrawn and holds nothing. +func standingManifests(applied []*solutionhost.ModuleManifest, replacements map[string]*solutionhost.ModuleManifest) []*solutionhost.ModuleManifest { + standing := make([]*solutionhost.ModuleManifest, 0, len(applied)+len(replacements)) + seen := make(map[string]bool, len(applied)) + for _, manifest := range applied { + seen[manifest.Binding] = true + if replacement, replaced := replacements[manifest.Binding]; replaced { + standing = append(standing, replacement) + continue + } + standing = append(standing, manifest) + } + bindings := make([]string, 0, len(replacements)) + for binding := range replacements { + if !seen[binding] { + bindings = append(bindings, binding) + } + } + slices.Sort(bindings) + for _, binding := range bindings { + standing = append(standing, replacements[binding]) + } + return standing +} diff --git a/module/services/accounts/code/pkg/business/module_manifest_host_rules_test.go b/module/services/accounts/code/pkg/business/module_manifest_host_rules_test.go new file mode 100644 index 000000000..0472b8d6a --- /dev/null +++ b/module/services/accounts/code/pkg/business/module_manifest_host_rules_test.go @@ -0,0 +1,127 @@ +package business + +import ( + "testing" + + "github.com/codefly-dev/core/solutionhost" + "github.com/stretchr/testify/require" +) + +// The host's own rules about a manifest: one workload runs as one manifest, a +// principal and a publisher stay with their ownership domain, and an endpoint +// is its workload's in-cluster Service. + +// TWO NEW CLAIMANTS OF ONE WORKLOAD: the alias check judges what is applied, +// so both pass it; the first in delivery order keeps the workload and the +// second is withheld, naming the binding that holds it. +func TestASecondManifestForAHeldWorkloadIsWithheld(t *testing.T) { + first := fixtureManifest(t, "reports", 1) + second := fixtureManifest(t, "reports", 1) + second.Binding = "acme.test.second-reports" + + admission := admit(t, nil, first, second) + require.Len(t, admission.Accepted, 1) + require.Equal(t, first.Binding, admission.Accepted[0].Document.Binding) + require.Contains(t, admission.Withheld[second.Binding], `held by binding "acme.test.reports"`) + + err := testModuleNamespaceRule.checkManifestAgainstHost(second, []*solutionhost.ModuleManifest{first}) + require.ErrorIs(t, err, ErrSolutionDeliveryConflict) + require.ErrorContains(t, err, "workload reports/reports") + + // A withdrawal holds nothing, and another account in the namespace is + // another workload. + require.NoError(t, testModuleNamespaceRule.checkManifestAgainstHost(second, []*solutionhost.ModuleManifest{withdrawalOf(t, first, 2)})) + other := fixtureManifest(t, "reports", 1) + other.Binding = "acme.test.other-reports" + other.Workloads = []solutionhost.Workload{{Service: "worker", Container: "worker"}} + require.NoError(t, testModuleNamespaceRule.checkManifestAgainstHost(other, []*solutionhost.ModuleManifest{first})) +} + +// A FOREIGN DOMAIN CANNOT CLAIM A HELD PRINCIPAL OR PUBLISHER: a signer pinned +// for one domain speaks for that domain's bindings, so a manifest of another +// domain asking for a principal, or deploying a publisher, that one of them +// holds is refused, naming the holder. Within the domain a second claim on a +// principal is a conflict. +func TestAForeignDomainCannotClaimAHeldPrincipalOrPublisher(t *testing.T) { + held := fixtureManifest(t, "reports", 1) + held.Authority = &solutionhost.Authority{Principal: "reports"} + + foreign := func() *solutionhost.ModuleManifest { + claim := fixtureManifest(t, "ledger", 1) + claim.Binding, claim.Host.Domain = "globex.test.ledger", "globex" + claim.Release.Publisher = "globex" + return claim + } + + principal := foreign() + principal.Authority = &solutionhost.Authority{Principal: "reports"} + err := testModuleNamespaceRule.checkManifestAgainstHost(principal, []*solutionhost.ModuleManifest{held}) + require.ErrorIs(t, err, ErrSolutionDeliveryDomainNotGranted) + require.ErrorContains(t, err, `principal "reports" is held by binding "acme.test.reports" of domain "acme"`) + + publisher := foreign() + publisher.Release.Publisher = "acme" + err = testModuleNamespaceRule.checkManifestAgainstHost(publisher, []*solutionhost.ModuleManifest{held}) + require.ErrorIs(t, err, ErrSolutionDeliveryDomainNotGranted) + require.ErrorContains(t, err, `release publisher "acme" is held by binding "acme.test.reports"`) + + require.NoError(t, testModuleNamespaceRule.checkManifestAgainstHost(foreign(), []*solutionhost.ModuleManifest{held}), + "a principal and a publisher of its own are the foreign domain's to claim") + // Within one domain a principal is still one live binding: the registry + // would omit both, so the second claim is a conflict naming the holder. + sameDomain := fixtureManifest(t, "ledger", 1) + sameDomain.Authority = &solutionhost.Authority{Principal: "reports"} + err = testModuleNamespaceRule.checkManifestAgainstHost(sameDomain, []*solutionhost.ModuleManifest{held}) + require.ErrorIs(t, err, ErrSolutionDeliveryConflict) + require.ErrorContains(t, err, `principal "reports" is held by binding "acme.test.reports"`) + require.NoError(t, testModuleNamespaceRule.checkManifestAgainstHost(principal, []*solutionhost.ModuleManifest{withdrawalOf(t, held, 2)}), + "a withdrawn binding holds no principal") +} + +// AN ENDPOINT IS ITS WORKLOAD'S IN-CLUSTER SERVICE, by the cell's rule — not +// the metadata server, not another module's namespace, not a free host — and +// a native run's endpoints are its own processes on the loopback. +func TestAnEndpointIsItsWorkloadsInClusterService(t *testing.T) { + cell, err := NewModuleNamespaceRule("platform-acme-test-saas", "saas") + require.NoError(t, err) + check := func(rule ModuleNamespaceRule, address string) error { + return rule.checkEndpointAddress("reports", solutionhost.Endpoint{Name: "rest", Service: "api", API: "rest", Address: address}) + } + for _, address := range []string{ + "http://api.platform-acme-test-reports.svc.cluster.local:8080", + "https://api.platform-acme-test-reports.svc.cluster.local:8443", + "http://api.platform-acme-test-reports.svc.cluster.local:8080/", + } { + require.NoError(t, check(cell, address), address) + } + for _, address := range []string{ + "http://169.254.169.254:80", + "http://169.254.169.254/latest/meta-data", + "http://api.platform-acme-test-billing.svc.cluster.local:8080", + "http://api.reports.svc.cluster.local:8080", + "http://worker.platform-acme-test-reports.svc.cluster.local:8080", + "http://api.platform-acme-test-reports.svc.cluster.local", + "http://api.platform-acme-test-reports.svc.cluster.local:8080/v1", + "http://api.platform-acme-test-reports.svc.cluster.local:8080?x=1", + "http://user@api.platform-acme-test-reports.svc.cluster.local:8080", + "ftp://api.platform-acme-test-reports.svc.cluster.local:8080", + "api.platform-acme-test-reports.svc.cluster.local:8080", + "http://127.0.0.1:8080", + } { + require.Error(t, check(cell, address), address) + } + + native := NativeModuleNamespaceRule() + for _, address := range []string{"http://127.0.0.1:9000", "http://localhost:9000", "http://[::1]:9000"} { + require.NoError(t, check(native, address), address) + } + for _, address := range []string{"http://169.254.169.254:80", "http://api.reports.svc.cluster.local:8080", "http://10.0.0.7:9000"} { + require.Error(t, check(native, address), address) + } + + manifest := fixtureManifest(t, "reports", 1) + manifest.Endpoints = []solutionhost.Endpoint{{Name: "rest", Service: "reports", API: "rest", Address: "http://169.254.169.254:80"}} + err = testModuleNamespaceRule.checkManifestAgainstHost(manifest, nil) + require.ErrorIs(t, err, ErrSolutionDeliveryRefused) + require.ErrorContains(t, err, "169.254.169.254") +} diff --git a/module/services/accounts/code/pkg/business/module_namespace_rule.go b/module/services/accounts/code/pkg/business/module_namespace_rule.go new file mode 100644 index 000000000..0b52e0e7e --- /dev/null +++ b/module/services/accounts/code/pkg/business/module_namespace_rule.go @@ -0,0 +1,56 @@ +package business + +import ( + "fmt" + "strings" +) + +// ModuleNamespaceRule is how this cell spells the namespace a module's +// workloads run in, so a reviewed (namespace, ServiceAccount) pair reads back +// to a manifest's (module, service) and a module reads forward to the +// namespace its delivery carrier runs in. +// +// A manifest declares no namespace: core's rule is that a cell runs a +// module's workloads in the namespace named after the module, as the +// ServiceAccount named after the service (workloadidentity). A cell that +// composes several modules spells that namespace with its environment's +// prefix, "-". This host is itself a workload of +// the cell, placed by the same rule, so the prefix is read off its OWN +// placement — its namespace and its module — rather than configured: a value +// a deployer could type is a value that could disagree with the cell. +type ModuleNamespaceRule struct { + prefix string + // native is a native run's rule: the namespace is the module, and its + // endpoints are its own processes on the loopback. + native bool +} + +// NativeModuleNamespaceRule is the rule under a native run. +func NativeModuleNamespaceRule() ModuleNamespaceRule { return ModuleNamespaceRule{native: true} } + +// NewModuleNamespaceRule derives the rule from where this host runs: the +// namespace it runs in and the module it was composed as. A namespace that is +// the module's name is core's bare rule; one ending in "-" carries the +// environment's prefix; anything else is a placement no rule explains, and a +// host that cannot read its own placement cannot read anyone else's. +func NewModuleNamespaceRule(hostNamespace, hostModule string) (ModuleNamespaceRule, error) { + if hostNamespace == "" || hostModule == "" { + return ModuleNamespaceRule{}, fmt.Errorf("the module namespace rule needs this host's namespace and module, and got %q and %q", hostNamespace, hostModule) + } + if hostNamespace == hostModule { + return ModuleNamespaceRule{}, nil + } + prefix, found := strings.CutSuffix(hostNamespace, "-"+hostModule) + if !found || prefix == "" { + return ModuleNamespaceRule{}, fmt.Errorf("this host runs as module %q in namespace %q, which is neither the module's name nor \"-%s\"; no rule places a module's workloads, so none can be read back", + hostModule, hostNamespace, hostModule) + } + return ModuleNamespaceRule{prefix: prefix + "-"}, nil +} + +// Namespace is the namespace the cell runs module's workloads in. +func (r ModuleNamespaceRule) Namespace(module string) string { return r.prefix + module } + +// SetModuleNamespaceRule wires the rule. Boot wiring; unset is core's bare +// rule, the namespace named after the module. +func (s *Service) SetModuleNamespaceRule(rule ModuleNamespaceRule) { s.moduleNamespaces = rule } diff --git a/module/services/accounts/code/pkg/business/module_namespace_rule_test.go b/module/services/accounts/code/pkg/business/module_namespace_rule_test.go new file mode 100644 index 000000000..e0e93b168 --- /dev/null +++ b/module/services/accounts/code/pkg/business/module_namespace_rule_test.go @@ -0,0 +1,35 @@ +package business + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// The cell's namespace rule is read off this host's own placement, both +// spellings, and a placement no rule explains refuses the boot by name. +func TestTheModuleNamespaceRuleIsReadOffTheHostsOwnPlacement(t *testing.T) { + bare, err := NewModuleNamespaceRule("saas", "saas") + require.NoError(t, err) + require.Equal(t, "tracing", bare.Namespace("tracing"), "core's rule: the namespace named after the module") + + prefixed, err := NewModuleNamespaceRule("platform-acme-staging-saas", "saas") + require.NoError(t, err) + require.Equal(t, "platform-acme-staging-tracing", prefixed.Namespace("tracing"), + "a cell composing several modules prefixes the module with its environment's namespace") + + for _, placement := range [][2]string{ + {"platform-acme-staging", "saas"}, + {"saas-platform", "saas"}, + {"-saas", "saas"}, + {"saas", ""}, + {"", "saas"}, + } { + _, err := NewModuleNamespaceRule(placement[0], placement[1]) + require.Error(t, err, "namespace %q as module %q", placement[0], placement[1]) + if placement[0] != "" && placement[1] != "" { + require.ErrorContains(t, err, placement[0]) + require.ErrorContains(t, err, placement[1]) + } + } +} diff --git a/module/services/accounts/code/pkg/business/module_principal_reconcile.go b/module/services/accounts/code/pkg/business/module_principal_reconcile.go index 320c72e0d..9e48fb1f3 100644 --- a/module/services/accounts/code/pkg/business/module_principal_reconcile.go +++ b/module/services/accounts/code/pkg/business/module_principal_reconcile.go @@ -7,7 +7,6 @@ import ( "maps" "slices" "sort" - "strings" "sync" modulecontract "github.com/codefly-dev/core/contracts/module" @@ -16,322 +15,209 @@ import ( // The module registry, DERIVED (the solution lifecycle design, §3 and §4.2). // -// WHAT THIS REPLACES. Every capability decision reads one registry — -// declaredModules() — and that registry was MODULE_PRINCIPALS, parsed once from -// the environment at boot. Nothing wrote it afterwards, so adding a module, -// narrowing one, or withdrawing one meant a configuration edit and a restart, -// and a delivered authority document reached nothing any decision read. +// Every capability decision reads one registry — declaredModules() — and each +// reconcile pass builds the WHOLE of it from two inputs and swaps it in: // -// WHAT IT IS NOW. Each reconcile pass builds the WHOLE registry from three -// inputs and swaps it in with SetModulePrincipals: +// - the module manifests this host has APPLIED, each re-verified against the +// current pins: which workloads run as the module, and the authority it +// asks for; +// - the current envelope, the administrator's approval, which a platform +// super_admin writes at runtime. // -// - the current envelope, which a platform super_admin writes at runtime; -// - every live, re-verified authority document inside that envelope; -// - the applied presence document each authority is granted over. +// A principal is in the registry only while both agree: an applied, live +// manifest asks for it, and the envelope allows it. Its entry is the +// envelope's grant intersected with what the manifest asks for — never wider +// than either — and its workloads are the manifest's, spelled by the cell's +// rule (namespace from the module, ServiceAccount from the service). // -// A principal is in the registry only while all three agree: the envelope -// allows it, a signed delivery asks for it, and the presence it names is -// applied on this host. Its entry is the envelope's grant intersected with what -// the document asks for — never wider than either. +// A READ FAILURE NEVER ADDS AUTHORITY. A pass that cannot read the applied +// manifests publishes an empty registry. A pass that can read them but not the +// envelope keeps only the entries whose manifest is still the applied, live +// generation it was derived from, at the revision it was derived under, so the +// envelope fence still refuses them once a newer ceiling is committed. // -// A READ FAILURE NEVER ADDS AUTHORITY, AND NEVER KEEPS ONE A REVOCATION -// REMOVED. Each pass reads applied presence and the delivery inbox in one -// snapshot, then derives withdrawals before verifying the newest documents. -// A failed snapshot publishes an empty degraded registry: partial reads cannot -// establish authority. If only the envelope cannot be read, the last registry -// is narrowed by the snapshot's withdrawals, presence tombstones and documents -// still in force under the current signer policy. Nothing is stamped with a -// revision the fence would accept for a newer ceiling. -// -// A principal the inputs describe incoherently — two live documents, a -// withdrawn binding, a document that no longer verifies, a signer the policy no -// longer admits, a workload the envelope does not allow, an entry that fails -// validation — is OMITTED by name and never widened. The host-wide inbox is why -// omission is per principal: one module's bad delivery must not take every -// other module off the host. -// -// GENERATIONS. The inbox keeps every generation and the desired set is the -// newest per authority id, so within an id a lower generation can never be the -// answer — that order is durable, in the store, on every replica. Across ids -// generations are not comparable, and a principal granted by two live -// documents is refused. A principal moving from one authority to another — X -// stops naming it, Y names it — is accepted only because Y is inside the -// current envelope: its units are filed under Y's presence binding, so the -// envelope admitting them is the administrator's approval of the move. +// A principal the inputs describe incoherently — two manifests asking for it, +// a manifest that no longer verifies, a workload two principals claim, an +// entry that fails validation — is OMITTED by name and never widened. One +// module's bad delivery must not take every other module off the host. var ( // ErrModulePrincipalInputsUnreadable reports that an input could not be - // read, and that the registry published by the pass is degraded (see the - // file comment) or, when the envelope itself could not be read, that the - // published registry was not re-stamped. + // read, and that the published registry is degraded (see above). ErrModulePrincipalInputsUnreadable = errors.New("an input of the module registry could not be read") // ErrModulePrincipalOmitted reports a principal left out of the registry // this pass, with the reason beside it. ErrModulePrincipalOmitted = errors.New("module principal omitted from the registry") - // ErrModulePrincipalBindingOmitted reports an operation audience left out of - // a principal's entry because what the document grants and what the - // envelope allows have nothing in common. + // ErrModulePrincipalBindingOmitted reports an operation binding a manifest + // asks for that the envelope does not allow, or allows nothing of. ErrModulePrincipalBindingOmitted = errors.New("module operation audience omitted from the registry") - - // ErrModuleAuthorityOutsideEnvelope reports a live authority document the - // current envelope does not hold. It grants nothing. - ErrModuleAuthorityOutsideEnvelope = errors.New("delivered authority is outside the current envelope") - - // ErrModuleWorkloadNotApproved reports a principal whose applied presence - // runs its approved build under a workload the envelope does not allow. - ErrModuleWorkloadNotApproved = errors.New("the presence names a workload the envelope does not allow for this principal") ) -// ModulePrincipalReconciler derives the module registry and installs it. +// ModulePrincipalReconciler derives the module registry each pass. type ModulePrincipalReconciler struct { - activation *SolutionAuthorityActivation + reconciler *SolutionHostBindingReconciler + envelopes ModuleEnvelopeSource - // mutex serializes passes and guards last. mutex sync.Mutex - // last is the registry this reconciler last published, with where each - // entry came from. A degraded pass may only keep entries of it. - last *publishedModuleRegistry + last *publishedModuleRegistry } -// publishedModuleRegistry is one published registry, the envelope revision it -// was derived from, and the source of each entry. +// publishedModuleRegistry is what a pass installed, and what each entry was +// derived from, so a degraded pass can tell which entries still stand. type publishedModuleRegistry struct { registry ModulePrincipalRegistry revision uint64 sources map[string]modulePrincipalSource - // delivered is the inbox's size in the snapshot this registry was derived - // from. Nil on every degraded publish: a registry that was not derived - // from a full read of the inbox says nothing about what the inbox holds. + // delivered is the inbox's size when the inputs were read: the marker a + // mint compares to learn a delivery has not been read yet. delivered *uint64 } -// modulePrincipalSource is what an entry was derived from: enough to re-judge -// it against a revocation without re-deriving it. +// modulePrincipalSource is the applied generation an entry was derived from. type modulePrincipalSource struct { - presenceBinding string - domain string - signer string - authority string - generation uint64 -} - -// NewModulePrincipalReconciler builds the reconciler over the activation -// reader, which owns re-verification of every delivered authority document -// against the host's current signer policy and the read of the current ceiling. -func NewModulePrincipalReconciler(activation *SolutionAuthorityActivation) (*ModulePrincipalReconciler, error) { - if activation == nil || activation.reconciler == nil { - return nil, errors.New("the module principal reconciler requires the authority activation reader, which owns document re-verification and the ceiling") - } - return &ModulePrincipalReconciler{activation: activation}, nil + binding string + generation uint64 } func (r *ModulePrincipalReconciler) publish(published *publishedModuleRegistry) { r.last = published - r.activation.reconciler.service.InstallDerivedModulePrincipals(published.registry, published.revision, published.delivered) + r.reconciler.service.InstallDerivedModulePrincipals(published.registry, published.revision, published.delivered) } -// degraded is the last published registry kept only where it is still -// confirmed: derived from revision, over a binding neither withdrawn nor with -// its presence withdrawn, by a signer the host still admits for its domain — -// and, when this pass could read the newest documents, from a document still in -// force at the same generation (inForce: authority id → generation). A document -// this pass refused — it no longer verifies, its signer lost the domain, its -// binding was withdrawn — or superseded takes its cached entry with it. Never -// wider than the last published registry, by construction: it only removes -// entries. -func (r *ModulePrincipalReconciler) degraded( - revision uint64, withdrawn map[string]string, withdrawnPresence map[string]bool, inForce map[string]uint64, -) *publishedModuleRegistry { - kept := &publishedModuleRegistry{registry: ModulePrincipalRegistry{}, revision: revision, sources: map[string]modulePrincipalSource{}} - if r.last == nil || r.last.revision != revision || withdrawn == nil || withdrawnPresence == nil { - return kept - } - host := r.activation.reconciler.host - for id, grant := range r.last.registry { - source, known := r.last.sources[id] - if !known { - continue +// modulePrincipalInputs is one snapshot of the applied state and the inbox. +type modulePrincipalInputs struct { + applied []*SolutionHostBindingRecord + delivered *uint64 +} + +func (r *ModulePrincipalReconciler) readInputs(ctx context.Context) (*modulePrincipalInputs, error) { + service := r.reconciler.service + inputs := &modulePrincipalInputs{} + err := service.store.WithControlPlaneReadSnapshot(ctx, func(ctx context.Context) error { + var err error + if inputs.applied, err = service.store.ListSolutionHostBindings(ctx); err != nil { + return fmt.Errorf("the applied manifests: %w", err) } - if _, gone := withdrawn[source.presenceBinding]; gone || withdrawnPresence[source.presenceBinding] { - continue + if counter, counts := service.deliveryStore.(SolutionDeliveryCounter); counts { + delivered, err := counter.CountDeliveredDocuments(ctx) + if err != nil { + return fmt.Errorf("the inbox's size: %w", err) + } + inputs.delivered = &delivered } - if !slices.Contains(host.Domains, source.domain) || !slices.Contains(host.DomainsBySigner[source.signer], source.domain) { - continue + return nil + }) + return inputs, err +} + +func (r *ModulePrincipalReconciler) currentEnvelope(ctx context.Context) (*ModuleEnvelope, error) { + if r.envelopes == nil { + return nil, nil + } + envelope, err := r.envelopes.CurrentModuleEnvelope(ctx) + if errors.Is(err, ErrModuleEnvelopeUnavailable) { + return nil, nil + } + return envelope, err +} + +// degraded keeps the entries of the last registry whose manifest is still the +// applied, live generation they were derived from. +func (r *ModulePrincipalReconciler) degraded(applied []*SolutionHostBindingRecord) *publishedModuleRegistry { + kept := &publishedModuleRegistry{registry: ModulePrincipalRegistry{}, sources: map[string]modulePrincipalSource{}} + if r.last == nil { + return kept + } + kept.revision = r.last.revision + live := map[string]uint64{} + for _, record := range applied { + if record.Applied != nil && !record.Applied.Removed { + live[record.BindingID] = record.Applied.Generation } - if inForce != nil { - if generation, live := inForce[source.authority]; !live || generation != source.generation { - continue - } + } + for id, grant := range r.last.registry { + source := r.last.sources[id] + if generation, ok := live[source.binding]; ok && generation == source.generation { + kept.registry[id] = grant + kept.sources[id] = source } - kept.registry[id] = grant - kept.sources[id] = source } return kept } -// RunOnce derives the registry from the current inputs and installs it, tagged -// with the envelope revision it was derived from. -// -// It returns every refusal joined: a principal omitted, an operation audience -// omitted, a document outside the ceiling or refused, an input that could not -// be read. +// RunOnce derives the registry from the applied manifests and the envelope and +// installs it. Its error names every principal and binding it omitted. func (r *ModulePrincipalReconciler) RunOnce(ctx context.Context) error { r.mutex.Lock() defer r.mutex.Unlock() - // THE INPUTS, IN ONE SNAPSHOT, before anything else: the applied presence, - // the authority history and the newest generations, read in one - // repeatable-read transaction. The withdrawals, the documents in force and - // the workloads are all derived from this one read, so a binding admitted - // on another replica mid-pass is either wholly visible — owner, tombstone - // and presence together — or not at all. - inputs, inputsErr := r.activation.readRegistryInputs(ctx) - if errors.Is(inputsErr, ErrSolutionAuthorityCeilingUnavailable) { - // No delivery inbox wired: no document can ever be delivered, so no - // principal can ever be asked for. - envelope, err := r.activation.currentEnvelope(ctx) - if err != nil { - return fmt.Errorf("%w: the envelope: %w", ErrModulePrincipalInputsUnreadable, err) - } - var revision uint64 - if envelope != nil { - revision = envelope.Revision - } - r.publish(&publishedModuleRegistry{registry: ModulePrincipalRegistry{}, revision: revision}) - return nil - } - var ( - withdrawn map[string]string - presence map[string]bool - live []liveVerifiedAuthority - refusals []error - inForce map[string]uint64 - ) - if inputsErr == nil { - // THE REVOCATIONS FIRST, independently of document verification. - withdrawn, inputsErr = r.activation.withdrawnIn(inputs) - } - if inputsErr == nil { - presence, inputsErr = withdrawnPresenceIn(inputs) - } - if inputsErr == nil { - live, refusals = r.activation.liveAuthorityIn(ctx, inputs, withdrawn) - // The documents in force this pass: a cached entry never outlives one - // this pass refused or superseded. - inForce = make(map[string]uint64, len(live)) - for _, one := range live { - inForce[one.document.Authority] = one.document.Generation - } + inputs, inputsErr := r.readInputs(ctx) + if inputsErr != nil { + r.publish(&publishedModuleRegistry{registry: ModulePrincipalRegistry{}, revision: r.lastRevision()}) + return fmt.Errorf("%w: %w", ErrModulePrincipalInputsUnreadable, inputsErr) } - - envelope, envelopeErr := r.activation.currentEnvelope(ctx) - if envelopeErr != nil { - // The ceiling is unknown, so nothing is stamped with a new revision: - // the last registry is narrowed by the revocations the snapshot could - // establish and keeps its own revision, which the fence refuses once a - // newer one is committed. - if r.last != nil { - if inputsErr != nil { - withdrawn, presence, inForce = nil, nil, nil - } - r.publish(r.degraded(r.last.revision, withdrawn, presence, inForce)) - } - return errors.Join(fmt.Errorf("%w: the envelope: %w", ErrModulePrincipalInputsUnreadable, envelopeErr), inputsErr) + envelope, err := r.currentEnvelope(ctx) + if err != nil { + r.publish(r.degraded(inputs.applied)) + return fmt.Errorf("%w: the envelope: %w", ErrModulePrincipalInputsUnreadable, err) } if envelope == nil { - // No ceiling has ever been written: nothing is allowed, so nothing is - // granted. Not an unreadable input — the empty registry is the answer. - empty := &publishedModuleRegistry{registry: ModulePrincipalRegistry{}} - if inputsErr == nil { - empty.delivered = inputs.delivered - } - r.publish(empty) + r.publish(&publishedModuleRegistry{registry: ModulePrincipalRegistry{}, delivered: inputs.delivered}) return nil } - if inputsErr != nil { - // No revocation can be established, so no entry can be kept. - r.publish(r.degraded(envelope.Revision, nil, nil, nil)) - return fmt.Errorf("%w: %w", ErrModulePrincipalInputsUnreadable, inputsErr) - } - applied := make(map[string]*SolutionHostBindingRecord, len(inputs.applied)) - for _, record := range inputs.applied { - applied[record.BindingID] = record + manifests, refusals := r.appliedManifestsVerified(ctx, inputs.applied) + askedBy := map[string][]string{} + for _, manifest := range manifests { + if manifest.Authority != nil { + askedBy[manifest.Authority.Principal] = append(askedBy[manifest.Authority.Principal], manifest.Binding) + } } - var inside []liveVerifiedAuthority - for _, one := range live { - document := one.document - // THE CEILING, through core: the projection of the current envelope - // for this document — the host's own units, whole. - if err := document.ValidateAgainst(envelope.authorityEnvelopeFor(document)); err != nil { - refusals = append(refusals, fmt.Errorf("%w: authority %q generation %d: %w", - ErrModuleAuthorityOutsideEnvelope, document.Authority, document.Generation, err)) + next := &publishedModuleRegistry{ + registry: ModulePrincipalRegistry{}, revision: envelope.Revision, + sources: map[string]modulePrincipalSource{}, delivered: inputs.delivered, + } + rule := r.reconciler.service.moduleNamespaces + for _, manifest := range manifests { + if manifest.Authority == nil { + // A manifest with no authority asks for nothing: a module that is + // present and holds no principal. continue } - inside = append(inside, one) - } - - // Which principals more than one document inside the ceiling grants to, - // collected before anything is derived: the second document is what makes - // the first ambiguous too. - grantedBy := map[string][]string{} - for _, one := range inside { - for _, principal := range one.document.Principals { - grantedBy[principal.Principal] = append(grantedBy[principal.Principal], one.document.Authority) + prefix := manifest.Authority.Principal + omit := func(reason error) { + refusals = append(refusals, fmt.Errorf("%w: %q (binding %q generation %d): %w", + ErrModulePrincipalOmitted, prefix, manifest.Binding, manifest.Generation, reason)) } - } - - next := &publishedModuleRegistry{ - registry: ModulePrincipalRegistry{}, revision: envelope.Revision, sources: map[string]modulePrincipalSource{}, - // Derived from the whole inbox as this snapshot held it. - delivered: inputs.delivered, - } - for _, one := range inside { - document := one.document - candidates, workloadErr := r.workloadsFor(document, applied[document.PresenceBinding]) - for _, principal := range document.Principals { - prefix := principal.Principal - omit := func(reason error) { - refusals = append(refusals, fmt.Errorf("%w: %q (authority %q generation %d): %w", - ErrModulePrincipalOmitted, prefix, document.Authority, document.Generation, reason)) - } - if authorities := grantedBy[prefix]; len(authorities) > 1 { - named := slices.Clone(authorities) - sort.Strings(named) - omit(fmt.Errorf("granted by authorities %v, whose generations are not comparable", named)) - continue - } - entry, declared := envelope.Principals[prefix] - if !declared { - // Unreachable through ValidateAgainst — a principal with no - // entry has no unit admitted — and refused rather than trusted - // to stay unreachable. - omit(errors.New("the envelope declares no ceiling for it")) - continue - } - if workloadErr != nil { - omit(workloadErr) - continue - } - workload, err := approvedWorkload(entry, candidates, document) - if err != nil { - omit(err) - continue - } - grant, omitted, err := deriveModulePrincipalGrant(prefix, entry, document, principal.Bindings, workload) - refusals = append(refusals, omitted...) - if err != nil { - omit(err) - continue - } - id := ModulePrincipalID(prefix) - next.registry[id] = grant - next.sources[id] = modulePrincipalSource{presenceBinding: document.PresenceBinding, domain: document.OwnershipDomain, - signer: one.signer, authority: document.Authority, generation: document.Generation} + if bindings := askedBy[prefix]; len(bindings) > 1 { + named := slices.Clone(bindings) + sort.Strings(named) + omit(fmt.Errorf("asked for by the manifests of bindings %v, so which one it runs as has no single answer", named)) + continue + } + entry, allowed := envelope.Principals[prefix] + if !allowed { + omit(errors.New("the envelope declares no ceiling for it")) + continue } + workloads := make([]ModuleWorkload, 0, len(manifest.Workloads)) + for _, workload := range manifest.Workloads { + workloads = append(workloads, ModuleWorkload{ + Namespace: rule.Namespace(manifest.Alias), ServiceAccount: workload.Service, Container: workload.Container, + }) + } + grant, omitted, err := deriveModulePrincipalGrant(prefix, entry, manifest, workloads) + refusals = append(refusals, omitted...) + if err != nil { + omit(err) + continue + } + id := ModulePrincipalID(prefix) + next.registry[id] = grant + next.sources[id] = modulePrincipalSource{binding: manifest.Binding, generation: manifest.Generation} } refusals = append(refusals, next.oneWorkloadOnePrincipal()...) @@ -339,15 +225,94 @@ func (r *ModulePrincipalReconciler) RunOnce(ctx context.Context) error { return errors.Join(refusals...) } -// oneWorkloadOnePrincipal omits every principal whose workload (namespace and -// service account) another principal also derived. The envelope refuses such -// an overlap when written; this is the same rule at derivation, so a pod token -// can never authorize two prefixes whatever reached the registry. +func (r *ModulePrincipalReconciler) lastRevision() uint64 { + if r.last == nil { + return 0 + } + return r.last.revision +} + +// appliedManifestsVerified is every applied, live manifest asking for authority +// whose stored carrier still verifies under the current pins and is exactly the +// applied generation. +// Re-verifying is what makes an unpinned key stop granting: its manifests stay +// applied (a removal is a withdrawal generation, never a lost pin) but they +// confer no authority. +func (r *ModulePrincipalReconciler) appliedManifestsVerified( + ctx context.Context, records []*SolutionHostBindingRecord, +) ([]*solutionhost.ModuleManifest, []error) { + service := r.reconciler.service + var ( + manifests []*solutionhost.ModuleManifest + refusals []error + ) + for _, record := range records { + if record.Applied == nil || record.Applied.Removed { + continue + } + refuse := func(err error) { + refusals = append(refusals, fmt.Errorf("%w: binding %q generation %d grants nothing: %w", + ErrModulePrincipalOmitted, record.BindingID, record.Applied.Generation, err)) + } + manifest, err := record.AppliedManifest() + if err != nil { + refuse(err) + continue + } + if manifest.Authority == nil { + // Asks for nothing, so there is nothing to re-verify it for. + continue + } + if service.deliveryStore == nil { + refuse(errors.New("this host has no delivery inbox to re-verify the applied manifest against")) + continue + } + var stored *SolutionDeliveryRecord + if err := service.store.WithControlPlane(ctx, func(ctx context.Context) error { + var readErr error + stored, readErr = service.deliveryStore.ReadDeliveredDocument(ctx, record.BindingID, record.Applied.Generation) + return readErr + }); err != nil { + refuse(err) + continue + } + if stored == nil { + refuse(errors.New("the inbox holds no carrier for the applied generation")) + continue + } + delivered, err := solutionhost.VerifyDelivered(ctx, carrierOf(stored), r.reconciler.verifier) + if err != nil { + refuse(err) + continue + } + verified, err := delivered.Manifest() + if err != nil { + refuse(err) + continue + } + digest, err := verified.Digest() + if err != nil || digest != record.Applied.Digest { + refuse(errors.New("the stored carrier is not the applied manifest")) + continue + } + manifests = append(manifests, manifest) + } + return manifests, refusals +} + +// oneWorkloadOnePrincipal refuses every principal whose workload another +// principal also runs as. A pod's token names its namespace and ServiceAccount +// — never its container — so two principals sharing one would let one module's +// pod token authorize the other's prefix. func (p *publishedModuleRegistry) oneWorkloadOnePrincipal() []error { holders := map[ModuleWorkload][]string{} for id, grant := range p.registry { - key := ModuleWorkload{Namespace: grant.Workload.Namespace, ServiceAccount: grant.Workload.ServiceAccount} - holders[key] = append(holders[key], id) + for _, workload := range grant.Workloads { + key := ModuleWorkload{Namespace: workload.Namespace, ServiceAccount: workload.ServiceAccount} + if !slices.Contains(holders[key], id) { + holders[key] = append(holders[key], id) + } + } } var refusals []error for key, ids := range holders { @@ -356,7 +321,9 @@ func (p *publishedModuleRegistry) oneWorkloadOnePrincipal() []error { } var prefixes []string for _, id := range ids { - prefixes = append(prefixes, p.registry[id].Prefix) + if grant, held := p.registry[id]; held { + prefixes = append(prefixes, grant.Prefix) + } delete(p.registry, id) delete(p.sources, id) } @@ -367,179 +334,70 @@ func (p *publishedModuleRegistry) oneWorkloadOnePrincipal() []error { return refusals } -// workloadsFor reads the workloads a document's principals could run as, from -// the presence this host APPLIED for the binding the document is granted over: -// every distinct (SPIFFE ID, container) running the build the document is -// approved for. -// -// Applied, not merely delivered: a generation this host refused is not running -// here. The applied record holds the canonical document this host reconciled, -// written only by the reconcile pass. Which of these the principal may BE is -// the envelope's to say (approvedWorkload). -func (r *ModulePrincipalReconciler) workloadsFor( - document *solutionhost.AuthorityDocument, record *SolutionHostBindingRecord, -) ([]ModuleWorkloadIdentity, error) { - host := r.activation.reconciler.host - if document.Host.Coordinate != host.Coordinate { - return nil, fmt.Errorf("the document grants on host %q, and this host is %q", document.Host.Coordinate, host.Coordinate) - } - if record == nil || record.Applied == nil { - return nil, fmt.Errorf("presence binding %q has no applied generation on this host", document.PresenceBinding) - } - if record.Applied.Removed { - return nil, fmt.Errorf("presence binding %q is withdrawn on this host", document.PresenceBinding) - } - if record.Applied.Domain != document.OwnershipDomain { - return nil, fmt.Errorf("presence binding %q was applied under ownership domain %q and the document speaks for %q", - document.PresenceBinding, record.Applied.Domain, document.OwnershipDomain) - } - if record.Applied.Generation < document.EffectiveFrom { - return nil, fmt.Errorf("the document is effective from presence generation %d and binding %q has applied %d", - document.EffectiveFrom, document.PresenceBinding, record.Applied.Generation) - } - presence, err := solutionhost.Parse([]byte(record.Applied.Document)) - if err != nil { - return nil, fmt.Errorf("read the applied presence for binding %q: %w", document.PresenceBinding, err) - } - if presence.Binding != document.PresenceBinding { - return nil, fmt.Errorf("the applied presence for binding %q names binding %q", document.PresenceBinding, presence.Binding) - } - // Both halves of the tuple name the ceiling they were rendered against, - // and they must agree — core's activation rule. - if presence.EnvelopeRevision != document.EnvelopeRevision { - return nil, fmt.Errorf("the applied presence for binding %q names envelope revision %d and the document names %d", - document.PresenceBinding, presence.EnvelopeRevision, document.EnvelopeRevision) - } - var found []ModuleWorkloadIdentity - for _, workload := range presence.Workloads { - if workload.Image.Digest != document.ApprovedBuild { - continue - } - candidate := ModuleWorkloadIdentity{SPIFFEID: workload.Identity.SPIFFEID, Container: workload.Container} - if !slices.Contains(found, candidate) { - found = append(found, candidate) - } - } - if len(found) == 0 { - return nil, fmt.Errorf("no workload of applied presence binding %q runs the approved build %s", document.PresenceBinding, document.ApprovedBuild) - } - return found, nil -} - -// approvedWorkload is the one workload a principal runs as: the workload of the -// applied presence running its approved build, and only when the envelope -// allows that exact identity — SPIFFE ID and container. -// -// The envelope pins it because nothing else does. An image digest is not an -// identity: a later presence generation can run the same build under another -// service account, namespace or container, and binding the principal to -// whatever the newest presence names would move its authority to a workload -// nobody approved. Several allowed identities running the build have no single -// answer and are refused. -func approvedWorkload( - entry ModuleEnvelopePrincipal, candidates []ModuleWorkloadIdentity, document *solutionhost.AuthorityDocument, -) (ModuleWorkload, error) { - var allowed []ModuleWorkloadIdentity - for _, candidate := range candidates { - if slices.Contains(entry.WorkloadIdentities, candidate) { - allowed = append(allowed, candidate) - } - } - switch len(allowed) { - case 0: - return ModuleWorkload{}, fmt.Errorf("%w: binding %q runs build %s as %v", - ErrModuleWorkloadNotApproved, document.PresenceBinding, document.ApprovedBuild, candidates) - case 1: - return allowed[0].workload(), nil - } - return ModuleWorkload{}, fmt.Errorf("%d allowed workloads of binding %q run build %s, so the principal's identity has no single answer", - len(allowed), document.PresenceBinding, document.ApprovedBuild) -} - -// deriveModulePrincipalGrant is one registry entry: the envelope's grant for the -// principal, bounded by what the document asks for. -// -// - queues, namespaces: the envelope's, kept only where the document declares -// them too (core already refused a document declaring more); -// - resources: the envelope's, kept only where the document's scope ceilings -// name the kind — the module's own vocabulary; -// - external namespaces: the envelope's, within the namespaces kept; -// - operation audiences: the envelope's, kept only for a binding the document -// grants an invoke unit for, each operation's scopes intersected with the -// scope of the unit the document grants for it; an operation the document -// grants no unit for is absent; -// - tenant, cross_tenant, read audiences and artifact policies: the -// envelope's, which no delivered document may speak to; -// - workload: the applied presence's. -// -// The result is validated whole; an entry that fails is an error and the -// caller omits the principal. +// deriveModulePrincipalGrant is the envelope's grant intersected with what the +// manifest asks for. Every list keeps only what both name; an operation binding +// is granted only when the manifest asks for it on the envelope's audience, and +// then only the scopes both name, per operation. func deriveModulePrincipalGrant( - prefix string, entry ModuleEnvelopePrincipal, document *solutionhost.AuthorityDocument, - units []solutionhost.AuthorityBinding, workload ModuleWorkload, + prefix string, entry ModuleEnvelopePrincipal, manifest *solutionhost.ModuleManifest, workloads []ModuleWorkload, ) (ModulePrincipalGrant, []error, error) { ceiling := entry.Grant + request := manifest.Authority grant := ModulePrincipalGrant{ - PresenceBinding: document.PresenceBinding, + PresenceBinding: manifest.Binding, ArtifactPolicies: maps.Clone(ceiling.ArtifactPolicies), ReadAudiences: maps.Clone(ceiling.ReadAudiences), Prefix: prefix, - Queues: keepDeclared(ceiling.Queues, document.Queues), - Namespaces: keepDeclared(ceiling.Namespaces, document.Namespaces), + Queues: keepDeclared(ceiling.Queues, request.Queues), + Namespaces: keepDeclared(ceiling.Namespaces, request.Namespaces), + Resources: keepDeclared(ceiling.Resources, request.ResourceKinds), CrossTenant: ceiling.CrossTenant, Tenant: ceiling.Tenant, - Workload: workload, + Workloads: workloads, } grant.ExternalNamespaces = keepDeclared(ceiling.ExternalNamespaces, grant.Namespaces) - kinds := make([]string, 0, len(document.ScopeCeilings)) - for _, declared := range document.ScopeCeilings { - kinds = append(kinds, declared.ResourceKind) - } - grant.Resources = keepDeclared(ceiling.Resources, kinds) - byID := make(map[string]solutionhost.AuthorityBinding, len(units)) - for _, unit := range units { - byID[unit.ID] = unit - } - unitFor := func(binding, operation string) (solutionhost.AuthorityBinding, bool) { - unit, ok := byID[document.PresenceBinding+":"+binding+":"+operation] - return unit, ok - } var omitted []error - ids := slices.Sorted(maps.Keys(ceiling.OperationAudiences)) - for _, id := range ids { - allowed := ceiling.OperationAudiences[id] - invoke, granted := unitFor(id, modulecontract.OperationInvoke) - if !granted { - // The module did not ask for this audience. Not a refusal: the - // envelope allows more than one delivery needs. + omit := func(id string, err error) { + omitted = append(omitted, fmt.Errorf("%w: %q binding %q (manifest %q generation %d): %w", + ErrModulePrincipalBindingOmitted, prefix, id, manifest.Binding, manifest.Generation, err)) + } + for _, asked := range request.Bindings { + allowed, inside := ceiling.OperationAudiences[asked.ID] + switch { + case !inside: + omit(asked.ID, errors.New("the envelope allows no such operation binding")) + continue + case allowed.Audience != asked.Audience: + omit(asked.ID, fmt.Errorf("the manifest asks for audience %q and the envelope allows %q", asked.Audience, allowed.Audience)) + continue + } + invoke := scopeSet(asked.Operations[modulecontract.OperationInvoke]) + if len(invoke) == 0 { + omit(asked.ID, errors.New("the manifest asks for no invoke scope, and a binding is granted through its invoke operation")) continue } - invokeScopes := scopeSet(invoke.Scope) derived := ModuleOperationAudience{ Audience: allowed.Audience, - InvokeScopes: intersectUnitScopes(allowed.InvokeScopes, invokeScopes), + InvokeScopes: intersectUnitScopes(allowed.InvokeScopes, invoke), } - if lookup, ok := unitFor(id, modulecontract.OperationLookup); ok { - derived.LookupScopes = intersectUnitScopes(allowed.LookupScopes, scopeSet(lookup.Scope)) + if lookup := scopeSet(asked.Operations[modulecontract.OperationLookup]); len(lookup) > 0 { + derived.LookupScopes = intersectUnitScopes(allowed.LookupScopes, lookup) } - if headless, ok := unitFor(id, modulecontract.OperationHeadless); ok && allowed.HeadlessScopes != nil { - derived.HeadlessScopes = nilIfEmpty(intersectUnitScopes(allowed.HeadlessScopes, scopeSet(headless.Scope))) + if headless := scopeSet(asked.Operations[modulecontract.OperationHeadless]); len(headless) > 0 && allowed.HeadlessScopes != nil { + derived.HeadlessScopes = nilIfEmpty(intersectUnitScopes(allowed.HeadlessScopes, headless)) } - // Source delegation acts on a person's behalf, so it is bounded by the - // invoke grant the document gives — never by nothing. if allowed.SourceDelegationScopes != nil { - derived.SourceDelegationScopes = nilIfEmpty(intersectUnitScopes(allowed.SourceDelegationScopes, invokeScopes)) + derived.SourceDelegationScopes = nilIfEmpty(intersectUnitScopes(allowed.SourceDelegationScopes, invoke)) } - if err := validateOperationAudiences(prefix, map[string]ModuleOperationAudience{id: derived}); err != nil { - omitted = append(omitted, fmt.Errorf("%w: %q binding %q: what authority %q grants and what the envelope allows leave no valid audience: %w", - ErrModulePrincipalBindingOmitted, prefix, id, document.Authority, err)) + if err := validateOperationAudiences(prefix, map[string]ModuleOperationAudience{asked.ID: derived}); err != nil { + omit(asked.ID, fmt.Errorf("what the manifest asks for and what the envelope allows leave no valid audience: %w", err)) continue } if grant.OperationAudiences == nil { grant.OperationAudiences = map[string]ModuleOperationAudience{} } - grant.OperationAudiences[id] = derived + grant.OperationAudiences[asked.ID] = derived } if err := ValidateModulePrincipalGrant(prefix, grant); err != nil { return ModulePrincipalGrant{}, omitted, err @@ -547,9 +405,8 @@ func deriveModulePrincipalGrant( return grant, omitted, nil } -// keepDeclared is allowed restricted to declared, in allowed's order. Never nil -// for a non-empty result and nil for an empty one, so an entry's revision hash -// does not change with how an empty list was spelled. +// keepDeclared is allowed narrowed to what declared also names, in allowed's +// order. func keepDeclared(allowed, declared []string) []string { var kept []string for _, value := range allowed { @@ -560,21 +417,17 @@ func keepDeclared(allowed, declared []string) []string { return kept } -// scopeSet reads a unit's scope — sorted "kind:action" values joined by commas, -// as delivery writes it — into a set. -func scopeSet(scope string) map[string]bool { - set := map[string]bool{} - for _, value := range strings.Split(scope, ",") { - if value = strings.TrimSpace(value); value != "" { - set[value] = true - } +// scopeSet is a manifest operation's ":" scopes as a set. +func scopeSet(scopes []string) map[string]bool { + set := make(map[string]bool, len(scopes)) + for _, scope := range scopes { + set[scope] = true } return set } -// intersectUnitScopes keeps, of each allowed scope, the actions the unit's -// scope grants on the same kind. A scope left with no action is dropped. Resource -// IDs stay the envelope's: a unit cannot name them, so it can never widen them. +// intersectUnitScopes keeps, per allowed resource kind, the actions the +// manifest also asks for; a kind left with none is dropped. func intersectUnitScopes(allowed []ModuleOperationScope, granted map[string]bool) []ModuleOperationScope { var kept []ModuleOperationScope for _, scope := range allowed { diff --git a/module/services/accounts/code/pkg/business/module_registry_delivery_fence_test.go b/module/services/accounts/code/pkg/business/module_registry_delivery_fence_test.go index 95124a59d..93b17c4a7 100644 --- a/module/services/accounts/code/pkg/business/module_registry_delivery_fence_test.go +++ b/module/services/accounts/code/pkg/business/module_registry_delivery_fence_test.go @@ -24,7 +24,7 @@ func (c *countedInbox) CountDeliveredDocuments(context.Context) (uint64, error) return c.count, c.err } -func (c *countedInbox) CountUnappliedPresence(context.Context) (uint64, error) { +func (c *countedInbox) CountUnappliedManifests(context.Context) (uint64, error) { return c.unapplied, c.err } diff --git a/module/services/accounts/code/pkg/business/module_registry_review_test.go b/module/services/accounts/code/pkg/business/module_registry_review_test.go index 657c08e71..51b8e2568 100644 --- a/module/services/accounts/code/pkg/business/module_registry_review_test.go +++ b/module/services/accounts/code/pkg/business/module_registry_review_test.go @@ -2,24 +2,18 @@ package business import ( "context" - "encoding/json" "errors" - "maps" - "slices" "testing" - "accounts/internal/deliverytest" - - "github.com/codefly-dev/core/solutionhost" "github.com/stretchr/testify/require" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) -// Regressions for the second review of the derived module registry, built on -// the reviewer's own probes: in-memory stores and a stub signature verifier, -// with the real parsing, receipt, reconciliation and authorization. Each test -// is the attack, and asserts the refusal by its reason. +// Regressions for the second review of the derived module registry: the +// envelope fence every grant is checked against, with in-memory stores and the +// real authorization. Each test is the attack, and asserts the refusal by its +// reason. // reviewControlPlaneStore runs a control-plane transaction as a plain call. type reviewControlPlaneStore struct{ Store } @@ -122,7 +116,7 @@ func TestMintsAndRevisionConfirmationReadTheCommittedRevision(t *testing.T) { setup := func(store *fakeEnvelopeStore) (*Service, string) { s := &Service{store: reviewControlPlaneStore{}} grant := parseTestEnvelope(t).Principals[testModulePrefix].Grant - grant.Workload = ModuleWorkload{Namespace: "acme-prod", ServiceAccount: "reports", Container: "app"} + grant.Workloads = []ModuleWorkload{testWorkload} s.InstallDerivedModulePrincipals(ModulePrincipalRegistry{ModulePrincipalID(testModulePrefix): grant}, 3, nil) s.SetModuleEnvelopeStore(store) return s, bindTestModule(s, testModulePrefix) @@ -157,596 +151,6 @@ func TestMintsAndRevisionConfirmationReadTheCommittedRevision(t *testing.T) { } } -// registryReviewStore is the applied-state half of the store. -type registryReviewStore struct { - Store - applied []*SolutionHostBindingRecord - appliedErr error - inbox *registryReviewInbox - // A failed commit invalidates all reads, including a successful owner read. - snapshotCommitErr error - afterAppliedRead func() -} - -func (*registryReviewStore) WithControlPlane(ctx context.Context, fn func(context.Context) error) error { - return fn(ctx) -} - -// The fake stores share the context's snapshot, as the Postgres store methods -// share its transaction. Records are immutable; deliveries replace the slices. -type registryReviewSnapshotKey struct{} - -type registryReviewSnapshot struct { - applied []*SolutionHostBindingRecord - newest map[SolutionDeliveryKind][]*SolutionDeliveryRecord - history map[SolutionDeliveryKind][]*SolutionDeliveryRecord -} - -func (s *registryReviewStore) WithControlPlaneReadSnapshot(ctx context.Context, fn func(context.Context) error) error { - snapshot := ®istryReviewSnapshot{applied: slices.Clone(s.applied)} - if s.inbox != nil { - snapshot.newest, snapshot.history = maps.Clone(s.inbox.newest), maps.Clone(s.inbox.history) - } - if err := fn(context.WithValue(ctx, registryReviewSnapshotKey{}, snapshot)); err != nil { - return err - } - return s.snapshotCommitErr -} - -func (s *registryReviewStore) ListSolutionHostBindings(ctx context.Context) ([]*SolutionHostBindingRecord, error) { - records := s.applied - if snapshot, ok := ctx.Value(registryReviewSnapshotKey{}).(*registryReviewSnapshot); ok { - records = snapshot.applied - } - if after := s.afterAppliedRead; after != nil { - s.afterAppliedRead = nil - after() - } - return records, s.appliedErr -} - -// registryReviewInbox is the delivery inbox, kind by kind: the newest -// generation per document and the whole history. -type registryReviewInbox struct { - newest map[SolutionDeliveryKind][]*SolutionDeliveryRecord - history map[SolutionDeliveryKind][]*SolutionDeliveryRecord - historyReads int - // newestErr fails the newest-document read of one kind. - newestErr map[SolutionDeliveryKind]error -} - -func (s *registryReviewInbox) ListNewestDeliveredDocuments(ctx context.Context, kind SolutionDeliveryKind) ([]*SolutionDeliveryRecord, error) { - if err := s.newestErr[kind]; err != nil { - return nil, err - } - if snapshot, ok := ctx.Value(registryReviewSnapshotKey{}).(*registryReviewSnapshot); ok { - return snapshot.newest[kind], nil - } - return s.newest[kind], nil -} - -func (s *registryReviewInbox) ListDeliveredDocuments(ctx context.Context, kind SolutionDeliveryKind) ([]*SolutionDeliveryRecord, error) { - s.historyReads++ - if snapshot, ok := ctx.Value(registryReviewSnapshotKey{}).(*registryReviewSnapshot); ok { - return snapshot.history[kind], nil - } - return s.history[kind], nil -} - -func (s *registryReviewInbox) RecordDeliveredDocument(_ context.Context, record *SolutionDeliveryRecord) (bool, error) { - s.deliver(record) - return true, nil -} - -// deliver adds a generation to the history and makes it the newest of its -// document. -func (s *registryReviewInbox) deliver(record *SolutionDeliveryRecord) { - s.history[record.Kind] = append(s.history[record.Kind], record) - var kept []*SolutionDeliveryRecord - for _, newest := range s.newest[record.Kind] { - if newest.DocumentID != record.DocumentID { - kept = append(kept, newest) - } - } - s.newest[record.Kind] = append(kept, record) -} - -type permissiveCarrier struct{} - -func (permissiveCarrier) AuthorizeCarrier(context.Context, string, SolutionDeliveryKind, []string) error { - return nil -} - -func authorityRecord(t *testing.T, document *solutionhost.AuthorityDocument) *SolutionDeliveryRecord { - t.Helper() - payload, err := document.CanonicalBytes() - require.NoError(t, err) - return &SolutionDeliveryRecord{Kind: SolutionDeliveryAuthority, DocumentID: document.Authority, - Generation: document.Generation, Payload: payload, Bundle: deliverytest.Envelope(t, payload, reviewSigner(document.OwnershipDomain))} -} - -func presenceRecord(t *testing.T, document *solutionhost.SolutionHostBinding) *SolutionDeliveryRecord { - t.Helper() - payload, err := document.CanonicalBytes() - require.NoError(t, err) - return &SolutionDeliveryRecord{Kind: SolutionDeliveryPresence, DocumentID: document.Binding, - Generation: document.Generation, Payload: payload, Bundle: deliverytest.Envelope(t, payload, reviewSigner(document.OwnershipDomain))} -} - -func tombstoneOf(t *testing.T, document *solutionhost.AuthorityDocument) *solutionhost.AuthorityDocument { - t.Helper() - removed := *document - removed.Generation++ - removed.Removed = true - removed.ApprovedBuild, removed.EffectiveFrom, removed.Principals = "", 0, nil - removed.Queues, removed.Namespaces, removed.ScopeCeilings = []string{}, []string{}, []solutionhost.ScopeCeiling{} - require.NoError(t, removed.Validate()) - return &removed -} - -type registryReview struct { - service *Service - reconciler *ModulePrincipalReconciler - inbox *registryReviewInbox - store *registryReviewStore - verifier *solutionhost.Verifier - authority *solutionhost.AuthorityDocument - presence *solutionhost.SolutionHostBinding -} - -// newRegistryReview is one module whose authority and applied presence are -// derived into the registry at envelope revision 3. -func newRegistryReview(t *testing.T) *registryReview { - t.Helper() - authority := testAuthority(t, testUnit("run", "invoke"), testUnit("run", "lookup")) - raw, err := solutionhost.FixtureDocument(solutionhost.DocumentTypePresence, "valid") - require.NoError(t, err) - presence, err := solutionhost.Parse(raw) - require.NoError(t, err) - presence.Binding, presence.EnvelopeRevision, presence.Generation = testPresenceBinding, 3, 1 - presence.Host, presence.OwnershipDomain = authority.Host, authority.OwnershipDomain - presence.Workloads[0].Image.Digest = testEnvelopeBuild - presence.Workloads[0].Identity.SPIFFEID = testSPIFFEID - presence.Workloads[0].Container = testContainer - canonical, err := presence.CanonicalBytes() - require.NoError(t, err) - - store := ®istryReviewStore{applied: []*SolutionHostBindingRecord{{ - BindingID: testPresenceBinding, - Applied: &SolutionHostBindingApplied{Domain: authority.OwnershipDomain, - SolutionHostBindingGeneration: SolutionHostBindingGeneration{Generation: 1, Document: string(canonical)}}, - }}} - inbox := ®istryReviewInbox{newest: map[SolutionDeliveryKind][]*SolutionDeliveryRecord{}, history: map[SolutionDeliveryKind][]*SolutionDeliveryRecord{}} - store.inbox = inbox - inbox.deliver(presenceRecord(t, presence)) - inbox.deliver(authorityRecord(t, authority)) - verifier := deliverytest.Verifier(t, map[string][]string{"example-signer": {authority.OwnershipDomain}, "foreign-signer": {"other-domain"}}) - service := &Service{store: store, deliveryStore: inbox} - service.SetModuleEnvelopeStore(&fakeEnvelopeStore{latest: 3, record: &ModuleEnvelopeRecord{Revision: 3, Document: encodeEnvelope(t, testEnvelopeDocument())}}) - service.SetModuleAuthorityReads(reviewLiveAuthority{service: service}, nil) - host := solutionhost.Host{ - Coordinate: authority.Host.Coordinate, - Domains: []string{authority.OwnershipDomain, "other-domain"}, - DomainsBySigner: map[string][]string{"example-signer": {authority.OwnershipDomain}, "foreign-signer": {"other-domain"}}, - } - activation := &SolutionAuthorityActivation{reconciler: &SolutionHostBindingReconciler{service: service, host: host, verifier: verifier}, envelopes: service} - reconciler, err := NewModulePrincipalReconciler(activation) - require.NoError(t, err) - require.NoError(t, reconciler.RunOnce(context.Background())) - require.Contains(t, service.ModulePrincipals(), ModulePrincipalID(testModulePrefix)) - return ®istryReview{service: service, reconciler: reconciler, inbox: inbox, store: store, verifier: verifier, authority: authority, presence: presence} -} - -func (r *registryReview) present() bool { - _, ok := r.service.ModulePrincipals()[ModulePrincipalID(testModulePrefix)] - return ok -} - -// TestAWithdrawalIsHonouredWhenTheNewestDocumentNoLongerVerifies is finding 2, -// scenario A: the binding is withdrawn at generation 2, and the newest document -// over it no longer verifies. The withdrawal history is read first and on its -// own, so the principal is gone — not reinstated from the last pass. -func TestAWithdrawalIsHonouredWhenTheNewestDocumentNoLongerVerifies(t *testing.T) { - r := newRegistryReview(t) - r.inbox.deliver(authorityRecord(t, tombstoneOf(t, r.authority))) - later := *r.authority - later.Generation = 3 - r.inbox.deliver(authorityRecord(t, &later)) - *r.verifier = solutionhost.Verifier{} - - before := r.inbox.historyReads - err := r.reconciler.RunOnce(context.Background()) - require.Greater(t, r.inbox.historyReads, before, "the withdrawal history is read whatever the newest documents do") - require.ErrorIs(t, err, ErrSolutionAuthorityUnverifiable, "the unverifiable document is refused by name") - require.False(t, r.present(), "a withdrawn binding is never reinstated from an earlier pass") - _, err = r.service.AuthorizeModuleCapability(context.Background(), ModuleCaller{PrincipalID: ModulePrincipalID(testModulePrefix), BoundOrg: testEnvelopeTenant}) - require.Error(t, err) -} - -// TestAWithdrawalIsHonouredWhileTheNewestDocumentsCannotBeRead: the newest -// authority read fails outright, so the pass is degraded — and a degraded pass -// still drops the entry whose binding the readable history withdrew. -func TestAWithdrawalIsHonouredWhileTheNewestDocumentsCannotBeRead(t *testing.T) { - r := newRegistryReview(t) - r.inbox.deliver(authorityRecord(t, tombstoneOf(t, r.authority))) - r.inbox.newestErr = map[SolutionDeliveryKind]error{SolutionDeliveryAuthority: errors.New("newest documents unreadable")} - require.ErrorIs(t, r.reconciler.RunOnce(context.Background()), ErrModulePrincipalInputsUnreadable) - require.False(t, r.present(), "a degraded registry honours every withdrawal that could be read") -} - -// TestAnUnreadableWithdrawalHistoryKeepsNothing: with no way to establish the -// withdrawals, no entry can be kept. -func TestAnUnreadableWithdrawalHistoryKeepsNothing(t *testing.T) { - r := newRegistryReview(t) - failing := &failingHistoryInbox{registryReviewInbox: r.inbox} - r.service.deliveryStore = failing - require.ErrorIs(t, r.reconciler.RunOnce(context.Background()), ErrModulePrincipalInputsUnreadable) - require.False(t, r.present()) -} - -type failingHistoryInbox struct{ *registryReviewInbox } - -func (*failingHistoryInbox) ListDeliveredDocuments(context.Context, SolutionDeliveryKind) ([]*SolutionDeliveryRecord, error) { - return nil, errors.New("history unreadable") -} - -// TestAnUnverifiableDocumentGrantsNothingAtANewRevision: cached inputs never -// carry authority across an envelope revision. -func TestAnUnverifiableDocumentGrantsNothingAtANewRevision(t *testing.T) { - r := newRegistryReview(t) - document := testEnvelopeDocument() - document["revision"] = float64(4) - r.service.SetModuleEnvelopeStore(&fakeEnvelopeStore{latest: 4, record: &ModuleEnvelopeRecord{Revision: 4, Document: encodeEnvelope(t, document)}}) - *r.verifier = solutionhost.Verifier{} - require.Error(t, r.reconciler.RunOnce(context.Background())) - require.Empty(t, r.service.ModulePrincipals()) -} - -// TestAFailedPresenceReadNeverAddsAuthority is finding 2, scenario B: the -// applied-state read fails while a fresh authority generation adds a headless -// unit. Fresh and stale inputs are never combined, and a cached entry never -// outlives the document it came from — superseded here by generation 2. -func TestAFailedPresenceReadNeverAddsAuthority(t *testing.T) { - newer := func(r *registryReview) { - document := *r.authority - document.Generation = 2 - document.Principals = []solutionhost.PrincipalAuthority{{Principal: testModulePrefix, - Bindings: []solutionhost.AuthorityBinding{testUnit("run", "invoke"), testUnit("run", "lookup"), testUnit("run", "headless")}}} - r.inbox.deliver(authorityRecord(t, &document)) - r.store.appliedErr = errors.New("applied state unreadable") - } - - for name, fail := range map[string]func(*registryReviewStore){ - "the applied-state read fails": func(s *registryReviewStore) { s.appliedErr = errors.New("applied state unreadable") }, - "the snapshot commit fails after the owner read": func(s *registryReviewStore) { - s.appliedErr = nil - s.snapshotCommitErr = errors.New("snapshot commit failed") - }, - } { - t.Run(name, func(t *testing.T) { - r := newRegistryReview(t) - newer(r) - fail(r.store) - require.ErrorIs(t, r.reconciler.RunOnce(context.Background()), ErrModulePrincipalInputsUnreadable) - after, present := r.service.ModulePrincipals()[ModulePrincipalID(testModulePrefix)] - require.Empty(t, after.OperationAudiences["run"].HeadlessScopes, "a degraded pass is never wider than the last confirmed one") - require.False(t, present, "the cached entry's document was superseded, so it is not kept") - }) - } - t.Run("a presence withdrawal delivery made is honoured", func(t *testing.T) { - r := newRegistryReview(t) - removed := *r.presence - removed.Generation = 2 - removed.Removed = true - removed.Routes, removed.Artifacts, removed.Workloads, removed.Modules, removed.Endpoints = nil, nil, nil, nil, nil - require.NoError(t, removed.Validate()) - r.inbox.deliver(presenceRecord(t, &removed)) - newer(r) - require.ErrorIs(t, r.reconciler.RunOnce(context.Background()), ErrModulePrincipalInputsUnreadable) - require.False(t, r.present(), "the withdrawn presence takes its principal with it even while applied state is unreadable") - }) -} - -// TestAnUnverifiableAuthorityDoesNotSurviveAFailedPresenceRead is the third -// review's residual A: the verifier refuses the document this pass while the -// applied-state read fails. The refusal is known, so the degraded pass drops -// the cached entry, including when the snapshot fails after reading the owner. -func TestAnUnverifiableAuthorityDoesNotSurviveAFailedPresenceRead(t *testing.T) { - for name, fail := range map[string]func(*registryReviewStore){ - "the applied-state read fails": func(s *registryReviewStore) { s.appliedErr = errors.New("applied state unreadable") }, - "the snapshot commit fails after the owner read": func(s *registryReviewStore) { - s.appliedErr = nil - s.snapshotCommitErr = errors.New("snapshot commit failed") - }, - } { - t.Run(name, func(t *testing.T) { - r := newRegistryReview(t) - *r.verifier = solutionhost.Verifier{} - r.store.appliedErr = errors.New("applied presence unreadable") - fail(r.store) - require.ErrorIs(t, r.reconciler.RunOnce(context.Background()), ErrModulePrincipalInputsUnreadable) - require.False(t, r.present(), "a cached grant never survives a document this pass refused") - _, err := r.service.AuthorizeModuleCapability(context.Background(), ModuleCaller{PrincipalID: ModulePrincipalID(testModulePrefix), BoundOrg: testEnvelopeTenant}) - require.Error(t, err) - }) - } -} - -// TestARefusedForeignPresenceEstablishesNoOwnership is the third review's -// residual B: the binding is admitted under domain A at generation 5; a foreign -// signer delivers generation 1 under domain B, which core's lifecycle admission -// refuses. The owner is the ADMITTED generation's domain, from the applied -// state — so B's tombstone is refused at receipt and, if stored anyway, -// withdraws nothing. -func TestARefusedForeignPresenceEstablishesNoOwnership(t *testing.T) { - setup := func(t *testing.T) *registryReview { - r := newRegistryReview(t) - own := *r.presence - own.Generation = 5 - canonical, err := own.CanonicalBytes() - require.NoError(t, err) - digest, err := own.Digest() - require.NoError(t, err) - r.store.applied[0].Applied.Generation, r.store.applied[0].Applied.Document, r.store.applied[0].Applied.Digest = 5, string(canonical), digest - r.inbox.history[SolutionDeliveryPresence], r.inbox.newest[SolutionDeliveryPresence] = nil, nil - r.inbox.deliver(presenceRecord(t, &own)) - require.NoError(t, r.reconciler.RunOnce(context.Background())) - require.True(t, r.present()) - - policy := map[string][]string{"example-signer": {r.authority.OwnershipDomain}, "foreign-signer": {"other-domain"}} - r.service.SetSolutionDelivery(deliverytest.Verifier(t, map[string][]string{"foreign-signer": {"other-domain"}}), r.inbox, permissiveCarrier{}, policy) - r.service.SetSolutionDeliveryCoordinate(r.authority.Host.Coordinate) - foreign := *r.presence - foreign.OwnershipDomain, foreign.Generation = "other-domain", 1 - foreign.Workloads = append([]solutionhost.Workload(nil), r.presence.Workloads...) - foreign.Workloads[0].Identity.SPIFFEID = "spiffe://acme.test/ns/other-prod/sa/other" - _, _, err = r.service.ReceiveSolutionDelivery(context.Background(), - SolutionDeliveryCarrier{Kind: SolutionDeliveryPresence, Raw: deliverytest.Carrier(t, presenceRecord(t, &foreign).Payload, "foreign-signer")}, "carrier") - require.NoError(t, err, "receipt keeps the row; core's admission is what refuses it") - host := r.reconciler.activation.reconciler.host - state, ok := r.store.applied[0].AppliedState() - require.True(t, ok) - host.Applied = []solutionhost.Applied{state} - foreignDelivered, err := solutionhost.VerifyDelivered(context.Background(), carrierOf(presenceRecord(t, &foreign)), deliverytest.Verifier(t, map[string][]string{"foreign-signer": {"other-domain"}})) - require.NoError(t, err) - _, err = host.Admit(foreignDelivered) - require.ErrorIs(t, err, solutionhost.ErrWrongDomain) - r.inbox.newest[SolutionDeliveryPresence] = []*SolutionDeliveryRecord{presenceRecord(t, &own)} - return r - } - foreignTombstone := func(r *registryReview) *solutionhost.AuthorityDocument { - tombstone := tombstoneOf(t, r.authority) - tombstone.Authority, tombstone.OwnershipDomain = "foreign-authority", "other-domain" - return tombstone - } - - t.Run("refused at receipt", func(t *testing.T) { - r := setup(t) - _, _, err := r.service.ReceiveSolutionDelivery(context.Background(), - SolutionDeliveryCarrier{Kind: SolutionDeliveryAuthority, Raw: reviewCarrier(t, authorityRecord(t, foreignTombstone(r)).Payload)}, "carrier") - require.ErrorIs(t, err, ErrSolutionDeliveryDomainNotGranted) - }) - t.Run("ignored in history", func(t *testing.T) { - r := setup(t) - r.inbox.deliver(authorityRecord(t, foreignTombstone(r))) - if err := r.reconciler.RunOnce(context.Background()); err != nil { - require.NotErrorIs(t, err, ErrSolutionAuthorityWithdrawn) - } - require.True(t, r.present(), "the binding's admitted owner is still domain A") - require.Equal(t, r.authority.OwnershipDomain, r.store.applied[0].Applied.Domain) - }) -} - -// TestAForeignTombstoneWithdrawsNothing is finding 3: a tombstone speaking for -// another ownership domain, or addressed to another host, cannot withdraw a -// binding — refused at receipt, and ignored in history if it is there anyway. -// A tombstone from the binding's own domain and host still withdraws it. -func TestAForeignTombstoneWithdrawsNothing(t *testing.T) { - foreign := map[string]func(*solutionhost.AuthorityDocument){ - "another domain": func(document *solutionhost.AuthorityDocument) { document.OwnershipDomain = "other-domain" }, - "another host": func(document *solutionhost.AuthorityDocument) { document.Host.Coordinate = "another-host" }, - } - for name, mutate := range foreign { - t.Run(name+" is refused at receipt", func(t *testing.T) { - r := newRegistryReview(t) - tombstone := tombstoneOf(t, r.authority) - tombstone.Authority = "other-authority" - mutate(tombstone) - policy := map[string][]string{"example-signer": {r.authority.OwnershipDomain, "other-domain"}} - r.service.SetSolutionDelivery(deliverytest.Verifier(t, policy), r.inbox, permissiveCarrier{}, policy) - r.service.SetSolutionDeliveryCoordinate(r.authority.Host.Coordinate) - _, _, err := r.service.ReceiveSolutionDelivery(context.Background(), - SolutionDeliveryCarrier{Kind: SolutionDeliveryAuthority, Raw: deliverytest.Carrier(t, authorityRecord(t, tombstone).Payload, "example-signer")}, "carrier") - require.ErrorIs(t, err, ErrSolutionDeliveryDomainNotGranted) - }) - t.Run(name+" in history is ignored", func(t *testing.T) { - r := newRegistryReview(t) - tombstone := tombstoneOf(t, r.authority) - tombstone.Authority = "other-authority" - mutate(tombstone) - r.inbox.deliver(authorityRecord(t, tombstone)) - if err := r.reconciler.RunOnce(context.Background()); err != nil { - require.NotErrorIs(t, err, ErrSolutionAuthorityWithdrawn) - } - require.True(t, r.present(), "a tombstone that could not speak for the binding withdraws nothing") - }) - } - t.Run("the binding's own tombstone withdraws it", func(t *testing.T) { - r := newRegistryReview(t) - renamed := tombstoneOf(t, r.authority) - renamed.Authority = "renamed-authority" - r.inbox.deliver(authorityRecord(t, renamed)) - _ = r.reconciler.RunOnce(context.Background()) - require.False(t, r.present()) - }) -} - -// TestAnAdmittedWithdrawalSurvivesRenameAndRestart: once admitted, a withdrawal -// is terminal for the binding — a later generation of the same id or a renamed -// authority grants nothing, for a reconciler built after a restart too. -func TestAnAdmittedWithdrawalSurvivesRenameAndRestart(t *testing.T) { - r := newRegistryReview(t) - r.inbox.deliver(authorityRecord(t, tombstoneOf(t, r.authority))) - later := *r.authority - later.Generation = 3 - r.inbox.deliver(authorityRecord(t, &later)) - renamed := *r.authority - renamed.Authority, renamed.Generation = "new-authority", 1 - for _, document := range []*solutionhost.AuthorityDocument{&later, &renamed} { - r.inbox.deliver(authorityRecord(t, document)) - restarted, err := NewModulePrincipalReconciler(r.reconciler.activation) - require.NoError(t, err) - require.ErrorIs(t, restarted.RunOnce(context.Background()), ErrSolutionAuthorityWithdrawn) - require.False(t, r.present()) - } -} - -// referenceExchangeStore serves one source delegation, and can run something -// while it is read — the moment a concurrent registry install would land. -type referenceExchangeStore struct { - Store - delegation *SourceDelegation - duringGet func() -} - -func (*referenceExchangeStore) WithControlPlane(ctx context.Context, fn func(context.Context) error) error { - return fn(ctx) -} - -func (s *referenceExchangeStore) GetSourceDelegation(context.Context, string, string) (*SourceDelegation, error) { - if s.duringGet != nil { - s.duringGet() - } - return s.delegation, nil -} - -func (*referenceExchangeStore) SourceDelegationFacts(context.Context, string, string, string) (*SourceDelegationFacts, error) { - return &SourceDelegationFacts{SourceExists: true, MemberRole: "admin", UserStatus: "active", PrincipalRevision: 42}, nil -} - -func (s *referenceExchangeStore) ActiveSourceDelegationsForPrincipal(context.Context, string, string, string) ([]*SourceDelegation, error) { - return []*SourceDelegation{s.delegation}, nil -} - -// referenceExchange is revision 3 — a worker allowed to write, and no source -// module — and revision 4 — the source module restored, the worker narrowed to -// reads. Neither registry admits a write exchange through the delegation. -func referenceExchange(t *testing.T) (*Service, *referenceExchangeStore, *fakeEnvelopeStore, ModuleCaller, ModulePrincipalRegistry) { - t.Helper() - read := []ModuleOperationScope{{ResourceKind: "collections", Actions: []string{"read"}}} - write := []ModuleOperationScope{{ResourceKind: "collections", Actions: []string{"write"}}} - both := []ModuleOperationScope{{ResourceKind: "collections", Actions: []string{"read", "write"}}} - sourceBinding := ModuleOperationAudience{Audience: "worker", InvokeScopes: both, LookupScopes: read, SourceDelegationScopes: write} - worker := ModulePrincipalGrant{Prefix: "worker", Tenant: testEnvelopeTenant, - OperationAudiences: map[string]ModuleOperationAudience{"run": {Audience: "sink", InvokeScopes: both, LookupScopes: read}}} - source := ModulePrincipalGrant{Prefix: "source", Tenant: "another-org", OperationAudiences: map[string]ModuleOperationAudience{"sync": sourceBinding}} - narrowed := worker - narrowed.OperationAudiences = map[string]ModuleOperationAudience{"run": {Audience: "sink", InvokeScopes: read, LookupScopes: read}} - store := &referenceExchangeStore{delegation: &SourceDelegation{ID: "example-delegation", OrgID: "delegated-org", - PrincipalID: "example-person", SourceID: "example-source", ModulePrefix: "source", BindingID: "sync", - BindingDigest: SourceDelegationBindingDigest(sourceBinding)}} - s := &Service{store: store} - s.SetModuleAuthorityReads(reviewLiveAuthority{service: s}, nil) - envelopes := &fakeEnvelopeStore{latest: 3} - s.SetModuleEnvelopeStore(envelopes) - s.InstallDerivedModulePrincipals(ModulePrincipalRegistry{ModulePrincipalID("worker"): worker}, 3, nil) - return s, store, envelopes, ModuleCaller{PrincipalID: ModulePrincipalID("worker"), BoundOrg: testEnvelopeTenant}, - ModulePrincipalRegistry{ModulePrincipalID("worker"): narrowed, ModulePrincipalID("source"): source} -} - -// TestAReferenceExchangeReadsBothPrincipalsFromOneSnapshot is the third -// review's residual C: revision 4 is installed after the caller's fence and -// before the delegator is looked up. Both are read from the snapshot the fence -// passed, so the revision-3 worker's write is never paired with the revision-4 -// source — and a revision-4 decision refuses too. -func TestAReferenceExchangeReadsBothPrincipalsFromOneSnapshot(t *testing.T) { - s, store, envelopes, caller, newer := referenceExchange(t) - _, err := s.AuthorizeDelegationReferenceExchange(context.Background(), caller, store.delegation.ID, "run", false) - require.ErrorIs(t, err, ErrSourceDelegationInvalid, "revision 3 has no source module") - - store.duringGet = func() { - envelopes.latest = 4 - s.InstallDerivedModulePrincipals(newer, 4, nil) - } - _, err = s.AuthorizeDelegationReferenceExchange(WithModuleEnvelopeRevisionMemo(context.Background()), caller, store.delegation.ID, "run", false) - require.ErrorIs(t, err, ErrSourceDelegationInvalid, "the delegator is read from the caller's snapshot, never a later install") - - store.duringGet = nil - _, err = s.AuthorizeDelegationReferenceExchange(context.Background(), caller, store.delegation.ID, "run", false) - require.ErrorIs(t, err, ErrSourceDelegationInvalid, "revision 4's narrowed worker cannot write either") -} - -// TestAReferenceExchangeFencesAnUnobservantReplica: the exchange refuses a -// registry older than the committed revision, or one it cannot compare. -func TestAReferenceExchangeFencesAnUnobservantReplica(t *testing.T) { - for _, unreadable := range []bool{false, true} { - s, store, envelopes, caller, _ := referenceExchange(t) - envelopes.latest = 4 - if unreadable { - envelopes.revisionErr = errors.New("revision unavailable") - } - _, err := s.AuthorizeDelegationReferenceExchange(context.Background(), caller, store.delegation.ID, "run", false) - require.Equal(t, codes.Unavailable, status.Code(err)) - } -} - -// The reviewer's interleaving, inverted: neither committed state grants a -// principal. Before admission it has no presence; after admission its owner's -// tombstone is terminal. A pass spanning the commit must grant nothing too. -func TestFirstAdmissionBetweenOwnerAndDerivationReadsCannotRestoreWithdrawnAuthority(t *testing.T) { - r := newRegistryReview(t) - applied := r.store.applied - r.store.applied = nil - r.inbox.deliver(authorityRecord(t, tombstoneOf(t, r.authority))) - renamed := *r.authority - renamed.Authority = "renamed-authority" - r.inbox.deliver(authorityRecord(t, &renamed)) - ctx := context.Background() - require.ErrorIs(t, r.reconciler.RunOnce(ctx), ErrModulePrincipalOmitted) - require.False(t, r.present(), "no applied presence before the concurrent admission") - - // Core really admits the presence: the authority tombstone does not prevent - // another replica committing this first presence generation. - delivered, err := solutionhost.VerifyDelivered(ctx, carrierOf(presenceRecord(t, r.presence)), r.verifier) - require.NoError(t, err) - _, err = r.reconciler.activation.reconciler.host.Admit(delivered) - require.NoError(t, err) - committed := false - r.store.afterAppliedRead = func() { - r.store.applied = applied - committed = true - } - err = r.reconciler.RunOnce(ctx) - require.True(t, committed, "the other replica committed after the owner read") - require.False(t, r.present(), "the renamed grant must not combine new presence with an ownerless withdrawal map") - require.ErrorIs(t, err, ErrModulePrincipalOmitted) - grant, err := r.service.AuthorizeModuleCapability(ctx, ModuleCaller{PrincipalID: ModulePrincipalID(testModulePrefix), BoundOrg: testEnvelopeTenant}) - require.Error(t, err) - require.Nil(t, grant) - - // No delivery or envelope change: the next snapshot sees both the admitted - // owner and the tombstone, and still grants nothing. - require.ErrorIs(t, r.reconciler.RunOnce(ctx), ErrSolutionAuthorityWithdrawn) - require.False(t, r.present()) -} - -func TestRegistryInputsKeepConcurrentWithdrawalInTheNextSnapshot(t *testing.T) { - r := newRegistryReview(t) - removed := authorityRecord(t, tombstoneOf(t, r.authority)) - r.store.afterAppliedRead = func() { r.inbox.deliver(removed) } - inputs, err := r.reconciler.activation.readRegistryInputs(context.Background()) - require.NoError(t, err) - withdrawn, err := r.reconciler.activation.withdrawnIn(inputs) - require.NoError(t, err) - require.Empty(t, withdrawn, "history must be from the same snapshot as the applied presence") - require.Len(t, inputs.newestAuthority, 1) - require.Equal(t, r.authority.Generation, inputs.newestAuthority[0].Generation) - - // The committed withdrawal is visible on the following pass. - require.NoError(t, r.reconciler.RunOnce(context.Background())) - require.False(t, r.present()) -} - func (*fakeEnvelopeStore) ModuleInstallations(context.Context) ([]ModuleInstallationConsent, error) { return nil, nil } @@ -754,20 +158,3 @@ func (*fakeEnvelopeStore) ModuleInstallations(context.Context) ([]ModuleInstalla func (f reviewLiveAuthority) BeginModuleAuthorityUse(ctx context.Context) (context.Context, func(), error) { return ctx, func() {}, nil } - -// The foreign-domain cases are signed by a distinct key, so their signatures -// remain valid while admission decides whether that key may change this binding. -func reviewSigner(domain string) string { - if domain == "other-domain" { - return "foreign-signer" - } - return "example-signer" -} - -func reviewCarrier(t *testing.T, payload []byte) []byte { - var header struct { - OwnershipDomain string `json:"ownership_domain"` - } - require.NoError(t, json.Unmarshal(payload, &header)) - return deliverytest.Carrier(t, payload, reviewSigner(header.OwnershipDomain)) -} diff --git a/module/services/accounts/code/pkg/business/moduleregistrytest/registry.go b/module/services/accounts/code/pkg/business/moduleregistrytest/registry.go index 24d95ce6a..e97fc2e16 100644 --- a/module/services/accounts/code/pkg/business/moduleregistrytest/registry.go +++ b/module/services/accounts/code/pkg/business/moduleregistrytest/registry.go @@ -1,10 +1,11 @@ // Package moduleregistrytest states a module principal registry for a test. // // No production path reads a registry from text: the running registry is -// derived each reconcile pass from delivered authority inside the module -// envelope (business.ModulePrincipalReconciler). A capability test still needs +// derived each reconcile pass from the applied module manifests inside the +// module envelope (business.ModulePrincipalReconciler). A capability test still needs // a registry in hand, and the shortest honest way to write one is the entry's -// own shape — so this parses `{: {, "workload": {...}}}` and +// own shape — so this parses `{: {, "workload": {...}}}` (one +// workload, the common case) and // holds every entry to business.ValidateModulePrincipalGrant, the same check // the reconciler runs over each entry it derives. A fixture the reconciler // could never install fails here instead of testing a state no host reaches. @@ -65,7 +66,7 @@ func Parse(raw string) (business.ModulePrincipalRegistry, error) { Resources: declared.Resources, CrossTenant: declared.CrossTenant, Tenant: tenant, - Workload: declared.Workload, + Workloads: []business.ModuleWorkload{declared.Workload}, } if err := business.ValidateModulePrincipalGrant(prefix, grant); err != nil { return nil, err diff --git a/module/services/accounts/code/pkg/business/platform_catalogue.go b/module/services/accounts/code/pkg/business/platform_catalogue.go index ef4e696ce..a167ffdd9 100644 --- a/module/services/accounts/code/pkg/business/platform_catalogue.go +++ b/module/services/accounts/code/pkg/business/platform_catalogue.go @@ -57,15 +57,15 @@ type PlatformCatalogue struct { } const ( - catalogueNoDeclaration = "This host holds no applied presence document for this entry, which is what records what the composition declares." - catalogueNoApplication = "This host has applied no presence generation for this entry." + catalogueNoDeclaration = "This host holds no applied module manifest for this entry, which is what records what the composition declares." + catalogueNoApplication = "This host has applied no module manifest generation for this entry." catalogueNoApproval = "This host holds no approval record: signed platform approval of the execution inventory, bound to target and ownership scope, has not been built yet." catalogueNotObserved = "This host reads no observed cluster state, so what runs is unknown — never assumed to match." catalogueNoVerdict = "There is no approval record to judge the observed execution against." catalogueNoWithdrawal = "This host holds no withdrawal record; withdrawal is a change to the approval record, which does not exist yet." catalogueNoRevocation = "This host holds no credential-revocation record for this entry." catalogueNoRetirement = "No retirement controller reports to this host; without stop or fence evidence nothing is reported as retired." - catalogueNoBuildSize = "Build size is the presence document's build_size section (codefly-dev/core#708), computed at build by the CLI; this host holds no presence document for this entry." + catalogueNoBuildSize = "A module manifest carries no build size, and this host counts none itself." catalogueModulePublisher = "A composed module is known by its principal prefix alone; the composition records no publisher." catalogueUnregistered = "No registration exists under this identifier; it is named only by its installations." catalogueNoRevision = "Installations carry no revision of their own on this host." diff --git a/module/services/accounts/code/pkg/business/platform_catalogue_test.go b/module/services/accounts/code/pkg/business/platform_catalogue_test.go index bf7e65861..1439e85ec 100644 --- a/module/services/accounts/code/pkg/business/platform_catalogue_test.go +++ b/module/services/accounts/code/pkg/business/platform_catalogue_test.go @@ -241,7 +241,7 @@ func TestExecutionInventoryContractRequiresAContainer(t *testing.T) { // The read boundary withholds an affirmative verdict whose evidence is not // beside it. judgeCatalogueObserved is the only author of a verdict and applies // the same rule, so nothing should reach this check — but the states are filled -// from records that do not exist yet (applied presence, the signed approval, +// from records that do not exist yet (the signed approval, // cluster observation), and a filler that set a value case without re-judging // would otherwise publish a green cell. The boundary fails closed instead. func TestCatalogueReadBoundaryWithholdsUnsupportedAffirmativeVerdict(t *testing.T) { @@ -422,7 +422,7 @@ func TestProjectPlatformCatalogue(t *testing.T) { require.Equal(t, notRecorded, e.GetWithdrawing().GetCredentialRevocationStateGap().GetReason(), e.GetName()) require.Equal(t, notObserved, e.GetRetired().GetRetirementStateGap().GetReason(), e.GetName()) require.Equal(t, notRecorded, e.GetBuildSizeGap().GetReason(), e.GetName()) - require.Contains(t, e.GetBuildSizeGap().GetDetail(), "codefly-dev/core#708") + require.Contains(t, e.GetBuildSizeGap().GetDetail(), "module manifest carries no build size") } module := byName["alpha"] diff --git a/module/services/accounts/code/pkg/business/registry_fixture_test.go b/module/services/accounts/code/pkg/business/registry_fixture_test.go index 5f1b3bda4..392f72b36 100644 --- a/module/services/accounts/code/pkg/business/registry_fixture_test.go +++ b/module/services/accounts/code/pkg/business/registry_fixture_test.go @@ -28,7 +28,7 @@ func parseRegistryFixture(raw string) (ModulePrincipalRegistry, error) { if tenant, err := uuid.Parse(grant.Tenant); err == nil { grant.Tenant = tenant.String() } - grant.Workload = declared.Workload + grant.Workloads = []ModuleWorkload{declared.Workload} if err := ValidateModulePrincipalGrant(prefix, grant); err != nil { return nil, err } diff --git a/module/services/accounts/code/pkg/business/service.go b/module/services/accounts/code/pkg/business/service.go index eac1023bd..5a0883946 100644 --- a/module/services/accounts/code/pkg/business/service.go +++ b/module/services/accounts/code/pkg/business/service.go @@ -157,17 +157,17 @@ type Service struct { // asserted by its constant rather than by its behaviour. audienceClock func() time.Time - // The delivery inbox's wiring. All three or none: ReceiveSolutionDelivery + // The delivery inbox's wiring. All of it or none: ReceiveSolutionDelivery // fails closed and names the gap when any is absent, because a deployment // that mounted the endpoint without a verifier must not accept documents. - deliveryVerifier *solutionhost.Verifier - deliveryStore SolutionDeliveryStore - deliveryCarrier SolutionDeliveryCarrierAuthorizer - deliveryDomainsBySigner map[string][]string - // deliveryCoordinate is this host's coordinate, so an authority document - // addressed to another host is refused at receipt. Empty skips that check - // (the reconcile pass still applies it). - deliveryCoordinate string + deliveryVerifier *solutionhost.Verifier + deliveryStore SolutionDeliveryStore + deliveryCarrier SolutionDeliveryCarrierAuthorizer + // deliveryCoordinate is this host's coordinate, which every admission is + // decided under. + deliveryCoordinate string + // moduleNamespaces spells the namespace a module's workloads run in. + moduleNamespaces ModuleNamespaceRule frontendSource frontendpayload.Source frontendLazyLoadWindow time.Duration @@ -197,11 +197,10 @@ type Service struct { moduleInstallations ModuleInstallationResolver moduleOperationBindings ModuleOperationBindingStore - // The independent sources execution-bound minting needs. Unset means + // The workload reviewer execution-bound minting needs. Unset means // BindExecution answers ErrExecutionUnbound — never a mint that skips the // check. - executionReviewer ExecutionReviewer - executionAuthority ExecutionAuthority + executionReviewer ExecutionReviewer // workContextSeals answers the row-backed half of a Work Context's seal // (work_context_seal.go). Unset answers no seal, so no capability is @@ -237,15 +236,9 @@ func (s *Service) SetPolicyLog(log PolicyLog, store PolicyLogStore) { s.policyLogStore = store } -// SetSolutionDelivery wires the delivery endpoint: the bundle verifier, the -// inbox's persistence, the carrier authorizer, and which ownership domains each -// attested signer may deliver under. -// -// The signer-to-domain mapping comes from the SAME document as the identity -// allowlist — the verifier's own policy — rather than from this host's -// environment. When the two were separable, a deployer who could set the -// environment could widen what an accepted signer speaks for without touching -// the independently-delivered policy at all. +// SetSolutionDelivery wires the delivery endpoint: the pinned-key verifier, +// the inbox's persistence and the carrier authorizer. Which ownership domains a +// signer may deliver under is the verifier's own pin policy. // // Boot wiring, called once before the service listens, so these are plain // fields: an atomic that is only ever written once reads as a claim that @@ -254,12 +247,10 @@ func (s *Service) SetSolutionDelivery( verifier *solutionhost.Verifier, store SolutionDeliveryStore, carrier SolutionDeliveryCarrierAuthorizer, - domainsBySigner map[string][]string, ) { s.deliveryVerifier = verifier s.deliveryStore = store s.deliveryCarrier = carrier - s.deliveryDomainsBySigner = maps.Clone(domainsBySigner) } // declaredModules is the module principal registry as it stands right now. diff --git a/module/services/accounts/code/pkg/business/solution_delivery.go b/module/services/accounts/code/pkg/business/solution_delivery.go index 64ff3c99d..e6a944b8d 100644 --- a/module/services/accounts/code/pkg/business/solution_delivery.go +++ b/module/services/accounts/code/pkg/business/solution_delivery.go @@ -2,8 +2,6 @@ package business import ( "context" - "crypto/sha256" - "encoding/hex" "errors" "fmt" "time" @@ -11,157 +9,104 @@ import ( "github.com/codefly-dev/core/solutionhost" ) -// The delivery inbox: how a signed document reaches this host, and why it is -// persisted rather than read from a directory. +// The delivery inbox: how a signed module manifest reaches this host, and why +// it is persisted rather than read from a directory. // -// A mount is the CURRENT desired set and nothing else. A document that arrived, -// was recorded as desired, failed to apply and then disappeared from the mount -// was never retried: the host had recorded that delivery wanted something and -// had no way to want it again. The persisted representation also dropped the -// carrier, so a restore could not re-verify what it had accepted — only trust -// its own earlier judgement. +// A module instance is declared by ONE signed document, core's +// codefly/module-manifest/v1: where it runs, which workloads run as it, where +// its endpoints are and what authority it asks for. Delivery POSTs its carrier; +// the host verifies it on receipt, authorises the caller that handed it over, +// decides it against the generation already applied for its binding, and +// persists the bytes. The reconciler then reads its desired set from durable +// state, which is what makes "delivery stopped talking" and "delivery said +// remove it" different facts. Removal is a withdrawal GENERATION; nothing +// deletes from the inbox. + +// SolutionDeliveryRecord is one row of the inbox: one generation of one +// binding's manifest, with the attestation over exactly those bytes. // -// So delivery POSTs a carrier, the host verifies it ON RECEIPT, and persists the -// bytes. The reconciler then reads its desired set from durable state, which is -// what makes "delivery stopped talking" and "delivery said remove it" different -// facts. Removal stays a tombstone GENERATION; nothing deletes from the inbox. - -// SolutionDeliveryKind is which half of the lifecycle a document is. -// -// The two have different authorised writers and different identities, so they -// are never interchangeable: the kind is part of every lookup and part of the -// carrier authorisation decision. -type SolutionDeliveryKind string - -const ( - // SolutionDeliveryPresence declares what runs where. - SolutionDeliveryPresence SolutionDeliveryKind = "presence" - // SolutionDeliveryAuthority declares what a module instance is approved for. - SolutionDeliveryAuthority SolutionDeliveryKind = "authority" - // SolutionDeliveryEndpoints carries Codefly values for an applied presence. - SolutionDeliveryEndpoints SolutionDeliveryKind = "endpoints" -) - -// Valid reports whether a kind is one this host serves. Checked at the edge, so -// an unknown kind is a 404 on a path rather than a row with a kind nothing reads. -func (kind SolutionDeliveryKind) Valid() bool { - return kind == SolutionDeliveryPresence || kind == SolutionDeliveryAuthority || kind == SolutionDeliveryEndpoints -} - -// SolutionDeliveryCarrier is one received carrier: the canonical payload and the -// attestation over exactly those bytes. -// -// The two travel together and are stored together. A payload without its envelope -// is a document nobody can re-verify, which is the state that made a restore -// unable to re-check what it had accepted. -type SolutionDeliveryCarrier struct { - Kind SolutionDeliveryKind - // Raw is the delivered {document, envelope} carrier. Core's strict parser - // refuses unknown fields and missing envelopes before verification. - Raw []byte -} - -// SolutionDeliveryRecord is one row of the inbox. +// The payload and its envelope travel together and are stored together. A +// payload without its envelope is a document nobody can re-verify. type SolutionDeliveryRecord struct { - Kind SolutionDeliveryKind - DocumentID string + // Binding is the manifest's binding id. + Binding string Generation uint64 - // ContentHash is sha256 of the canonical payload, `sha256:<64 hex>`. + // ContentHash is sha256 of the canonical payload, `sha256:<64 hex>` — the + // manifest's digest, since the payload is its canonical encoding. ContentHash string Payload []byte - // Bundle retains its storage name, but holds the DSSE envelope verbatim - // so reconciliation can verify the same bytes under the current pins. - Bundle []byte - // SignerIdentity is what the verifier attested, and OwnershipDomain is what - // the document asserts. Both recorded at receipt so an operator can see who - // delivered what without re-running verification — and so a later narrowing - // of the policy reads as a disagreement with history rather than as a silent - // change in what the host would accept today. + // Envelope is the DSSE envelope verbatim, so reconciliation verifies the + // same bytes under the current pins. + Envelope []byte + // SignerIdentity is the pinned key id the verifier attested, and + // OwnershipDomain the domain the manifest speaks for, both recorded at + // receipt so an operator can see who delivered what. SignerIdentity string OwnershipDomain string ReceivedAt time.Time } -// SolutionDeliveryDisposition is what the host did with a carrier. Every value -// maps to exactly one response code, and the mapping is the published contract. +// SolutionDeliveryDisposition is what the host did with a carrier. type SolutionDeliveryDisposition string const ( - // SolutionDeliveryAccepted: verified, new, and now durable desired state — - // whether or not it has been applied yet. 202. + // SolutionDeliveryAccepted: verified, admitted against the applied + // generation, and now durable desired state. SolutionDeliveryAccepted SolutionDeliveryDisposition = "accepted" - // SolutionDeliveryReplayed: this exact (document, generation, content hash) - // is already durable. Writes nothing. 200. + // SolutionDeliveryReplayed: this exact generation is already durable. + // Writes nothing. SolutionDeliveryReplayed SolutionDeliveryDisposition = "replayed" ) // Delivery refusals. Each is a distinct fact with a distinct response code, -// because a retry is right for some and wrong for the rest. +// because a retry is right for some and wrong for the rest; the adapter maps +// them and the wrapped core error names the cause. var ( - // ErrSolutionDeliveryNotApplied asks the producer to retry its signed value - // after presence reconciliation. A receipt alone never activates a route. - ErrSolutionDeliveryNotApplied = errors.New("endpoint delivery is waiting for applied presence") - - // ErrSolutionDeliveryMalformed: the carrier does not parse, or the payload - // is not the canonical encoding of the document it decodes to. 400, - // terminal. A signer and a host that disagree about which bytes represent - // the document disagree about what was approved. - ErrSolutionDeliveryMalformed = errors.New("solution delivery carrier is malformed") - - // ErrSolutionDeliveryUnattested: the bundle does not verify, or its identity - // is not one this host accepts. 403, terminal. - ErrSolutionDeliveryUnattested = errors.New("solution delivery carrier is not attested by an accepted signer") - - // ErrSolutionDeliveryDomainNotGranted: the attested signer may not deliver - // under the ownership domain the document asserts. 403, terminal. - // - // Distinct from the above on purpose: the signature is genuine and the - // signer is accepted, and what failed is the scope of its authority. An - // operator fixes those two in different places. - ErrSolutionDeliveryDomainNotGranted = errors.New("attested signer may not deliver under this ownership domain") - - // ErrSolutionDeliveryInvalid: the document parses but Validate refuses it. - // 422, terminal. - ErrSolutionDeliveryInvalid = errors.New("solution delivery document is invalid") - - // ErrSolutionDeliveryRewritten: this generation is already durable with - // DIFFERENT bytes. 409, terminal. - // - // It is a conflict rather than a bad request because the operator needs to - // see it as a rewrite of a delivered generation: publish never rewrites one, - // so reaching this means a hand-edited delivery tree. - ErrSolutionDeliveryRewritten = errors.New("solution delivery generation was already delivered with different content") + // ErrSolutionDeliveryMalformed: the carrier does not parse, or its payload + // is not the canonical encoding of a valid manifest of the one schema this + // host reads. 400, terminal. + ErrSolutionDeliveryMalformed = errors.New("module manifest carrier is malformed") + + // ErrSolutionDeliveryUnattested: the envelope does not verify under any + // key this host pins. 401, terminal. + ErrSolutionDeliveryUnattested = errors.New("module manifest is not signed by a key this host pins") + + // ErrSolutionDeliveryDomainNotGranted: the signer is pinned, but not for + // the ownership domain the manifest speaks for, or the binding was applied + // under another domain. 403, terminal. + ErrSolutionDeliveryDomainNotGranted = errors.New("module manifest speaks for an ownership domain its signer or binding does not hold") + + // ErrSolutionDeliveryConflict: the generation is older than the applied + // one, rewrites a delivered one, or would bring a withdrawn binding back. + // 409, terminal: publish never produces one, so it is a stale or + // hand-edited delivery. + ErrSolutionDeliveryConflict = errors.New("module manifest generation conflicts with this binding's history") + + // ErrSolutionDeliveryRefused: a valid, attested manifest this host will not + // take — another host's, or one whose route alias is reserved or held by + // another binding. 422, terminal. + ErrSolutionDeliveryRefused = errors.New("module manifest is refused by this host") + + // ErrSolutionDeliveryHostState: the binding's applied record on this host + // cannot be read back. 503: the host's own state, which an operator + // repairs; the delivery is not at fault. + ErrSolutionDeliveryHostState = errors.New("this host's applied record for the binding is unusable") ) // SolutionDeliveryStore is the inbox's persistence. type SolutionDeliveryStore interface { // RecordDeliveredDocument inserts one received carrier. It reports whether - // the row was new: an exact replay writes nothing and is not an error. - // - // The unique index on (kind, document_id, generation) is what makes a - // rewrite detectable, so the implementation turns that collision into - // ErrSolutionDeliveryRewritten rather than overwriting. + // the row was new: an exact replay writes nothing and is not an error. A + // generation already durable with different bytes answers + // ErrSolutionDeliveryConflict. RecordDeliveredDocument(ctx context.Context, record *SolutionDeliveryRecord) (inserted bool, err error) - // ListNewestDeliveredDocuments returns the newest generation per document - // for one kind — the desired set. - ListNewestDeliveredDocuments(ctx context.Context, kind SolutionDeliveryKind) ([]*SolutionDeliveryRecord, error) + // ListNewestDeliveredDocuments returns the newest generation per binding — + // the desired set. + ListNewestDeliveredDocuments(ctx context.Context) ([]*SolutionDeliveryRecord, error) - // ListDeliveredDocuments returns every delivered generation of one kind: - // the history a terminal withdrawal is read from. - ListDeliveredDocuments(ctx context.Context, kind SolutionDeliveryKind) ([]*SolutionDeliveryRecord, error) -} - -// SolutionDeliveryReader retrieves one exact historical delivery. Applied reads -// must not substitute the newest desired generation for the committed one. -type SolutionDeliveryReader interface { - ReadDeliveredDocument(context.Context, SolutionDeliveryKind, string, uint64) (*SolutionDeliveryRecord, error) -} - -// SolutionDeliveryAppliedReader recovers the last workload-bearing presence -// from committed decision history, never from desired or refused deliveries. -type SolutionDeliveryAppliedReader interface { - ReadAppliedWorkloadPresence(context.Context, string, uint64) (*SolutionHostBindingGeneration, error) + // ReadDeliveredDocument retrieves one exact generation, or nil. + ReadDeliveredDocument(ctx context.Context, binding string, generation uint64) (*SolutionDeliveryRecord, error) } // SolutionDeliveryCounter is the inbox's size, where the store can answer it. @@ -170,429 +115,225 @@ type SolutionDeliveryAppliedReader interface { // registry with no marker, and a mint then answers exactly as it did before // the marker existed. type SolutionDeliveryCounter interface { - // CountDeliveredDocuments is the number of delivered documents of every - // kind. The inbox is append-only, so it never decreases. + // CountDeliveredDocuments is the number of delivered manifests. The inbox + // is append-only, so it never decreases. CountDeliveredDocuments(ctx context.Context) (uint64, error) - // CountUnappliedPresence is the number of delivered presence documents - // newer than what their binding has applied and not refused. - CountUnappliedPresence(ctx context.Context) (uint64, error) + // CountUnappliedManifests is the number of bindings whose newest delivered + // manifest is newer than what they applied and not refused. + CountUnappliedManifests(ctx context.Context) (uint64, error) } -// SolutionDeliveryCarrierAuthorizer decides whether the CALLER may deliver this -// kind of document. +// SolutionDeliveryCarrierAuthorizer decides whether the CALLER may hand this +// host a manifest. // -// It is separate from bundle verification, and keeping them apart is the point. -// The bundle says who SIGNED the content; this says who HANDED IT OVER. A valid -// bundle does not make every caller a legitimate carrier — otherwise anyone who -// could replay a captured carrier could re-deliver it, including a superseded -// generation whose signature is still perfectly good. +// Separate from signature verification, and keeping them apart is the point: +// the envelope says who SIGNED the content; this says who HANDED IT OVER. A +// valid signature does not make every caller a carrier — otherwise anyone who +// captured a carrier could re-deliver it. type SolutionDeliveryCarrierAuthorizer interface { - // AuthorizeCarrier authorises the caller's credential for this kind, given - // the namespaces the document's own workloads declare. - // - // The namespaces are a parameter because presence and authority are - // authorised differently: authority is a fixed (service account, namespace) - // pair, while a presence carrier must come from the namespace the document - // itself declares. That makes the presence namespace a property of the - // SIGNED DOCUMENT rather than of host configuration a deployer can edit — - // the same reason the ownership domain lives inside the canonical bytes. - AuthorizeCarrier(ctx context.Context, credential string, kind SolutionDeliveryKind, declaredNamespaces []string) error + // AuthorizeCarrier authorises the caller's credential as the delivery + // account of namespace: the namespace the cell runs the manifest's module + // in, by rule (ModuleNamespaceRule). + AuthorizeCarrier(ctx context.Context, credential, namespace string) error } -// ReceiveSolutionDelivery verifies, authorises and persists one delivered -// carrier. +// ReceiveSolutionDelivery verifies, authorises, admits and persists one +// delivered module manifest carrier. // // The ORDER is the contract, and it is signature-first: // // 1. parse the carrier with core's strict ParseSigned; -// 2. verify the envelope against the pinned keys and their ownership domains, -// WITH THE READER FOR THE KIND THE PATH NAMES; -// 3. check the attested signer may speak for the domain the document asserts; -// 4. authorise the CALLER as a carrier for this kind; -// 5. persist. +// 2. verify the envelope against the pinned keys, the payload's canonical +// encoding, and the signer's pin for the manifest's ownership domain +// (core's VerifyDelivered); +// 3. authorise the CALLER: the delivery account of the module's namespace — +// the manifest's module, or for a withdrawal the module of the binding's +// previously applied manifest; +// 4. admit the generation against the binding's applied manifest (core's +// Admit: stale, rewritten, withdrawn, another host or domain) and its +// route alias against every applied manifest (core's CheckAlias); +// 5. hold it to this host's own rules — one workload, one manifest; a +// principal and a publisher stay with their domain; an endpoint is its +// workload's in-cluster Service (checkManifestAgainstHost); +// 6. persist. // -// Verification comes before carrier authorisation deliberately. The cheaper -// check is the carrier's, so an efficiency argument would put it first — but -// then an unauthenticated caller's malformed bytes would be refused with a -// message about credentials, and a captured carrier replayed by an authorised -// one would be refused with a message about signatures. Ordering by what the -// operator needs to read beats ordering by cost. +// Verification comes before carrier authorisation so an unauthenticated +// caller's malformed bytes are refused for being malformed, and a captured +// carrier replayed by an authorised one is refused for what it is. func (s *Service) ReceiveSolutionDelivery( - ctx context.Context, carrier SolutionDeliveryCarrier, credential string, + ctx context.Context, raw []byte, credential string, ) (SolutionDeliveryDisposition, *SolutionDeliveryRecord, error) { - if !carrier.Kind.Valid() { - return "", nil, fmt.Errorf("%w: unknown delivery kind %q", ErrSolutionDeliveryMalformed, carrier.Kind) - } - if s.deliveryVerifier == nil || s.deliveryStore == nil || s.deliveryCarrier == nil { - // Fail closed and say which half is missing. A deployment that wired - // the endpoint without a verifier must not accept documents. + if s.deliveryVerifier == nil || s.deliveryStore == nil || s.deliveryCarrier == nil || s.deliveryCoordinate == "" { + // Fail closed and say so. A deployment that wired the endpoint without + // a verifier or a coordinate must not accept documents. return "", nil, fmt.Errorf("%w: the delivery endpoint is not fully wired on this host", ErrSolutionDeliveryUnattested) } - - signed, err := solutionhost.ParseSigned(carrier.Raw) + signed, err := solutionhost.ParseSigned(raw) if err != nil { return "", nil, fmt.Errorf("%w: %w", ErrSolutionDeliveryMalformed, err) } - - received, err := s.receiveDeliveredDocument(ctx, carrier.Kind, signed) + delivered, err := solutionhost.VerifyDelivered(ctx, signed, s.deliveryVerifier) if err != nil { - return "", nil, err + return "", nil, classifySolutionDeliveryVerification(err) } - - // The attested signer must be entitled to the domain the document asserts. - // Checked here as well as by core's Admit, because a document that will be - // refused at admission should not become durable desired state in the first - // place — an inbox of documents nobody may act on is an inbox an operator - // has to triage. - if !s.signerMaySpeakFor(received.signer, received.ownershipDomain) { - return "", nil, fmt.Errorf("%w: signer %q, domain %q", - ErrSolutionDeliveryDomainNotGranted, received.signer, received.ownershipDomain) + manifest, err := delivered.Manifest() + if err != nil { + return "", nil, fmt.Errorf("%w: %w", ErrSolutionDeliveryMalformed, err) } - if carrier.Kind == SolutionDeliveryAuthority { - if err := s.admitAuthorityTarget(ctx, received.payload); err != nil { - return "", nil, err + records, err := s.ListSolutionHostBindings(ctx) + if err != nil { + return "", nil, err + } + var record *SolutionHostBindingRecord + for _, candidate := range records { + if candidate.BindingID == manifest.Binding { + record = candidate } } + previous, previousErr := record.AppliedManifest() + + // The carrier is the delivery account of the module's namespace. A + // withdrawal is delivered from where the binding was delivered from, so + // its module is the previously applied one. + module := manifest.Alias + if manifest.Removed && previous != nil { + module = previous.Alias + } + if err := s.deliveryCarrier.AuthorizeCarrier(ctx, credential, s.moduleNamespaces.Namespace(module)); err != nil { + return "", nil, err + } - if err := s.deliveryCarrier.AuthorizeCarrier( - ctx, credential, carrier.Kind, received.carrierNamespaces); err != nil { + if previousErr != nil { + return "", nil, classifySolutionDeliveryAdmission(previousErr) + } + if _, err := solutionhost.Admit(s.deliveryCoordinate, previous, manifest); err != nil { + return "", nil, classifySolutionDeliveryAdmission(err) + } + if err := record.checkOwnershipDomain(manifest); err != nil { + return "", nil, err + } + // The alias is held to the reserved namespaces and to every other binding + // as it stands once its newest delivered generation applies, so a + // withdrawal already delivered releases the alias it held. + newest, err := s.newestDeliveredManifests(ctx, manifest.Binding) + if err != nil { + return "", nil, err + } + holders := aliasHolders(appliedManifests(records), newest) + if err := solutionhost.CheckAlias([]string{SolutionHostReservedRouteNamespace}, holders, manifest); err != nil { + return "", nil, classifySolutionDeliveryAdmission(err) + } + // The workloads, principal, publisher and endpoint addresses are held to + // this host's rules against every other binding as it stands once its + // newest delivered generation applies — so a second claim is refused here, + // naming the binding that holds it, rather than found by a pass. + if err := s.moduleNamespaces.checkManifestAgainstHost(manifest, standingManifests(appliedManifests(records), newest)); err != nil { return "", nil, err } - record := &SolutionDeliveryRecord{ - Kind: carrier.Kind, - DocumentID: received.documentID, - Generation: received.generation, - ContentHash: solutionDeliveryContentHash(received.payload), - Payload: received.payload, - Bundle: signed.Envelope, - SignerIdentity: received.signer, - OwnershipDomain: received.ownershipDomain, + digest, err := manifest.Digest() + if err != nil { + return "", nil, fmt.Errorf("%w: %w", ErrSolutionDeliveryMalformed, err) + } + stored := &SolutionDeliveryRecord{ + Binding: manifest.Binding, + Generation: manifest.Generation, + ContentHash: digest, + Payload: delivered.Payload(), + Envelope: signed.Envelope, + SignerIdentity: delivered.Signer().KeyID, + OwnershipDomain: manifest.Host.Domain, ReceivedAt: time.Now().UTC(), } - var inserted bool if err := s.store.WithControlPlane(ctx, func(ctx context.Context) error { var storeErr error - inserted, storeErr = s.deliveryStore.RecordDeliveredDocument(ctx, record) + inserted, storeErr = s.deliveryStore.RecordDeliveredDocument(ctx, stored) return storeErr }); err != nil { return "", nil, err } if inserted { - // A new document changes what the host should apply and which module - // principals the registry holds, so a pass is requested now rather than - // at the next tick. An exact replay changes nothing and asks for none. + // A new generation changes what the host applies and which module + // principals the registry holds, so a pass is requested now rather + // than at the next tick. An exact replay changes nothing. s.requestRegistryPass() - return SolutionDeliveryAccepted, record, nil + return SolutionDeliveryAccepted, stored, nil } - return SolutionDeliveryReplayed, record, nil + return SolutionDeliveryReplayed, stored, nil } -// admitAuthorityTarget refuses an authority document — a grant or a tombstone — -// that cannot speak for the binding it names: one addressed to another host, -// or one asserting an ownership domain other than the binding's OWNER (the -// domain of the presence generation this host admitted and applied — never a -// delivered row core refused). The signer -// check above only proves the signer may speak for the domain the document -// ASSERTS; without this, an accepted signer for one domain could deliver a -// tombstone naming another domain's binding and withdraw it permanently. -// Reconciliation applies the same rule to what is already stored. -func (s *Service) admitAuthorityTarget(ctx context.Context, payload []byte) error { - document, err := solutionhost.AuthorityFromVerified(payload) - if err != nil { - return fmt.Errorf("%w: %w", ErrSolutionDeliveryMalformed, err) - } - if s.deliveryCoordinate != "" && document.Host.Coordinate != s.deliveryCoordinate { - return fmt.Errorf("%w: authority %q targets host %q, and this host is %q", - ErrSolutionDeliveryDomainNotGranted, document.Authority, document.Host.Coordinate, s.deliveryCoordinate) - } - owners, err := s.admittedBindingOwners(ctx) - if err != nil { +// newestDeliveredManifests is the newest delivered manifest of every binding +// but except, keyed by binding: the desired state an alias is judged against. +// The rows were verified on receipt; one that no longer reads claims nothing +// here, and the pass reports it against its binding. +func (s *Service) newestDeliveredManifests(ctx context.Context, except string) (map[string]*solutionhost.ModuleManifest, error) { + var records []*SolutionDeliveryRecord + if err := s.store.WithControlPlane(ctx, func(ctx context.Context) error { + var err error + records, err = s.deliveryStore.ListNewestDeliveredDocuments(ctx) return err + }); err != nil { + return nil, fmt.Errorf("read the delivery inbox: %w", err) } - if domain, known := owners[document.PresenceBinding]; known && domain != document.OwnershipDomain { - return fmt.Errorf("%w: authority %q speaks for domain %q over binding %q, which domain %q owns", - ErrSolutionDeliveryDomainNotGranted, document.Authority, document.OwnershipDomain, document.PresenceBinding, domain) + newest := make(map[string]*solutionhost.ModuleManifest, len(records)) + for _, record := range records { + if record.Binding == except { + continue + } + if manifest, err := solutionhost.ManifestFromVerified(record.Payload); err == nil { + newest[record.Binding] = manifest + } } - return nil + return newest, nil } // SetSolutionDeliveryCoordinate names the host coordinate this host answers -// for, so delivery refuses an authority document addressed to another host at -// receipt. Boot wiring, beside SetSolutionDelivery. +// for, which every admission is decided under. Boot wiring, beside +// SetSolutionDelivery. func (s *Service) SetSolutionDeliveryCoordinate(coordinate string) { s.deliveryCoordinate = coordinate } -// receivedSolutionDelivery is one verified document read into the shape the -// inbox stores. The two kinds are different documents with different identity -// fields and different carrier rules, and this is where that difference ends: -// everything past it is one row. -type receivedSolutionDelivery struct { - documentID string - generation uint64 - ownershipDomain string - signer string - payload []byte - - // carrierNamespaces is what the CALLER is authorised against. It is empty - // for authority, which has one fixed writer namespace, and for presence it - // is the namespaces the document's own workloads declare — see - // presenceCarrierNamespaces for the generation that declares none. - carrierNamespaces []string -} - -// receiveDeliveredDocument verifies the carrier with the reader for the kind -// the PATH named. -// -// This dispatch is the defect it replaces, not a tidy-up. Both paths called -// VerifyDelivered — the PRESENCE reader — so the two halves of the lifecycle -// were exactly inverted at the endpoint: -// -// - a genuine authority document POSTed to /authority was refused, because -// the presence reader checks the schema inside the signed bytes and an -// authority payload declares the authority schema; -// - a presence carrier POSTed to /authority was ACCEPTED and stored as -// kind=authority, because the presence reader is exactly what it satisfies. -// -// So the inbox could hold a presence document filed as authority and could -// never hold an authority document at all. The kind in the path and the schema -// inside the attested bytes have to be checked against each other, and core -// gives one reader per kind precisely so that check is a function call rather -// than a comparison a host writes itself. -func (s *Service) receiveDeliveredDocument( - ctx context.Context, kind SolutionDeliveryKind, signed *solutionhost.Signed, -) (*receivedSolutionDelivery, error) { - switch kind { - case SolutionDeliveryPresence: - return s.receiveDeliveredPresence(ctx, signed) - case SolutionDeliveryAuthority: - return s.receiveDeliveredAuthority(ctx, signed) - case SolutionDeliveryEndpoints: - return s.receiveDeliveredEndpoints(ctx, signed) - } - return nil, fmt.Errorf("%w: unknown delivery kind %q", ErrSolutionDeliveryMalformed, kind) -} - -func (s *Service) receiveDeliveredPresence( - ctx context.Context, signed *solutionhost.Signed, -) (*receivedSolutionDelivery, error) { - delivered, err := solutionhost.VerifyDelivered(ctx, signed, s.deliveryVerifier) - if err != nil { - return nil, classifySolutionDeliveryVerification(err) - } - document, err := delivered.Document() - if err != nil { - // The payload verified but does not decode to a document whose canonical - // encoding is those same bytes. Malformed rather than unattested: the - // attestation is sound and the bytes are not what they claim. - return nil, fmt.Errorf("%w: %w", ErrSolutionDeliveryMalformed, err) - } - if err := document.Validate(); err != nil { - return nil, fmt.Errorf("%w: %w", ErrSolutionDeliveryInvalid, err) - } - namespaces, err := s.presenceCarrierNamespaces(ctx, document) - if err != nil { - return nil, err - } - return &receivedSolutionDelivery{ - documentID: document.Binding, - generation: document.Generation, - ownershipDomain: document.OwnershipDomain, - signer: delivered.DeliveredBy(), - payload: delivered.Payload(), - carrierNamespaces: namespaces, - }, nil -} - -func (s *Service) receiveDeliveredAuthority( - ctx context.Context, signed *solutionhost.Signed, -) (*receivedSolutionDelivery, error) { - delivered, err := solutionhost.VerifyDeliveredAuthority(ctx, signed, s.deliveryVerifier) - if err != nil { - return nil, classifySolutionDeliveryVerification(err) - } - document, err := delivered.Document() - if err != nil { - return nil, fmt.Errorf("%w: %w", ErrSolutionDeliveryMalformed, err) - } - if err := document.Validate(); err != nil { - return nil, fmt.Errorf("%w: %w", ErrSolutionDeliveryInvalid, err) - } - // The authority document's identity is its OWN id, never the presence - // binding it is granted over. One binding can be granted authority under - // successive authority IDs, and core's own fold is keyed on the binding - // precisely so a rename does not reinstate a withdrawal — which only works - // if the inbox keeps the authority ID it was delivered as. - return &receivedSolutionDelivery{ - documentID: document.Authority, - generation: document.Generation, - ownershipDomain: document.OwnershipDomain, - signer: delivered.DeliveredBy(), - payload: delivered.Payload(), - // Authority has ONE writer namespace for the whole platform, so there - // is nothing read out of the document to authorise the carrier against. - carrierNamespaces: nil, - }, nil -} - // classifySolutionDeliveryVerification separates "the signer is wrong" from // "the bytes are wrong", because the two have different response codes and a // caller acts differently on each. -// -// Core verifies the envelope, reads the canonical document of the requested -// kind, then checks the pinned key's ownership domains. Signature and domain -// refusals are about the SIGNER. A schema -// mismatch, a payload that is not its own canonical encoding, or a document -// core will not parse are all facts about the bytes — which is why a genuine -// authority document POSTed to /presence is 400 rather than 403. Answering 403 -// would send an operator to look at the signing identity for a delivery that -// was signed perfectly well and simply sent to the wrong path. func classifySolutionDeliveryVerification(err error) error { - if errors.Is(err, solutionhost.ErrWrongDomain) { + switch { + case errors.Is(err, solutionhost.ErrWrongDomain): return fmt.Errorf("%w: %w", ErrSolutionDeliveryDomainNotGranted, err) - } - if errors.Is(err, solutionhost.ErrUnsigned) { + case errors.Is(err, solutionhost.ErrUnsigned): return fmt.Errorf("%w: %w", ErrSolutionDeliveryUnattested, err) } return fmt.Errorf("%w: %w", ErrSolutionDeliveryMalformed, err) } -// presenceCarrierNamespaces is what a presence carrier is authorised against. -// -// Normally it is the namespaces the document's OWN workloads declare, through -// their SPIFFE IDs — a property of the signed bytes rather than of host -// configuration a deployer can edit. -// -// A generation that declares no workload declares no namespace, and there are -// two of those: a TOMBSTONE, which declares absence and may carry nothing at -// all, and a present generation that renders only a frontend surface. Refusing -// the empty case outright made REMOVAL THROUGH THIS ENDPOINT IMPOSSIBLE — the -// one document delivery must be able to hand over to withdraw a binding was the -// one document the carrier check could never authorise. -// -// So the fallback is the binding's LAST WORKLOAD-BEARING APPLIED namespace: what this host -// recorded, from a generation it had already verified and admitted, for the same -// binding. That is a stronger anchor than the arriving document, because the -// arriving document is the thing under check and the applied record is the -// host's own state. -// -// It can still come back empty — a first generation for a binding nothing has -// applied, or a binding whose applied history never declared a workload. -// Empty is handed to the authorizer rather than resolved here, so the refusal -// reads in one place: there is nothing to authorise this carrier against. -func (s *Service) presenceCarrierNamespaces( - ctx context.Context, document *solutionhost.SolutionHostBinding, -) ([]string, error) { - if declared := declaredNamespaces(document); len(declared) > 0 { - return declared, nil - } - records, err := s.ListSolutionHostBindings(ctx) - if err != nil { - // NOT an empty namespace set. A store this host cannot read must not - // turn into "authorise against nothing", which for a document that - // declares none is indistinguishable from a carrier check that passed. - return nil, err - } - for _, record := range records { - if record.BindingID != document.Binding || record.Applied == nil { - continue - } - applied, err := solutionhost.Parse([]byte(record.Applied.Document)) - if err != nil { - // This host wrote that document itself, after verifying and - // admitting it. Unparseable means the host's own state is damaged, - // which is reported rather than treated as "no namespace". - return nil, fmt.Errorf("%w: binding %q applied generation %d does not parse back: %w", - ErrSolutionDeliveryInvalid, record.BindingID, record.Applied.Generation, err) - } - if namespaces := declaredNamespaces(applied); len(namespaces) > 0 { - return namespaces, nil - } - reader, ok := s.deliveryStore.(SolutionDeliveryAppliedReader) - if !ok { - return nil, fmt.Errorf("%w: applied carrier history is unavailable", ErrSolutionDeliveryInvalid) - } - var previous *SolutionHostBindingGeneration - if err := s.store.WithControlPlane(ctx, func(ctx context.Context) error { - var readErr error - previous, readErr = reader.ReadAppliedWorkloadPresence(ctx, document.Binding, record.Applied.Generation) - return readErr - }); err != nil { - return nil, err - } - if previous == nil { - return nil, nil - } - prior, err := solutionhost.Parse([]byte(previous.Document)) - if err != nil { - return nil, fmt.Errorf("%w: parse applied carrier history: %w", ErrSolutionDeliveryInvalid, err) - } - digest, err := prior.Digest() - if err != nil || digest != previous.Digest || prior.Binding != document.Binding || - prior.Generation != previous.Generation || prior.Generation > record.Applied.Generation || - prior.Host != applied.Host || prior.OwnershipDomain != applied.OwnershipDomain || prior.Removed { - return nil, fmt.Errorf("%w: applied carrier history does not match binding %q", ErrSolutionDeliveryInvalid, document.Binding) - } - return declaredNamespaces(prior), nil - } - return nil, nil -} - -// signerMaySpeakFor reports whether an attested signer is granted an ownership -// domain, by the same mapping core's Admit is given. -func (s *Service) signerMaySpeakFor(signer, domain string) bool { - for _, granted := range s.deliveryDomainsBySigner[signer] { - if granted == domain { - return true - } - } - return false -} - -// declaredNamespaces is the set of namespaces the document's own workloads name, -// read out of each workload identity's SPIFFE ID. -// -// A presence carrier is authorised against this rather than against a configured -// constant, because a presence Job runs one per module tree in that module's own -// namespace — a fixed value would refuse every genuine carrier. Reading it from -// the signed document also keeps it out of configuration a deployer can edit. -// -// More than one distinct namespace is returned as more than one entry, and the -// authorizer refuses that: the renderer uses one namespace per render and cannot -// emit such a document, so one that exists was hand-built, and "the namespace -// this was delivered from" would have no single answer. -func declaredNamespaces(document *solutionhost.SolutionHostBinding) []string { - seen := map[string]bool{} - var namespaces []string - for _, workload := range document.Workloads { - identity, err := ParseKubernetesSPIFFEID(workload.Identity.SPIFFEID) - namespace := identity.Namespace - if err != nil || seen[namespace] { - continue - } - seen[namespace] = true - namespaces = append(namespaces, namespace) +// classifySolutionDeliveryAdmission maps core's admission refusals onto this +// host's delivery taxonomy, keeping core's own wording as the cause. +func classifySolutionDeliveryAdmission(err error) error { + switch { + case errors.Is(err, solutionhost.ErrStaleGeneration), + errors.Is(err, solutionhost.ErrRewrittenGeneration), + errors.Is(err, solutionhost.ErrWithdrawn): + return fmt.Errorf("%w: %w", ErrSolutionDeliveryConflict, err) + case errors.Is(err, solutionhost.ErrWrongDomain): + return fmt.Errorf("%w: %w", ErrSolutionDeliveryDomainNotGranted, err) + case errors.Is(err, solutionhost.ErrAppliedUnusable): + return fmt.Errorf("%w: %w", ErrSolutionDeliveryHostState, err) + case errors.Is(err, solutionhost.ErrInvalid), errors.Is(err, solutionhost.ErrSchema): + return fmt.Errorf("%w: %w", ErrSolutionDeliveryMalformed, err) } - return namespaces -} - -// solutionDeliveryContentHash is the idempotency key's content half: sha256 over -// the canonical payload, in the `sha256:<64 hex>` shape the schema requires. -func solutionDeliveryContentHash(payload []byte) string { - sum := sha256.Sum256(payload) - return "sha256:" + hex.EncodeToString(sum[:]) + // Another host's coordinate, a reserved or held route alias. + return fmt.Errorf("%w: %w", ErrSolutionDeliveryRefused, err) } // DeliveredSolutionHostBindings is the reconciler's source, reading the newest // generation per binding from the durable inbox. // -// It replaces the mount, and it closes the gap a mount could not: a document -// whose apply failed stays in the desired set and is retried on the next pass, -// because the desired set is what the host was TOLD rather than what a directory -// still happens to contain. +// A document whose apply failed stays in the desired set and is retried on the +// next pass, because the desired set is what the host was TOLD rather than +// what a directory still happens to contain. type DeliveredSolutionHostBindings struct { service *Service } @@ -605,39 +346,39 @@ func NewDeliveredSolutionHostBindings(service *Service) *DeliveredSolutionHostBi // Documents returns the newest delivered generation per binding, as carriers. // // Each row is handed back as the carrier it arrived as, so the reconciler -// re-verifies it on every pass rather than trusting the inbox. That is -// deliberate and it is what makes a narrowed verification policy take effect: a -// signer removed from the allowlist stops being able to keep its documents -// applied, instead of its last delivery remaining authoritative forever. +// re-verifies it on every pass rather than trusting the inbox: a key unpinned +// since stops keeping its manifests applied. // -// An unreadable inbox is an ERROR, not an empty desired set — the same rule the -// mount had, for the same reason: removal is a tombstone generation precisely so -// that losing the input can never be reconciled as "remove everything". +// An unreadable inbox is an ERROR, not an empty desired set: removal is a +// withdrawal generation precisely so that losing the input can never be +// reconciled as "remove everything". func (d *DeliveredSolutionHostBindings) Documents(ctx context.Context) ([]SolutionHostBindingDocument, error) { var records []*SolutionDeliveryRecord if err := d.service.store.WithControlPlane(ctx, func(ctx context.Context) error { var err error - records, err = d.service.deliveryStore.ListNewestDeliveredDocuments(ctx, SolutionDeliveryPresence) + records, err = d.service.deliveryStore.ListNewestDeliveredDocuments(ctx) return err }); err != nil { return nil, fmt.Errorf("read the delivery inbox: %w", err) } documents := make([]SolutionHostBindingDocument, 0, len(records)) for _, record := range records { - carrier, err := solutionhost.MarshalSigned(&solutionhost.Signed{ - Document: record.Payload, - Envelope: record.Bundle, - }) + carrier, err := solutionhost.MarshalSigned(carrierOf(record)) if err != nil { // A row that cannot be put back into a carrier is a defect in what // was stored, and it must not read as "delivery withdrew this". return nil, fmt.Errorf("rebuild the carrier for %s generation %d: %w", - record.DocumentID, record.Generation, err) + record.Binding, record.Generation, err) } documents = append(documents, SolutionHostBindingDocument{ - Source: fmt.Sprintf("inbox:%s@%d", record.DocumentID, record.Generation), + Source: fmt.Sprintf("inbox:%s@%d", record.Binding, record.Generation), Data: carrier, }) } return documents, nil } + +// carrierOf rebuilds a stored row's signed carrier. +func carrierOf(record *SolutionDeliveryRecord) *solutionhost.Signed { + return &solutionhost.Signed{Document: record.Payload, Envelope: record.Envelope} +} diff --git a/module/services/accounts/code/pkg/business/solution_delivery_activation.go b/module/services/accounts/code/pkg/business/solution_delivery_activation.go deleted file mode 100644 index f0b0d5c48..000000000 --- a/module/services/accounts/code/pkg/business/solution_delivery_activation.go +++ /dev/null @@ -1,634 +0,0 @@ -package business - -import ( - "context" - "errors" - "fmt" - "slices" - - "github.com/codefly-dev/core/solutionhost" -) - -// Activation: the one shape in which delivered authority is active (issue #952). -// -// WHAT THIS CLOSES. The delivery endpoint accepted authority documents and -// persisted them, and nothing ever read one. There was no call site for core's -// Activate anywhere in this host, so the authority half of the lifecycle was a -// write-only inbox: an operator could deliver a reviewed, signed, envelope-bound -// grant and the host would store it and behave exactly as if it had not. -// -// NEITHER HALF ACTIVATES ALONE, which is why this takes a binding and a build -// rather than offering a way to ask the question of a document. An authority -// document on its own grants nothing, because nothing says the build it approves -// is the build that is present; a presence document on its own grants nothing, -// because nothing says anyone was granted authority over it. The build is a -// parameter rather than something read out of either document, because it is the -// thing being asked about — reading it out of the document that approves it would -// make the question answer itself. -// -// THE CEILING IS NOT HERE. core's Envelope is supplied by the caller and never -// read out of a document, because "an envelope a document carried would be a -// document declaring its own ceiling" — and ValidateAgainst tests a document's -// grants and approved build against the envelope's, so an envelope assembled -// from the delivery tree answers itself. The host reads the CURRENT module -// envelope — the record a platform super_admin writes (module_envelope.go) — -// on every call, and projects it for the document being judged. -// -// THIS HOST APPLIES AUTHORITY TO NOTHING, and that is stated rather than -// defaulted. core's ActivationRequest requires either the applied authority -// record or an explicit marker that none exists, because "nothing applied" is -// the most permissive input the call takes. This host holds no applied authority -// record — there is no durable AppliedAuthority state and nothing to reconcile -// it into — so the marker is the truth, and the replay protection core's fold -// would otherwise give comes from the inbox instead: the desired set is the -// NEWEST generation per document, so a genuinely signed older generation -// re-delivered never becomes the answer. -// -// One thing the fold does that the inbox does not do by itself is refuse an -// authority WITHDRAWN under one id and re-signed under another — core keys that -// on the binding precisely so a rename cannot reinstate it. So this does it -// explicitly: every delivered authority document over the binding is read, and a -// tombstone among them refuses activation for that binding permanently. Nothing -// deletes from the inbox, so a withdrawal stays visible forever, which is what -// makes that check equivalent rather than merely similar. - -var ( - // ErrSolutionAuthorityCeilingUnavailable reports that no authority envelope - // is delivered to this host, so it can answer no authority question. - // - // Distinct from "not activated" on purpose: nothing was judged. A host with - // no ceiling has not decided that a grant is absent, it has decided - // nothing, and an operator fixes those two in different places. - ErrSolutionAuthorityCeilingUnavailable = errors.New("this host has no authority envelope, so it activates nothing") - - // ErrSolutionAuthorityNotDelivered reports that one half is missing from the - // inbox: no authority document over this binding, or no presence document - // for it. - ErrSolutionAuthorityNotDelivered = errors.New("solution authority or presence has not been delivered for this binding") - - // ErrSolutionAuthorityWithdrawn reports that authority over this binding was - // withdrawn. TERMINAL for the binding, including for an authority re-signed - // under a new id: a withdrawal is a statement about the binding, and - // renaming the grant does not reinstate it. - ErrSolutionAuthorityWithdrawn = errors.New("solution authority over this binding was withdrawn") - - // ErrSolutionAuthorityAmbiguous reports more than one live authority - // document over one binding. Refused rather than resolved: whichever one a - // host picked would be a choice nobody reviewed. - ErrSolutionAuthorityAmbiguous = errors.New("more than one live solution authority document is delivered over this binding") -) - -// SolutionAuthorityActivation answers whether delivered authority is active for -// a binding at a build. -// -// It is built on the reconciler rather than beside it, and that is not -// convenience: the reconciler already holds the four policy inputs activation -// needs — this host's coordinate, the ownership domains it accepts, which signer -// may speak for which domain, and the bundle verifier — and a second copy of any -// of them is a second answer to "what does this host accept". -type SolutionAuthorityActivation struct { - reconciler *SolutionHostBindingReconciler - // envelopes is where the CURRENT ceiling is read from, on every call. Nil - // is a host with no ceiling: it activates nothing. - envelopes ModuleEnvelopeSource -} - -// newSolutionAuthorityActivation builds the activation reader, which the -// reconciler's constructor owns: a caller that could build one with a different -// coordinate, a different signer policy or a different verifier than the -// reconciler's would be a second answer to what this host accepts. Reach it -// through SolutionHostBindingReconciler.AuthorityActivation. -// -// No envelope — no source, or none written yet — is accepted and makes every -// activation refuse with ErrSolutionAuthorityCeilingUnavailable, rather than -// refusing at construction. -// The distinction matters: a host that reconciles presence and has no ceiling -// delivered is a correct, complete deployment — authority is simply not a -// question it can answer — while a host that refused to start would make the -// presence half depend on the authority half being configured. -func newSolutionAuthorityActivation( - reconciler *SolutionHostBindingReconciler, envelopes ModuleEnvelopeSource, -) (*SolutionAuthorityActivation, error) { - if reconciler == nil { - return nil, errors.New("solution authority activation requires the declared-presence reconciler, which holds this host's coordinate, domains, signer policy and verifier") - } - return &SolutionAuthorityActivation{reconciler: reconciler, envelopes: envelopes}, nil -} - -// currentEnvelope reads the ceiling as it stands now. Nil with no error is a -// host with no ceiling — no source wired, or no revision written — which -// activates and grants nothing; an error is a ceiling that could not be -// established, which a caller must not read as "none". -func (a *SolutionAuthorityActivation) currentEnvelope(ctx context.Context) (*ModuleEnvelope, error) { - if a == nil || a.envelopes == nil { - return nil, nil - } - envelope, err := a.envelopes.CurrentModuleEnvelope(ctx) - if errors.Is(err, ErrModuleEnvelopeUnavailable) { - return nil, nil - } - return envelope, err -} - -// Activate reports whether the delivered authority over binding and the -// delivered presence for it form a matched tuple for build. -// -// Both halves are RE-VERIFIED out of the inbox on every call rather than trusted -// as stored, which is the same rule the reconcile pass follows and for the same -// reason: a signer removed from the allowlist must stop being able to keep its -// documents effective, instead of its last delivery remaining authoritative -// forever. -func (a *SolutionAuthorityActivation) Activate( - ctx context.Context, binding string, build solutionhost.ImageDigest, -) (solutionhost.Activation, error) { - if a == nil || a.reconciler == nil { - return solutionhost.Activation{}, ErrSolutionAuthorityCeilingUnavailable - } - envelope, err := a.currentEnvelope(ctx) - if err != nil { - return solutionhost.Activation{}, fmt.Errorf("read the authority ceiling for binding %q: %w", binding, err) - } - if envelope == nil { - return solutionhost.Activation{}, fmt.Errorf("%w: binding %q", ErrSolutionAuthorityCeilingUnavailable, binding) - } - - authority, err := a.deliveredAuthority(ctx, binding) - if err != nil { - return solutionhost.Activation{}, err - } - authorityDocument, err := authority.Document() - if err != nil { - return solutionhost.Activation{}, fmt.Errorf("read delivered authority over binding %q: %w", binding, err) - } - presence, err := a.deliveredPresence(ctx, binding) - if err != nil { - return solutionhost.Activation{}, err - } - host := a.reconciler.host - records, err := a.appliedState(ctx) - if err != nil { - return solutionhost.Activation{}, err - } - return solutionhost.Activate(solutionhost.ActivationRequest{ - Authority: authority, - Presence: presence, - Coordinate: host.Coordinate, - Build: build, - Envelope: envelope.authorityEnvelopeFor(authorityDocument), - DomainsBySigner: host.DomainsBySigner, - // The ownership domains this host ACCEPTS, which is a different - // question from which signer may speak for one. Core requires it for - // the reason it requires it of Admit: activation checked who may speak - // for a domain and never whether the host accepts the domain at all, - // so an activation succeeded for a domain the host does not list while - // Admit refused the very same documents. - Domains: host.Domains, - // The applied state, which CORE READS ITSELF through this reader. - Records: records, - }) -} - -// appliedState builds the reader core reads the host's applied state through. -// -// It pre-loads the records because the interface takes no context: a reader that -// closed over one and queried per call would run a database read inside core's -// judgement, where a failure has nowhere to go but a bool. -func (a *SolutionAuthorityActivation) appliedState( - ctx context.Context, -) (*appliedSolutionState, error) { - records, err := a.reconciler.service.ListSolutionHostBindings(ctx) - if err != nil { - return nil, fmt.Errorf("read applied solution host bindings: %w", err) - } - byBinding := make(map[string]*SolutionHostBindingRecord, len(records)) - for _, record := range records { - byBinding[record.BindingID] = record - } - return &appliedSolutionState{byBinding: byBinding}, nil -} - -// appliedSolutionState answers core's two applied-state questions. -// -// WHY THIS EXISTS AT ALL, since an earlier version of this file said it did not. -// That comment read core v0.9.0's removal of `Applied`, `FirstAuthorityRecord`, -// `AppliedPresence` and `FirstPresenceRecord` and concluded activation no longer -// takes the host's applied state. It does: core replaced four caller-SUPPLIED -// fields with one reader it calls ITSELF, having derived the binding from the -// attested presence bytes — so the key cannot be the caller's choice and "no -// record" cannot be the caller's assertion. Passing neither it nor Domains made -// core refuse every tuple by name, which is how this was found. -type appliedSolutionState struct { - byBinding map[string]*SolutionHostBindingRecord -} - -// PresenceRecord answers from the durable applied generation. -func (s *appliedSolutionState) PresenceRecord(binding string) (solutionhost.Applied, bool, error) { - record, ok := s.byBinding[binding] - if !ok { - return solutionhost.Applied{}, false, nil - } - applied, ok := record.AppliedState() - return applied, ok, nil -} - -// AuthorityRecord reports that there is none, for every binding. -// -// THIS HOST HOLDS NO APPLIED AUTHORITY STATE. There is no durable -// AppliedAuthority relation and nothing to reconcile one into, so "no record" is -// the truth here rather than a marker — and it is the truth UNIFORMLY, which is -// what makes it safe. Core's reason for taking the reader was that a host keyed -// by AUTHORITY ID truthfully found no record for a RENAMED authority and -// activated it over a withdrawn binding: the bypass needed a lookup that could -// miss. A reader that answers "none" for every binding cannot miss selectively. -// -// The replay protection core's fold would otherwise give comes from the inbox -// instead, and that is not a weaker substitute for the renaming case -// specifically: deliveredAuthority reads EVERY delivered authority document over -// the binding and a tombstone among them refuses activation permanently, -// whatever id it was signed under. Nothing deletes from the inbox, so a -// withdrawal stays visible forever. -// -// If this host ever grows durable applied authority state, this must answer from -// it, keyed on the BINDING and never on the authority id. -func (s *appliedSolutionState) AuthorityRecord(string) (solutionhost.AppliedAuthority, bool, error) { - return solutionhost.AppliedAuthority{}, false, nil -} - -var _ solutionhost.AppliedStateReader = (*appliedSolutionState)(nil) - -// Core v0.9.0 replaced `Applied`, `FirstAuthorityRecord`, `AppliedPresence` and -// `FirstPresenceRecord` with ONE `Records AppliedStateReader` that core calls -// itself, keyed on a binding it derives from the attested presence bytes. An -// earlier version of this comment read the removal and missed the replacement, -// concluding activation no longer takes applied state; it does, and passing -// neither it nor `Domains` made core refuse every tuple. -// -// None of that retires the withdrawal check below. Core's activation judges the -// documents it is handed and the records it reads; it does not know that a -// tombstone for this binding was delivered under a different authority id, -// because nothing in the request carries the inbox — and this host's -// AuthorityRecord has none to give. Reading every authority document over the -// binding is still this host's job. -// -// deliveredAuthority reads the live authority document over one binding. -// -// EVERY delivered authority document is read, not just the one that matches. -// Two reasons, and the first is the hole: a withdrawal is recorded under the -// authority id it was delivered as, so finding "the authority over this binding" -// by looking only at a candidate would miss a tombstone under a different id — -// which is exactly the rename that core's binding-keyed fold exists to refuse. -// The second is that two live documents over one binding have no right answer, -// and a host that read the first match would pick one silently. -func (a *SolutionAuthorityActivation) deliveredAuthority( - ctx context.Context, binding string, -) (*solutionhost.DeliveredAuthority, error) { - inputs, err := a.readRegistryInputs(ctx) - if err != nil { - return nil, err - } - delivered, err := a.verifiedAuthorityIn(ctx, inputs.newestAuthority) - if err != nil { - return nil, err - } - withdrawn, err := a.withdrawnIn(inputs) - if err != nil { - return nil, err - } - if authority, gone := withdrawn[binding]; gone { - return nil, fmt.Errorf("%w: authority %q withdrew binding %q, and a withdrawal is terminal for it — a later generation or a new authority id does not reinstate it", - ErrSolutionAuthorityWithdrawn, authority, binding) - } - var live []*solutionhost.DeliveredAuthority - for _, one := range delivered { - if one.document.PresenceBinding != binding { - continue - } - if one.document.Removed { - return nil, fmt.Errorf("%w: authority %q was withdrawn at generation %d, and a withdrawal is terminal for binding %q — re-signing under a new authority id does not reinstate it", - ErrSolutionAuthorityWithdrawn, one.document.Authority, one.document.Generation, binding) - } - live = append(live, one.delivered) - } - switch len(live) { - case 0: - return nil, fmt.Errorf("%w: no authority document is delivered over binding %q", - ErrSolutionAuthorityNotDelivered, binding) - case 1: - return live[0], nil - } - return nil, fmt.Errorf("%w: %d over binding %q", ErrSolutionAuthorityAmbiguous, len(live), binding) -} - -// verifiedAuthorityDocument pairs a re-verified carrier with the document it -// carries, so a caller that needs both does not verify twice — and, more to the -// point, so there is exactly one place where a stored row is turned back into a -// document this host accepts. -type verifiedAuthorityDocument struct { - delivered *solutionhost.DeliveredAuthority - document *solutionhost.AuthorityDocument -} - -// verifiedAuthorityIn re-verifies EVERY newest-generation authority row and -// returns what it read, tombstones included. -// -// FAIL CLOSED OVER THE WHOLE READ rather than skipping the row. The binding a -// row is over is only knowable by reading it, so a row this host can no longer -// verify leaves the authority state for EVERY binding unestablished — including -// whether a withdrawal is among them. Skipping it would make an unverifiable -// tombstone disappear, which is the one row whose disappearance grants -// something. -// -// Tombstones are RETURNED rather than filtered here, because the two callers -// need opposite things from them and neither reading is the general one: -// activation treats a tombstone over its binding as terminal, while the -// approved-build view treats it as the removal of a principal's entry. A helper -// that dropped them would silently give the second caller the first caller's -// answer. -func (a *SolutionAuthorityActivation) verifiedAuthorityIn( - ctx context.Context, records []*SolutionDeliveryRecord, -) ([]verifiedAuthorityDocument, error) { - read := make([]verifiedAuthorityDocument, 0, len(records)) - for _, record := range records { - delivered, err := solutionhost.VerifyDeliveredAuthority(ctx, carrierOf(record), a.reconciler.verifier) - if err != nil { - return nil, fmt.Errorf("re-verify delivered authority %s generation %d: %w", - record.DocumentID, record.Generation, err) - } - document, err := delivered.Document() - if err != nil { - return nil, fmt.Errorf("read delivered authority %s generation %d: %w", - record.DocumentID, record.Generation, err) - } - read = append(read, verifiedAuthorityDocument{delivered: delivered, document: document}) - } - return read, nil -} - -// registryInputs is everything one decision about delivered authority reads -// from the store: the applied presence (whose admitted owners decide which -// tombstones count), the whole authority history, and the newest authority and -// presence generations. -// -// ONE SNAPSHOT. They are read in a single repeatable-read, read-only -// transaction, and every answer the decision gives — which bindings are -// withdrawn, which documents are in force, which workload a principal runs as — -// is derived from that one read. Read separately, a binding first admitted on -// another replica between the owner read and the presence read is derived from -// its presence with a withdrawal map that skipped its tombstone (the binding had -// no owner yet), resurrecting authority no committed state grants. -type registryInputs struct { - applied []*SolutionHostBindingRecord - authorityHistory []*SolutionDeliveryRecord - newestAuthority []*SolutionDeliveryRecord - newestPresence []*SolutionDeliveryRecord - // delivered is the inbox's size in this same snapshot, nil where the store - // cannot count: the marker a registry derived from these inputs carries. - delivered *uint64 -} - -func (a *SolutionAuthorityActivation) readRegistryInputs(ctx context.Context) (*registryInputs, error) { - service := a.reconciler.service - if service.deliveryStore == nil { - return nil, fmt.Errorf("%w: this host has no delivery inbox wired", ErrSolutionAuthorityCeilingUnavailable) - } - inputs := ®istryInputs{} - if err := service.store.WithControlPlaneReadSnapshot(ctx, func(ctx context.Context) error { - var err error - if inputs.applied, err = service.store.ListSolutionHostBindings(ctx); err != nil { - return fmt.Errorf("the applied presence: %w", err) - } - if inputs.authorityHistory, err = service.deliveryStore.ListDeliveredDocuments(ctx, SolutionDeliveryAuthority); err != nil { - return fmt.Errorf("the authority history: %w", err) - } - if inputs.newestAuthority, err = service.deliveryStore.ListNewestDeliveredDocuments(ctx, SolutionDeliveryAuthority); err != nil { - return fmt.Errorf("the newest authority: %w", err) - } - if inputs.newestPresence, err = service.deliveryStore.ListNewestDeliveredDocuments(ctx, SolutionDeliveryPresence); err != nil { - return fmt.Errorf("the newest presence: %w", err) - } - if counter, counts := service.deliveryStore.(SolutionDeliveryCounter); counts { - delivered, err := counter.CountDeliveredDocuments(ctx) - if err != nil { - return fmt.Errorf("the inbox's size: %w", err) - } - inputs.delivered = &delivered - } - return nil - }); err != nil { - return nil, fmt.Errorf("read the delivered authority's inputs in one snapshot: %w", err) - } - return inputs, nil -} - -// withdrawnIn returns every presence binding an ADMITTED tombstone in the -// snapshot's WHOLE authority history withdrew, with the authority that -// withdrew it. -// -// The whole history, not the newest generation per authority id: a withdrawal -// is terminal for the binding, and the newest-per-id read hides it as soon as a -// later generation of the SAME id is delivered. Nothing deletes from the inbox, -// so the tombstone is still there to be read. -// -// ADMITTED means the tombstone could speak for the binding it names: it targets -// THIS host's coordinate, and it speaks for the binding's OWNER — the ownership -// domain of the presence generation this host ADMITTED and applied, from the -// SAME snapshot's applied state, which core's lifecycle admission then holds -// every later generation to. A delivered row is never ownership: a presence -// generation core refused sits in the inbox and establishes nothing. A -// tombstone asserting another domain, or another host, withdraws nothing here. -// A binding with no applied presence in the snapshot has no owner, and a -// tombstone over it waits — it is re-judged on every read, against the same -// snapshot the binding's presence is derived from. -// -// Rows are decoded rather than re-verified, and that is deliberate: this is -// independent of whether the newest documents still verify, so a document that -// can no longer be verified can never hide a withdrawal. Only the fact that a -// row WITHDRAWS is taken from it, which can only remove authority; an admitted -// withdrawal survives an authority rename and its signer's later removal. -func (a *SolutionAuthorityActivation) withdrawnIn(inputs *registryInputs) (map[string]string, error) { - owners := ownersOf(inputs.applied) - coordinate := a.reconciler.host.Coordinate - withdrawn := map[string]string{} - for _, record := range inputs.authorityHistory { - document, err := solutionhost.AuthorityFromVerified(record.Payload) - if err != nil { - return nil, fmt.Errorf("read delivered authority %s generation %d: %w", record.DocumentID, record.Generation, err) - } - if !document.Removed || document.Host.Coordinate != coordinate { - continue - } - if owner, known := owners[document.PresenceBinding]; !known || owner != document.OwnershipDomain { - continue - } - if _, already := withdrawn[document.PresenceBinding]; !already { - withdrawn[document.PresenceBinding] = document.Authority - } - } - return withdrawn, nil -} - -// ownersOf is each presence binding's OWNER: the ownership domain of the -// generation this host admitted and applied. Only an admitted generation is -// ever written to the applied state — the reconcile pass writes it under the -// binding row lock after core's lifecycle admission — so a delivered row core -// refused can never move it. -func ownersOf(records []*SolutionHostBindingRecord) map[string]string { - owners := make(map[string]string, len(records)) - for _, record := range records { - if record.Applied != nil && record.Applied.Domain != "" { - owners[record.BindingID] = record.Applied.Domain - } - } - return owners -} - -// admittedBindingOwners reads the admitted owners for a decision that needs -// nothing else from the applied state (receipt). -func (s *Service) admittedBindingOwners(ctx context.Context) (map[string]string, error) { - records, err := s.ListSolutionHostBindings(ctx) - if err != nil { - return nil, fmt.Errorf("read the admitted presence owners: %w", err) - } - return ownersOf(records), nil -} - -// ErrSolutionAuthoritySignerNotGranted reports an authority document whose -// signer the host's CURRENT policy no longer lets speak for its ownership -// domain, or whose domain the host no longer accepts. The signature still -// verifies; the policy that made it authoritative has moved. -var ErrSolutionAuthoritySignerNotGranted = errors.New("the authority's signer may no longer speak for its ownership domain on this host") - -// ErrSolutionAuthorityUnverifiable reports a newest authority document this -// host can no longer verify. It grants nothing; it does not stop the others. -var ErrSolutionAuthorityUnverifiable = errors.New("the delivered authority no longer verifies") - -// liveVerifiedAuthority is one authority document in force, with the signer -// that delivered it. -type liveVerifiedAuthority struct { - document *solutionhost.AuthorityDocument - signer string -} - -// liveAuthorityIn returns the authority documents in force, and the reasons the -// others are not. -// -// ORDER IS THE POINT. The caller derives the withdrawals FIRST (withdrawnIn, -// from the same snapshot) and hands them in, independently of the newest documents, -// so nothing about a newest document — not even its failure to verify — can -// stop a withdrawal being honoured, and one pass judges every document against -// one reading of it. Then each newest -// document is judged on its own: a tombstone is dropped; a document that no -// longer verifies, is over a withdrawn binding, or whose signer the host's -// current policy no longer admits for its domain is REFUSED, by name, and grants -// nothing — while the rest stay in force. The only error is a read the store -// could not answer, and that is the snapshot read's (readRegistryInputs). -func (a *SolutionAuthorityActivation) liveAuthorityIn( - ctx context.Context, inputs *registryInputs, withdrawn map[string]string, -) ([]liveVerifiedAuthority, []error) { - records := inputs.newestAuthority - host := a.reconciler.host - var ( - live []liveVerifiedAuthority - refusals []error - ) - for _, record := range records { - delivered, err := solutionhost.VerifyDeliveredAuthority(ctx, carrierOf(record), a.reconciler.verifier) - if err != nil { - refusals = append(refusals, fmt.Errorf("%w: %s generation %d: %w", ErrSolutionAuthorityUnverifiable, record.DocumentID, record.Generation, err)) - continue - } - document, err := delivered.Document() - if err != nil { - refusals = append(refusals, fmt.Errorf("%w: %s generation %d: %w", ErrSolutionAuthorityUnverifiable, record.DocumentID, record.Generation, err)) - continue - } - if document.Removed { - continue - } - if authority, gone := withdrawn[document.PresenceBinding]; gone { - refusals = append(refusals, fmt.Errorf("%w: authority %q generation %d is over binding %q, which authority %q withdrew", - ErrSolutionAuthorityWithdrawn, document.Authority, document.Generation, document.PresenceBinding, authority)) - continue - } - signer := delivered.DeliveredBy() - if !slices.Contains(host.Domains, document.OwnershipDomain) || !slices.Contains(host.DomainsBySigner[signer], document.OwnershipDomain) { - refusals = append(refusals, fmt.Errorf("%w: authority %q generation %d, signer %q, domain %q", - ErrSolutionAuthoritySignerNotGranted, document.Authority, document.Generation, signer, document.OwnershipDomain)) - continue - } - live = append(live, liveVerifiedAuthority{document: document, signer: signer}) - } - return live, refusals -} - -// withdrawnPresenceIn is the presence bindings whose NEWEST delivered -// generation in the snapshot is a tombstone. It narrows the cached registry -// when the envelope cannot be read. Decoded, not re-verified, for the reason -// withdrawnIn gives. -func withdrawnPresenceIn(inputs *registryInputs) (map[string]bool, error) { - removed := map[string]bool{} - for _, record := range inputs.newestPresence { - document, err := solutionhost.PresenceFromVerified(record.Payload) - if err != nil { - return nil, fmt.Errorf("read delivered presence %s generation %d: %w", record.DocumentID, record.Generation, err) - } - if document.Removed { - removed[document.Binding] = true - } - } - return removed, nil -} - -// deliveredPresence reads the newest delivered presence document for one -// binding. The inbox is keyed by the binding for this kind, so this is a lookup -// rather than a scan. -func (a *SolutionAuthorityActivation) deliveredPresence( - ctx context.Context, binding string, -) (*solutionhost.Delivered, error) { - records, err := a.newestDelivered(ctx, SolutionDeliveryPresence) - if err != nil { - return nil, err - } - for _, record := range records { - if record.DocumentID != binding { - continue - } - delivered, err := solutionhost.VerifyDelivered(ctx, carrierOf(record), a.reconciler.verifier) - if err != nil { - return nil, fmt.Errorf("re-verify delivered presence %s generation %d: %w", - record.DocumentID, record.Generation, err) - } - return delivered, nil - } - return nil, fmt.Errorf("%w: no presence document is delivered for binding %q", - ErrSolutionAuthorityNotDelivered, binding) -} - -func (a *SolutionAuthorityActivation) newestDelivered( - ctx context.Context, kind SolutionDeliveryKind, -) ([]*SolutionDeliveryRecord, error) { - service := a.reconciler.service - if service.deliveryStore == nil { - return nil, fmt.Errorf("%w: this host has no delivery inbox wired", ErrSolutionAuthorityCeilingUnavailable) - } - var records []*SolutionDeliveryRecord - if err := service.store.WithControlPlane(ctx, func(ctx context.Context) error { - var err error - records, err = service.deliveryStore.ListNewestDeliveredDocuments(ctx, kind) - return err - }); err != nil { - return nil, fmt.Errorf("read the %s delivery inbox: %w", kind, err) - } - return records, nil -} - -// carrierOf rebuilds the carrier a row arrived as, so the stored bytes go back -// through core's verification rather than being trusted because they are stored. -// core validates the carrier's own invariants, so a row that cannot be a carrier -// is refused by the same check that refuses one off the wire. -func carrierOf(record *SolutionDeliveryRecord) *solutionhost.Signed { - return &solutionhost.Signed{ - Document: record.Payload, - Envelope: record.Bundle, - } -} diff --git a/module/services/accounts/code/pkg/business/solution_delivery_applied.go b/module/services/accounts/code/pkg/business/solution_delivery_applied.go index 05ba55c19..7b5947cd2 100644 --- a/module/services/accounts/code/pkg/business/solution_delivery_applied.go +++ b/module/services/accounts/code/pkg/business/solution_delivery_applied.go @@ -8,23 +8,36 @@ import ( "github.com/codefly-dev/core/solutionhost" ) -// SolutionDeliveryObserverAuthorizer grants the protected endpoint publisher a -// read of committed presence. It grants no ability to deliver documents. +// SolutionDeliveryObserverAuthorizer grants the protected applied-state +// observer a read of committed manifests. It grants no ability to deliver. type SolutionDeliveryObserverAuthorizer interface { AuthorizeAppliedObserver(context.Context, string) error } // SolutionDeliveryApplied reports a committed generation, never an inbox receipt -// or a desired generation. A publisher must compare every identity field with -// its expected declaration before publishing corresponding endpoints. +// or a desired generation, read from the binding's applied manifest. A reader +// compares every identity field with the manifest it delivered: a receipt says +// the carrier reached the inbox; only this says the module is served. type SolutionDeliveryApplied struct { - Host solutionhost.HostTarget `json:"host"` - Binding string `json:"binding"` - Generation uint64 `json:"generation"` - PresenceDigest string `json:"presenceDigest"` - Release string `json:"release"` - Removed bool `json:"removed"` - AppliedAt time.Time `json:"appliedAt"` + Host solutionhost.HostTarget `json:"host"` + Binding string `json:"binding"` + Generation uint64 `json:"generation"` + // Digest is the applied manifest's digest (solutionhost.ModuleManifest.Digest). + Digest string `json:"digest"` + Release string `json:"release"` + Removed bool `json:"removed"` + AppliedAt time.Time `json:"appliedAt"` + // Pending is a newer generation this host received and withheld, with the + // host's own reason: "delivered" is not "applied", and the reader is told + // which. + Pending *SolutionDeliveryPending `json:"pending,omitempty"` +} + +// SolutionDeliveryPending is a received generation this host has not applied +// and why. +type SolutionDeliveryPending struct { + Generation uint64 `json:"generation"` + Reason string `json:"reason"` } // ReadSolutionDeliveryApplied authenticates before reading any durable binding. @@ -45,27 +58,24 @@ func (s *Service) ReadSolutionDeliveryApplied(ctx context.Context, binding, cred if record == nil || record.BindingID != binding || record.Applied == nil { continue } - applied := record.Applied - // The row's host fields may already describe a withheld desired update. - // All reported identity comes from the committed APPLIED document. - document, err := solutionhost.Parse([]byte(applied.Document)) + manifest, err := record.AppliedManifest() if err != nil { - return nil, fmt.Errorf("read applied presence: %w", err) + return nil, fmt.Errorf("read the applied manifest: %w", err) } - digest, err := document.Digest() - if err != nil { - return nil, fmt.Errorf("digest applied presence: %w", err) + digest, err := manifest.Digest() + if err != nil || digest != record.Applied.Digest || + manifest.Release.Identity() != record.Applied.Release || record.Applied.At.IsZero() { + return nil, fmt.Errorf("the applied record of binding %q is inconsistent with its manifest", binding) + } + applied := &SolutionDeliveryApplied{ + Host: manifest.Host, Binding: binding, Generation: manifest.Generation, + Digest: digest, Release: record.Applied.Release, Removed: manifest.Removed, + AppliedAt: record.Applied.At, } - if document.Binding != binding || document.Generation != applied.Generation || - digest != applied.Digest || document.Removed != applied.Removed || - document.Release.Identity() != applied.Release || applied.At.IsZero() { - return nil, fmt.Errorf("applied presence record is inconsistent") + if pending := record.PendingGeneration(); pending != 0 && record.PendingReason != "" { + applied.Pending = &SolutionDeliveryPending{Generation: pending, Reason: record.PendingReason} } - return &SolutionDeliveryApplied{ - Host: document.Host, Binding: binding, Generation: document.Generation, - PresenceDigest: digest, Release: applied.Release, Removed: document.Removed, - AppliedAt: applied.At, - }, nil + return applied, nil } return nil, ErrSolutionHostBindingNotDeclared } diff --git a/module/services/accounts/code/pkg/business/solution_delivery_endpoints.go b/module/services/accounts/code/pkg/business/solution_delivery_endpoints.go deleted file mode 100644 index 1fc5e775d..000000000 --- a/module/services/accounts/code/pkg/business/solution_delivery_endpoints.go +++ /dev/null @@ -1,115 +0,0 @@ -package business - -import ( - "context" - "errors" - "fmt" - - "github.com/codefly-dev/core/solutionhost" -) - -// Endpoint values share the signed inbox, not the registry's identity tables. -// Core owns their encoding and their join to the complete applied declaration. -func (s *Service) receiveDeliveredEndpoints(ctx context.Context, signed *solutionhost.Signed) (*receivedSolutionDelivery, error) { - delivered, err := solutionhost.VerifyDeliveredEndpoints(ctx, signed, s.deliveryVerifier) - if err != nil { - return nil, classifySolutionDeliveryVerification(err) - } - identity, err := delivered.Identity() - if err != nil { - return nil, fmt.Errorf("%w: %w", ErrSolutionDeliveryMalformed, err) - } - var presence *solutionhost.SolutionHostBinding - err = s.store.WithControlPlane(ctx, func(ctx context.Context) error { - records, err := s.store.ListSolutionHostBindings(ctx) - if err != nil { - return err - } - for _, record := range records { - if record.BindingID != identity.Binding { - continue - } - presence, err = s.joinEndpointDelivery(delivered, record) - if errors.Is(err, solutionhost.ErrEndpointsUnapplied) { - return ErrSolutionDeliveryNotApplied - } - if err != nil { - return fmt.Errorf("%w: %w", ErrSolutionDeliveryInvalid, err) - } - return nil - } - return ErrSolutionDeliveryNotApplied - }) - if err != nil { - return nil, err - } - return &receivedSolutionDelivery{ - documentID: identity.Key(), generation: identity.Generation, - ownershipDomain: identity.OwnershipDomain, signer: delivered.DeliveredBy(), - payload: delivered.Payload(), carrierNamespaces: declaredNamespaces(presence), - }, nil -} - -func (s *Service) joinEndpointDelivery(delivered *solutionhost.DeliveredEndpoints, record *SolutionHostBindingRecord) (*solutionhost.SolutionHostBinding, error) { - applied, ok := record.AppliedState() - if !ok { - return nil, solutionhost.ErrEndpointsUnapplied - } - host := solutionhost.Host{ - Coordinate: s.deliveryCoordinate, - Applied: []solutionhost.Applied{applied}, - DomainsBySigner: s.deliveryDomainsBySigner, - } - if _, err := host.AdmitEndpoints(delivered); err != nil { - return nil, err - } - presence, err := solutionhost.Parse([]byte(record.Applied.Document)) - if err != nil { - return nil, err - } - // The owner checks the complete document and endpoint subset; Accounts - // never interprets carriers or chooses an address from the result. - if _, err := solutionhost.ParseEndpointDelivery(delivered.Payload(), presence); err != nil { - return nil, err - } - return presence, nil -} - -// attachAppliedEndpointDeliveries runs inside the same control-plane read as -// the binding projection. A desired-only successor never replaces these bytes. -func (s *Service) attachAppliedEndpointDeliveries(ctx context.Context, records []*SolutionHostBindingRecord) error { - reader, ok := s.deliveryStore.(SolutionDeliveryReader) - if !ok { - return nil // No delivery source means no route, never a boot fallback. - } - for _, record := range records { - if record == nil || record.Applied == nil || record.Applied.Removed { - continue - } - record.Applied.EndpointDelivery = nil - presence, err := solutionhost.Parse([]byte(record.Applied.Document)) - if err != nil { - return fmt.Errorf("read applied endpoint presence: %w", err) - } - identity := solutionhost.EndpointDeliveryIdentity{Host: presence.Host, Binding: presence.Binding} - stored, err := reader.ReadDeliveredDocument(ctx, SolutionDeliveryEndpoints, identity.Key(), record.Applied.Generation) - if err != nil { - return err - } - if stored == nil { - continue - } - delivered, err := solutionhost.VerifyDeliveredEndpoints(ctx, &solutionhost.Signed{Document: stored.Payload, Envelope: stored.Bundle}, s.deliveryVerifier) - if err != nil { - return fmt.Errorf("verify applied endpoint delivery: %w", err) - } - if !s.signerMaySpeakFor(delivered.DeliveredBy(), record.Applied.Domain) { - return ErrSolutionDeliveryDomainNotGranted - } - if _, err := s.joinEndpointDelivery(delivered, record); err != nil { - return fmt.Errorf("join applied endpoint delivery: %w", err) - } - record.Applied.EndpointDelivery = delivered.Payload() - } - return nil -} diff --git a/module/services/accounts/code/pkg/business/solution_group_admission_test.go b/module/services/accounts/code/pkg/business/solution_group_admission_test.go index 420d3cff7..85a9eb2f1 100644 --- a/module/services/accounts/code/pkg/business/solution_group_admission_test.go +++ b/module/services/accounts/code/pkg/business/solution_group_admission_test.go @@ -13,7 +13,8 @@ import ( func TestSignedSolutionGroupOrderIsRepresentedExactlyOrRefused(t *testing.T) { for _, order := range []int64{-2147483649, -2147483648, 2147483647, 2147483648} { t.Run(fmt.Sprint(order), func(t *testing.T) { - document := mustFixtureDocument(t, "valid") + document := fixtureManifest(t, "grouped", 1) + document.Kind = solutionhost.KindSolution document.Group = &solutionhost.SolutionGroup{ID: "operations", Title: "Operations", Order: 0} canonical, err := solutionhost.SignedPayload(document) require.NoError(t, err) diff --git a/module/services/accounts/code/pkg/business/solution_host_binding_admission.go b/module/services/accounts/code/pkg/business/solution_host_binding_admission.go index e9e37760b..490168b82 100644 --- a/module/services/accounts/code/pkg/business/solution_host_binding_admission.go +++ b/module/services/accounts/code/pkg/business/solution_host_binding_admission.go @@ -1,106 +1,133 @@ package business import ( + "slices" + "github.com/codefly-dev/core/solutionhost" ) // Admission of a delivered desired set (issue #952). // -// core's solutionhost.Host.Admit is the judge and the whole judge. It answers -// one Admission per document — a decision, or the reason that document alone was -// refused — so a host reads the refusals it must expose straight off the result. -// -// This file used to narrow the set by hand: judge each document alone, then ask -// core about the survivors, then attribute the cross-document refusals it -// answered with one error. core v0.7.0 made that unnecessary. It attributes per -// document itself, and it refuses alias collisions one at a time until the set -// is stable — which is the subtlety that made the hand-rolled version wrong -// before its test caught it: refusing a document changes the question, because -// the generation it would have replaced keeps the aliases the set assumed it was -// releasing. Deleting the local version is the point. A host that re-derives its -// dependency's rules owns a second copy of them, and the copy is what drifts. -// -// core cd443989 narrowed the door further: Admit takes *solutionhost.Delivered, -// which only VerifyDelivered constructs from a signed carrier and this host's -// pinned-key Verifier. So "this document was attested" is held by the compiler -// rather than by a naming convention, and there is no sequence of calls that -// reaches a judgement on bytes nobody verified. -// -// What remains here is the part that is this host's: reading core's answer into -// the shape the reconcile pass applies. +// core is the judge and the whole judge: solutionhost.Admit decides a +// manifest's generation against the one this host applied for its binding +// (stale, rewritten, withdrawn, another host or domain), and +// solutionhost.CheckAlias decides its route alias against every applied +// manifest. What remains here is the part that is this host's: asking core +// about each delivered manifest and reading the answers into the shape the +// reconcile pass applies. -// SolutionHostBindingDecision is one admitted document and what the host should +// SolutionHostBindingDecision is one admitted manifest and what the host should // do with it. type SolutionHostBindingDecision struct { // Delivered is the attested carrier this decision was made on. The apply - // re-admits inside its own transaction, and that re-admission is a HOST - // judgement, so it needs the verified value rather than the document read - // out of it — handing core the document again would be the second copy of - // "verified" that *Delivered exists to make impossible. + // re-admits inside its own transaction from the verified value, never from + // the manifest read out of it. Delivered *solutionhost.Delivered - Document *solutionhost.SolutionHostBinding + Document *solutionhost.ModuleManifest Decision solutionhost.Decision } // solutionHostBindingAdmission is the outcome of one pass's admission: the -// documents to reconcile, and the reason each withheld binding was withheld. +// manifests to reconcile, and the reason each withheld binding was withheld. type solutionHostBindingAdmission struct { Accepted []SolutionHostBindingDecision Withheld map[string]string } -// admitSolutionHostBindings asks core about the whole desired set and sorts its -// answer into what to apply and what to expose as refused. -// -// core's error is deliberately ignored: it is non-nil whenever ANY document was -// refused, which is the right answer for a caller that applies all or nothing — a -// renderer refusing bytes it is about to write. A host applies what is sound and -// exposes the rest, so it reads the Admissions. Nothing core refused is applied, -// because an Admission with an Err carries no decision. +// admitSolutionHostBindings asks core about each delivered manifest, against +// the applied record of its own binding (Admit) and the aliases the applied +// manifests hold (CheckAlias), and sorts the answers into what to apply and +// what to expose as refused. Nothing core refused is applied. // -// A document core refused before it could read a binding ID is attributed to the -// empty key and reported by the caller against the document it came from; it -// cannot collide with a real binding, because core refuses "" as a binding ID. +// An applied manifest whose binding this pass admits a withdrawal or a new +// alias for holds nothing: that is what lets an alias move from one binding to +// another in one pass. Refusing such a candidate changes the question — its +// binding's applied alias is held again — so the alias check repeats until no +// candidate is refused. Two new claimants of one alias both pass this check, +// which judges what is applied; the second apply meets the registry's durable +// backstop (ErrSolutionHostBindingRouteHeld) and is recorded as refused. func admitSolutionHostBindings( - host solutionhost.Host, delivered []deliveredSolutionHostBinding, + coordinate string, rule ModuleNamespaceRule, records []*SolutionHostBindingRecord, delivered []deliveredSolutionHostBinding, ) solutionHostBindingAdmission { result := solutionHostBindingAdmission{Withheld: map[string]string{}} - // The carriers are what core judges; the documents beside them are what - // this host reads to attribute the answer. Admit re-derives its own from - // each carrier's attested bytes, so the two cannot diverge — see - // deliveredSolutionHostBinding. - carriers := make([]*solutionhost.Delivered, len(delivered)) - documents := make([]*solutionhost.SolutionHostBinding, len(delivered)) - for index, one := range delivered { - carriers[index], documents[index] = one.Delivered, one.Document + byBinding := make(map[string]*SolutionHostBindingRecord, len(records)) + for _, record := range records { + byBinding[record.BindingID] = record } - admissions, err := host.Admit(carriers...) - if admissions == nil { - // core refused the call itself rather than any one document — applied - // state handed to it without a coordinate, or an applied record it - // could not read. Nothing is admitted, and the reason is attributed to - // every delivered binding, because the host cannot tell which document - // the refusal is about. - for _, document := range documents { - result.Withheld[document.Binding] = err.Error() + for _, one := range delivered { + candidate := one.Document + previous, err := byBinding[candidate.Binding].AppliedManifest() + var decision solutionhost.Decision + if err == nil { + decision, err = solutionhost.Admit(coordinate, previous, candidate) } - return result - } - result.Accepted = make([]SolutionHostBindingDecision, 0, len(admissions)) - for index, admission := range admissions { - if admission.Err != nil { - binding := admission.Binding - if binding == "" && index < len(documents) { - binding = documents[index].Binding - } - result.Withheld[binding] = admission.Err.Error() + if err == nil { + err = byBinding[candidate.Binding].checkOwnershipDomain(candidate) + } + if err != nil { + result.Withheld[candidate.Binding] = err.Error() continue } result.Accepted = append(result.Accepted, SolutionHostBindingDecision{ - Delivered: delivered[index].Delivered, - Document: documents[index], - Decision: admission.Decision, + Delivered: one.Delivered, Document: candidate, Decision: decision, }) } + applied := appliedManifests(records) + reserved := []string{SolutionHostReservedRouteNamespace} + for refused := true; refused; { + refused = false + holders := aliasHolders(applied, acceptedDocuments(result.Accepted)) + kept := result.Accepted[:0] + // The host's own rules are judged against every binding as it stands + // once this pass applies, where a binding nothing has applied yet holds + // only once an earlier candidate of this pass has been kept: of two new + // claimants, the first in delivery order keeps the claim. + replacements := acceptedDocuments(result.Accepted) + for binding := range replacements { + if !appliedBinding(applied, binding) { + delete(replacements, binding) + } + } + for _, accepted := range result.Accepted { + err := solutionhost.CheckAlias(reserved, holders, accepted.Document) + if err == nil { + err = rule.checkManifestAgainstHost(accepted.Document, standingManifests(applied, replacements)) + } + if err != nil { + result.Withheld[accepted.Document.Binding] = err.Error() + refused = true + continue + } + replacements[accepted.Document.Binding] = accepted.Document + kept = append(kept, accepted) + } + result.Accepted = kept + } return result } + +// aliasHolders is the applied manifests that still hold their alias once the +// given manifests stand in for their bindings: one whose replacement is a +// withdrawal, or names another alias, has released its own. +func aliasHolders(applied []*solutionhost.ModuleManifest, replacements map[string]*solutionhost.ModuleManifest) []*solutionhost.ModuleManifest { + holders := make([]*solutionhost.ModuleManifest, 0, len(applied)) + for _, manifest := range applied { + if replacement, replaced := replacements[manifest.Binding]; replaced && (replacement.Removed || replacement.Alias != manifest.Alias) { + continue + } + holders = append(holders, manifest) + } + return holders +} + +func acceptedDocuments(accepted []SolutionHostBindingDecision) map[string]*solutionhost.ModuleManifest { + documents := make(map[string]*solutionhost.ModuleManifest, len(accepted)) + for _, one := range accepted { + documents[one.Document.Binding] = one.Document + } + return documents +} + +func appliedBinding(applied []*solutionhost.ModuleManifest, binding string) bool { + return slices.ContainsFunc(applied, func(manifest *solutionhost.ModuleManifest) bool { return manifest.Binding == binding }) +} diff --git a/module/services/accounts/code/pkg/business/solution_host_binding_admission_test.go b/module/services/accounts/code/pkg/business/solution_host_binding_admission_test.go index b6b73e537..7c676a7f2 100644 --- a/module/services/accounts/code/pkg/business/solution_host_binding_admission_test.go +++ b/module/services/accounts/code/pkg/business/solution_host_binding_admission_test.go @@ -1,703 +1,151 @@ package business import ( - "bytes" "context" - "errors" - "sort" - "strings" "testing" "accounts/internal/deliverytest" - "github.com/codefly-dev/core/composition" "github.com/codefly-dev/core/solutionhost" + "github.com/stretchr/testify/require" ) -// Admission is exercised against core's own shipped fixtures rather than against -// documents written here. A document invented in this repository agrees with this -// repository's reading of the spec by construction, which is exactly the -// agreement that is worth nothing: the renderer in codefly-dev/cli and this host -// have to reach the same verdict on the same bytes, and the fixtures are those -// bytes. +// Admission of delivered manifests without a database: core's Admit and +// CheckAlias against the host's applied records, read into what the pass +// applies and what it exposes as withheld. The Postgres half — row locks, the +// registry's unique key, restart — is solution_host_bindings_integration_test.go. -// TestSolutionHostBindingFixturesReachTheirRequiredOutcome is the conformance -// test core ships the fixtures for: each one carries the outcome a conforming -// implementation must reach against solutionhost.FixtureHost, and this host must -// reach it through the same path it reconciles with. -func TestSolutionHostBindingFixturesReachTheirRequiredOutcome(t *testing.T) { - host, err := solutionhost.FixtureHost() - if err != nil { - t.Fatalf("fixture host: %v", err) - } - // Core ships presence, authority and signed-carrier fixtures. This host's - // admission path is the presence reconciler, so it is driven with the presence - // set; the authority and carrier documents are verified before admission and - // belong to the delivery endpoint's own conformance run. - all := solutionhost.Fixtures() - var fixtures []solutionhost.Fixture - for _, fixture := range all { - if fixture.Type == solutionhost.DocumentTypePresence { - fixtures = append(fixtures, fixture) - } - } - if len(fixtures) == 0 { - t.Fatalf("core shipped no presence fixtures among %d, so nothing was actually checked", len(all)) - } - for _, fixture := range fixtures { - t.Run(fixture.Name, func(t *testing.T) { - document, parseErr := solutionhost.Parse(fixture.Document) - if parseErr != nil { - if fixture.Outcome != solutionhost.OutcomeRejected { - t.Fatalf("fixture must be %s but did not parse: %v (%s)", - fixture.Outcome, parseErr, fixture.Reason) - } - return - } - verified, problems := verifySolutionHostBindingDocuments(context.Background(), fixtureSigner(t), - []SolutionHostBindingDocument{{Source: fixture.Name, Data: signedCarrier(t, document)}}) - if len(problems) > 0 { - if fixture.Outcome != solutionhost.OutcomeRejected { - t.Fatalf("accepted fixture did not verify: %v", problems) - } - return - } - admission := admitSolutionHostBindings(host, verified) - switch fixture.Outcome { - case solutionhost.OutcomeAccepted: - if reason, withheld := admission.Withheld[document.Binding]; withheld { - t.Fatalf("fixture must be accepted but was withheld: %s (%s)", reason, fixture.Reason) - } - if len(admission.Accepted) != 1 { - t.Fatalf("accepted %d documents, want 1", len(admission.Accepted)) - } - if got := admission.Accepted[0].Decision; got != fixture.Decision { - t.Fatalf("decision = %q, want %q (%s)", got, fixture.Decision, fixture.Reason) - } - case solutionhost.OutcomeRejected: - if len(admission.Accepted) != 0 { - t.Fatalf("fixture must be rejected but was admitted (%s)", fixture.Reason) - } - if _, withheld := admission.Withheld[document.Binding]; !withheld { - t.Fatalf("fixture was neither admitted nor attributed a reason (%s)", fixture.Reason) - } - default: - t.Fatalf("unknown required outcome %q", fixture.Outcome) +func admit(t *testing.T, records []*SolutionHostBindingRecord, manifests ...*solutionhost.ModuleManifest) solutionHostBindingAdmission { + t.Helper() + return admitSolutionHostBindings(testDeliveryCoordinate, testModuleNamespaceRule, records, delivered(t, manifests...)) +} + +func TestANewBindingAndAHigherGenerationAreAccepted(t *testing.T) { + applied := fixtureManifest(t, "reports", 1) + next := fixtureManifest(t, "reports", 2) + next.Release.Version = "1.1.0" + admission := admit(t, []*SolutionHostBindingRecord{appliedRecordOf(t, applied)}, next, fixtureManifest(t, "billing", 1)) + require.Empty(t, admission.Withheld) + require.Len(t, admission.Accepted, 2) + for _, accepted := range admission.Accepted { + require.Equal(t, solutionhost.DecisionAccept, accepted.Decision) + } +} + +func TestTheAppliedGenerationIsAReplay(t *testing.T) { + applied := fixtureManifest(t, "reports", 3) + admission := admit(t, []*SolutionHostBindingRecord{appliedRecordOf(t, applied)}, applied) + require.Empty(t, admission.Withheld) + require.Len(t, admission.Accepted, 1) + require.Equal(t, solutionhost.DecisionReplay, admission.Accepted[0].Decision) +} + +// Each refusal is withheld for its own binding with core's reason, and leaves +// every other binding's admission alone. +func TestARefusedManifestIsWithheldWithItsReason(t *testing.T) { + applied := fixtureManifest(t, "reports", 3) + rewritten := fixtureManifest(t, "reports", 3) + rewritten.Release.Version = "9.9.9" + elsewhere := fixtureManifest(t, "reports", 4) + elsewhere.Host.Coordinate = "another-host" + reserved := fixtureManifest(t, "codefly", 1) + held := fixtureManifest(t, "reports", 1) + held.Binding = "acme.test.second-reports" + + withdrawn := fixtureManifest(t, "archive", 1) + tombstone := withdrawalOf(t, withdrawn, 2) + revived := fixtureManifest(t, "archive", 3) + + records := []*SolutionHostBindingRecord{appliedRecordOf(t, applied), appliedRecordOf(t, tombstone)} + for name, tc := range map[string]struct { + candidate *solutionhost.ModuleManifest + want error + }{ + "a stale generation": {fixtureManifest(t, "reports", 2), solutionhost.ErrStaleGeneration}, + "a rewritten generation": {rewritten, solutionhost.ErrRewrittenGeneration}, + "another host's": {elsewhere, solutionhost.ErrWrongHost}, + "a withdrawn binding back": {revived, solutionhost.ErrWithdrawn}, + "a reserved alias": {reserved, nil}, + "an alias another binding holds": {held, nil}, + } { + t.Run(name, func(t *testing.T) { + sibling := fixtureManifest(t, "billing", 1) + admission := admit(t, records, tc.candidate, sibling) + reason, withheld := admission.Withheld[tc.candidate.Binding] + require.True(t, withheld) + if tc.want != nil { + require.Contains(t, reason, tc.want.Error()) } + require.Len(t, admission.Accepted, 1, "the sibling is admitted regardless") + require.Equal(t, sibling.Binding, admission.Accepted[0].Document.Binding) }) } } -// The duplicate-route-alias fixture is rejected because an alias the fixture host -// already holds is claimed by a second binding. That is the one fixture whose -// verdict depends on host state rather than on the document, so it is worth -// pinning that the host reaches it for the collision reason and not by accident. -func TestSolutionHostBindingCollisionWithAnAppliedAliasNamesTheCollision(t *testing.T) { - host, err := solutionhost.FixtureHost() - if err != nil { - t.Fatalf("fixture host: %v", err) - } - document := mustFixtureDocument(t, "duplicate-route-alias") - admission := admitSolutionHostBindings(host, delivered(t, document)) - reason, withheld := admission.Withheld[document.Binding] - if !withheld { - t.Fatal("a binding claiming an alias an applied binding holds must be withheld") - } - if !strings.Contains(reason, composition.ErrCollision.Error()) { - t.Fatalf("reason %q does not name the collision", reason) - } -} - -// A stale document must not hold back a sibling binding's new generation. -// -// This is the failure mode that makes per-document isolation load-bearing rather -// than a nicety: the renderer derives a generation from the previously delivered -// document, so a render from a checkout that cannot see the prior tree emits -// generation 1, which this host correctly refuses. If that refusal held the whole -// set, one bad pipeline run would freeze every other solution on the host. -func TestSolutionHostBindingStaleDocumentDoesNotBlockASibling(t *testing.T) { - host, err := solutionhost.FixtureHost() - if err != nil { - t.Fatalf("fixture host: %v", err) - } - stale := mustFixtureDocument(t, "stale-generation") - sibling := mustFixtureDocument(t, "valid") - sibling.Binding = "pim-eu-west-1-01" - sibling.Routes[0].Alias = "pim" - - admission := admitSolutionHostBindings(host, delivered(t, stale, sibling)) - - if _, withheld := admission.Withheld[stale.Binding]; !withheld { - t.Fatal("the stale document must be withheld") - } - if reason, withheld := admission.Withheld[sibling.Binding]; withheld { - t.Fatalf("the sibling must still be admitted, was withheld: %s", reason) - } - if len(admission.Accepted) != 1 || admission.Accepted[0].Document.Binding != sibling.Binding { - t.Fatalf("accepted = %v, want only %q", bindingsOf(admission.Accepted), sibling.Binding) - } -} - -// Two documents claiming one alias: exactly one is admitted, and which one does -// not depend on the order the mount was walked in. -// -// This asserts core's rule, not a rule of this host's. An earlier version of this -// host attributed collisions itself and withheld EVERY claimant, reasoning that it -// had no rule making one of two simultaneous claims the winner. core v0.7.0 has -// one — lowest binding ID, resolved one refusal at a time until the set is stable -// — and it is the better answer: refusing both wedges two solutions where -// admitting the deterministic winner keeps one serving. The host no longer owns -// this policy, so the test pins core's. -func TestSolutionHostBindingTwoClaimantsOfOneAliasYieldOneDeterministicWinner(t *testing.T) { - host := fixtureSignerHost(solutionhost.FixtureCoordinate) - first := mustFixtureDocument(t, "valid") - first.Binding = "crm-eu-west-1-aa" - second := mustFixtureDocument(t, "valid") - second.Binding = "crm-eu-west-1-bb" - - forward := admitSolutionHostBindings(host, delivered(t, first, second)) - reverse := admitSolutionHostBindings(host, delivered(t, second, first)) - - for label, admission := range map[string]solutionHostBindingAdmission{"forward": forward, "reverse": reverse} { - if len(admission.Accepted) != 1 { - t.Fatalf("%s: accepted %v, want exactly one claimant", label, bindingsOf(admission.Accepted)) - } - if got := admission.Accepted[0].Document.Binding; got != first.Binding { - t.Fatalf("%s: admitted %q, want the lowest binding ID %q", label, got, first.Binding) - } - reason, withheld := admission.Withheld[second.Binding] - if !withheld { - t.Fatalf("%s: the losing claimant was neither admitted nor attributed a reason", label) - } - if !strings.Contains(reason, composition.ErrCollision.Error()) { - t.Fatalf("%s: reason %q does not name the collision", label, reason) - } - } -} - -// A collision must not take an unrelated binding down with it: the loser is -// refused, the winner and every unrelated binding still apply. -func TestSolutionHostBindingCollisionWithholdsOnlyTheLosingClaimant(t *testing.T) { - host := fixtureSignerHost(solutionhost.FixtureCoordinate) - first := mustFixtureDocument(t, "valid") - first.Binding = "crm-eu-west-1-aa" - second := mustFixtureDocument(t, "valid") - second.Binding = "crm-eu-west-1-bb" - unrelated := mustFixtureDocument(t, "valid") - unrelated.Binding = "pim-eu-west-1-01" - unrelated.Routes[0].Alias = "pim" - - admission := admitSolutionHostBindings(host, - delivered(t, first, second, unrelated)) - - admitted := bindingsOf(admission.Accepted) - sort.Strings(admitted) - if len(admitted) != 2 || admitted[0] != first.Binding || admitted[1] != unrelated.Binding { - t.Fatalf("accepted = %v, want the winning claimant and the unrelated binding", admitted) - } - if _, withheld := admission.Withheld[second.Binding]; !withheld { - t.Fatal("the losing claimant must be withheld") - } - if len(admission.Withheld) != 1 { - t.Fatalf("withheld = %v, want only the losing claimant", admission.Withheld) - } -} - -// One binding delivered twice in one pass: the second occurrence is refused and -// names both documents, so an operator can find the duplicate mount. The first -// still applies — core resolves it rather than freezing the binding, and it -// reports which two documents collided. -func TestSolutionHostBindingDeclaredTwiceRefusesTheSecondOccurrence(t *testing.T) { - host := fixtureSignerHost(solutionhost.FixtureCoordinate) - first := mustFixtureDocument(t, "valid") - second := mustFixtureDocument(t, "valid") - - admission := admitSolutionHostBindings(host, delivered(t, first, second)) - - if len(admission.Accepted) != 1 { - t.Fatalf("accepted %v, want exactly one of the two", bindingsOf(admission.Accepted)) - } - reason, withheld := admission.Withheld[first.Binding] - if !withheld { - t.Fatal("the duplicate must be attributed to its binding") - } - if !strings.Contains(reason, "declared twice") { - t.Fatalf("reason %q does not say the binding was delivered twice", reason) - } -} - -// A document targeting another host is refused here, not merged. A host that -// reconciled whatever it was handed would reconcile its neighbour's desired -// state. -func TestSolutionHostBindingForAnotherCoordinateIsWithheld(t *testing.T) { - // Derived from the fixtures' own coordinate rather than written out, so this - // test names no deployment of its own. - host := fixtureSignerHost(solutionhost.FixtureCoordinate + "-elsewhere") - document := mustFixtureDocument(t, "valid") - - admission := admitSolutionHostBindings(host, delivered(t, document)) - - if len(admission.Accepted) != 0 { - t.Fatalf("accepted %v, want nothing", bindingsOf(admission.Accepted)) - } - if reason := admission.Withheld[document.Binding]; !strings.Contains(reason, solutionhost.ErrWrongHost.Error()) { - t.Fatalf("reason %q does not name the wrong-host refusal", reason) - } -} - -// A generation whose contents changed under an applied generation number is the -// one thing core treats as an error rather than an answer, and the host must -// withhold it rather than reapply it. -func TestSolutionHostBindingRewrittenGenerationIsWithheld(t *testing.T) { - host, err := solutionhost.FixtureHost() - if err != nil { - t.Fatalf("fixture host: %v", err) - } - rewritten := mustFixtureDocument(t, "valid") - rewritten.Release.Version = "1.4.1" - for index := range rewritten.Artifacts { - rewritten.Artifacts[index].Release = rewritten.Release.Identity() - } - - admission := admitSolutionHostBindings(host, delivered(t, rewritten)) - - if len(admission.Accepted) != 0 { - t.Fatalf("accepted %v, want nothing", bindingsOf(admission.Accepted)) - } - if reason := admission.Withheld[rewritten.Binding]; !strings.Contains(reason, solutionhost.ErrRewrittenGeneration.Error()) { - t.Fatalf("reason %q does not name the rewritten generation", reason) - } -} - -// An alias being handed over must not refuse its claimant. -// -// This is the case that makes per-document judging subtle: the binding holding the -// alias is IN the delivered set, as a tombstone releasing it, so its applied claim -// must not count against the claimant. Judging each document against every applied -// alias refused the claimant here, and the hand-over then never completed — the -// next pass read the same two documents and refused the same one, forever. -func TestSolutionHostBindingAliasHandedOverInOneSetAdmitsBoth(t *testing.T) { - holder := mustFixtureDocument(t, "valid") - applied, err := solutionhost.AppliedFrom(holder) - if err != nil { - t.Fatalf("applied from the holder: %v", err) - } - host := fixtureSignerHost(solutionhost.FixtureCoordinate) - host.Applied = []solutionhost.Applied{applied} - releasing := mustFixtureDocument(t, "tombstone") - claiming := mustFixtureDocument(t, "valid") - claiming.Binding = "crm-eu-west-1-02" - - admission := admitSolutionHostBindings(host, - delivered(t, releasing, claiming)) - - if len(admission.Withheld) != 0 { - t.Fatalf("withheld %v, want a clean hand-over", admission.Withheld) - } - if len(admission.Accepted) != 2 { - t.Fatalf("accepted = %v, want both sides of the hand-over", bindingsOf(admission.Accepted)) - } -} - -// The other side of that rule: a candidate claiming an alias held by a binding -// whose document is NOT in the set is refused, and refused alone. -func TestSolutionHostBindingClaimingAHeldAliasWithholdsOnlyTheClaimant(t *testing.T) { - holder := mustFixtureDocument(t, "valid") - applied, err := solutionhost.AppliedFrom(holder) - if err != nil { - t.Fatalf("applied from the holder: %v", err) - } - host := fixtureSignerHost(solutionhost.FixtureCoordinate) - host.Applied = []solutionhost.Applied{applied} - claiming := mustFixtureDocument(t, "valid") - claiming.Binding = "crm-eu-west-1-02" - unrelated := mustFixtureDocument(t, "valid") - unrelated.Binding = "pim-eu-west-1-01" - unrelated.Routes[0].Alias = "pim" - - admission := admitSolutionHostBindings(host, - delivered(t, claiming, unrelated)) +// A withdrawal recorded before this host read module manifests cannot be read +// as one, and it is still terminal. +func TestAPreManifestWithdrawalStaysTerminal(t *testing.T) { + record := &SolutionHostBindingRecord{BindingID: "acme.test.archive", Applied: &SolutionHostBindingApplied{ + SolutionHostBindingGeneration: SolutionHostBindingGeneration{Generation: 4, Document: "schema: codefly/solution-host-binding/v3\n"}, + Removed: true, + }} + _, err := record.AppliedManifest() + require.ErrorIs(t, err, solutionhost.ErrWithdrawn) + admission := admit(t, []*SolutionHostBindingRecord{record}, fixtureManifest(t, "archive", 5)) + require.Contains(t, admission.Withheld["acme.test.archive"], solutionhost.ErrWithdrawn.Error()) - if _, withheld := admission.Withheld[claiming.Binding]; !withheld { - t.Fatal("a candidate claiming a held alias must be withheld") - } - if len(admission.Accepted) != 1 || admission.Accepted[0].Document.Binding != unrelated.Binding { - t.Fatalf("accepted = %v, want only the unrelated binding", bindingsOf(admission.Accepted)) - } + record.Applied.Removed = false + _, err = record.AppliedManifest() + require.ErrorIs(t, err, solutionhost.ErrAppliedUnusable, "a live record nobody can read is the host's own damage") } -// A binding whose alias hands over to another binding is applied before the -// claimant, so the hand-over completes in one pass instead of waiting for the -// next one to find the key free. -func TestSolutionHostBindingAppliesReleasesBeforeClaims(t *testing.T) { - releasing := mustFixtureDocument(t, "tombstone") - claiming := mustFixtureDocument(t, "valid") - claiming.Binding = "crm-eu-west-1-02" - - records := []*SolutionHostBindingRecord{ - { - BindingID: releasing.Binding, - Applied: &SolutionHostBindingApplied{ - SolutionHostBindingGeneration: SolutionHostBindingGeneration{Generation: 4}, - Routes: []string{"crm"}, - SolutionID: "crm", - }, - }, - } - // Deliberately given claimant-first, which is the order a mount walk would - // produce for these two names. +// A route alias handed from one binding to another releases before it is +// claimed, so the hand-over completes in one pass. +func TestAppliesReleaseAnAliasBeforeTheyClaimIt(t *testing.T) { + leaving := fixtureManifest(t, "reports", 1) + records := []*SolutionHostBindingRecord{appliedRecordOf(t, leaving)} + claimant := fixtureManifest(t, "reports", 1) + claimant.Binding = "acme.test.reports-next" ordered := orderSolutionHostBindingApplies([]SolutionHostBindingDecision{ - {Document: claiming, Decision: solutionhost.DecisionApply}, - {Document: releasing, Decision: solutionhost.DecisionApply}, + {Document: claimant}, {Document: withdrawalOf(t, leaving, 2)}, }, records) - - if len(ordered) != 2 || ordered[0].Document.Binding != releasing.Binding { - t.Fatalf("apply order = %v, want the releasing binding first", bindingsOf(ordered)) - } -} - -// The registry key is the route alias, and an alias this host cannot address is -// refused with a reason naming the route rather than surfacing as a database -// error later. -func TestSolutionHostBindingRegistryKeyResolution(t *testing.T) { - present := mustFixtureDocument(t, "valid") - key, err := solutionHostBindingRegistryKey(present) - if err != nil { - t.Fatalf("valid fixture: %v", err) - } - // Read from the fixture rather than hardcoded: core moved this alias once - // already, and pinning its content here tests core's testdata rather than - // this host's resolution. - if key != present.Routes[0].Alias { - t.Fatalf("registry key = %q, want the route alias %q", key, present.Routes[0].Alias) - } - // The fixture's route alias and its ownership domain are currently the SAME - // string, so the assertion above cannot tell "the alias" from "the domain" - // — and the domain is the plausible wrong answer. Re-asking with a distinct - // alias is what actually pins which field is the registry key. - distinct := mustFixtureDocument(t, "valid") - distinct.Routes[0].Alias = "storefront" - if key, err = solutionHostBindingRegistryKey(distinct); err != nil { - t.Fatalf("distinct alias: %v", err) - } - if key != "storefront" { - t.Fatalf("registry key = %q, want the route alias %q and not the ownership domain %q", - key, "storefront", distinct.OwnershipDomain) - } - - dotted := mustFixtureDocument(t, "valid") - dotted.Routes[0].Alias = "acme.crm" - if _, err := solutionHostBindingRegistryKey(dotted); !errors.Is(err, ErrSolutionHostBindingRouteNotAddressable) { - t.Fatalf("dotted alias error = %v, want ErrSolutionHostBindingRouteNotAddressable", err) - } - - routeless := mustFixtureDocument(t, "valid") - routeless.Routes = nil - if _, err := solutionHostBindingRegistryKey(routeless); !errors.Is(err, ErrSolutionHostBindingRouteRequired) { - t.Fatalf("routeless error = %v, want ErrSolutionHostBindingRouteRequired", err) - } - - twoRoutes := mustFixtureDocument(t, "valid") - twoRoutes.Routes = append(twoRoutes.Routes, solutionhost.Route{Alias: "crm-api", Surface: solutionhost.SurfaceBackend}) - if _, err := solutionHostBindingRegistryKey(twoRoutes); !errors.Is(err, ErrSolutionHostBindingRouteRequired) { - t.Fatalf("two-route error = %v, want ErrSolutionHostBindingRouteRequired", err) - } -} - -// A verified payload core refuses to parse still names the binding its refusal -// belongs to, so an operator reads "delivery is shipping something unreadable" -// beside the generation that is still running. -func TestSolutionHostBindingAttributionOfAnUnparseableDocument(t *testing.T) { - _, problems := verifySolutionHostBindingDocuments(context.Background(), fixtureSigner(t), - []SolutionHostBindingDocument{{ - Source: "mount/broken.codefly.yaml", - Data: carrierOver(t, []byte(`{"schema":"codefly/solution-host-binding/v1",`+ - `"binding":"crm-eu-west-1-01","generation":4,"whatIsThis":true}`)), - }}) - if len(problems) != 1 { - t.Fatalf("problems = %d, want 1", len(problems)) - } - if problems[0].binding != "crm-eu-west-1-01" { - t.Fatalf("attributed to %q, want the binding the document names", problems[0].binding) - } - if !strings.Contains(problems[0].err.Error(), "mount/broken.codefly.yaml") { - t.Fatalf("problem %q does not name the document it came from", problems[0].err) - } -} - -// A payload with no readable binding field is reported without a binding rather -// than attributed to a guess. -func TestSolutionHostBindingAttributionOfUnreadableBytes(t *testing.T) { - _, problems := verifySolutionHostBindingDocuments(context.Background(), fixtureSigner(t), - []SolutionHostBindingDocument{{ - Source: "mount/garbage.codefly.yaml", - // A JSON value that is not a document at all. The carrier itself is - // JSON, so bytes that are not even a JSON value are refused one - // layer earlier, by ParseSigned — which is what - // TestSolutionHostBindingRefusesAnUnsignedDocument covers. - Data: []byte(`{"document":"not a document","envelope":{}}`), - }}) - if len(problems) != 1 { - t.Fatalf("problems = %d, want 1", len(problems)) - } - if problems[0].binding != "" { - t.Fatalf("attributed to %q, want no attribution", problems[0].binding) - } -} - -// The regression that matters most on this change: a BARE document — the shape -// delivery shipped before signing existed, and the shape every fixture file on -// disk still has — is refused rather than admitted. There is no path from -// unattested bytes to a host judgement, and this is the test that says so. -func TestSolutionHostBindingRefusesAnUnsignedDocument(t *testing.T) { - bare, err := solutionhost.Marshal(mustFixtureDocument(t, "valid")) - if err != nil { - t.Fatalf("marshal: %v", err) - } - verified, problems := verifySolutionHostBindingDocuments(context.Background(), fixtureSigner(t), - []SolutionHostBindingDocument{{Source: "mount/unsigned.codefly.yaml", Data: bare}}) - if len(verified) != 0 { - t.Fatalf("verified %d unsigned documents, want none admitted without an attestation", len(verified)) - } - if len(problems) != 1 { - t.Fatalf("problems = %d, want 1", len(problems)) - } - // A bare document is not a carrier, so there is nothing whose binding could - // be trusted enough to attribute: core refuses it before any field is read. - if problems[0].binding != "" { - t.Fatalf("attributed to %q, want no attribution for bytes that are not a carrier", problems[0].binding) - } - if !errors.Is(problems[0].err, solutionhost.ErrUnsigned) { - t.Fatalf("error = %v, want ErrUnsigned", problems[0].err) - } -} - -// A carrier whose bundle does not verify is refused, and the refusal is still -// attributed so an operator sees which binding delivery is failing for. -func TestSolutionHostBindingRefusesACarrierWhoseBundleDoesNotVerify(t *testing.T) { - document := mustFixtureDocument(t, "valid") - refusing := deliverytest.Verifier(t, map[string][]string{"unlisted": {solutionhost.FixtureDomain}}) - verified, problems := verifySolutionHostBindingDocuments(context.Background(), refusing, - []SolutionHostBindingDocument{{ - Source: "mount/forged.codefly.yaml", - Data: signedCarrier(t, document), - }}) - if len(verified) != 0 { - t.Fatalf("verified %d documents, want none", len(verified)) - } - if len(problems) != 1 { - t.Fatalf("problems = %d, want 1", len(problems)) - } - if problems[0].binding != document.Binding { - t.Fatalf("attributed to %q, want %q", problems[0].binding, document.Binding) - } -} - -// The verifier must be asked about the carrier's bytes VERBATIM. Re-encoding a -// parsed document would produce bytes the signature does not cover, and then the -// only honest answer is that nothing verified. -func TestSolutionHostBindingVerifierSeesTheCarrierBytesVerbatim(t *testing.T) { - document := mustFixtureDocument(t, "valid") - canonical, err := document.CanonicalBytes() - if err != nil { - t.Fatalf("canonical bytes: %v", err) - } - verifier := fixtureSigner(t) - if _, problems := verifySolutionHostBindingDocuments(context.Background(), verifier, - []SolutionHostBindingDocument{{Source: "mount/valid.codefly.yaml", Data: carrierOver(t, canonical)}}, - ); len(problems) != 0 { - t.Fatalf("problems = %v, want none", problems) - } - // A change to the carrier's document leaves the envelope over the old - // bytes. Re-encoding or trusting the parsed document must not admit it. - raw := carrierOver(t, canonical) - raw = bytes.Replace(raw, []byte(`"generation":4`), []byte(`"generation":5`), 1) - if bytes.Equal(raw, carrierOver(t, canonical)) { - t.Fatal("fixture mutation changed no bytes") - } - if verified, problems := verifySolutionHostBindingDocuments(context.Background(), verifier, - []SolutionHostBindingDocument{{Source: "mount/tampered.codefly.yaml", Data: raw}}); len(verified) != 0 || len(problems) != 1 { - t.Fatalf("tampered document: verified %d, problems %v", len(verified), problems) - } -} - -// A signer the host accepts at all must still not be able to deliver under a -// domain the host did not let it speak for. This is the self-asserted ownership -// domain hole: the document states its own domain, so the signer policy is the -// only thing standing between an accepted signer and every accepted domain. -func TestSolutionHostBindingRefusesADomainTheSignerMayNotSpeakFor(t *testing.T) { - host, err := solutionhost.FixtureHost() - if err != nil { - t.Fatalf("fixture host: %v", err) - } - document := mustFixtureDocument(t, "valid") - // An identity the host's policy says nothing about, attesting a document - // whose domain the host does accept. - stranger := fixtureSigner(t) - host.DomainsBySigner = map[string][]string{solutionhost.FixtureDeliveredBy: {"other-domain"}} - admission := admitSolutionHostBindings(host, deliveredBy(t, stranger, document)) - if len(admission.Accepted) != 0 { - t.Fatalf("accepted %v, want nothing from a signer with no domain policy", bindingsOf(admission.Accepted)) - } - reason, withheld := admission.Withheld[document.Binding] - if !withheld { - t.Fatal("a document delivered by a signer the host does not let speak for its domain must be withheld") - } - if !strings.Contains(reason, "speak for it") { - t.Fatalf("reason %q does not say the signer may not speak for the domain", reason) - } -} - -// PendingGeneration is what an operator reads beside the reason: it is zero when -// desired and applied agree and the desired generation otherwise. -func TestSolutionHostBindingPendingGeneration(t *testing.T) { - agreed := &SolutionHostBindingRecord{ - Desired: &SolutionHostBindingGeneration{Generation: 4, Digest: "sha256:aa"}, - Applied: &SolutionHostBindingApplied{ - SolutionHostBindingGeneration: SolutionHostBindingGeneration{Generation: 4, Digest: "sha256:aa"}, - }, - } - if got := agreed.PendingGeneration(); got != 0 { - t.Fatalf("pending generation = %d, want 0 when desired and applied agree", got) - } - behind := &SolutionHostBindingRecord{ - Desired: &SolutionHostBindingGeneration{Generation: 3, Digest: "sha256:bb"}, - Applied: &SolutionHostBindingApplied{ - SolutionHostBindingGeneration: SolutionHostBindingGeneration{Generation: 4, Digest: "sha256:aa"}, - }, - } - if got := behind.PendingGeneration(); got != 3 { - t.Fatalf("pending generation = %d, want the generation delivery is showing", got) - } - // The same generation number with different contents is pending, not current: - // that is the rewrite core refuses, and reporting it as current would hide it. - rewritten := &SolutionHostBindingRecord{ - Desired: &SolutionHostBindingGeneration{Generation: 4, Digest: "sha256:bb"}, - Applied: &SolutionHostBindingApplied{ - SolutionHostBindingGeneration: SolutionHostBindingGeneration{Generation: 4, Digest: "sha256:aa"}, - }, - } - if got := rewritten.PendingGeneration(); got != 4 { - t.Fatalf("pending generation = %d, want the rewritten generation", got) - } -} - -func mustFixtureDocument(t *testing.T, name string) *solutionhost.SolutionHostBinding { - t.Helper() - data, err := solutionhost.FixtureDocument(solutionhost.DocumentTypePresence, name) - if err != nil { - t.Fatalf("fixture %q: %v", name, err) - } - document, err := solutionhost.Parse(data) - if err != nil { - t.Fatalf("parse fixture %q: %v", name, err) - } - return document -} - -func bindingsOf(decisions []SolutionHostBindingDecision) []string { - names := make([]string, 0, len(decisions)) - for _, decision := range decisions { - names = append(names, decision.Document.Binding) - } - return names -} - -// Admission must read the generation the attestation covered, not the one a -// caller holding the document happens to have moved. -// -// This pins a property of the DEPENDENCY rather than of local logic, which is -// why it is worth a test here: core's Delivered used to hand out the parsed -// document it would itself admit, so `delivered.Document().Generation += 7` -// changed what Admit consumed, with the attestation still covering the original -// bytes. core 67ee7220 made Document() re-derive from the attested payload per -// call. This host is what depends on that — it holds a derived document for the -// whole pass to attribute refusals and record desired state with — so a future -// pin that regressed to the shared pointer must come back red here rather than -// as a generation nobody signed being written to a row. -// -// The assertion is deliberately two-sided: it proves the mutation really landed -// on the caller's copy AND that the verdict ignored it, so it cannot pass by -// the mutation having silently failed to apply. -func TestSolutionHostBindingAdmissionReadsTheAttestedBytesNotTheCallersCopy(t *testing.T) { - host, err := solutionhost.FixtureHost() - if err != nil { - t.Fatalf("fixture host: %v", err) - } - // The generation FixtureHost has already applied, so a sound reading is - // DecisionCurrent and a mutated one would be DecisionApply. - document := mustFixtureDocument(t, "valid") - pairs := delivered(t, document) - if len(pairs) != 1 { - t.Fatalf("delivered %d carriers, want 1", len(pairs)) - } - attested := pairs[0].Document.Generation - pairs[0].Document.Generation = attested + 7 - - admission := admitSolutionHostBindings(host, pairs) - - if reason, withheld := admission.Withheld[document.Binding]; withheld { - t.Fatalf("the attested generation is the applied one and must be admitted, was withheld: %s", reason) - } - if len(admission.Accepted) != 1 { - t.Fatalf("accepted %d documents, want 1", len(admission.Accepted)) - } - if got := admission.Accepted[0].Document.Generation; got != attested+7 { - t.Fatalf("the caller's copy reads generation %d, want %d: the mutation this test is about did not land, so the verdict below proves nothing", - got, attested+7) - } - if got := admission.Accepted[0].Decision; got != solutionhost.DecisionCurrent { - t.Fatalf("decision = %q, want %q: admission read generation %d from the caller's copy rather than %d from the attested bytes", - got, solutionhost.DecisionCurrent, attested+7, attested) - } -} - -// A carrier whose bundle is absent, null or not an object is refused as -// unsigned, and the host must never read the document out of it. -// -// The field is the one a renderer with no signing step yet can fill: writing -// `null` costs nothing and produces a carrier that is structurally a carrier. -// Core looks inside no bundle — it holds no trust root — so "there is one" is -// the entire check available at this layer, and it is the difference between a -// signed document and a document. -func TestSolutionHostBindingRefusesACarrierWithNoBundle(t *testing.T) { - document := mustFixtureDocument(t, "valid") - canonical, err := document.CanonicalBytes() - if err != nil { - t.Fatalf("canonical bytes: %v", err) - } - for _, one := range []struct { - name string - bundle string - }{ - {"null", `null`}, - {"absent", ``}, - {"a string rather than an object", `"not-a-bundle"`}, - {"an array rather than an object", `[]`}, - } { - t.Run(one.name, func(t *testing.T) { - // Hand-written rather than built with MarshalSigned, which refuses - // these itself: the point is what the host does with bytes that - // reached its mount, not what its own writer would produce. - field := "" - if one.bundle != "" { - field = `,"envelope":` + one.bundle - } - raw := []byte(`{"document":` + string(canonical) + field + `}`) - - verified, problems := verifySolutionHostBindingDocuments( - context.Background(), fixtureSigner(t), - []SolutionHostBindingDocument{{Source: "unsigned.json", Data: raw}}, - ) - if len(verified) != 0 { - t.Fatalf("admitted %d carriers with a %s bundle, want none", len(verified), one.name) - } - if len(problems) != 1 { - t.Fatalf("recorded %d problems, want 1", len(problems)) - } - if !errors.Is(problems[0].err, solutionhost.ErrUnsigned) { - t.Fatalf("refusal %v is not ErrUnsigned, so the host refused it for some other reason", problems[0].err) - } - }) - } + require.Equal(t, leaving.Binding, ordered[0].Document.Binding) + require.Equal(t, claimant.Binding, ordered[1].Document.Binding) +} + +func TestTheRegistryKeyIsTheAlias(t *testing.T) { + key, err := solutionHostBindingRegistryKey(fixtureManifest(t, "reports", 1)) + require.NoError(t, err) + require.Equal(t, "reports", key) + _, err = solutionHostBindingRegistryKey(fixtureManifest(t, "reports.v2", 1)) + require.ErrorIs(t, err, ErrSolutionHostBindingRouteNotAddressable, "core admits a dot; a registry key does not") +} + +// The pass verifies every carrier: one signed by no pinned key, or one whose +// payload is not a manifest, never reaches admission, and it is attributed to +// the binding its bytes name. +func TestThePassRefusesWhatDoesNotVerify(t *testing.T) { + manifest := fixtureManifest(t, "reports", 1) + other := deliverytest.Verifier(t, map[string][]string{"another-key": {testDeliveryDomain}}) + verified, problems := verifySolutionHostBindingDocuments(context.Background(), other, + []SolutionHostBindingDocument{{Source: "inbox:reports", Data: signedCarrier(t, manifest)}}) + require.Empty(t, verified) + require.Len(t, problems, 1) + require.Equal(t, manifest.Binding, problems[0].binding) + require.ErrorIs(t, problems[0].err, solutionhost.ErrUnsigned) + + verified, problems = verifySolutionHostBindingDocuments(context.Background(), fixtureSigner(t), + []SolutionHostBindingDocument{{Source: "inbox:bogus", Data: carrierOver(t, []byte(`{"binding":"acme.test.bogus","schema":"codefly/module-manifest/v1"}`))}}) + require.Empty(t, verified) + require.Len(t, problems, 1) + require.Equal(t, "acme.test.bogus", problems[0].binding) + require.ErrorIs(t, problems[0].err, solutionhost.ErrInvalid) +} + +func TestPendingGenerationIsWhatDeliveryShowsAndTheHostHasNotApplied(t *testing.T) { + record := appliedRecordOf(t, fixtureManifest(t, "reports", 2)) + record.Desired = &record.Applied.SolutionHostBindingGeneration + require.Zero(t, record.PendingGeneration()) + record.Desired = &SolutionHostBindingGeneration{Generation: 3, Digest: "sha256:other"} + require.Equal(t, uint64(3), record.PendingGeneration()) } diff --git a/module/services/accounts/code/pkg/business/solution_host_binding_delivery_fake_test.go b/module/services/accounts/code/pkg/business/solution_host_binding_delivery_fake_test.go index dbf2b851a..f02f137a8 100644 --- a/module/services/accounts/code/pkg/business/solution_host_binding_delivery_fake_test.go +++ b/module/services/accounts/code/pkg/business/solution_host_binding_delivery_fake_test.go @@ -11,35 +11,65 @@ import ( // Every admission fixture is signed and verified, because core's Delivered // values can only be constructed by checking the envelope and ownership domain. + +const ( + testDeliveryKey = "example-release" + testDeliveryDomain = "acme" + testDeliveryCoordinate = "acme-test" +) + +// testModuleNamespaceRule is core's bare rule: a module's namespace is its +// name. +var testModuleNamespaceRule = ModuleNamespaceRule{} + func fixtureSigner(t *testing.T) *solutionhost.Verifier { - return deliverytest.Verifier(t, map[string][]string{solutionhost.FixtureDeliveredBy: {solutionhost.FixtureDomain}}) + return deliverytest.Verifier(t, map[string][]string{testDeliveryKey: {testDeliveryDomain}}) +} + +// fixtureManifest is a valid live manifest for one alias on the test host. +func fixtureManifest(t *testing.T, alias string, generation uint64) *solutionhost.ModuleManifest { + t.Helper() + manifest := &solutionhost.ModuleManifest{ + Schema: solutionhost.SchemaModuleManifestV1, Binding: "acme.test." + alias, Alias: alias, + Kind: solutionhost.KindModule, Generation: generation, + Host: solutionhost.HostTarget{Coordinate: testDeliveryCoordinate, Domain: testDeliveryDomain}, + Release: solutionhost.Release{Publisher: "acme", Name: alias, Version: "1.0.0"}, + Workloads: []solutionhost.Workload{{Service: alias, Container: alias}}, + } + if err := manifest.Validate(); err != nil { + t.Fatalf("fixture manifest: %v", err) + } + return manifest +} + +// withdrawalOf is the withdrawal of manifest at generation. +func withdrawalOf(t *testing.T, manifest *solutionhost.ModuleManifest, generation uint64) *solutionhost.ModuleManifest { + t.Helper() + removed := *manifest + removed.Generation, removed.Removed = generation, true + removed.Workloads, removed.Endpoints, removed.Authority, removed.Frontend, removed.Group = nil, nil, nil, nil, nil + if err := removed.Validate(); err != nil { + t.Fatalf("fixture withdrawal: %v", err) + } + return &removed } -// signedCarrier wraps one document in the carrier delivery would ship: the +// signedCarrier wraps one manifest in the carrier delivery ships: the // canonical bytes verbatim, plus a DSSE envelope over them. -// -// CanonicalBytes, not Marshal. Marshal renders YAML for a delivery repository; -// the SIGNING input is the canonical JSON, and PresenceFromVerified refuses a -// payload that is not the canonical encoding of the document it decodes to — -// even when the attestation over those bytes is genuine, because a signer and a -// host that disagree about which bytes represent the document disagree about -// what was approved. -func signedCarrier(t *testing.T, document *solutionhost.SolutionHostBinding) []byte { +func signedCarrier(t *testing.T, manifest *solutionhost.ModuleManifest) []byte { t.Helper() - canonical, err := solutionhost.SignedPayload(document) + canonical, err := solutionhost.SignedPayload(manifest) if err != nil { - t.Fatalf("canonical bytes for %q: %v", document.Binding, err) + t.Fatalf("canonical bytes for %q: %v", manifest.Binding, err) } - return deliverytest.Carrier(t, canonical, solutionhost.FixtureDeliveredBy) + return deliverytest.Carrier(t, canonical, testDeliveryKey) } // carrierOver wraps arbitrary payload bytes, so a test can deliver bytes that -// are deliberately not a valid document. The carrier is JSON, so a payload that -// is not a JSON value cannot be carried at all — which is itself the shape of -// the refusal a test about unreadable bytes is asserting. +// are deliberately not a valid manifest. func carrierOver(t *testing.T, payload []byte) []byte { t.Helper() - envelope := deliverytest.Envelope(t, payload, solutionhost.FixtureDeliveredBy) + envelope := deliverytest.Envelope(t, payload, testDeliveryKey) carrier, err := solutionhost.MarshalSigned(&solutionhost.Signed{Document: payload, Envelope: envelope}) if err != nil { t.Fatalf("marshal carrier: %v", err) @@ -47,54 +77,52 @@ func carrierOver(t *testing.T, payload []byte) []byte { return carrier } -// delivered verifies documents through the real VerifyDelivered with the fixture -// signer, producing the values admission takes. -func delivered(t *testing.T, documents ...*solutionhost.SolutionHostBinding) []deliveredSolutionHostBinding { - t.Helper() - return deliveredBy(t, fixtureSigner(t), documents...) -} - -// deliveredBy is delivered with a pinned-key verifier, for a test about who -// signed rather than about what was signed. -// -// It pairs each carrier with the document re-derived from its attested bytes, -// the way the reconcile pass does, rather than with the document it was handed: -// a helper that returned the caller's own pointer would let a test assert -// against a value admission never reads. See deliveredSolutionHostBinding. -func deliveredBy( - t *testing.T, verifier *solutionhost.Verifier, documents ...*solutionhost.SolutionHostBinding, -) []deliveredSolutionHostBinding { +// delivered verifies manifests through the real VerifyDelivered, pairing each +// carrier with the manifest re-derived from its attested bytes, as the pass +// does. +func delivered(t *testing.T, manifests ...*solutionhost.ModuleManifest) []deliveredSolutionHostBinding { t.Helper() - out := make([]deliveredSolutionHostBinding, 0, len(documents)) - for _, document := range documents { - carrier, err := solutionhost.ParseSigned(signedCarrier(t, document)) + out := make([]deliveredSolutionHostBinding, 0, len(manifests)) + for _, manifest := range manifests { + carrier, err := solutionhost.ParseSigned(signedCarrier(t, manifest)) if err != nil { - t.Fatalf("parse carrier for %q: %v", document.Binding, err) + t.Fatalf("parse carrier for %q: %v", manifest.Binding, err) } - one, err := solutionhost.VerifyDelivered(context.Background(), carrier, verifier) + one, err := solutionhost.VerifyDelivered(context.Background(), carrier, fixtureSigner(t)) if err != nil { - t.Fatalf("verify carrier for %q: %v", document.Binding, err) + t.Fatalf("verify carrier for %q: %v", manifest.Binding, err) } - attested, err := one.Document() + attested, err := one.Manifest() if err != nil { - t.Fatalf("re-derive document for %q: %v", document.Binding, err) + t.Fatalf("re-derive manifest for %q: %v", manifest.Binding, err) } out = append(out, deliveredSolutionHostBinding{Delivered: one, Document: attested}) } return out } -// fixtureSignerHost is a host at one coordinate that accepts core's fixture -// domain and lets the fixture signer speak for it. -// -// DomainsBySigner is required whenever a coordinate is set — core refuses the -// call without it — so a test host that omitted it would fail with "declare a -// signer policy" instead of the refusal it meant to assert, which is how a test -// passes for the wrong reason. -func fixtureSignerHost(coordinate string) solutionhost.Host { - return solutionhost.Host{ - Coordinate: coordinate, - Domains: []string{solutionhost.FixtureDomain}, - DomainsBySigner: map[string][]string{solutionhost.FixtureDeliveredBy: {solutionhost.FixtureDomain}}, +// appliedRecordOf is the host's record of having applied manifest. +func appliedRecordOf(t *testing.T, manifest *solutionhost.ModuleManifest) *SolutionHostBindingRecord { + t.Helper() + digest, err := manifest.Digest() + if err != nil { + t.Fatal(err) + } + encoded, err := solutionhost.Marshal(manifest) + if err != nil { + t.Fatal(err) + } + var routes []string + solutionID := manifest.Alias + if !manifest.Removed { + routes = []string{manifest.Alias} + } + return &SolutionHostBindingRecord{ + BindingID: manifest.Binding, HostCoordinate: manifest.Host.Coordinate, + Applied: &SolutionHostBindingApplied{ + SolutionHostBindingGeneration: SolutionHostBindingGeneration{Generation: manifest.Generation, Digest: digest, Document: string(encoded)}, + Removed: manifest.Removed, Routes: routes, SolutionID: solutionID, + Release: manifest.Release.Identity(), Domain: manifest.Host.Domain, + }, } } diff --git a/module/services/accounts/code/pkg/business/solution_host_bindings.go b/module/services/accounts/code/pkg/business/solution_host_bindings.go index 1776a7c08..2b729484d 100644 --- a/module/services/accounts/code/pkg/business/solution_host_bindings.go +++ b/module/services/accounts/code/pkg/business/solution_host_bindings.go @@ -14,22 +14,22 @@ import ( "gopkg.in/yaml.v3" ) -// Declared solution presence (issue #952). +// Declared module and solution presence (issue #952). // -// Delivery declares which solution runs on this host at which generation. Core -// owns the document and its admission rules; the host reconciles admitted -// declarations into the durable registry. +// Delivery declares, in one signed module manifest per binding, which module +// or solution runs on this host at which generation, which workloads run as +// it, where its endpoints are and what authority it asks for. Core owns the +// document and its admission rules; the host reconciles admitted manifests into +// the durable registry and derives everything else from the applied one. // // DESIRED records the newest generation delivery supplied and any refusal. -// APPLIED records the generation reconciled into durable state. Endpoint and -// manifest observations remain separate from that declaration and never grant -// presence or installation authority. +// APPLIED records the generation reconciled into durable state. const ( // SolutionHostBindingReconcileInterval is how often the host re-reads its - // mount. Delivery replaces a mounted document at its own cadence and gives - // the host no signal, so the pass is a poll; core's DecisionCurrent exists - // precisely because the same generation is read over and over. + // inbox when nothing asked for a pass sooner. A delivery and an envelope + // write trigger one, so the tick is the convergence bound for everything + // else (a re-pinned key, a retried apply). SolutionHostBindingReconcileInterval = 30 * time.Second // SolutionHostReservedRouteNamespace is the route namespace no delivered @@ -43,14 +43,9 @@ var ( // ErrSolutionHostBindingNotDeclared is returned when a binding ID has no // durable record. ErrSolutionHostBindingNotDeclared = errors.New("solution host binding is not declared") - // ErrSolutionHostBindingRouteRequired is returned when a present generation - // does not declare exactly one route alias. core permits several; this host - // reconciles a binding into one registry record addressed by one path - // segment, so it needs exactly one and refuses rather than choosing. - ErrSolutionHostBindingRouteRequired = errors.New("solution host binding must declare exactly one route alias") // ErrSolutionHostBindingRouteNotAddressable is returned when the declared - // alias is not a registry solution id: core's alias vocabulary admits dots - // and slashes, and a registry key is one lowercase path segment. + // alias is not a registry solution id: core's alias is one lowercase + // segment that may hold dots, and a registry key may not. ErrSolutionHostBindingRouteNotAddressable = errors.New("solution host binding route alias is not an addressable solution id") // ErrSolutionHostBindingKindNotRoutable is returned when a present generation // declares a kind this host has no routing surface for. core admits only @@ -73,7 +68,7 @@ var solutionIDPattern = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?$`) // maxSolutionIDLength matches the registry's own bound on solution_id. const maxSolutionIDLength = 128 -// SolutionHostBindingGeneration is one generation of one document: what it +// SolutionHostBindingGeneration is one generation of one manifest: what it // declares, and the digest that identifies it. The document is kept whole, not // just its digest, because the host must be able to say what it is running and // what it has been asked to run. @@ -88,60 +83,105 @@ type SolutionHostBindingGeneration struct { // reconciled it into. type SolutionHostBindingApplied struct { SolutionHostBindingGeneration - // Removed records that the applied generation was a tombstone. It is not an - // absence: the binding ID and its generation remain, so a late or replayed - // older generation is still refused after a removal. + // Removed records that the applied generation was a withdrawal. It is not + // an absence: the binding ID and its generation remain, so a late or + // replayed generation is still refused after a removal. Removed bool // Routes are the aliases the applied generation holds. Routes []string // SolutionID is the registry record the applied generation reconciled into. - // A tombstone generation declares no route, so this is the only way it knows - // which record to withdraw. + // A withdrawal is routed nowhere, so this is how it knows which record to + // withdraw. SolutionID string // Release is publisher/name@version of the applied generation. Release string // Domain is the ownership domain the applied generation declared. // - // It is persisted rather than re-derived because it decides WHO MAY CHANGE - // this record: core refuses a later generation for the binding that arrives - // under a different domain, which is what stops one accepted delivery taking - // over a binding another delivery owns. Re-deriving it from the host's own - // configured domain list would answer "a domain this host accepts" instead - // of "the domain this binding was claimed under", and those differ exactly - // when it matters — a host accepting two domains. + // It is persisted as well as held in the applied manifest because it + // decides WHO MAY CHANGE this record: core refuses a later generation for + // the binding that arrives under a different domain. Domain string - // EndpointDelivery is an opaque read projection from the signed inbox, - // joined to this applied document. It is not a registry address column. - EndpointDelivery []byte } // SolutionHostBindingRecord is the host's durable record for one binding ID. type SolutionHostBindingRecord struct { BindingID string HostCoordinate string - HostComponent string - Desired *SolutionHostBindingGeneration - Applied *SolutionHostBindingApplied - PendingReason string - PendingSince *time.Time - UpdatedAt time.Time + // Domain is the ownership domain the binding first applied under. It is + // kept when live applied state is not — migration 41 cleared that and kept + // this — so a binding awaiting redelivery still belongs to its domain, and + // no other pinned domain can take its id (and its installations). + Domain string + Desired *SolutionHostBindingGeneration + Applied *SolutionHostBindingApplied + PendingReason string + PendingSince *time.Time + UpdatedAt time.Time } -// AppliedState is the record in the shape core judges a document against. A -// binding with nothing applied contributes no state, which is how core is told -// "never seen". -func (r *SolutionHostBindingRecord) AppliedState() (solutionhost.Applied, bool) { +// ownershipDomain is the domain the binding belongs to, or "" for a binding +// no generation was ever applied for. +func (r *SolutionHostBindingRecord) ownershipDomain() string { + switch { + case r == nil: + return "" + case r.Domain != "": + return r.Domain + case r.Applied != nil: + return r.Applied.Domain + } + return "" +} + +// checkOwnershipDomain refuses a manifest for this binding under another +// ownership domain than the one it belongs to — core's Admit does the same +// against an applied manifest, and this holds when none remains. +func (r *SolutionHostBindingRecord) checkOwnershipDomain(candidate *solutionhost.ModuleManifest) error { + if domain := r.ownershipDomain(); domain != "" && candidate.Host.Domain != domain { + return fmt.Errorf("%w: binding %q belongs to ownership domain %q, and this manifest speaks for %q", + ErrSolutionDeliveryDomainNotGranted, candidate.Binding, domain, candidate.Host.Domain) + } + return nil +} + +// AppliedManifest is the manifest this host applied for the binding, the one +// core's Admit judges a candidate against; nil when nothing was applied. +// +// A withdrawal recorded before the module manifest replaced the presence +// document cannot be read as a manifest, and it is still terminal: it answers +// core's ErrWithdrawn, never "nothing applied". Any other unreadable record is +// the host's own damaged state (ErrAppliedUnusable). +func (r *SolutionHostBindingRecord) AppliedManifest() (*solutionhost.ModuleManifest, error) { if r == nil || r.Applied == nil { - return solutionhost.Applied{}, false - } - return solutionhost.Applied{ - Binding: r.BindingID, - Generation: r.Applied.Generation, - Digest: r.Applied.Digest, - Domain: r.Applied.Domain, - Routes: r.Applied.Routes, - Removed: r.Applied.Removed, - }, true + return nil, nil + } + manifest, err := solutionhost.Parse([]byte(r.Applied.Document)) + if err != nil { + if r.Applied.Removed { + return nil, fmt.Errorf("%w: binding %q was withdrawn at generation %d, before this host read module manifests", + solutionhost.ErrWithdrawn, r.BindingID, r.Applied.Generation) + } + return nil, fmt.Errorf("%w: binding %q applied generation %d: %v", + solutionhost.ErrAppliedUnusable, r.BindingID, r.Applied.Generation, err) + } + if manifest.Binding != r.BindingID || manifest.Generation != r.Applied.Generation || manifest.Removed != r.Applied.Removed { + return nil, fmt.Errorf("%w: binding %q applied generation %d records a manifest for %q generation %d", + solutionhost.ErrAppliedUnusable, r.BindingID, r.Applied.Generation, manifest.Binding, manifest.Generation) + } + return manifest, nil +} + +// appliedManifests is every readable applied manifest: the incumbents a +// candidate's route alias is held against. An unreadable record claims no +// alias here; its own binding's admission reports it. +func appliedManifests(records []*SolutionHostBindingRecord) []*solutionhost.ModuleManifest { + applied := make([]*solutionhost.ModuleManifest, 0, len(records)) + for _, record := range records { + if manifest, err := record.AppliedManifest(); err == nil && manifest != nil { + applied = append(applied, manifest) + } + } + return applied } // PendingGeneration is the generation delivery is showing that this host has not @@ -157,66 +197,43 @@ func (r *SolutionHostBindingRecord) PendingGeneration() uint64 { return r.Desired.Generation } -// SolutionHostBindingDocument is one delivered document as it was read. Source -// is where it came from and appears only in operator-facing messages; identity -// comes from the document's own binding field, never from a file name. +// SolutionHostBindingDocument is one delivered manifest carrier as it was read. +// Source is where it came from and appears only in operator-facing messages; +// identity comes from the manifest's own binding field. type SolutionHostBindingDocument struct { Source string - // Data is a SIGNED CARRIER — core's {schema, document, bundle} — not a bare - // presence document. Delivery signs at publish and the host verifies at - // admission, so the bytes that cross the mount are the bytes the signature - // covers. A bare document now fails to parse here rather than being - // admitted unverified, which is the point: core cd443989 removed every path - // from unattested bytes to a host judgement, and this is the host side of - // that removal. + // Data is a SIGNED CARRIER — core's {document, envelope} — never a bare + // manifest, so the bytes the pass judges are the bytes the signature + // covers. Data []byte } // SolutionHostBindingSource is the delivered desired set. // -// It returns an error rather than an empty set when the mount cannot be read. -// Removal is a tombstone generation precisely so that a lost, unmounted or -// unreadable mount can never be reconciled as "remove everything". +// It returns an error rather than an empty set when the inbox cannot be read. +// Removal is a withdrawal generation precisely so that a lost or unreadable +// input can never be reconciled as "remove everything". type SolutionHostBindingSource interface { Documents(ctx context.Context) ([]SolutionHostBindingDocument, error) } // SolutionHostBindingReconciler drives one pass and, when started, repeats it. type SolutionHostBindingReconciler struct { - service *Service - source SolutionHostBindingSource - host solutionhost.Host + service *Service + source SolutionHostBindingSource + coordinate string // verifier uses the platform's pins; core checks the envelope and the - // signer's ownership domains before returning an admitted document. + // signer's ownership domain before a manifest reaches admission. verifier *solutionhost.Verifier interval time.Duration now func() time.Time - // activation answers whether delivered authority is active for a binding at - // a build. It lives on the reconciler because the reconciler already holds - // every policy input activation needs — this host's coordinate, the - // ownership domains it accepts, which signer may speak for which, and the - // bundle verifier — and a second copy of any of them would be a second - // answer to "what does this host accept". - activation *SolutionAuthorityActivation - - // approvedBuilds is the execution authority this host serves to the mint, - // rebuilt from the same inbox in the same pass. - // - // ONE PASS FOR BOTH HALVES, deliberately. Presence and authority arrive in - // one inbox and describe one delivery, so refreshing them on separate - // schedules opens a window in which a generation is applied while the build - // it approves is still the previous one — and that window is exactly where - // a superseded build is mintable. Nothing can drift if there is only one - // pass. - approvedBuilds *ApprovedBuildReconciler - // modulePrincipals derives the module registry every capability decision - // reads, from the live authority inside the current envelope and the - // applied presence it names, and swaps it into the service each pass. + // reads, from the applied manifests and the current envelope, and swaps it + // into the service each pass. modulePrincipals *ModulePrincipalReconciler // trigger asks the loop for a pass now. Buffered by one: a request made @@ -229,163 +246,78 @@ type SolutionHostBindingReconciler struct { } // SolutionHostBindingReconcilerConfig is everything one host's reconciler needs -// to judge delivery. Every field but Interval is required, and each absence is -// refused by name rather than defaulted, because every one of them is a -// different way for the host to accept a binding it should not. +// to judge delivery. Every field but Interval and Envelopes is required, and +// each absence is refused by name rather than defaulted. type SolutionHostBindingReconcilerConfig struct { // Source is the delivered desired set, as signed carriers. Source SolutionHostBindingSource - // Verifier is this host's attestation check over a carrier's bundle. It is - // required: without one there is no way to construct the verified value - // core's Admit takes, so a host with no verifier admits nothing at all - // rather than falling back to admitting everything. + // Verifier is this host's pinned-key check over a carrier. Without one + // there is no way to construct the verified value admission needs, so a + // host with no verifier admits nothing rather than everything. Verifier *solutionhost.Verifier // Coordinate is the host this reconciler answers for. Coordinate string - // Domains are the ownership domains this host accepts delivery under. - Domains []string - - // DomainsBySigner is which of those domains each attested signer identity - // may speak for, keyed by the identity Verifier returns. A document asserts - // its own ownership domain, so without this policy any signer the host - // accepted at all could deliver under any domain the host accepted and take - // over bindings in it. - DomainsBySigner map[string][]string - // Interval between passes. Zero takes the default. Interval time.Duration - // Envelopes is where the AUTHORITY CEILING is read from — the module - // envelope a platform super_admin writes — on every pass and every - // activation, so a narrowing takes effect at the next check with no - // restart. - // - // Optional, and its absence is not a weaker ceiling. No source, or no - // revision written, means no ceiling: every activation refuses with - // ErrSolutionAuthorityCeilingUnavailable and the module registry is empty — - // the host has decided nothing rather than decided that a grant is absent. - // A host that reconciles presence with no ceiling is a correct, complete - // deployment: making the presence half depend on the authority half would be - // the wrong coupling. + // Envelopes is where the administrator's approval — the ceiling a module's + // requested authority is intersected with — is read from on every pass, + // so a narrowing takes effect at the next pass with no restart. // - // It is never read out of a delivered document. core: "an envelope a - // document carried would be a document declaring its own ceiling". + // Optional, and its absence is not a weaker ceiling: no source, or no + // revision written, means the module registry is empty. It is never read + // out of a delivered manifest: a manifest requests, it never approves. Envelopes ModuleEnvelopeSource } // NewSolutionHostBindingReconciler builds the reconciler for one host -// coordinate. The coordinate is required: core refuses a document that targets +// coordinate. The coordinate is required: core refuses a manifest that targets // another host, and a host that did not know its own coordinate would reconcile // whatever it was handed. func NewSolutionHostBindingReconciler( service *Service, config SolutionHostBindingReconcilerConfig, ) (*SolutionHostBindingReconciler, error) { - source, domains, interval := config.Source, config.Domains, config.Interval - if service == nil || source == nil { + if service == nil || config.Source == nil { return nil, errors.New("solution host binding reconciler requires a service and a source") } if config.Verifier == nil { - return nil, errors.New("solution host binding reconciler requires a bundle verifier; a host that cannot verify a carrier must admit nothing, not everything") + return nil, errors.New("solution host binding reconciler requires a pinned-key verifier; a host that cannot verify a carrier must admit nothing, not everything") } if config.Coordinate == "" { return nil, errors.New("solution host binding reconciler requires this host's coordinate") } - // Core requires the accepted ownership domains whenever a coordinate is set, - // and refuses a document from an unstated one. The reason is that the applied - // record cannot bound a binding's FIRST generation — there is nothing to - // compare against yet — so without this, any delivery could claim any unseen - // binding ID under a domain of its choosing and own it from then on. It is the - // host's to declare because core cannot know which delivery is entitled to a - // name it has never seen. - if len(domains) == 0 { - return nil, errors.New("solution host binding reconciler requires the ownership domains this host accepts delivery from") - } - // Same reasoning one axis over, and core refuses the call without it: an - // unstated signer policy would let every accepted signer claim every - // accepted domain, which makes the domain list decorative. - if len(config.DomainsBySigner) == 0 { - return nil, errors.New("solution host binding reconciler requires the ownership domains each signer identity may deliver under") - } + interval := config.Interval if interval <= 0 { interval = SolutionHostBindingReconcileInterval } reconciler := &SolutionHostBindingReconciler{ - service: service, - source: source, - verifier: config.Verifier, - host: solutionhost.Host{ - Coordinate: config.Coordinate, - Domains: domains, - DomainsBySigner: config.DomainsBySigner, - Reserved: []string{SolutionHostReservedRouteNamespace}, - }, - interval: interval, - now: func() time.Time { return time.Now().UTC() }, - } - activation, err := newSolutionAuthorityActivation(reconciler, config.Envelopes) - if err != nil { - return nil, err - } - reconciler.activation = activation - approvedBuilds, err := NewApprovedBuildReconciler(activation) - if err != nil { - return nil, err - } - reconciler.approvedBuilds = approvedBuilds - modulePrincipals, err := NewModulePrincipalReconciler(activation) - if err != nil { - return nil, err - } - reconciler.modulePrincipals = modulePrincipals - reconciler.trigger = make(chan struct{}, 1) + service: service, + source: config.Source, + coordinate: config.Coordinate, + verifier: config.Verifier, + interval: interval, + now: func() time.Time { return time.Now().UTC() }, + trigger: make(chan struct{}, 1), + } + reconciler.modulePrincipals = &ModulePrincipalReconciler{reconciler: reconciler, envelopes: config.Envelopes} return reconciler, nil } -// ApprovedBuilds is the ExecutionAuthority a host wires into the mint: what -// this host approves for a principal, as the last successful pass established -// it. -// -// Exposed from here for the same reason AuthorityActivation is — the reconciler -// already holds every policy input, and a second reader built beside it would -// be a second answer to what this host approves. -func (r *SolutionHostBindingReconciler) ApprovedBuilds() ExecutionAuthority { - if r == nil { - return nil - } - return r.approvedBuilds -} - -// AuthorityActivation is the seam a capability-minting path asks "is the -// delivered authority over this binding active for the build I have established -// this caller is running". -// -// It is exposed rather than consumed here because the host does not APPLY -// authority to anything: there is no durable state an activation reconciles -// into, and a reconcile pass that logged the answer every interval would be -// noise rather than an effect. The answer is a read, and the caller that needs -// it is the one that has independently established a running build. -func (r *SolutionHostBindingReconciler) AuthorityActivation() *SolutionAuthorityActivation { - if r == nil { - return nil - } - return r.activation -} - -// RunOnce reads the mount and reconciles one pass. +// RunOnce reads the inbox and reconciles one pass. // // The pass is ordered so that a refusal can never disturb what is running. -// Everything is read and judged before anything is written: the mount, then the -// durable applied state, then core's verdict on the whole desired set. Only then -// is desired state recorded — for every delivered binding, admitted or not, so a -// withheld generation and its reason are visible — and only then is an admitted -// generation applied. +// Everything is read and judged before anything is written: the inbox, then the +// durable applied state, then core's verdict on each delivered manifest. Only +// then is desired state recorded — for every delivered binding, admitted or +// not, so a withheld generation and its reason are visible — and only then is +// an admitted generation applied. func (r *SolutionHostBindingReconciler) RunOnce(ctx context.Context) error { documents, err := r.source.Documents(ctx) if err != nil { - // An unreadable mount is not an empty desired set. Nothing is recorded + // An unreadable inbox is not an empty desired set. Nothing is recorded // and nothing is applied: the last generation that passed keeps running. return fmt.Errorf("read delivered solution host bindings: %w", err) } @@ -396,10 +328,7 @@ func (r *SolutionHostBindingReconciler) RunOnce(ctx context.Context) error { if err != nil { return err } - host := r.host - host.Applied = appliedStates(records) - - admission := admitSolutionHostBindings(host, verified) + admission := admitSolutionHostBindings(r.coordinate, r.service.moduleNamespaces, records, verified) // Desired state is recorded for every delivered document before anything is // applied, so an operator sees what delivery is showing even when none of it @@ -411,7 +340,7 @@ func (r *SolutionHostBindingReconciler) RunOnce(ctx context.Context) error { // apply owns its reason. settled := make(map[string]bool, len(admission.Accepted)) for _, accepted := range admission.Accepted { - settled[accepted.Document.Binding] = accepted.Decision == solutionhost.DecisionCurrent + settled[accepted.Document.Binding] = accepted.Decision == solutionhost.DecisionReplay } var failures []error for _, one := range verified { @@ -425,16 +354,14 @@ func (r *SolutionHostBindingReconciler) RunOnce(ctx context.Context) error { } for _, applied := range orderSolutionHostBindingApplies(admission.Accepted, records) { - if applied.Decision != solutionhost.DecisionApply { - // DecisionCurrent: this exact generation is already applied. A host - // re-reads its mount every pass, so this is the normal answer and + if applied.Decision != solutionhost.DecisionAccept { + // DecisionReplay: this exact generation is already applied. A host + // re-reads its inbox every pass, so this is the normal answer and // not an error. Desired state was recorded above; there is nothing // to change. continue } - if err := r.service.applySolutionHostBinding( - ctx, applied.Delivered, r.host.Coordinate, r.host.Domains, r.host.DomainsBySigner, r.now(), - ); err != nil { + if err := r.service.applySolutionHostBinding(ctx, applied.Delivered, r.coordinate, r.now()); err != nil { // One binding's apply failing leaves every other binding's progress // intact: each apply is its own transaction, because each binding is // independent desired state. The reason is recorded as this @@ -449,21 +376,9 @@ func (r *SolutionHostBindingReconciler) RunOnce(ctx context.Context) error { } } - // THE APPROVED-BUILD VIEW, from the same inbox, in the same pass. It is - // refreshed AFTER the applies rather than before: a generation that has just - // been applied is the one whose approved build the mint will be asked about, - // and refreshing first would serve the previous answer for one whole - // interval. Its failures join the pass's, so a principal this host refused - // to approve is visible in the same place as a binding it refused to apply. - if r.approvedBuilds != nil { - if err := r.approvedBuilds.RunOnce(ctx); err != nil { - failures = append(failures, fmt.Errorf("refresh the approved-build view: %w", err)) - } - } - - // THE MODULE REGISTRY, last: it reads the presence this pass just applied - // and the same authority inbox, so deriving it before the applies would - // serve the previous generation's workload for one whole interval. + // THE MODULE REGISTRY, last: it reads the manifests this pass just + // applied, so deriving it before the applies would serve the previous + // generation's workloads and authority for one whole interval. if r.modulePrincipals != nil { if err := r.modulePrincipals.RunOnce(ctx); err != nil { failures = append(failures, fmt.Errorf("derive the module principal registry: %w", err)) @@ -573,8 +488,8 @@ func (r *SolutionHostBindingReconciler) Shutdown(ctx context.Context) error { // not. // // Every refusal here is one of three things, and they are deliberately not -// distinguished in what is recorded: not a carrier, a carrier whose bundle does -// not verify, or a verified payload core will not parse. An operator needs to +// distinguished in what is recorded: not a carrier, a carrier whose envelope does +// not verify, or a verified payload core will not read. An operator needs to // know delivery is shipping something this host will not admit; which of the // three it is tells an attacker which half of the door it got past, so the // reason recorded against a binding stays coarse while the returned error — which @@ -611,13 +526,12 @@ func verifySolutionHostBindingDocuments( }) continue } - document, err := one.Document() + document, err := one.Manifest() if err != nil { - // core parsed these same bytes inside VerifyDelivered, so this is + // core read these same bytes inside VerifyDelivered, so this is // unreachable by construction rather than merely unlikely. It is // still handled as a refusal: a host that cannot read back what it - // just verified must admit nothing from it, and the alternative is - // a nil document reaching admission. + // just verified must admit nothing from it. problems = append(problems, unparsedSolutionHostBinding{ binding: attributeSolutionHostBinding(carrier.Document), err: fmt.Errorf("delivered solution host binding %s: %w", delivered.Source, err), @@ -630,25 +544,13 @@ func verifySolutionHostBindingDocuments( return verified, problems } -// deliveredSolutionHostBinding pairs an attested carrier with the document -// re-derived from its attested bytes, derived ONCE per pass. -// -// core 67ee7220 made Delivered.Document() a derivation returning an error -// rather than a field returning a pointer, because the pointer form let a -// caller mutate what Admit would afterwards consume — the attestation covered -// one generation and admission consumed another, with nothing anywhere saying -// so. Two consequences land here. A derivation that can fail cannot be read -// inside a sort comparator, and re-reading it per use would re-parse and -// canonically round-trip the same bytes once for every question asked of them. -// -// Holding the result is safe in a way holding the old pointer was not: Admit -// re-derives from the attested payload itself, so this copy is what the pass -// READS and cannot become what the host admits. That is the property core's -// redesign bought, and pairing the two values here is what makes the pass use -// one document rather than a fresh one per call site. +// deliveredSolutionHostBinding pairs an attested carrier with the manifest +// re-derived from its attested bytes, derived ONCE per pass. The apply +// re-derives its own from the carrier, so this copy is what the pass READS and +// cannot become what the host applies. type deliveredSolutionHostBinding struct { Delivered *solutionhost.Delivered - Document *solutionhost.SolutionHostBinding + Document *solutionhost.ModuleManifest } // unparsedSolutionHostBinding is a delivered document core would not parse, and @@ -672,16 +574,6 @@ func attributeSolutionHostBinding(data []byte) string { return named.Binding } -func appliedStates(records []*SolutionHostBindingRecord) []solutionhost.Applied { - applied := make([]solutionhost.Applied, 0, len(records)) - for _, record := range records { - if state, ok := record.AppliedState(); ok { - applied = append(applied, state) - } - } - return applied -} - // orderSolutionHostBindingApplies puts the generations that RELEASE a registry // key before the ones that claim one. // @@ -707,12 +599,7 @@ func orderSolutionHostBindingApplies( if candidate.Document.Removed { return true } - for _, alias := range candidate.Document.Aliases() { - if alias == current { - return false - } - } - return true + return candidate.Document.Alias != current } ordered := make([]SolutionHostBindingDecision, 0, len(accepted)) for _, candidate := range accepted { @@ -728,21 +615,16 @@ func orderSolutionHostBindingApplies( return ordered } -// solutionHostBindingRegistryKey resolves the registry record a present -// generation reconciles into. +// solutionHostBindingRegistryKey resolves the registry record a live manifest +// reconciles into. // -// The key is the binding's route alias, because the alias is what this host +// The key is the manifest's route alias, because the alias is what this host // routes on: it is the in /solutions//* and the key the frontend loads // a remote under. The binding ID is deliberately not the key — it identifies a -// deployment instance, may carry characters a path segment may not, and a second -// instance of the same solution inherits nothing from the first, alias included. -func solutionHostBindingRegistryKey(document *solutionhost.SolutionHostBinding) (string, error) { - aliases := document.Aliases() - if len(aliases) != 1 { - return "", fmt.Errorf("%w: binding %q declares %d", - ErrSolutionHostBindingRouteRequired, document.Binding, len(aliases)) - } - alias := aliases[0] +// deployment instance, and a second instance of the same solution inherits +// nothing from the first, alias included. +func solutionHostBindingRegistryKey(document *solutionhost.ModuleManifest) (string, error) { + alias := document.Alias if !solutionIDPattern.MatchString(alias) || len(alias) > maxSolutionIDLength { return "", fmt.Errorf("%w: binding %q declares route %q, which is not one lowercase path segment", ErrSolutionHostBindingRouteNotAddressable, document.Binding, alias) diff --git a/module/services/accounts/code/pkg/business/solution_host_bindings_apply.go b/module/services/accounts/code/pkg/business/solution_host_bindings_apply.go index 2d1f5b339..2d406815b 100644 --- a/module/services/accounts/code/pkg/business/solution_host_bindings_apply.go +++ b/module/services/accounts/code/pkg/business/solution_host_bindings_apply.go @@ -32,7 +32,7 @@ func (s *Service) ListSolutionHostBindings(ctx context.Context) ([]*SolutionHost if err != nil { return err } - return s.attachAppliedEndpointDeliveries(ctx, records) + return nil }) if err != nil { return nil, fmt.Errorf("list solution host bindings: %w", err) @@ -48,7 +48,7 @@ func (s *Service) ListSolutionHostBindings(ctx context.Context) ([]*SolutionHost // activating: the last generation that passed every check goes on serving, and // what delivery wants plus the reason it was refused sit beside it. func (s *Service) recordSolutionHostBindingDesired( - ctx context.Context, document *solutionhost.SolutionHostBinding, + ctx context.Context, document *solutionhost.ModuleManifest, withheldReason string, settled bool, now time.Time, ) error { digest, err := document.Digest() @@ -74,7 +74,6 @@ func (s *Service) recordSolutionHostBindingDesired( record = &SolutionHostBindingRecord{BindingID: document.Binding} } record.HostCoordinate = document.Host.Coordinate - record.HostComponent = document.Host.Component record.Desired = &desired // A withheld document's reason is recorded here. An admitted one's is // NOT cleared here unless the generation is already applied (settled): @@ -178,13 +177,12 @@ func pendingSince(record *SolutionHostBindingRecord, now time.Time) *time.Time { // generation in between. core answers DecisionCurrent for that, which is not an // error and writes nothing — only a rewrite of an applied generation is. func (s *Service) applySolutionHostBinding( - ctx context.Context, delivered *solutionhost.Delivered, - coordinate string, domains []string, domainsBySigner map[string][]string, now time.Time, + ctx context.Context, delivered *solutionhost.Delivered, coordinate string, now time.Time, ) error { // Re-derived here rather than taken from the pass: apply owns its own // read of the attested bytes, so there is no second value a caller could // have adjusted between admission and the write. - document, err := delivered.Document() + document, err := delivered.Manifest() if err != nil { return err } @@ -241,31 +239,24 @@ func (s *Service) applySolutionHostBinding( if record == nil { record = &SolutionHostBindingRecord{BindingID: document.Binding} } - // core judges this one document against this one binding's applied - // state. The set-wide rules — alias collisions, a binding declared - // twice — were settled by the pass, and the partial unique index on the - // registry key is the durable backstop for two replicas racing. - host := solutionhost.Host{ - Coordinate: coordinate, - Domains: domains, - DomainsBySigner: domainsBySigner, - Reserved: []string{SolutionHostReservedRouteNamespace}, - } - if state, ok := record.AppliedState(); ok { - host.Applied = []solutionhost.Applied{state} - } - // The VERIFIED value, not the document read out of it. core's Admit - // takes *Delivered precisely so a second judgement cannot be made on - // bytes whose attestation was checked once and then dropped on the way - // here. - admissions, err := host.Admit(delivered) + // core judges this manifest against this binding's LOCKED applied + // record. The alias rule was settled by the pass, and the partial unique + // index on the registry key is the durable backstop for two replicas + // racing. + applied, err := record.AppliedManifest() if err != nil { - // One document, so core's set-level error and this document's own - // refusal are the same thing; returning it is what leaves the - // running generation untouched. return err } - if admissions[0].Decision == solutionhost.DecisionCurrent { + decision, err := solutionhost.Admit(coordinate, applied, document) + if err == nil { + err = record.checkOwnershipDomain(document) + } + if err != nil { + // Returning the refusal is what leaves the running generation + // untouched. + return err + } + if decision == solutionhost.DecisionReplay { // Another replica applied it, or this pass raced its own previous // one. Nothing to do, and deliberately not an error — but it is // recorded, because "delivery kept saying the same thing" and @@ -328,7 +319,6 @@ func (s *Service) applySolutionHostBinding( } record.HostCoordinate = document.Host.Coordinate - record.HostComponent = document.Host.Component generation := SolutionHostBindingGeneration{ Generation: document.Generation, Digest: digest, @@ -336,30 +326,19 @@ func (s *Service) applySolutionHostBinding( At: now, } record.Desired = &generation - // The half core owns is built by core. AppliedFrom is defined as "the - // record a host persists after applying a document", so deriving those - // fields here would be a second copy of its rules — and the copy is what - // drifts: presence v2 made the ownership domain a required part of this - // record, and a host that assembled it by hand fed core an applied state - // with no domain, which core refuses. The symptom was not a widened - // check. It was a reconciler that applied its first generation and then - // refused the WHOLE set on every pass afterwards, because one unusable - // applied record makes the set unjudgeable. - // - // Reading it from core instead means the next required field is a - // compile error or a loud refusal here, not a silent omission. - owned, err := solutionhost.AppliedFrom(document) - if err != nil { - return fmt.Errorf("build applied record for binding %q: %w", document.Binding, err) + var routes []string + if !document.Removed { + routes = []string{document.Alias} } record.Applied = &SolutionHostBindingApplied{ SolutionHostBindingGeneration: generation, - Removed: owned.Removed, - Routes: owned.Routes, - Domain: owned.Domain, - // Release is the host's own column: core's Applied does not carry it. - // It identifies the release whose presence this generation declares. - Release: document.Release.Identity(), + Removed: document.Removed, + Routes: routes, + Domain: document.Host.Domain, + Release: document.Release.Identity(), + } + if record.Domain == "" { + record.Domain = document.Host.Domain } if document.Removed { // A tombstone keeps the key it withdrew, so the removal stays @@ -459,7 +438,7 @@ func (s *Service) liveSolutionTargetForBinding(ctx context.Context, bindingID st // declareSolutionRegistration records the binding and immutable target that own // this alias. Only observations for that same declared target may survive. func (s *Service) declareSolutionRegistration( - ctx context.Context, document *solutionhost.SolutionHostBinding, solutionID, targetID string, now time.Time, + ctx context.Context, document *solutionhost.ModuleManifest, solutionID, targetID string, now time.Time, ) (int64, error) { current, err := s.store.GetSolutionRegistrationForUpdate(ctx, solutionID) if err != nil { @@ -546,7 +525,7 @@ func (s *Service) declareSolutionRegistration( // generation actually became. func (s *Service) withdrawDeclaredSolutionRegistration( ctx context.Context, record *SolutionHostBindingRecord, - document *solutionhost.SolutionHostBinding, closedTargetID string, now time.Time, + document *solutionhost.ModuleManifest, closedTargetID string, now time.Time, ) (int64, error) { if record.Applied == nil || record.Applied.SolutionID == "" { // A removal for a binding this host never applied. The generation is @@ -678,7 +657,7 @@ func solutionHostBindingResource(record *SolutionHostBindingRecord) string { // and performing that with nothing appended is the unwitnessed narrowing the // protocol forbids outright. func (s *Service) reconcileSolutionTarget( - ctx context.Context, document *solutionhost.SolutionHostBinding, + ctx context.Context, document *solutionhost.ModuleManifest, solutionID, witnessedTargetClose string, now time.Time, ) (string, error) { live, err := s.store.GetLiveSolutionTargetForUpdate(ctx, document.Binding) diff --git a/module/services/accounts/code/pkg/business/solution_host_bindings_integration_test.go b/module/services/accounts/code/pkg/business/solution_host_bindings_integration_test.go index 0db5cfac0..6260d9e80 100644 --- a/module/services/accounts/code/pkg/business/solution_host_bindings_integration_test.go +++ b/module/services/accounts/code/pkg/business/solution_host_bindings_integration_test.go @@ -35,7 +35,7 @@ func (d *deliveredSet) Documents(context.Context) ([]business.SolutionHostBindin return d.documents, nil } -func (d *deliveredSet) put(t *testing.T, document *solutionhost.SolutionHostBinding) { +func (d *deliveredSet) put(t *testing.T, document *solutionhost.ModuleManifest) { t.Helper() d.documents = []business.SolutionHostBindingDocument{{ Source: "mount/" + document.Binding + ".codefly.yaml", @@ -50,11 +50,10 @@ const testSignerIdentity = "example-release" // canonical bytes verbatim, plus a DSSE envelope over them. Admission takes only // verified values, so a mount that handed over bare documents would be refused — // which is the behaviour TestSolutionHostBindingRefusesAnUnsignedDocument pins. -func testCarrier(t *testing.T, document *solutionhost.SolutionHostBinding) []byte { +func testCarrier(t *testing.T, document *solutionhost.ModuleManifest) []byte { t.Helper() - // CanonicalBytes, not Marshal: the signing input is the canonical JSON, and - // PresenceFromVerified refuses a payload that is not the canonical encoding - // of the document it decodes to. + // The signing input is the canonical JSON: core refuses a payload that is + // not the canonical encoding of the manifest it decodes to. canonical, err := solutionhost.SignedPayload(document) if err != nil { t.Fatalf("canonical bytes for %s: %v", document.Binding, err) @@ -69,80 +68,41 @@ const testHostCoordinate = "acme/test/eu-west-1" // unseen binding ID under a domain of its own choosing. const testOwnershipDomain = "acme" -// declaredBinding builds a present generation for one solution on this host. The -// binding ID is dotted and the alias is a single lowercase segment, which is the -// shape the renderer commits to (codefly-dev/cli#853): the binding ID is never -// the registry key. -func declaredBinding(t *testing.T, solutionID string, generation uint64) *solutionhost.SolutionHostBinding { +// declaredBinding builds a live module manifest for one solution on this host. +// The binding ID is dotted and the alias is a single lowercase segment, which +// is the shape the renderer commits to: the binding ID is never the registry +// key. +func declaredBinding(t *testing.T, solutionID string, generation uint64) *solutionhost.ModuleManifest { t.Helper() - document := &solutionhost.SolutionHostBinding{ - Schema: solutionhost.SchemaPresenceV1, - Kind: solutionhost.KindSolution, - Binding: "acme.test." + solutionID, - Generation: generation, - OwnershipDomain: testOwnershipDomain, - EnvelopeRevision: 1, - Host: solutionhost.HostTarget{Coordinate: testHostCoordinate, Component: "saas-host"}, - // Three digests, three distinct values. Presence v2 requires the release - // digest — a generation that omits it is one no authority document can be - // matched to — and core types the three separately so that comparing a - // rendered digest to an image digest is a compile error. Giving them - // different bytes here is what keeps a test from passing on a mix-up the - // types happen not to catch. - Release: solutionhost.Release{ - Publisher: "acme", Name: solutionID, Version: "1.4.0", - Digest: solutionhost.ReleaseDigest("sha256:" + strings.Repeat("ef", 32)), - }, - Routes: []solutionhost.Route{{Alias: solutionID, Surface: solutionhost.SurfaceBackend}}, - Artifacts: []solutionhost.Artifact{{ - Surface: solutionhost.SurfaceBackend, - Name: "api", - Release: "acme/" + solutionID + "@1.4.0", - Digest: solutionhost.RenderedDigest("sha256:" + strings.Repeat("ab", 32)), - }}, - Workloads: []solutionhost.Workload{{ - Name: "api", - Artifact: "api", - Container: "api", - Image: solutionhost.Image{ - Repository: "registry.example/acme/" + solutionID, - Digest: solutionhost.ImageDigest("sha256:" + strings.Repeat("cd", 32)), - }, - Identity: solutionhost.WorkloadIdentity{ - Audience: "https://test.acme.example/solutions", - Subject: "system:serviceaccount:" + solutionID + ":api", - SPIFFEID: "spiffe://acme.test/ns/" + solutionID + "/sa/api", - }, - // A sidecar that must never be accepted as the authenticating - // container. Core requires this list EXPLICITLY — empty declares - // "there are none", absent is refused — because a plain slice - // cannot tell those apart and a host that cannot tell has to choose - // between refusing every workload and trusting any container that - // asks. Naming one here rather than writing the empty list keeps - // the non-degenerate case in the reconciler's own fixtures. - NonAuthenticating: &[]string{"telemetry-sidecar"}, - }}, + document := &solutionhost.ModuleManifest{ + Schema: solutionhost.SchemaModuleManifestV1, + Kind: solutionhost.KindSolution, + Binding: "acme.test." + solutionID, + Alias: solutionID, + Generation: generation, + Host: solutionhost.HostTarget{Coordinate: testHostCoordinate, Domain: testOwnershipDomain}, + Release: solutionhost.Release{Publisher: "acme", Name: solutionID, Version: "1.4.0"}, + Workloads: []solutionhost.Workload{{Service: "api", Container: "api"}}, + Endpoints: []solutionhost.Endpoint{{Name: "rest", Service: "api", API: "rest", Address: "http://api." + solutionID + ".svc.cluster.local:8080"}}, } if err := document.Validate(); err != nil { - t.Fatalf("test document is not a valid binding: %v", err) + t.Fatalf("test document is not a valid manifest: %v", err) } return document } -func tombstoneOf(t *testing.T, document *solutionhost.SolutionHostBinding, generation uint64) *solutionhost.SolutionHostBinding { +func tombstoneOf(t *testing.T, document *solutionhost.ModuleManifest, generation uint64) *solutionhost.ModuleManifest { t.Helper() removed := *document removed.Generation = generation - removed.Routes = nil - removed.Artifacts = nil removed.Workloads = nil - removed.Modules = nil removed.Endpoints = nil + removed.Authority = nil removed.Frontend = nil removed.Group = nil removed.Removed = true if err := removed.Validate(); err != nil { - t.Fatalf("test tombstone is not valid: %v", err) + t.Fatalf("test withdrawal is not valid: %v", err) } return &removed } @@ -156,12 +116,10 @@ func newTestReconciler(t *testing.T, source business.SolutionHostBindingSource) t.Helper() reconciler, err := business.NewSolutionHostBindingReconciler(testService, business.SolutionHostBindingReconcilerConfig{ - Source: source, - Verifier: deliverytest.Verifier(t, map[string][]string{testSignerIdentity: {testOwnershipDomain}}), - Coordinate: testHostCoordinate, - Domains: []string{testOwnershipDomain}, - DomainsBySigner: map[string][]string{testSignerIdentity: {testOwnershipDomain}}, - Interval: time.Minute, + Source: source, + Verifier: deliverytest.Verifier(t, map[string][]string{testSignerIdentity: {testOwnershipDomain}}), + Coordinate: testHostCoordinate, + Interval: time.Minute, }) if err != nil { t.Fatalf("reconciler: %v", err) @@ -578,9 +536,8 @@ func TestSolutionHostBinding_AnEmptyMountRemovesNothing(t *testing.T) { func TestSolutionHostBinding_ABindingRefusedAtItsFirstGenerationIsStillVisible(t *testing.T) { solutionID := testDeclaredSolutionID(t) unaddressable := declaredBinding(t, solutionID, 1) - unaddressable.Routes[0].Alias = solutionID + ".internal" - unaddressable.Artifacts[0].Surface = solutionhost.SurfaceBackend - unaddressable.Routes[0].Surface = solutionhost.SurfaceBackend + unaddressable.Alias = solutionID + ".internal" + unaddressable.Endpoints[0].Address = "http://api." + unaddressable.Alias + ".svc.cluster.local:8080" mount := &deliveredSet{} mount.put(t, unaddressable) @@ -616,7 +573,8 @@ func TestSolutionHostBinding_ABindingRefusedAtItsFirstGenerationIsStillVisible(t func TestSolutionHostBinding_AStandingRefusalKeepsWhenItStarted(t *testing.T) { solutionID := testDeclaredSolutionID(t) unaddressable := declaredBinding(t, solutionID, 1) - unaddressable.Routes[0].Alias = solutionID + ".internal" + unaddressable.Alias = solutionID + ".internal" + unaddressable.Endpoints[0].Address = "http://api." + unaddressable.Alias + ".svc.cluster.local:8080" mount := &deliveredSet{} mount.put(t, unaddressable) reconciler := newTestReconciler(t, mount) @@ -771,8 +729,10 @@ func TestSolutionTarget_ARenameKeepsTheIdentityAndMovesTheAlias(t *testing.T) { before := liveTarget(t, document.Binding) renamed := declaredBinding(t, first, 2) - renamed.Routes[0].Alias = second - renamed.Artifacts[0].Release = renamed.Release.Identity() + renamed.Alias = second + // The module's namespace is its alias by the rule, so a rename moves its + // endpoints' Service with it. + renamed.Endpoints[0].Address = "http://api." + second + ".svc.cluster.local:8080" mount.put(t, renamed) if err := reconciler.RunOnce(testCtx); err != nil { t.Fatalf("apply the rename: %v", err) @@ -917,7 +877,7 @@ func TestSolutionTarget_RePresentingAWithdrawnBindingIsRefused(t *testing.T) { if record == nil { t.Fatal("the withdrawn binding lost its record") } - if !strings.Contains(record.PendingReason, solutionhost.ErrTombstoned.Error()) { + if !strings.Contains(record.PendingReason, solutionhost.ErrWithdrawn.Error()) { t.Fatalf("pending reason = %q, want it to name the terminal tombstone", record.PendingReason) } if record.Applied == nil || !record.Applied.Removed { @@ -990,11 +950,9 @@ func testReconcilerAcceptingDomains( Source: source, // The key is pinned for every domain the host grants it, so only // the rule under test can refuse. - Verifier: deliverytest.Verifier(t, map[string][]string{testSignerIdentity: domains}), - Coordinate: testHostCoordinate, - Domains: domains, - DomainsBySigner: map[string][]string{testSignerIdentity: domains}, - Interval: time.Minute, + Verifier: deliverytest.Verifier(t, map[string][]string{testSignerIdentity: domains}), + Coordinate: testHostCoordinate, + Interval: time.Minute, }) if err != nil { t.Fatalf("reconciler: %v", err) @@ -1048,7 +1006,7 @@ func TestSolutionHostBinding_ADomainChangeDoesNotTakeOverAnAppliedBinding(t *tes // The same binding ID, a newer generation, a different owner. takeover := declaredBinding(t, solutionID, 5) - takeover.OwnershipDomain = otherDomain + takeover.Host.Domain = otherDomain mount.put(t, takeover) if err := testReconcilerAcceptingDomains(t, mount, testOwnershipDomain, otherDomain).RunOnce(testCtx); err != nil { t.Fatalf("a withheld document must not fail the pass: %v", err) diff --git a/module/services/accounts/code/pkg/business/solution_presence_test_helpers_test.go b/module/services/accounts/code/pkg/business/solution_presence_test_helpers_test.go index 5c104d61c..c4ba5ddd2 100644 --- a/module/services/accounts/code/pkg/business/solution_presence_test_helpers_test.go +++ b/module/services/accounts/code/pkg/business/solution_presence_test_helpers_test.go @@ -83,7 +83,6 @@ func declareCapabilityPresence(t *testing.T, bindingID, alias string) string { return testStore.SaveSolutionHostBinding(ctx, &business.SolutionHostBindingRecord{ BindingID: bindingID, HostCoordinate: "acme/local/host", - HostComponent: "saas-starter", Desired: &entry, Applied: &business.SolutionHostBindingApplied{ SolutionHostBindingGeneration: entry, diff --git a/module/services/accounts/code/pkg/business/solution_registry_declaration_test.go b/module/services/accounts/code/pkg/business/solution_registry_declaration_test.go index c221fa134..2fcd11b09 100644 --- a/module/services/accounts/code/pkg/business/solution_registry_declaration_test.go +++ b/module/services/accounts/code/pkg/business/solution_registry_declaration_test.go @@ -50,7 +50,7 @@ func TestDeclaredPresenceWritesItsOwnFrontendBlock(t *testing.T) { Frontend: &SolutionFrontendHalf{Manifest: "{}"}, }} svc := &Service{store: store} - document := &solutionhost.SolutionHostBinding{Binding: "acme.test.example", Generation: 2, + document := &solutionhost.ModuleManifest{Binding: "acme.test.example", Generation: 2, Kind: solutionhost.KindSolution, Release: solutionhost.Release{Publisher: "acme", Name: "example", Version: "1.0.0"}, Frontend: tc.frontend} @@ -90,7 +90,7 @@ func TestTheDeclaredKindReachesTheRegistryRecord(t *testing.T) { t.Run(string(tc.kind), func(t *testing.T) { store := &declarationProjectionStore{} svc := &Service{store: store} - document := &solutionhost.SolutionHostBinding{ + document := &solutionhost.ModuleManifest{ Binding: "acme.test.example", Generation: 2, Kind: tc.kind, Release: solutionhost.Release{Publisher: "acme", Name: "example", Version: "1.0.0"}, } @@ -122,7 +122,7 @@ func TestDeclaredSolutionGroupFollowsAppliedGenerationWithoutChangingTarget(t *t Group: &SolutionGroup{ID: "operations", Title: "Operations", Order: 1}, }} svc := &Service{store: store} - document := &solutionhost.SolutionHostBinding{ + document := &solutionhost.ModuleManifest{ Binding: "acme.test.example", Generation: 2, Kind: solutionhost.KindSolution, Release: solutionhost.Release{Publisher: "acme", Name: "example", Version: "1.0.0"}, Group: tc.group, } @@ -147,7 +147,7 @@ func TestWithdrawalClearsSolutionGroup(t *testing.T) { now := time.Now() _, err := svc.withdrawDeclaredSolutionRegistration(context.Background(), &SolutionHostBindingRecord{BindingID: "acme.test.example", Applied: &SolutionHostBindingApplied{SolutionID: "example"}}, - &solutionhost.SolutionHostBinding{Binding: "acme.test.example", Generation: 2, Kind: solutionhost.KindSolution, Removed: true, + &solutionhost.ModuleManifest{Binding: "acme.test.example", Generation: 2, Kind: solutionhost.KindSolution, Removed: true, Release: solutionhost.Release{Publisher: "acme", Name: "example", Version: "1.0.0"}}, "target", now) require.NoError(t, err) require.Nil(t, store.saved.Group) @@ -170,7 +170,7 @@ func TestADeclaredKindThisHostDoesNotRouteIsRefused(t *testing.T) { t.Run(string("kind="+kind), func(t *testing.T) { store := &declarationProjectionStore{} svc := &Service{store: store} - document := &solutionhost.SolutionHostBinding{ + document := &solutionhost.ModuleManifest{ Binding: "acme.test.example", Generation: 2, Kind: kind, Release: solutionhost.Release{Publisher: "acme", Name: "example", Version: "1.0.0"}, } @@ -213,8 +213,8 @@ func (s *countingDeclarationStore) LiveDeclaredBindings(context.Context) ([]Live // solution becoming addressable), and a withdrawal REMOVES one (a stale snapshot // keeps a withdrawn solution addressable for up to the bound). func TestTheApplyPathInvalidatesTheAudienceSnapshot(t *testing.T) { - document := func(kind solutionhost.Kind) *solutionhost.SolutionHostBinding { - return &solutionhost.SolutionHostBinding{ + document := func(kind solutionhost.Kind) *solutionhost.ModuleManifest { + return &solutionhost.ModuleManifest{ Binding: "acme.test.example", Generation: 2, Kind: kind, Release: solutionhost.Release{Publisher: "acme", Name: "example", Version: "1.0.0"}, } diff --git a/module/services/accounts/code/pkg/business/source_delegation.go b/module/services/accounts/code/pkg/business/source_delegation.go index 6841e4dc2..197c0357e 100644 --- a/module/services/accounts/code/pkg/business/source_delegation.go +++ b/module/services/accounts/code/pkg/business/source_delegation.go @@ -688,14 +688,12 @@ func (s *Service) AuthorizeDelegationReferenceExchange( // audience the delegation's binding names — so its hop attests the build // its own pod is running, and the delegation is the operation binding the // hop exercises (business.SourceDelegationBindingID), read live by every - // verifier. The caller authenticated with its module Work Context, whose - // seal names the build its pod was bound to at that mint and which core - // has just held against the approved build again. + // verifier. The caller authenticated with its module Work Context. authority = SourceOperationContextAuthority{ ModuleWorkContextAuthority: ModuleWorkContextAuthority{ PrincipalID: caller.PrincipalID, Tenant: delegation.OrgID, InstallationID: callerAuthority.InstallationID, InstallationRevision: callerAuthority.InstallationRevision, - ProducerEpoch: callerAuthority.ProducerEpoch, Execution: caller.SealedExecution(), + ProducerEpoch: callerAuthority.ProducerEpoch, }, ModulePrefix: grant.Prefix, OwnerPrincipalID: delegation.PrincipalID, diff --git a/module/services/accounts/code/pkg/business/story_host_test.go b/module/services/accounts/code/pkg/business/story_host_test.go index f31488515..bd4b7261d 100644 --- a/module/services/accounts/code/pkg/business/story_host_test.go +++ b/module/services/accounts/code/pkg/business/story_host_test.go @@ -104,7 +104,6 @@ func TestStory_HOST_ID_001(t *testing.T) { require.Equal(t, personID, verified.Context().GetOwnerPrincipalId()) require.NotEmpty(t, verified.Context().GetSeal().GetInstallationId(), "sealed to the organization's installation of the module") - require.Empty(t, verified.Context().GetSeal().GetImageDigest(), "a person bears no execution") // And a Work Context minted for another audience is refused. _, err = verify("billing", tenantA) @@ -333,14 +332,6 @@ func (s storySeals) PrincipalEpoch(ctx context.Context, principalID string) (uin return testService.WorkContextPrincipalEpoch(ctx, s.tenant, principalID) } -func (s storySeals) ApprovedBuild(ctx context.Context, principalID string) (string, uint64, error) { - digest, incarnation, err := testService.WorkContextApprovedBuild(ctx, s.tenant, principalID) - if errors.Is(err, business.ErrBearsNoExecution) { - return "", 0, workcontext.ErrNoApprovedBuild - } - return digest, incarnation, err -} - func (storySeals) OperationBinding(context.Context, string) (workcontext.OperationBinding, error) { return workcontext.OperationBinding{}, workcontext.ErrNoBinding } diff --git a/module/services/accounts/code/pkg/business/work_context_seal.go b/module/services/accounts/code/pkg/business/work_context_seal.go index 1e6f235f7..84ed9f495 100644 --- a/module/services/accounts/code/pkg/business/work_context_seal.go +++ b/module/services/accounts/code/pkg/business/work_context_seal.go @@ -46,10 +46,6 @@ var ErrWorkContextPrincipalUnknown = errors.New("the principal has no authorizat // bound to and is refused. var ErrWorkContextNoAudienceInstallation = errors.New("the audience names no installation active in this organization") -// ErrBearsNoExecution is WorkContextApprovedBuild's answer for a principal that -// bears no execution. The adapter translates it to core's own sentinel. -var ErrBearsNoExecution = errors.New("the principal bears no execution") - // WorkContextSealStore is the row-backed half: a person's or a registered // principal's seal, and the organization's installation of a solution. type WorkContextSealStore interface { @@ -146,39 +142,6 @@ func (s *Service) WorkContextPrincipalEpoch(ctx context.Context, orgID, principa return store.WorkContextPrincipalEpoch(ctx, orgID, principalID) } -// WorkContextApprovedBuild answers the execution principalID exercises. -// -// A module principal ALWAYS bears one: a known module with no approved build is -// refused, exactly as BindExecution refuses it, never answered as "bears none" — -// that answer would let a module capability verify with no execution at all. -// A person or registered agent known to the organization bears none, and an -// unknown principal is refused. -func (s *Service) WorkContextApprovedBuild(ctx context.Context, orgID, principalID string) (string, uint64, error) { - if snapshot := s.moduleRegistrySnapshot(); snapshot != nil { - if grant, declared := snapshot.registry[principalID]; declared { - if s.executionAuthority == nil { - return "", 0, ErrExecutionUnbound - } - // Keyed by the PREFIX, which is the name the authority document - // gives the principal — exactly as BindExecution asks — never by - // this host's registry UUID, which no document carries. - approved, incarnation, err := s.executionAuthority.ApprovedBuild(ctx, grant.Prefix) - if err != nil { - return "", 0, err - } - digest, ok := digestPortion(string(approved)) - if !ok { - return "", 0, fmt.Errorf("%w: the approved build for %s names no digest", ErrExecutionNotApproved, grant.Prefix) - } - return digest, incarnation, nil - } - } - if _, err := s.WorkContextPrincipalEpoch(ctx, orgID, principalID); err != nil { - return "", 0, err - } - return "", 0, ErrBearsNoExecution -} - // WorkContextRevision answers the authorization revision a capability owned by // ownerID carries. // diff --git a/module/services/accounts/code/pkg/infra/approved_build_reconcile_test.go b/module/services/accounts/code/pkg/infra/approved_build_reconcile_test.go deleted file mode 100644 index 0c5fbc3e6..000000000 --- a/module/services/accounts/code/pkg/infra/approved_build_reconcile_test.go +++ /dev/null @@ -1,436 +0,0 @@ -//go:build !pure - -package infra_test - -import ( - "testing" - "time" - - "accounts/pkg/business" - - "github.com/codefly-dev/core/solutionhost" - "github.com/stretchr/testify/require" -) - -// THE APPROVED-BUILD VIEW, from the inbox the delivery endpoint writes. -// -// The defect these cover is an absence, like activation's: `MonotonicApprovedBuilds` -// held the authoritative view, enforced the ordering rule over it, and NOTHING -// EVER WROTE TO IT. Every principal was therefore unknown, `ApprovedBuild` -// refused every one, and so `BindExecution` — the whole execution-binding -// mechanism, built and unit-tested — could not answer a single question on a -// running host. A reviewer reading those unit tests and that guard concludes the -// check is enforced. -// -// Driven through the REAL delivery handler and read back out of real Postgres, -// so what is under test is the host's path from "a carrier arrived" to "this -// principal is approved for this build" — not a hand-assembled document handed -// to the reconciler, which would prove only that the reconciler reads a struct. -// -// THE INBOX IS HOST-WIDE, and that is not a test artifact — it is the fact that -// shaped the design. Every test in this package delivers into one inbox, and so -// does every solution in a running host. A pass therefore always reads -// documents it is not about, and `RunOnce` returns the reasons for every -// principal it refused, including other solutions'. So these tests assert on -// the VIEW, which is the outcome, and check separately that their own -// principals are not among the refusals. A test that required RunOnce to return -// no error at all would be asserting that no other solution ever delivers -// anything questionable. - -// TestDeliveredAuthorityPopulatesTheApprovedBuildView is the regression test for -// the absence: before a pass the principal is UNKNOWN, after one it is approved -// for exactly what the delivered document approves. -func TestDeliveredAuthorityPopulatesTheApprovedBuildView(t *testing.T) { - scenario := newApprovedBuildScenario(t, "abv") - - // Before any pass: REFUSED, and refused as "no pass has completed" rather - // than as "unknown principal". A host that has reconciled nothing must not - // mint unbound capabilities for anyone, which is unchanged; what changed is - // that it no longer reports a cold start as a principal it has never heard - // of, because that sends an operator to correct a declaration that is - // already right. - // - // ADAPTED, not reverted: this is a DB-gated test, so it did not run when - // the distinction was introduced, and it is the test that proves the new - // sentinel reaches the real reconciler rather than only the constructed one. - _, _, err := scenario.reconciler.ApprovedBuild(testCtx, scenario.principal) - require.ErrorIs(t, err, business.ErrExecutionAuthorityUnreconciled, - "an unreconciled host must refuse, because an unbound capability for an unknown identity is the permissive answer") - require.NotErrorIs(t, err, business.ErrUnknownExecutionPrincipal, - "and it must not blame the principal for the host still starting") - - scenario.deliverAuthority(t, scenario.authority(t, "a", 3, false, scenario.principal)) - scenario.runPass(t) - - digest, incarnation, err := scenario.reconciler.ApprovedBuild(testCtx, scenario.principal) - require.NoError(t, err) - require.Equal(t, business.ApprovedDigest(scenario.build), digest, - "the approved digest is the signed document's, which is the one input the caller has no influence over") - require.Equal(t, uint64(3), incarnation, - "the incarnation is the authority document's generation, which core guarantees is strictly monotonic per authority id") -} - -// TestWithdrawnAuthorityMakesThePrincipalUnknownAgain is why the view is -// RECONSTRUCTED rather than written to. -// -// `MonotonicApprovedBuilds` has Approve and Declare and no third verb, so a -// withdrawal cannot be recorded into it — and the answer a withdrawal must -// produce is "unknown", not "bears no execution". Those are the two outcomes it -// would be easy to conflate and only one is safe: "bears none" mints a -// capability carrying no execution that every verifier accepts, which is right -// for a human session and wrong for a module whose authority was withdrawn. -func TestWithdrawnAuthorityMakesThePrincipalUnknownAgain(t *testing.T) { - scenario := newApprovedBuildScenario(t, "abw") - - scenario.deliverAuthority(t, scenario.authority(t, "a", 1, false, scenario.principal)) - scenario.runPass(t) - _, _, err := scenario.reconciler.ApprovedBuild(testCtx, scenario.principal) - require.NoError(t, err, "the grant must take effect before its withdrawal can mean anything") - - scenario.deliverAuthority(t, scenario.authority(t, "a", 2, true, scenario.principal)) - scenario.runPass(t) - - _, _, err = scenario.reconciler.ApprovedBuild(testCtx, scenario.principal) - require.ErrorIs(t, err, business.ErrUnknownExecutionPrincipal, - "a withdrawn authority must leave the principal UNKNOWN; answering ErrNoApprovedBuild would let it mint an unbound capability") -} - -// TestARewindAcrossPassesIsRefused is the test a fresh instance per pass cannot -// pass, and the reason the high-water marks outlive the view. -// -// Core's guidance is that a source which must rewind is reconstructing state and -// belongs behind a fresh instance. Applied naively that loses the guard: a fresh -// instance holds no previous record, so each pass accepts its own input as the -// first thing it ever saw, and generation 3 followed by generation 2 is accepted -// twice. The ordering rule is a property of the sequence of PASSES. -func TestARewindAcrossPassesIsRefused(t *testing.T) { - // A withdrawal is terminal for the binding, so the rewind is staged - // without one: authority "a" moves on to another principal, and "b" — a - // different id over the same binding — names this one at a lower - // generation. - other := "oth-" + uniqueAlias("abr") - scenario := newApprovedBuildScenario(t, "abr", other) - - scenario.deliverAuthority(t, scenario.authority(t, "a", 3, false, scenario.principal)) - scenario.runPass(t) - _, incarnation, err := scenario.reconciler.ApprovedBuild(testCtx, scenario.principal) - require.NoError(t, err) - require.Equal(t, uint64(3), incarnation) - - // The inbox keeps the NEWEST generation per document, so a lower generation - // has to arrive under a different authority id to be in the inbox at all — - // which is also the shape that matters, since generations are monotonic per - // authority id and two ids are not comparable. Move the first on to another - // principal so the pass is not merely ambiguous, then deliver the lower one. - scenario.deliverAuthority(t, scenario.authority(t, "a", 4, false, other)) - scenario.deliverAuthority(t, scenario.authority(t, "b", 2, false, scenario.principal)) - - reasons := scenario.reconciler.RunOnce(testCtx) - require.ErrorIs(t, reasons, business.ErrApprovedBuildRewind, - "a generation below the high-water mark must be refused, not accepted as a fresh start") - require.Contains(t, reasons.Error(), scenario.principal, - "the refusal must name the principal it is about, or an operator cannot act on it") - - // AND THE PRINCIPAL IS NOW UNKNOWN rather than still serving generation 3. - // A rewind means delivery no longer claims the generation this host served, - // so continuing to serve it would be answering with something nothing - // claims. Omitted is refused, which is the safe direction. - _, _, err = scenario.reconciler.ApprovedBuild(testCtx, scenario.principal) - require.ErrorIs(t, err, business.ErrUnknownExecutionPrincipal) -} - -// TestAPrincipalGrantedByTwoLiveAuthoritiesIsRefused covers the catch in using -// the generation as the incarnation. -// -// Generations are monotonic PER AUTHORITY ID, so two documents with different -// ids have independent sequences. If both name one principal their numbers are -// incomparable: the view would flap between them, and whenever the numbers -// happen to ascend the principal is silently resealed to the other document's -// build with no counter having gone backwards for the guard to catch. -func TestAPrincipalGrantedByTwoLiveAuthoritiesIsRefused(t *testing.T) { - scenario := newApprovedBuildScenario(t, "aba") - - scenario.deliverAuthority(t, scenario.authority(t, "a", 1, false, scenario.principal)) - scenario.deliverAuthority(t, scenario.authority(t, "b", 1, false, scenario.principal)) - - reasons := scenario.reconciler.RunOnce(testCtx) - require.ErrorIs(t, reasons, business.ErrApprovedBuildAmbiguous, - "two live authority documents granting to one principal have no single generation, so this is refused rather than resolved") - require.Contains(t, reasons.Error(), scenario.principal) - - _, _, err := scenario.reconciler.ApprovedBuild(testCtx, scenario.principal) - require.ErrorIs(t, err, business.ErrUnknownExecutionPrincipal) -} - -// TestOneRefusedPrincipalDoesNotWithholdTheOthers is the correction a test -// found, and it is the reason the refusals are per principal. -// -// The first version of this reconciler installed NOTHING when any principal was -// refused, on the argument that installing the coherent part of an incoherent -// inbox is how a withheld document becomes a quietly narrower system. The inbox -// is host-wide, so that made one solution's bad delivery an outage for every -// module on the host — while being no safer for the principal actually in -// doubt, which is refused either way. -func TestOneRefusedPrincipalDoesNotWithholdTheOthers(t *testing.T) { - // The second principal is named to the constructor so its grant is in the - // ceiling the reconciler holds; see newApprovedBuildScenario. - innocent := "inn-" + uniqueAlias("abp") - scenario := newApprovedBuildScenario(t, "abp", innocent) - - // The scenario's own principal granted twice — ambiguous — and a second - // principal granted once and correctly, in the same inbox. - scenario.deliverAuthority(t, scenario.authority(t, "a", 1, false, scenario.principal)) - scenario.deliverAuthority(t, scenario.authority(t, "b", 1, false, scenario.principal, innocent)) - - reasons := scenario.reconciler.RunOnce(testCtx) - require.ErrorIs(t, reasons, business.ErrApprovedBuildAmbiguous) - - _, _, err := scenario.reconciler.ApprovedBuild(testCtx, scenario.principal) - require.ErrorIs(t, err, business.ErrUnknownExecutionPrincipal, - "the ambiguous principal is refused") - - digest, incarnation, err := scenario.reconciler.ApprovedBuild(testCtx, innocent) - require.NoError(t, err, - "a principal whose documents say exactly one thing must not be refused because an unrelated one is ambiguous") - require.Equal(t, business.ApprovedDigest(scenario.build), digest) - require.Equal(t, uint64(1), incarnation) -} - -// TestAnAuthorityOutsideTheCeilingApprovesNothing is the check that an authentic -// signature is not an approval. -// -// A signed document says who wrote it. Whether what it claims is inside the -// ceiling a platform administrator wrote is a separate question, which core -// keeps as a separate call — and the clause that matters for this view is that -// the build must be one THE ENVELOPE approved. Without it a delivery writer -// approves any image by signing a document naming it, which would defeat the -// whole execution check this view feeds. -func TestAnAuthorityOutsideTheCeilingApprovesNothing(t *testing.T) { - scenario := newApprovedBuildScenario(t, "abc") - - // A build the envelope does not approve. Core's second fixture build is - // well-formed and not this scenario's, so the refusal is about the ceiling - // rather than about a malformed digest. - unapproved := solutionhost.FixtureEnvelope().ApprovedBuilds[1] - require.NotEqual(t, scenario.build, unapproved) - - document := scenario.authority(t, "a", 1, false, scenario.principal) - document.ApprovedBuild = unapproved - require.NoError(t, document.Validate(), - "the document must be VALID and merely outside the ceiling, or this tests document validation instead") - scenario.deliverAuthority(t, document) - - reasons := scenario.reconciler.RunOnce(testCtx) - require.Error(t, reasons) - require.Contains(t, reasons.Error(), "outside this host's ceiling") - - _, _, err := scenario.reconciler.ApprovedBuild(testCtx, scenario.principal) - require.ErrorIs(t, err, business.ErrUnknownExecutionPrincipal, - "a document the ceiling does not cover must approve nothing, however well signed") -} - -// TestAnUnverifiableDocumentApprovesNothing: a row this host can no longer -// verify approves nothing, and says so by name. -// -// It used to make the whole pass unreadable and keep the previous view, on the -// argument that an unverifiable row might be a tombstone whose disappearance -// grants something. Withdrawals are now read from the whole history first and -// independently of verification, so that argument no longer holds — and -// keeping the previous view would keep serving an approval nothing can verify. -func TestAnUnverifiableDocumentApprovesNothing(t *testing.T) { - scenario := newApprovedBuildScenario(t, "abu") - - scenario.deliverAuthority(t, scenario.authority(t, "a", 2, false, scenario.principal)) - scenario.runPass(t) - _, incarnation, err := scenario.reconciler.ApprovedBuild(testCtx, scenario.principal) - require.NoError(t, err) - require.Equal(t, uint64(2), incarnation) - - // A second reconciler over the SAME inbox whose verifier refuses every - // carrier: the host's signer policy moved, which is exactly the case - // re-verification on every pass exists for. - refusing, err := business.NewApprovedBuildReconciler( - activationForVerifier(t, scenario.delivery.service, productionPathCoordinate, - scenario.envelope, &solutionhost.Verifier{})) - require.NoError(t, err) - require.ErrorIs(t, refusing.RunOnce(testCtx), business.ErrSolutionAuthorityUnverifiable) - _, _, err = refusing.ApprovedBuild(testCtx, scenario.principal) - require.ErrorIs(t, err, business.ErrUnknownExecutionPrincipal, - "a document that no longer verifies approves nothing") -} - -// approvedBuildScenario is one solution's delivery, its envelope, and the -// reconciler reading its inbox. -type approvedBuildScenario struct { - delivery *productionPathDelivery - reconciler *business.ApprovedBuildReconciler - envelope *business.ModuleEnvelope - grants []solutionhost.EnvelopeGrant - binding string - build solutionhost.ImageDigest - solutionID string - namespace string - principal string -} - -func newApprovedBuildScenario(t *testing.T, prefix string, extra ...string) *approvedBuildScenario { - t.Helper() - solutionID := uniqueAlias(prefix) - namespace := "ns-" + solutionID - build := fixturePresenceBuild(t) - - delivery := newProductionPathDelivery(t) - presence := productionPathBinding(t, solutionID, namespace, 1) - delivery.callerNamespace = namespace - requireDelivered(t, delivery.post(t, business.SolutionDeliveryPresence, - productionPathCarrier(t, presence))) - - scenario := &approvedBuildScenario{ - delivery: delivery, - binding: presence.Binding, - build: build, - solutionID: solutionID, - namespace: namespace, - // Per scenario, so one test's inbox never makes another's principal - // ambiguous. The shared harness grants to one fixed principal for every - // solution, which is precisely the collision the ambiguity check - // refuses — so these tests cannot reuse it. - principal: "op-" + solutionID, - } - // EVERY GRANT BEFORE THE ACTIVATION. The test's ceiling is built once from - // the grants, so a grant appended afterwards is invisible to it and the - // document naming that principal reads as outside the ceiling. The extra principals - // are therefore constructor arguments rather than something a test adds - // later — the first version of this test added one later and spent a run - // proving that the ceiling check works. - for _, principal := range append([]string{scenario.principal}, extra...) { - scenario.grant(principal) - } - scenario.envelope = moduleCeiling(solutionhost.FixtureEnvelopeRevision, - []solutionhost.ImageDigest{build}, scenario.grants...) - - activation := activationFor(t, delivery.service, productionPathCoordinate, scenario.envelope) - require.NotNil(t, activation) - reconciler, err := business.NewApprovedBuildReconciler(activation) - require.NoError(t, err) - scenario.reconciler = reconciler - return scenario -} - -// grant adds a principal and its own binding to the scenario's ceiling. -// -// Its OWN binding, because core refuses a document in which two principals hold -// one binding ID — "an ID identifies one unit of authority" — so a second -// principal needs a second unit, both in the document and in the envelope that -// has to hold it exactly. -// -// It is unexported and called only from the constructor, because the envelope -// reaches the reconciler by VALUE: a grant appended after the activation is -// built is invisible to it, and the document naming that principal then reads -// as outside the ceiling — a confusing way to fail, and one this test hit -// before the extra principals became constructor arguments. -func (s *approvedBuildScenario) grant(principal string) { - s.grants = append(s.grants, solutionhost.EnvelopeGrant{ - Principal: principal, - Binding: s.bindingFor(principal), - }) -} - -func (s *approvedBuildScenario) bindingFor(principal string) solutionhost.AuthorityBinding { - binding := authorityBindingFor(s.binding, s.namespace) - // One unit of authority per principal, named after it so the two halves - // cannot drift apart — in delivery's convention, so the module envelope - // can admit it. - binding.ID = s.binding + ":reconcile-" + principal + ":invoke" - return binding -} - -// runPass runs a reconcile pass and requires that this scenario's own -// principals are not among whatever it refused. -// -// It deliberately does NOT require the pass to be error-free. The inbox is -// host-wide: a pass reads every other test's documents, and other solutions' -// principals are legitimately refused in it. Requiring no error would make -// every test here depend on every other test's deliveries being beyond -// question, which is the contamination that shaped the per-principal design in -// the first place. -func (s *approvedBuildScenario) runPass(t *testing.T) { - t.Helper() - if err := s.reconciler.RunOnce(testCtx); err != nil { - require.NotContains(t, err.Error(), s.principal, - "the pass refused this scenario's own principal, which no assertion here expects") - t.Logf("pass refused principals belonging to other deliveries in the shared inbox: %v", err) - } -} - -func (s *approvedBuildScenario) deliverAuthority(t *testing.T, document *solutionhost.AuthorityDocument) { - t.Helper() - s.delivery.callerNamespace = authorityWriterNamespace - requireDelivered(t, s.delivery.post(t, business.SolutionDeliveryAuthority, - productionPathAuthorityCarrier(t, document))) -} - -// authority builds one authority document over this scenario's binding, naming -// the principals given. -// -// It takes the principals rather than using the shared harness's single fixed -// one, because every assertion here is about which principal a document grants -// to — a builder that always named the same one could not express "two -// documents, one principal" at all. -func (s *approvedBuildScenario) authority( - t *testing.T, suffix string, generation uint64, removed bool, principals ...string, -) *solutionhost.AuthorityDocument { - t.Helper() - document := &solutionhost.AuthorityDocument{ - Schema: solutionhost.SchemaAuthorityV1, - Authority: "acme.test." + s.solutionID + "." + suffix, - Generation: generation, - PresenceBinding: s.binding, - Host: solutionhost.HostTarget{Coordinate: productionPathCoordinate, Component: "saas-host"}, - OwnershipDomain: productionPathDomain, - EnvelopeRevision: solutionhost.FixtureEnvelopeRevision, - // This subject owns no queues, audit namespaces or scope vocabulary. - // Core requires explicit declarations, including on tombstones. - Queues: []string{}, - Namespaces: []string{}, - ScopeCeilings: []solutionhost.ScopeCeiling{}, - } - if removed { - // A withdrawn generation names no principal, approves no build and is - // effective from no generation. - document.Removed = true - } else { - document.ApprovedBuild = s.build - document.EffectiveFrom = 1 - for _, principal := range principals { - document.Principals = append(document.Principals, solutionhost.PrincipalAuthority{ - Principal: principal, - Bindings: []solutionhost.AuthorityBinding{s.bindingFor(principal)}, - }) - } - } - require.NoError(t, document.Validate(), "the fixture must be a valid authority document") - return document -} - -// activationForVerifier is activationFor with the attestation check as a -// parameter, for the one test that needs a reader over the same inbox that can -// no longer verify it. -func activationForVerifier( - t *testing.T, service *business.Service, coordinate string, - envelope *business.ModuleEnvelope, verifier *solutionhost.Verifier, -) *business.SolutionAuthorityActivation { - t.Helper() - reconciler, err := business.NewSolutionHostBindingReconciler(service, - business.SolutionHostBindingReconcilerConfig{ - Source: business.NewDeliveredSolutionHostBindings(service), - Verifier: verifier, - Coordinate: coordinate, - Domains: []string{solutionhost.FixtureDomain, productionPathDomain}, - DomainsBySigner: map[string][]string{productionPathSigner: {solutionhost.FixtureDomain, productionPathDomain}}, - Interval: time.Minute, - Envelopes: staticCeiling{envelope: envelope}, - }) - require.NoError(t, err) - return reconciler.AuthorityActivation() -} diff --git a/module/services/accounts/code/pkg/infra/installation_credential_test.go b/module/services/accounts/code/pkg/infra/installation_credential_test.go index 73a993cc0..9993c4b68 100644 --- a/module/services/accounts/code/pkg/infra/installation_credential_test.go +++ b/module/services/accounts/code/pkg/infra/installation_credential_test.go @@ -113,15 +113,13 @@ func TestSealedModuleCredentialDBLiveInstallationAtEveryEntryPoint(t *testing.T) svc.SetModuleAuthorityReads(testStore, nil) svc.SetWorkContextSealStore(testStore) cluster := executiontest.New() - svc.SetExecutionBinding(cluster, cluster) - cluster.Approve("example", executiontest.Digest("example"), 1) + svc.SetExecutionBinding(cluster) _, key, err := ed25519.GenerateKey(rand.Reader) require.NoError(t, err) issuer := &adapters.WorkContextAuthorityServer{} issuer.Configure(adapters.WorkContextAuthorityConfiguration{Issuer: "accounts.test", KeyID: "example-key", PrivateKey: key, Authority: testStore, Seals: svc}) credential, _, err := issuer.StartModuleTask(testCtx, business.ModuleWorkContextAuthority{ PrincipalID: principal, Tenant: org, InstallationID: installation.Id, InstallationRevision: 1, - Execution: business.ExecutionIdentity{Running: business.RunningDigest(executiontest.Image("example")), Incarnation: 1}, }) require.NoError(t, err, "the module's principal holds an epoch from its installation (migration 35)") verified, err := issuer.VerifyModuleWorkContext(testCtx, credential) diff --git a/module/services/accounts/code/pkg/infra/installation_dynamic_test.go b/module/services/accounts/code/pkg/infra/installation_dynamic_test.go index 2bda796ea..9b6114abb 100644 --- a/module/services/accounts/code/pkg/infra/installation_dynamic_test.go +++ b/module/services/accounts/code/pkg/infra/installation_dynamic_test.go @@ -215,10 +215,8 @@ func TestDynamicInstallationDBTenancyChangeRevokesOnlyForbiddenOrganizations(t * require.NoError(t, err) otherOrg := seedOrg(t, s.admin) require.NoError(t, testStore.As(business.Identity{OrgID: otherOrg}).AddOrgMember(testCtx, s.admin, "admin")) - revision := s.putEnvelope(t, envelopeOptions{resources: true, crossTenant: true}) - s.deliverPresence(t, s.primary, 2, revision) - s.pass(t) - s.deliverAuthority(t, s.authority(t, s.primary, 2, revision, false, s.grantsTo(s.primary))) + s.putEnvelope(t, envelopeOptions{resources: true, crossTenant: true}) + s.deliverManifest(t, s.primary, 2) s.pass(t) other := s.install(t, otherOrg) s.putEnvelope(t, envelopeOptions{resources: true}) diff --git a/module/services/accounts/code/pkg/infra/kubernetes_execution_review_test.go b/module/services/accounts/code/pkg/infra/kubernetes_execution_review_test.go index e853b3c03..17c7cd37b 100644 --- a/module/services/accounts/code/pkg/infra/kubernetes_execution_review_test.go +++ b/module/services/accounts/code/pkg/infra/kubernetes_execution_review_test.go @@ -32,12 +32,6 @@ type reviewTransport func(*http.Request) (*http.Response, error) func (f reviewTransport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } -type reviewApproval struct{} - -func (reviewApproval) ApprovedBuild(context.Context, string) (business.ApprovedDigest, uint64, error) { - return business.ApprovedDigest(reviewDigest), 7, nil -} - var reviewDigest = "sha256:" + strings.Repeat("1", 64) // reviewToken is a JWT-shaped projected token with the given lifetime. Its @@ -85,7 +79,6 @@ func reviewService(t *testing.T, client *KubernetesClient, registry string) *bus installations := &installationtest.Store{} for principal, grant := range parsed { grant.PresenceBinding = "example." + grant.Prefix - grant.Workload.SPIFFEID = "spiffe://cluster.local/ns/" + grant.Workload.Namespace + "/sa/" + grant.Workload.ServiceAccount parsed[principal] = grant scope := business.FullInstallationScope(grant) _, err := installations.InstallSolution(context.Background(), &business.InstallSolutionParams{OrgID: grant.Tenant, RegistryPrincipalID: principal, RegistryBindingID: grant.PresenceBinding, Scope: &scope}) @@ -93,7 +86,7 @@ func reviewService(t *testing.T, client *KubernetesClient, registry string) *bus } service.SetModulePrincipals(parsed) service.SetModuleAuthorityReads(installations, nil) - service.SetExecutionBinding(client, reviewApproval{}) + service.SetExecutionBinding(client) return service } @@ -113,6 +106,20 @@ func TestReviewedRunningAppAloneMints(t *testing.T) { require.Equal(t, "pod-alpha", authority.Execution.PodUID) } +// #1145: containerd reports, as a container's status.imageID, the FIRST digest +// it learned for the content, so a build re-published unchanged under a new +// digest reads as the old one. Nothing at mint reads an image any more: a pod +// whose spec and status name different digests for the same container mints, +// because the workload is what binds, never the build. +func TestAnAliasedImageIDIsNotReadAtMint(t *testing.T) { + spec := `"containers":[{"name":"app","image":"registry.example/app@sha256:` + strings.Repeat("a", 64) + `"}]` + status := `"phase":"Running","containerStatuses":[{"name":"app","imageID":"registry.example/app@sha256:` + strings.Repeat("b", 64) + `","state":{"running":{}}}]` + service := reviewService(t, reviewClient(t, spec, status), reviewAlphaOnly) + authority, err := service.ModuleAuthorizeWorkContext(business.WithVerifiedWorkloadPresenter(context.Background(), "spiffe://cluster.local/ns/acme-prod/sa/shared"), "alpha", reviewToken(t, 10*time.Minute)) + require.NoError(t, err) + require.Equal(t, "app", authority.Execution.ContainerName) +} + // FINDING 1: alpha's own token minted as beta (and alpha) when the two share a // workload. Now neither. func TestReviewSharedWorkloadMintsAsNeitherPrefix(t *testing.T) { @@ -218,7 +225,7 @@ func TestProductionPresenterMustMatchAppliedAndReviewedWorkload(t *testing.T) { service := reviewService(t, client, reviewAlphaOnly) token := reviewToken(t, 10*time.Minute) const valid = "spiffe://cluster.local/ns/acme-prod/sa/shared" - for _, presenter := range []string{"", "spiffe://other/ns/acme-prod/sa/shared", "spiffe://cluster.local/ns/other/sa/shared", "spiffe://cluster.local/ns/acme-prod/sa/attacker", valid + "?"} { + for _, presenter := range []string{"", "spiffe://cluster.local/ns/other/sa/shared", "spiffe://cluster.local/ns/acme-prod/sa/attacker", valid + "?"} { t.Run(presenter, func(t *testing.T) { ctx := business.WithVerifiedWorkloadPresenter(context.Background(), presenter) _, err := service.ModuleAuthorizeWorkContext(ctx, "alpha", token) @@ -230,15 +237,4 @@ func TestProductionPresenterMustMatchAppliedAndReviewedWorkload(t *testing.T) { _, err := service.ModuleAuthorizeWorkContext(ctx, "alpha", token) require.NoError(t, err, "legitimate exchanges do not spend the projection") } - // Changing the applied trust domain must not be hidden by equal ns/SA. - registry, err := moduleregistrytest.Parse(reviewAlphaOnly) - require.NoError(t, err) - for id, grant := range registry { - grant.Workload.SPIFFEID = "spiffe://other/ns/acme-prod/sa/shared" - grant.PresenceBinding = "example." + grant.Prefix - registry[id] = grant - } - service.SetModulePrincipals(registry) - _, err = service.ModuleAuthorizeWorkContext(ctx, "alpha", token) - require.ErrorIs(t, err, business.ErrExecutionIdentityMismatch) } diff --git a/module/services/accounts/code/pkg/infra/kubernetes_token_review.go b/module/services/accounts/code/pkg/infra/kubernetes_token_review.go index bc11a12ae..6a572f700 100644 --- a/module/services/accounts/code/pkg/infra/kubernetes_token_review.go +++ b/module/services/accounts/code/pkg/infra/kubernetes_token_review.go @@ -247,6 +247,16 @@ func kubernetesClientFrom(endpoint string, authority []byte, tokenPath, namespac }, nil } +// Namespace is the namespace this host runs in, as the kubelet projected it +// beside the token this client authenticates with. +func (c *KubernetesClient) Namespace() (string, error) { + namespace, err := os.ReadFile(c.namespacePath) + if err != nil { + return "", fmt.Errorf("%w: cannot read this service's namespace: %w", ErrKubernetesUnavailable, err) + } + return strings.TrimSpace(string(namespace)), nil +} + // InClusterNamespace is the namespace this service runs in. func InClusterNamespace() (string, error) { namespace, err := os.ReadFile(inClusterNamespacePath) @@ -414,14 +424,12 @@ type podResponse struct { type specContainer struct { Name string `json:"name"` - Image string `json:"image"` RestartPolicy string `json:"restartPolicy"` } type containerStatus struct { - Name string `json:"name"` - ImageID string `json:"imageID"` - State struct { + Name string `json:"name"` + State struct { Running *struct{} `json:"running"` Waiting *struct{} `json:"waiting"` Terminated *struct{} `json:"terminated"` @@ -441,7 +449,8 @@ func (status containerStatus) state() business.ContainerState { } // readPod reads one pod: its UID, phase, deletion, and every container with -// its kind, live state and running image. +// its kind and live state. No image is read: nothing at mint compares one +// (build approval is deploy-time admission, outside this host). // // Only `get` is used, never `list`: the host asks about a pod it was told about // by a TokenReview, so it never needs to enumerate. That keeps the RBAC the @@ -479,12 +488,11 @@ func (c *KubernetesClient) readPod(ctx context.Context, namespace, pod string) ( for _, declared := range group.spec { container := business.ContainerExecution{ Name: declared.Name, Kind: group.kind, State: business.ContainerStateUnknown, - DeclaredImage: declared.Image, - Restartable: group.kind == business.ContainerInit && declared.RestartPolicy == "Always", + Restartable: group.kind == business.ContainerInit && declared.RestartPolicy == "Always", } for _, status := range group.statuses { if status.Name == declared.Name { - container.State, container.ImageID = status.state(), status.ImageID + container.State = status.state() } } seen[declared.Name] = true @@ -495,7 +503,7 @@ func (c *KubernetesClient) readPod(ctx context.Context, namespace, pod string) ( for _, status := range group.statuses { if !seen[status.Name] { read.Containers = append(read.Containers, business.ContainerExecution{ - Name: status.Name, Kind: group.kind, State: status.state(), ImageID: status.ImageID, + Name: status.Name, Kind: group.kind, State: status.state(), }) } } diff --git a/module/services/accounts/code/pkg/infra/module_envelope_witness_test.go b/module/services/accounts/code/pkg/infra/module_envelope_witness_test.go index d3a0a238c..760f5810e 100644 --- a/module/services/accounts/code/pkg/infra/module_envelope_witness_test.go +++ b/module/services/accounts/code/pkg/infra/module_envelope_witness_test.go @@ -39,17 +39,16 @@ func envelopeJSONAt(t *testing.T, doc map[string]any, revision uint64) []byte { return raw } -// Only a pure grant skips the policy-log witness: approved builds added, -// whole principals added, everything else the same value. With no policy log +// Only a pure grant skips the policy-log witness: whole principals added, +// everything else the same value. With no policy log // wired, a write that needs the witness is refused and writes nothing, so "it // landed" is the proof a change was applied unwitnessed. // // Every shape below is a real narrowing that a comparison by "more JSON means // more authority" let through: a resource list that appears restricts an // audience that was unrestricted, a required kind that appears refuses -// artifacts that passed, a unit that gains a field is a different unit, a -// tenant that is set removes every other organization, and two revisions past -// 2^53 are one float. +// artifacts that passed, and a tenant that is set removes every other +// organization. func TestAnEnvelopeChangeToAGrantedPrincipalNeedsTheWitness(t *testing.T) { s := newRegistryScenario(t, "rvf") digest := "sha256:" + strings.Repeat("ab", 32) @@ -75,8 +74,6 @@ func TestAnEnvelopeChangeToAGrantedPrincipalNeedsTheWitness(t *testing.T) { "required_kinds": []any{"a"}, }, } - // The lookup unit sits at revision 2^53 so the float64 comparison can be probed. - principal(base)["bindings"].([]any)[1].(map[string]any)["revision"] = json.Number("9007199254740992") current := s.currentRevision(t) _, err := s.delivery.service.PutModuleEnvelope(testCtx, s.admin, current, envelopeJSONAt(t, base, current+1)) @@ -110,12 +107,6 @@ func TestAnEnvelopeChangeToAGrantedPrincipalNeedsTheWitness(t *testing.T) { {"artifact policy gains a REQUIRED contract kind", func(doc map[string]any) { grant(doc)["artifact_policies"].(map[string]any)["p1"].(map[string]any)["required_kinds"] = []any{"a", "b"} }}, - {"an admitted unit is replaced by a different unit under the same id (queue set)", func(doc map[string]any) { - principal(doc)["bindings"].([]any)[0].(map[string]any)["queue"] = "reports.render" - }}, - {"an admitted unit's revision moves 2^53 -> 2^53+1", func(doc map[string]any) { - principal(doc)["bindings"].([]any)[1].(map[string]any)["revision"] = json.Number("9007199254740993") - }}, {"tenancy pinned: shared (every organization) -> one organization", func(doc map[string]any) { grant(doc)["tenant"] = s.tenant }}, } for _, tc := range cases { @@ -132,21 +123,19 @@ func TestAnEnvelopeChangeToAGrantedPrincipalNeedsTheWitness(t *testing.T) { }) } - // And the pure grant: a build approved beside the ones already approved. - t.Run("an approved build added lands with no witness", func(t *testing.T) { + // And the pure grant: a whole principal added beside the ones granted. + t.Run("a principal added lands with no witness", func(t *testing.T) { next := envelopeJSONClone(t, state) - builds, _ := next["approved_builds"].([]any) - next["approved_builds"] = append(builds, "sha256:"+strings.Repeat("cd", 32)) + next["principals"].(map[string]any)["added-"+s.prefix] = map[string]any{"grant": map[string]any{"cross_tenant": true}} current := s.currentRevision(t) _, err := s.delivery.service.PutModuleEnvelope(testCtx, s.admin, current, envelopeJSONAt(t, next, current+1)) require.NoError(t, err) require.Equal(t, current+1, s.currentRevision(t)) state = next }) - t.Run("an approved build removed needs the witness", func(t *testing.T) { + t.Run("a principal removed needs the witness", func(t *testing.T) { next := envelopeJSONClone(t, state) - builds := next["approved_builds"].([]any) - next["approved_builds"] = builds[:len(builds)-1] + delete(next["principals"].(map[string]any), "added-"+s.prefix) current := s.currentRevision(t) _, err := s.delivery.service.PutModuleEnvelope(testCtx, s.admin, current, envelopeJSONAt(t, next, current+1)) require.ErrorIs(t, err, business.ErrPolicyLogUnreachable) @@ -171,10 +160,7 @@ func TestAnEnvelopeThatWouldRevokeAnInstallationNeedsTheWitness(t *testing.T) { current := s.currentRevision(t) _, err := s.delivery.service.PutModuleEnvelope(testCtx, s.admin, current, envelopeJSONAt(t, shared, current+1)) require.NoError(t, err) - revision := current + 1 - s.deliverPresence(t, s.primary, 1, revision) - s.pass(t) - s.deliverAuthority(t, s.authority(t, s.primary, 1, revision, false, s.grantsTo(s.primary))) + s.deliverManifest(t, s.primary, 1) s.pass(t) _, present := s.entry() require.True(t, present) @@ -206,3 +192,8 @@ func TestAnEnvelopeThatWouldRevokeAnInstallationNeedsTheWitness(t *testing.T) { "an unwitnessed envelope write must not revoke an installation") s.delivery.service.SetPolicyLog(&productionPathPolicyLog{}, testStore) } + +func jsonNumber(value uint64) string { + raw, _ := json.Marshal(value) + return string(raw) +} diff --git a/module/services/accounts/code/pkg/infra/module_principal_registry_test.go b/module/services/accounts/code/pkg/infra/module_principal_registry_test.go index 11dbf746a..275aec524 100644 --- a/module/services/accounts/code/pkg/infra/module_principal_registry_test.go +++ b/module/services/accounts/code/pkg/infra/module_principal_registry_test.go @@ -5,74 +5,53 @@ package infra_test import ( "context" "encoding/json" - "errors" - "strings" + "net/http" "sync/atomic" "testing" "time" + "accounts/internal/deliverytest" "accounts/pkg/business" + "accounts/pkg/business/executiontest" gen "accounts/pkg/gen/saas/accounts/v1" "github.com/codefly-dev/core/solutionhost" "github.com/stretchr/testify/require" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" ) -// THE MODULE REGISTRY, DERIVED FROM DELIVERY, through real Postgres. -// -// The defect these cover: every capability decision read MODULE_PRINCIPALS, -// parsed once from the environment at boot, and a delivered authority document -// reached nothing any decision read. Adding, narrowing or withdrawing a module -// was a configuration edit and a restart. +// THE MODULE REGISTRY, DERIVED FROM THE APPLIED MODULE MANIFESTS, through real +// Postgres. // // Each test drives the host's own path: a platform super_admin writes the -// module envelope through the business method the RPC calls, presence and -// authority are POSTed through the real delivery handler, and the registry is +// module envelope through the business method the RPC calls, the module's +// manifest is POSTed through the real delivery handler, and the registry is // read back through the service every capability decision reads — with no -// service rebuilt and nothing read from the environment between the steps. +// service rebuilt between the steps. // // The envelope is HOST-WIDE and append-only, like the inbox: each test writes // the next revision after whatever is current, and asserts only on its own -// principal. Other tests' documents sit in the same inbox and are refused as -// outside this test's ceiling, which is the shape of a real host. +// principal. -// registryBinding is one presence binding of the scenario's module: one -// deployed instance, with its own namespace and workload. +// registryBinding is one deployed instance of the scenario's module. type registryBinding struct { solutionID string - namespace string binding string - build solutionhost.ImageDigest } func newRegistryBinding(t *testing.T, label string) *registryBinding { t.Helper() solutionID := uniqueAlias(label) - namespace := "ns-" + solutionID - presence := productionPathBinding(t, solutionID, namespace, 1) - return ®istryBinding{solutionID: solutionID, namespace: namespace, binding: presence.Binding, build: presence.Builds()[0]} -} - -// spiffeID is the identity productionPathBinding's workload presents. -func (b *registryBinding) spiffeID() string { - return "spiffe://acme.test/ns/" + b.namespace + "/sa/api" + return ®istryBinding{solutionID: solutionID, binding: "acme.test." + solutionID} } -func (b *registryBinding) unitID(operation string) string { return b.binding + ":run:" + operation } - -// units are the units delivery writes for this binding: invoke and lookup of -// the `run` binding, field for field what the envelope admits. -func (b *registryBinding) units() []solutionhost.AuthorityBinding { - return []solutionhost.AuthorityBinding{ - {ID: b.unitID("invoke"), Revision: 1, Audience: "example-producer", Scope: "profiles:read,profiles:write"}, - {ID: b.unitID("lookup"), Revision: 1, Audience: "example-producer", Scope: "profiles:read"}, - } +// workload is the identity the binding's one workload runs as, by the cell's +// rule. +func (b *registryBinding) workload() business.ModuleWorkload { + return business.ModuleWorkload{Namespace: productionPathNamespace(b.solutionID), ServiceAccount: "api", Container: "api"} } -// registryScenario is one module on one host: its principal, its presence -// bindings, and the reconciler deriving the registry. +// registryScenario is one module on one host: its principal, its binding, and +// the reconciler deriving the registry. type registryScenario struct { delivery *productionPathDelivery reconciler *business.SolutionHostBindingReconciler @@ -86,20 +65,15 @@ type registryScenario struct { func newRegistryScenario(t *testing.T, label string) *registryScenario { t.Helper() delivery := newRegistryDelivery(t) - scenario := ®istryScenario{ - delivery: delivery, - admin: seedUser(t), - tenant: business.NewIDString(), - primary: newRegistryBinding(t, label), - } + scenario := ®istryScenario{delivery: delivery, admin: seedUser(t), primary: newRegistryBinding(t, label)} seedHumanPrincipal(t, scenario.admin, "Jane Doe") scenario.tenant = seedOrg(t, scenario.admin) require.NoError(t, testStore.As(business.Identity{OrgID: scenario.tenant}).AddOrgMember(testCtx, scenario.admin, "admin")) - scenario.prefix = "mod-" + scenario.primary.solutionID + // The module's principal is its own name: the manifest's alias. + scenario.prefix = scenario.primary.solutionID scenario.resource = scenario.prefix + ".reports" grantPlatformRoleForTest(t, scenario.admin, "super_admin") - scenario.reconciler = newRegistryReconciler(t, delivery.service, productionVerifier(t), - map[string][]string{productionPathSigner: {productionPathDomain}}, delivery.service) + scenario.reconciler = newRegistryReconciler(t, delivery.service, productionVerifier(t), delivery.service) return scenario } @@ -114,22 +88,18 @@ func newRegistryDelivery(t *testing.T) *productionPathDelivery { } func newRegistryReconciler( - t *testing.T, service *business.Service, verifier *solutionhost.Verifier, - domainsBySigner map[string][]string, envelopes business.ModuleEnvelopeSource, + t *testing.T, service *business.Service, verifier *solutionhost.Verifier, envelopes business.ModuleEnvelopeSource, ) *business.SolutionHostBindingReconciler { t.Helper() - reconciler, err := business.NewSolutionHostBindingReconciler(service, - business.SolutionHostBindingReconcilerConfig{ - Source: business.NewDeliveredSolutionHostBindings(service), - Verifier: verifier, - Coordinate: productionPathCoordinate, - Domains: []string{productionPathDomain}, - DomainsBySigner: domainsBySigner, - // An hour: a pass these tests observe is one they ran or one a - // delivery triggered, never one the clock happened to deliver. - Interval: time.Hour, - Envelopes: envelopes, - }) + reconciler, err := business.NewSolutionHostBindingReconciler(service, business.SolutionHostBindingReconcilerConfig{ + Source: business.NewDeliveredSolutionHostBindings(service), + Verifier: verifier, + Coordinate: productionPathCoordinate, + // An hour: a pass these tests observe is one they ran or one a + // delivery triggered, never one the clock happened to deliver. + Interval: time.Hour, + Envelopes: envelopes, + }) require.NoError(t, err) return reconciler } @@ -160,16 +130,13 @@ func (s *registryScenario) currentRevision(t *testing.T) uint64 { return record.Revision } -// envelopeOptions is what one revision of the ceiling admits. +// envelopeOptions is what one revision of the ceiling grants. type envelopeOptions struct { crossTenant bool // resources lets the module's own content kind through the grant. resources bool // tenant overrides the scenario's tenant. tenant string - // bindings are the presence bindings whose units are admitted, and whose - // workloads are allowed. - bindings []*registryBinding // none writes a ceiling with no entry for the scenario's principal. none bool } @@ -180,9 +147,6 @@ func (s *registryScenario) envelope(t *testing.T, revision uint64, options envel if options.tenant != "" { tenant = options.tenant } - if options.bindings == nil { - options.bindings = []*registryBinding{s.primary} - } grant := map[string]any{ "tenant": tenant, "cross_tenant": options.crossTenant, @@ -199,37 +163,16 @@ func (s *registryScenario) envelope(t *testing.T, revision uint64, options envel if options.resources { grant["resources"] = []string{s.resource} } - var units, workloads []any - for _, binding := range options.bindings { - for _, unit := range binding.units() { - units = append(units, map[string]any{"id": unit.ID, "revision": unit.Revision, "scope": unit.Scope}) - } - workloads = append(workloads, map[string]any{"spiffe_id": binding.spiffeID(), "container": "api"}) - } principals := map[string]any{} if !options.none { - principals[s.prefix] = map[string]any{ - "bindings": units, - "workload_identities": workloads, - "scope_ceilings": []any{map[string]any{"resource_kind": s.resource, "actions": []string{"read"}}}, - "grant": grant, - } + principals[s.prefix] = map[string]any{"grant": grant} } - builds := map[string]bool{} - var approved []string - for _, binding := range options.bindings { - if !builds[string(binding.build)] { - builds[string(binding.build)] = true - approved = append(approved, string(binding.build)) - } - } - raw, err := json.Marshal(map[string]any{"revision": revision, "approved_builds": approved, "principals": principals}) + raw, err := json.Marshal(map[string]any{"revision": revision, "principals": principals}) require.NoError(t, err) return raw } -// putEnvelope writes the next revision as the super_admin, through the method -// PutModuleEnvelope calls, and returns it. +// putEnvelope writes the next revision as the super_admin and returns it. func (s *registryScenario) putEnvelope(t *testing.T, options envelopeOptions) uint64 { t.Helper() current := s.currentRevision(t) @@ -239,71 +182,38 @@ func (s *registryScenario) putEnvelope(t *testing.T, options envelopeOptions) ui return record.Revision } -func (s *registryScenario) deliverPresence(t *testing.T, binding *registryBinding, generation, revision uint64, mutate ...func(*solutionhost.SolutionHostBinding)) { +// manifest is the module's manifest for one binding at generation: one +// workload, and an authority request for the `run` binding, its queue, +// namespace and resource kind. +func (s *registryScenario) manifest(t *testing.T, binding *registryBinding, generation uint64) *solutionhost.ModuleManifest { t.Helper() - presence := productionPathBinding(t, binding.solutionID, binding.namespace, generation) - presence.EnvelopeRevision = revision - for _, change := range mutate { - change(presence) + manifest := productionPathManifest(t, binding.solutionID, generation) + manifest.Kind = solutionhost.KindModule + manifest.Authority = &solutionhost.Authority{ + Principal: s.prefix, + Bindings: []solutionhost.AuthorityBinding{{ID: "run", Audience: "example-producer", Operations: map[string][]string{ + "invoke": {"profiles:read", "profiles:write"}, "lookup": {"profiles:read"}, + }}}, + Queues: []string{"reports.render"}, + Namespaces: []string{"reports"}, + ResourceKinds: []string{s.resource}, } - require.NoError(t, presence.Validate()) - s.delivery.callerNamespace = binding.namespace - requireDelivered(t, s.delivery.post(t, business.SolutionDeliveryPresence, productionPathCarrier(t, presence))) + require.NoError(t, manifest.Validate()) + return manifest } -// authority is an authority document over one binding: the module's name as -// the principal, the units delivery derives, at an envelope revision. -func (s *registryScenario) authority( - t *testing.T, binding *registryBinding, generation, revision uint64, removed bool, principals map[string][]solutionhost.AuthorityBinding, -) *solutionhost.AuthorityDocument { +func (s *registryScenario) deliverManifest(t *testing.T, binding *registryBinding, generation uint64) { t.Helper() - document := &solutionhost.AuthorityDocument{ - Schema: solutionhost.SchemaAuthorityV1, - Authority: binding.binding + "-authority", - Generation: generation, - PresenceBinding: binding.binding, - Host: solutionhost.HostTarget{Coordinate: productionPathCoordinate, Component: "saas-host"}, - OwnershipDomain: productionPathDomain, - EnvelopeRevision: revision, - Queues: []string{"reports.render"}, - Namespaces: []string{"reports"}, - ScopeCeilings: []solutionhost.ScopeCeiling{{ResourceKind: s.resource, Actions: []string{"read"}}}, - } - if removed { - document.Queues, document.Namespaces, document.ScopeCeilings = []string{}, []string{}, []solutionhost.ScopeCeiling{} - document.Removed = true - } else { - document.ApprovedBuild = binding.build - document.EffectiveFrom = 1 - for principal, units := range principals { - document.Principals = append(document.Principals, solutionhost.PrincipalAuthority{Principal: principal, Bindings: units}) - } - } - require.NoError(t, document.Validate()) - return document + requireDelivered(t, s.delivery.deliver(t, s.manifest(t, binding, generation))) } -// grantsTo is the usual principal map: this module's own principal, holding -// the binding's units. -func (s *registryScenario) grantsTo(binding *registryBinding) map[string][]solutionhost.AuthorityBinding { - return map[string][]solutionhost.AuthorityBinding{s.prefix: binding.units()} -} - -func (s *registryScenario) deliverAuthority(t *testing.T, document *solutionhost.AuthorityDocument) { - t.Helper() - s.delivery.callerNamespace = authorityWriterNamespace - requireDelivered(t, s.delivery.post(t, business.SolutionDeliveryAuthority, productionPathAuthorityCarrier(t, document))) -} - -// established is the common start: an envelope admitting the primary binding, -// its presence applied, and its authority delivered and derived. It returns -// the envelope revision. +// established is the common start: an envelope granting the principal, its +// manifest applied and derived, an installation, and a passing check. It +// returns the envelope revision. func (s *registryScenario) established(t *testing.T) uint64 { t.Helper() revision := s.putEnvelope(t, envelopeOptions{resources: true}) - s.deliverPresence(t, s.primary, 1, revision) - s.pass(t) - s.deliverAuthority(t, s.authority(t, s.primary, 1, revision, false, s.grantsTo(s.primary))) + s.deliverManifest(t, s.primary, 1) s.pass(t) _, present := s.entry() require.True(t, present, "the module must be in the registry before anything can take it out") @@ -326,494 +236,177 @@ func (s *registryScenario) pass(t *testing.T) { t.Helper() if err := s.passReasons(t); err != nil { require.NotErrorIs(t, err, business.ErrModulePrincipalInputsUnreadable) - require.NotContains(t, err.Error(), s.prefix, "the pass refused this scenario's own principal") + require.NotContains(t, err.Error(), `"`+s.prefix+`"`, "the pass refused this scenario's own principal") } } -// TestDeliveringAuthorityChangesTheRegistryWithNoRestart is the acceptance row -// for an added module: the envelope allows it, delivery asks for it, and the -// registry every capability decision reads holds it — on a running service, -// within the pass the delivery triggered. -func TestDeliveringAuthorityChangesTheRegistryWithNoRestart(t *testing.T) { - s := newRegistryScenario(t, "reg") - revision := s.putEnvelope(t, envelopeOptions{resources: true}) - s.deliverPresence(t, s.primary, 1, revision) +func (s *registryScenario) install(t *testing.T, org string) *gen.Installation { + t.Helper() + available, _, err := s.delivery.service.ListAvailableSolutions(testCtx, org, "", 500) + require.NoError(t, err) + var target string + for _, entry := range available { + if entry.BindingID == s.primary.binding { + target = entry.TargetID + } + } + require.NotEmpty(t, target) + role := business.NewIDString() + require.NoError(t, testStore.WithOrgTx(testCtx, org, func(ctx context.Context) error { + return testStore.CreateRole(ctx, &gen.Role{Id: role, OrgId: org, Name: "Example role " + role, Permissions: []*gen.Permission{{Resource: "solution", Action: "use"}}}) + })) + installation, err := s.delivery.service.InstallSolution(testCtx, s.admin, &business.InstallSolutionParams{ + OrgID: org, TargetID: target, AgentIdentifier: "acme.example/registry:1.0.0", RoleID: role, + }) + require.NoError(t, err) + acknowledgeInstallation(t, installation) + return installation +} - // The pass trigger is wired as work.go wires it, through a counter: the - // assertion that the DELIVERY asked for the pass is what makes this test - // about the trigger rather than about whichever pass happened to run. +// TestDeliveringAManifestChangesTheRegistryWithNoRestart is the acceptance row +// for an added module: the envelope grants it, its manifest asks for it, and +// the registry every capability decision reads holds the intersection — on a +// running service, within the pass the delivery triggered. +func TestDeliveringAManifestChangesTheRegistryWithNoRestart(t *testing.T) { + s := newRegistryScenario(t, "reg") + s.putEnvelope(t, envelopeOptions{resources: true}) var requested atomic.Int32 s.delivery.service.SetModuleRegistryPassTrigger(func() { requested.Add(1) s.reconciler.Trigger() }) - ctx, cancel := context.WithCancel(testCtx) t.Cleanup(func() { cancel() _ = s.reconciler.Shutdown(context.Background()) }) s.reconciler.Start(ctx) - require.Eventually(t, func() bool { - records, err := s.delivery.service.ListSolutionHostBindings(testCtx) - if err != nil { - return false - } - for _, one := range records { - if one.BindingID == s.primary.binding && one.Applied != nil { - return true - } - } - return false - }, 20*time.Second, 50*time.Millisecond, "the presence was never applied") _, present := s.entry() - require.False(t, present, "a ceiling alone grants nothing: no delivery has asked for this principal") + require.False(t, present, "a ceiling alone grants nothing: no manifest has asked for this principal") before := requested.Load() - s.deliverAuthority(t, s.authority(t, s.primary, 1, revision, false, s.grantsTo(s.primary))) + s.deliverManifest(t, s.primary, 1) require.Equal(t, before+1, requested.Load(), "an accepted delivery asks for a pass rather than waiting for the tick") require.Eventually(t, func() bool { _, ok := s.entry(); return ok }, 20*time.Second, 50*time.Millisecond, - "a delivered authority inside the envelope must reach the registry without a restart or a tick") + "a delivered manifest inside the envelope must reach the registry without a restart or a tick") grant, _ := s.entry() - require.Equal(t, s.prefix, grant.Prefix, "the principal is the module's own name") + require.Equal(t, s.prefix, grant.Prefix) require.Equal(t, s.tenant, grant.Tenant) - require.Equal(t, business.ModuleWorkload{SPIFFEID: s.primary.spiffeID(), ServiceAccount: "api", Namespace: s.primary.namespace, Container: "api"}, grant.Workload, - "the workload is the applied presence's, and one the envelope allows") + require.Equal(t, s.primary.binding, grant.PresenceBinding) + require.Equal(t, []business.ModuleWorkload{s.primary.workload()}, grant.Workloads, + "the workloads are the applied manifest's, spelled by the cell's rule") require.Equal(t, []string{"reports.render"}, grant.Queues) require.Equal(t, []string{s.resource}, grant.Resources) require.Equal(t, []business.ModuleOperationScope{{ResourceKind: "profiles", Actions: []string{"read", "write"}}}, grant.OperationAudiences["run"].InvokeScopes) - - resources, err := s.delivery.service.ModuleContentResources(s.prefix) - require.NoError(t, err, "the capability decision reads the derived registry") - require.Equal(t, []string{s.resource}, resources) s.install(t, s.tenant) - _, err = s.delivery.service.AuthorizeModuleCapability(testCtx, s.caller(s.tenant)) + _, err := s.delivery.service.AuthorizeModuleCapability(testCtx, s.caller(s.tenant)) require.NoError(t, err) } -// TestNarrowingTheEnvelopeRefusesTheNextCheck (finding 4): the check right -// after the write is refused — no pass run, no trigger consumed — and refused -// as a registry older than the committed ceiling. The pass that follows -// derives the narrower registry: by revision first, then through the -// intersection once delivery renders against the narrower ceiling. +// Narrowing the envelope refuses the next check before any pass, and the pass +// that follows derives the narrower intersection. func TestNarrowingTheEnvelopeRefusesTheNextCheck(t *testing.T) { s := newRegistryScenario(t, "nar") s.established(t) - narrow := s.putEnvelope(t, envelopeOptions{resources: false}) + s.putEnvelope(t, envelopeOptions{resources: false}) _, err := s.delivery.service.AuthorizeModuleCapability(testCtx, s.caller(s.tenant)) require.ErrorIs(t, err, business.ErrModuleRegistryStale, "the next check after a narrowing is refused before any pass runs") - _, err = s.delivery.service.ModuleContentResources(s.prefix) - require.Error(t, err, "and so is every read of the registry the narrowing predates") - refusals := s.passReasons(t) - _, present := s.entry() - require.False(t, present, "a document rendered against the previous revision is outside the new one") - require.ErrorIs(t, refusals, business.ErrModuleAuthorityOutsideEnvelope) - - // REDELIVERED at the narrower revision: inside it, and bounded by it. - s.deliverPresence(t, s.primary, 2, narrow) - s.pass(t) - s.deliverAuthority(t, s.authority(t, s.primary, 2, narrow, false, s.grantsTo(s.primary))) s.pass(t) grant, present := s.entry() - require.True(t, present, "the redelivered document is inside the narrower ceiling") - require.Empty(t, grant.Resources, "the module asks for its content and the envelope no longer allows it") - _, err = s.delivery.service.ModuleContentResources(s.prefix) - require.Error(t, err, "the narrowing holds through the intersection, not only through the revision") -} - -// TestANarrowingOnOneReplicaRefusesTheNextCheckOnAnother (finding 4): the -// replica that did not take the write receives no trigger and runs no pass, -// and still refuses at its next check, because the fence reads the committed -// revision from the store. -func TestANarrowingOnOneReplicaRefusesTheNextCheckOnAnother(t *testing.T) { - s := newRegistryScenario(t, "rep") - s.established(t) - - other := newRegistryDelivery(t) - otherReconciler := newRegistryReconciler(t, other.service, productionVerifier(t), - map[string][]string{productionPathSigner: {productionPathDomain}}, other.service) - if err := otherReconciler.RunOnce(testCtx); err != nil { - require.NotErrorIs(t, err, business.ErrModulePrincipalInputsUnreadable) - } - _, present := entryOn(other.service, s.prefix) - require.True(t, present, "the second replica derived the same registry") - _, err := other.service.AuthorizeModuleCapability(testCtx, s.caller(s.tenant)) - require.NoError(t, err) - - s.putEnvelope(t, envelopeOptions{none: true}) - _, err = other.service.AuthorizeModuleCapability(testCtx, s.caller(s.tenant)) - require.ErrorIs(t, err, business.ErrModuleRegistryStale, - "a replica that has not run a pass since the narrowing refuses at its next check") - _, present = entryOn(other.service, s.prefix) - require.False(t, present, "and once it has seen the committed revision it serves nothing from the older registry") -} - -// staleCeiling returns one fixed envelope, the way a pass that read the -// previous revision just before a write committed would see it. -type staleCeiling struct{ envelope *business.ModuleEnvelope } - -func (c staleCeiling) CurrentModuleEnvelope(context.Context) (*business.ModuleEnvelope, error) { - return c.envelope, nil -} - -// TestAPassThatReadThePreviousCeilingDoesNotServeAfterANarrowing (finding 4): -// the in-flight race. A pass reads revision N, the narrowing N+1 commits, the -// pass publishes its registry tagged N — and the next check refuses it. -func TestAPassThatReadThePreviousCeilingDoesNotServeAfterANarrowing(t *testing.T) { - s := newRegistryScenario(t, "inf") - s.established(t) - previous, err := s.delivery.service.CurrentModuleEnvelope(testCtx) - require.NoError(t, err) - - s.putEnvelope(t, envelopeOptions{none: true}) - inFlight := newRegistryReconciler(t, s.delivery.service, productionVerifier(t), - map[string][]string{productionPathSigner: {productionPathDomain}}, staleCeiling{envelope: previous}) - _ = inFlight.ModulePrincipals().RunOnce(testCtx) - - _, present := s.entry() - require.False(t, present, "a registry derived from the revision a committed narrowing replaced serves nothing") + require.True(t, present) + require.Empty(t, grant.Resources, "the grant no longer allows the resource kind the manifest asks for") _, err = s.delivery.service.AuthorizeModuleCapability(testCtx, s.caller(s.tenant)) - require.ErrorIs(t, err, business.ErrModuleRegistryStale) -} - -// TestANarrowingHoldsWhileTheInboxIsUnreadable (finding 4): the envelope is -// narrowed and the documents no longer verify. The registry is derived from -// the NEW ceiling — the narrowing takes effect — rather than the previous -// registry being kept. -func TestANarrowingHoldsWhileTheInboxIsUnreadable(t *testing.T) { - s := newRegistryScenario(t, "unb") - verifier := productionVerifier(t) - s.reconciler = newRegistryReconciler(t, s.delivery.service, verifier, - map[string][]string{productionPathSigner: {productionPathDomain}}, s.delivery.service) - s.established(t) + require.NoError(t, err) s.putEnvelope(t, envelopeOptions{none: true}) - *verifier = solutionhost.Verifier{} - err := s.reconciler.ModulePrincipals().RunOnce(testCtx) - require.ErrorIs(t, err, business.ErrSolutionAuthorityUnverifiable, "a document that no longer verifies is refused by name") - _, present := s.entry() - require.False(t, present, "a read failure never keeps a grant the committed ceiling withdrew") - _, err = s.delivery.service.AuthorizeModuleCapability(testCtx, s.caller(s.tenant)) - require.Equal(t, codes.PermissionDenied, status.Code(err), - "the registry was DERIVED from the new ceiling — the principal is simply not in it — rather than the old one kept and fenced") - require.NotErrorIs(t, err, business.ErrModuleRegistryStale) + require.ErrorIs(t, s.passReasons(t), business.ErrModulePrincipalOmitted) + _, present = s.entry() + require.False(t, present, "an envelope with no ceiling for the principal grants it nothing") } -// TestAWithdrawalIsTerminalForTheBinding (finding 2): authority X withdraws -// the binding at generation 2, then a live generation 3 of the SAME id is -// delivered. The newest-per-id read no longer contains the tombstone; the -// history does, and the binding stays withdrawn — in the registry, in -// activation, and for a reconciler built after a restart. +// A withdrawal takes the principal out and is terminal for the binding. func TestAWithdrawalIsTerminalForTheBinding(t *testing.T) { - s := newRegistryScenario(t, "wdr") - revision := s.established(t) - - s.deliverAuthority(t, s.authority(t, s.primary, 2, revision, true, nil)) + s := newRegistryScenario(t, "wd") + s.established(t) + requireDelivered(t, s.delivery.deliver(t, productionPathWithdrawal(t, s.manifest(t, s.primary, 1), 2))) s.pass(t) _, present := s.entry() - require.False(t, present, "a withdrawn authority leaves its principal out of the registry") - - s.deliverAuthority(t, s.authority(t, s.primary, 3, revision, false, s.grantsTo(s.primary))) - refusals := s.passReasons(t) - _, present = s.entry() - require.False(t, present, "a later generation of the same authority does not reinstate a withdrawn binding") - require.ErrorIs(t, refusals, business.ErrSolutionAuthorityWithdrawn) - - _, err := s.reconciler.AuthorityActivation().Activate(testCtx, s.primary.binding, s.primary.build) - require.ErrorIs(t, err, business.ErrSolutionAuthorityWithdrawn, "activation reads the same history") - - restarted := newRegistryReconciler(t, s.delivery.service, productionVerifier(t), - map[string][]string{productionPathSigner: {productionPathDomain}}, s.delivery.service) - _ = restarted.ModulePrincipals().RunOnce(testCtx) - _, present = s.entry() - require.False(t, present, "the withdrawal is read from the store, so a restart does not forget it") -} - -// TestAPrincipalMovesToAnotherAuthorityOnlyInsideTheEnvelope (finding 2): X -// stops naming the principal and Y, over another presence binding, names it at -// a lower generation. Generations of two ids are not comparable; what decides -// is whether the envelope admits Y's units — the administrator's approval of -// the move — and the answer is the same after a restart. -func TestAPrincipalMovesToAnotherAuthorityOnlyInsideTheEnvelope(t *testing.T) { - s := newRegistryScenario(t, "mov") - second := newRegistryBinding(t, "mov2") - other := "oth-" + s.primary.solutionID - - // Revision R1 admits the primary binding only. - r1 := s.putEnvelope(t, envelopeOptions{resources: true}) - s.deliverPresence(t, s.primary, 1, r1) - s.deliverPresence(t, second, 1, r1) - s.pass(t) - for generation := uint64(1); generation <= 5; generation++ { - s.deliverAuthority(t, s.authority(t, s.primary, generation, r1, false, s.grantsTo(s.primary))) - } - s.pass(t) - grant, present := s.entry() - require.True(t, present) - require.Equal(t, s.primary.namespace, grant.Workload.Namespace) - - // X moves on to another principal; Y names this one at generation 1. - s.deliverAuthority(t, s.authority(t, s.primary, 6, r1, false, - map[string][]solutionhost.AuthorityBinding{other: s.primary.units()})) - s.deliverAuthority(t, s.authority(t, second, 1, r1, false, s.grantsTo(second))) - require.ErrorIs(t, s.passReasons(t), business.ErrModuleAuthorityOutsideEnvelope) - _, present = s.entry() - require.False(t, present, "Y's units are not admitted, so the move is not approved") - - // Revision R2 admits both bindings: the move is approved. - r2 := s.putEnvelope(t, envelopeOptions{resources: true, bindings: []*registryBinding{s.primary, second}}) - s.deliverPresence(t, s.primary, 2, r2) - s.deliverPresence(t, second, 2, r2) - _ = s.passReasons(t) // applies the presence; the r1 documents are outside r2 - s.deliverAuthority(t, s.authority(t, s.primary, 7, r2, false, - map[string][]solutionhost.AuthorityBinding{other: s.primary.units()})) - s.deliverAuthority(t, s.authority(t, second, 2, r2, false, s.grantsTo(second))) - // The registry step alone: the approved-build view keeps its own - // process-local ordering across ids (it feeds BindExecution, which no mint - // calls yet) and refuses this move until a restart; the registry does not. - if err := s.reconciler.ModulePrincipals().RunOnce(testCtx); err != nil { - require.NotContains(t, err.Error(), s.prefix, "the registry refused the approved move") - } - grant, present = s.entry() - require.True(t, present, "inside the envelope, the replacement authority holds the principal") - require.Equal(t, second.namespace, grant.Workload.Namespace, "and binds it to the replacement's approved workload") - - restarted := newRegistryReconciler(t, s.delivery.service, productionVerifier(t), - map[string][]string{productionPathSigner: {productionPathDomain}}, s.delivery.service) - _ = restarted.ModulePrincipals().RunOnce(testCtx) - again, present := s.entry() - require.True(t, present, "the answer does not depend on whether the process restarted") - require.Equal(t, grant, again) + require.False(t, present, "a withdrawn binding's principal is out of the registry") + _, err := s.delivery.service.AuthorizeModuleCapability(testCtx, s.caller(s.tenant)) + require.Error(t, err) + require.Equal(t, http.StatusConflict, s.delivery.deliver(t, s.manifest(t, s.primary, 3)).Code, + "no later generation brings a withdrawn binding back") } -// TestARevokedSignerDomainGrantsNothing (finding 5): the signer's permission to -// speak for the domain is removed from the host's policy. Its stored documents -// still verify, and grant nothing. -func TestARevokedSignerDomainGrantsNothing(t *testing.T) { - s := newRegistryScenario(t, "sig") +// A key unpinned since a manifest was applied keeps the binding applied — a +// removal is a withdrawal generation, never a lost pin — and grants nothing. +func TestAnUnpinnedSignerGrantsNothing(t *testing.T) { + s := newRegistryScenario(t, "pin") s.established(t) - - narrowed := newRegistryReconciler(t, s.delivery.service, productionVerifier(t), - map[string][]string{productionPathSigner: {solutionhost.FixtureDomain}}, s.delivery.service) - refusals := narrowed.ModulePrincipals().RunOnce(testCtx) - _, present := s.entry() - require.False(t, present, "a signer that may no longer speak for the domain authorizes nothing in it") - require.ErrorIs(t, refusals, business.ErrSolutionAuthoritySignerNotGranted) -} - -// TestAPresenceGenerationCannotRetargetThePrincipal (finding 6): a later -// presence generation runs the approved build under another service account. -// It applies — presence is presence — and the principal does not follow it. -func TestAPresenceGenerationCannotRetargetThePrincipal(t *testing.T) { - s := newRegistryScenario(t, "ret") - revision := s.established(t) - - s.deliverPresence(t, s.primary, 2, revision, func(presence *solutionhost.SolutionHostBinding) { - presence.Workloads[0].Identity.Subject = "system:serviceaccount:" + s.primary.namespace + ":intruder" - presence.Workloads[0].Identity.SPIFFEID = "spiffe://acme.test/ns/" + s.primary.namespace + "/sa/intruder" - }) - refusals := s.passReasons(t) - _, present := s.entry() - require.False(t, present, "the principal binds to the workload the envelope allows, not to whatever presence names next") - require.ErrorIs(t, refusals, business.ErrModuleWorkloadNotApproved) -} - -// TestAWidenedUnitIsRefusedAndActivatesNothing (finding 7): the document keeps -// an admitted unit id and widens its scope. The ceiling core compares against -// is the envelope's own unit, so the document is outside it — in the registry -// and in activation. -func TestAWidenedUnitIsRefusedAndActivatesNothing(t *testing.T) { - s := newRegistryScenario(t, "wid") - revision := s.putEnvelope(t, envelopeOptions{resources: true}) - s.deliverPresence(t, s.primary, 1, revision) - s.pass(t) - - units := s.primary.units() - units[1].Scope = "profiles:delete,profiles:read" - document := s.authority(t, s.primary, 1, revision, false, map[string][]solutionhost.AuthorityBinding{s.prefix: units}) - s.deliverAuthority(t, document) - refusals := s.passReasons(t) + other := deliverytest.Verifier(t, map[string][]string{"another-key": {productionPathDomain}}) + repinned := newRegistryReconciler(t, s.delivery.service, other, s.delivery.service) + refusals := repinned.RunOnce(testCtx) + require.Error(t, refusals) _, present := s.entry() require.False(t, present) - require.ErrorIs(t, refusals, business.ErrModuleAuthorityOutsideEnvelope) - require.Contains(t, refusals.Error(), document.Authority, "the refusal names the document") - - _, err := s.reconciler.AuthorityActivation().Activate(testCtx, s.primary.binding, s.primary.build) - require.ErrorIs(t, err, solutionhost.ErrOutsideEnvelope, "activation holds the document to the same envelope-built units") + require.NotNil(t, productionPathBindingRecord(t, s.delivery.service, s.primary.binding).Applied) } -// TestMovingThePrincipalsTenantRevokesTheOldTenantsCredentials (finding 1): the -// envelope moves the principal to another tenant. A credential sealed for the -// old tenant is refused at the next check, named as such; one for the new -// tenant is accepted. -func TestMovingThePrincipalsTenantRevokesTheOldTenantsCredentials(t *testing.T) { - s := newRegistryScenario(t, "ten") +// THE MINT, BY RULE: a projected token for the manifest's workload — the +// module's namespace, the service's account — binds; any other pair, and the +// same pair once the binding is withdrawn, does not. +func TestTheMintIsBoundToTheManifestsWorkloadByRule(t *testing.T) { + s := newRegistryScenario(t, "mint") s.established(t) - moved := seedOrg(t, s.admin) - require.NoError(t, testStore.As(business.Identity{OrgID: moved}).AddOrgMember(testCtx, s.admin, "admin")) - - revision := s.putEnvelope(t, envelopeOptions{resources: true, tenant: moved}) - s.deliverPresence(t, s.primary, 2, revision) - s.pass(t) - s.deliverAuthority(t, s.authority(t, s.primary, 2, revision, false, s.grantsTo(s.primary))) - s.pass(t) + cluster := executiontest.New() + s.delivery.service.SetExecutionBinding(cluster) + workload := s.primary.workload() + declared := executiontest.Workload{Namespace: workload.Namespace, ServiceAccount: workload.ServiceAccount, Container: workload.Container} - _, err := s.delivery.service.AuthorizeModuleCapability(testCtx, s.caller(s.tenant)) - require.ErrorIs(t, err, business.ErrModuleTenantNotCurrent, "the old tenant's credential is refused, and named as such") - _, err = testStore.LiveModuleAuthority(testCtx, business.ModulePrincipalID(s.prefix), s.tenant, "") - require.ErrorIs(t, err, business.ErrModuleInstallationInactive, "tenancy revoked the previous consent in the envelope transaction") - _, err = s.delivery.service.AuthorizeModuleCapability(testCtx, s.caller(moved)) - require.ErrorIs(t, err, business.ErrModuleInstallationInactive, "moving tenancy creates no consent") - s.install(t, moved) - _, err = s.delivery.service.AuthorizeModuleCapability(testCtx, s.caller(moved)) + identity, err := s.delivery.service.ModuleAuthorizeWorkContext(testCtx, s.prefix, cluster.Run(declared)) require.NoError(t, err) -} - -// TestADocumentOutsideTheEnvelopeGrantsNothing: signed and delivered is not -// allowed. A unit the envelope does not admit puts the whole document outside -// the ceiling, so the principal is absent and the refusal names the document. -func TestADocumentOutsideTheEnvelopeGrantsNothing(t *testing.T) { - s := newRegistryScenario(t, "out") - revision := s.putEnvelope(t, envelopeOptions{resources: true}) - s.deliverPresence(t, s.primary, 1, revision) - s.pass(t) - - units := append(s.primary.units(), solutionhost.AuthorityBinding{ - ID: s.primary.binding + ":admin:invoke", Revision: 1, Audience: "example-producer", Scope: "profiles:delete", - }) - document := s.authority(t, s.primary, 1, revision, false, map[string][]solutionhost.AuthorityBinding{s.prefix: units}) - s.deliverAuthority(t, document) - refusals := s.passReasons(t) - - _, present := s.entry() - require.False(t, present, "a document claiming one unit more than the envelope admits grants nothing at all") - require.ErrorIs(t, refusals, business.ErrModuleAuthorityOutsideEnvelope) - require.Contains(t, refusals.Error(), document.Authority, "the refusal names the document, so an operator can act on it") -} - -// TestAnUnreadableEnvelopeServesNothingItPredates: a stored revision this -// binary no longer accepts is not "no ceiling", and it is not permission to -// keep serving the revision before it either. Nothing is derived, and the -// fence refuses the older registry; the next valid revision is applied as -// usual. -func TestAnUnreadableEnvelopeServesNothingItPredates(t *testing.T) { - s := newRegistryScenario(t, "unr") - revision := s.established(t) - - // A revision written past the validation PutModuleEnvelope applies — the - // shape a row written by an older binary would have. - broken := []byte(`{"revision":` + jsonNumber(revision+1) + `,"principals":{"` + s.prefix + `":{"workload":{}}}}`) - require.NoError(t, testStore.WithControlPlane(testCtx, func(ctx context.Context) error { - return testStore.AppendModuleEnvelopeRecord(ctx, &business.ModuleEnvelopeRecord{ - Revision: revision + 1, Document: broken, WrittenBy: s.admin, - }, revision) - })) - err := s.reconciler.RunOnce(testCtx) - require.ErrorIs(t, err, business.ErrModulePrincipalInputsUnreadable) - _, err = s.delivery.service.AuthorizeModuleCapability(testCtx, s.caller(s.tenant)) - require.ErrorIs(t, err, business.ErrModuleRegistryStale, "a committed revision nobody can read still fences the one before it") + require.Equal(t, s.principalID(), identity.PrincipalID) + require.Equal(t, workload.Namespace, identity.Execution.Namespace) + + for name, wrong := range map[string]executiontest.Workload{ + "the bare module namespace": {Namespace: s.primary.solutionID, ServiceAccount: "api", Container: "api"}, + "another account": {Namespace: workload.Namespace, ServiceAccount: "intruder", Container: "api"}, + "another module's namespace": { + Namespace: productionPathNamespace("billing"), ServiceAccount: "api", Container: "api", + }, + } { + _, err := s.delivery.service.ModuleAuthorizeWorkContext(testCtx, s.prefix, cluster.Run(wrong)) + require.ErrorIs(t, err, business.ErrModuleIdentityDenied, name) + } - s.putEnvelope(t, envelopeOptions{resources: true}) + requireDelivered(t, s.delivery.deliver(t, productionPathWithdrawal(t, s.manifest(t, s.primary, 1), 2))) s.pass(t) - _, present := s.entry() - require.False(t, present, "the document rendered against the old revision is outside the new one") -} - -// TestTheEnvelopeIsWrittenOnlyAtTheNextRevision: a writer that read a stale -// revision is refused and writes nothing; a document carrying another revision -// than the one it replaces is refused; a caller who is not a super_admin is -// refused before anything is parsed. -func TestTheEnvelopeIsWrittenOnlyAtTheNextRevision(t *testing.T) { - s := newRegistryScenario(t, "rev") - current := s.currentRevision(t) - _, err := s.delivery.service.PutModuleEnvelope(testCtx, s.admin, current, s.envelope(t, current+1, envelopeOptions{resources: true})) - require.NoError(t, err) - - _, err = s.delivery.service.PutModuleEnvelope(testCtx, s.admin, current, s.envelope(t, current+1, envelopeOptions{})) - var storeErr *business.StoreError - require.True(t, errors.As(err, &storeErr)) - require.Equal(t, business.ErrTypeConflict, storeErr.StoreErrorType, "a stale expected revision is a conflict, never a second write") - - _, err = s.delivery.service.PutModuleEnvelope(testCtx, s.admin, current+1, s.envelope(t, current+3, envelopeOptions{resources: true})) - require.True(t, errors.As(err, &storeErr)) - require.Equal(t, business.ErrTypeValidation, storeErr.StoreErrorType) - - outsider := seedUser(t) - _, err = s.delivery.service.PutModuleEnvelope(testCtx, outsider, current+1, s.envelope(t, current+2, envelopeOptions{resources: true})) - require.True(t, errors.As(err, &storeErr)) - require.Equal(t, business.ErrTypePermission, storeErr.StoreErrorType) - _, err = s.delivery.service.GetModuleEnvelope(testCtx, outsider) - require.True(t, errors.As(err, &storeErr)) - require.Equal(t, business.ErrTypePermission, storeErr.StoreErrorType) - - record, err := s.delivery.service.GetModuleEnvelope(testCtx, s.admin) - require.NoError(t, err) - require.Equal(t, current+1, record.Revision, "none of the refused writes was stored") - require.Equal(t, s.admin, record.WrittenBy) - require.True(t, strings.Contains(string(record.Document), s.resource)) + _, err = s.delivery.service.ModuleAuthorizeWorkContext(testCtx, s.prefix, cluster.Run(declared)) + require.ErrorIs(t, err, business.ErrModuleIdentityDenied, "a withdrawn binding's workload mints nothing") } -func jsonNumber(value uint64) string { - raw, _ := json.Marshal(value) - return string(raw) -} - -// Registry tests explicitly install only when they exercise capability use. -// Delivery and reconciliation themselves never synthesize consent. -func (s *registryScenario) install(t *testing.T, org string) *gen.Installation { - t.Helper() - available, _, err := s.delivery.service.ListAvailableSolutions(testCtx, org, "", 500) - require.NoError(t, err) - var target string - for _, entry := range available { - if entry.BindingID == s.primary.binding { - target = entry.TargetID - } - } - require.NotEmpty(t, target) - role := business.NewIDString() - require.NoError(t, testStore.WithOrgTx(testCtx, org, func(ctx context.Context) error { - return testStore.CreateRole(ctx, &gen.Role{Id: role, OrgId: org, Name: "Example role " + role, Permissions: []*gen.Permission{{Resource: "solution", Action: "use"}}}) - })) - installation, err := s.delivery.service.InstallSolution(testCtx, s.admin, &business.InstallSolutionParams{ - OrgID: org, TargetID: target, AgentIdentifier: "acme.example/registry:1.0.0", RoleID: role, - }) - require.NoError(t, err) - acknowledgeInstallation(t, installation) - return installation -} - -// This scenario starts at signed delivery and the persisted operator envelope, -// not a seeded principal registry. Empty consumed scopes still yield distinct -// organisation installations on one running host service. -func TestEmptyPermissionDeliveryInstallsIndependentlyInTwoOrganizations(t *testing.T) { +// A manifest asking for no operation installs independently in two +// organisations: empty consumed scopes are still an installable principal. +func TestAManifestAskingForNothingInstallsIndependentlyInTwoOrganizations(t *testing.T) { s := newRegistryScenario(t, "empty") current := s.currentRevision(t) - var wire map[string]any - require.NoError(t, json.Unmarshal(s.envelope(t, current+1, envelopeOptions{}), &wire)) - principal := wire["principals"].(map[string]any)[s.prefix].(map[string]any) - principal["bindings"] = []any{} - principal["admitted_bindings"] = []string{s.primary.binding} - principal["scope_ceilings"] = []any{} - principal["grant"] = map[string]any{"cross_tenant": true} - raw, err := json.Marshal(wire) + raw, err := json.Marshal(map[string]any{"revision": current + 1, "principals": map[string]any{ + s.prefix: map[string]any{"grant": map[string]any{"cross_tenant": true}}, + }}) require.NoError(t, err) - record, err := s.delivery.service.PutModuleEnvelope(testCtx, s.admin, current, raw) + _, err = s.delivery.service.PutModuleEnvelope(testCtx, s.admin, current, raw) require.NoError(t, err) - s.deliverPresence(t, s.primary, 1, record.Revision) - s.pass(t) - document := s.authority(t, s.primary, 1, record.Revision, false, map[string][]solutionhost.AuthorityBinding{s.prefix: {}}) - document.Queues, document.Namespaces, document.ScopeCeilings = []string{}, []string{}, []solutionhost.ScopeCeiling{} - s.deliverAuthority(t, document) + manifest := s.manifest(t, s.primary, 1) + manifest.Authority = &solutionhost.Authority{Principal: s.prefix} + requireDelivered(t, s.delivery.deliver(t, manifest)) s.pass(t) grant, present := s.entry() require.True(t, present) require.Empty(t, grant.OperationAudiences) - require.Empty(t, grant.ReadAudiences) require.Empty(t, grant.Queues) require.Empty(t, grant.Resources) otherOrg := seedOrg(t, s.admin) diff --git a/module/services/accounts/code/pkg/infra/module_registry_snapshot_test.go b/module/services/accounts/code/pkg/infra/module_registry_snapshot_test.go deleted file mode 100644 index 1362e39fa..000000000 --- a/module/services/accounts/code/pkg/infra/module_registry_snapshot_test.go +++ /dev/null @@ -1,163 +0,0 @@ -//go:build !pure - -package infra_test - -import ( - "context" - "testing" - "time" - - "accounts/internal/deliverytest" - "accounts/pkg/business" - "accounts/pkg/infra" - - "github.com/codefly-dev/core/solutionhost" - "github.com/jackc/pgx/v5/pgconn" - "github.com/stretchr/testify/require" -) - -// Pause after the actual SELECT has materialized its rows. The callback commits -// through another connection; it never changes or substitutes the read result. -type admissionBetweenRegistryReadsStore struct { - *infra.PostgresStore - afterRead func() -} - -func (s *admissionBetweenRegistryReadsStore) ListSolutionHostBindings(ctx context.Context) ([]*business.SolutionHostBindingRecord, error) { - records, err := s.PostgresStore.ListSolutionHostBindings(ctx) - if err == nil && s.afterRead != nil { - after := s.afterRead - s.afterRead = nil - after() - } - return records, err -} - -func TestRegistrySnapshotCannotRestoreAuthorityDuringFirstAdmission(t *testing.T) { - s := newRegistryScenario(t, "snapshot-admission") - revision := s.putEnvelope(t, envelopeOptions{resources: true}) - s.deliverPresence(t, s.primary, 1, revision) - s.deliverAuthority(t, s.authority(t, s.primary, 1, revision, true, nil)) - renamed := s.authority(t, s.primary, 1, revision, false, s.grantsTo(s.primary)) - renamed.Authority += "-renamed" - s.deliverAuthority(t, renamed) - - store := &admissionBetweenRegistryReadsStore{PostgresStore: testStore} - replica, err := business.NewService(store) - require.NoError(t, err) - replica.SetModuleEnvelopeStore(testStore) - replica.SetModuleAuthorityReads(testStore, nil) - verifier := productionVerifier(t) - policy := map[string][]string{productionPathSigner: {productionPathDomain}} - replica.SetSolutionDelivery(verifier, testStore, nil, policy) - presenceReconciler := newRegistryReconciler(t, replica, verifier, policy, replica) - registry, err := business.NewModulePrincipalReconciler(presenceReconciler.AuthorityActivation()) - require.NoError(t, err) - - // The first replica has delivered presence, but neither replica has applied - // it yet. Run just the registry on the second replica. - require.ErrorIs(t, registry.RunOnce(testCtx), business.ErrModulePrincipalOmitted) - _, present := entryOn(replica, s.prefix) - require.False(t, present) - - committed := false - store.afterRead = func() { - // Real core admission and Postgres commit while the other replica's - // snapshot remains open. Its reads hold no write lock on this binding. - require.NotErrorIs(t, s.passReasons(t), business.ErrModulePrincipalInputsUnreadable) - records, err := s.delivery.service.ListSolutionHostBindings(testCtx) - require.NoError(t, err) - for _, record := range records { - if record.BindingID == s.primary.binding { - require.NotNil(t, record.Applied) - require.Equal(t, productionPathDomain, record.Applied.Domain) - committed = true - } - } - require.True(t, committed, "the first admission must actually commit") - } - err = registry.RunOnce(testCtx) - require.True(t, committed) - _, present = entryOn(replica, s.prefix) - require.False(t, present, "new presence must not be paired with an ownerless withdrawal map") - require.ErrorIs(t, err, business.ErrModulePrincipalOmitted) - grant, err := replica.AuthorizeModuleCapability(testCtx, s.caller(s.tenant)) - require.Error(t, err) - require.Nil(t, grant) - - require.ErrorIs(t, registry.RunOnce(testCtx), business.ErrSolutionAuthorityWithdrawn) - _, present = entryOn(replica, s.prefix) - require.False(t, present, "the next snapshot sees the admitted owner and its terminal tombstone together") -} - -func TestControlPlaneReadSnapshotKeepsPresenceAndWithdrawalHistoryTogether(t *testing.T) { - alias := uniqueAlias("registry-snapshot") - binding := "binding-" + alias - now := time.Now().UTC() - applied := appliedPresenceRecord(t, binding, alias, 1, now, false) - raw, err := solutionhost.FixtureDocument(solutionhost.DocumentTypeAuthority, "valid") - require.NoError(t, err) - document, err := solutionhost.ParseAuthority(raw) - require.NoError(t, err) - document.Authority, document.PresenceBinding = "authority-"+alias, binding - document.Removed = true - document.ApprovedBuild, document.EffectiveFrom, document.Principals = "", 0, nil - document.Queues, document.Namespaces, document.ScopeCeilings = []string{}, []string{}, []solutionhost.ScopeCeiling{} - document.OwnershipDomain = applied.Applied.Domain - document.Host = solutionhost.HostTarget{Coordinate: applied.HostCoordinate, Component: applied.HostComponent} - payload, err := document.CanonicalBytes() - require.NoError(t, err) - withdrawal := &business.SolutionDeliveryRecord{ - Kind: business.SolutionDeliveryAuthority, DocumentID: document.Authority, - Generation: document.Generation, ContentHash: fixtureDigest(string(payload)), - Payload: payload, Bundle: deliverytest.Envelope(t, payload, solutionhost.FixtureDeliveredBy), - SignerIdentity: productionPathSigner, OwnershipDomain: document.OwnershipDomain, ReceivedAt: now, - } - assertVisible := func(ctx context.Context, want bool) { - t.Helper() - records, err := testStore.ListSolutionHostBindings(ctx) - require.NoError(t, err) - found := false - for _, record := range records { - found = found || record.BindingID == binding - } - require.Equal(t, want, found, "applied presence") - for _, read := range []func(context.Context, business.SolutionDeliveryKind) ([]*business.SolutionDeliveryRecord, error){ - testStore.ListDeliveredDocuments, testStore.ListNewestDeliveredDocuments, - } { - rows, err := read(ctx, business.SolutionDeliveryAuthority) - require.NoError(t, err) - found = false - for _, row := range rows { - found = found || row.DocumentID == document.Authority - } - require.Equal(t, want, found, "withdrawal history and newest authority") - } - } - require.NoError(t, testStore.WithControlPlaneReadSnapshot(testCtx, func(ctx context.Context) error { - assertVisible(ctx, false) - // Deliberately use the outer context: a different transaction commits - // both relations after the snapshot's first read. - require.NoError(t, testStore.WithControlPlane(testCtx, func(writer context.Context) error { - if err := testStore.SaveSolutionHostBinding(writer, applied); err != nil { - return err - } - _, err := testStore.RecordDeliveredDocument(writer, withdrawal) - return err - })) - assertVisible(ctx, false) - return nil - })) - require.NoError(t, testStore.WithControlPlaneReadSnapshot(testCtx, func(ctx context.Context) error { - assertVisible(ctx, true) - return nil - })) - - // READ ONLY must be enforced by Postgres, even for the control-plane role. - err = testStore.WithControlPlaneReadSnapshot(testCtx, func(ctx context.Context) error { - return testStore.SaveSolutionHostBinding(ctx, applied) - }) - var pgErr *pgconn.PgError - require.ErrorAs(t, err, &pgErr) - require.Equal(t, "25006", pgErr.Code) -} diff --git a/module/services/accounts/code/pkg/infra/native_workload_identity.go b/module/services/accounts/code/pkg/infra/native_workload_identity.go new file mode 100644 index 000000000..369e4366c --- /dev/null +++ b/module/services/accounts/code/pkg/infra/native_workload_identity.go @@ -0,0 +1,133 @@ +package infra + +import ( + "context" + "errors" + "fmt" + "sync" + + "accounts/pkg/business" + + "github.com/codefly-dev/core/workloadidentity" + "github.com/codefly-dev/core/workloadidentity/native" +) + +// NativeWorkloadIdentity is the host's workload identity under a native +// `codefly run`: the run's launcher signs each process it started — and the +// delivery carrier and applied observer it acts as — a credential of core's +// workloadidentity/native mechanism, and this host verifies it against the +// run's anchor (CODEFLY_NATIVE_IDENTITY_ANCHOR), the way a cell's host reviews +// a projected token. Nothing imitates a cluster: the launcher names the +// declared workload, and that workload is the identity. +// +// It answers the three questions the Kubernetes client answers in a cell: +// who handed over a manifest (the `delivery` workload of the manifest's +// module), who reads applied state (core's AppliedObserver), and which +// workload a minting process is. A native run's namespace is its module: the +// host runs the native rule (business.NativeModuleNamespaceRule). +type NativeWorkloadIdentity struct { + attester *native.Attester + + // running holds the facts of each attested instance, by module and + // instance, so the pod read that follows a review answers about the same + // credential. + mu sync.Mutex + running map[string]*workloadidentity.Facts +} + +// NewNativeWorkloadIdentity wraps the run's attester. +func NewNativeWorkloadIdentity(attester *native.Attester) *NativeWorkloadIdentity { + return &NativeWorkloadIdentity{attester: attester, running: map[string]*workloadidentity.Facts{}} +} + +func (n *NativeWorkloadIdentity) attest(ctx context.Context, token, audience string) (*workloadidentity.Attestation, error) { + if n == nil || n.attester == nil { + return nil, fmt.Errorf("%w: this host has no native anchor", ErrKubernetesUnavailable) + } + if token == "" { + return nil, fmt.Errorf("%w: no credential was presented", ErrCarrierUnauthenticated) + } + attested, err := n.attester.Attest(ctx, token, audience) + switch { + case errors.Is(err, workloadidentity.ErrRejected): + return nil, fmt.Errorf("%w: %w", ErrCarrierUnauthenticated, err) + case err != nil: + return nil, fmt.Errorf("%w: %w", ErrKubernetesUnavailable, err) + } + return attested, nil +} + +// AuthorizeCarrier requires the credential to name the `delivery` workload of +// module (a native namespace is its module). +func (n *NativeWorkloadIdentity) AuthorizeCarrier(ctx context.Context, credential, module string) error { + attested, err := n.attest(ctx, bearerToken(credential), deliveryTokenAudience) + if err != nil { + return err + } + if attested.Workload.Module != module || attested.Workload.Service != workloadidentity.DeliveryService { + return fmt.Errorf("%w: the manifest is delivered by %s/%s, and the carrier is %s", + ErrCarrierNotAuthorized, module, workloadidentity.DeliveryService, attested.Workload.Unique()) + } + return nil +} + +// AuthorizeAppliedObserver requires core's applied observer. +func (n *NativeWorkloadIdentity) AuthorizeAppliedObserver(ctx context.Context, credential string) error { + attested, err := n.attest(ctx, bearerToken(credential), deliveryTokenAudience) + if err != nil { + return err + } + observer := workloadidentity.AppliedObserver + if attested.Workload.Module != observer.Module || attested.Workload.Service != observer.Service { + return fmt.Errorf("%w: caller %s is not the applied-state observer", ErrCarrierNotAuthorized, attested.Workload.Unique()) + } + return nil +} + +// NativeDevelopment is true: no mesh presents a native process. +func (*NativeWorkloadIdentity) NativeDevelopment() bool { return true } + +// ReviewToken attests a minting process's credential: its module is the +// namespace, its service the account, its instance the pod, and the +// credential's own id the incarnation. +func (n *NativeWorkloadIdentity) ReviewToken(ctx context.Context, token, audience string) (*business.ReviewedExecutionToken, error) { + attested, err := n.attest(ctx, token, audience) + if errors.Is(err, ErrCarrierUnauthenticated) { + return nil, fmt.Errorf("%w: %w", business.ErrExecutionTokenRejected, err) + } + if err != nil { + return nil, err + } + n.mu.Lock() + n.running[attested.Workload.Module+"/"+attested.Workload.Instance] = attested.Facts + n.mu.Unlock() + return &business.ReviewedExecutionToken{ + ServiceAccount: attested.Workload.Service, Namespace: attested.Workload.Module, + PodName: attested.Workload.Instance, PodUID: attested.Facts.InstanceUID, + IssuedAt: attested.Credential.IssuedAt, ExpiresAt: attested.Credential.ExpiresAt, + }, nil +} + +// ReadPod answers what the launcher vouched for about the instance it attested. +func (n *NativeWorkloadIdentity) ReadPod(_ context.Context, namespace, pod string) (*business.PodExecution, error) { + n.mu.Lock() + facts, attested := n.running[namespace+"/"+pod] + n.mu.Unlock() + if !attested || facts == nil { + return nil, fmt.Errorf("%w: native instance %s/%s was not attested", ErrKubernetesUnavailable, namespace, pod) + } + read := &business.PodExecution{UID: facts.InstanceUID, Phase: facts.Phase, Terminating: facts.Terminating} + for _, container := range facts.Containers { + read.Containers = append(read.Containers, business.ContainerExecution{ + Name: container.Name, Kind: business.ContainerKind(container.Kind), State: business.ContainerState(container.State), + Restartable: container.Restartable, + }) + } + return read, nil +} + +var ( + _ business.SolutionDeliveryCarrierAuthorizer = (*NativeWorkloadIdentity)(nil) + _ business.SolutionDeliveryObserverAuthorizer = (*NativeWorkloadIdentity)(nil) + _ business.ExecutionReviewer = (*NativeWorkloadIdentity)(nil) +) diff --git a/module/services/accounts/code/pkg/infra/native_workload_identity_test.go b/module/services/accounts/code/pkg/infra/native_workload_identity_test.go new file mode 100644 index 000000000..6bda5c84c --- /dev/null +++ b/module/services/accounts/code/pkg/infra/native_workload_identity_test.go @@ -0,0 +1,150 @@ +package infra_test + +import ( + "context" + "testing" + + "accounts/internal/deliverytest" + "accounts/pkg/business" + "accounts/pkg/infra" + + "github.com/codefly-dev/core/solutionhost" + "github.com/codefly-dev/core/workloadidentity" + "github.com/codefly-dev/core/workloadidentity/native" + "github.com/stretchr/testify/require" +) + +// A native `codefly run` has no cluster: its launcher signs each process a +// credential of core's native mechanism, and the host verifies it against the +// run's anchor. These tests are the launcher's side (core's real Issuer) and +// the host's (NativeWorkloadIdentity, the key verifier's native binding). + +const nativeTrustDomain = "cluster.local" + +func nativeWorkload(module, service, instance string) workloadidentity.Workload { + return workloadidentity.Workload{TrustDomain: nativeTrustDomain, Module: module, Service: service, Instance: instance} +} + +// nativeRun is one run's issuer, vouching for the reports module's delivery +// carrier and api workload, and for the applied observer. +func nativeRun(t *testing.T) (*native.Issuer, *infra.NativeWorkloadIdentity) { + t.Helper() + issuer, err := native.NewIssuer() + require.NoError(t, err) + issuer.Declare( + nativeWorkload("reports", workloadidentity.DeliveryService, ""), + nativeWorkload("reports", "api", ""), + nativeWorkload(workloadidentity.AppliedObserver.Module, workloadidentity.AppliedObserver.Service, ""), + ) + attester, err := native.NewAttester(issuer.Anchor()) + require.NoError(t, err) + return issuer, infra.NewNativeWorkloadIdentity(attester) +} + +func nativeCredential(t *testing.T, issuer *native.Issuer, workload workloadidentity.Workload, container string) string { + t.Helper() + token, err := issuer.Issue(native.Subject{Workload: workload, Container: container}, "accounts") + require.NoError(t, err) + return token +} + +// THE CARRIER IS THE MODULE'S DELIVERY WORKLOAD, as the launcher vouches for +// it; another workload of the module, another module's carrier, and a +// credential another run signed are each refused for what they are. +func TestANativeCarrierIsTheModulesDeliveryWorkload(t *testing.T) { + issuer, identity := nativeRun(t) + ctx := context.Background() + carrier := nativeCredential(t, issuer, nativeWorkload("reports", workloadidentity.DeliveryService, "delivery-1"), "") + + require.NoError(t, identity.AuthorizeCarrier(ctx, "Bearer "+carrier, "reports")) + require.ErrorIs(t, identity.AuthorizeCarrier(ctx, "Bearer "+carrier, "billing"), infra.ErrCarrierNotAuthorized) + api := nativeCredential(t, issuer, nativeWorkload("reports", "api", "api-1"), "api") + require.ErrorIs(t, identity.AuthorizeCarrier(ctx, "Bearer "+api, "reports"), infra.ErrCarrierNotAuthorized) + + other, _ := nativeRun(t) + foreign := nativeCredential(t, other, nativeWorkload("reports", workloadidentity.DeliveryService, "delivery-1"), "") + require.ErrorIs(t, identity.AuthorizeCarrier(ctx, "Bearer "+foreign, "reports"), infra.ErrCarrierUnauthenticated) + require.ErrorIs(t, identity.AuthorizeCarrier(ctx, "", "reports"), infra.ErrCarrierUnauthenticated) + + observer := nativeCredential(t, issuer, nativeWorkload(workloadidentity.AppliedObserver.Module, workloadidentity.AppliedObserver.Service, "observer-1"), "") + require.NoError(t, identity.AuthorizeAppliedObserver(ctx, "Bearer "+observer)) + require.ErrorIs(t, identity.AuthorizeAppliedObserver(ctx, "Bearer "+carrier), infra.ErrCarrierNotAuthorized, + "the delivery carrier cannot read applied state") +} + +// A MINTING PROCESS IS THE WORKLOAD THE LAUNCHER STARTED: the review names +// its module as the namespace and its service as the account, and the pod +// read that follows answers with what the launcher vouched for about that +// same instance — never another module's. +func TestANativeMintIsReviewedAsTheWorkloadTheLauncherStarted(t *testing.T) { + issuer, identity := nativeRun(t) + ctx := context.Background() + token := nativeCredential(t, issuer, nativeWorkload("reports", "api", "api-1"), "api") + + reviewed, err := identity.ReviewToken(ctx, token, "accounts") + require.NoError(t, err) + require.Equal(t, "reports", reviewed.Namespace) + require.Equal(t, "api", reviewed.ServiceAccount) + require.Equal(t, "api-1", reviewed.PodName) + require.NotEmpty(t, reviewed.PodUID) + + pod, err := identity.ReadPod(ctx, "reports", "api-1") + require.NoError(t, err) + require.Equal(t, reviewed.PodUID, pod.UID) + require.Len(t, pod.Containers, 1) + require.Equal(t, "api", pod.Containers[0].Name) + + _, err = identity.ReadPod(ctx, "billing", "api-1") + require.ErrorIs(t, err, infra.ErrKubernetesUnavailable, "an instance is read only under the module it was attested for") + + _, err = identity.ReviewToken(ctx, token, "another-audience") + require.ErrorIs(t, err, business.ErrExecutionTokenRejected) + other, _ := nativeRun(t) + _, err = identity.ReviewToken(ctx, nativeCredential(t, other, nativeWorkload("reports", "api", "api-1"), "api"), "accounts") + require.ErrorIs(t, err, business.ErrExecutionTokenRejected) +} + +// A LOCAL PROFILE BOUND TO A NATIVE RUN admits its developer only under the +// run whose anchor it pins: another run's anchor is refused at boot, and so is +// a cluster. A profile bound to a cluster is not a native one, and a profile +// may not name both. +func TestANativeLocalProfileIsProvenAgainstItsRunsAnchor(t *testing.T) { + run, _ := nativeRun(t) + other, _ := nativeRun(t) + policy := func(local *infra.SolutionHostLocalProfile) *infra.SolutionHostVerificationPolicy { + return &infra.SolutionHostVerificationPolicy{ + Local: local, + Signers: []infra.SolutionHostSigner{{Name: "developer", Kind: solutionhost.KeyKindPerson, KeyID: "example-developer", + PublicKey: deliverytest.PublicKey(t, "example-developer"), Domains: []string{verifierTestDomain}}}, + } + } + carrier := deliverytest.Carrier(t, bindingPayload(t), "example-developer") + + bound := policy(&infra.SolutionHostLocalProfile{NativeAnchorSHA256: run.Anchor().Fingerprint()}) + verifier, err := infra.NewSolutionHostKeyVerifierFrom(bound) + require.NoError(t, err) + _, err = verifyPresence(t, verifier, carrier) + require.ErrorIs(t, err, infra.ErrSolutionHostLocalPolicyOutsideItsCluster, "nothing is admitted before the run is proven") + require.ErrorIs(t, verifier.ProveNativeRun(other.Anchor().Fingerprint()), infra.ErrSolutionHostLocalPolicyOutsideItsCluster) + require.ErrorIs(t, verifier.ProveLocalCluster(context.Background(), newCluster(t).host(t)), infra.ErrSolutionHostLocalPolicyOutsideItsCluster) + require.NoError(t, verifier.ProveNativeRun(run.Anchor().Fingerprint())) + _, err = verifyPresence(t, verifier, carrier) + require.NoError(t, err) + + cluster := newCluster(t) + clusterBound, err := infra.NewSolutionHostKeyVerifierFrom(localPolicy(t, cluster.fingerprint)) + require.NoError(t, err) + require.ErrorIs(t, clusterBound.ProveNativeRun(run.Anchor().Fingerprint()), infra.ErrSolutionHostLocalPolicyOutsideItsCluster) + deployed, err := infra.NewSolutionHostKeyVerifierFrom(acceptedPolicy(t)) + require.NoError(t, err) + require.ErrorIs(t, deployed.ProveNativeRun(run.Anchor().Fingerprint()), infra.ErrSolutionHostLocalPolicyOutsideItsCluster, + "a deployed policy is never pointed at a native anchor") + + for _, local := range []*infra.SolutionHostLocalProfile{ + {NativeAnchorSHA256: run.Anchor().Fingerprint(), ClusterCertificateAuthoritySHA256: cluster.fingerprint}, + {NativeAnchorSHA256: "not-a-fingerprint"}, + } { + _, err := infra.NewSolutionHostKeyVerifierFrom(policy(local)) + require.Error(t, err) + } +} diff --git a/module/services/accounts/code/pkg/infra/postgres_module_installations_test.go b/module/services/accounts/code/pkg/infra/postgres_module_installations_test.go index be0a852eb..e8ee4910e 100644 --- a/module/services/accounts/code/pkg/infra/postgres_module_installations_test.go +++ b/module/services/accounts/code/pkg/infra/postgres_module_installations_test.go @@ -159,9 +159,9 @@ func TestModuleInstallationPostgresHTTPAuthenticationAndAudit(t *testing.T) { // `example.api` here is how a composition declares it. installerWorkload := executiontest.Workload{Namespace: "acme-prod", ServiceAccount: "installer", Container: "app"} svc.SetModuleCapabilities(nil, nil, business.ModulePrincipalRegistry{ - p.InstallerPrincipalID: {Prefix: "example-installer", Tenant: p.OrgID, Workload: business.ModuleWorkload{ + p.InstallerPrincipalID: {Prefix: "example-installer", Tenant: p.OrgID, Workloads: []business.ModuleWorkload{{ Namespace: installerWorkload.Namespace, ServiceAccount: installerWorkload.ServiceAccount, Container: installerWorkload.Container, - }}, + }}}, business.ModulePrincipalID("example.api"): {Prefix: "example.api"}, }) installCapabilityFixture(t, svc, p.OrgID, p.OwnerPrincipalID) @@ -183,12 +183,13 @@ func TestModuleInstallationPostgresHTTPAuthenticationAndAudit(t *testing.T) { })) svc.InstallDerivedModulePrincipals(registry, envelope, nil) // The installer proves which module it is with its pod's projected token, - // bound to the build its authority approves (#1056). + // bound to a workload its manifest lists (#1056). cluster := executiontest.New() - svc.SetExecutionBinding(cluster, cluster) - cluster.Approve("example-installer", executiontest.Digest("installer-v1"), 1) - installerToken := cluster.Run(installerWorkload, executiontest.Image("installer-v1")) - unapprovedToken := cluster.Run(installerWorkload, executiontest.Image("installer-unreviewed")) + svc.SetExecutionBinding(cluster) + installerToken := cluster.Run(installerWorkload) + undeclared := installerWorkload + undeclared.ServiceAccount = "intruder" + unapprovedToken := cluster.Run(undeclared) _, private, err := ed25519.GenerateKey(rand.Reader) require.NoError(t, err) previousAuthority := *adapters.WorkContextSingleton() diff --git a/module/services/accounts/code/pkg/infra/postgres_solution_delivery.go b/module/services/accounts/code/pkg/infra/postgres_solution_delivery.go index e724851e0..a4b37fa4a 100644 --- a/module/services/accounts/code/pkg/infra/postgres_solution_delivery.go +++ b/module/services/accounts/code/pkg/infra/postgres_solution_delivery.go @@ -18,28 +18,30 @@ import ( // and no DELETE. An inbox that can be edited is not a record of what was // received, so a correction is a new generation rather than a rewrite. Every // method assumes the caller opened a WithControlPlane transaction. +// +// It holds one kind of document, the module manifest (migration 41): the row's +// document_id is the manifest's binding. + +// solutionDeliveryManifestKind is the one kind the inbox holds. +const solutionDeliveryManifestKind = "manifest" -const solutionDeliveryColumns = `kind, document_id, generation, content_hash, +const solutionDeliveryColumns = `document_id, generation, content_hash, payload, bundle, signer_identity, ownership_domain, received_at` // RecordDeliveredDocument inserts one received carrier, and reports whether the // row was new. // -// Idempotency is the whole of this method. The unique index on -// (kind, document_id, generation) means a second delivery at the same generation -// collides, and the collision has two meanings that must NOT be conflated: +// The unique index on (kind, document_id, generation) means a second delivery +// at the same generation collides, and the collision has two meanings that +// must NOT be conflated: // // - the same bytes again — a retry, or a re-sync of an unchanged tree. Writes -// nothing, reports not-inserted, and the caller answers 200. -// - DIFFERENT bytes at a generation already delivered — a rewrite of a -// delivered generation, which publish never produces. Refused as -// ErrSolutionDeliveryRewritten, and the caller answers 409 so an operator -// sees it as the hand-edited delivery tree it is. +// nothing and reports not-inserted. +// - DIFFERENT bytes at a generation already delivered — a rewrite, which +// publish never produces. Refused as ErrSolutionDeliveryConflict. // -// `ON CONFLICT DO NOTHING` alone could not tell those apart: it reports no row -// written for both, and the second case would then read as a successful replay -// while the host kept serving different bytes than delivery believes it sent. -// So the conflict is resolved by reading the stored content hash back. +// `ON CONFLICT DO NOTHING` alone could not tell those apart, so the stored row +// is read back and compared. func (s *PostgresStore) RecordDeliveredDocument( ctx context.Context, record *business.SolutionDeliveryRecord, ) (bool, error) { @@ -48,12 +50,12 @@ func (s *PostgresStore) RecordDeliveredDocument( var storedHash string var storedPayload []byte err := executor.QueryRow(ctx, ` - INSERT INTO public.solution_delivery_documents (`+solutionDeliveryColumns+`) + INSERT INTO public.solution_delivery_documents (kind, `+solutionDeliveryColumns+`) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) ON CONFLICT (kind, document_id, generation) DO NOTHING RETURNING content_hash, payload`, - string(record.Kind), record.DocumentID, int64(record.Generation), record.ContentHash, - record.Payload, record.Bundle, record.SignerIdentity, record.OwnershipDomain, record.ReceivedAt, + solutionDeliveryManifestKind, record.Binding, int64(record.Generation), record.ContentHash, //nolint:gosec // a manifest generation is a small positive counter + record.Payload, record.Envelope, record.SignerIdentity, record.OwnershipDomain, record.ReceivedAt, ).Scan(&storedHash, &storedPayload) if err == nil { return true, nil @@ -61,112 +63,81 @@ func (s *PostgresStore) RecordDeliveredDocument( if !errors.Is(err, pgx.ErrNoRows) { return false, err } - - // The insert was a no-op, so this generation already exists. Which of the - // two meanings it has depends on the bytes. if err := executor.QueryRow(ctx, ` SELECT content_hash, payload FROM public.solution_delivery_documents WHERE kind = $1 AND document_id = $2 AND generation = $3`, - string(record.Kind), record.DocumentID, int64(record.Generation), + solutionDeliveryManifestKind, record.Binding, int64(record.Generation), //nolint:gosec // a manifest generation is a small positive counter ).Scan(&storedHash, &storedPayload); err != nil { return false, err } - // The hash is compared AND the bytes are, rather than the hash alone. The - // hash is what the schema constrains and what an index could be built on, - // but it is also a value a writer supplies: comparing the payload as well - // means a collision cannot be talked past by sending a matching hash with - // different content. if storedHash == record.ContentHash && bytes.Equal(storedPayload, record.Payload) { return false, nil } - return false, fmt.Errorf("%w: %s %s generation %d was delivered as %s and is now offered as %s", - business.ErrSolutionDeliveryRewritten, - record.Kind, record.DocumentID, record.Generation, storedHash, record.ContentHash) + return false, fmt.Errorf("%w: binding %s generation %d was delivered as %s and is now offered as %s", + business.ErrSolutionDeliveryConflict, record.Binding, record.Generation, storedHash, record.ContentHash) } -// ListNewestDeliveredDocuments returns the newest generation per document for -// one kind: the desired set. -// -// DISTINCT ON over (document_id) ordered by generation DESC, which is the -// newest-per-key read the index `solution_delivery_documents_newest` exists for. -// Superseded generations stay in the table — they are the evidence of what was -// delivered — and are deliberately not returned: the desired set is what -// delivery wants NOW, and handing the reconciler every generation would make it -// judge a set containing its own history. -func (s *PostgresStore) ListNewestDeliveredDocuments( - ctx context.Context, kind business.SolutionDeliveryKind, -) ([]*business.SolutionDeliveryRecord, error) { +// ListNewestDeliveredDocuments returns the newest delivered generation per +// binding: the desired set. +func (s *PostgresStore) ListNewestDeliveredDocuments(ctx context.Context) ([]*business.SolutionDeliveryRecord, error) { rows, err := s.getQueryExecutor(ctx).Query(ctx, ` SELECT DISTINCT ON (document_id) `+solutionDeliveryColumns+` FROM public.solution_delivery_documents WHERE kind = $1 - ORDER BY document_id, generation DESC`, string(kind)) + ORDER BY document_id, generation DESC`, solutionDeliveryManifestKind) if err != nil { return nil, err } defer rows.Close() - records := []*business.SolutionDeliveryRecord{} for rows.Next() { - var ( - record business.SolutionDeliveryRecord - kindText string - generation int64 - ) - if err := rows.Scan(&kindText, &record.DocumentID, &generation, &record.ContentHash, - &record.Payload, &record.Bundle, &record.SignerIdentity, &record.OwnershipDomain, - &record.ReceivedAt); err != nil { + record, err := scanSolutionDelivery(rows) + if err != nil { return nil, err } - record.Kind = business.SolutionDeliveryKind(kindText) - record.Generation = uint64(generation) - records = append(records, &record) + records = append(records, record) } return records, rows.Err() } -// ListDeliveredDocuments returns EVERY delivered generation of one kind, oldest -// first. The desired set is the newest per document; this is the history, and -// it is what a withdrawal is read from: a tombstone superseded by a later -// generation of the same document is still a withdrawal that happened, and the -// newest-per-document read would hide it. -func (s *PostgresStore) ListDeliveredDocuments( - ctx context.Context, kind business.SolutionDeliveryKind, -) ([]*business.SolutionDeliveryRecord, error) { - rows, err := s.getQueryExecutor(ctx).Query(ctx, ` - SELECT `+solutionDeliveryColumns+` - FROM public.solution_delivery_documents - WHERE kind = $1 - ORDER BY document_id, generation`, string(kind)) - if err != nil { - return nil, err +// ReadDeliveredDocument reads the exact inbox generation selected by applied +// state, or nil. Absence is not permission to use a newer or older delivery. +func (s *PostgresStore) ReadDeliveredDocument( + ctx context.Context, binding string, generation uint64, +) (*business.SolutionDeliveryRecord, error) { + if generation == 0 || generation > uint64(1<<63-1) { + return nil, fmt.Errorf("delivery generation is outside the stored range") } - defer rows.Close() - records := []*business.SolutionDeliveryRecord{} - for rows.Next() { - var ( - record business.SolutionDeliveryRecord - kindText string - generation int64 - ) - if err := rows.Scan(&kindText, &record.DocumentID, &generation, &record.ContentHash, - &record.Payload, &record.Bundle, &record.SignerIdentity, &record.OwnershipDomain, - &record.ReceivedAt); err != nil { - return nil, err - } - record.Kind = business.SolutionDeliveryKind(kindText) - record.Generation = uint64(generation) //nolint:gosec // CHECKed positive - records = append(records, &record) + record, err := scanSolutionDelivery(s.getQueryExecutor(ctx).QueryRow(ctx, ` + SELECT `+solutionDeliveryColumns+` FROM public.solution_delivery_documents + WHERE kind = $1 AND document_id = $2 AND generation = $3`, + solutionDeliveryManifestKind, binding, int64(generation))) //nolint:gosec // range CHECKed above + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil } - return records, rows.Err() + return record, err +} + +func scanSolutionDelivery(row pgx.Row) (*business.SolutionDeliveryRecord, error) { + var ( + record business.SolutionDeliveryRecord + generation int64 + ) + if err := row.Scan(&record.Binding, &generation, &record.ContentHash, + &record.Payload, &record.Envelope, &record.SignerIdentity, &record.OwnershipDomain, + &record.ReceivedAt); err != nil { + return nil, err + } + record.Generation = uint64(generation) //nolint:gosec // CHECKed positive + return &record, nil } var _ business.SolutionDeliveryStore = (*PostgresStore)(nil) -// CountDeliveredDocuments is how many documents the inbox holds, of every kind. +// CountDeliveredDocuments is how many manifests the inbox holds. // // The inbox is append-only — a generation is inserted once and never rewritten -// or removed — so the count only grows, and it grows exactly when a document a +// or removed — so the count only grows, and it grows exactly when a manifest a // registry pass has not read yet arrives. That makes it the marker a registry // snapshot is compared with: see business.Service.deliveredSince. func (s *PostgresStore) CountDeliveredDocuments(ctx context.Context) (uint64, error) { @@ -175,14 +146,14 @@ func (s *PostgresStore) CountDeliveredDocuments(ctx context.Context) (uint64, er `SELECT count(*) FROM public.solution_delivery_documents`).Scan(&count); err != nil { return 0, err } - return uint64(count), nil + return uint64(count), nil //nolint:gosec // a count is non-negative } -// CountUnappliedPresence is how many presence documents the inbox holds at a -// generation newer than the one their binding has applied, and that the host -// has not refused. Each is a declaration the binding reconciler has yet to -// apply: until it has, the registry cannot hold the principal it declares. -func (s *PostgresStore) CountUnappliedPresence(ctx context.Context) (uint64, error) { +// CountUnappliedManifests is how many bindings hold a delivered manifest newer +// than the generation they applied, that the host has not refused. Each is a +// declaration the reconciler has yet to apply: until it has, the registry +// cannot hold the principal it declares. +func (s *PostgresStore) CountUnappliedManifests(ctx context.Context) (uint64, error) { var count int64 if err := s.getQueryExecutor(ctx).QueryRow(ctx, ` SELECT count(*) @@ -193,39 +164,10 @@ func (s *PostgresStore) CountUnappliedPresence(ctx context.Context) (uint64, err LEFT JOIN public.solution_host_bindings binding ON binding.binding_id = newest.document_id WHERE COALESCE(binding.applied_generation, 0) < newest.generation AND binding.pending_reason IS NULL`, - string(business.SolutionDeliveryPresence)).Scan(&count); err != nil { + solutionDeliveryManifestKind).Scan(&count); err != nil { return 0, err } - return uint64(count), nil -} - -// ReadDeliveredDocument reads the exact inbox generation selected by applied -// state. Absence is not permission to use a newer desired or older delivery. -func (s *PostgresStore) ReadDeliveredDocument( - ctx context.Context, kind business.SolutionDeliveryKind, documentID string, generation uint64, -) (*business.SolutionDeliveryRecord, error) { - if generation == 0 || generation > uint64(1<<63-1) { - return nil, fmt.Errorf("delivery generation is outside the stored range") - } - var record business.SolutionDeliveryRecord - var kindText string - var storedGeneration int64 - err := s.getQueryExecutor(ctx).QueryRow(ctx, ` - SELECT `+solutionDeliveryColumns+` FROM public.solution_delivery_documents - WHERE kind = $1 AND document_id = $2 AND generation = $3`, - string(kind), documentID, int64(generation), - ).Scan(&kindText, &record.DocumentID, &storedGeneration, &record.ContentHash, - &record.Payload, &record.Bundle, &record.SignerIdentity, &record.OwnershipDomain, - &record.ReceivedAt) - if errors.Is(err, pgx.ErrNoRows) { - return nil, nil - } - if err != nil { - return nil, err - } - record.Kind = business.SolutionDeliveryKind(kindText) - record.Generation = uint64(storedGeneration) //nolint:gosec // CHECKed positive - return &record, nil + return uint64(count), nil //nolint:gosec // a count is non-negative } -var _ business.SolutionDeliveryReader = (*PostgresStore)(nil) +var _ business.SolutionDeliveryCounter = (*PostgresStore)(nil) diff --git a/module/services/accounts/code/pkg/infra/postgres_solution_delivery_applied.go b/module/services/accounts/code/pkg/infra/postgres_solution_delivery_applied.go deleted file mode 100644 index 791cd24bf..000000000 --- a/module/services/accounts/code/pkg/infra/postgres_solution_delivery_applied.go +++ /dev/null @@ -1,55 +0,0 @@ -package infra - -import ( - "context" - "fmt" - - "accounts/pkg/business" - - "github.com/codefly-dev/core/solutionhost" -) - -// ReadAppliedWorkloadPresence reads immutable applied decisions and their exact -// inbox bytes. Stream history without the catalogue's diagnostic row limit, so -// later headless generations cannot erase a carrier's original namespace. -func (s *PostgresStore) ReadAppliedWorkloadPresence(ctx context.Context, binding string, through uint64) (*business.SolutionHostBindingGeneration, error) { - if through == 0 || through > uint64(1<<63-1) { - return nil, fmt.Errorf("applied generation is outside the stored range") - } - rows, err := s.getQueryExecutor(ctx).Query(ctx, ` - SELECT h.generation, h.digest, d.payload, h.decided_at - FROM public.solution_generation_history h - LEFT JOIN public.solution_delivery_documents d ON d.kind = 'presence' - AND d.document_id = h.binding_id AND d.generation = h.generation AND d.content_hash = h.digest - WHERE h.binding_id = $1 AND h.generation <= $2 AND h.decision = 'applied' - ORDER BY h.generation DESC`, binding, int64(through)) - if err != nil { - return nil, err - } - defer rows.Close() - for rows.Next() { - var result business.SolutionHostBindingGeneration - var generation int64 - var payload []byte - if err := rows.Scan(&generation, &result.Digest, &payload, &result.At); err != nil { - return nil, err - } - document, err := solutionhost.Parse(payload) - if err != nil { - return nil, fmt.Errorf("read applied carrier presence: %w", err) - } - digest, err := document.Digest() - if err != nil || digest != result.Digest || document.Binding != binding || document.Generation != uint64(generation) || document.Removed { - return nil, fmt.Errorf("applied carrier presence is inconsistent") - } - if len(document.Workloads) == 0 { - continue - } - result.Generation = uint64(generation) - result.Document = string(payload) - return &result, nil - } - return nil, rows.Err() -} - -var _ business.SolutionDeliveryAppliedReader = (*PostgresStore)(nil) diff --git a/module/services/accounts/code/pkg/infra/postgres_solution_frontends.go b/module/services/accounts/code/pkg/infra/postgres_solution_frontends.go index ab8b602f7..09d7bd4ca 100644 --- a/module/services/accounts/code/pkg/infra/postgres_solution_frontends.go +++ b/module/services/accounts/code/pkg/infra/postgres_solution_frontends.go @@ -19,7 +19,7 @@ func (s *PostgresStore) GetRetainedSolutionFrontend(ctx context.Context, binding SELECT h.generation, h.digest, d.payload, h.decided_at, h.target_id::text FROM public.solution_generation_history h JOIN public.solution_targets t ON t.id = h.target_id AND t.binding_id = h.binding_id - JOIN public.solution_delivery_documents d ON d.kind = 'presence' + JOIN public.solution_delivery_documents d ON d.kind = 'manifest' AND d.document_id = h.binding_id AND d.generation = h.generation AND d.content_hash = h.digest WHERE h.binding_id = $1 AND h.asset_version = $2 AND h.decision = 'applied' AND t.closed_generation IS NULL diff --git a/module/services/accounts/code/pkg/infra/postgres_solution_host_bindings.go b/module/services/accounts/code/pkg/infra/postgres_solution_host_bindings.go index d4c2a7750..5ea2b1450 100644 --- a/module/services/accounts/code/pkg/infra/postgres_solution_host_bindings.go +++ b/module/services/accounts/code/pkg/infra/postgres_solution_host_bindings.go @@ -15,11 +15,11 @@ import ( // caller opened a WithControlPlane transaction, which getQueryExecutor picks up // from ctx. -const solutionHostBindingColumns = `binding_id, host_coordinate, host_component, +const solutionHostBindingColumns = `binding_id, host_coordinate, desired_generation, desired_digest, desired_document, desired_seen_at, applied_generation, applied_digest, applied_document, applied_removed, applied_routes, applied_solution_id, applied_release, applied_domain, applied_at, - pending_reason, pending_since, updated_at` + pending_reason, pending_since, updated_at, first_domain` func scanSolutionHostBinding(row pgx.Row) (*business.SolutionHostBindingRecord, error) { var ( @@ -39,13 +39,14 @@ func scanSolutionHostBinding(row pgx.Row) (*business.SolutionHostBindingRecord, appliedAt *time.Time pendingReason *string pendingSince *time.Time + firstDomain *string ) if err := row.Scan( - &record.BindingID, &record.HostCoordinate, &record.HostComponent, + &record.BindingID, &record.HostCoordinate, &desiredGeneration, &desiredDigest, &desiredDocument, &desiredSeenAt, &appliedGeneration, &appliedDigest, &appliedDocument, &appliedRemoved, &appliedRoutes, &appliedSolutionID, &appliedRelease, &appliedDomain, &appliedAt, - &pendingReason, &pendingSince, &record.UpdatedAt, + &pendingReason, &pendingSince, &record.UpdatedAt, &firstDomain, ); err != nil { return nil, err } @@ -77,6 +78,7 @@ func scanSolutionHostBinding(row pgx.Row) (*business.SolutionHostBindingRecord, } record.PendingReason = derefString(pendingReason) record.PendingSince = pendingSince + record.Domain = derefString(firstDomain) return &record, nil } @@ -169,17 +171,24 @@ func (s *PostgresStore) SaveSolutionHostBinding( if record.PendingReason != "" { pendingReason = &record.PendingReason } + // The binding's domain is written once and never changed: the stored one + // wins over whatever a later save carries (first_domain, migration 41). + var firstDomain *string + if record.Domain != "" { + firstDomain = &record.Domain + } else if appliedDomain != nil { + firstDomain = appliedDomain + } _, err := s.getQueryExecutor(ctx).Exec(ctx, ` INSERT INTO public.solution_host_bindings ( - binding_id, host_coordinate, host_component, + binding_id, host_coordinate, desired_generation, desired_digest, desired_document, desired_seen_at, applied_generation, applied_digest, applied_document, applied_removed, applied_routes, applied_solution_id, applied_release, applied_domain, applied_at, - pending_reason, pending_since, updated_at) + pending_reason, pending_since, updated_at, first_domain) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) ON CONFLICT (binding_id) DO UPDATE SET host_coordinate = EXCLUDED.host_coordinate, - host_component = EXCLUDED.host_component, desired_generation = EXCLUDED.desired_generation, desired_digest = EXCLUDED.desired_digest, desired_document = EXCLUDED.desired_document, @@ -195,12 +204,13 @@ func (s *PostgresStore) SaveSolutionHostBinding( applied_at = EXCLUDED.applied_at, pending_reason = EXCLUDED.pending_reason, pending_since = EXCLUDED.pending_since, - updated_at = EXCLUDED.updated_at`, - record.BindingID, record.HostCoordinate, record.HostComponent, + updated_at = EXCLUDED.updated_at, + first_domain = COALESCE(solution_host_bindings.first_domain, EXCLUDED.first_domain)`, + record.BindingID, record.HostCoordinate, desiredGeneration, desiredDigest, desiredDocument, desiredSeenAt, appliedGeneration, appliedDigest, appliedDocument, appliedRemoved, appliedRoutes, appliedSolutionID, appliedRelease, appliedDomain, appliedAt, - pendingReason, record.PendingSince, record.UpdatedAt) + pendingReason, record.PendingSince, record.UpdatedAt, firstDomain) return err } diff --git a/module/services/accounts/code/pkg/infra/postgres_solution_host_bindings_first_insert_test.go b/module/services/accounts/code/pkg/infra/postgres_solution_host_bindings_first_insert_test.go index b5ddb4784..9627a1925 100644 --- a/module/services/accounts/code/pkg/infra/postgres_solution_host_bindings_first_insert_test.go +++ b/module/services/accounts/code/pkg/infra/postgres_solution_host_bindings_first_insert_test.go @@ -63,7 +63,6 @@ func TestSolutionHostBindingFirstDeliverySerializesTwoPasses(t *testing.T) { return testStore.SaveSolutionHostBinding(ctx, &business.SolutionHostBindingRecord{ BindingID: binding, HostCoordinate: "acme.example/host", - HostComponent: "accounts", Desired: &business.SolutionHostBindingGeneration{ Generation: 1, Digest: fixtureDigest(binding + ":1"), diff --git a/module/services/accounts/code/pkg/infra/solution_delivery_activation_test.go b/module/services/accounts/code/pkg/infra/solution_delivery_activation_test.go deleted file mode 100644 index fc8e6d4e1..000000000 --- a/module/services/accounts/code/pkg/infra/solution_delivery_activation_test.go +++ /dev/null @@ -1,338 +0,0 @@ -//go:build !pure - -package infra_test - -import ( - "context" - "strings" - "testing" - "time" - - "accounts/internal/deliverytest" - "accounts/pkg/business" - - "github.com/codefly-dev/core/solutionhost" - "github.com/stretchr/testify/require" -) - -// ACTIVATION, from the inbox the delivery endpoint writes. -// -// The defect this covers is an absence rather than a wrong answer: the endpoint -// accepted authority documents, persisted them, and NOTHING EVER READ ONE. There -// was no call site for core's Activate anywhere in this host, so a reviewed, -// signed, envelope-bound grant could be delivered and the host would behave -// exactly as if it had not been. -// -// Both halves are POSTed through the real handler and read back out of real -// Postgres, so what is under test is the host's own path from "a carrier -// arrived" to "this tuple is active" — not a hand-assembled pair of documents -// handed straight to core, which proves core. -// -// The ceiling is the MODULE envelope, keyed by module principal and admitting -// unit IDs in delivery's convention (::), -// so the documents here are built in that convention rather than taken from -// core's fixtures, whose principals and unit IDs no module envelope can name. -// The tuple still has to be core's: the approved build is the build the presence -// document's workload declares, both halves name the same envelope revision, and -// the authority is effective from a generation the presence has reached. - -// TestDeliveredAuthorityActivatesAgainstDeliveredPresence drives the whole -// question through the host. -func TestDeliveredAuthorityActivatesAgainstDeliveredPresence(t *testing.T) { - delivery := newProductionPathDelivery(t) - - solutionID := uniqueAlias("acv") - namespace := "ns-" + solutionID - presence := productionPathBinding(t, solutionID, namespace, 1) - build := presence.Builds()[0] - delivery.callerNamespace = namespace - requireDelivered(t, delivery.post(t, business.SolutionDeliveryPresence, productionPathCarrier(t, presence))) - authority := productionPathAuthority(t, solutionID, presence.Binding, "a", 1, build, false) - delivery.callerNamespace = authorityWriterNamespace - requireDelivered(t, delivery.post(t, business.SolutionDeliveryAuthority, productionPathAuthorityCarrier(t, authority))) - - // A second approved build the presence does not run, so the refusal below - // is about the tuple rather than about the ceiling. - other := solutionhost.FixtureEnvelope().ApprovedBuilds[1] - require.NotEqual(t, build, other) - activation := activationFor(t, delivery.service, productionPathCoordinate, - moduleCeiling(solutionhost.FixtureEnvelopeRevision, []solutionhost.ImageDigest{build, other}, - solutionhost.EnvelopeGrant{Principal: productionPathPrincipal, Binding: authorityBindingFor(presence.Binding, namespace)})) - require.NotNil(t, activation, "the reconciler exposes no activation seam, so the authority inbox is unread again") - - active, err := activation.Activate(testCtx, presence.Binding, build) - require.NoError(t, err) - require.Equal(t, authority.Authority, active.Authority) - require.Equal(t, presence.Binding, active.Binding) - require.Equal(t, build, active.Build) - require.Equal(t, uint64(1), active.Generation, "the activation names the PRESENCE generation that activated it") - require.Equal(t, uint64(solutionhost.FixtureEnvelopeRevision), active.EnvelopeRevision) - require.Equal(t, productionPathDomain, active.Domain) - require.Equal(t, productionPathCoordinate, active.Host.Coordinate, - "an activation that did not say which host it was for could be read as an activation anywhere") - - // ANOTHER BUILD ACTIVATES NOTHING. This is the acceptance case for the whole - // design: authority is held for the build that was reviewed, so shipping a - // new build does not carry the old build's authority forward. - _, err = activation.Activate(testCtx, presence.Binding, other) - require.ErrorIs(t, err, solutionhost.ErrNotActivated) - - // PRESENCE ALONE GRANTS NOTHING. A binding delivered with no authority - // document over it is not a refusal of a grant — nothing was granted — and - // the two are distinct errors because an operator fixes them in different - // places. - onlyID := uniqueAlias("act") - onlyNamespace := "ns-" + onlyID - presenceOnly := productionPathBinding(t, onlyID, onlyNamespace, 1) - delivery.callerNamespace = onlyNamespace - requireDelivered(t, delivery.post(t, business.SolutionDeliveryPresence, - productionPathCarrier(t, presenceOnly))) - _, err = activation.Activate(testCtx, presenceOnly.Binding, build) - require.ErrorIs(t, err, business.ErrSolutionAuthorityNotDelivered) -} - -// NO CEILING MEANS NO ANSWER, not a permissive one. -// -// A host with no envelope delivered has decided nothing about authority, which -// is a different fact from deciding that a grant is absent — and the difference -// is the whole reason the error is its own sentinel. A zero envelope that fell -// through to core would be refused there too, by the "no envelope revision was -// named" guard, but with a message about a tuple rather than about this host's -// configuration. -func TestWithoutAnEnvelopeTheHostActivatesNothing(t *testing.T) { - delivery := newProductionPathDelivery(t) - delivery.callerNamespace = fixturePresenceNamespace - requireDelivered(t, delivery.post(t, business.SolutionDeliveryPresence, signedFixture(t, "presence"))) - delivery.callerNamespace = authorityWriterNamespace - requireDelivered(t, delivery.post(t, business.SolutionDeliveryAuthority, signedFixture(t, "authority"))) - - // No envelope: what a host whose platform administrator has written no - // ceiling holds. - activation := fixtureActivation(t, delivery.service, nil) - _, err := activation.Activate(testCtx, solutionhost.FixtureBindingID, fixturePresenceBuild(t)) - require.ErrorIs(t, err, business.ErrSolutionAuthorityCeilingUnavailable) -} - -// fixtureActivation builds the activation seam for a host that answers for the -// FIXTURE coordinate and domain, which is what the shipped documents target. -func fixtureActivation( - t *testing.T, service *business.Service, envelope *business.ModuleEnvelope, -) *business.SolutionAuthorityActivation { - t.Helper() - return activationFor(t, service, solutionhost.FixtureCoordinate, envelope) -} - -// staticCeiling serves one ceiling by value, the way the store serves the -// current revision. A nil envelope is a host with no ceiling written. -type staticCeiling struct{ envelope *business.ModuleEnvelope } - -func (c staticCeiling) CurrentModuleEnvelope(context.Context) (*business.ModuleEnvelope, error) { - return c.envelope, nil -} - -// productionPathPrincipal is the module principal the production-path documents -// grant to: a module's own name, which is what delivery writes. -const productionPathPrincipal = "operator" - -// moduleCeiling is the module envelope that admits exactly the (principal, -// unit) pairs given, whole, at one revision, approving the builds given. Each -// unit's ID follows delivery's convention, and the principal's grant declares -// the operation audience, queue and namespace the unit names. These tests read -// activation and approved builds, which take no workload, so it allows none. -func moduleCeiling( - revision uint64, builds []solutionhost.ImageDigest, grants ...solutionhost.EnvelopeGrant, -) *business.ModuleEnvelope { - envelope := &business.ModuleEnvelope{ - Revision: revision, - ApprovedBuilds: builds, - Principals: map[string]business.ModuleEnvelopePrincipal{}, - } - for _, grant := range grants { - entry := envelope.Principals[grant.Principal] - // The whole unit is the envelope's own, so a document whose unit - // differs in any field is outside it. - entry.Units = append(entry.Units, grant.Binding) - parts := strings.Split(grant.Binding.ID, ":") - if entry.Grant.OperationAudiences == nil { - entry.Grant.OperationAudiences = map[string]business.ModuleOperationAudience{} - } - entry.Grant.OperationAudiences[parts[len(parts)-2]] = business.ModuleOperationAudience{Audience: grant.Binding.Audience} - if grant.Binding.Queue != "" { - entry.Grant.Queues = append(entry.Grant.Queues, grant.Binding.Queue) - } - if grant.Binding.Namespace != "" { - entry.Grant.Namespaces = append(entry.Grant.Namespaces, grant.Binding.Namespace) - } - envelope.Principals[grant.Principal] = entry - } - return envelope -} - -func activationFor( - t *testing.T, service *business.Service, coordinate string, envelope *business.ModuleEnvelope, -) *business.SolutionAuthorityActivation { - t.Helper() - reconciler, err := business.NewSolutionHostBindingReconciler(service, - business.SolutionHostBindingReconcilerConfig{ - Source: business.NewDeliveredSolutionHostBindings(service), - Verifier: deliverytest.Verifier(t, map[string][]string{productionPathSigner: {solutionhost.FixtureDomain, productionPathDomain}}), - Coordinate: coordinate, - Domains: []string{solutionhost.FixtureDomain, productionPathDomain}, - DomainsBySigner: map[string][]string{productionPathSigner: {solutionhost.FixtureDomain, productionPathDomain}}, - Interval: time.Minute, - Envelopes: staticCeiling{envelope: envelope}, - }) - require.NoError(t, err) - return reconciler.AuthorityActivation() -} - -// fixturePresenceBuild is the image digest core's valid presence fixture's -// workload declares — read out of the fixture rather than written down, so a -// fixture whose build moves fails here instead of silently testing a build no -// document names. -func fixturePresenceBuild(t *testing.T) solutionhost.ImageDigest { - t.Helper() - document, err := solutionhost.Parse(presenceFixtureDocument(t)) - require.NoError(t, err) - builds := document.Builds() - require.Len(t, builds, 1) - return builds[0] -} - -func presenceFixtureDocument(t *testing.T) []byte { - t.Helper() - data, err := solutionhost.FixtureDocument(solutionhost.DocumentTypePresence, "valid") - require.NoError(t, err) - return data -} - -// A WITHDRAWN AUTHORITY IS NOT REINSTATED BY RENAMING IT. -// -// This is the one part of core's generation fold the inbox cannot perform by -// itself, so the host performs it explicitly — and the reason it has to is -// specific. core keys the authority fold on the BINDING rather than on the -// authority id, precisely because an authority re-signed under a NEW id has no -// applied record of its own and would therefore read as a first generation. This -// host holds no applied authority record at all, so that fold is not available -// to it: what stands in is reading EVERY delivered authority document over the -// binding and refusing when a tombstone is among them. Nothing deletes from the -// inbox, so a withdrawal stays visible forever, which is what makes the -// substitute equivalent rather than merely similar. -// -// Core's own authority tombstone fixture is deliberately NOT used here. It -// carries the same authority id as the valid fixture at a higher generation, and -// this package's inbox is a shared, append-only table with no DELETE grant — so -// delivering it would supersede the valid document for every later run of -// TestDeliveredAuthorityActivatesAgainstDeliveredPresence. The documents here -// are this file's own, over a binding id unique to the run. -func TestAWithdrawnAuthorityIsNotReinstatedUnderANewID(t *testing.T) { - solutionID := uniqueAlias("wd") - namespace := "ns-" + solutionID - presence := productionPathBinding(t, solutionID, namespace, 1) - build := presence.Builds()[0] - - delivery := newProductionPathDelivery(t) - delivery.callerNamespace = namespace - requireDelivered(t, delivery.post(t, business.SolutionDeliveryPresence, - productionPathCarrier(t, presence))) - - // First the grant, then its withdrawal, then the SAME grant under a new - // authority id — which is the move the check exists to refuse. - granted := productionPathAuthority(t, solutionID, presence.Binding, "a", 1, build, false) - withdrawn := productionPathAuthority(t, solutionID, presence.Binding, "a", 2, build, true) - renamed := productionPathAuthority(t, solutionID, presence.Binding, "b", 1, build, false) - - activation := activationFor(t, delivery.service, productionPathCoordinate, - productionPathEnvelope(presence.Binding, namespace, build)) - - delivery.callerNamespace = authorityWriterNamespace - requireDelivered(t, delivery.post(t, business.SolutionDeliveryAuthority, - productionPathAuthorityCarrier(t, granted))) - active, err := activation.Activate(testCtx, presence.Binding, build) - require.NoError(t, err, "the grant must activate before its withdrawal can mean anything") - require.Equal(t, granted.Authority, active.Authority) - - requireDelivered(t, delivery.post(t, business.SolutionDeliveryAuthority, - productionPathAuthorityCarrier(t, withdrawn))) - _, err = activation.Activate(testCtx, presence.Binding, build) - require.ErrorIs(t, err, business.ErrSolutionAuthorityWithdrawn) - - requireDelivered(t, delivery.post(t, business.SolutionDeliveryAuthority, - productionPathAuthorityCarrier(t, renamed))) - _, err = activation.Activate(testCtx, presence.Binding, build) - require.ErrorIs(t, err, business.ErrSolutionAuthorityWithdrawn, - "a withdrawal is a statement about the BINDING, so a new authority id over it reinstates nothing") -} - -// productionPathAuthority is one authority document over one binding, under the -// authority id `acme.test..`. -func productionPathAuthority( - t *testing.T, solutionID, binding, suffix string, generation uint64, - build solutionhost.ImageDigest, removed bool, -) *solutionhost.AuthorityDocument { - t.Helper() - document := &solutionhost.AuthorityDocument{ - Schema: solutionhost.SchemaAuthorityV1, - Authority: "acme.test." + solutionID + "." + suffix, - Generation: generation, - PresenceBinding: binding, - Host: solutionhost.HostTarget{Coordinate: productionPathCoordinate, Component: "saas-host"}, - OwnershipDomain: productionPathDomain, - EnvelopeRevision: solutionhost.FixtureEnvelopeRevision, - // This subject owns no queues, audit namespaces or scope vocabulary. - // Core requires explicit declarations, including on tombstones. - Queues: []string{}, - Namespaces: []string{}, - ScopeCeilings: []solutionhost.ScopeCeiling{}, - } - if !removed { - document.ApprovedBuild = build - document.EffectiveFrom = 1 - document.Principals = []solutionhost.PrincipalAuthority{{ - Principal: productionPathPrincipal, - Bindings: []solutionhost.AuthorityBinding{authorityBindingFor(binding, "ns-"+solutionID)}, - }} - } else { - // A withdrawn generation names no principal, approves no build and is - // effective from no generation. Carrying any of those would be a - // withdrawal that still says what it grants. - document.Removed = true - } - require.NoError(t, document.Validate(), "the fixture must be a valid authority document") - return document -} - -// productionPathEnvelope is the ceiling the documents above sit inside. It is -// assembled by the TEST rather than read out of a document, which is the rule: -// an envelope a document carried would be a document declaring its own ceiling. -func productionPathEnvelope( - presenceBinding, namespace string, build solutionhost.ImageDigest, -) *business.ModuleEnvelope { - return moduleCeiling(solutionhost.FixtureEnvelopeRevision, []solutionhost.ImageDigest{build}, - solutionhost.EnvelopeGrant{Principal: productionPathPrincipal, Binding: authorityBindingFor(presenceBinding, namespace)}) -} - -// authorityBindingFor is the one unit the production-path documents grant: the -// invoke operation of a `reconcile` binding over the presence binding, named as -// delivery names it. -func authorityBindingFor(presenceBinding, namespace string) solutionhost.AuthorityBinding { - return solutionhost.AuthorityBinding{ - ID: presenceBinding + ":reconcile:invoke", - Revision: 1, - Audience: "https://test.acme.example/operations", - Scope: "reconcile:run", - Queue: "reconcile.default", - Namespace: namespace, - } -} - -// productionPathAuthorityCarrier wraps an authority document in the carrier -// delivery POSTs. SignedPayloadFor, not Marshal: the signing input is the -// canonical encoding, and AuthorityFromVerified refuses a payload that is not -// the canonical encoding of the document it decodes to. -func productionPathAuthorityCarrier(t *testing.T, document *solutionhost.AuthorityDocument) []byte { - t.Helper() - payload, err := solutionhost.SignedPayloadFor(document) - require.NoError(t, err) - return deliverytest.Carrier(t, payload, productionPathSigner) -} diff --git a/module/services/accounts/code/pkg/infra/solution_delivery_carrier.go b/module/services/accounts/code/pkg/infra/solution_delivery_carrier.go index 8f6820c8c..deee27747 100644 --- a/module/services/accounts/code/pkg/infra/solution_delivery_carrier.go +++ b/module/services/accounts/code/pkg/infra/solution_delivery_carrier.go @@ -9,45 +9,26 @@ import ( "accounts/pkg/business" ) -// Who may HAND a delivered document to this host. +// Who may HAND a delivered manifest to this host. // // Separate from who signed it, and keeping the two apart is the point. The -// bundle says who produced the content; this says who delivered it. A valid -// bundle does not make every caller a legitimate carrier — otherwise anyone who -// could replay a captured carrier could re-deliver it, including a superseded -// generation whose signature is still perfectly good. +// envelope says who produced the content; this says who delivered it. A valid +// signature does not make every caller a legitimate carrier — otherwise anyone +// who could replay a captured carrier could re-deliver it, including a +// superseded generation whose signature is still perfectly good. // -// The two halves are authorised differently, and the asymmetry is deliberate: -// -// - AUTHORITY has one fixed (service account, namespace) pair, because there -// is one authority writer for the whole platform. -// - PRESENCE is checked against the namespace THE DOCUMENT'S OWN WORKLOADS -// DECLARE, because a presence Job runs one per module tree in that module's -// own namespace. A fixed value would refuse every genuine carrier. -// -// A generation that declares no workload declares no namespace, and a TOMBSTONE -// is exactly that: removal is a generation, and it carries nothing. Refusing the -// empty case outright therefore made removal through this endpoint IMPOSSIBLE. -// The caller resolves such a document against the namespace this host last -// applied for that binding, which is its own recorded state rather than -// anything the arriving document asserts. -// -// Reading the presence namespace out of the signed document rather than from -// configuration is what keeps it out of a deployer's reach — the same property -// that puts the ownership domain inside the canonical bytes. And this host's -// check is the SECOND layer, not the perimeter: a manifest claiming a namespace -// other than the one its delivery tree declares is refused by the delivery -// controller's own destination list, at apply. - -// The delivery service account, for both kinds. One name rather than two: the -// renderer uses a single constant, and a second name here would be a constant -// only this host believes in — which fails with a TokenReview that SUCCEEDS on a -// genuine identity, refused for a name. +// The carrier is the `delivery` ServiceAccount of the module's own namespace: +// one delivery Job per module tree runs there. The namespace is the module's by +// the cell's rule (business.ModuleNamespaceRule) — the manifest's module, or for +// a withdrawal the module of the binding's previously applied manifest — so it +// comes from signed bytes and this host's own placement, never from +// configuration a deployer can edit. + +// deliveryServiceAccount is the account a delivery carrier runs as: the CLI's +// constant (the `delivery` ServiceAccount its render creates per module +// namespace), also core's workloadidentity.DeliveryService. const deliveryServiceAccount = "delivery" -// The authority half's fixed namespace. -const authorityDeliveryNamespace = "platform-authority" - // The audience a delivery carrier's token must be minted for. The same audience // the credential mint uses, because accounts is the service performing the // review in both cases and a second audience is another projected token to @@ -61,12 +42,12 @@ const deliveryTokenAudience = "accounts" var ErrCarrierUnauthenticated = errors.New("delivery carrier is not authenticated") // ErrCarrierNotAuthorized reports that the carrier authenticated but is not the -// writer for this kind of document. Terminal. +// delivery account of the manifest's module namespace. Terminal. // // Distinct from unauthenticated because the two are fixed in different places: a // refused token is the caller's credential, and a wrong service account or // namespace is the deployment's wiring. -var ErrCarrierNotAuthorized = errors.New("delivery carrier is not the authorized writer for this document kind") +var ErrCarrierNotAuthorized = errors.New("delivery carrier is not the delivery account of the manifest's module namespace") // SolutionDeliveryCarrierCheck authorises a carrier by TokenReview. type SolutionDeliveryCarrierCheck struct { @@ -102,24 +83,19 @@ func NewSolutionDeliveryCarrierCheck(kubernetes *KubernetesClient) *SolutionDeli return &SolutionDeliveryCarrierCheck{kubernetes: kubernetes} } -// AuthorizeCarrier reviews the caller's token and checks the identity it -// authenticates as against the writer this document kind requires. +// AuthorizeCarrier reviews the caller's token and requires it to authenticate +// as the delivery account of namespace. // // The failure taxonomy is the contract, because each class has a different right // response from the caller: // // - no token, or a token the API server REFUSED → ErrCarrierUnauthenticated, // terminal (401). The credential is wrong; retrying asks the same question. -// - authenticated, wrong writer → ErrCarrierNotAuthorized, terminal (403). +// - authenticated, wrong account or namespace → ErrCarrierNotAuthorized, +// terminal (403). // - the API server could not be ASKED → ErrKubernetesUnavailable, retryable // (503). Nothing was verified, so nothing may be concluded. -// -// The third is why the Kubernetes client separates "refused" from "unreachable" -// at all: a control-plane outage reported as a refusal would look like a fleet -// of misconfigured callers, and an operator would go and check the callers. -func (c *SolutionDeliveryCarrierCheck) AuthorizeCarrier( - ctx context.Context, credential string, kind business.SolutionDeliveryKind, declaredNamespaces []string, -) error { +func (c *SolutionDeliveryCarrierCheck) AuthorizeCarrier(ctx context.Context, credential, namespace string) error { if c == nil || c.kubernetes == nil { // Fail closed. A deployment that mounted the endpoint without a way to // review carriers must not accept documents from anyone. @@ -129,7 +105,6 @@ func (c *SolutionDeliveryCarrierCheck) AuthorizeCarrier( if token == "" { return fmt.Errorf("%w: no bearer token was presented", ErrCarrierUnauthenticated) } - reviewed, err := c.kubernetes.reviewToken(ctx, token, deliveryTokenAudience) if err != nil { if errors.Is(err, ErrTokenRejected) { @@ -138,50 +113,11 @@ func (c *SolutionDeliveryCarrierCheck) AuthorizeCarrier( // Unreachable, or the host's own RBAC refused: nothing was verified. return err } - - if reviewed.ServiceAccount != deliveryServiceAccount { - return fmt.Errorf("%w: %s is delivered by service account %q, and the carrier authenticated as %q", - ErrCarrierNotAuthorized, kind, deliveryServiceAccount, reviewed.ServiceAccount) - } - - switch kind { - case business.SolutionDeliveryAuthority: - if reviewed.Namespace != authorityDeliveryNamespace { - return fmt.Errorf("%w: authority is delivered from namespace %q, and the carrier authenticated in %q", - ErrCarrierNotAuthorized, authorityDeliveryNamespace, reviewed.Namespace) - } - return nil - - case business.SolutionDeliveryPresence, business.SolutionDeliveryEndpoints: - // The namespace comes from the signed document's own workloads, or — - // for a generation that declares none — from the namespace this host - // last APPLIED for that binding. Which of the two answered is the - // caller's to resolve; see business.presenceCarrierNamespaces. - // - // Empty means neither did: a first generation for a binding nothing has - // applied, declaring no workload. That cannot be authorised at all — - // there is nothing to check the carrier against, and accepting it would - // make the namespace check vacuous for exactly the documents that - // omitted one. - if len(declaredNamespaces) == 0 { - return fmt.Errorf("%w: neither this presence document nor the binding's applied generation names a workload namespace, so there is nothing to authorise its carrier against", - ErrCarrierNotAuthorized) - } - // More than one distinct namespace has no single answer to "where was - // this delivered from". The renderer uses one namespace per render and - // cannot emit such a document, so one that exists was hand-built — - // refused by name rather than resolved by picking one. - if len(declaredNamespaces) > 1 { - return fmt.Errorf("%w: the presence document declares workloads in %d namespaces (%s), so the namespace it was delivered from has no single answer", - ErrCarrierNotAuthorized, len(declaredNamespaces), strings.Join(declaredNamespaces, ", ")) - } - if reviewed.Namespace != declaredNamespaces[0] { - return fmt.Errorf("%w: the presence document's workloads declare namespace %q, and the carrier authenticated in %q", - ErrCarrierNotAuthorized, declaredNamespaces[0], reviewed.Namespace) - } - return nil + if reviewed.ServiceAccount != deliveryServiceAccount || reviewed.Namespace != namespace { + return fmt.Errorf("%w: the manifest is delivered by %s/%s, and the carrier authenticated as %s/%s", + ErrCarrierNotAuthorized, namespace, deliveryServiceAccount, reviewed.Namespace, reviewed.ServiceAccount) } - return fmt.Errorf("%w: unknown document kind %q", ErrCarrierNotAuthorized, kind) + return nil } // bearerToken reads the token out of an Authorization header value. diff --git a/module/services/accounts/code/pkg/infra/solution_delivery_carrier_test.go b/module/services/accounts/code/pkg/infra/solution_delivery_carrier_test.go index b9f1f2e49..714a29d99 100644 --- a/module/services/accounts/code/pkg/infra/solution_delivery_carrier_test.go +++ b/module/services/accounts/code/pkg/infra/solution_delivery_carrier_test.go @@ -11,20 +11,17 @@ import ( "path/filepath" "testing" - "accounts/pkg/business" "accounts/pkg/infra" "github.com/stretchr/testify/require" ) -// Carrier authorisation: who may HAND a document to this host, as opposed to who +// Carrier authorisation: who may HAND a manifest to this host, as opposed to who // signed it. // -// Every test here is a way the host could accept a document from a caller that -// is not the authorised writer, and the asymmetry between the two kinds is the -// substance: authority has one fixed (service account, namespace) pair, while -// presence is checked against the namespace the document's own workloads -// declare. +// Every test here is a way the host could accept a manifest from a caller that +// is not its carrier: the `delivery` ServiceAccount of the namespace the cell +// runs the manifest's module in. // tokenReviewing stands up an API server that answers TokenReview with a given // username and audiences, and records what it was asked. @@ -80,7 +77,7 @@ func TestAppliedObserverIsASeparateReadIdentity(t *testing.T) { {"system:serviceaccount:istio-presenter:endpoint-publisher", "accounts", nil}, {"system:serviceaccount:other:endpoint-publisher", "accounts", infra.ErrCarrierNotAuthorized}, {"system:serviceaccount:istio-presenter:delivery", "accounts", infra.ErrCarrierNotAuthorized}, - {"system:serviceaccount:platform-authority:delivery", "accounts", infra.ErrCarrierNotAuthorized}, + {"system:serviceaccount:acme-crm:delivery", "accounts", infra.ErrCarrierNotAuthorized}, {"system:serviceaccount:istio-presenter:endpoint-publisher", "kubernetes", infra.ErrCarrierUnauthenticated}, } { t.Run(tc.username+"/"+tc.audience, func(t *testing.T) { @@ -95,9 +92,7 @@ func TestAppliedObserverIsASeparateReadIdentity(t *testing.T) { require.Equal(t, []string{"accounts"}, probe.audiences) require.Equal(t, "publisher-token", probe.token) if tc.want == nil { - for _, kind := range []business.SolutionDeliveryKind{business.SolutionDeliveryPresence, business.SolutionDeliveryAuthority} { - require.ErrorIs(t, check.AuthorizeCarrier(context.Background(), "Bearer publisher-token", kind, []string{"istio-presenter"}), infra.ErrCarrierNotAuthorized) - } + require.ErrorIs(t, check.AuthorizeCarrier(context.Background(), "Bearer publisher-token", "istio-presenter"), infra.ErrCarrierNotAuthorized) } }) } @@ -112,11 +107,11 @@ func TestAppliedObserverIsASeparateReadIdentity(t *testing.T) { // is given for talking to the API server authenticate here. func TestCarrierReviewSendsTheAudience(t *testing.T) { client, probe := tokenReviewing(t, - "system:serviceaccount:platform-authority:delivery", []string{"accounts"}, true) + "system:serviceaccount:acme-crm:delivery", []string{"accounts"}, true) check := infra.NewSolutionDeliveryCarrierCheck(client) require.NoError(t, check.AuthorizeCarrier( - context.Background(), "Bearer caller-token", business.SolutionDeliveryAuthority, nil)) + context.Background(), "Bearer caller-token", "acme-crm")) require.Equal(t, []string{"accounts"}, probe.audiences, "an audience-less review accepts a token minted for the api server itself") require.Equal(t, "caller-token", probe.token) @@ -124,69 +119,48 @@ func TestCarrierReviewSendsTheAudience(t *testing.T) { "the host presents its OWN credential to the api server") } -// The authority pair is fixed, and both halves of it are checked. -func TestCarrierAuthorityRequiresTheFixedPair(t *testing.T) { +// The carrier pair is (the module's namespace, delivery), and both halves of it +// are checked. +func TestCarrierRequiresTheDeliveryAccountOfTheNamespace(t *testing.T) { for name, username := range map[string]string{ "wrong namespace": "system:serviceaccount:platform:delivery", - "wrong service account": "system:serviceaccount:platform-authority:publisher", + "wrong service account": "system:serviceaccount:acme-crm:publisher", "both wrong": "system:serviceaccount:default:default", } { t.Run(name, func(t *testing.T) { client, _ := tokenReviewing(t, username, []string{"accounts"}, true) check := infra.NewSolutionDeliveryCarrierCheck(client) err := check.AuthorizeCarrier( - context.Background(), "Bearer t", business.SolutionDeliveryAuthority, nil) + context.Background(), "Bearer t", "acme-crm") require.ErrorIs(t, err, infra.ErrCarrierNotAuthorized) }) } } -// Presence is checked against the namespace THE DOCUMENT declares, not a -// constant. A carrier from the module's own namespace is authorised; the same -// carrier is refused for a document declaring another namespace. -// -// Both directions in one test, so it cannot pass by refusing everything. -func TestCarrierPresenceIsCheckedAgainstTheDocumentsNamespace(t *testing.T) { +// A carrier from the module's own namespace is authorised; the same carrier is +// refused for a manifest of another module. Both directions in one test, so it +// cannot pass by refusing everything. +func TestCarrierIsCheckedAgainstTheModulesNamespace(t *testing.T) { client, _ := tokenReviewing(t, "system:serviceaccount:acme-crm:delivery", []string{"accounts"}, true) check := infra.NewSolutionDeliveryCarrierCheck(client) require.NoError(t, check.AuthorizeCarrier( - context.Background(), "Bearer t", business.SolutionDeliveryPresence, []string{"acme-crm"}), - "a carrier from the namespace the document declares is the authorised writer") + context.Background(), "Bearer t", "acme-crm"), + "a carrier from the module's namespace is its delivery account") err := check.AuthorizeCarrier( - context.Background(), "Bearer t", business.SolutionDeliveryPresence, []string{"acme-other"}) + context.Background(), "Bearer t", "acme-other") require.ErrorIs(t, err, infra.ErrCarrierNotAuthorized, - "the same carrier must not deliver a document declaring another namespace") -} - -// A presence document declaring no workload namespace cannot be authorised: there -// is nothing to check the carrier against, and accepting it would make the -// namespace check vacuous for exactly the documents that omitted it. -func TestCarrierPresenceWithNoDeclaredNamespaceIsRefused(t *testing.T) { - client, _ := tokenReviewing(t, - "system:serviceaccount:acme-crm:delivery", []string{"accounts"}, true) - check := infra.NewSolutionDeliveryCarrierCheck(client) - - err := check.AuthorizeCarrier( - context.Background(), "Bearer t", business.SolutionDeliveryPresence, nil) - require.ErrorIs(t, err, infra.ErrCarrierNotAuthorized) + "the same carrier must not deliver another module's manifest") } -// Two namespaces have no single answer to "where was this delivered from". The -// renderer uses one namespace per render and cannot emit such a document, so one -// that exists was hand-built — refused by name rather than resolved by picking -// one, which is how a check becomes satisfiable by adding a namespace. -func TestCarrierPresenceWithTwoNamespacesIsRefusedByName(t *testing.T) { +// An empty namespace authorises no carrier: a reviewed account always names one. +func TestCarrierForNoNamespaceIsRefused(t *testing.T) { client, _ := tokenReviewing(t, "system:serviceaccount:acme-crm:delivery", []string{"accounts"}, true) - check := infra.NewSolutionDeliveryCarrierCheck(client) - - err := check.AuthorizeCarrier(context.Background(), "Bearer t", - business.SolutionDeliveryPresence, []string{"acme-crm", "acme-other"}) + err := infra.NewSolutionDeliveryCarrierCheck(client).AuthorizeCarrier(context.Background(), "Bearer t", "") require.ErrorIs(t, err, infra.ErrCarrierNotAuthorized) - require.Contains(t, err.Error(), "no single answer") } // A token the API server REFUSED is terminal and distinct from one it could not @@ -197,7 +171,7 @@ func TestCarrierRefusedTokenIsUnauthenticated(t *testing.T) { check := infra.NewSolutionDeliveryCarrierCheck(client) err := check.AuthorizeCarrier( - context.Background(), "Bearer t", business.SolutionDeliveryAuthority, nil) + context.Background(), "Bearer t", "acme-crm") require.ErrorIs(t, err, infra.ErrCarrierUnauthenticated) require.NotErrorIs(t, err, infra.ErrKubernetesUnavailable, "a refusal is a verdict, never an outage") @@ -208,11 +182,11 @@ func TestCarrierRefusedTokenIsUnauthenticated(t *testing.T) { // legitimately given for a different service from delivering documents. func TestCarrierTokenForAnotherAudienceIsRefused(t *testing.T) { client, _ := tokenReviewing(t, - "system:serviceaccount:platform-authority:delivery", []string{"some-other-service"}, true) + "system:serviceaccount:acme-crm:delivery", []string{"some-other-service"}, true) check := infra.NewSolutionDeliveryCarrierCheck(client) err := check.AuthorizeCarrier( - context.Background(), "Bearer t", business.SolutionDeliveryAuthority, nil) + context.Background(), "Bearer t", "acme-crm") require.ErrorIs(t, err, infra.ErrCarrierUnauthenticated) } @@ -224,7 +198,7 @@ func TestCarrierNonServiceAccountIdentityIsRefused(t *testing.T) { client, _ := tokenReviewing(t, username, []string{"accounts"}, true) check := infra.NewSolutionDeliveryCarrierCheck(client) err := check.AuthorizeCarrier( - context.Background(), "Bearer t", business.SolutionDeliveryAuthority, nil) + context.Background(), "Bearer t", "acme-crm") require.ErrorIs(t, err, infra.ErrCarrierUnauthenticated, "username %q", username) } } @@ -232,12 +206,12 @@ func TestCarrierNonServiceAccountIdentityIsRefused(t *testing.T) { // No credential at all is refused before the API server is asked. func TestCarrierWithoutATokenIsRefused(t *testing.T) { client, probe := tokenReviewing(t, - "system:serviceaccount:platform-authority:delivery", []string{"accounts"}, true) + "system:serviceaccount:acme-crm:delivery", []string{"accounts"}, true) check := infra.NewSolutionDeliveryCarrierCheck(client) for _, credential := range []string{"", " ", "Basic abc", "Bearer", "Bearer a b"} { err := check.AuthorizeCarrier( - context.Background(), credential, business.SolutionDeliveryAuthority, nil) + context.Background(), credential, "acme-crm") require.ErrorIs(t, err, infra.ErrCarrierUnauthenticated, "credential %q", credential) } require.Empty(t, probe.token, "the api server must not be asked about a credential that is not a bearer token") @@ -248,11 +222,11 @@ func TestCarrierWithoutATokenIsRefused(t *testing.T) { // would make obvious. func TestCarrierBearerSchemeIsCaseInsensitive(t *testing.T) { client, _ := tokenReviewing(t, - "system:serviceaccount:platform-authority:delivery", []string{"accounts"}, true) + "system:serviceaccount:acme-crm:delivery", []string{"accounts"}, true) check := infra.NewSolutionDeliveryCarrierCheck(client) require.NoError(t, check.AuthorizeCarrier( - context.Background(), "bearer t", business.SolutionDeliveryAuthority, nil)) + context.Background(), "bearer t", "acme-crm")) } // An unreachable API server is RETRYABLE and says nothing about the caller. A @@ -266,7 +240,7 @@ func TestCarrierUnreachableApiServerIsNotAVerdict(t *testing.T) { check := infra.NewSolutionDeliveryCarrierCheck(infra.NewKubernetesClientForTest(t, server)) err := check.AuthorizeCarrier( - context.Background(), "Bearer t", business.SolutionDeliveryAuthority, nil) + context.Background(), "Bearer t", "acme-crm") require.ErrorIs(t, err, infra.ErrKubernetesUnavailable) require.NotErrorIs(t, err, infra.ErrCarrierUnauthenticated) require.NotErrorIs(t, err, infra.ErrCarrierNotAuthorized) @@ -276,7 +250,7 @@ func TestCarrierUnreachableApiServerIsNotAVerdict(t *testing.T) { func TestCarrierCheckWithoutAClientFailsClosed(t *testing.T) { check := infra.NewSolutionDeliveryCarrierCheck(nil) err := check.AuthorizeCarrier( - context.Background(), "Bearer t", business.SolutionDeliveryAuthority, nil) + context.Background(), "Bearer t", "acme-crm") require.ErrorIs(t, err, infra.ErrKubernetesUnavailable) } @@ -298,7 +272,7 @@ func TestHostTokenIsReReadPerRequest(t *testing.T) { require.NoError(t, json.NewEncoder(w).Encode(map[string]any{"status": map[string]any{ "authenticated": true, "audiences": []string{"accounts"}, - "user": map[string]any{"username": "system:serviceaccount:platform-authority:delivery"}, + "user": map[string]any{"username": "system:serviceaccount:acme-crm:delivery"}, }})) })) t.Cleanup(server.Close) @@ -307,10 +281,10 @@ func TestHostTokenIsReReadPerRequest(t *testing.T) { check := infra.NewSolutionDeliveryCarrierCheck(client) require.NoError(t, check.AuthorizeCarrier( - context.Background(), "Bearer t", business.SolutionDeliveryAuthority, nil)) + context.Background(), "Bearer t", "acme-crm")) require.NoError(t, os.WriteFile(tokenPath, []byte("rotated"), 0o600)) require.NoError(t, check.AuthorizeCarrier( - context.Background(), "Bearer t", business.SolutionDeliveryAuthority, nil)) + context.Background(), "Bearer t", "acme-crm")) require.Equal(t, []string{"Bearer first", "Bearer rotated"}, seen, "the host's own token must be re-read per request, or it stops working when the kubelet rotates it") diff --git a/module/services/accounts/code/pkg/infra/solution_delivery_production_path_test.go b/module/services/accounts/code/pkg/infra/solution_delivery_production_path_test.go index 8a832573d..131c34172 100644 --- a/module/services/accounts/code/pkg/infra/solution_delivery_production_path_test.go +++ b/module/services/accounts/code/pkg/infra/solution_delivery_production_path_test.go @@ -5,7 +5,6 @@ package infra_test import ( "context" "encoding/json" - "errors" "net/http" "net/http/httptest" "strings" @@ -23,40 +22,36 @@ import ( ) // The delivery endpoint, END TO END: the real HTTP handler, the real -// TokenReview carrier check, the real Postgres inbox, and the real reconciler -// reading that inbox. -// -// WHY THIS FILE EXISTS RATHER THAN MORE HANDLER TESTS. The handler tests beside -// `solution_delivery_http.go` wire the service's whole delivery half out of -// fakes: a fake verifier, a fake inbox, a fake carrier authorizer. That is the -// right shape for the response-code table and the wrong shape for everything -// else, because a fake standing in for the thing under test proves the caller -// and never the thing. Three defects survived that suite: -// -// - both paths verified with the PRESENCE reader, so an authority document -// could not be delivered at all and a presence document POSTed to -// /authority was stored as kind=authority; -// - a tombstone declares no workload and therefore no namespace, so the -// carrier check refused it and REMOVAL THROUGH THE API WAS IMPOSSIBLE; -// - nothing read the inbox, because the reconciler's source was the mount. -// -// None of the three is visible when the inbox and the carrier check are fakes. -// So these drive the real ones. -// -// Signing and verification use real platform-key envelopes. The remaining -// doubles model the cluster API and the external policy log, while persistence -// and reconciliation exercise the production path. - -// The signer identity this host attests delivery as for the fixtures. It is -// core's own, because the shipped documents are written against it. -const productionPathSigner = solutionhost.FixtureDeliveredBy - -// deliveryWriter is the one service account that delivers, for either kind. -const deliveryWriterAccount = "delivery" - -// authorityWriterNamespace is the authority half's fixed writer namespace, as -// the carrier check requires it. -const authorityWriterNamespace = "platform-authority" +// TokenReview carrier check against a fake API server, the real Postgres inbox, +// and the real reconciler reading that inbox. The handler tests beside +// solution_delivery_http.go fake the inbox and the carrier check; these do +// not, so a manifest that cannot actually be delivered, applied and withdrawn +// through the API fails here. + +// productionPathSigner is the pinned key these tests sign with, and +// productionPathForeignSigner a key pinned for another ownership domain. +const ( + productionPathSigner = "example-release" + productionPathForeignSigner = "globex-release" + productionPathForeignDomain = "globex" +) + +// productionPathDomain is the ownership domain this file's manifests are +// delivered under, and productionPathCoordinate the host they target. +const ( + productionPathDomain = "acme" + productionPathCoordinate = "acme/test/eu-west-1" +) + +// The host these tests stand in for runs as module "saas" in +// "platform-acme-test-saas", so a module's namespace is +// "platform-acme-test-" by the cell's rule. +const ( + productionPathHostNamespace = "platform-acme-test-saas" + productionPathHostModule = "saas" +) + +func productionPathNamespace(module string) string { return "platform-acme-test-" + module } // productionPathDelivery is the whole receiving side, assembled from the real // pieces. @@ -64,18 +59,16 @@ type productionPathDelivery struct { service *business.Service handler http.Handler - // callerNamespace is the namespace the fake API SERVER authenticates the - // caller in. A fake api server rather than a fake carrier check: the - // namespace comparison, the service-account comparison and the audience on - // the review are all real, and a fake check would let every one of them go - // untested while looking identical. + // callerNamespace and callerAccount are what the fake API SERVER + // authenticates the caller as. A fake api server rather than a fake carrier + // check: the namespace and account comparisons are real. callerNamespace string callerAccount string } func newProductionPathDelivery(t *testing.T) *productionPathDelivery { t.Helper() - delivery := &productionPathDelivery{callerNamespace: authorityWriterNamespace, callerAccount: deliveryWriterAccount} + delivery := &productionPathDelivery{callerAccount: "delivery"} server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/apis/authentication.k8s.io/v1/tokenreviews" { http.NotFound(w, r) @@ -95,344 +88,187 @@ func newProductionPathDelivery(t *testing.T) *productionPathDelivery { service, err := business.NewService(testStore) require.NoError(t, err) - // A WITHDRAWAL IS A NARROWING OF AUTHORITY, so the apply that closes an - // installable target must be witnessed by the external policy log before it - // takes effect. A service with no log cannot withdraw at all — which is the - // correct production behaviour and not a configuration a test should assert - // against, so the double below stands in for the warehouse. It is the LOG - // that is doubled, not the protocol: the receipts and the cursor go to the - // real Postgres store. + // A withdrawal is a narrowing of authority, witnessed by the external + // policy log before it takes effect; the double stands in for the log + // only, and the receipts go to the real store. service.SetPolicyLog(&productionPathPolicyLog{}, testStore) - service.SetSolutionDelivery( - deliverytest.Verifier(t, map[string][]string{productionPathSigner: {solutionhost.FixtureDomain, productionPathDomain}}), - testStore, - infra.NewSolutionDeliveryCarrierCheck(infra.NewKubernetesClientForTest(t, server)), - map[string][]string{ - productionPathSigner: {solutionhost.FixtureDomain, productionPathDomain}, - }, - ) + service.SetSolutionDelivery(productionVerifier(t), testStore, + infra.NewSolutionDeliveryCarrierCheck(infra.NewKubernetesClientForTest(t, server))) + service.SetSolutionDeliveryCoordinate(productionPathCoordinate) + rule, err := business.NewModuleNamespaceRule(productionPathHostNamespace, productionPathHostModule) + require.NoError(t, err) + service.SetModuleNamespaceRule(rule) delivery.service = service delivery.handler = adapters.NewSolutionDeliveryHTTPHandler(service) return delivery } -// post delivers one carrier to one kind's path, as the Job does. -func (d *productionPathDelivery) post( - t *testing.T, kind business.SolutionDeliveryKind, body []byte, -) *httptest.ResponseRecorder { +// post delivers one carrier, as the Job does. +func (d *productionPathDelivery) post(t *testing.T, body []byte) *httptest.ResponseRecorder { t.Helper() - request := httptest.NewRequest(http.MethodPost, - adapters.SolutionDeliveryPrefix+string(kind), strings.NewReader(string(body))) + request := httptest.NewRequest(http.MethodPost, adapters.SolutionDeliveryManifestPath, strings.NewReader(string(body))) request.Header.Set("Authorization", "Bearer projected-delivery-token") recorder := httptest.NewRecorder() d.handler.ServeHTTP(recorder, request) return recorder } -// inbox reads the newest delivered generation per document for one kind, -// through the store method the reconciler itself reads with. -func inbox(t *testing.T, kind business.SolutionDeliveryKind) map[string]*business.SolutionDeliveryRecord { +// deliver posts manifest as the delivery account of its module's namespace. +func (d *productionPathDelivery) deliver(t *testing.T, manifest *solutionhost.ModuleManifest) *httptest.ResponseRecorder { t.Helper() - var records []*business.SolutionDeliveryRecord - require.NoError(t, testStore.WithControlPlane(testCtx, func(ctx context.Context) error { - var err error - records, err = testStore.ListNewestDeliveredDocuments(ctx, kind) - return err - })) - byID := make(map[string]*business.SolutionDeliveryRecord, len(records)) - for _, record := range records { - byID[record.DocumentID] = record - } - return byID + d.callerNamespace, d.callerAccount = productionPathNamespace(manifest.Alias), "delivery" + return d.post(t, productionPathCarrier(t, manifest)) } -// --------------------------------------------------------------------------- -// The two kinds are two readers -// --------------------------------------------------------------------------- - -// CORE'S OWN SIGNED FIXTURES, one per kind, through the real handler: each is -// accepted on its own path and refused on the other's. -// -// The fixtures are the point. A document this repository writes for itself -// agrees with this repository's reading of the schema by construction; core's -// are the bytes the renderer produces and the three repositories share. And the -// cross-kind half is what the inverted dispatch could not do: before it, the -// genuine authority fixture was REFUSED at /authority with "schema not -// supported", while the presence fixture POSTed to /authority was accepted and -// filed as kind=authority. -// -// The refusal is asserted as 400 rather than merely "an error". The bytes are -// wrong, not the signer: a 403 would send an operator to look at a signing -// identity that signed the document perfectly well and was simply sent to the -// wrong path. -func TestSignedFixturesAreAcceptedOnTheirOwnKindAndRefusedOnTheOther(t *testing.T) { - presence := signedFixture(t, "presence") - authority := signedFixture(t, "authority") - - delivery := newProductionPathDelivery(t) - - // The presence fixture's workloads declare namespace alpha-region-a-01, and - // the carrier must authenticate there. - delivery.callerNamespace = fixturePresenceNamespace - accepted := delivery.post(t, business.SolutionDeliveryPresence, presence) - requireDelivered(t, accepted) - presenceRow := inbox(t, business.SolutionDeliveryPresence)[solutionhost.FixtureBindingID] - require.NotNil(t, presenceRow, "the presence fixture is not in the inbox under its binding id") - require.Equal(t, uint64(4), presenceRow.Generation) - require.Equal(t, solutionhost.FixtureDomain, presenceRow.OwnershipDomain) - require.Equal(t, productionPathSigner, presenceRow.SignerIdentity) - - delivery.callerNamespace = authorityWriterNamespace - accepted = delivery.post(t, business.SolutionDeliveryAuthority, authority) - requireDelivered(t, accepted) - authorityRow := inbox(t, business.SolutionDeliveryAuthority)[fixtureAuthorityID] - require.NotNil(t, authorityRow, - "the authority fixture is not in the inbox: an authority document could not be delivered at all") - require.Equal(t, uint64(2), authorityRow.Generation) - require.Equal(t, fixtureAuthorityID, authorityRow.DocumentID, - "an authority row is keyed by its OWN id, never by the presence binding it is granted over") - - // Now each on the other's path. - refused := delivery.post(t, business.SolutionDeliveryAuthority, presence) - require.Equal(t, http.StatusBadRequest, refused.Code, - "a presence document POSTed to /authority is wrong BYTES for that path, not a wrong signer") - require.NotContains(t, inbox(t, business.SolutionDeliveryAuthority), solutionhost.FixtureBindingID, - "a presence document was filed as kind=authority") - - delivery.callerNamespace = fixturePresenceNamespace - refused = delivery.post(t, business.SolutionDeliveryPresence, authority) - require.Equal(t, http.StatusBadRequest, refused.Code, - "an authority document POSTed to /presence is wrong BYTES for that path") - require.NotContains(t, inbox(t, business.SolutionDeliveryPresence), fixtureAuthorityID, - "an authority document was filed as kind=presence") -} - -// fixturePresenceNamespace is the namespace core's valid presence fixture -// declares through its workload's SPIFFE ID, which is what its carrier is -// authorised against. -const fixturePresenceNamespace = "alpha-region-a-01" - -// fixtureAuthorityID is the authority id core's valid authority fixture -// declares. It is deliberately NOT FixtureBindingID: an authority document is -// identified by its own id and granted over a binding, and conflating the two -// is what let a withdrawal be reinstated by renaming. -const fixtureAuthorityID = "alpha-region-a-01-authority" - -// signedFixture returns one of core's shipped signed carriers by name, through -// FixturesOf so a fixture that is renamed or dropped fails here rather than -// silently stopping being exercised. -func signedFixture(t *testing.T, name string) []byte { +// observe reads the applied state as the protected observer. +func (d *productionPathDelivery) observe(t *testing.T, binding string, want int) *business.SolutionDeliveryApplied { t.Helper() - for _, fixture := range solutionhost.FixturesOf(solutionhost.DocumentTypeSigned) { - if fixture.Name == name { - require.Equal(t, solutionhost.OutcomeAccepted, fixture.Outcome, - "this test drives the ACCEPTED fixtures; %q is now expected to be rejected", name) - return fixture.Document - } + namespace, account := d.callerNamespace, d.callerAccount + d.callerNamespace, d.callerAccount = "istio-presenter", "endpoint-publisher" + defer func() { d.callerNamespace, d.callerAccount = namespace, account }() + r := httptest.NewRequest(http.MethodGet, adapters.SolutionDeliveryPrefix+"applied/"+binding, nil) + r.Header.Set("Authorization", "Bearer projected-observer-token") + w := httptest.NewRecorder() + d.handler.ServeHTTP(w, r) + require.Equal(t, want, w.Code, w.Body.String()) + if want != http.StatusOK { + return nil } - t.Fatalf("core ships no signed fixture named %q", name) - return nil + var applied business.SolutionDeliveryApplied + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &applied)) + return &applied } -// requireDelivered accepts 202 and 200 and nothing else. -// -// Both are success and the difference is whether anything changed. The inbox is -// append-only with no DELETE grant, so a fixture whose binding id and -// generation are fixed is 202 the first time this package's database sees it and -// 200 on every run after — and a test that demanded 202 would pass once and then -// fail forever for a reason that has nothing to do with the behaviour. +// requireDelivered accepts a 200 receipt: "accepted" the first time this +// package's database sees a generation, "replayed" after. func requireDelivered(t *testing.T, recorder *httptest.ResponseRecorder) { t.Helper() - if recorder.Code != http.StatusAccepted && recorder.Code != http.StatusOK { - t.Fatalf("delivery was refused with %d: %s", recorder.Code, recorder.Body.String()) - } + require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String()) } -// --------------------------------------------------------------------------- -// Removal through the API, end to end -// --------------------------------------------------------------------------- - -// productionPathDomain is the ownership domain this file's own documents are -// delivered under, and productionPathCoordinate the host they target. -const ( - productionPathDomain = "acme" - productionPathCoordinate = "acme/test/eu-west-1" -) - -// A TOMBSTONE IS ACCEPTED AND THE BINDING IS REMOVED, through the real endpoint -// and the real inbox. -// -// This is the defect that made the delivery API unable to express the one thing -// the whole design rests on. Removal is a GENERATION — nothing deletes from the -// inbox and nothing deletes a binding — and a tombstone declares absence, so it -// carries no workload and therefore no workload namespace. The carrier check -// authorised a presence carrier against the namespaces the document's own -// workloads declare and refused the empty set by name, so every tombstone was -// 403 and a binding delivered through this endpoint could never be withdrawn -// through it. -// -// The fix is not to relax the check. It is to authorise the carrier against the -// namespace this host LAST APPLIED for that binding: its own recorded state, -// from a generation it already verified and admitted, rather than anything the -// arriving document asserts. -// -// The whole lifecycle is driven here because each half alone is satisfiable -// without the other: a tombstone accepted but never reconciled leaves the -// solution serving, and a reconcile that withdraws without the POST having been -// accepted is the state the fake-sourced tests were already in. -func TestTombstoneIsAcceptedAndWithdrawsTheRegistrationEndToEnd(t *testing.T) { - solutionID := uniqueAlias("rm") - namespace := "ns-" + solutionID - present := productionPathBinding(t, solutionID, namespace, 1) - +// A MANIFEST IS DELIVERED, APPLIED AND WITHDRAWN, through the real endpoint, +// the real inbox and the real reconciler. +func TestAManifestIsDeliveredAppliedAndWithdrawnEndToEnd(t *testing.T) { + solutionID := uniqueAlias("e2e") + present := productionPathManifest(t, solutionID, 1) delivery := newProductionPathDelivery(t) - observe := func(want int) *business.SolutionDeliveryApplied { - previousNamespace, previousAccount := delivery.callerNamespace, delivery.callerAccount - delivery.callerNamespace, delivery.callerAccount = "istio-presenter", "endpoint-publisher" - defer func() { delivery.callerNamespace, delivery.callerAccount = previousNamespace, previousAccount }() - r := httptest.NewRequest(http.MethodGet, adapters.SolutionDeliveryPrefix+"applied/"+present.Binding, nil) - r.Header.Set("Authorization", "Bearer projected-observer-token") - w := httptest.NewRecorder() - delivery.handler.ServeHTTP(w, r) - require.Equal(t, want, w.Code, w.Body.String()) - if want != http.StatusOK { - return nil - } - var receipt business.SolutionDeliveryApplied - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &receipt)) - return &receipt + delivery.observe(t, present.Binding, http.StatusNotFound) + + recorder := delivery.deliver(t, present) + requireDelivered(t, recorder) + var receipt struct { + Disposition string `json:"disposition"` + Generation uint64 `json:"generation"` } - observe(http.StatusNotFound) - delivery.callerNamespace = namespace - requireDelivered(t, delivery.post(t, business.SolutionDeliveryPresence, - productionPathCarrier(t, present))) - observe(http.StatusNotFound) // The durable desired receipt has not applied. + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &receipt)) + require.Equal(t, "accepted", receipt.Disposition) + require.Equal(t, uint64(1), receipt.Generation) + delivery.observe(t, present.Binding, http.StatusNotFound) // received is not applied reconciler := newProductionPathReconciler(t, delivery.service) require.NoError(t, reconciler.RunOnce(testCtx)) - applied := productionPathBindingRecord(t, delivery.service, present.Binding) require.NotNil(t, applied.Applied, "the delivered generation was never applied from the inbox") require.Equal(t, uint64(1), applied.Applied.Generation) require.Equal(t, solutionID, applied.Applied.SolutionID) - require.False(t, applied.Applied.Removed) digest, err := present.Digest() require.NoError(t, err) - require.Equal(t, digest, observe(http.StatusOK).PresenceDigest) - // A saved desired successor does not alter the reported committed identity. - desired := *present - desired.Generation = 99 - desired.Host.Coordinate = "withheld-desired-host" - desiredDigest, err := desired.Digest() - require.NoError(t, err) - desiredBytes, err := solutionhost.Marshal(&desired) - require.NoError(t, err) - applied.Desired = &business.SolutionHostBindingGeneration{Generation: 99, Digest: desiredDigest, Document: string(desiredBytes), At: time.Now()} - applied.HostCoordinate = "withheld-desired-host" - require.NoError(t, testStore.WithControlPlane(testCtx, func(ctx context.Context) error { - return testStore.SaveSolutionHostBinding(ctx, applied) - })) - require.Equal(t, present.Host, observe(http.StatusOK).Host) - // A transaction that writes an applied change then rolls back must remain - // invisible to an observer on another control-plane transaction. - rolledBack := errors.New("test rollback") - require.ErrorIs(t, testStore.WithControlPlane(testCtx, func(ctx context.Context) error { - applied.Applied.Generation++ - if saveErr := testStore.SaveSolutionHostBinding(ctx, applied); saveErr != nil { - return saveErr - } - return rolledBack - }), rolledBack) - require.Equal(t, uint64(1), observe(http.StatusOK).Generation) - - // THE POST THAT WAS IMPOSSIBLE. The tombstone declares no workload, so its - // carrier is authorised against the namespace generation 1 applied. - tombstone := productionPathTombstone(t, present, 2) - receipt := delivery.post(t, business.SolutionDeliveryPresence, productionPathCarrier(t, tombstone)) - if receipt.Code == http.StatusForbidden { - t.Fatalf("the tombstone's carrier was refused, so removal through the API is impossible: %s", - receipt.Body.String()) - } - requireDelivered(t, receipt) - require.False(t, observe(http.StatusOK).Removed, "desired tombstone must not report committed removal") - - // The inbox's desired set is the NEWEST generation per document, so the - // tombstone supersedes generation 1 without anything being deleted. - row := inbox(t, business.SolutionDeliveryPresence)[present.Binding] - require.NotNil(t, row) - require.Equal(t, uint64(2), row.Generation) - + observed := delivery.observe(t, present.Binding, http.StatusOK) + require.Equal(t, digest, observed.Digest) + require.Equal(t, present.Host, observed.Host) + require.Nil(t, observed.Pending) + require.NotNil(t, registrationOf(t, delivery.service, solutionID)) + + // Delivering the same generation again is a replay; an older or rewritten + // one is a conflict. + replay := delivery.deliver(t, present) + requireDelivered(t, replay) + require.Contains(t, replay.Body.String(), `"replayed"`) + rewritten := productionPathManifest(t, solutionID, 1) + rewritten.Release.Version = "9.9.9" + require.Equal(t, http.StatusConflict, delivery.deliver(t, rewritten).Code) + + // THE WITHDRAWAL, carried by the delivery account of the module it + // withdraws. + withdrawal := productionPathWithdrawal(t, present, 2) + requireDelivered(t, delivery.deliver(t, withdrawal)) + require.False(t, delivery.observe(t, present.Binding, http.StatusOK).Removed, "received is not applied") require.NoError(t, reconciler.RunOnce(testCtx)) withdrawn := productionPathBindingRecord(t, delivery.service, present.Binding) - require.NotNil(t, withdrawn.Applied) - require.Equal(t, uint64(2), withdrawn.Applied.Generation) - require.True(t, withdrawn.Applied.Removed, "the tombstone generation did not apply") - observedRemoval := observe(http.StatusOK) - require.True(t, observedRemoval.Removed) - require.Equal(t, uint64(2), observedRemoval.Generation) - require.Equal(t, solutionID, withdrawn.Applied.SolutionID, - "a tombstone keeps the key it withdrew, so the removal stays attributable") - - // Later received-only and refused history is not carrier provenance. - unapplied := *present - unapplied.Generation = 3 - unapplied.Workloads = append([]solutionhost.Workload(nil), present.Workloads...) - unapplied.Workloads[0].Identity.SPIFFEID = "spiffe://cluster.local/ns/unrelated-namespace/sa/backend" - delivery.callerNamespace = "unrelated-namespace" - requireDelivered(t, delivery.post(t, business.SolutionDeliveryPresence, productionPathCarrier(t, &unapplied))) + require.True(t, withdrawn.Applied.Removed, "the withdrawal did not apply") + require.Equal(t, solutionID, withdrawn.Applied.SolutionID, "a withdrawal keeps the key it withdrew") + require.True(t, delivery.observe(t, present.Binding, http.StatusOK).Removed) + require.NotNil(t, registrationOf(t, delivery.service, solutionID).TombstonedAt, + "the registration is still live after the binding was withdrawn") + + // A replay after a restart is carried from the same namespace and nowhere + // else, and the binding never comes back. + restarted := newProductionPathDelivery(t) + requireDelivered(t, restarted.deliver(t, withdrawal)) + restarted.callerNamespace = productionPathNamespace("unrelated") + require.Equal(t, http.StatusForbidden, restarted.post(t, productionPathCarrier(t, withdrawal)).Code) + revived := productionPathManifest(t, solutionID, 3) + require.Equal(t, http.StatusConflict, restarted.deliver(t, revived).Code) +} + +// The carrier is the `delivery` account of the manifest's module namespace by +// the cell's rule, and nothing else: another account in that namespace, +// another module's delivery account, and the bare module namespace are each +// refused, and nothing reaches the inbox. +func TestOnlyTheModulesDeliveryAccountCarriesItsManifest(t *testing.T) { + manifest := productionPathManifest(t, uniqueAlias("carrier"), 1) + delivery := newProductionPathDelivery(t) + for name, caller := range map[string][2]string{ + "another account": {productionPathNamespace(manifest.Alias), "default"}, + "another module's delivery": {productionPathNamespace("billing"), "delivery"}, + "the bare module namespace": {manifest.Alias, "delivery"}, + } { + t.Run(name, func(t *testing.T) { + delivery.callerNamespace, delivery.callerAccount = caller[0], caller[1] + recorder := delivery.post(t, productionPathCarrier(t, manifest)) + require.Equal(t, http.StatusForbidden, recorder.Code, recorder.Body.String()) + require.Contains(t, recorder.Body.String(), productionPathNamespace(manifest.Alias)+"/delivery") + }) + } + var newest []*business.SolutionDeliveryRecord require.NoError(t, testStore.WithControlPlane(testCtx, func(ctx context.Context) error { - for i := uint64(100); i < 160; i++ { - if err := testStore.RecordSolutionGenerationDecision(ctx, &business.SolutionGenerationDecision{BindingID: present.Binding, Generation: i, Digest: digest, Decision: business.SolutionGenerationRefused, Reason: "unapplied generation"}); err != nil { - return err - } - } - return nil + var err error + newest, err = testStore.ListNewestDeliveredDocuments(ctx) + return err })) + for _, record := range newest { + require.NotEqual(t, manifest.Binding, record.Binding, "a refused carrier reached the inbox") + } +} - // Replaying after APPLIED must retain the original carrier namespace, - // including after the process serving delivery has restarted. - restarted := newProductionPathDelivery(t) - restarted.callerNamespace = namespace - replay := restarted.post(t, business.SolutionDeliveryPresence, productionPathCarrier(t, tombstone)) - require.Equal(t, http.StatusOK, replay.Code, replay.Body.String()) - restarted.callerNamespace = "unrelated-namespace" - refused := restarted.post(t, business.SolutionDeliveryPresence, productionPathCarrier(t, tombstone)) - require.Equal(t, http.StatusForbidden, refused.Code, refused.Body.String()) - - // And the registry record the solution was served under is tombstoned, which - // is what actually takes it out of service. - registrations, _, err := delivery.service.ListSolutionRegistrations(testCtx, true) +func registrationOf(t *testing.T, service *business.Service, solutionID string) *business.SolutionRegistration { + t.Helper() + registrations, _, err := service.ListSolutionRegistrations(testCtx, true) require.NoError(t, err) for _, registration := range registrations { if registration.SolutionID == solutionID { - require.NotNil(t, registration.TombstonedAt, - "the registration is still live after the binding was withdrawn") + return registration } } + return nil } -// newProductionPathReconciler builds the reconciler over the DURABLE INBOX, +// newProductionPathReconciler builds the reconciler over the durable inbox, // which is the only source there is. -func newProductionPathReconciler( - t *testing.T, service *business.Service, -) *business.SolutionHostBindingReconciler { +func newProductionPathReconciler(t *testing.T, service *business.Service) *business.SolutionHostBindingReconciler { t.Helper() reconciler, err := business.NewSolutionHostBindingReconciler(service, business.SolutionHostBindingReconcilerConfig{ - Source: business.NewDeliveredSolutionHostBindings(service), - Verifier: deliverytest.Verifier(t, map[string][]string{productionPathSigner: {solutionhost.FixtureDomain, productionPathDomain}}), - Coordinate: productionPathCoordinate, - Domains: []string{productionPathDomain}, - DomainsBySigner: map[string][]string{productionPathSigner: {productionPathDomain}}, - Interval: time.Minute, + Source: business.NewDeliveredSolutionHostBindings(service), + Verifier: productionVerifier(t), + Coordinate: productionPathCoordinate, + Interval: time.Minute, }) require.NoError(t, err) return reconciler } -func productionPathBindingRecord( - t *testing.T, service *business.Service, bindingID string, -) *business.SolutionHostBindingRecord { +func productionPathBindingRecord(t *testing.T, service *business.Service, bindingID string) *business.SolutionHostBindingRecord { t.Helper() records, err := service.ListSolutionHostBindings(testCtx) require.NoError(t, err) @@ -445,123 +281,70 @@ func productionPathBindingRecord( return nil } -// productionPathBinding is a present generation for one solution on this test -// host, with its workload in namespace. -func productionPathBinding( - t *testing.T, solutionID, namespace string, generation uint64, -) *solutionhost.SolutionHostBinding { +// productionPathManifest is a live manifest for one module on this test host: +// one workload, one routed endpoint. +func productionPathManifest(t *testing.T, alias string, generation uint64) *solutionhost.ModuleManifest { t.Helper() - document := &solutionhost.SolutionHostBinding{ - Schema: solutionhost.SchemaPresenceV1, - Kind: solutionhost.KindSolution, - Binding: "acme.test." + solutionID, - Generation: generation, - OwnershipDomain: productionPathDomain, - EnvelopeRevision: solutionhost.FixtureEnvelopeRevision, - Host: solutionhost.HostTarget{ - Coordinate: productionPathCoordinate, Component: "saas-host", - }, - Release: solutionhost.Release{ - Publisher: "acme", Name: solutionID, Version: "1.4.0", - Digest: solutionhost.ReleaseDigest(fixtureDigest("release/" + solutionID)), - }, - Routes: []solutionhost.Route{{Alias: solutionID, Surface: solutionhost.SurfaceBackend}}, - Artifacts: []solutionhost.Artifact{{ - Surface: solutionhost.SurfaceBackend, Name: "api", - Release: "acme/" + solutionID + "@1.4.0", - Digest: solutionhost.RenderedDigest(fixtureDigest("rendered/" + solutionID)), - }}, - Workloads: []solutionhost.Workload{{ - Name: "api", Artifact: "api", Container: "api", - Image: solutionhost.Image{ - Repository: "registry.example/acme/" + solutionID, - Digest: solutionhost.ImageDigest(fixtureDigest("image/" + solutionID)), - }, - // The namespace the carrier is authorised against reaches the host - // through this SPIFFE ID and nowhere else, which is what keeps it - // inside the signed bytes. - Identity: solutionhost.WorkloadIdentity{ - Audience: "https://test.acme.example/solutions", - Subject: "system:serviceaccount:" + namespace + ":api", - SPIFFEID: "spiffe://acme.test/ns/" + namespace + "/sa/api", - }, - NonAuthenticating: &[]string{"telemetry-sidecar"}, - }}, + manifest := &solutionhost.ModuleManifest{ + Schema: solutionhost.SchemaModuleManifestV1, Binding: "acme.test." + alias, Alias: alias, + Kind: solutionhost.KindSolution, Generation: generation, + Host: solutionhost.HostTarget{Coordinate: productionPathCoordinate, Domain: productionPathDomain}, + Release: solutionhost.Release{Publisher: "acme", Name: alias, Version: "1.4.0"}, + Workloads: []solutionhost.Workload{{Service: "api", Container: "api"}}, + Endpoints: []solutionhost.Endpoint{{Name: "rest", Service: "api", API: "rest", Address: "http://api." + productionPathNamespace(alias) + ".svc.cluster.local:8080"}}, } - require.NoError(t, document.Validate(), "the fixture must be a valid binding") - return document + require.NoError(t, manifest.Validate(), "the fixture must be a valid manifest") + return manifest } -// productionPathTombstone is the withdrawal of one binding: a higher generation -// that declares nothing at all. -func productionPathTombstone( - t *testing.T, document *solutionhost.SolutionHostBinding, generation uint64, -) *solutionhost.SolutionHostBinding { +// productionPathWithdrawal is the withdrawal of a manifest at generation. +func productionPathWithdrawal(t *testing.T, manifest *solutionhost.ModuleManifest, generation uint64) *solutionhost.ModuleManifest { t.Helper() - removed := *document - removed.Generation = generation - removed.Routes, removed.Artifacts, removed.Workloads = nil, nil, nil - removed.Modules, removed.Endpoints = nil, nil - removed.Removed = true - require.NoError(t, removed.Validate(), "the tombstone must be a valid binding") + removed := *manifest + removed.Generation, removed.Removed = generation, true + removed.Workloads, removed.Endpoints, removed.Authority, removed.Frontend, removed.Group = nil, nil, nil, nil, nil + require.NoError(t, removed.Validate(), "the withdrawal must be a valid manifest") return &removed } -// productionPathCarrier wraps a document in the carrier delivery POSTs: the -// CANONICAL bytes, which are the signing input, plus a bundle over them. -func productionPathCarrier(t *testing.T, document *solutionhost.SolutionHostBinding) []byte { +// productionPathCarrier is the carrier delivery POSTs: the canonical bytes, +// which are the signing input, and a DSSE envelope over them. +func productionPathCarrier(t *testing.T, manifest *solutionhost.ModuleManifest) []byte { t.Helper() - canonical, err := solutionhost.SignedPayload(document) + payload, err := solutionhost.SignedPayload(manifest) require.NoError(t, err) - return deliverytest.Carrier(t, canonical, productionPathSigner) + return deliverytest.Carrier(t, payload, productionPathSigner) } // productionPathPolicyLog witnesses a narrowing, so a withdrawal can take -// effect. -// -// It is deliberately a RECORDING double rather than a no-op that fabricates a -// receipt out of nothing: the operation id is what the host's idempotency rests -// on, so a log that answered the same receipt for every entry would let the -// protocol pass while recording nothing distinguishable. +// effect. A RECORDING double: the operation id is what the host's idempotency +// rests on, so it is honoured. type productionPathPolicyLog struct { mu sync.Mutex sequence uint64 records []*business.PolicyLogRecord } -func (l *productionPathPolicyLog) Append( - _ context.Context, entry *business.PolicyLogEntry, -) (*business.PolicyLogReceipt, error) { +func (l *productionPathPolicyLog) Append(_ context.Context, entry *business.PolicyLogEntry) (*business.PolicyLogReceipt, error) { l.mu.Lock() defer l.mu.Unlock() - // Idempotent on the operation id, which the interface requires of a real - // log: a retry returns the original receipt rather than appending again. for _, record := range l.records { if record.OperationID == entry.OperationID { - return &business.PolicyLogReceipt{ - Receipt: record.Receipt, Sequence: record.Sequence, AppendedAt: record.LoggedAt, - }, nil + return &business.PolicyLogReceipt{Receipt: record.Receipt, Sequence: record.Sequence, AppendedAt: record.LoggedAt}, nil } } l.sequence++ receipt := &business.PolicyLogReceipt{ - Receipt: "delivery-test-receipt-" + entry.OperationID, - Sequence: l.sequence, - AppendedAt: time.Now().UTC(), + Receipt: "delivery-test-receipt-" + entry.OperationID, Sequence: l.sequence, AppendedAt: time.Now().UTC(), } copied := *entry l.records = append(l.records, &business.PolicyLogRecord{ - PolicyLogEntry: copied, - Receipt: receipt.Receipt, - Sequence: receipt.Sequence, - LoggedAt: receipt.AppendedAt, + PolicyLogEntry: copied, Receipt: receipt.Receipt, Sequence: receipt.Sequence, LoggedAt: receipt.AppendedAt, }) return receipt, nil } -func (l *productionPathPolicyLog) Entries( - _ context.Context, after uint64, _ int, -) ([]*business.PolicyLogRecord, error) { +func (l *productionPathPolicyLog) Entries(_ context.Context, after uint64, _ int) ([]*business.PolicyLogRecord, error) { l.mu.Lock() defer l.mu.Unlock() var out []*business.PolicyLogRecord @@ -573,21 +356,9 @@ func (l *productionPathPolicyLog) Entries( return out, nil } -// wireNarrowingPolicyLog gives a test service the protocol's two halves, so a -// narrowing can actually happen. -// -// WHY EVERY SERVICE IN THIS PACKAGE THAT NARROWS NEEDS THIS. A narrowing of -// authority must be appended to the witness and receipted before it takes -// effect, and `WithPolicyLoggedNarrowing` REFUSES on a host with no log — which -// is the settled fail-closed decision, not an oversight. So a test service built -// without one cannot revoke a principal, remove a member, revoke a delegation or -// archive an organisation at all, and a test that performs one is exercising a -// configuration production is deliberately not meant to have. -// -// The REAL postgres store is the local half, so the receipt still has to commit -// in the same transaction as the narrowing; only the external log is a double. -// That keeps the atomicity these tests are mostly about under test rather than -// stubbing it away. +// wireNarrowingPolicyLog gives a test service the policy log's two halves, so +// a narrowing can actually happen: WithPolicyLoggedNarrowing refuses on a host +// with no log, by design. func wireNarrowingPolicyLog(service *business.Service) *business.Service { service.SetPolicyLog(&productionPathPolicyLog{}, testStore) return service @@ -595,5 +366,93 @@ func wireNarrowingPolicyLog(service *business.Service) *business.Service { func productionVerifier(t *testing.T) *solutionhost.Verifier { t.Helper() - return deliverytest.Verifier(t, map[string][]string{productionPathSigner: {solutionhost.FixtureDomain, productionPathDomain}}) + return deliverytest.Verifier(t, map[string][]string{ + productionPathSigner: {productionPathDomain}, + productionPathForeignSigner: {productionPathForeignDomain}, + }) +} + +// THE HOST'S OWN RULES ARE ENFORCED AT DELIVERY, through the real endpoint: +// a second manifest for a workload a delivered binding holds is a 409 naming +// that binding, a foreign domain's claim on a held principal is a 403, and an +// endpoint that is not its workload's in-cluster Service is a 422. Nothing +// refused reaches the inbox. +func TestTheHostsRulesAreEnforcedAtDelivery(t *testing.T) { + d := newProductionPathDelivery(t) + + holder := productionPathManifest(t, "ruleheld", 1) + holder.Authority = &solutionhost.Authority{Principal: "ruleheld"} + requireDelivered(t, d.deliver(t, holder)) + + second := productionPathManifest(t, "ruleheld", 1) + second.Binding = "acme.test.ruleheld-second" + recorder := d.deliver(t, second) + require.Equal(t, http.StatusConflict, recorder.Code, recorder.Body.String()) + require.Contains(t, recorder.Body.String(), `held by binding \"acme.test.ruleheld\"`) + + // One principal is one live binding within a domain too. + samePrincipal := productionPathManifest(t, "ruleshared", 1) + samePrincipal.Authority = &solutionhost.Authority{Principal: "ruleheld"} + recorder = d.deliver(t, samePrincipal) + require.Equal(t, http.StatusConflict, recorder.Code, recorder.Body.String()) + require.Contains(t, recorder.Body.String(), `principal \"ruleheld\" is held by binding \"acme.test.ruleheld\"`) + + foreign := productionPathManifest(t, "ruleforeign", 1) + foreign.Binding, foreign.Host.Domain, foreign.Release.Publisher = "globex.test.ruleforeign", productionPathForeignDomain, "globex" + foreign.Authority = &solutionhost.Authority{Principal: "ruleheld"} + payload, err := solutionhost.SignedPayload(foreign) + require.NoError(t, err) + d.callerNamespace, d.callerAccount = productionPathNamespace(foreign.Alias), "delivery" + recorder = d.post(t, deliverytest.Carrier(t, payload, productionPathForeignSigner)) + require.Equal(t, http.StatusForbidden, recorder.Code, recorder.Body.String()) + require.Contains(t, recorder.Body.String(), `principal \"ruleheld\" is held by binding \"acme.test.ruleheld\"`) + + for _, address := range []string{ + "http://169.254.169.254:80", + "http://api." + productionPathNamespace("another") + ".svc.cluster.local:8080", + } { + escaping := productionPathManifest(t, "ruleaddress", 1) + escaping.Endpoints[0].Address = address + recorder = d.deliver(t, escaping) + require.Equal(t, http.StatusUnprocessableEntity, recorder.Code, address+": "+recorder.Body.String()) + } +} + +// A BINDING KEEPS ITS DOMAIN WHEN ITS LIVE STATE IS GONE. Migration 41 cleared +// every live binding's applied manifest and kept the domain it applied under +// (first_domain); until the owner redelivers, another pinned domain's +// manifest for that binding id is refused, and the owner's is not. A binding +// the host applies records its domain the same way. +func TestABindingAwaitingRedeliveryKeepsItsDomain(t *testing.T) { + d := newProductionPathDelivery(t) + const binding = "acme.test.ruletakeover" + // The state migration 41 leaves: no applied manifest, the domain kept. + require.NoError(t, testStore.WithControlPlane(testCtx, func(ctx context.Context) error { + return testStore.SaveSolutionHostBinding(ctx, &business.SolutionHostBindingRecord{ + BindingID: binding, HostCoordinate: productionPathCoordinate, Domain: productionPathDomain, UpdatedAt: time.Now().UTC(), + }) + })) + + takeover := productionPathManifest(t, "ruletakeover", 1) + takeover.Binding, takeover.Host.Domain, takeover.Release.Publisher = binding, productionPathForeignDomain, "globex" + payload, err := solutionhost.SignedPayload(takeover) + require.NoError(t, err) + d.callerNamespace, d.callerAccount = productionPathNamespace(takeover.Alias), "delivery" + recorder := d.post(t, deliverytest.Carrier(t, payload, productionPathForeignSigner)) + require.Equal(t, http.StatusForbidden, recorder.Code, recorder.Body.String()) + require.Contains(t, recorder.Body.String(), `binding \"acme.test.ruletakeover\" belongs to ownership domain \"acme\"`) + + owner := productionPathManifest(t, "ruletakeover", 1) + owner.Binding = binding + requireDelivered(t, d.deliver(t, owner)) + require.NoError(t, newProductionPathReconciler(t, d.service).RunOnce(testCtx)) + record := productionPathBindingRecord(t, d.service, binding) + require.NotNil(t, record.Applied, record.PendingReason) + require.Equal(t, productionPathDomain, record.Domain) + + fresh := productionPathManifest(t, "ruledomainrecorded", 1) + requireDelivered(t, d.deliver(t, fresh)) + require.NoError(t, newProductionPathReconciler(t, d.service).RunOnce(testCtx)) + require.Equal(t, productionPathDomain, productionPathBindingRecord(t, d.service, fresh.Binding).Domain, + "an applied binding records the domain it applied under") } diff --git a/module/services/accounts/code/pkg/infra/solution_frontend_lifecycle_test.go b/module/services/accounts/code/pkg/infra/solution_frontend_lifecycle_test.go index bda71b934..eaea37176 100644 --- a/module/services/accounts/code/pkg/infra/solution_frontend_lifecycle_test.go +++ b/module/services/accounts/code/pkg/infra/solution_frontend_lifecycle_test.go @@ -53,12 +53,10 @@ func TestFrontendAppliedHistoryPreservesUpdatesRollbackAndWithdrawal(t *testing. delivery.service.SetFrontendArtifactSource(source) reconciler := newProductionPathReconciler(t, delivery.service) alias := uniqueAlias("frontend") - namespace := "ns-" + alias - delivery.callerNamespace = namespace - document := productionPathBinding(t, alias, namespace, 1) + document := productionPathManifest(t, alias, 1) document.Frontend = a apply := func() error { - requireDelivered(t, delivery.post(t, business.SolutionDeliveryPresence, productionPathCarrier(t, document))) + requireDelivered(t, delivery.deliver(t, document)) return reconciler.RunOnce(testCtx) } require.NoError(t, apply()) @@ -122,10 +120,7 @@ func TestFrontendAppliedHistoryPreservesUpdatesRollbackAndWithdrawal(t *testing. document.Generation = 6 document.Removed = true document.Workloads = nil - document.Routes = nil - document.Modules = nil document.Endpoints = nil - document.Artifacts = nil require.NoError(t, apply()) for generation := uint64(1); generation <= 3; generation++ { read(recovered, generation, false) @@ -134,7 +129,7 @@ func TestFrontendAppliedHistoryPreservesUpdatesRollbackAndWithdrawal(t *testing. func TestFrontendRetentionIsNotDiagnosticHistoryLimit(t *testing.T) { alias := uniqueAlias("retained") - binding := productionPathBinding(t, alias, "ns-"+alias, 1) + binding := productionPathManifest(t, alias, 1) target := declarePresence(t, binding.Binding, alias) now := time.Now().UTC() revision := int64(1) @@ -148,7 +143,7 @@ func TestFrontendRetentionIsNotDiagnosticHistoryLimit(t *testing.T) { require.NoError(t, err) digest, err := binding.Digest() require.NoError(t, err) - _, err = testStore.RecordDeliveredDocument(ctx, &business.SolutionDeliveryRecord{Kind: business.SolutionDeliveryPresence, DocumentID: binding.Binding, Generation: generation, ContentHash: digest, Payload: data, Bundle: deliverytest.Envelope(t, data, solutionhost.FixtureDeliveredBy), SignerIdentity: productionPathSigner, OwnershipDomain: productionPathDomain, ReceivedAt: now}) + _, err = testStore.RecordDeliveredDocument(ctx, &business.SolutionDeliveryRecord{Binding: binding.Binding, Generation: generation, ContentHash: digest, Payload: data, Envelope: deliverytest.Envelope(t, data, productionPathSigner), SignerIdentity: productionPathSigner, OwnershipDomain: productionPathDomain, ReceivedAt: now}) require.NoError(t, err) decision := "applied" reason := "" diff --git a/module/services/accounts/code/pkg/infra/solution_host_bundle_verifier.go b/module/services/accounts/code/pkg/infra/solution_host_bundle_verifier.go index b9cf753bf..5b24b037a 100644 --- a/module/services/accounts/code/pkg/infra/solution_host_bundle_verifier.go +++ b/module/services/accounts/code/pkg/infra/solution_host_bundle_verifier.go @@ -52,6 +52,11 @@ type solutionHostBundleVerifierWithPolicy interface { // refuses a local profile. A deployed policy is bound to no cluster and this // does nothing. Until it succeeds a local policy admits no carrier. ProveLocalCluster(ctx context.Context, cluster SolutionHostClusterAuthority) error + + // ProveNativeRun binds a LOCAL policy written for a native run to the run + // whose anchor fingerprint this host was started under, and refuses any + // other policy. + ProveNativeRun(anchorFingerprint string) error } // SolutionHostTrustAnchorPath is where the verification policy is read from, diff --git a/module/services/accounts/code/pkg/infra/solution_host_key_verifier.go b/module/services/accounts/code/pkg/infra/solution_host_key_verifier.go index 64efeff57..8e73ec695 100644 --- a/module/services/accounts/code/pkg/infra/solution_host_key_verifier.go +++ b/module/services/accounts/code/pkg/infra/solution_host_key_verifier.go @@ -174,6 +174,9 @@ func (v *keyBundleVerifier) ProveLocalCluster(ctx context.Context, cluster Solut if v.local == nil { return nil } + if v.local.NativeAnchorSHA256 != "" { + return fmt.Errorf("%w: the verification policy is bound to a native run, and this host is proving a cluster", ErrSolutionHostLocalPolicyOutsideItsCluster) + } if cluster == nil { return fmt.Errorf("%w: the verification policy is a local profile and this host has no cluster to prove it against", ErrSolutionHostLocalPolicyOutsideItsCluster) } @@ -183,3 +186,18 @@ func (v *keyBundleVerifier) ProveLocalCluster(ctx context.Context, cluster Solut v.localClusterProven.Store(true) return nil } + +// ProveNativeRun binds a LOCAL policy written for a native run to the run this +// host was started under: the anchor's key fingerprint must be the one the +// policy pins. Any other policy — a deployed one, or one bound to a cluster — +// refuses, so a host is never pointed at a native anchor it was not given. +func (v *keyBundleVerifier) ProveNativeRun(anchorFingerprint string) error { + if v.local == nil || v.local.NativeAnchorSHA256 == "" { + return fmt.Errorf("%w: this host runs under a native anchor, and its verification policy is not bound to one", ErrSolutionHostLocalPolicyOutsideItsCluster) + } + if anchorFingerprint != v.local.NativeAnchorSHA256 { + return fmt.Errorf("%w: the verification policy is bound to native run anchor %s, and this host runs under %s", ErrSolutionHostLocalPolicyOutsideItsCluster, v.local.NativeAnchorSHA256, anchorFingerprint) + } + v.localClusterProven.Store(true) + return nil +} diff --git a/module/services/accounts/code/pkg/infra/solution_host_key_verifier_test.go b/module/services/accounts/code/pkg/infra/solution_host_key_verifier_test.go index c0aeb051c..b8808f426 100644 --- a/module/services/accounts/code/pkg/infra/solution_host_key_verifier_test.go +++ b/module/services/accounts/code/pkg/infra/solution_host_key_verifier_test.go @@ -21,17 +21,24 @@ func acceptedPolicy(t *testing.T) *infra.SolutionHostVerificationPolicy { t.Helper() return &infra.SolutionHostVerificationPolicy{Signers: []infra.SolutionHostSigner{{ Name: "release label", Kind: solutionhost.KeyKindRelease, KeyID: "example-release-v1", - PublicKey: deliverytest.PublicKey(t, "example-release-v1"), Domains: []string{solutionhost.FixtureDomain}, + PublicKey: deliverytest.PublicKey(t, "example-release-v1"), Domains: []string{verifierTestDomain}, }}} } +// verifierTestDomain is the ownership domain the verifier tests' keys are +// pinned for and their manifest speaks for. +const verifierTestDomain = "acme" + func bindingPayload(t *testing.T) []byte { t.Helper() - raw, err := solutionhost.FixtureDocument(solutionhost.DocumentTypePresence, "valid") - require.NoError(t, err) - document, err := solutionhost.Parse(raw) - require.NoError(t, err) - payload, err := solutionhost.SignedPayload(document) + manifest := &solutionhost.ModuleManifest{ + Schema: solutionhost.SchemaModuleManifestV1, Binding: "acme.test.verifier", Alias: "verifier", + Kind: solutionhost.KindModule, Generation: 4, + Host: solutionhost.HostTarget{Coordinate: "acme-test", Domain: verifierTestDomain}, + Release: solutionhost.Release{Publisher: "acme", Name: "verifier", Version: "1.0.0"}, + Workloads: []solutionhost.Workload{{Service: "verifier", Container: "verifier"}}, + } + payload, err := solutionhost.SignedPayload(manifest) require.NoError(t, err) return payload } @@ -45,17 +52,17 @@ func TestPinnedKeysVerifyDeliveryOffline(t *testing.T) { accepted := deliverytest.Carrier(t, payload, policy.Signers[0].KeyID) delivered, err := verifyPresence(t, verifier, accepted) require.NoError(t, err) - require.Equal(t, policy.Signers[0].KeyID, delivered.DeliveredBy()) + require.Equal(t, policy.Signers[0].KeyID, delivered.Signer().KeyID) require.Equal(t, solutionhost.KeyKindRelease, delivered.Signer().Kind) require.Equal(t, payload, delivered.Payload()) - require.Equal(t, map[string][]string{policy.Signers[0].KeyID: {solutionhost.FixtureDomain}}, verifier.SignerDomains()) + require.Equal(t, map[string][]string{policy.Signers[0].KeyID: {verifierTestDomain}}, verifier.SignerDomains()) for name, change := range map[string]func(*solutionhost.Signed){ "tampered document": func(s *solutionhost.Signed) { s.Document = bytes.Replace(s.Document, []byte(`"generation":4`), []byte(`"generation":5`), 1) }, "tampered payload type": func(s *solutionhost.Signed) { - s.Envelope = bytes.Replace(s.Envelope, []byte(solutionhost.PayloadTypePresence), []byte(solutionhost.PayloadTypeAuthority), 1) + s.Envelope = bytes.Replace(s.Envelope, []byte("application/vnd.codefly.module-manifest+json"), []byte("application/vnd.codefly.other+json"), 1) }, "two signatures": func(s *solutionhost.Signed) { var envelope map[string]json.RawMessage @@ -215,7 +222,7 @@ func TestLocalKeyRequiresSuccessfulClusterProof(t *testing.T) { require.NoError(t, verifier.ProveLocalCluster(context.Background(), accepted)) delivered, err := verifyPresence(t, verifier, carrier) require.NoError(t, err) - require.Equal(t, "example-developer", delivered.DeliveredBy()) + require.Equal(t, "example-developer", delivered.Signer().KeyID) require.Equal(t, solutionhost.KeyKindPerson, delivered.Signer().Kind) // The same bytes are still refused by an ordinary deployed policy. _, err = verifyPresence(t, verifierFor(t, acceptedPolicy(t)), carrier) diff --git a/module/services/accounts/code/pkg/infra/solution_host_local_profile.go b/module/services/accounts/code/pkg/infra/solution_host_local_profile.go index d47056193..b6c8ea782 100644 --- a/module/services/accounts/code/pkg/infra/solution_host_local_profile.go +++ b/module/services/accounts/code/pkg/infra/solution_host_local_profile.go @@ -115,19 +115,36 @@ import ( var ErrSolutionHostLocalPolicyOutsideItsCluster = errors.New("solution host local policy is not bound to this cluster") // SolutionHostLocalProfile declares a verification policy LOCAL and binds it to -// one cluster. +// one cluster or one native run: exactly one of the two is named. type SolutionHostLocalProfile struct { // ClusterCertificateAuthoritySHA256 is the lowercase hex SHA-256 of the DER // bytes of the local cluster's API-server certificate authority — the // certificate Kubernetes projects into every pod as the service account's // `ca.crt`. Required: a local declaration naming no cluster would bind to // every cluster, which is the property the declaration exists to remove. - ClusterCertificateAuthoritySHA256 string `json:"clusterCertificateAuthoritySha256"` + ClusterCertificateAuthoritySHA256 string `json:"clusterCertificateAuthoritySha256,omitempty"` + + // NativeAnchorSHA256 binds the profile to one native run instead: the + // lowercase hex SHA-256 of the run's workload-identity anchor key (core's + // native.Anchor.Fingerprint), which the CLI writes beside the anchor it + // hands this host. A native run has no cluster; its launcher is the party + // that vouches for its processes, and the profile admits a developer's key + // only under the one run whose anchor it pins. + NativeAnchorSHA256 string `json:"nativeAnchorSha256,omitempty"` } var sha256Hex = regexp.MustCompile(`^[0-9a-f]{64}$`) func (profile *SolutionHostLocalProfile) validate() error { + if profile.NativeAnchorSHA256 != "" { + if profile.ClusterCertificateAuthoritySHA256 != "" { + return errors.New("verification policy declares itself local to a cluster and to a native run at once; a local profile is bound to exactly one") + } + if !sha256Hex.MatchString(profile.NativeAnchorSHA256) { + return fmt.Errorf("verification policy declares itself local to native run anchor %q, which is not a lowercase hex SHA-256", profile.NativeAnchorSHA256) + } + return nil + } if !sha256Hex.MatchString(profile.ClusterCertificateAuthoritySHA256) { return fmt.Errorf("verification policy declares itself local with cluster authority %q, which is not a lowercase hex SHA-256: "+ "a local profile is valid in exactly one cluster, the one whose certificate authority it pins, and a profile naming no cluster would bind to every one", diff --git a/module/services/accounts/code/pkg/infra/solution_host_local_profile_test.go b/module/services/accounts/code/pkg/infra/solution_host_local_profile_test.go index 4c056b7e8..aed754055 100644 --- a/module/services/accounts/code/pkg/infra/solution_host_local_profile_test.go +++ b/module/services/accounts/code/pkg/infra/solution_host_local_profile_test.go @@ -232,7 +232,7 @@ func (c *cluster) hostForControl(t *testing.T) error { func localPolicy(t *testing.T, fingerprint string) *infra.SolutionHostVerificationPolicy { return &infra.SolutionHostVerificationPolicy{ Local: &infra.SolutionHostLocalProfile{ClusterCertificateAuthoritySHA256: fingerprint}, - Signers: []infra.SolutionHostSigner{{Name: "developer", Kind: solutionhost.KeyKindPerson, KeyID: "example-developer", PublicKey: deliverytest.PublicKey(t, "example-developer"), Domains: []string{solutionhost.FixtureDomain}}}, + Signers: []infra.SolutionHostSigner{{Name: "developer", Kind: solutionhost.KeyKindPerson, KeyID: "example-developer", PublicKey: deliverytest.PublicKey(t, "example-developer"), Domains: []string{verifierTestDomain}}}, } } @@ -271,9 +271,9 @@ func TestALocalHostAdmitsAListedDeveloper(t *testing.T) { require.NoError(t, verifier.ProveLocalCluster(context.Background(), local.host(t))) delivered, err := verifyPresence(t, verifier, carrier) require.NoError(t, err) - require.Equal(t, "example-developer", delivered.DeliveredBy()) + require.Equal(t, "example-developer", delivered.Signer().KeyID) require.Equal(t, solutionhost.KeyKindPerson, delivered.Signer().Kind) - require.Equal(t, map[string][]string{"example-developer": {solutionhost.FixtureDomain}}, verifier.SignerDomains()) + require.Equal(t, map[string][]string{"example-developer": {verifierTestDomain}}, verifier.SignerDomains()) } func TestTheSameDocumentIsRefusedByADeployedHost(t *testing.T) { diff --git a/module/services/accounts/code/pkg/infra/solution_presence_test_helpers_test.go b/module/services/accounts/code/pkg/infra/solution_presence_test_helpers_test.go index 6f15de52f..37fbac73c 100644 --- a/module/services/accounts/code/pkg/infra/solution_presence_test_helpers_test.go +++ b/module/services/accounts/code/pkg/infra/solution_presence_test_helpers_test.go @@ -78,30 +78,6 @@ func declarePresence(t *testing.T, bindingID, alias string) string { return targetID } -// withdrawPresence applies a tombstone generation: it closes the target and -// revokes every active installation of it, exactly as the reconcile pass does. -// A test asserting that a withdrawal ends consent must go through this rather -// than updating the row, or it proves only that an UPDATE works. -func withdrawPresence(t *testing.T, bindingID, targetID, alias string, generation uint64) []business.RevokedInstallation { - t.Helper() - now := time.Now().UTC() - var revoked []business.RevokedInstallation - require.NoError(t, testStore.WithControlPlane(testCtx, func(ctx context.Context) error { - if err := testStore.CloseSolutionTarget(ctx, targetID, generation, now); err != nil { - return err - } - if err := testStore.SaveSolutionHostBinding(ctx, - appliedPresenceRecord(t, bindingID, alias, generation, now, true)); err != nil { - return err - } - var err error - revoked, err = testStore.RevokeInstallationsOfTarget(ctx, targetID, - "solution target was withdrawn", now) - return err - })) - return revoked -} - // appliedPresenceRecord is the binding row an applied generation leaves. The // ownership domain is non-empty deliberately: core refuses an applied record // without one, and an empty string passes a whole-or-absent constraint while @@ -111,16 +87,12 @@ func appliedPresenceRecord( t *testing.T, bindingID, alias string, generation uint64, now time.Time, removed bool, ) *business.SolutionHostBindingRecord { t.Helper() - document := productionPathBinding(t, alias, "fixture", generation) + document := productionPathManifest(t, alias, generation) document.Binding = bindingID - document.OwnershipDomain = "alpha" - document.Host = solutionhost.HostTarget{Coordinate: "acme/local/host", Component: "saas-starter"} + document.Host = solutionhost.HostTarget{Coordinate: "acme/local/host", Domain: "alpha"} document.Release.Version = "1.0.0" - for i := range document.Artifacts { - document.Artifacts[i].Release = document.Release.Identity() - } if removed { - document = productionPathTombstone(t, document, generation) + document = productionPathWithdrawal(t, document, generation) } raw, err := document.CanonicalBytes() require.NoError(t, err) @@ -145,7 +117,6 @@ func appliedPresenceRecord( return &business.SolutionHostBindingRecord{ BindingID: bindingID, HostCoordinate: "acme/local/host", - HostComponent: "saas-starter", Desired: &entry, Applied: applied, UpdatedAt: now, diff --git a/module/services/accounts/code/pkg/infra/source_delegation_exchange_test.go b/module/services/accounts/code/pkg/infra/source_delegation_exchange_test.go index e6b75e4be..c47b24fa9 100644 --- a/module/services/accounts/code/pkg/infra/source_delegation_exchange_test.go +++ b/module/services/accounts/code/pkg/infra/source_delegation_exchange_test.go @@ -12,7 +12,6 @@ import ( "accounts/pkg/adapters" "accounts/pkg/business" - "accounts/pkg/business/executiontest" "accounts/pkg/business/moduleregistrytest" gen "accounts/pkg/gen/saas/accounts/v1" @@ -553,7 +552,6 @@ func TestSourceDelegation_MultiHopActorChainIsRefusedUnderADelegation(t *testing require.NoError(t, err) extra := proto.Clone(genuine.GetActorChain()[0]).(*basev0.WorkActorV1) extra.PrincipalId, extra.PrincipalEpoch, extra.GrantedScopes = reports, epoch, scopes - extra.ImageDigest = proto.String(executiontest.Digest(otherModule)) deeperContext := proto.Clone(genuine).(*basev0.WorkContextV1) deeperContext.Audience, deeperContext.AuthorityScopes = runtimeModule, scopes deeperContext.ActorChain = []*basev0.WorkActorV1{extra, deeperContext.ActorChain[0]} diff --git a/module/services/accounts/code/pkg/infra/source_delegation_test.go b/module/services/accounts/code/pkg/infra/source_delegation_test.go index e71bc38ae..be648ffc0 100644 --- a/module/services/accounts/code/pkg/infra/source_delegation_test.go +++ b/module/services/accounts/code/pkg/infra/source_delegation_test.go @@ -38,20 +38,20 @@ const ( runtimeModuleToken = "projected.runtime" ) -// bindDelegationModules runs one pod per module that mints here, each on its -// approved build, and binds svc's mints to that cluster. +// bindDelegationModules runs one pod per module that mints here, each as its +// declared workload, and binds svc's mints to that cluster. func bindDelegationModules(svc *business.Service, registry business.ModulePrincipalRegistry) { cluster := executiontest.New() - svc.SetExecutionBinding(cluster, cluster) + svc.SetExecutionBinding(cluster) for prefix, token := range map[string]string{delegationModule: delegationModuleToken, otherModule: otherModuleToken, runtimeModule: runtimeModuleToken} { grant, declared := registry[business.ModulePrincipalID(prefix)] if !declared { continue } - cluster.Approve(prefix, executiontest.Digest(prefix), 1) + workload := grant.Workloads[0] cluster.RunWithToken(token, executiontest.Workload{ - Namespace: grant.Workload.Namespace, ServiceAccount: grant.Workload.ServiceAccount, Container: grant.Workload.Container, - }, executiontest.Image(prefix)) + Namespace: workload.Namespace, ServiceAccount: workload.ServiceAccount, Container: workload.Container, + }) } } diff --git a/module/services/accounts/code/pkg/infra/testdata/control_plane_call_sites.txt b/module/services/accounts/code/pkg/infra/testdata/control_plane_call_sites.txt index cc39205f8..2d78f77a2 100644 --- a/module/services/accounts/code/pkg/infra/testdata/control_plane_call_sites.txt +++ b/module/services/accounts/code/pkg/infra/testdata/control_plane_call_sites.txt @@ -88,6 +88,8 @@ pkg/business/module_identity.go: (*Service).RecordModuleWorkContextMint: WithCon pkg/business/module_identity.go: (*Service).VerifyModuleTenant: WithControlPlane pkg/business/module_installations.go: (*Service).ReconcileModuleInstallation: WithControlPlane pkg/business/module_operation_context.go: (*Service).RecordModuleOperationContextMint: WithControlPlane +pkg/business/module_principal_reconcile.go: (*ModulePrincipalReconciler).appliedManifestsVerified: WithControlPlane +pkg/business/module_principal_reconcile.go: (*ModulePrincipalReconciler).readInputs: WithControlPlaneReadSnapshot pkg/business/notifications.go: (*Service).resolveNotificationUser: WithControlPlane pkg/business/onboarding.go: (*Service).organizationConfigured: WithControlPlane pkg/business/organization_lifecycle.go: (*Service).GetOrganizationRoster: WithControlPlane @@ -139,10 +141,7 @@ pkg/business/solution_audit_events.go: (*Service).AuditEventTypes: WithControlPl pkg/business/solution_audit_events.go: (*Service).lookupDeclaredAuditEventType: WithControlPlane pkg/business/solution_delivery.go: (*DeliveredSolutionHostBindings).Documents: WithControlPlane pkg/business/solution_delivery.go: (*Service).ReceiveSolutionDelivery: WithControlPlane -pkg/business/solution_delivery.go: (*Service).presenceCarrierNamespaces: WithControlPlane -pkg/business/solution_delivery_activation.go: (*SolutionAuthorityActivation).newestDelivered: WithControlPlane -pkg/business/solution_delivery_activation.go: (*SolutionAuthorityActivation).readRegistryInputs: WithControlPlaneReadSnapshot -pkg/business/solution_delivery_endpoints.go: (*Service).receiveDeliveredEndpoints: WithControlPlane +pkg/business/solution_delivery.go: (*Service).newestDeliveredManifests: WithControlPlane pkg/business/solution_frontend_history.go: (*Service).GetRetainedSolutionFrontend: WithControlPlane pkg/business/solution_host_bindings_apply.go: (*Service).ListSolutionGenerationHistory: WithControlPlane pkg/business/solution_host_bindings_apply.go: (*Service).ListSolutionHostBindings: WithControlPlane diff --git a/module/services/accounts/code/pkg/infra/unapplied_presence_test.go b/module/services/accounts/code/pkg/infra/unapplied_manifest_test.go similarity index 66% rename from module/services/accounts/code/pkg/infra/unapplied_presence_test.go rename to module/services/accounts/code/pkg/infra/unapplied_manifest_test.go index 21ebde5a4..6eab0bd9b 100644 --- a/module/services/accounts/code/pkg/infra/unapplied_presence_test.go +++ b/module/services/accounts/code/pkg/infra/unapplied_manifest_test.go @@ -9,9 +9,9 @@ import ( "github.com/stretchr/testify/require" ) -// A presence the inbox holds at a generation its binding has not applied is +// A manifest the inbox holds at a generation its binding has not applied is // counted until the binding reconciler applies it, and not after. -func TestUnappliedPresenceIsCountedUntilItsBindingApplies(t *testing.T) { +func TestUnappliedManifestIsCountedUntilItsBindingApplies(t *testing.T) { s := newRegistryScenario(t, "unap") // On the control plane, as its one production caller reads it: the // request role sees neither table. @@ -19,7 +19,7 @@ func TestUnappliedPresenceIsCountedUntilItsBindingApplies(t *testing.T) { var count uint64 err := testStore.WithControlPlane(testCtx, func(ctx context.Context) error { var err error - count, err = testStore.CountUnappliedPresence(ctx) + count, err = testStore.CountUnappliedManifests(ctx) return err }) return count, err @@ -27,13 +27,11 @@ func TestUnappliedPresenceIsCountedUntilItsBindingApplies(t *testing.T) { before, err := unapplied() require.NoError(t, err) - current := s.currentRevision(t) - _, err = s.delivery.service.PutModuleEnvelope(testCtx, s.admin, current, s.envelope(t, current+1, envelopeOptions{resources: true})) - require.NoError(t, err) - s.deliverPresence(t, s.primary, 1, current+1) + s.putEnvelope(t, envelopeOptions{resources: true}) + s.deliverManifest(t, s.primary, 1) pending, err := unapplied() require.NoError(t, err) - require.Equal(t, before+1, pending, "a delivered presence nothing has applied is pending") + require.Equal(t, before+1, pending, "a delivered manifest nothing has applied is pending") if err := s.passReasons(t); err != nil { t.Logf("reconcile refusals: %v", err) @@ -43,5 +41,5 @@ func TestUnappliedPresenceIsCountedUntilItsBindingApplies(t *testing.T) { require.Equal(t, uint64(1), record.Applied.Generation) after, err := unapplied() require.NoError(t, err) - require.Equal(t, before, after, "an applied presence is no longer pending") + require.Equal(t, before, after, "an applied manifest is no longer pending") } diff --git a/module/services/accounts/code/work.go b/module/services/accounts/code/work.go index 80523cfe0..3cb7c729c 100644 --- a/module/services/accounts/code/work.go +++ b/module/services/accounts/code/work.go @@ -39,8 +39,10 @@ import ( "sync" "time" + "github.com/codefly-dev/core/resources" "github.com/codefly-dev/core/wool" wooltel "github.com/codefly-dev/core/wool/otel" + "github.com/codefly-dev/core/workloadidentity/native" "github.com/codefly-dev/module-saas-starter/module/frontendpayload" codefly "github.com/codefly-dev/sdk-go" "github.com/google/uuid" @@ -507,19 +509,19 @@ func doWork(ctx context.Context) (Clean, error) { ) // NO MODULE OR SOLUTION SECRET IS READ HERE (#1056). A module proves which // module it is with its pod's projected ServiceAccount token, which - // BindExecution reviews and holds against the build the authority document - // approves; MODULE_IDENTITY_SECRETS, MODULE_REGISTRATION_SECRETS and + // BindExecution reviews and holds against the workloads its applied module + // manifest lists; MODULE_IDENTITY_SECRETS, MODULE_REGISTRATION_SECRETS and // SOLUTION_REGISTRATION_SECRETS are deleted with both registration mints. - // Execution binding is wired with delivery below, because its approved - // builds come from delivered authority: a host with no delivery mints for no + // Execution binding is wired with delivery below, because the workloads + // come from delivered manifests: a host with no delivery mints for no // module. - // Declared solution presence (issue #952). Delivery POSTs one signed - // SolutionHostBinding carrier per solution instance to the delivery - // endpoint; this host verifies it on receipt and persists it, and the - // reconciler reads its desired set from that durable inbox on every pass, - // asks Core whether each document may be applied, and reconciles what it - // admits into the durable declaration registry. + // Declared module and solution presence (issue #952). Delivery POSTs one + // signed module manifest carrier per instance to the delivery endpoint; + // this host verifies, authorises and admits it on receipt and persists it, + // and the reconciler reads its desired set from that durable inbox on every + // pass, asks Core whether each manifest may be applied, and reconciles what + // it admits into the durable declaration registry. // // Unset SOLUTION_HOST_COORDINATE leaves the whole surface off — no // reconciler and no delivery endpoint — and nothing on this host is @@ -1871,13 +1873,12 @@ func configuredSolutionHostBindingReconciler( coordinate := strings.TrimSpace(workspaceEnv("federation", "SOLUTION_HOST_COORDINATE")) if coordinate == "" { // NO DECLARED-PRESENCE SURFACE ALSO MEANS NO EXECUTION BINDING, and - // that is the coherent answer rather than an oversight. The approved - // build is read from delivered authority documents, and a host with no - // coordinate has no delivery endpoint to receive one — so it approves - // nothing, `SetExecutionBinding` is never called, and `BindExecution` - // answers ErrExecutionUnbound for every caller. A module cannot mint - // here, which is the fail-closed direction: the alternative is a host - // that cannot establish what anyone is running and mints anyway. + // that is the coherent answer rather than an oversight. A module's + // workloads are read from delivered manifests, and a host with no + // coordinate has no delivery endpoint to receive one — so + // `SetExecutionBinding` is never called, and `BindExecution` answers + // ErrExecutionUnbound for every caller. A module cannot mint here, + // which is the fail-closed direction. return nil, nil } // The ownership domains this host accepts delivery from. Required with the @@ -1956,63 +1957,89 @@ func configuredSolutionHostBindingReconciler( } interval = parsed } - // The ONLY way a document reaches this host is the delivery endpoint, so a - // host that cannot review a carrier's credential has no input at all. - // Saying so at boot beats a reconciler that polls an inbox nothing can ever - // write to. - kubernetes, err := infra.NewKubernetesClient() + // WHO HANDS OVER A MANIFEST, WHO READS APPLIED STATE, AND WHICH WORKLOAD + // A MINTING PROCESS IS — answered by the environment's one mechanism. A + // native `codefly run` names its anchor (CODEFLY_NATIVE_IDENTITY_ANCHOR) and + // signs each process it started a credential of core's native mechanism; + // core refuses that anchor inside a pod. Everywhere else the cluster's API + // server reviews projected tokens. + nativeAttester, err := native.LoadAttester() if err != nil { - return nil, fmt.Errorf("the declared-presence surface has no input: a delivered carrier is authorised by TokenReview, and this host cannot reach its api server: %w", err) - } - // A LOCAL PROFILE IS PROVEN AGAINST THIS CLUSTER BEFORE ANYTHING IS - // ADMITTED, and a host that cannot prove it does not start (issue #1063). - // - // A verification policy that declares itself local admits a developer's - // own identity, and it is bound to ONE cluster by that cluster's - // certificate authority. The proof uses only what the kubelet puts in this - // pod: the projected CA bundle must be exactly the pinned certificate, and - // the API server reached under that certificate alone must authenticate - // this pod's own token. On success the pinned, redirect-refusing transport - // becomes THIS client's for the life of the process, so the TokenReviews - // and pod reads wired below run under the same binding. A host in any other - // cluster fails here, by name. A deployed policy is bound to no cluster and - // this does nothing. - // - // Not left to this call alone: until it succeeds the verifier itself - // refuses every carrier under a local policy, so a boot path that forgot to - // ask would refuse developers, never admit them somewhere else. - if err := verifier.ProveLocalCluster(context.Background(), kubernetes); err != nil { return nil, err } + var ( + carrier business.SolutionDeliveryCarrierAuthorizer + reviewer business.ExecutionReviewer + namespaces business.ModuleNamespaceRule + ) + if nativeAttester != nil { + // The local profile is bound to THIS run's anchor, and until that is + // proven the verifier admits no carrier. A native run's namespace is its + // module: the bare rule. + if err := verifier.ProveNativeRun(nativeAttester.Anchor().Fingerprint()); err != nil { + return nil, err + } + identity := infra.NewNativeWorkloadIdentity(nativeAttester) + namespaces = business.NativeModuleNamespaceRule() + carrier, reviewer = identity, identity + } else { + // A host that cannot review a carrier's credential has no input at all; + // saying so at boot beats a reconciler polling an inbox nothing writes. + kubernetes, err := infra.NewKubernetesClient() + if err != nil { + return nil, fmt.Errorf("the declared-presence surface has no input: a delivered carrier is authorised by TokenReview, and this host cannot reach its api server: %w", err) + } + // A LOCAL PROFILE IS PROVEN AGAINST THIS CLUSTER BEFORE ANYTHING IS + // ADMITTED, and a host that cannot prove it does not start (issue + // #1063): the projected CA bundle must be exactly the pinned + // certificate, and the API server reached under it alone must + // authenticate this pod's own token. A deployed policy is bound to no + // cluster and this does nothing; until it succeeds the verifier refuses + // every carrier under a local policy. + if err := verifier.ProveLocalCluster(context.Background(), kubernetes); err != nil { + return nil, err + } + // THE CELL'S NAMESPACE RULE, read off this host's own placement: the + // namespace it runs in and the module it was composed as. A host that + // cannot read its own placement refuses to boot rather than authorise + // against a guess. + hostNamespace, err := kubernetes.Namespace() + if err != nil { + return nil, err + } + if namespaces, err = business.NewModuleNamespaceRule(hostNamespace, strings.TrimSpace(os.Getenv(resources.ModulePrefix))); err != nil { + return nil, err + } + carrier, reviewer = infra.NewSolutionDeliveryCarrierCheck(kubernetes), kubernetes + } keyVerifier, err := verifier.Verifier() if err != nil { return nil, err } - service.SetSolutionDelivery(keyVerifier, store, infra.NewSolutionDeliveryCarrierCheck(kubernetes), domainsBySigner) + service.SetModuleNamespaceRule(namespaces) + service.SetSolutionDelivery(keyVerifier, store, carrier) service.SetSolutionDeliveryCoordinate(coordinate) // Deliberately not routed at the gateway, and the asymmetry with the // credential mint is the reason. `POST /platform/_credential` is brokered by // the gateway because a solution runtime is an independently deployed // workload that must not reach accounts' internal listener. A delivery Job - // is not that: it runs in-cluster as one of two known service accounts, so - // routing it through the edge would put accounts-audience tokens across the - // perimeter for no gain. + // is not that: it runs in-cluster as its module's `delivery` service + // account, so routing it through the edge would put accounts-audience + // tokens across the perimeter for no gain. // // codefly:gateway-route-exempt delivery is in-cluster and reaches accounts directly, never the public edge adapters.RegisterHTTPRoute(adapters.SolutionDeliveryPrefix, adapters.NewSolutionDeliveryHTTPHandler(service)) // The AUTHORITY CEILING is the module envelope a platform super_admin - // writes at runtime, read by every pass and every activation from the - // store — never a file read once here, and never out of a delivered - // document, because a document must never carry its own ceiling. + // writes at runtime, read by every pass from the store — never a file read + // once here, and never out of a delivered manifest, because a manifest + // requests authority and never approves its own. reconciler, err := business.NewSolutionHostBindingReconciler(service, business.SolutionHostBindingReconcilerConfig{ - Source: business.NewDeliveredSolutionHostBindings(service), - Verifier: keyVerifier, - Coordinate: coordinate, - Domains: domains, - DomainsBySigner: domainsBySigner, - Interval: interval, - Envelopes: service, + Source: business.NewDeliveredSolutionHostBindings(service), + Verifier: keyVerifier, + Coordinate: coordinate, + Interval: interval, + Envelopes: service, }) if err != nil { return nil, err @@ -2021,30 +2048,14 @@ func configuredSolutionHostBindingReconciler( // is in force within one pass instead of one interval. service.SetModuleRegistryPassTrigger(reconciler.Trigger) - // EXECUTION-BOUND MINTING, wired here because this is where both of its - // independent sources exist. - // - // THIS IS THE LINE THAT WAS MISSING. `BindExecution`, the three digest - // types, the monotonicity guard and the TokenReview client were all built, - // guarded and unit-tested, and `SetExecutionBinding` was called from - // nowhere — so on a running host the reviewer was nil, `BindExecution` - // answered ErrExecutionUnbound for every caller, and the whole mechanism was - // unreachable. A check nothing calls is indistinguishable from a check that - // passes, which is why this is wired rather than merely available. - // - // THE TWO SOURCES ARE DELIBERATELY DIFFERENT ONES, and that is the entire - // design. The reviewer is the Kubernetes API, keyed by a pod UID that came - // from a TokenReview of a token the caller could not forge: it answers what - // the caller IS RUNNING. The authority answers what THIS HOST APPROVES, read - // from signed documents the caller has no influence over. Filling both sides - // from one source compares a value to itself and passes for every caller, - // including the superseded pod the check exists to refuse. - // - // It is the SAME client that reviews a delivery carrier, which is right - // rather than merely convenient: one api server, one in-cluster credential, - // one place a lost credential stops both halves at once instead of leaving - // one of them silently answering. - service.SetExecutionBinding(kubernetes, reconciler.ApprovedBuilds()) + // EXECUTION-BOUND MINTING, wired here because this is where its inputs + // exist: the reviewer is the environment's mechanism (a TokenReview of a + // token the caller could not forge and the pod it names, or natively the + // run's anchor and what the launcher vouches for), and the workloads it is + // held against come from the manifests this reconciler applies. It is the + // SAME mechanism that authorises a delivery carrier: one source, one place + // a lost credential stops both halves. + service.SetExecutionBinding(reviewer) return reconciler, nil } diff --git a/module/services/accounts/code/work_auth_test.go b/module/services/accounts/code/work_auth_test.go index d5ec3d12a..b16663f39 100644 --- a/module/services/accounts/code/work_auth_test.go +++ b/module/services/accounts/code/work_auth_test.go @@ -590,10 +590,8 @@ func TestNoModuleSecretAuthenticatesAMint(t *testing.T) { service.SetModuleAuthorityReads(installations, nil) service.SetModuleCapabilities(nil, nil, registry) cluster := executiontest.New() - service.SetExecutionBinding(cluster, cluster) - cluster.Approve("documents", executiontest.Digest("documents-v1"), 3) - token := cluster.Run(executiontest.Workload{Namespace: "acme-prod", ServiceAccount: "documents", Container: "app"}, - executiontest.Image("documents-v1")) + service.SetExecutionBinding(cluster) + token := cluster.Run(executiontest.Workload{Namespace: "acme-prod", ServiceAccount: "documents", Container: "app"}) _, err = service.ModuleAuthorizeWorkContext(context.Background(), "documents", "identity-secret") require.ErrorIs(t, err, business.ErrModuleIdentityDenied, "a secret is not a token, and nothing reads the secret maps") @@ -603,8 +601,8 @@ func TestNoModuleSecretAuthenticatesAMint(t *testing.T) { require.NoError(t, err) require.Equal(t, business.ModulePrincipalID("documents"), authority.PrincipalID) require.Equal(t, tenant, authority.Tenant) - require.Equal(t, uint64(3), authority.Execution.Incarnation) - require.Equal(t, executiontest.Image("documents-v1"), string(authority.Execution.Running)) + require.Equal(t, "acme-prod", authority.Execution.Namespace) + require.Equal(t, "documents", authority.Execution.ServiceAccount) } // keyServiceBackend selects the key-service backend on both carriers, for the diff --git a/module/services/auth-gateway/AGENTS.md b/module/services/auth-gateway/AGENTS.md index eca540d49..c44fc50da 100644 --- a/module/services/auth-gateway/AGENTS.md +++ b/module/services/auth-gateway/AGENTS.md @@ -74,24 +74,21 @@ alias the catalog *owns* is refused outright rather than half-served — the mat takes only the paths it matches, so serving the rest would split one prefix between two authorities. -Where it goes is **resolved, never recorded**, and a solution is routed the -same way. The registry holds no address. On each refresh the gateway reads the -endpoints the APPLIED presence declares for the service that runs its workload -(`appliedRouteEndpoints`: the presence's own HTTP-family endpoints, and only -those) and asks the Codefly SDK -(`codefly.For(...).Module().Service().Endpoint().API().ResolveDeliveredNetworkInstance(applied, raw)`) -for each (`resolveRouteUpstreams`). The host-binding read supplies opaque Codefly -endpoint delivery bytes beside APPLIED state. The SDK requires their complete -presence digest to match this applied generation and owns their interpretation. The CLI selects exactly the endpoints composed workloads -are reached through, so exactly one answers and that -is where `/v1/` — and `/solutions/` — goes. None, or more than -one, leaves the alias unrouted (`503 … registration not active`) and is logged -once. In-cluster traffic after the gateway is plain HTTP; the mesh provides mTLS. - -A missing, withdrawn or mismatched projection never falls back to startup -environment addresses. The signed delivery inbox supplies new values without -changing this process or mounting configuration. Producer/Core/SDK integration -is in progress; this read projection alone does not qualify a cell. +Where it goes is **the applied manifest's**, and a solution is routed the same +way. The registry holds no address. On each refresh the gateway parses the +APPLIED module manifest of each binding (`appliedManifest`, which must name the +registration's binding, generation and alias) and takes the HTTP-family +endpoints it declares for its own workloads (`appliedRouteEndpoints`), each at +the address the signed manifest carries (`manifestEndpointAddress`, +`resolveRouteUpstreams`). The CLI writes exactly the endpoints composed +workloads are reached through, so exactly one answers and that is where +`/v1/` — and `/solutions/` — goes. None, or more than one, leaves +the alias unrouted (`503 … registration not active`) and is logged once. +In-cluster traffic after the gateway is plain HTTP; the mesh provides mTLS. + +A missing, withdrawn or mismatched manifest never falls back to startup +environment addresses, and a withheld desired generation never replaces the +applied generation's addresses. The two surfaces share one shape: the same cache, single carried resolution, upstream URL policy, guarded transport, tombstone rule and the 120 s @@ -232,21 +229,20 @@ is not issued on an unanswered question. **The ceiling is the consumer's delivered authority, not a list an administrator keeps.** Accounts reads the registry grant of the principal the stated installation records (`business.ConsumerInvokeCeiling`): the invoke half of its operation -audiences, derived from the consumer's signed authority document, the applied -presence and the envelope, and intersected with the installation's consent. The +audiences, derived from the consumer's applied module manifest and the envelope, and intersected with the installation's consent. The audience must be one of those bindings' audiences, and the scopes must fit one binding there. The installation's `allowed_audiences` and `allowed_scopes` then narrow when they name something and add no bound when empty: they are never a source of authority, so lists that are empty or wider than the grant admit nothing it does not. -**What reaches that grant today.** The renderer derives an authority document from a +**What reaches that grant today.** The renderer derives a manifest's `authority` from a module's `module.contract.codefly.yaml`, and from nothing else. A binding there may declare `invoke` alone — lookup is optional, and an invoke-only binding is kept — so a module that publishes a contract reaches this ceiling. **A solution's `api.consumes` in `solution.codefly.yaml` does not:** it feeds the generated SDK and -`CODEFLY__API_CONSUMES`, never an authority document. A solution with no contract has +`CODEFLY__API_CONSUMES`, never a manifest's `authority`. A solution with no contract has no registry grant, so every confined mint addressed through it is refused. Rendering a -solution's declared consumption into its authority document is the delivery half of +solution's declared consumption into its manifest's `authority` is the delivery half of issue #1083 and belongs to the renderer. What a refusal says, and who acts on it: @@ -387,8 +383,8 @@ The listener puts the authenticated presenter in private request context and removes the raw headers. `moduleMintContext` sends that principal to accounts with the existing gateway-provenance credential, separately from the internal perimeter credential. A caller's presenter header is never copied into the RPC. -Accounts verifies provenance and compares the presenter with applied presence -and TokenReview before granting work authority. +Accounts verifies provenance and compares the presenter's namespace and +ServiceAccount with TokenReview before granting work authority. Native development has no waypoint and stamps no presenter. Accounts permits that absence only with its actual non-pod stand-in Kubernetes client; a local @@ -407,7 +403,7 @@ pod's **projected ServiceAccount token** as `Authorization: Bearer ` (exactly one header, that scheme), body `{prefix, organization_id?}`, bounded at 4 KiB. Nothing is reported with it: a solution's address is resolved and its manifest comes from its presence -([SOLUTION_REGISTRATION.md](../../SOLUTION_REGISTRATION.md#the-document-and-who-owns-which-field)). +([SOLUTION_REGISTRATION.md](../../SOLUTION_REGISTRATION.md#7-declared-presence-one-signed-module-manifest-per-binding)). Accounts answers `FailedPrecondition` for a workload that is admitted and installed nowhere, relayed as `412`. A shared module selects an organization; accounts requires its live installation and the envelope's `cross_tenant` grant. A @@ -431,7 +427,7 @@ brokers it to `ModuleCapabilitiesService/MintModuleOperationContext` and answers `expires_at`) with `cache-control: no-store` — snake_case, unlike the camelCase `/modules/_work-context`, because consumers were built against this shape. It relays two distinct refusals: `401` when the module is not proven (bad internal token, a missing or refused projected token, another workload, an -unapproved build, an undeclared module) and `403` when a proven module names a +undeclared module) and `403` when a proven module names a binding it may not mint with no person present. Every module exchange answers `503` when accounts could not establish what the pod is running (`Unavailable`): nothing was decided, and the module retries. What the diff --git a/module/services/auth-gateway/code/gateway_applied_presence.go b/module/services/auth-gateway/code/gateway_applied_presence.go index 5b9b72203..87f32f682 100644 --- a/module/services/auth-gateway/code/gateway_applied_presence.go +++ b/module/services/auth-gateway/code/gateway_applied_presence.go @@ -6,14 +6,14 @@ import ( "github.com/codefly-dev/core/solutionhost" ) -// Read APPLIED presence only. Desired documents do not authorize traffic. The -// registration, applied generation and parsed document must name the same +// Read APPLIED manifests only. Desired documents do not authorize traffic. The +// registration, applied generation and parsed manifest must name the same // binding and generation. -// appliedPresence is the presence document this host APPLIED for the binding, +// appliedManifest is the module manifest this host APPLIED for the binding, // and only when the registration, the applied generation and the parsed -// document name the same binding and generation. -func appliedPresence(binding *accountsv1.SolutionHostBindingState) (*solutionhost.SolutionHostBinding, bool) { +// manifest name the same binding and generation. +func appliedManifest(binding *accountsv1.SolutionHostBindingState) (*solutionhost.ModuleManifest, bool) { applied := binding.GetApplied() record := binding.GetRegistration() declared := record.GetDeclared() @@ -23,11 +23,12 @@ func appliedPresence(binding *accountsv1.SolutionHostBindingState) (*solutionhos applied.GetSolutionId() != record.GetSolutionId() || record.GetTombstonedAt() != nil { return nil, false } - document, err := solutionhost.Parse([]byte(applied.GetGeneration().GetDocument())) - if err != nil || document.Binding != declared.GetBindingId() || document.Generation != declared.GetGeneration() { + manifest, err := solutionhost.Parse([]byte(applied.GetGeneration().GetDocument())) + if err != nil || manifest.Binding != declared.GetBindingId() || manifest.Generation != declared.GetGeneration() || + manifest.Alias != record.GetSolutionId() { return nil, false } - return document, true + return manifest, true } // routeGatewayAPIs are the APIs a declared route is served on: the REST surface @@ -35,45 +36,39 @@ func appliedPresence(binding *accountsv1.SolutionHostBindingState) (*solutionhos // endpoint's address is not an HTTP origin the proxy can forward to. var routeGatewayAPIs = map[string]bool{"rest": true, "http": true} -// moduleEndpoint names the endpoint a declared presence serves this gateway on: -// module, service, endpoint and API, exactly as Codefly keys an endpoint. It is -// a NAME, never an address — the presence declares endpoints and freezes no -// address — and the Codefly SDK resolves it to where it lives. The module is -// the Codefly module the service is composed under, which a solution is too. +// moduleEndpoint is one endpoint an applied manifest declares for this +// gateway to route to: module (the manifest's alias), service, endpoint and +// API, as Codefly keys an endpoint, and the address the signed manifest +// carries for it. type moduleEndpoint struct { Module, Service, Name, API string - // applied binds endpoint resolution to the same declaration as routing. - applied *solutionhost.SolutionHostBinding - // delivery is opaque Codefly input carried beside the applied declaration. - delivery []byte + // Address is where the manifest says the endpoint is reached: resolved + // at render in a cell, at delivery natively. It is signed with the rest of + // the manifest, so it is never a value this gateway or a caller chose. + Address string } -// appliedRouteEndpoints are the endpoints an applied presence — a solution's -// or a module's, one rule for both — declares for the service that runs its -// workload. Which one this gateway forwards the route to is not decided here: -// it is the one the composition handed this gateway an address for (see -// solutionRegistryCache.resolveRouteUpstreams), because the composition is -// what knows which endpoint traffic reaches the workload through. -// -// Only the presence's own endpoints: a presence naming another module's -// endpoint could otherwise point its alias at any address this gateway holds a -// carrier for. "Own" is the Codefly module the alias names — the CLI composes a -// solution under a module whose name is its route alias, exactly as it does a -// module — so the rule is the same test for both kinds. +// appliedRouteEndpoints are the HTTP-family endpoints an applied manifest — a +// solution's or a module's, one rule for both — declares for the services that +// run as it. Which one this gateway forwards the route to is decided by +// solutionRegistryCache.resolveRouteUpstreams. A manifest's endpoints are its +// own by construction: each names one of its declared workloads. func appliedRouteEndpoints(binding *accountsv1.SolutionHostBindingState) []moduleEndpoint { - document, ok := appliedPresence(binding) + manifest, ok := appliedManifest(binding) if !ok { return nil } serving := map[string]bool{} - for _, workload := range document.Workloads { - serving[workload.Artifact] = true + for _, workload := range manifest.Workloads { + serving[workload.Service] = true } - alias := binding.GetRegistration().GetSolutionId() var endpoints []moduleEndpoint - for _, endpoint := range document.Endpoints { - if serving[endpoint.Service] && endpoint.Module == alias && routeGatewayAPIs[endpoint.API] { - endpoints = append(endpoints, moduleEndpoint{Module: endpoint.Module, Service: endpoint.Service, Name: endpoint.Name, API: endpoint.API, applied: document, delivery: binding.GetApplied().GetEndpointDelivery()}) + for _, endpoint := range manifest.Endpoints { + if serving[endpoint.Service] && routeGatewayAPIs[endpoint.API] { + endpoints = append(endpoints, moduleEndpoint{ + Module: manifest.Alias, Service: endpoint.Service, Name: endpoint.Name, API: endpoint.API, + Address: endpoint.Address, + }) } } return endpoints diff --git a/module/services/auth-gateway/code/gateway_endpoint_delivery_test.go b/module/services/auth-gateway/code/gateway_endpoint_delivery_test.go deleted file mode 100644 index a698e7fa8..000000000 --- a/module/services/auth-gateway/code/gateway_endpoint_delivery_test.go +++ /dev/null @@ -1,63 +0,0 @@ -package main - -import ( - "testing" - - "github.com/codefly-dev/core/resources" - "github.com/codefly-dev/core/solutionhost" - codefly "github.com/codefly-dev/sdk-go" - "github.com/stretchr/testify/require" -) - -// Exercise the production SDK resolver, not the test binary's address-map seam. -// The same gateway resolver sees addition, relocation and withdrawal without -// Init or an environment reload between deliveries. -func TestGatewayResolvesOnlyTheAppliedEndpointDelivery(t *testing.T) { - t.Setenv(resources.ModulePrefix, "host") - _, err := codefly.Init(t.Context()) - require.NoError(t, err) - binding := modulePresenceBinding(t, endpointOf("documents", "rest", "rest")) - endpoints := appliedRouteEndpoints(binding) - require.Len(t, endpoints, 1) - endpoint := endpoints[0] - key := resources.EndpointAsEnvironmentVariableKey(&resources.EndpointInformation{ - Module: endpoint.Module, Service: endpoint.Service, Name: endpoint.Name, API: endpoint.API, - }) - t.Setenv(key, "http://stale.example:8080") - require.NoError(t, codefly.LoadEnvironmentVariables()) - deliver := func(document *solutionhost.SolutionHostBinding, address string) { - t.Helper() - raw, err := solutionhost.MarshalEndpointDelivery(document, map[string]string{key: address}) - require.NoError(t, err) - binding.Applied.EndpointDelivery = raw - projected := appliedRouteEndpoints(binding) - require.Len(t, projected, 1) - endpoint = projected[0] - } - _, err = sdkModuleEndpointAddress(t.Context(), endpoint) - require.ErrorIs(t, err, codefly.ErrEndpointDeliveryUnavailable) - deliver(endpoint.applied, "http://first.example:8081") - address, err := sdkModuleEndpointAddress(t.Context(), endpoint) - require.NoError(t, err) - require.Contains(t, address, "first.example") - next := *endpoint.applied - next.Generation++ - deliver(&next, "http://second.example:8082") - _, err = sdkModuleEndpointAddress(t.Context(), endpoint) - require.ErrorIs(t, err, codefly.ErrEndpointDeliveryUnavailable) - canonical, err := solutionhost.Marshal(&next) - require.NoError(t, err) - binding.Applied.Generation.Document = string(canonical) - binding.Applied.Generation.Generation = next.Generation - binding.Registration.Declared.Generation = next.Generation - endpoints = appliedRouteEndpoints(binding) - require.Len(t, endpoints, 1) - endpoint = endpoints[0] - address, err = sdkModuleEndpointAddress(t.Context(), endpoint) - require.NoError(t, err) - require.Contains(t, address, "second.example") - binding.Applied.EndpointDelivery = nil - endpoint = appliedRouteEndpoints(binding)[0] - _, err = sdkModuleEndpointAddress(t.Context(), endpoint) - require.ErrorIs(t, err, codefly.ErrEndpointDeliveryUnavailable) -} diff --git a/module/services/auth-gateway/code/gateway_mcp_module_read_test.go b/module/services/auth-gateway/code/gateway_mcp_module_read_test.go index 4e874db24..0a973c407 100644 --- a/module/services/auth-gateway/code/gateway_mcp_module_read_test.go +++ b/module/services/auth-gateway/code/gateway_mcp_module_read_test.go @@ -51,7 +51,6 @@ func viewerSeals(t *testing.T, owner, installation string) *workcontext.MemorySe seals := workcontext.NewMemorySealSource() require.NoError(t, seals.Put(owner, workcontext.Seal{InstallationID: installation, InstallationRevision: 1})) require.NoError(t, seals.PutEpoch(owner, 1)) - require.NoError(t, seals.PutBearsNoExecution(owner)) return seals } diff --git a/module/services/auth-gateway/code/gateway_module_endpoint_test.go b/module/services/auth-gateway/code/gateway_module_endpoint_test.go index 01fa4d984..4dbe679cb 100644 --- a/module/services/auth-gateway/code/gateway_module_endpoint_test.go +++ b/module/services/auth-gateway/code/gateway_module_endpoint_test.go @@ -2,7 +2,6 @@ package main import ( "context" - "encoding/json" "errors" "net/http" "net/http/httptest" @@ -14,74 +13,61 @@ import ( "github.com/stretchr/testify/require" ) -// A declared module is reached at the address the Codefly SDK resolves for the -// endpoint its applied presence declares — never at an address a registration +// A declared module is reached at the address its applied module manifest +// carries for the endpoint it declares — never at an address a registration // records. These are the selection rule and the routing it feeds. -// modulePresenceBinding is an applied module presence whose one workload runs +// moduleManifestBinding is an applied module manifest whose one workload runs // service "documents", declaring the given endpoints. -func modulePresenceBinding(t *testing.T, endpoints ...solutionhost.Endpoint) *accountsv1.SolutionHostBindingState { +func moduleManifestBinding(t *testing.T, endpoints ...solutionhost.Endpoint) *accountsv1.SolutionHostBindingState { t.Helper() - var document *solutionhost.SolutionHostBinding - for _, fixture := range solutionhost.FixturesOf(solutionhost.DocumentTypePresence) { - parsed, err := solutionhost.Parse(fixture.Document) - if err == nil && len(parsed.Workloads) == 1 { - document = parsed - break - } + manifest := &solutionhost.ModuleManifest{ + Schema: solutionhost.SchemaModuleManifestV1, Binding: "acme.test.documents", Alias: "documents", + Kind: solutionhost.KindModule, Generation: 1, + Host: solutionhost.HostTarget{Coordinate: "example-host", Domain: "example"}, + Release: solutionhost.Release{Publisher: "acme", Name: "documents", Version: "1.0.0"}, + Workloads: []solutionhost.Workload{{Service: "documents", Container: "documents"}, {Service: "store", Container: "store"}}, + Endpoints: endpoints, } - require.NotNil(t, document, "Core must ship a readable one-workload presence fixture") - copied := *document - copied.Kind = solutionhost.KindModule - copied.Workloads = append([]solutionhost.Workload{}, document.Workloads...) - copied.Workloads[0].Artifact = "documents" - artifacts := append([]solutionhost.Artifact{}, document.Artifacts...) - for i := range artifacts { - if artifacts[i].Name == document.Workloads[0].Artifact { - artifacts[i].Name = "documents" - } - } - copied.Artifacts = artifacts - copied.Endpoints = endpoints - require.NoError(t, copied.Validate()) - body, err := json.Marshal(&copied) + body, err := solutionhost.Marshal(manifest) require.NoError(t, err) return &accountsv1.SolutionHostBindingState{ - BindingId: copied.Binding, + BindingId: manifest.Binding, Applied: &accountsv1.SolutionHostBindingAppliedGeneration{SolutionId: "documents", - Generation: &accountsv1.SolutionHostBindingGeneration{Generation: copied.Generation, Document: string(body)}}, + Generation: &accountsv1.SolutionHostBindingGeneration{Generation: manifest.Generation, Document: string(body)}}, Registration: &accountsv1.SolutionRegistration{SolutionId: "documents", - Declared: &accountsv1.SolutionDeclaredBinding{BindingId: copied.Binding, Generation: copied.Generation, + Declared: &accountsv1.SolutionDeclaredBinding{BindingId: manifest.Binding, Generation: manifest.Generation, Kind: accountsv1.SolutionDeclaredKind_SOLUTION_DECLARED_KIND_MODULE}}, } } func endpointOf(service, name, api string) solutionhost.Endpoint { - return solutionhost.Endpoint{Name: name, Service: service, Module: "documents", API: api, Visibility: "internal"} + return solutionhost.Endpoint{Name: name, Service: service, API: api, Address: "http://" + service + ":8080"} } -func TestAModulePresenceOffersTheEndpointsOfTheServiceItRuns(t *testing.T) { - binding := modulePresenceBinding(t, - endpointOf("documents", "grpc", "grpc"), endpointOf("documents", "rest", "rest"), endpointOf("store", "rest", "rest")) - document, ok := appliedPresence(binding) +func TestAModuleManifestOffersTheHTTPEndpointsOfItsWorkloads(t *testing.T) { + binding := moduleManifestBinding(t, endpointOf("documents", "grpc", "grpc"), endpointOf("documents", "rest", "rest")) + _, ok := appliedManifest(binding) require.True(t, ok) require.Equal(t, []moduleEndpoint{ - {Module: "documents", Service: "documents", Name: "rest", API: "rest", applied: document}, - }, appliedRouteEndpoints(binding), "only the serving service's HTTP-family endpoints: not grpc, not another service's") + {Module: "documents", Service: "documents", Name: "rest", API: "rest", Address: "http://documents:8080"}, + }, appliedRouteEndpoints(binding), "only HTTP-family endpoints: not grpc") - foreign := modulePresenceBinding(t, solutionhost.Endpoint{Name: "rest", Service: "documents", Module: "accounts", API: "rest", Visibility: "internal"}) - require.Empty(t, appliedRouteEndpoints(foreign), "a presence may not name another module's endpoint as its own surface") + binding.Registration.SolutionId = "another" + binding.Applied.SolutionId = "another" + require.Empty(t, appliedRouteEndpoints(binding), "a manifest is routed only under its own alias") +} - var solutionDocument solutionhost.SolutionHostBinding - require.NoError(t, json.Unmarshal([]byte(binding.Applied.Generation.Document), &solutionDocument)) - solutionDocument.Kind = solutionhost.KindSolution - body, err := json.Marshal(&solutionDocument) +// The production resolver answers the address the applied manifest carries, +// and refuses an endpoint that carries none. +func TestTheManifestAddressIsWhereAnEndpointLives(t *testing.T) { + endpoint := appliedRouteEndpoints(moduleManifestBinding(t, endpointOf("documents", "rest", "rest")))[0] + address, err := manifestEndpointAddress(context.Background(), endpoint) require.NoError(t, err) - binding.Applied.Generation.Document = string(body) - document, ok = appliedPresence(binding) - require.True(t, ok) - require.Equal(t, []moduleEndpoint{{Module: "documents", Service: "documents", Name: "rest", API: "rest", applied: document}}, - appliedRouteEndpoints(binding), "a solution's presence offers its endpoints by the same rule") + require.Equal(t, "http://documents:8080", address) + endpoint.Address = "" + _, err = manifestEndpointAddress(context.Background(), endpoint) + require.Error(t, err) } // Of the endpoints a presence offers, the gateway forwards to the one the diff --git a/module/services/auth-gateway/code/gateway_solution_registry.go b/module/services/auth-gateway/code/gateway_solution_registry.go index 8b3967a12..5b2e156eb 100644 --- a/module/services/auth-gateway/code/gateway_solution_registry.go +++ b/module/services/auth-gateway/code/gateway_solution_registry.go @@ -16,7 +16,6 @@ import ( accountsv1 "auth-gateway/pkg/gen/saas/accounts/v1" "github.com/codefly-dev/module-saas-starter/module/frontendpayload" - codefly "github.com/codefly-dev/sdk-go" "google.golang.org/grpc" "google.golang.org/grpc/metadata" "google.golang.org/protobuf/proto" @@ -51,12 +50,12 @@ const ( // solutionRegistrySnapshot joins only matching applied generations. The compact // registration has no endpoint field; the host-binding read carries Core's -// complete presence document. No new wire schema duplicates that contract. +// complete applied module manifest. No new wire schema duplicates that contract. type solutionRegistrySnapshot struct { *accountsv1.ListSolutionRegistrationsResponse // routeEndpoints is, per declared alias — a solution's or a module's — the // endpoints its applied presence declares for the service that runs it - // (appliedRouteEndpoints): names, resolved by the Codefly SDK on refresh. + // (appliedRouteEndpoints), with the addresses the manifest carries. routeEndpoints map[string][]moduleEndpoint } @@ -162,9 +161,8 @@ type solutionRegistryCache struct { // it at time.Now. now func() time.Time - // resolveEndpoint answers where an endpoint lives. Production asks the - // Codefly SDK, which holds every address the composition handed this - // gateway; the registry holds none. + // resolveEndpoint answers where an endpoint lives. Production reads the + // address the applied manifest carries; the registry columns hold none. resolveEndpoint func(context.Context, moduleEndpoint) (string, error) mu sync.RWMutex @@ -313,10 +311,10 @@ func (c *solutionRegistryCache) reconcile(ctx context.Context) { // resolveRouteUpstreams resolves each declared alias — a solution's // /solutions/ or a module's /v1/ — to the one address this -// gateway forwards it to: of the HTTP-family endpoints its applied presence -// declares, the one the composition handed this gateway an address for — the -// Codefly CLI writes exactly the endpoints the host reaches a composed -// workload through. An address the upstream policy refuses is not a candidate +// gateway forwards it to: of the HTTP-family endpoints its applied manifest +// declares — the CLI writes exactly the endpoints the host reaches a composed +// workload through, each with its address. An address the upstream policy +// refuses is not a candidate // (it is logged, and another endpoint may still answer); none leaves the alias // unrouted; several are refused as ambiguous rather than chosen between. Each // alias's reason is logged once, and again only after it changes. @@ -345,9 +343,9 @@ func (c *solutionRegistryCache) resolveRouteUpstreams(ctx context.Context, endpo case len(resolved) == 0 && len(refused) > 0: c.noteRouteRefusal(alias, fmt.Sprintf("its endpoints resolve only to addresses the upstream policy refuses (%v)", refused)) case len(resolved) == 0: - c.noteRouteRefusal(alias, "the composition handed this gateway no address for any endpoint its presence declares") + c.noteRouteRefusal(alias, "its applied manifest gives no address for any HTTP endpoint it declares") default: - c.noteRouteRefusal(alias, fmt.Sprintf("the composition handed this gateway addresses for several endpoints (%v); which one the route forwards to has no single answer", named)) + c.noteRouteRefusal(alias, fmt.Sprintf("its applied manifest declares several HTTP endpoints (%v); which one the route forwards to has no single answer", named)) } } return upstreams @@ -376,17 +374,15 @@ func (e moduleEndpoint) String() string { // moduleEndpointResolver is the resolver every cache starts with. A test binary // replaces it with one standing in for the composition's carriers. -var moduleEndpointResolver = sdkModuleEndpointAddress +var moduleEndpointResolver = manifestEndpointAddress -// sdkModuleEndpointAddress asks the Codefly SDK where an endpoint lives. -func sdkModuleEndpointAddress(ctx context.Context, endpoint moduleEndpoint) (string, error) { - instance, err := codefly.For(ctx). - Module(endpoint.Module).Service(endpoint.Service).Endpoint(endpoint.Name).API(endpoint.API). - ResolveDeliveredNetworkInstance(endpoint.applied, endpoint.delivery) - if err != nil { - return "", err +// manifestEndpointAddress is where the applied manifest says the endpoint +// lives. +func manifestEndpointAddress(_ context.Context, endpoint moduleEndpoint) (string, error) { + if endpoint.Address == "" { + return "", fmt.Errorf("the applied manifest gives %s no address", endpoint) } - return instance.Address, nil + return endpoint.Address, nil } // routableUpstream holds a resolved address to the upstream URL policy: http or diff --git a/module/services/auth-gateway/code/go.mod b/module/services/auth-gateway/code/go.mod index d52da8a8c..bd6af0a83 100644 --- a/module/services/auth-gateway/code/go.mod +++ b/module/services/auth-gateway/code/go.mod @@ -4,10 +4,10 @@ go 1.27.2 require ( buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2 - github.com/codefly-dev/core v0.19.5-0.20261014005330-835460f16fb5 + github.com/codefly-dev/core v0.19.6-0.20261018051341-b81d92e6feda github.com/codefly-dev/module-saas-starter/module/frontendpayload v0.0.0-20261014024718-5858903a66c8 - github.com/codefly-dev/sdk-go v0.4.1-0.20261014012056-0abc4f26ff47 - github.com/codefly-dev/sdk-go/workcontext v0.1.0 + github.com/codefly-dev/sdk-go v0.4.1-0.20261018052022-70dc7a546458 + github.com/codefly-dev/sdk-go/workcontext v0.1.1-0.20261018052022-70dc7a546458 github.com/envoyproxy/go-control-plane/envoy v1.39.0 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/uuid v1.6.0 diff --git a/module/services/auth-gateway/code/go.sum b/module/services/auth-gateway/code/go.sum index dfcc29fea..0c512792f 100644 --- a/module/services/auth-gateway/code/go.sum +++ b/module/services/auth-gateway/code/go.sum @@ -44,16 +44,16 @@ github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= -github.com/codefly-dev/core v0.19.5-0.20261014005330-835460f16fb5 h1:qrWgyj57UfL/BQJPQ6b5mFJdLbR0At1o8zGRmce/hvY= -github.com/codefly-dev/core v0.19.5-0.20261014005330-835460f16fb5/go.mod h1:0uX/7lTBVaGV0RI2Mb3mpEr/PPdx/jZqXHym/U8FVDI= +github.com/codefly-dev/core v0.19.6-0.20261018051341-b81d92e6feda h1:ytTihtIEfVhQ7llQl+ls5swIL8JgJu89X0wlDMeqM+o= +github.com/codefly-dev/core v0.19.6-0.20261018051341-b81d92e6feda/go.mod h1:0uX/7lTBVaGV0RI2Mb3mpEr/PPdx/jZqXHym/U8FVDI= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= github.com/codefly-dev/gortk v0.2.0/go.mod h1:dDWUMFgAP063OCGhTEpV7FFUj9+zTcxxO/nV80VKEwg= github.com/codefly-dev/module-saas-starter/module/frontendpayload v0.0.0-20261014024718-5858903a66c8 h1:oq3ISoHhFUQOIJKT+KXuQjCyR24Ktiso04tpTw0VOZo= github.com/codefly-dev/module-saas-starter/module/frontendpayload v0.0.0-20261014024718-5858903a66c8/go.mod h1:OxvwxMdo3HD190xFiMOPHtUsBC7/7Jt1evsEts/2HRs= -github.com/codefly-dev/sdk-go v0.4.1-0.20261014012056-0abc4f26ff47 h1:GDoct/nlIz5i2fzOQiAcGrucjIH27nf6rStHW3PKBwI= -github.com/codefly-dev/sdk-go v0.4.1-0.20261014012056-0abc4f26ff47/go.mod h1:XX6JnENJtvYGUsyuI1ROyutoxL6c4sjwGUrjxGX4rsQ= -github.com/codefly-dev/sdk-go/workcontext v0.1.0 h1:oFU/Lg1xnlibRPqyKR0KGdgGttwH8EdZIuErqBaRZxo= -github.com/codefly-dev/sdk-go/workcontext v0.1.0/go.mod h1:FeKytaeqEXRigN5UflAjVeoMoV2gEN2oJLgBBgpr0Jg= +github.com/codefly-dev/sdk-go v0.4.1-0.20261018052022-70dc7a546458 h1:hi+KhotTLMOJ/PUSzVO/Axfho7yZ2UOjrCFfrLI1EKU= +github.com/codefly-dev/sdk-go v0.4.1-0.20261018052022-70dc7a546458/go.mod h1:Qg8SMzZxKSllwvZwShMrWgSetBVMjxP0s17mwWHWjk0= +github.com/codefly-dev/sdk-go/workcontext v0.1.1-0.20261018052022-70dc7a546458 h1:wBOL7QsuoC0HsS43gsH2+tpsipk4os077C2VUDkMUHg= +github.com/codefly-dev/sdk-go/workcontext v0.1.1-0.20261018052022-70dc7a546458/go.mod h1:hwtrdGtgh8i2xzjgihyd4wY2u+UK7nHQz30OGKL0n+M= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= @@ -310,8 +310,8 @@ golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba h1:Ck8QetSgk912qxWLMCKxd0in+aiyBQyDSMae6e/xmpU= golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba/go.mod h1:50RgIsmK7OwqzTTeqcSXQW8SswW0o8fRcDxmqGluJ8E= -golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= -golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= +golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg= +golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs= golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= diff --git a/module/services/auth-gateway/code/work_context_test.go b/module/services/auth-gateway/code/work_context_test.go index c8caebd58..fbd1236e1 100644 --- a/module/services/auth-gateway/code/work_context_test.go +++ b/module/services/auth-gateway/code/work_context_test.go @@ -81,7 +81,6 @@ func mintCapability(t *testing.T, kid string, priv ed25519.PrivateKey, c edgeCap seals := workcontext.NewMemorySealSource() require.NoError(t, seals.Put(c.Owner, workcontext.Seal{InstallationID: "installation-1", InstallationRevision: 1})) require.NoError(t, seals.PutEpoch(c.Owner, 1)) - require.NoError(t, seals.PutBearsNoExecution(c.Owner)) authority := &workcontext.Authority{ Issuer: workContextIssuer, KeyID: kid, Key: priv, Revisions: workcontext.FixedRevision(1), Seals: seals, Now: c.Now, diff --git a/module/services/store/code/migration_41_module_manifest_inbox_test.go b/module/services/store/code/migration_41_module_manifest_inbox_test.go new file mode 100644 index 000000000..426436957 --- /dev/null +++ b/module/services/store/code/migration_41_module_manifest_inbox_test.go @@ -0,0 +1,100 @@ +package main + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/golang-migrate/migrate/v4" +) + +// Migration 41: the inbox holds module manifests only. Rows of the replaced +// document family are removed, live applied state is cleared for the next +// delivered manifest to re-establish on its installation target, a recorded +// withdrawal is kept, host_component goes, and the down refuses while a +// manifest is stored. +func TestMigration41ModuleManifestInboxAndDown(t *testing.T) { + db, databaseURL := throwawayPostgres(t) + if err := migrateStoreFrom("file://"+ledgerUpTo(t, 40), databaseURL); err != nil { + t.Fatal(err) + } + hash := "sha256:" + strings.Repeat("ab", 32) + for _, kind := range []string{"presence", "authority", "endpoints"} { + execColdCutoverSQL(t, db, `INSERT INTO solution_delivery_documents(kind,document_id,generation,content_hash,payload,bundle,signer_identity,ownership_domain,received_at) + VALUES ('`+kind+`','acme.test.live',1,'`+hash+`','{}'::bytea,'{}'::bytea,'example-key','acme',now())`) + } + execColdCutoverSQL(t, db, `INSERT INTO solution_host_bindings(binding_id,host_coordinate,host_component, + desired_generation,desired_digest,desired_document,desired_seen_at, + applied_generation,applied_digest,applied_document,applied_routes,applied_solution_id,applied_release,applied_domain,applied_at) + VALUES ('acme.test.live','example-host','example-component',1,'`+hash+`','presence',now(), + 1,'`+hash+`','presence','{live}','live','acme/live@1.0.0','acme',now())`) + execColdCutoverSQL(t, db, `INSERT INTO solution_host_bindings(binding_id,host_coordinate,host_component, + applied_generation,applied_digest,applied_document,applied_removed,applied_solution_id,applied_release,applied_domain,applied_at) + VALUES ('acme.test.gone','example-host','example-component',2,'`+hash+`','presence',true,'gone','acme/gone@1.0.0','acme',now())`) + + ledger := ledgerUpTo(t, 41) + if err := migrateStoreFrom("file://"+ledger, databaseURL); err != nil { + t.Fatal(err) + } + var count string + mustQuery(t, db, `SELECT count(*)::text FROM solution_delivery_documents`, &count) + if count != "0" { + t.Fatalf("the replaced document family must leave the inbox, %s rows remain", count) + } + if _, err := db.Exec(`INSERT INTO solution_delivery_documents(kind,document_id,generation,content_hash,payload,bundle,signer_identity,ownership_domain,received_at) + VALUES ('presence','acme.test.live',2,$1,'{}'::bytea,'{}'::bytea,'example-key','acme',now())`, hash); err == nil { + t.Fatal("the inbox must refuse a presence row") + } + execColdCutoverSQL(t, db, `INSERT INTO solution_delivery_documents(kind,document_id,generation,content_hash,payload,bundle,signer_identity,ownership_domain,received_at) + VALUES ('manifest','acme.test.live',2,'`+hash+`','{}'::bytea,'{}'::bytea,'example-key','acme',now())`) + var live, gone string + mustQuery(t, db, `SELECT (applied_generation IS NULL AND desired_generation IS NULL AND cardinality(applied_routes)=0)::text + FROM solution_host_bindings WHERE binding_id='acme.test.live'`, &live) + mustQuery(t, db, `SELECT (applied_removed AND applied_generation = 2 AND applied_document = 'presence')::text + FROM solution_host_bindings WHERE binding_id='acme.test.gone'`, &gone) + if live != "true" || gone != "true" { + t.Fatalf("live applied state must clear (%s) and a withdrawal must stay (%s)", live, gone) + } + if _, err := db.Exec(`SELECT host_component FROM solution_host_bindings`); err == nil { + t.Fatal("host_component must be dropped") + } + // A binding keeps the domain it applied under when its live state is + // cleared, so no other domain can take its id before its owner redelivers. + var liveDomain, goneDomain string + mustQuery(t, db, `SELECT first_domain FROM solution_host_bindings WHERE binding_id='acme.test.live'`, &liveDomain) + mustQuery(t, db, `SELECT first_domain FROM solution_host_bindings WHERE binding_id='acme.test.gone'`, &goneDomain) + if liveDomain != "acme" || goneDomain != "acme" { + t.Fatalf("first_domain must keep each binding's applied domain, got %q and %q", liveDomain, goneDomain) + } + if _, err := db.Exec(`UPDATE solution_host_bindings SET first_domain = '' WHERE binding_id='acme.test.live'`); err == nil { + t.Fatal("first_domain must refuse an empty domain") + } + + abs, err := filepath.Abs(ledger) + if err != nil { + t.Fatal(err) + } + runner, err := migrate.New("file://"+abs, databaseURL) + if err != nil { + t.Fatal(err) + } + defer func() { _, _ = runner.Close() }() + if err := runner.Steps(-1); err == nil || !strings.Contains(err.Error(), "migration 41 cannot be reverted") { + t.Fatalf("the down must refuse while a manifest is stored, got %v", err) + } + if err := runner.Force(41); err != nil { + t.Fatal(err) + } + execColdCutoverSQL(t, db, `DELETE FROM solution_delivery_documents WHERE kind='manifest'`) + if err := runner.Steps(-1); err != nil { + t.Fatal(err) + } + var component string + mustQuery(t, db, `SELECT host_component FROM solution_host_bindings WHERE binding_id='acme.test.gone'`, &component) + if component != "example-host" { + t.Fatalf("the down restores host_component from the coordinate, got %q", component) + } + if _, err := db.Exec(`SELECT first_domain FROM solution_host_bindings`); err == nil { + t.Fatal("the down must drop first_domain") + } +} diff --git a/module/services/store/migrations/41_module_manifest_inbox.down.sql b/module/services/store/migrations/41_module_manifest_inbox.down.sql new file mode 100644 index 000000000..b82631e52 --- /dev/null +++ b/module/services/store/migrations/41_module_manifest_inbox.down.sql @@ -0,0 +1,18 @@ +-- Refuse a rollback while it would strand signed manifest history: the inbox +-- is append-only, and the replaced document family cannot hold a manifest. +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM public.solution_delivery_documents WHERE kind = 'manifest') THEN + RAISE EXCEPTION 'solution_delivery_documents holds module manifests; migration 41 cannot be reverted'; + END IF; +END $$; +ALTER TABLE public.solution_delivery_documents + DROP CONSTRAINT solution_delivery_documents_kind_check, + ADD CONSTRAINT solution_delivery_documents_kind_check + CHECK (kind IN ('presence', 'authority', 'endpoints')); +ALTER TABLE public.solution_host_bindings ADD COLUMN host_component text; +UPDATE public.solution_host_bindings SET host_component = host_coordinate; +ALTER TABLE public.solution_host_bindings + ALTER COLUMN host_component SET NOT NULL, + ADD CONSTRAINT solution_host_bindings_host_component_check CHECK ((host_component <> ''::text)); +ALTER TABLE public.solution_host_bindings DROP COLUMN first_domain; diff --git a/module/services/store/migrations/41_module_manifest_inbox.up.sql b/module/services/store/migrations/41_module_manifest_inbox.up.sql new file mode 100644 index 000000000..d046ccae7 --- /dev/null +++ b/module/services/store/migrations/41_module_manifest_inbox.up.sql @@ -0,0 +1,31 @@ +-- One signed module manifest per binding replaces the presence, authority and +-- endpoint documents (codefly/module-manifest/v1). Core reads no document of +-- the replaced family, so their inbox rows can never be verified again; they +-- are removed and the inbox holds manifests only, keyed by binding. +DELETE FROM public.solution_delivery_documents WHERE kind <> 'manifest'; +ALTER TABLE public.solution_delivery_documents + DROP CONSTRAINT solution_delivery_documents_kind_check, + ADD CONSTRAINT solution_delivery_documents_kind_check CHECK (kind = 'manifest'); + +-- A binding belongs to the ownership domain it first applied under, and keeps +-- it when its live applied state is cleared below: until its owner redelivers, +-- no other pinned domain may take the binding id (and its installations). The +-- host writes it once and never changes it. +ALTER TABLE public.solution_host_bindings + ADD COLUMN first_domain text CONSTRAINT solution_host_bindings_first_domain_check CHECK (first_domain <> ''::text); +UPDATE public.solution_host_bindings SET first_domain = applied_domain WHERE applied_domain IS NOT NULL; + +-- Desired and live applied state were presence documents: the next delivered +-- manifest of each binding re-establishes them, keeping its installation +-- target. A recorded withdrawal stays as it is — it is terminal, and the host +-- refuses every later manifest for that binding. +UPDATE public.solution_host_bindings + SET desired_generation = NULL, desired_digest = NULL, desired_document = NULL, desired_seen_at = NULL, + pending_reason = NULL, pending_since = NULL; +UPDATE public.solution_host_bindings + SET applied_generation = NULL, applied_digest = NULL, applied_document = NULL, applied_routes = '{}', + applied_solution_id = NULL, applied_release = NULL, applied_domain = NULL, applied_at = NULL + WHERE NOT applied_removed; + +-- A manifest names a host coordinate and no component. +ALTER TABLE public.solution_host_bindings DROP COLUMN host_component; diff --git a/module/tools/execution_binding_wired_test.go b/module/tools/execution_binding_wired_test.go index fb3a8819c..44e954af3 100644 --- a/module/tools/execution_binding_wired_test.go +++ b/module/tools/execution_binding_wired_test.go @@ -14,8 +14,7 @@ import ( // and this is the gate on that. // // The defect it closes shipped, and it is the one a reviewer is least likely to -// find by reading the mechanism: execution-bound minting — `BindExecution`, the -// three separate digest types, the monotonicity guard on the approved build, +// find by reading the mechanism: execution-bound minting — `BindExecution` and // the TokenReview client — was built, documented, guarded and unit-tested, and // `SetExecutionBinding` was called from NOWHERE. On a running host the reviewer // was nil, `BindExecution` answered ErrExecutionUnbound for every caller, and @@ -33,7 +32,7 @@ import ( // in a long comment: a host holding a log it can never reach is worse than a // host holding none. So "the setter is called" is not the property; the property // is that it is called with real sources. A future edit that reduced -// SetExecutionBinding to `SetExecutionBinding(nil, nil)` would satisfy a grep +// SetExecutionBinding to `SetExecutionBinding(nil)` would satisfy a grep // for the call and restore the hole exactly. func TestExecutionBindingIsWiredInProduction(t *testing.T) { moduleDir := findModuleDir(t) @@ -58,22 +57,20 @@ func TestExecutionBindingIsWiredInProduction(t *testing.T) { }) if len(calls) == 0 { - t.Fatal("work.go never calls SetExecutionBinding, so on a running host the execution reviewer and the " + - "approved-build authority are both nil.\n\n" + + t.Fatal("work.go never calls SetExecutionBinding, so on a running host the execution reviewer is nil.\n\n" + "BindExecution then answers ErrExecutionUnbound for every caller and the whole execution-binding " + "mechanism is unreachable — while every unit test of it still passes, because each wires the " + "sources itself. That is the state this gate exists to refuse.\n\n" + - "Wire it where both independent sources exist: the Kubernetes client that reviews a delivery " + - "carrier, and the reconciler's ApprovedBuilds().") + "Wire it where the reviewer exists: the Kubernetes client that reviews a delivery carrier.") } for _, call := range calls { - if len(call.Args) != 2 { - t.Fatalf("SetExecutionBinding is called with %d arguments; it takes the reviewer and the authority", + if len(call.Args) != 1 { + t.Fatalf("SetExecutionBinding is called with %d arguments; it takes the workload reviewer", len(call.Args)) } - for position, argument := range call.Args { - which := []string{"the execution reviewer", "the approved-build authority"}[position] + for _, argument := range call.Args { + which := "the workload reviewer" if identifier, ok := argument.(*ast.Ident); ok && identifier.Name == "nil" { t.Fatalf("SetExecutionBinding is called with nil for %s at %s.\n\n"+ "A nil source makes BindExecution answer ErrExecutionUnbound for every caller, which is the "+ @@ -157,48 +154,6 @@ func callsFunction(file *ast.File, function string) bool { return called } -// TestTheExecutionReviewerAndAuthorityAreDifferentSources is the gate on the -// property that makes the check mean anything. -// -// THE TAUTOLOGY IS THE FAILURE MODE HERE. Filling both sides of "approved == -// running" from one source compares a value to itself: it type-checks, it -// passes for every caller including the superseded pod the check exists to -// refuse, and no test fails. The two arguments must therefore come from -// genuinely different places — the api server for what is running, signed -// documents for what is approved — and a future edit that passed the same -// expression twice, or derived one from the other, would be the defect. -func TestTheExecutionReviewerAndAuthorityAreDifferentSources(t *testing.T) { - moduleDir := findModuleDir(t) - work := filepath.Join(moduleDir, "services", "accounts", "code", "work.go") - - source := readFileOrFail(t, work) - fileSet := token.NewFileSet() - parsed, err := parser.ParseFile(fileSet, work, nil, parser.ParseComments) - if err != nil { - t.Fatalf("parse work.go: %v", err) - } - - ast.Inspect(parsed, func(node ast.Node) bool { - call, ok := node.(*ast.CallExpr) - if !ok { - return true - } - selector, ok := call.Fun.(*ast.SelectorExpr) - if !ok || selector.Sel.Name != "SetExecutionBinding" || len(call.Args) != 2 { - return true - } - reviewer := expressionText(source, fileSet, call.Args[0]) - authority := expressionText(source, fileSet, call.Args[1]) - if reviewer == authority { - t.Fatalf("SetExecutionBinding is given the same expression for both sources (%q).\n\n"+ - "Then 'approved == running' compares a value to itself. It passes for every caller, including "+ - "a pod from a superseded generation, and nothing fails. The approved digest must come from the "+ - "signed authority documents and the running digest from the api server.", reviewer) - } - return true - }) -} - // enclosingFunction names the function declaration containing a position. func enclosingFunction(file *ast.File, position token.Pos) string { for _, declaration := range file.Decls { @@ -213,17 +168,6 @@ func enclosingFunction(file *ast.File, position token.Pos) string { return "" } -// expressionText returns the source text of an expression, which is what makes -// "the same expression twice" answerable without reimplementing printing. -func expressionText(source string, fileSet *token.FileSet, expression ast.Expr) string { - start := fileSet.Position(expression.Pos()).Offset - end := fileSet.Position(expression.End()).Offset - if start < 0 || end > len(source) || start >= end { - return "" - } - return strings.TrimSpace(source[start:end]) -} - // TestEveryModuleMintIsExecutionBound is the gate on #1056: every way a module // obtains a Work Context goes through BindExecution, and nothing else proves a // module's identity. diff --git a/module/tools/solution_registration_boundary_test.go b/module/tools/solution_registration_boundary_test.go index 2efe24345..5d4d09eda 100644 --- a/module/tools/solution_registration_boundary_test.go +++ b/module/tools/solution_registration_boundary_test.go @@ -242,8 +242,7 @@ func TestSolutionRegistrationBoundaryIsDocumented(t *testing.T) { // rename that does not move these lines is a rename that breaks every // consumer silently. "`POST /platform/_credential`", - "`POST /platform/_delivery/presence`", - "`POST /platform/_delivery/authority`", + "`POST /platform/_delivery/manifest`", // The rule that makes hardcoding the path correct and hardcoding the // origin wrong. Without it the table reads as an invitation to pin // everything. @@ -261,13 +260,11 @@ func TestSolutionRegistrationBoundaryIsDocumented(t *testing.T) { // something one of the three implementing repositories had wrong, and a // rename that does not move these lines breaks a consumer silently. // - // The authority pair is the only hardcoded (SA, namespace) in the - // scheme: a wrong constant there makes the TokenReview SUCCEED on a - // genuine identity and the host refuse the real carrier for a name. - "| authority | `delivery` | `platform-authority` — a fixed pair |", - // The presence namespace is NOT a constant, and the reason is the half - // that gets lost first: a fixed value refuses every genuine carrier. - "**the namespace the document's own workloads declare**", + // The carrier is one (SA, namespace) per module, and the namespace is + // read by rule, never configured: a fixed value refuses every genuine + // carrier. + "**The carrier is the `delivery` ServiceAccount of the module's namespace**", + "the namespace the cell runs the manifest's module in", // Offline verification, and the failure shape that makes it matter. "would **hang rather than fail fast**", // That the host is the second layer, not the perimeter. Without this diff --git a/module/tools/work_contexts_contract_test.go b/module/tools/work_contexts_contract_test.go index 9f01345c3..e46ea0d15 100644 --- a/module/tools/work_contexts_contract_test.go +++ b/module/tools/work_contexts_contract_test.go @@ -151,7 +151,7 @@ func TestWorkContextsContractStatesTheGrantNamingDecision(t *testing.T) { "the producing module owns what its ids mean", "The host neither derives nor checks them", "Name the resource by its **stable identity**, not by a digest of its current definition.", - "The module principal registry is derived, each reconcile pass, from the delivered authority documents inside the module envelope", + "The module principal registry is derived, each reconcile pass, from the applied module manifests and the module envelope", "an entry change does not revoke them", "Pinning a definition is the producer's own check", "The host makes no such check",