From f3d7e41670791450dae51e3fb7178fba62ead3fc Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:04:33 -0400 Subject: [PATCH 1/5] feat!: serve plain HTTP, mint over the gateway's workload endpoint, declare the frontend in one file (#68) The in-cell plain-HTTP cutover, this runtime's part. Past the host's auth gateway every hop inside a cell is plaintext carried by the mesh, so the listener is plain TCP on the resolved port and presents no identity: identity.go, the three peer files, the SVID loading, the per-connection rechecks and the workload-identity group are deleted, not fenced. The gateway is resolved as auth-gateway/workload (http), which serves the gateway's whole handler, and the mint is the host's published broker route on that one origin, so CODEFLY__CREDENTIAL_MINT_URL is gone. The native broker client keeps the host's wire contract over plain HTTP; the gateway URL and the mint derived on it are held to the SDK's one transport rule, workcontext.AdmitEndpoint, with the composition's internal-transport/mesh-protected assertion read once in loadConfig. Nothing is reported with the mint any more. The frontend a solution is listed under is declared once, in solution.frontend.codefly.yaml beside the backend's service.codefly.yaml, embedded as Manifest.Frontend and served from by the manifest route; the CLI reads the same file into the presence document's frontend block, field for field with core#795. Breaking: Manifest loses Title, Order, ExposedModule, Dashboard and Surfaces and gains Frontend; the Surface type, IdentitySource, PeerAnchorSource and Server.Identity are removed, as are the CredentialMintURLEnvironmentVariable and WorkloadIdentity* constants. Builds against sdk-go#77 head aabfb4cf by pseudo-version; re-pin to the workcontext tag before merging. Co-Authored-By: Claude Fable 5.1 --- AGENTS.md | 248 ++- README.md | 556 +++---- boot_test.go | 858 ++-------- broker_body_test.go | 103 -- broker_credential.go | 137 +- broker_credential_test.go | 35 +- consumer_identity_test.go | 1 - contract_test.go | 6 +- credential.go | 91 +- credential_test.go | 284 +--- frontend.go | 447 ++++++ go.mod | 2 +- go.sum | 4 +- handshake_test.go | 2337 ---------------------------- identity.go | 1286 --------------- identity_test.go | 1227 --------------- installation.go | 8 +- installation_test.go | 42 +- internal/seam/seam.go | 10 +- mcp.go | 18 +- mcp_test.go | 20 +- mint_migration_test.go | 11 +- passthrough.go | 10 +- passthroughtest/identity.go | 115 -- passthroughtest/passthroughtest.go | 86 +- projection_boundary_test.go | 13 +- redirect_test.go | 110 +- served_test.go | 2 +- solution.go | 1649 +++----------------- solution_test.go | 732 ++++----- uninstalled_test.go | 127 +- 31 files changed, 1629 insertions(+), 8946 deletions(-) delete mode 100644 broker_body_test.go create mode 100644 frontend.go delete mode 100644 handshake_test.go delete mode 100644 identity.go delete mode 100644 identity_test.go delete mode 100644 passthroughtest/identity.go diff --git a/AGENTS.md b/AGENTS.md index d8a5db8..6d7427b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,9 +4,11 @@ This repository owns the generic Go runtime for **codefly solutions** — module deployed independently of the host they extend, with no build-time coupling. It owns what every solution needs identically: configuration resolution through the codefly SDK, obtaining this execution's credential once from the projected -service-account token, a listener that presents the workload's own X.509-SVID, -CORS, Module Federation asset serving, the capability handshake, the manifest, -the published authority contract, serving the solution's MCP server to agent +service-account token, a plain-HTTP listener on the port Codefly resolves — +the cell's mesh carries transport security and destination identity for every +hop inside it — CORS, Module Federation asset serving, the capability +handshake, the manifest served from the one declared frontend file, the +published authority contract, serving the solution's MCP server to agent clients, and the gateway client a handler uses to read composed modules on the viewer's behalf. @@ -23,7 +25,8 @@ runtime removed. It owns none of the counterparts it talks to. The mint endpoint and the admission rules belong to the host (the composition's own host module) and the -gateway; the authority reader and the TLS reloader to `codefly-dev/sdk-go`; the broker +gateway; the authority reader and the one transport rule that admits a +plaintext URL (`workcontext.AdmitEndpoint`) to `codefly-dev/sdk-go`; the broker wire contract to the host, implemented natively here; endpoint, port and secret resolution to `sdk-go` as well; workspace, manifest and presence/authority document types to `codefly-dev/core`; running a composition, provisioning its secrets and rendering the authority @@ -37,9 +40,9 @@ on the handler `Serve` mounts, reached through `internal/seam`. **That seam is not public API, and must not become one again.** `Server.PassthroughHandler` was exported, and a deployment calling it completed a viewer mint and a module call over plaintext, 200, having skipped -`validate()`, the mTLS boot, the caller allow-list, the ceiling and -authenticated outbound — with the viewer's bearer and this workload's own -credential on the wire, and supplying a credential source defeated the "no +`validate()`, the boot, the ceiling and the origin-pinned outbound client — +with the viewer's bearer and this workload's own credential sent wherever it +was mounted, and supplying a credential source defeated the "no source, nothing to mint with" mitigation it relied on. Go's internal-package rule is the gate, which is a compiler rather than a naming convention; `TestNoExportedPathBuildsACredentialBearingHandlerWithoutTheBoot` fails if an @@ -253,50 +256,39 @@ embedding it. - Validate at the boundary — boot, and the headers a request arrives with — not between internal callers. `validate()` is the model: each refusal names the variable or the provisioning path that fixes it. -- The listener presents this workload's own identity and there is no plain-HTTP - listener. A configuration that cannot produce one is refused at boot naming the - material, because a solution serving plain HTTP is refused at the edge instead, - for a reason only the edge can see. Where the identity comes from is the - platform's: `IdentitySource` is the hook, and the default reads the pair the - platform projects through the SDK's reloader. A source's configuration is - checked for what it must *have* (a certificate, a TLS 1.3 floor, - `RequireAndVerifyClientCert`, a named anchor) **and refused for what it must - not replace**: `Time`, `Rand`, `KeyLogWriter`, `WrapSession`, `UnwrapSession`, - `InsecureSkipVerify`. Each has a safe zero value, so refusing a non-zero one - is exact — and resumption is the sharpest, because a resumed connection - presents no certificate, so a source encoding its own tickets admits callers - without any check here seeing a handshake. That list is a denylist over a - struct this package does not own, and it is **not** complete. That claim was - made here and falsified twice: first by the session-ticket keys, then by - `VerifyPeerCertificate`. A `Clone()` copies 29 of 32 fields unchanged, so - whatever this list does not name, a source keeps. Where a property is - load-bearing, **take** it rather than check it — resumption is disabled - outright, and a caller's identity is read from a fresh parse of the raw DER - rather than from the `*x509.Certificate` a source's own verifier was handed a - pointer to. This list is what *tells* a source it has gone wrong; it is not - what makes the listener safe. The per-connection answer runs through the same - function rather than a copy of its reasoning. `SessionTicketKey` - is on that list for the same reason as the callbacks — choosing the key tickets - are sealed with is the same capability as encoding them — and - `SetSessionTicketKeys` is the one hole a check cannot close, because it is a - method and the keys it installs cannot be read back off the configuration. -- **This listener does not resume TLS sessions, and that is a control rather - than a check.** A resumed connection presents no certificate, so anything - able to forge a ticket is admitted as whoever it was issued to — demonstrated - end to end with a source-supplied ticket key, against a listener whose - posture checks all passed. A denylist cannot close it (`SetSessionTicketKeys` - installs keys that cannot be read back), so `SessionTicketsDisabled` is set on - the served configuration and on every per-connection clone. Where a property - cannot be verified on a value a consumer hands over, take it rather than - inspect it. -- **A source's anchor is asked for, never fabricated.** A consumer source that - resolves its anchor per handshake implements `PeerAnchorSource`; one that can - only answer through a `GetConfigForClient` call is refused at boot, because - obtaining the anchor would mean passing a `ClientHelloInfo` nobody sent — the - same synthetic-probe defect this cutover was blocked on for certificates, and - worse, since an anchor has no check behind it. The outbound *certificate* is - still taken through an empty hello, which is sound only because the frozen - principal hold catches a wrong answer at the moment it is presented. +- **The listener is plain HTTP on the resolved port, and the cell's mesh is + what carries the hop.** Past the host's auth gateway every hop inside a cell + is plaintext under istio ambient's ztunnel mTLS, the workload's identity is + its ServiceAccount's SPIFFE ID — the mesh principal a presence document + names, not an SVID this process presents — and TLS ends at the edge. So this + listener presents no certificate and verifies no caller, and the mesh + admits its port only from the gateway's principal. The previous generation + served TLS 1.3 under a projected X.509-SVID, verified callers against a + projected anchor, admitted them by three provisioned peer files, re-verified + every established connection once a second in both directions and refused + session resumption; `identity.go` and all of it are **deleted, not fenced**, + because every one of those checks was this process re-deciding what the + cell decides for every workload. If you are about to add a check on who is + on the other end of a connection, you are rebuilding what this cutover + removed; what this process still decides is what a caller may do once + admitted (the stamped identity headers, this execution's credential) and + where its own credentials may travel (below). +- **A plaintext destination is admitted only where the mesh is, by the SDK's + rule and never by a copy of it.** The gateway URL — and the mint derived on + it — goes through `workcontext.AdmitEndpoint`: `https` to any host; `http` + to a loopback host on a local run, or to an in-cluster Service address + (`..svc[.]`) when the composition asserts + `internal-transport/mesh-protected=true`; anything else is `ErrInvalid` + naming the rule. It is the rule the composed modules apply to their own + gateway value, and `validate()` and `newBrokerCredentialSource` both call + it rather than restating it. The assertion is read ONCE, in `loadConfig`, + into `meshTransport`, and answered from that snapshot wherever the rule + asks — because every environment read sits in `loadConfig`'s call tree, and + a configuration value is fixed at process start anyway. The snapshot keeps + the SDK's three answers three (unset, set to a value, unreadable) and never + trims the value: only the literal `true` asserts, and a misspelled + assertion is a refusal rather than either answer. Do not add a boolean + beside it; a per-service opt-in is what an address never has to qualify for. - The credential is obtained **once**, before the listener exists. A *refusal* fails the boot and is never retried — the host is saying this build is not the one its presence document approved, which no number of attempts changes, and a @@ -314,54 +306,17 @@ embedding it. Location: http://…` the viewer's bearer, their capability and this workload's credential in cleartext. Build clients through `Gateway.platformClient`, which carries `ErrUseLastResponse`, and leave the transport's origin pin in place. -- **Peer trust is re-read per connection in BOTH directions, and established - connections are re-verified.** Inbound, per handshake through - `GetConfigForClient`; outbound, per dial through `DialTLSContext`. Per-dial - and per-handshake bound nothing about a connection that already exists, which - an idle timeout cannot fix because it only bounds a connection nobody is - using: a busy outbound connection answered 31s after its root was removed, and - the listener had no recheck, no `IdleTimeout` and no `ReadHeaderTimeout` at - all. Both directions now re-verify the established peer against current trust - once a second and close what stops verifying. The recheck reads only the - anchor and the admitted set — never this workload's own leaf, so a - half-written rotation of our own key pair cannot tear down connections whose - peers are fine — and the outbound leaf comes from ONE long-lived SDK reloader, - which keeps the last good pair; building one per dial threw that away. The outbound half went - unanswered through two review rounds while the inbound argument — judging by - a stale anchor admits whoever should be refused — was already written down - here. It applies harder outbound: those destinations receive the projected - token, the viewer's bearer and this workload's credential. -- **Authentication is not authorisation.** Every workload in the trust domain - holds a certificate from the same anchor, the consumed modules included, so - the admitted caller set is provisioned - (`workload-identity`/`ALLOWED_CALLERS_FILE`) and refused at boot when absent. - Outbound there is **one set per destination** (`MINT_PEERS_FILE`, - `GATEWAY_PEERS_FILE`): the mint and the gateway are two parties, and a single - set spanning both authorises each to stand in for the other at the other's - address — the mint being the one that receives the projected token. - `MINT_PEERS_FILE` governs the mint hop again, per handshake, through the - native broker's live admitted-peer reader — so the question it was kept open - for is settled: it is the authorization set for the destination that receives - the projected service-account token. It also still narrows the shared-address - case, where the mint and the gateway answer at one address and a dial cannot - be attributed, and what is admitted there is the intersection of both sets. A dial to - a third address has no set and is refused, because this runtime talks to - exactly two destinations. - An admission set answered by **both** the env override and the platform's - provisioning is refused rather than ranked — two sources for one - authorization fact, which is the stance the SDK already takes on a value - delivered inline and by carrier. The other paths in that group still take the - override first, because the worst case there is this process reading its own - material from somewhere else, not admitting a caller. - Both admission sets are **paths this process reads itself**, per handshake and - per dial, not configuration values: a value is fixed at process start, so - "re-resolved per handshake" through the SDK's accessor returned the boot answer - forever while the code and the README both said revocation needed no restart. - Anything in `workload-identity` the platform can change is a path or it is a - snapshot pretending otherwise. A - listener that verifies every caller and admits all of them lets a consumed - module set its own `x-org-id`/`x-session-id` and drive mints under this - workload's attestation. +- **Authentication is the mesh's; what is still this process's is which + stamped identity a request carries.** `x-org-id`, `x-session-id` and + `x-codefly-installation-id` are read as the gateway stamped them from + verified claims, and a request reaching this port came through the gateway + or the mesh would not have carried it. The admitted-caller file this + runtime once read per handshake, and the two per-destination peer files for + the mint and the gateway, are gone: `ALLOWED_CALLERS_FILE`, + `MINT_PEERS_FILE` and `GATEWAY_PEERS_FILE` admitted identities the mesh now + admits at L4 from the edges the CLI delivers, and a second set here would be + a second answer. The runtime dials exactly one origin, the gateway's + workload endpoint, and `originPinned` refuses every other destination. - **Every mint names the installation it acts under, and it comes from the header the gateway stamps.** The host requires it (core's `StartInput.InstallationID`) and refuses a mint naming none; an organization @@ -444,18 +399,24 @@ embedding it. answer is recorded **where the answer lands** rather than by whichever caller was still waiting — the ask is detached, so it can finish with nobody listening. -- **The mint reports the dynamic half of the registration; nothing else - does.** `upstream` (where the gateway dials this runtime) and `manifest` (the - frontend manifest it serves) travel in the mint body, recorded by the host - under the identity the projected token proves. This is not the registration - this runtime removed: it adds no request, no timer and no credential, and it - cannot make a workload present — the host completes only a declaration its - own delivery already applied. `upstream` is the **advertised** address - (`CODEFLY__SELF_ENDPOINT__…`, through core's accessor), never the listen - address a cell localizes to `localhost`; a present carrier is never read as - absent, and a deployed runtime context refuses a loopback origin at boot. - `brokerMintBodyMaxBytes` bounds the request at boot, under what the host - reads, so the bound that fires is the one that can name the manifest. +- **Nothing is reported with the mint, and the frontend is declared in one + file.** `upstream` (the advertised origin) and `manifest` (the frontend + manifest) travelled in the mint body for one generation and are deleted: + the gateway resolves this solution's address from the delivered presence + document's endpoints, through the SDK, from the carrier the CLI hands it — + as for a module — and the frontend travels in the same document. The CLI + reads it from `solution.frontend.codefly.yaml` beside the backend's + `service.codefly.yaml`, the same file this runtime embeds + (`Manifest.Frontend`, `go:embed`) and serves its manifest from + (`frontend.go`): two readers of one file cannot disagree, where a Go field + beside a file could. Its keys are core's `frontend` block field for field, + every scalar declared because core refuses an omitted one, the dashboard + held to the JSON object core carries (a mapping, string keys, no fraction, + 64 KiB encoded) and surfaces carried whole (`contract`, `applies`, `events` + included). A boot without the file is refused naming it. Do not add a Go + field for anything the host lists a solution under, and do not report + anything about this process to the host: a process cannot declare what the + composition did not. - **`412` is "admitted, installed nowhere", and it is a state, not an error.** The host issues this execution's credential only after the install step is acknowledged, so the step must run without it — it acts under the capability @@ -466,19 +427,34 @@ embedding it. source and reported by nobody. A viewer's route still answers 503. Do not widen the exemption to any other unavailable state: a host that cannot be reached is not an uninstalled solution. -- **The execution mint follows the host's module broker contract natively.** +- **The execution mint follows the host's module broker contract natively, + over plain HTTP to the gateway's workload endpoint.** `saas-sdk-go/moduleauthority` at `217bf9fe3f10` is the wire reference; its capability dependency is incompatible with this runtime. Send - `prefix: Manifest.ID` — and beside it only `upstream` and `manifest`, below — - to `/modules/_work-context`, with the projected token - as bearer and `X-Codefly-Internal-Token` from the declared workspace secret - `module-authority/INTERNAL_TOKEN`. Resolve it in `loadConfig`, refuse absence - in `validate()`, and never resurrect a legacy secret reader. The projection - audience is `accounts`; the prefix never replaces the frozen SPIFFE principal. - The native transport owns a fresh mutually authenticated TLS handshake for - every mint, checks live trust and admission before and after it, disables - redirects/proxies/reuse, and holds the presented leaf to the frozen principal. - The SDK still owns the certificate reloader and authority reader. Core's + `prefix: Manifest.ID` and nothing else to `/modules/_work-context`, with + the projected token as bearer and `X-Codefly-Internal-Token` from the + declared workspace secret `module-authority/INTERNAL_TOKEN` — kept: the + host's waiver of that header on its workload peer existed only because the + SVID authenticated the peer, and with the SVID gone the waiver goes too. + Resolve the secret in `loadConfig`, refuse absence in `validate()`, and + never resurrect a legacy secret reader. The projection audience is + `accounts`; the prefix never replaces the frozen SPIFFE principal. The mint + URL is the host's published route on the gateway this process already + resolved: the workload endpoint serves the gateway's whole handler, so there + is no second address to resolve and `CODEFLY__CREDENTIAL_MINT_URL` is gone. + The native transport owns one plain-HTTP request per mint on a connection + dialled for it, pinned to that origin, with redirects, proxies and reuse + disabled, and the URL held to the SDK's transport rule before anything is + sent; an `https` gateway is verified against the system roots, the right + anchor for an edge that presents a public certificate. The SDK still owns + the authority reader. `workcontext.MintClient` is NOT the production mint + client and must not become one without the host serving its wire shape: it + posts `{audience, projection_audience}` and reads `{work_context}`, where + the host takes `{prefix}` and answers `{token, expiresAt, principalId, + tenant}`; this runtime replaced it with the native client in #59 for that + reason, and the two places that still construct one — `credential_test.go` + and `passthroughtest` — do so against fakes that speak the SDK's shape, which + is a finding for the audit and not something a deployment exercises. Core's `Decode` is the only structural reader of the returned token; no signed message is constructed or decoded here, and that read verifies no signature. It was `Inspect` until core v0.15, and the old name is now core's @@ -587,14 +563,19 @@ against. fix by working, by being small, by being local, or by the real fix belonging to core, the SDK or the host. 4. **Never hardcode what the system resolves, and never guess what it cannot.** - This package resolves every address, port and secret through the SDK; the - single `localhost` literal in it is the self upstream's listen-address - fallback, built from the *resolved* port and refused by `validate()` in a - deployed runtime context. The credential mint URL has **no default**: it is - the address the projected service-account token goes to, nothing resolves it - yet, and a derived path on the resolved gateway was carried for two rounds as - a "labelled stopgap" while the README described it as settled — a guess a - label does not make safe. It is refused at boot until something resolves it. + This package resolves every address, port and secret through the SDK, and + carries no address literal at all: the `localhost` the advertised-origin + fallback used went with the report it fed. The credential mint URL is the + one derived value, and it is derived from a published fact rather than a + guess: the gateway's workload endpoint serves the gateway's whole handler, + mint included, so the mint is `/modules/_work-context` on the one origin the + composition resolved. That is a different thing from the derivation this + rule used to forbid — a path assumed on the public gateway while the host + served its mint somewhere nobody had published, carried for two rounds as a + "labelled stopgap" — and the test that pinned its absence + (`TestNoProductionCodeDerivesTheMintAddress`) was replaced by one that pins + the published route. If the host moves the mint off the workload endpoint, + the fix is a resolved endpoint role, not an override. If you are typing an address, a port or a credential, you are encoding something true only on your machine for the next ten minutes; if you are deriving one the owner has not published, you are encoding a guess about @@ -679,12 +660,13 @@ credentials, no running composition. Keep this file short; add depth to the file that owns the subject. - [README.md](README.md) — the consumer-facing contract: handler errors, the - full configuration and env-override table, the workload identity and the one - mint, the published contract, consumed-module federation, and reading a + full configuration and env-override table, plain HTTP inside the cell, the + declared frontend file and the one mint, the published contract, + consumed-module federation, and reading a Work-Context-authenticated module. It also carries the migration note: an old runtime against a new host gets 404, and nothing bridges the two. - The package doc comment and the comments in [`solution.go`](solution.go), - [`credential.go`](credential.go), [`identity.go`](identity.go) and + [`credential.go`](credential.go), [`frontend.go`](frontend.go) and [`contract.go`](contract.go) and [`mcp.go`](mcp.go) — why a refusal, a renewal point or a cache lifetime is what it is. Several record a failure mode that is not obvious from the code; read the one next to what you are changing before you change it. diff --git a/README.md b/README.md index a25d7fe..648ca7f 100644 --- a/README.md +++ b/README.md @@ -2,15 +2,20 @@ Generic Go runtime for **codefly solutions** — independently deployed modules that plug into a host at runtime with no build-time coupling. Owns configuration -resolution, this execution's one credential, a listener that presents the -workload's own identity, CORS, Module Federation asset serving, the capability -handshake, the manifest, the published authority contract, and the gateway -client each handler uses to read composed modules on the viewer's behalf. +resolution, this execution's one credential, a plain-HTTP listener on the port +Codefly resolves, CORS, Module Federation asset serving, the capability +handshake, the manifest served from the declared frontend file, the published +authority contract, and the gateway client each handler uses to read composed +modules on the viewer's behalf. -A solution author writes a manifest and one handler: +A solution author writes a manifest, embeds the frontend it declares, and +writes one handler: ```go -solution.New(solution.Manifest{ID: "my-solution", Title: "My Solution"}). +//go:embed solution.frontend.codefly.yaml +var frontend []byte + +solution.New(solution.Manifest{ID: "my-solution", Frontend: frontend}). Handle("/thing", func(ctx context.Context, gw *solution.Gateway) (any, error) { resp, err := solution.Unary[Req, Resp](ctx, gw, "/pkg.Service/Method", &Req{}) return resp, err @@ -18,7 +23,12 @@ solution.New(solution.Manifest{ID: "my-solution", Title: "My Solution"}). Serve() ``` -The gateway URL, the caller's bearer, and the wire protocol are hidden. +The gateway URL, the caller's bearer, and the wire protocol are hidden. What +the host lists the solution under — title, nav order, exposed module, host +contract, dashboard, surfaces — lives in +[`solution.frontend.codefly.yaml`](#the-declared-frontend) beside the backend's +`service.codefly.yaml`, which the CLI delivers in the presence document and +this runtime serves its manifest from. ## A runtime does not register itself @@ -57,7 +67,7 @@ Four things follow, and each is a boot failure rather than a degraded run: | | | |---|---| -| The listener presents this workload's X.509-SVID over TLS | there is no plain-HTTP listener, because the host refuses a plain-HTTP destination | +| The listener is plain HTTP on the resolved port | the cell's mesh carries transport security and destination identity; this process presents no identity of its own and verifies no caller — see [Plain HTTP inside the cell](#plain-http-inside-the-cell) | | The credential is obtained once, before the listener exists | a refusal is terminal and never retried; an unavailable mint is waited out inside a bounded deadline — see [One credential per execution](#one-credential-per-execution) | | The authority-bearing values are read once and frozen | the credential is sealed to the values it was minted under, so a value that drifts is an error, never a reload | | The contract is published, and the declaration is held to it | an ask outside its declared ceiling fails the boot — see [The published contract](#the-published-contract) | @@ -65,27 +75,20 @@ Four things follow, and each is a boot failure rather than a degraded run: Execution minting uses the host's module broker protocol natively. The reference is `saas-sdk-go/moduleauthority` at `217bf9fe3f10`; importing that package is incompatible with this runtime's SDK capability API, so no dependency was added. -The request is `POST /modules/_work-context`, with -`{"prefix":""}`, `Authorization: Bearer `, and -`X-Codefly-Internal-Token`. The prefix is the declared solution ID, separate from -the frozen SPIFFE principal. Beside the prefix the body reports the dynamic half -of this runtime's registration, since nothing self-registers: `upstream`, the -https origin the gateway dials to reach this runtime, and `manifest`, the -frontend manifest this runtime serves. The host records both under the identity -the projected token proves and reaches the origin only under this workload's -SVID. The whole request is bounded at 64 KiB, and a manifest that takes it over -is refused at boot, naming the manifest. - -`upstream` is the address Codefly **advertises** for this service's http -endpoint — the `CODEFLY__SELF_ENDPOINT______HTTP__HTTP` carrier -— and never the address it listens on. A rendered cell localizes a service's own -`CODEFLY__ENDPOINT__` carrier to `localhost`, so reporting that would have the -gateway proxy the solution to the gateway's own loopback. With no carrier, a -process on a developer machine reports the loopback port it actually binds -(`PORT` included). In a deployed runtime context a loopback origin fails the -boot — which is what a missing carrier resolves to in a rendered cell — and so -does a carrier that does not hold exactly a host and a port; a present carrier -is never treated as absent. +The request is `POST /modules/_work-context` on the gateway's **workload** +endpoint — the one origin this runtime resolves, which serves the gateway's +whole handler — with `{"prefix":""}` and nothing else, +`Authorization: Bearer `, and `X-Codefly-Internal-Token`. The +prefix is the declared solution ID, separate from the frozen SPIFFE principal. + +Nothing about where this runtime serves or what its frontend declares is +reported with the mint. Both used to be: `upstream`, an https origin the +gateway dialled, and `manifest`, the frontend manifest this runtime served. +They are deleted, not fenced. The gateway resolves this solution's address from +the delivered presence document's endpoints, through the SDK, from the carrier +the CLI hands it — as it already did for modules — and the frontend travels in +the same document, read by the CLI from the file this runtime embeds. A +process cannot declare what the composition did not. A host that admits this workload but has not been asked to install it anywhere answers `412`. That is not a refusal. The runtime serves holding no credential: @@ -143,8 +146,8 @@ contains `ID`, `OrganizationID`, `Revision` and the granted `Scope` (`[]Scope`). `resourceKind`, `actions`, and `resourceIds`); unknown fields and bodies over 64 KiB return 400. -The route is mounted only on the workload-identity listener, whose mutual TLS -and gateway-only `ALLOWED_CALLERS_FILE` admit the caller; the runtime requires +The route is served on the one listener, which the cell's mesh admits only +from the gateway's principal; the runtime requires exactly one structurally valid Work Context via the SDK's `FromHeaders` (including its installation carriers), carries it to the step through `ViewerFromContext(ctx)`, and leaves verification to the host on every outgoing @@ -193,16 +196,11 @@ identifier and the issuer](#the-resource-identifier-and-the-issuer). | What | SDK resolution | Env override (default) | |---|---|---| | Own listen port | `codefly.For(ctx).Endpoint("http").NetworkInstance()` — the Codefly-assigned port, not a fixed default | `PORT` | -| Gateway URL (auth-gateway `rest`) | resolved by role — the single module owning the `auth-gateway` `rest`/`rest` endpoint, discovered from the injected carriers (or the workspace, run locally, where core's endpoint visibility applies: an endpoint private to the host's module is refused, not resolved, for a solution in another module). Must be `https` | `GATEWAY_URL` | -| Credential mint URL | Explicit HTTPS endpoint ending in `/modules/_work-context`. The host's broker path is published; its origin must still be supplied. A 404 is a terminal endpoint/configuration error | `CODEFLY__CREDENTIAL_MINT_URL` (**required**) | -| Broker perimeter credential | `codefly.For(ctx).WorkspaceSecret("module-authority", "INTERNAL_TOKEN")`, required at boot. Sent only to the execution broker as `X-Codefly-Internal-Token`; it does not replace the projected token or workload identity | — (declared workspace secret, no legacy env fallback) | -| Projected service-account token | `codefly.For(ctx).WorkspaceConfiguration("workload-identity", "TOKEN_FILE")` — a **path**, re-read at every mint; defaults to `/var/run/secrets/codefly/token`, the CLI projection | `CODEFLY__WORKLOAD_TOKEN_FILE` | -| Workload identity certificate | `workload-identity`/`CERT_FILE` | `CODEFLY__WORKLOAD_IDENTITY_CERT_FILE` | -| Workload identity private key | `workload-identity`/`KEY_FILE` | `CODEFLY__WORKLOAD_IDENTITY_KEY_FILE` | -| Peer trust anchor | `workload-identity`/`TRUST_BUNDLE_FILE` — **required**: the listener requires and verifies a caller's certificate against it, and the outbound client verifies the platform against it | `CODEFLY__WORKLOAD_IDENTITY_TRUST_BUNDLE_FILE` | -| Allowed callers | `workload-identity`/`ALLOWED_CALLERS_FILE` — **required**, a **path** to a file of identities, one per line (normally the gateway's); re-read per handshake. Verifying against the anchor says a caller holds an identity the platform issued; this says which of them this solution serves | `CODEFLY__WORKLOAD_IDENTITY_ALLOWED_CALLERS_FILE` | -| Credential mint peer | `workload-identity`/`MINT_PEERS_FILE` — **required**, a **path** to a file of identities; re-read per handshake. This is the set the credential mint endpoint is admitted by: the native broker client reads it during the handshake and refuses a peer whose single URI SAN is not in it, even when the certificate was signed by the accepted anchor. It also narrows the shared-address case, where the mint and the gateway answer at one address and a dial cannot be attributed: what is admitted there is the intersection of this set and the gateway's | `CODEFLY__WORKLOAD_IDENTITY_MINT_PEERS_FILE` | -| Gateway peer | `workload-identity`/`GATEWAY_PEERS_FILE` — **required**, same shape. One set **per destination**: the mint and the gateway are two parties, and a single set spanning both authorises each to stand in for the other at the other's address — and the mint is the destination that receives the projected service-account token. A dial to an address that is neither has no set and is refused. When the two are at the **same** host and port — the brokered shape — a dial there cannot be attributed to one of them, so it is held to the **intersection** of both sets, and an empty intersection is refused at boot | `CODEFLY__WORKLOAD_IDENTITY_GATEWAY_PEERS_FILE` | +| Gateway URL (auth-gateway `workload`) | resolved by role — the single module owning the `auth-gateway` `workload`/`http` endpoint, discovered from the injected carriers (or the workspace, run locally, where core's endpoint visibility applies: an endpoint private to the host's module is refused, not resolved, for a solution in another module). The workload endpoint serves the gateway's whole handler, the mint included; the public `rest` endpoint does not resolve it. Held to the SDK's transport rule: `https` anywhere; `http` only to a loopback host on a local run, or to an in-cluster Service address (`..svc[.]`) when the composition asserts `internal-transport/mesh-protected=true` | `GATEWAY_URL` | +| Credential mint URL | the host's published broker route, `/modules/_work-context`, on the resolved gateway. Derived, with no override: the gateway's workload endpoint serves it, and a second address would be a second destination for the projected service-account token. A 404 there means the host does not serve its gateway's full handler on that endpoint, which is terminal | — | +| Mesh assertion | `codefly.For(ctx).WorkspaceValueIfSet("internal-transport", "mesh-protected")` — read once at boot and handed to the SDK's rule above; exactly `true` asserts that every in-cluster hop is carried by a mutually authenticated mesh, unset or `false` keeps plaintext refused, any other value is a boot refusal. The same setting the composed modules apply to their own gateway value | — (declared configuration) | +| Broker perimeter credential | `codefly.For(ctx).WorkspaceSecret("module-authority", "INTERNAL_TOKEN")`, required at boot. Sent only to the execution broker as `X-Codefly-Internal-Token`; it does not replace the projected token | — (declared workspace secret, no legacy env fallback) | +| Projected service-account token | a **path**, re-read at every mint; `/var/run/secrets/codefly/token`, the CLI projection, which every Codefly-managed deployment mounts | `CODEFLY__WORKLOAD_TOKEN_FILE` | | Principal this workload runs as | `module-authority`/`PRINCIPAL`, read once and frozen | — | | Audience it mints against | `module-authority`/`AUDIENCE`, read once and frozen | — | | Audience of its own projected token | `module-authority`/`PROJECTION_AUDIENCE`, required to be `accounts`, read once and frozen | — | @@ -211,30 +209,11 @@ identifier and the issuer](#the-resource-identifier-and-the-issuer). | Host issuer (MCP) | `codefly.For(ctx).WorkspaceConfiguration("mcp", "issuer-url")` — the declared value the composition supplies; without one, the resolved host frontend origin (see [Exposing an MCP server](#exposing-an-mcp-server)). Checked at boot only when `ServeMCP` is declared | — (declared configuration, no env override) | | Public MCP URL | **declared** (`mcp/public-url`) in a deployed runtime context, where a derived value is refused: the derivation encodes the route the *host* serves this solution on. Locally, derived from `/solutions//mcp`. Overridden by `codefly.For(ctx).WorkspaceConfiguration("mcp", "public-url")` (must end in `/mcp`). With neither, the resource identifier is reconstructed per request from `x-forwarded-proto` / `x-forwarded-host` / `x-forwarded-prefix` | — (declared configuration, no env override) | -Every `workload-identity` value is a **path, never material**, the three -admission sets included — and that is what makes them live. They are admission -decisions, and an admission decision read once at boot cannot narrow: removing a -compromised identity from either file takes effect on the next handshake, the -next dial and the next connection check, with no restart. A set that resolves to -nothing, or that cannot be read, **refuses** rather than falling back to the set -the process booted with. - -> They were configuration *values* until round four of review, re-resolved on -> every handshake through the SDK's accessor — which does not work and this -> README claimed it did. A configuration value, inline or file-carried, is fixed -> at process start (the SDK reads the file once and keeps it, `file_carrier.go`), -> so the re-resolution returned the boot answer for the life of the process, the -> refusal branch was unreachable in any deployment, and revoking a caller -> silently needed a restart. They are paths now, read by this process like the -> trust anchor beside them, which is the only form that can be what was -> documented. - -The files behind these paths are read by this process — the token at every mint, -the key pair and the anchor at every handshake, the two admission sets at every -handshake and dial — so the SDK's value accessors are the wrong tool for any of -it: a file-carried configuration value is read once and kept for the life of the -process, which is right for a configuration value and wrong for anything the -platform changes underneath one. +The projected token is a **path, never a value**, because the projection is +rotated under the running process and a token read once at boot stops verifying +long before the process stops running. It is the one file this process reads +for itself; everything else is a configuration value the SDK resolves, fixed at +process start, which is right for a configuration value. The three `module-authority` values are read through the SDK's authority reader and **frozen**: the credential this process holds is sealed to the values it was @@ -256,12 +235,24 @@ token to does need a public origin, and derives from it when no `mcp/public-url` is declared. Whether a runtime should derive the host's `/solutions/` route even for that is an open follow-up. -**Removed in this cutover, with the registrations they fed**: `SELF_UPSTREAM`, `HOST_REGISTER_URL`, `GATEWAY_REGISTER_URL`, +**Removed with the registrations they fed**: `SELF_UPSTREAM`, `HOST_REGISTER_URL`, `GATEWAY_REGISTER_URL`, `GATEWAY_MODULE_REGISTER_URL`, `GATEWAY_MODULE_REGISTRATION_TOKEN_URL`, `GATEWAY_SOLUTION_REGISTRATION_TOKEN_URL`, `CODEFLY_INTERNAL_TOKEN`, and `CODEFLY__SOLUTION_REGISTRATION_INTERVAL`. Setting any of them now does nothing. +**Removed with the in-cell plain-HTTP cutover**: `CODEFLY__CREDENTIAL_MINT_URL` +(the mint is the published route on the resolved gateway), the whole +`workload-identity` workspace-configuration group and its overrides +`CODEFLY__WORKLOAD_IDENTITY_CERT_FILE`, `_KEY_FILE`, `_TRUST_BUNDLE_FILE`, +`_ALLOWED_CALLERS_FILE`, `_MINT_PEERS_FILE` and `_GATEWAY_PEERS_FILE` (no +workload serves or presents an X.509-SVID, and no peer file admits anybody — +the mesh does), and the `CODEFLY__SELF_ENDPOINT__…` carrier as an input (the +advertised origin was reported with the mint, and nothing is reported). The +projected token's `CODEFLY__WORKLOAD_TOKEN_FILE` override stays; its group does +not. `Server.Identity`, `IdentitySource` and `PeerAnchorSource` are gone from +the API with the listener they configured. + `CODEFLY_HOST_FRONTEND` is **not** in that list, and this said it was. It went with the registrations and then came back for one consumer, the same way `PUBLIC_URL` did: it names the host's frontend service, whose resolved URL is @@ -287,243 +278,73 @@ empty by default and only needs setting to disambiguate a composition where resolves to nothing and the runtime fails loud at boot rather than picking a host arbitrarily. Concrete addresses always come from the SDK. -## The workload identity - -The listener presents the X.509-SVID issued to this workload, over TLS 1.3, and -there is no plain-HTTP listener. A configuration that cannot produce an identity -is refused at boot, naming the missing material: a solution that came up on -plain HTTP would be refused at the edge instead, for a reason only the edge can -see, which is the failure the delivered-presence model exists to end. - -The default source is the pair the platform projects at the two paths above, -read through the SDK's certificate reloader: it re-reads the files when they -change and keeps serving the last good pair through a half-written replacement. -That matters more here than anywhere else, because workload leaves are -deliberately short-lived and rotated well before expiry — a process that loaded -its leaf once at boot serves a stale leaf while a valid one sits on disk, and -then fails every handshake with the issuer reporting nothing wrong. - -Issuing an identity is the platform's job, not this runtime's, so the source is -a hook: - -```go -solution.New(manifest).Identity(mySPIFFEWorkloadAPI{}).Serve() -``` - -An `IdentitySource` returns the `*tls.Config` the listener serves with, so an -issuer with its own rotation, revocation or peer-verification rules keeps them. -A source that returns no certificate is refused rather than started: a listener -that completes no handshake reports a TLS error naming nothing to every caller. - -**The listener authorises its callers, not merely authenticates them.** Every -workload in the trust domain holds a certificate from the same anchor — -including the modules this solution consumes — so verifying one answers "did the -platform issue this identity", not "may this caller call me". The admitted set -is therefore declared and provisioned (`workload-identity`/`ALLOWED_CALLERS_FILE`) -and the boot is refused when it is absent, naming the value; the comparison is -the SPIFFE ID in the caller's URI SAN. Without it a consumed module could call a -handler or a passthrough route directly, bypassing the admission the host -decides on its own routes, and — because `x-org-id` and `x-session-id` are read -as headers the gateway stamped from a verified bearer — set them itself and have -this runtime mint capabilities under its attestation for an organization and -session nobody authenticated. A confused deputy, where the deputy is the one -process the issuer trusts to say which module is asking. The refusal names the -identity that arrived and never the admitted set. - -**The listener authenticates its callers, and so does the identity it presents -outward.** It requires and verifies a caller's certificate against the projected -anchor — not optionally, and not when one happens to be configured: a listener -that verifies no peer accepts anything that can route to the pod, which bypasses -the admission and exposure decisions the host makes on its own routes. An absent -anchor is a boot refusal naming the value; an unusable one fails the handshake -rather than falling back, because judging callers by a stale anchor lets in who -should be refused, which is not symmetric with serving a stale leaf. Peer trust -is re-read **per handshake**, so removing a compromised root from the bundle -stops it authenticating new callers without a restart. - -And every **established** connection is re-verified too, once a second, in both -directions — the caller's chain against the current anchor and the current -admitted set, the platform's against the same. Re-reading trust per handshake -bounds nothing about a connection that has already handshaken: a caller removed -from the admitted set, or whose issuing root was pulled, kept the keep-alive -connection it already held and kept being served on it, and so did an outbound -connection carrying a request every 100ms. An idle timeout cannot bound a -connection that is never idle. A connection whose peer stops verifying is closed -— busy or idle, request or stream — and nothing else closes it: a failure to -re-read *this* workload's own key pair is not a judgement about the peer and -leaves established connections alone. The listener also sets a handshake and -header deadline and an idle timeout, so a peer that connects and then stalls -mid-`ClientHello` no longer holds a goroutine and a descriptor indefinitely; -neither bounds a whole request, because a declared long-running stream is -conforming traffic. - -**A consumer's `IdentitySource` that sets `GetConfigForClient` must implement -`PeerAnchorSource`** — whatever it carries on the base configuration. Re-verifying an established caller, and -verifying the platform this runtime dials, both need the anchor *as it is now* — -and if the only way to get it is the source's `GetConfigForClient`, the runtime -would have to call it with a `ClientHelloInfo` nobody sent. That is the defect -this cutover was already blocked on one field along: a source keyed on the hello -answers a synthetic probe with one thing and the real handshake with another, -demonstrably. So the source is asked directly — -`PeerAnchor() (*x509.CertPool, error)` — and one that can only answer through a -handshake is refused at boot. Carrying a pool on the base as well does not -answer it: that pool is one object fixed when the source built it, so it would -freeze the anchor this process judges by while the callback went on resolving a -fresh one — which is exactly what happened when the refusal only fired on a nil -base pool. A source with no callback at all may carry its anchor on the -configuration and needs nothing extra. The projected source implements the -interface. - -Nor may a source set `VerifyPeerCertificate`: it is handed the parsed chain this -runtime reads the caller's identity from, and it runs first, so a source that -rewrote the leaf's URI SANs decided who was admitted. The identity is read from -a fresh parse of the raw DER regardless — the bytes the peer signed are the only -thing that cannot be edited between the handshake and the check — and the field -is refused as well. - -The same fabrication is still used for the outbound **certificate**, and that is -sound for a reason worth stating: whatever comes back is held to the frozen -principal at the moment it is presented, so a source answering differently from -the real handshake is caught by that hold. An anchor has no check behind it — it -*is* the check. - -**An outbound TLS handshake carries its own 10-second deadline.** A custom -`DialTLSContext` takes `net/http` out of the handshake, so -`Transport.TLSHandshakeTimeout` does nothing and the only bound would be the -caller's context — which is right for a request and wrong for the one place it -matters, since the client that carries streams sets no timeout on purpose. A -handshake is not a stream. - -**An admission set answered twice is refused, not ranked.** The env override and -the platform's provisioning are two sources for one authorization fact; if both -answer, the boot fails naming both. Logging which one won reports the conflict -without resolving it, and for admission the cost of picking wrong is admitting a -caller. The other `workload-identity` paths still take the override first: the -worst case there is this process reading its own material from elsewhere. - -**This listener does not resume TLS sessions.** A resumed connection presents -**no certificate** — the peer is accepted on a ticket — and this listener's -whole model is that a caller's certificate names them and the provisioned set -decides whether that name may call. So anything able to forge a ticket is able -to be admitted as whoever it was issued to. A review demonstrated it: with a -source-supplied ticket key, a client holding only that key and an admitted -caller's ticket resumed as that caller, presenting nothing, and the per-second -recheck then re-verified the stolen certificate and kept the connection open. -A denylist could not close that, because `SetSessionTicketKeys` installs keys -that cannot be read back off the configuration — so resumption is turned off on -the configuration served and on every per-connection answer. It costs one round -trip on a reconnect. - -**An admission entry must be a whole line.** The file is read per handshake and -per dial, so it is read while the platform may be rewriting it: a file caught -mid-write truncated an entry, and the truncation was admitted as an identity of -its own (`…/sa/gateway-internal` read as `…/sa/gateway`, admitting a caller the -set never named). A file that does not end in a newline is refused, every entry -must be a SPIFFE ID, control characters and a byte-order mark are refused, and -the file is capped at 64 KiB because it is re-read once a second per established -connection in each direction. - -**A credential-bearing URL carries a destination and nothing else.** Userinfo, a -query and a fragment are refused at boot on both the gateway and the mint: -credentials in a URL are a secret that gets logged and, for userinfo, that -net/http strips before the request is sent, so it is disclosed and never used. -Both URLs are logged through `url.Redacted()` with the query dropped regardless, -because a log line is the wrong place to depend on a check that runs elsewhere. - -**No credential-bearing request follows a redirect, and none leaves the -gateway's own origin.** Go copies a request's headers onto a redirected one and -strips only `Authorization`, `WWW-Authenticate` and `Cookie`; a 307 re-sends the -body; and this runtime sets the bearer per round trip, which puts back the one -header Go strips. So a single `307 Location: http://elsewhere/` would hand a -third party the viewer's bearer, the capability minted for them and this -workload's own credential, in cleartext — nothing about an `http://` hop involves -the TLS configuration that protects the first one. Every client is therefore -built with `ErrUseLastResponse` (a 3xx is reported, never taken), and the -transport refuses any destination that is not the gateway's origin, so a client -built later without the policy is still stopped. - -**Outbound trust is re-read per connection, not snapshotted at boot** — the same -rule as inbound, for the same reason, and it applies with more force: the two -destinations on the other side of that client receive the projected -service-account token, the viewer's bearer and this workload's own credential, so -a root removed because it was compromised must stop authenticating them without a -restart. Each dial builds its own configuration — and each -established connection re-verifies the peer it already has, once a second, -against the current anchor and the current the destination's own provisioned set, closing it when -that stops holding. - -The recheck is there because the idle cap it replaces was not a bound. An -inactivity timeout bounds a connection nobody is using; a connection carrying a -request every 100ms never becomes idle, and one was demonstrated still answering -31 seconds after its server's root was removed from the bundle, over a single -handshake. A stream is the same shape by construction, held open for as long as -its declared `MaxStreamDuration` allows. Re-verifying rather than imposing a -maximum connection age is deliberate: a maximum age would cut a conforming -30-minute stream to answer a question that can be answered without cutting it, -since the chain the peer presented is already in hand. A peer that no longer -verifies loses the connection, in flight or idle, stream or request. - -**And the destination is authorised, not just authenticated.** Verifying the -chain and the hostname says the cell issued a certificate for the address this -runtime dialled, which every workload in the cell holds one of — a neighbouring -workload answering at the gateway's address under a valid certificate was handed -the projected token, the viewer's bearer and this workload's credential. So the -peer's own SPIFFE ID must be in that destination's provisioned set. The outbound leaf is held to -the frozen principal too, at the moment it is presented, which matters because -on the default path it comes from a second reloader over the same files as the -listener's. - -The same identity goes out. Every platform request — the mint, and every call a -handler's gateway makes — presents this workload's X.509-SVID and verifies the -far end against the same anchor, because an `https` URL on its own says only -that the scheme is https: without an anchor the far end is checked against -whatever the image's system roots hold, and without a client certificate it -cannot tell this workload from anything else that reached it. Nothing is -proxied, and no redirect is followed. - -**The certificate served is the certificate checked.** The pair the platform -projects and the principal it provisioned are two facts nothing else compares, -so this runtime compares them: the SPIFFE ID in the leaf's URI SAN must equal -`module-authority/PRINCIPAL`. A pair projected for a neighbouring workload — the -wrong Secret mounted, a Certificate issued for another service — would otherwise -be served happily, and the mismatch would surface at whatever verifies this -destination, as a refusal naming neither file. - -The comparison is made on the pair a handshake actually selected, not on a -sample. A boot-time check that called `GetCertificate` once and approved the -configuration was demonstrated passing four configurations that then served -another workload's leaf: a callback keyed on the server name answering a probe -and a real hello differently, a `NameToCertificate` map, a static -`Certificates[0]` that Go reaches without consulting the callback at all when -the caller sends no SNI, and a `tls.Certificate.Leaf` that did not describe its -own DER. So the selection surface is narrowed to one certificate (a -`NameToCertificate` map or a list of several is refused — a workload has one -X.509-SVID), the DER is parsed rather than `Leaf` believed, and the callback is -wrapped so that what it returns for this handshake is what is compared. A -boot-time refusal remains, asked with the empty hello a peer addressed by IP -really sends, and it can only refuse a boot — never approve one. - -Every one of those applies to a consumer-supplied `IdentitySource` too, checked -on what it returns: a certificate, a TLS 1.3 floor, and a caller it -authenticates. Checking only that a certificate came back left the floor and -peer authentication silently optional for exactly the consumers who wrote their -own integration. - -It is checked **per connection** as well as at boot. A `*tls.Config` may carry a -`GetConfigForClient` callback, and the configuration that callback returns -replaces the base one for that handshake — floor, peer requirement and -certificate included — so a source that conforms at boot could answer each -individual connection with TLS 1.2, no peer authentication, or a neighbouring -workload's leaf. This is the documented shape here rather than an odd one: the -projected source uses that callback to re-read peer trust. So each returned -configuration is held to the same posture and the same frozen principal, and one -below it fails that handshake instead of serving it weakened. A callback that -returns `nil` is Go's "serve the base configuration", and passes through only -when that base really satisfies the posture on its own: a source answering for -one hello and not another was otherwise serving a listener that requires a -caller's certificate and verifies it against the host's system roots, which is -mutual TLS in every log line and a client certificate from any public CA -admitted. +## Plain HTTP inside the cell + +Past the host's auth gateway, every hop inside a cell is plain HTTP, and the +cell's mesh — istio ambient: ztunnel mTLS, the workload's identity being its +ServiceAccount's SPIFFE ID — carries transport security and destination +identity. TLS ends at the edge. So this listener is plain TCP on the port +Codefly resolved, presents no certificate and verifies no caller: the mesh +admits the solution's port only from the gateway's principal, and a listener +that re-decided either would be a second copy of a decision the cell makes for +every workload. The SPIFFE ID a presence document names for this workload is +that mesh principal, not an SVID this process presents. + +This reverses what the previous generation did. It served TLS 1.3 under an +X.509-SVID the platform projected, required and verified its callers' +certificates against a projected anchor, admitted them by a provisioned +`ALLOWED_CALLERS_FILE`, held two more provisioned sets per outbound +destination, re-verified every established connection once a second in both +directions, and refused session resumption, `GetConfigForClient` answers below +the posture, and a consumer `IdentitySource` that fabricated its anchor. All of +it is deleted, not fenced — `identity.go`, its tests, the peer files and the +SVID loading — because every one of those checks was this process re-deciding +what the mesh decides, and a process that decides it twice has two answers. + +What this process still decides is what a caller may do once admitted, and +where its own credentials may travel: + +- **The stamped identity headers are the viewer.** `x-org-id`, `x-session-id` + and `x-codefly-installation-id` are read as the gateway stamped them from + verified claims; a request reaching this port came through the gateway, or + the mesh would not have carried it. +- **A plaintext destination is admitted only where the mesh is.** The gateway + URL — and the mint derived on it — is held to the SDK's one transport rule, + `workcontext.AdmitEndpoint`, the same rule the composed modules apply to + their own gateway value: `https` to any host; `http` to a loopback host on a + local run, or to an in-cluster Service address + (`..svc[.]`) when the composition asserts + `internal-transport/mesh-protected=true`; anything else is a boot refusal + naming the rule. The assertion is read once, in `loadConfig`, and answered + from that snapshot wherever the rule asks — at validation and inside every + mint — because every environment read sits in `loadConfig`'s call tree and a + configuration value is fixed at process start anyway. Only the literal + `true` asserts it; a misspelled assertion is refused rather than read as + either answer. +- **A credential-bearing URL carries a destination and nothing else.** + Userinfo, a query and a fragment are refused at boot by the same rule, and + the boot log redacts the URL regardless, because a log line is the wrong + place to depend on a check that runs elsewhere. +- **No credential-bearing request follows a redirect, and none leaves the + gateway's own origin.** Go copies a request's headers onto a redirected one + and strips only `Authorization`, `WWW-Authenticate` and `Cookie`; a 307 + re-sends the body; and this runtime sets the bearer per round trip. So a + single `307 Location: http://elsewhere/` would hand a third party the + viewer's bearer, the capability minted for them and this workload's own + credential. Every client is built with `ErrUseLastResponse`, the transport + refuses any destination that is not the gateway's origin, and nothing is + proxied — the default transport's `ProxyFromEnvironment` would dial these + requests to an arbitrary egress host under a `NO_PROXY` that misses the + in-cluster name. +- **The listener bounds a stalled peer.** A request's headers must arrive + within ten seconds and a keep-alive connection nobody uses is closed after + thirty; neither bounds a whole request, because a declared long-running + stream is conforming traffic. + +An `https` gateway is still accepted — an operator pointing `GATEWAY_URL` at +an edge presenting a public certificate — and is verified against the system +roots, which is the right anchor for a public edge and was the wrong one for +the in-cell SVID world this replaces. ## One credential per execution @@ -569,12 +390,14 @@ is waited for, within a bound.** The two are different answers: unusable are terminal too. They stop renewal and fail health. Status, status class and transport category survive; raw body, headers, parser diagnostics and causes do not. -Every mint makes a fresh TLS 1.3 handshake, presents this workload's identity, -checks `MINT_PEERS_FILE` and the trust anchor before and after the handshake, -and refuses a server that did not request a client certificate. Redirects, -proxies and connection reuse are disabled. The SDK still owns certificate -reloading and authority resolution. `CredentialSource` remains SDK-compatible -for consumers supplying their own issuer. +Every mint is one plain-HTTP request to the host's broker route on the +gateway's workload endpoint, over a connection dialled for it and closed after +it, pinned to that one origin. The mesh carries the hop and names this workload +to the host; the projected token attests which workload asks, and the +perimeter secret travels beside it. Redirects, proxies and connection reuse are +disabled, and the URL is held to the SDK's transport rule before anything is +sent. The SDK owns authority resolution. `CredentialSource` remains +SDK-compatible for consumers supplying their own issuer. The bound is what keeps the second case from being the heartbeat again: it is a boot waiting for a dependency, with no steady state, nothing minted on success @@ -765,20 +588,23 @@ document names the route and the destination, and the host resolves both. failed to load. The origin a browser reaches this solution through is the host's route, and naming the host's route layout here would couple every solution to it. -- The **upstream the gateway dials** is in the presence document, not in a - registration. `SELF_UPSTREAM` and the self-endpoint carrier it fell back to - are gone. A deployed solution used to register its own loopback listen - address, boot, look healthy, and be proxied by the gateway to the gateway - itself while the product reported it as failed to load; the refusal that - caught that is gone with the registration that needed it. +- The **address the gateway dials** is in the presence document's endpoints, + resolved by the gateway through the SDK from the carrier the CLI hands it, + exactly as for a module. Nothing here reports it: the advertised origin this + runtime used to send with its mint, and the `CODEFLY__SELF_ENDPOINT__…` + carrier it read it from, are gone with the report. A deployed solution once + registered its own loopback listen address and was proxied by the gateway to + the gateway itself; an address the composition wrote cannot be that. This solution still publishes what it is, at fixed paths, for anything that asks: `/.well-known/solution.json` (the manifest: nav, the exposed module, the federation manifest path, the backend's service alias, declared surfaces and an -optional dashboard), `/.well-known/capabilities` (the contract id and the -majors), `/.well-known/module-contract` (the authority contract above) and -`/health`. Publishing is not announcing — nothing is pushed, and answering -makes this solution present to nobody. +optional dashboard, all from [the declared frontend](#the-declared-frontend)), +`/.well-known/capabilities` (the contract id and the majors), +`/.well-known/module-contract` (the authority contract above) and `/health`. +Publishing is not announcing — nothing is pushed, and answering makes this +solution present to nobody: the host builds its registry entry from the +delivered presence document and its own constants, never from this route. `/health` **asks the credential** rather than reporting only what some other request happened to discover. Renewal is lazy by design, so a solution serving @@ -804,12 +630,64 @@ nothing and looks alive, which is what the delivered-presence model replaced the heartbeat to avoid. An *unavailable* mint is not a judgement and deliberately does not do this: exiting on a transient dependency failure is a crash loop. -The manifest declares the contract majors it is built against -(`schemaVersion: 1`, `frontend.hostContract: 1`) rather than leaving a reader to -assume them, and the capability document declares the same majors from the same -constants — so a bump cannot leave one document announcing the old major while -the other announces the new one. +The manifest declares the majors it is built against rather than leaving a +reader to assume them — `schemaVersion: 1`, this runtime's manifest schema, and +`frontend.hostContract`, the host contract the author declared — and the +capability document declares the same majors from the same places, so a bump +cannot leave one document announcing the old major while the other announces +the new one. + + +### The declared frontend + +A solution declares what the host lists it under in **one file**, +`solution.frontend.codefly.yaml`, beside the backend's `service.codefly.yaml`. +The backend embeds it — `//go:embed solution.frontend.codefly.yaml` into +`Manifest.Frontend` — and serves its manifest from it; the CLI reads the same +file into the presence document's `frontend` block (core's +`solutionhost.Frontend`), which is what the host's registry projects. Two +readers of one file cannot disagree, where a Go field beside a file could, and +nothing is reported at mint any more. A boot without the file is refused +naming it; so is a file the CLI would refuse. + +```yaml +title: Widgets # nav title; one non-empty line +order: 10 # nav order; declared, zero included +exposed_module: ./Page # the Module Federation module the host loads the page from +host_contract: 1 # the host page-contract major the page is built against; >= 1 +dashboard: # optional: a mapping the host's data-graph validator reads, + events: [...] # served and delivered as JSON, at most 64 KiB encoded +surfaces: # optional: what the solution offers inside a client + - id: footnote # lowercase letters and digits, dashes between; unique per client + client: word # word, powerpoint, excel or slack + title: Footnote + description: Cite a claim. # optional + module: /surfaces/word/footnote.js # a rooted path on this solution's own origin + contract: 1 # the surface contract major; >= 1 + applies: always # declared: "always", or {tagged: [one or more tags]} + events: [documents.entry] # optional; journal namespaces the surface reconciles on +``` +The file is YAML, decoded strictly — an unknown key is refused by name — and +its top-level mapping is the block itself, with no wrapper and no schema key: +its keys are core's field names, field for field. Every scalar is declared, +because core refuses an omitted one rather than reading it as zero. The +dashboard is opaque to this runtime but has to be the JSON object the host +reads: a mapping, every key a string, no fraction (JSON spells those more than +one way, and the presence document is canonical bytes), bounded at 64 KiB +encoded. A surface declares its `applies` selector — `always`, or +`{tagged: [...]}` naming one or more tags — because core carries no default, +and names each of its `events` once, as a lowercase dotted journal namespace +(segments of letters and digits separated by a dot, dash or underscore). What +a tag selects and what a namespace names are the client's to interpret; the +shapes are what core signs. + +`Manifest` keeps `ID`, `Contract` and `Capabilities` as Go fields: the id is +what the presence document names the solution by, and the capability handshake +at `/.well-known/capabilities` is the backend's own, which the frontend block +does not carry. `Title`, `Order`, `ExposedModule`, `Dashboard` and `Surfaces` +are gone from it with the `Surface` type; the MCP metadata document's +`resource_name` is the declared title. ### Serving the frontend @@ -1174,7 +1052,7 @@ message of it). #### Testing the passthrough: `passthroughtest` `Serve` resolves the gateway and api.consumes from the composition, mints this -execution's credential and listens with this workload's identity, so a solution +execution's credential and listens on the resolved port, so a solution cannot run its own passthrough in a test through it. Package `github.com/codefly-dev/solution-runtime-go/passthroughtest` serves the real passthrough — the handler `Serve` mounts, behind @@ -1205,11 +1083,11 @@ the test's stand-in does. **This seam is reachable only from here.** `Server.PassthroughHandler` was exported until this change, and that made it a production bypass: a solution could build a usable, credential-bearing handler that had skipped `validate()`, -the mTLS boot, the caller allow-list, the published ceiling and authenticated -outbound — a deployment calling it completed a viewer mint and a module call -over plaintext and answered 200, with the viewer's bearer and this workload's -own credential on the wire. Supplying a credential source, which a deployment -does, defeated the "no source, nothing to mint with" mitigation. It is reached +the boot, the published ceiling and the origin-pinned outbound client — a +deployment calling it completed a viewer mint and a module call and answered +200, with the viewer's bearer and this workload's own credential sent wherever +it was mounted. Supplying a credential source, which a deployment does, +defeated the "no source, nothing to mint with" mitigation. It is reached through `internal/seam` now, which Go's internal-package rule keeps inside this module. The fake host is still plaintext and still has no contract, and those are now properties of this module's own tests rather than of an API. A consumer @@ -1232,7 +1110,7 @@ person, through the host's gateway, with the same authority. The solution owns its tool surface; this runtime owns serving it. These are the three lines: ```go -solution.New(solution.Manifest{ID: "wiki", Title: "Wiki"}). +solution.New(solution.Manifest{ID: "wiki", Frontend: frontend}). // the embedded solution.frontend.codefly.yaml ServeMCP("wiki", "v1.0.0", func(srv *mcp.Server) { mcp.AddTool(srv, &mcp.Tool{Name: "ask_wiki", Description: "ask the wiki a question"}, askWiki) }). @@ -1411,9 +1289,9 @@ this cutover deleted. It was exported once, as `Server.MCPHandler`, and unexporting it was not a tidy-up: an exported method handing back a servable handler built without the boot is the `PassthroughHandler` incident verbatim — a handler that has skipped -`validate()`, the mTLS boot, the caller allow-list, the published ceiling and -authenticated outbound, carrying the viewer's bearer and this workload's own -credential over whatever it is mounted on. `TestNoExportedPathHandsOutAServableHandler` +`validate()`, the boot, the published ceiling and the origin-pinned outbound +client, carrying the viewer's bearer and this workload's own credential over +whatever it is mounted on. `TestNoExportedPathHandsOutAServableHandler` fails if it comes back. So the example is removed rather than repaired, and restoring the export to make it compile is the one fix that is not available. @@ -1433,11 +1311,11 @@ the test's stand-in does. **This seam is reachable only from here.** `Server.PassthroughHandler` was exported until this change, and that made it a production bypass: a solution could build a usable, credential-bearing handler that had skipped `validate()`, -the mTLS boot, the caller allow-list, the published ceiling and authenticated -outbound — a deployment calling it completed a viewer mint and a module call -over plaintext and answered 200, with the viewer's bearer and this workload's -own credential on the wire. Supplying a credential source, which a deployment -does, defeated the "no source, nothing to mint with" mitigation. It is reached +the boot, the published ceiling and the origin-pinned outbound client — a +deployment calling it completed a viewer mint and a module call and answered +200, with the viewer's bearer and this workload's own credential sent wherever +it was mounted. Supplying a credential source, which a deployment does, +defeated the "no source, nothing to mint with" mitigation. It is reached through `internal/seam` now, which Go's internal-package rule keeps inside this module. The fake host is still plaintext and still has no contract, and those are now properties of this module's own tests rather than of an API. A consumer diff --git a/boot_test.go b/boot_test.go index bd2df16..58714ef 100644 --- a/boot_test.go +++ b/boot_test.go @@ -3,22 +3,13 @@ package solution import ( "cmp" "context" - "crypto/ed25519" - "crypto/rand" - "crypto/tls" - "crypto/x509" - "crypto/x509/pkix" "encoding/json" - "encoding/pem" "errors" "fmt" "io" - "math/big" "net" "net/http" "net/http/httptest" - "net/http/httptrace" - "net/url" "os" "path/filepath" "strings" @@ -27,6 +18,7 @@ import ( "testing" "time" + "github.com/codefly-dev/core/resources" "github.com/codefly-dev/core/solution/manifest" corework "github.com/codefly-dev/core/workcontext" codefly "github.com/codefly-dev/sdk-go" @@ -47,11 +39,14 @@ const ( testSolutionTitle = "Widgets" testPrincipal = "spiffe://codefly.test/ns/solutions/sa/widgets" - testGatewayPrincipal = "spiffe://codefly.test/ns/platform/sa/gateway" testAudience = testSolutionID testProjectionAudience = "accounts" ) +// testFrontend is the declared frontend file every booted fixture embeds: the +// file FrontendFileName names, as an author writes it. +const testFrontend = "title: " + testSolutionTitle + "\norder: 1\nexposed_module: ./Page\nhost_contract: 1\n" + // The sealed values the stand-in host issues against are core's own conformance // values: the credential is minted by core's authority from core's fixture // identities, so the seal this runtime logs and a test asserts is the live one @@ -91,12 +86,9 @@ type hostMint struct { // workload the host refuses, 503 for an issuer that has nothing to mint // against yet. status int - // callers records the SPIFFE identity each request presented, so a test - // can assert the runtime named itself on the way out as well as in, and - // paths records what was asked for, so a surviving registration is visible - // as the path it would have used. - callers []string - paths []string + // paths records what was asked for, so a surviving registration is + // visible as the path it would have used. + paths []string // answer, when set, decides each workload mint in place of status and // recoverAfter: the status to answer, or zero to issue. It is called on // the host's goroutines, so what it reads must be safe to read there. @@ -112,18 +104,6 @@ type hostMint struct { // through the real client rather than by calling the wrapper directly. switchTo *hostMint switchAfter int64 - // cell is the anchor this mint's own identity is issued under, and the one - // a caller's certificate is verified against. Set by every constructor, - // because the mint endpoint now requires a client certificate and is - // itself admitted by the SPIFFE ID in its single URI SAN. - cell *cell - // caller is the leaf a test's own mint client presents, issued under the - // same anchor. The booted path presents the runtime's projected pair - // instead; this is for the unit-level clients. - caller *tls.Certificate - // cellRoots is set when serveTLS re-serves this mint under a cell's - // anchor, so roots() answers that rather than httptest's own certificate. - cellRoots *x509.CertPool // digest and incarnation override the build this host attests, so a test // can have a RENEWAL seal a different execution than the first credential. digest string @@ -265,62 +245,13 @@ func newHostMint(t *testing.T, mint *hostMint) *hostMint { if mint.switchTo != nil { mint.switchTo.prepareAuthority(t) } - // TLS, not plaintext, and under a cell of its own. The SDK's mint client - // refuses plain HTTP outright — the projected service-account token - // travels on that request — and httptest's shared certificate carries no - // SPIFFE ID, so a client that admits the endpoint by identity cannot - // accept it. A booted test calls serveTLS afterwards to move this host - // under the boot's anchor instead. - mint.serveUnder(t, newCell(t)) + // Plain HTTP on loopback: what the gateway's workload endpoint is inside a + // cell, and what the SDK's rule admits on a local run. + mint.Server = httptest.NewServer(http.HandlerFunc(mint.serve)) + t.Cleanup(mint.Close) return mint } -// serveUnder serves this fake mint under one cell: its own identity from that -// anchor, and the caller's certificate REQUIRED and verified against it. -// -// Required, not "verified if given". The fixture was weakened to -// VerifyClientCertIfGiven when the SDK owned the whole transport and presented -// nothing, and a reviewer was right that this established the changed -// behaviour rather than closing it: a fake host that tolerates an anonymous -// caller cannot tell a runtime that presents its X.509-SVID from one that does -// not. sdk-go#51 gives the client a ClientCertificate reader, so the fixture -// goes back to demanding what a real mint endpoint demands. -func (m *hostMint) serveUnder(t *testing.T, c *cell) { - t.Helper() - m.cell, m.cellRoots = c, c.roots - m.caller = c.identity(t, testPrincipal) - m.Server = httptest.NewUnstartedServer(http.HandlerFunc(m.serve)) - m.TLS = &tls.Config{ - MinVersion: tls.VersionTLS13, - Certificates: []tls.Certificate{*c.identity(t, testGatewayPrincipal)}, - ClientCAs: c.roots, - ClientAuth: tls.RequireAndVerifyClientCert, - } - m.StartTLS() - t.Cleanup(m.Close) -} - -// serveTLS restarts this host under the cell's anchor, presenting an identity -// of its own and requiring the caller's — which is what the runtime's outbound -// client expects, because an https URL alone says only that the scheme is -// https. -// roots is the pool that may sign this fake mint's certificate, which the SDK -// now requires and will not default to the system pool for. -func (m *hostMint) roots() (*x509.CertPool, error) { - if m.cellRoots != nil { - return m.cellRoots, nil - } - pool := x509.NewCertPool() - pool.AddCert(m.Certificate()) - return pool, nil -} - -func (m *hostMint) serveTLS(t *testing.T, c *cell) { - t.Helper() - m.Close() - m.serveUnder(t, c) -} - // execution is the build this host attests for the credential it mints, or // nothing at all when the test asks for an execution-free one. // installationFor is the installation the shape in force seals to. @@ -345,7 +276,6 @@ func (m *hostMint) serve(w http.ResponseWriter, r *http.Request) { m.mu.Lock() m.presented = append(m.presented, r.Header.Get("authorization")) m.headers = append(m.headers, r.Header.Clone()) - m.callers = append(m.callers, callerIdentity(r)) m.paths = append(m.paths, r.URL.Path) m.mu.Unlock() // The gateway this harness stands in for routes the viewer's mint as well @@ -364,7 +294,7 @@ func (m *hostMint) serve(w http.ResponseWriter, r *http.Request) { } if r.URL.Path == "/modules/_work-context" { var body map[string]string - if r.Method != http.MethodPost || r.Header.Get("Content-Type") != "application/json" || r.Header.Get("Accept") != "application/json" || r.Header.Get("X-Codefly-Internal-Token") != "broker-perimeter-token" || !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") || json.NewDecoder(r.Body).Decode(&body) != nil || body["prefix"] != testSolutionID || !onlyBrokerBodyKeys(body) { + if r.Method != http.MethodPost || r.Header.Get("Content-Type") != "application/json" || r.Header.Get("Accept") != "application/json" || r.Header.Get("X-Codefly-Internal-Token") != "broker-perimeter-token" || !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") || json.NewDecoder(r.Body).Decode(&body) != nil || body["prefix"] != testSolutionID || len(body) != 1 { http.Error(w, "invalid broker request", http.StatusBadRequest) return } @@ -430,143 +360,6 @@ func (m *hostMint) observedStartTasks() []string { return asked } -// callerIdentity is the SPIFFE ID the caller's own certificate named, or "" on -// a plain connection. -func callerIdentity(r *http.Request) string { - if r.TLS == nil || len(r.TLS.PeerCertificates) == 0 || len(r.TLS.PeerCertificates[0].URIs) == 0 { - return "" - } - return r.TLS.PeerCertificates[0].URIs[0].String() -} - -// cell is one deployment's trust anchor: the root every identity in a test is -// issued under — this workload's, its callers', and the platform hosts it -// dials. One anchor on purpose, because that is the shape the runtime assumes: -// the pair its listener presents and the anchor it verifies peers with are also -// what it presents and verifies on the way out. -type cell struct { - anchorPEM []byte - anchor *x509.Certificate - anchorKey ed25519.PrivateKey - roots *x509.CertPool -} - -func newCell(t *testing.T) *cell { - t.Helper() - pem, leaf, key := issueAnchor(t) - roots := x509.NewCertPool() - roots.AddCert(leaf) - return &cell{anchorPEM: pem, anchor: leaf, anchorKey: key, roots: roots} -} - -// workload writes the X.509-SVID the platform projects for a workload — a leaf -// whose URI SAN is its SPIFFE ID, which is what names a workload in this model -// and what the listener is held to — plus the anchor it was issued under, which -// is also the anchor this listener verifies its callers against. -// -// It returns a caller certificate too, because the listener requires one: a -// test that could reach this solution without presenting an identity would be -// exercising a listener this runtime refuses to start. -func (c *cell) workload(t *testing.T, spiffeID string) (certFile, keyFile, bundleFile string, caller *tls.Certificate, roots *x509.CertPool) { - t.Helper() - dir := t.TempDir() - bundleFile = filepath.Join(dir, "ca.crt") - writeFile(t, bundleFile, string(c.anchorPEM)) - - certFile, keyFile = filepath.Join(dir, "tls.crt"), filepath.Join(dir, "tls.key") - leafPEM, keyPEM, _ := issueLeaf(t, c.anchor, c.anchorKey, spiffeID) - writeFile(t, certFile, string(leafPEM)) - writeFile(t, keyFile, string(keyPEM)) - - return certFile, keyFile, bundleFile, c.identity(t, testGatewayPrincipal), c.roots -} - -// identity is a TLS certificate for one SPIFFE ID, for a party in a test that -// serves or dials rather than reading projected files. -func (c *cell) identity(t *testing.T, spiffeID string) *tls.Certificate { - t.Helper() - certPEM, keyPEM, _ := issueLeaf(t, c.anchor, c.anchorKey, spiffeID) - pair, err := tls.X509KeyPair(certPEM, keyPEM) - if err != nil { - t.Fatal(err) - } - return &pair -} - -// workloadIdentity is one workload in a cell of its own, for a test that does -// not need the platform's side of the anchor. -func workloadIdentity(t *testing.T, spiffeID string) (certFile, keyFile, bundleFile string, caller *tls.Certificate, roots *x509.CertPool) { - t.Helper() - return newCell(t).workload(t, spiffeID) -} - -// issueAnchor is the trust anchor a cell's identities are issued under. -func issueAnchor(t *testing.T) ([]byte, *x509.Certificate, ed25519.PrivateKey) { - t.Helper() - public, private, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - t.Fatal(err) - } - template := &x509.Certificate{ - SerialNumber: big.NewInt(1), - Subject: pkix.Name{CommonName: "codefly.test workload CA"}, - NotBefore: time.Now().Add(-time.Minute), - NotAfter: time.Now().Add(time.Hour), - KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature, - BasicConstraintsValid: true, - IsCA: true, - } - der, err := x509.CreateCertificate(rand.Reader, template, template, public, private) - if err != nil { - t.Fatal(err) - } - leaf, err := x509.ParseCertificate(der) - if err != nil { - t.Fatal(err) - } - return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), leaf, private -} - -// issueLeaf issues one workload identity under the anchor: a leaf whose only -// identity is the SPIFFE ID in its URI SAN. -func issueLeaf(t *testing.T, anchor *x509.Certificate, anchorKey ed25519.PrivateKey, spiffeID string) (certPEM, keyPEM []byte, leaf *x509.Certificate) { - t.Helper() - public, private, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - t.Fatal(err) - } - id, err := url.Parse(spiffeID) - if err != nil { - t.Fatal(err) - } - template := &x509.Certificate{ - SerialNumber: big.NewInt(time.Now().UnixNano()), - Subject: pkix.Name{CommonName: spiffeID}, - NotBefore: time.Now().Add(-time.Minute), - NotAfter: time.Now().Add(time.Hour), - KeyUsage: x509.KeyUsageDigitalSignature, - ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, - DNSNames: []string{"localhost"}, - IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, - URIs: []*url.URL{id}, - } - der, err := x509.CreateCertificate(rand.Reader, template, anchor, public, anchorKey) - if err != nil { - t.Fatal(err) - } - keyDER, err := x509.MarshalPKCS8PrivateKey(private) - if err != nil { - t.Fatal(err) - } - parsed, err := x509.ParseCertificate(der) - if err != nil { - t.Fatal(err) - } - return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), - pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}), - parsed -} - // booted is a solution serving on an ephemeral port, with the client that // trusts its workload identity. type booted struct { @@ -574,9 +367,6 @@ type booted struct { base string client *http.Client mint *hostMint - // cell is the CA the listener verifies callers against, for a test that - // must issue another certificate it will accept. - cell *cell // stop ends this runtime and waits for serve to return, so a test can // observe what a SHUTDOWN does rather than only what a serving process // does. It is idempotent, and the cleanup calls it too — a test that stops @@ -590,45 +380,13 @@ type booted struct { } // bootEnvironment provisions everything a deployed solution's boot reads — the -// projected identity, the anchor, the projected token, the resolved gateway — -// and starts the fake host under that same anchor, because the runtime's -// outbound client presents its identity and verifies the host against it. +// projected token, the resolved gateway, the profile — against the fake host. // // Shared by every boot test, including the ones that must fail: a boot refused // for the reason under test has to be refused with everything else in place, // or the test is asserting the first missing value instead. -func bootEnvironment(t *testing.T, mint *hostMint) (caller *tls.Certificate, roots *x509.CertPool) { - t.Helper() - caller, roots, _ = bootCell(t, mint) - return caller, roots -} - -// bootCell is bootEnvironment with the cell exposed, for a test that must issue -// another certificate under the SAME anchor the listener verifies against — a -// forged leaf from any other cell is refused for its chain, which would answer -// the test before the property under test is reached. -func bootCell(t *testing.T, mint *hostMint) (caller *tls.Certificate, roots *x509.CertPool, cell *cell) { - t.Helper() - certFile, keyFile, bundleFile, caller, roots, cell := bootIdentityIn(t, mint, testPrincipal) - _, _, _ = certFile, keyFile, bundleFile - return caller, roots, cell -} - -// bootIdentity is bootEnvironment with the projected material exposed, for a -// test that supplies its own IdentitySource and must do so under the *same* -// anchor the fake host is served with — otherwise the boot fails verifying the -// host rather than on the property under test. -func bootIdentity(t *testing.T, mint *hostMint, principal string) (certFile, keyFile, bundleFile string, caller *tls.Certificate, roots *x509.CertPool) { - certFile, keyFile, bundleFile, caller, roots, _ = bootIdentityIn(t, mint, principal) - return -} - -func bootIdentityIn(t *testing.T, mint *hostMint, principal string) (certFile, keyFile, bundleFile string, caller *tls.Certificate, roots *x509.CertPool, issuing *cell) { +func bootEnvironment(t *testing.T, mint *hostMint) { t.Helper() - cell := newCell(t) - issuing = cell - certFile, keyFile, bundleFile, caller, roots = cell.workload(t, principal) - mint.serveTLS(t, cell) authorityValues(t) tokenFile := filepath.Join(t.TempDir(), "token") writeFile(t, tokenFile, "projected-token") @@ -638,23 +396,13 @@ func bootIdentityIn(t *testing.T, mint *hostMint, principal string) (certFile, k ln, port := boundPort(t) provideListener(t, ln) t.Setenv("PORT", port) + // The gateway's workload endpoint, which serves the mint beside every + // other route: one resolved origin, and the mint address derived on it. + // Plaintext to loopback, which the SDK's rule admits on a local run — and + // this process is one, as codefly run makes a developer machine one. t.Setenv("GATEWAY_URL", mint.URL) - // Set explicitly, as a deployment must: nothing derives a mint address - // from the resolved gateway, because the projected token goes there. - t.Setenv(CredentialMintURLEnvironmentVariable, mint.URL+credentialMintPath) + t.Setenv(resources.EnvironmentPrefix, "local") t.Setenv(ProjectedTokenFileEnvironmentVariable, tokenFile) - t.Setenv(IdentityCertFileEnvironmentVariable, certFile) - t.Setenv(IdentityKeyFileEnvironmentVariable, keyFile) - t.Setenv(IdentityTrustBundleFileEnvironmentVariable, bundleFile) - // Who may call: the gateway, which is the identity cell.workload hands back - // as the caller. Holding a certificate from the cell's anchor is not the - // same as being a caller this solution serves, so the admitted set is - // provisioned rather than derived from the anchor. - t.Setenv(IdentityAllowedCallersFileEnvironmentVariable, identitiesFile(t, testGatewayPrincipal)) - // And whom this runtime may present credentials to: the same identity, for - // the mirror-image reason. The fake host serves under it. - t.Setenv(IdentityMintPeersFileEnvironmentVariable, identitiesFile(t, testGatewayPrincipal)) - t.Setenv(IdentityGatewayPeersFileEnvironmentVariable, identitiesFile(t, testGatewayPrincipal)) t.Setenv(ContractProfileEnvironmentVariable, localProfile) // A test that serves assets provisions the directory before booting, since // a boot's configuration is read by the serving goroutine and must not be @@ -662,14 +410,13 @@ func bootIdentityIn(t *testing.T, mint *hostMint, principal string) (certFile, k if os.Getenv("ASSETS_DIR") == "" { t.Setenv("ASSETS_DIR", t.TempDir()) } - return certFile, keyFile, bundleFile, caller, roots, issuing } // boot runs the whole boot a deployed solution runs — configuration, the // contract, the one mint, the TLS listener — on an ephemeral port. func boot(t *testing.T, server *Server, mint *hostMint) *booted { t.Helper() - caller, roots, cell := bootCell(t, mint) + bootEnvironment(t, mint) ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) ln, err := takeListener(server).start(ctx) @@ -705,18 +452,10 @@ func boot(t *testing.T, server *Server, mint *hostMint) *booted { stop: stop, ended: ended, server: server, - base: "https://127.0.0.1:" + os.Getenv("PORT"), - // The caller presents its own identity, because the listener requires - // one: a client that could reach this solution without presenting a - // certificate would be exercising a listener this runtime refuses to - // start. - client: &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{ - RootCAs: roots, - Certificates: []tls.Certificate{*caller}, - MinVersion: tls.VersionTLS13, - }}}, - mint: mint, - cell: cell, + base: "http://127.0.0.1:" + os.Getenv("PORT"), + // Plain HTTP, as the gateway reaches this solution inside the cell. + client: &http.Client{Transport: &http.Transport{}}, + mint: mint, } } @@ -765,12 +504,14 @@ func bootFailsWithin(t *testing.T, server *Server, mint *hostMint, within time.D } } -// TestBootMintsExactlyOnceAndServesOverTLS is the acceptance criterion of this -// runtime's whole boot: one mint per process start, zero registrations over any -// interval, and a listener that answers only over TLS. -func TestBootMintsExactlyOnceAndServesOverTLS(t *testing.T) { +// TestBootMintsExactlyOnceAndServesPlainHTTP is the acceptance criterion of +// this runtime's whole boot: one mint per process start, zero registrations +// over any interval, and a plain-HTTP listener on the resolved port — the +// cell's mesh carries the hop, and this process presents no identity of its +// own. +func TestBootMintsExactlyOnceAndServesPlainHTTP(t *testing.T) { mint := newHostMint(t, &hostMint{}) - solution := boot(t, New(Manifest{ID: testSolutionID, Title: testSolutionTitle}). + solution := boot(t, New(Manifest{ID: testSolutionID}). Handle("/thing", func(context.Context, *Gateway) (any, error) { return map[string]string{"ok": "yes"}, nil }), mint) for range 5 { @@ -787,17 +528,6 @@ func TestBootMintsExactlyOnceAndServesOverTLS(t *testing.T) { if want := "Bearer projected-token"; mint.presented[0] != want { t.Errorf("the mint was presented %q, want %q — the projected service-account token is what attests which workload this is", mint.presented[0], want) } - - // The same port answers no plain-HTTP request: the TLS handshake it expects - // never happens, so the request is refused before any route is reached. The - // control is the TLS health check above, which the same port answers 200. - plain, err := http.Get("http://" + solution.base[len("https://"):] + HealthPath) - if err == nil { - defer func() { _ = plain.Body.Close() }() - if plain.StatusCode == http.StatusOK { - t.Error("plain HTTP was served: the listener presents this workload's identity and there is no plain-HTTP listener") - } - } } // TestABootedRuntimeMintsOnceUnderConcurrentRequests is the mint-once claim @@ -870,33 +600,14 @@ func TestABootedRuntimeMintsOnceUnderConcurrentRequests(t *testing.T) { if got := len(mint.observedStartTasks()); got == 0 { t.Error("no viewer mint was attempted, so nothing in this test exercised the credential at all") } - // The mint is dialled WITH this workload's own certificate, and the - // identity it presented is asserted. - // - // This assertion has now been all three things. It asserted the identity; - // then the sdk-go v0.3.0 pin took the transport over and presented - // nothing, so it was changed to require the caller identity be EMPTY and - // the fake endpoint was weakened to VerifyClientCertIfGiven; a reviewer - // said that established the changed behaviour rather than closing the - // finding, and was right. sdk-go#51 gives the client a ClientCertificate - // reader, so both go back: the endpoint REQUIRES a certificate and this - // names which one it must be. - // - // The projected service-account token is still checked, because it is - // still what the host validates the projection by — it is simply no longer - // the whole of what attests who is asking. + // The projected service-account token is what the host validates the + // projection by, so it has to be on the one mint this process made. if len(mint.presented) == 0 { t.Fatal("the mint saw no authorization header at all, so nothing attested which workload was asking") } if got := mint.presented[0]; !strings.Contains(got, "projected-token") { t.Errorf("the mint was presented %q, want the projected service-account token", got) } - if len(mint.callers) == 0 { - t.Fatal("the mint endpoint recorded no caller, so the handshake carried no client certificate: the projected token would be the whole of what attests which workload is asking") - } - if got := mint.callers[0]; got != testPrincipal { - t.Errorf("the mint was dialled with client identity %q, want %q: the host holds this process to the identity its presence document approved, and the hop that carries the projected service-account token is the one that must present it", got, testPrincipal) - } } // TestUnauthenticatedCallIsRefusedNotProxied keeps the boot's serving contract: @@ -993,160 +704,21 @@ func getStatus(t *testing.T, client *http.Client, target string) int { // (manifest.APIConsumesEnvironmentVariable). const consumesThings = `[{"id":"thingstore.things","module":"thingstore","service":"things","endpoint":"rest","protocol":"rest","as":"things"}]` -// TestTheBootedListenerDropsACallerItNoLongerAdmits drives the production -// serve() path, which is the only place the per-connection watch is installed. +// TestABootRefusesAPlaintextGatewayTheMeshDoesNotCarry: validate() holds the +// gateway URL to the SDK's rule, and start() has to *call* validate() for that +// to mean anything. Removing the call left every boot test passing, because +// none of them boots with a configuration validate() would reject. // -// A unit test that builds its own http.Server proves the hook works and not -// that anything installs it: removing `ConnState: watch` from serve() left -// every such test passing. This boots the runtime the way Serve does. -func TestTheBootedListenerDropsACallerItNoLongerAdmits(t *testing.T) { - mint := newHostMint(t, &hostMint{}) - solution := boot(t, New(Manifest{ID: testSolutionID}). - Handle("/thing", func(context.Context, *Gateway) (any, error) { return map[string]string{"ok": "yes"}, nil }), mint) - - // AUTHORIZED, STATUS-CHECKED, DRAINED, AND ON ONE CONNECTION. - // - // This sent no bearer, ignored the status, closed the body without - // draining it and never looked at reuse — so "an admitted caller is - // served" was really "the request did not error", which a 401 satisfies, - // and an undrained body means the connection goes back to nobody rather - // than to the pool. The claim this control makes is that a handler ran - // over an existing BUSY connection, and none of those three omissions let - // it make that claim. - var reused atomic.Int64 - call := func() (int, error) { - request, err := http.NewRequest(http.MethodGet, solution.base+"/thing", nil) - if err != nil { - return 0, err - } - request.Header.Set("authorization", viewerBearer()) - request = request.WithContext(httptrace.WithClientTrace(request.Context(), &httptrace.ClientTrace{ - GotConn: func(info httptrace.GotConnInfo) { - if info.Reused { - reused.Add(1) - } - }, - })) - resp, err := solution.client.Do(request) - if err != nil { - return 0, err - } - status := resp.StatusCode - drainAndClose(resp) - return status, nil - } - - // The control: an admitted caller is SERVED — 200, body drained — and - // stays served on one busy connection across more than a recheck interval. - if status, err := call(); err != nil { - t.Fatalf("an admitted caller was refused: %v", err) - } else if status != http.StatusOK { - t.Fatalf("an admitted caller got %d, want 200: a refusal satisfies 'did not error', so without this the control proves nothing about the handler running", status) - } - quiet := time.Now().Add(2 * inboundTrustRecheckInterval) - for time.Now().Before(quiet) { - status, err := call() - if err != nil { - t.Fatalf("a request failed while this caller was still admitted, so nothing below is about admission: %v", err) - } - if status != http.StatusOK { - t.Fatalf("a request answered %d while this caller was still admitted", status) - } - time.Sleep(inboundTrustRecheckInterval / 10) - } - // ON ONE CONNECTION. The claim is that a handler ran over an existing BUSY - // connection — not that a fresh connection was made each time, which no - // per-connection recheck would need to notice. - if reused.Load() == 0 { - t.Fatal("not one request reused a connection, so this test never exercised an ESTABLISHED connection: a per-dial check alone would satisfy everything below") - } - - // The platform removes this caller from the provisioned file. Nothing - // restarts, and traffic keeps arriving on the connection it already holds. - callersFile := os.Getenv(IdentityAllowedCallersFileEnvironmentVariable) - if callersFile == "" { - t.Fatal("the boot resolved no allowed-callers path, so this test cannot withdraw admission") - } - writeFile(t, callersFile, "spiffe://codefly.test/ns/platform/sa/somebody-else\n") - - deadline := time.Now().Add(5 * inboundTrustRecheckInterval) - for { - if _, err := call(); err != nil { - return // the connection stopped being served, which is the point - } - if time.Now().After(deadline) { - t.Fatalf("this caller was still served %s after it was removed from the provisioned set, on the connection it already held: the booted listener installs no per-connection recheck, so revocation waits for a restart", - 5*inboundTrustRecheckInterval) - } - time.Sleep(inboundTrustRecheckInterval / 10) - } -} - -// TestABootWithAnUnusableAdmissionFileIsRefused: the path resolving is not the -// same question as the set being readable, and the second one has to be asked -// at boot rather than at the first handshake. -// -// A provisioned path naming a file that is empty, or absent, would otherwise -// start a listener that refuses every caller and a client that refuses every -// dial, and report it as an admission failure once traffic arrived instead of -// as the provisioning gap it is. -func TestABootWithAnUnusableAdmissionFileIsRefused(t *testing.T) { - for _, tc := range []struct { - name string - contents string - write bool - }{ - {"a file naming nobody", "\n", true}, - {"a file of comments only", "# the gateway goes here\n", true}, - {"a path with no file behind it", "", false}, - } { - t.Run(tc.name, func(t *testing.T) { - cell := newCell(t) - certFile, keyFile, bundleFile, _, _ := cell.workload(t, testPrincipal) - path := filepath.Join(t.TempDir(), "callers") - if tc.write { - writeFile(t, path, tc.contents) - } - server := New(Manifest{ID: testSolutionID}) - server.cfg = config{brokerInternalToken: "broker-perimeter-token", - port: "8080", gatewayURL: "https://gateway:42152", - mintURL: "https://gateway:42152" + credentialMintPath, - identityCertFile: certFile, - identityKeyFile: keyFile, - projectedTokenPath: "t", - trustBundleFile: bundleFile, - allowedCallersFile: path, - mintPeersFile: identitiesFile(t, testGatewayPrincipal), - gatewayPeersFile: identitiesFile(t, testGatewayPrincipal), - profile: localProfile, - } - err := server.validateSources() - if err == nil { - t.Fatal("the boot accepted an admission set it cannot read: the listener would start and refuse every caller, reported as an admission failure rather than as provisioning") - } - if !strings.Contains(err.Error(), "not usable at boot") { - t.Errorf("the refusal %q does not say the provisioned set is unusable, so it reads as a different problem", err) - } - }) - } -} - -// TestABootRefusesAPlaintextMintBeforeSendingTheToken: validate() refuses a -// plaintext credential mint, and start() has to *call* validate() for that to -// mean anything. Removing the call left every boot test passing, because none -// of them boots with a configuration validate() would reject — they all -// provision a conforming one and then assert on behaviour. -// -// The consequence is the whole reason this runtime refuses a plaintext -// destination at boot: the request to the mint carries the projected -// service-account token that attests which workload this process is, and over -// http that is readable by anything on the path. -func TestABootRefusesAPlaintextMintBeforeSendingTheToken(t *testing.T) { +// The consequence is the whole reason the rule exists: every request to the +// gateway carries the projected service-account token, the viewer's bearer or +// the capability minted for them, and over plaintext to a host the mesh does +// not carry those are readable by anything on the path. +func TestABootRefusesAPlaintextGatewayTheMeshDoesNotCarry(t *testing.T) { mint := newHostMint(t, &hostMint{}) bootEnvironment(t, mint) - // Everything else resolved, and the mint moved to plaintext. - plaintext := strings.Replace(mint.URL, "https://", "http://", 1) - t.Setenv(CredentialMintURLEnvironmentVariable, plaintext+credentialMintPath) + // Everything else resolved, and the gateway a plaintext host that is + // neither loopback nor an in-cluster Service address. + t.Setenv("GATEWAY_URL", "http://gateway.cell.example:8080") ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -1156,31 +728,63 @@ func TestABootRefusesAPlaintextMintBeforeSendingTheToken(t *testing.T) { _ = ln.Close() } if err == nil { - t.Fatal("the boot accepted a plaintext credential mint: the projected service-account token attesting which workload this is would go out in the clear") + t.Fatal("the boot accepted a plaintext gateway the mesh does not carry: the projected service-account token attesting which workload this is would go out in the clear") } - if !strings.Contains(err.Error(), "https") { - t.Errorf("the refusal %q does not say the destination has to be https", err) - } - // From THIS runtime's validate(), not from the SDK's own refusal. - // - // sdk-go v0.3.0 refuses a plaintext mint URL too, so this test passed with - // validate()'s check deleted — the mutation ledger caught it. Two - // refusals for one fact is fine; a test that cannot tell which one fired - // is not, because the boot's own check is what refuses BEFORE a mint - // client is built and names the variable an operator has to fix. - if !strings.Contains(err.Error(), CredentialMintURLEnvironmentVariable) { - t.Errorf("the refusal %q does not name %s, so it is not this runtime's boot check: the SDK refuses a plaintext mint as well, and a test that accepts either cannot tell whether validate() still does", - err, CredentialMintURLEnvironmentVariable) - } - if !strings.Contains(err.Error(), "credential mint URL") { + // From THIS runtime's validate(), naming the value an operator fixes, and + // with the SDK's rule as the reason: two refusals for one fact is fine, + // a test that cannot tell which fired is not. + if !strings.Contains(err.Error(), "GATEWAY_URL") || !strings.Contains(err.Error(), "gateway URL") { t.Errorf("the refusal %q is not validate()'s, which names the configuration it is refusing", err) } + if !errors.Is(err, workcontext.ErrInvalid) { + t.Errorf("the refusal %q does not carry the SDK's rule, so a second copy of it is deciding", err) + } // And it did not reach the mint on the way to refusing. if got := mint.count(); got != 0 { t.Errorf("the boot minted %d times before refusing the configuration: the refusal has to come before the token is sent anywhere", got) } } +// TestAPlaintextGatewayIsAdmittedExactlyWhereTheSDKsRuleAdmitsIt: https +// anywhere; http to a loopback host on a local run, or to an in-cluster +// Service address when the composition asserts the mesh. Each answer here is +// the SDK's, asked with the assertion as loadConfig read it, and the mint URL +// derived on the same origin is held by the same words at every mint. +func TestAPlaintextGatewayIsAdmittedExactlyWhereTheSDKsRuleAdmitsIt(t *testing.T) { + base := func(gateway string, transport meshTransport) config { + return config{brokerInternalToken: "broker-perimeter-token", port: "8080", profile: localProfile, + gatewayURL: gateway, mintURL: gateway + credentialMintPath, transport: transport} + } + asserted := meshTransport{value: "true", set: true} + for _, tc := range []struct { + name string + gateway string + transport meshTransport + admitted bool + }{ + {"https anywhere", "https://gateway.cell.example:8443", meshTransport{}, true}, + {"loopback on a local run", "http://127.0.0.1:8080", meshTransport{local: true}, true}, + {"loopback in a deployment", "http://127.0.0.1:8080", meshTransport{}, false}, + {"an in-cluster Service under the assertion", "http://auth-gateway.platform.svc.cluster.local:8080", asserted, true}, + {"an in-cluster Service with no assertion", "http://auth-gateway.platform.svc.cluster.local:8080", meshTransport{}, false}, + {"a host the assertion does not cover", "http://gateway.cell.example:8080", asserted, false}, + {"a misspelled assertion", "http://auth-gateway.platform.svc:8080", meshTransport{value: "yes", set: true}, false}, + } { + t.Run(tc.name, func(t *testing.T) { + err := base(tc.gateway, tc.transport).validate() + if tc.admitted && err != nil { + t.Fatalf("a gateway the SDK's rule admits was refused: %v", err) + } + if !tc.admitted && err == nil { + t.Fatal("a plaintext gateway the SDK's rule refuses was accepted: the credentials on every gateway request would travel in the clear") + } + if !tc.admitted && !errors.Is(err, workcontext.ErrInvalid) { + t.Errorf("the refusal %q is not the SDK's rule", err) + } + }) + } +} + // TestServeEndsOnATerminalCredentialRefusal: a refused build is not something // to keep serving under. serve() watches credentialRefusedC and closes the // listener; removing that watch left the process serving indefinitely while @@ -1242,14 +846,7 @@ func TestTheMintIsConfiguredWithTheFrozenAuthority(t *testing.T) { } _ = ln.Close() - anchor := x509.NewCertPool() - leafAsked := false - options := server.mintOptions("projection", - func() (*x509.CertPool, error) { return anchor, nil }, - func(*tls.CertificateRequestInfo) (*tls.Certificate, error) { - leafAsked = true - return &tls.Certificate{}, nil - }) + options := server.mintOptions("projection") if options.Authority == nil { t.Error("the mint client is configured with no authority to recheck, so a renewal proceeds after the principal, the mint audience or the projection audience has drifted — the one moment that drift would otherwise be caught") } @@ -1262,37 +859,19 @@ func TestTheMintIsConfiguredWithTheFrozenAuthority(t *testing.T) { if want := (workcontext.AuthorityValue{Name: AuthorityGroup, Key: AuthorityAudienceKey}); options.Audience != want { t.Errorf("the mint reads its audience from %v, want the pinned %v", options.Audience, want) } - // The three readers sdk-go#51 takes in place of a pool by value, each - // asked DURING the handshake. Checking that they are wired AND that they - // answer from this boot's own material, because a reader that answers from - // somewhere else is the defect a pool-by-value could not have. - if options.TrustAnchor == nil { - t.Fatal("the mint client is configured with no trust-anchor reader, so the anchor is whatever it was when the client was built and a removed root never stops this process dialling the mint") - } - if pool, err := options.TrustAnchor(); err != nil || pool != anchor { - t.Errorf("the mint's trust-anchor reader answered (%v, %v), want this boot's own pool: the endpoint receiving the projected service-account token is verified against something else", pool, err) - } - if options.ClientCertificate == nil { - t.Fatal("the mint client presents no certificate, so the projected service-account token is the whole of what attests which workload is asking") - } - if _, err := options.ClientCertificate(); err != nil { - t.Errorf("the mint's certificate reader refused: %v", err) - } - if !leafAsked { - t.Error("the mint's certificate reader does not come from this runtime's one long-lived reloader, so a rotated leaf is not what gets presented") + if options.URL != server.cfg.mintURL { + t.Errorf("the mint is configured for %q, not the resolved %q", options.URL, server.cfg.mintURL) } - if options.AdmittedPeers == nil { - t.Fatal("the mint client admits any peer the anchor signed, and every workload in the trust domain holds one of those: authentication is not authorisation") + if options.URL != mint.URL+credentialMintPath { + t.Errorf("the mint is configured for %q, want the host's broker route on the resolved gateway %q", options.URL, mint.URL+credentialMintPath) } - peers, err := options.AdmittedPeers() - if err != nil { - t.Fatalf("the mint's admitted-peer reader refused: %v", err) + // The transport rule is asked with the assertion loadConfig read, not + // with a live accessor and not with a boolean somebody typed. + if options.Transport != server.cfg.transport { + t.Error("the mint admits plaintext by something other than the mesh assertion this boot read") } - if len(peers) == 0 || peers[0] != testGatewayPrincipal { - t.Errorf("the mint admits %v, want the provisioned %q from MINT_PEERS_FILE", peers, testGatewayPrincipal) - } - if options.URL != server.cfg.mintURL { - t.Errorf("the mint is configured for %q, not the resolved %q", options.URL, server.cfg.mintURL) + if options.ProjectedToken == nil { + t.Fatal("the mint presents no projected token, which is the whole of what attests which workload is asking") } } @@ -1352,33 +931,6 @@ func TestACredentialThatCannotBePresentedRefusesTheMint(t *testing.T) { } } -// provisionedWorkloadValue makes the SDK answer for one workload-identity key, -// the way authorityValues does for the authority group. -// -// It exists because a comment in this suite claimed a test could not make the -// SDK's workspace configuration answer. It can, and because it was believed it -// could not, the two-sources rule had no behavioural test and three mutants of -// it survived. -func provisionedWorkloadValue(t *testing.T, key, value string) { - t.Helper() - t.Setenv("CODEFLY__WORKSPACE_CONFIGURATION__WORKLOAD_IDENTITY__"+key, value) - if err := codefly.LoadEnvironmentVariables(); err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = codefly.LoadEnvironmentVariables() }) -} - -// withoutWorkloadValues leaves the SDK with no workspace configuration at all, -// so a lookup errors rather than answering empty. -func withoutWorkloadValues(t *testing.T) { - t.Helper() - t.Setenv("CODEFLY__WORKSPACE_CONFIGURATION", "") - if err := codefly.LoadEnvironmentVariables(); err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = codefly.LoadEnvironmentVariables() }) -} - // provideListener parks a bound listener for the Server this test is about to // boot, and takeListener collects it. // @@ -1394,146 +946,22 @@ func provideListener(t *testing.T, ln net.Listener) { t.Cleanup(func() { parkedListener = nil }) } -// takeListener gives the server the listener bootIdentity bound, if there is -// one. A test that provisioned its environment some other way gets nil and the -// boot binds for itself. +// takeListener gives the server the listener bootEnvironment bound, if there +// is one. A test that provisioned its environment some other way gets nil and +// the boot binds for itself. +// +// It also embeds the fixture's declared frontend into a server that declared +// none, the way a solution's go:embed does: every boot requires the file, and +// a test about anything else should not have to spell it out. func takeListener(server *Server) *Server { if parkedListener != nil { server.boundListener = parkedListener parkedListener = nil } - return server -} - -// listenOn binds a listener for this server and returns its port, for a test -// that drives listen() directly rather than booting. -func listenOn(t *testing.T, server *Server) string { - t.Helper() - ln, port := boundPort(t) - server.boundListener = ln - return port -} - -// TestAHandlerCannotRewriteTheIdentityThisConnectionWasAdmittedAs is R8-2, -// reproduced on a booted listener over mTLS. -// -// The pin used to be recorded on the recheck's first tick, a second after the -// connection was accepted. I wrote that the window was sound because "no -// in-process hook can run inside it". That was false: a RequestHandler is an -// in-process hook, it is handed r.TLS, and a tls.ConnectionState shares its -// certificates with the connection. A handler rewriting -// PeerCertificates[0].Raw inside that first second had the pin record the -// rewritten identity, and a caller removed from the admitted set went on being -// served on that connection. -// -// The identity is recorded at the first http.StateActive now, which net/http -// calls after reading a request and before ServeHTTP — ahead of any author -// code, on the first request and every one after. -func TestAHandlerCannotRewriteTheIdentityThisConnectionWasAdmittedAs(t *testing.T) { - const survivor = "spiffe://codefly.test/ns/platform/sa/somebody-else" - mint := newHostMint(t, &hostMint{}) - var ( - swapped atomic.Bool - forged *x509.Certificate - ) - - server := New(Manifest{ID: testSolutionID}). - HandleRequest("/thing", func(r *http.Request, _ *Gateway) (any, error) { - // What a hostile or merely careless handler can reach: the parsed - // chain behind r.TLS, which is the connection's own. - if r.TLS != nil && len(r.TLS.PeerCertificates) > 0 { - // Forged to the identity that will REMAIN admitted after the - // withdrawal below, and issued under the anchor this listener - // verifies against. - // - // Rewriting to an identity nobody admits proves nothing: the - // recheck closes such a connection whichever object it read, - // so a test doing that passes without the fix. The attack is - // to name the identity that survives the change. - r.TLS.PeerCertificates[0].Raw = forged.Raw - r.TLS.PeerCertificates[0].URIs = forged.URIs - swapped.Store(true) - } - return map[string]string{"ok": "yes"}, nil - }) - solution := boot(t, server, mint) - forged = parsedLeaf(t, solution.cell.identity(t, survivor)) - provisioned := os.Getenv(IdentityAllowedCallersFileEnvironmentVariable) - if provisioned == "" { - t.Fatal("the boot resolved no allowed-callers path, so this test cannot withdraw admission") - } - - call := func() error { - request, err := http.NewRequest(http.MethodGet, solution.base+"/thing", nil) - if err != nil { - return err - } - // The viewer's bearer, because the route gate answers 401 without one - // and the handler never runs — which is how the first version of this - // test "passed" while exercising nothing. - request.Header.Set("authorization", viewerBearer()) - resp, err := solution.client.Do(request) - if err != nil { - return err - } - drainAndClose(resp) - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("the solution answered %d", resp.StatusCode) - } - return nil - } - - // One request, which runs the handler and lets it rewrite the chain. The - // pin was taken before that handler ran. - if err := call(); err != nil { - t.Fatalf("an admitted caller was refused: %v", err) - } - if !swapped.Load() { - t.Fatal("the handler never saw a peer chain, so this test exercised nothing") - } - - // The caller is withdrawn. Whatever the handler wrote, the connection is - // held to the identity it was admitted as, so the recheck must close it. - writeFile(t, provisioned, survivor+"\n") - - deadline := time.Now().Add(6 * inboundTrustRecheckInterval) - for time.Now().Before(deadline) { - if err := call(); err != nil { - return // closed, which is the point - } - time.Sleep(inboundTrustRecheckInterval / 10) - } - t.Fatalf("a de-admitted caller was still served %s after its identity was withdrawn: a handler rewrote the chain the recheck reads, and the identity the connection was admitted as has to be recorded before any handler runs", - 6*inboundTrustRecheckInterval) -} - -// TestTheListenerDoesNotNegotiateHTTP2 pins the assumption the recheck's pin -// rests on. -// -// net/http calls the ConnState hook with StateActive before ServeHTTP for -// HTTP/1.x, and passes skipHooks for HTTP/2 — so under h2 the pin would never -// be taken at a request and would fall back to the recheck's first tick, which -// is the window R8-2 exploited. Nothing negotiates h2 here: the served -// configuration advertises no ALPN protocol and serve() uses Serve rather than -// ServeTLS, which is what would configure HTTP/2. -// -// If that changes, this fails rather than the pin quietly stopping happening. -func TestTheListenerDoesNotNegotiateHTTP2(t *testing.T) { - c := newCell(t) - certFile, keyFile, bundleFile, _, _ := c.workload(t, testPrincipal) - server := New(Manifest{ID: testSolutionID}) - server.cfg = config{brokerInternalToken: "broker-perimeter-token", identityCertFile: certFile, identityKeyFile: keyFile, trustBundleFile: bundleFile, - allowedCallersFile: identitiesFile(t, testGatewayPrincipal)} - server.principal = testPrincipal - config, err := server.serverIdentity() - if err != nil { - t.Fatalf("serverIdentity: %v", err) - } - for _, proto := range config.NextProtos { - if proto == "h2" { - t.Fatal("the listener advertises h2: net/http skips the StateActive ConnState hook for HTTP/2, so the identity a connection was admitted as would no longer be recorded before a handler runs — move the pin before enabling it") - } + if server.manifest.Frontend == nil { + server.manifest.Frontend = []byte(testFrontend) } + return server } // TestABootedRenewalToAnotherExecutionEndsTheProcess is B3: the renewal @@ -1685,53 +1113,11 @@ func TestABootedRenewalToAnotherExecutionEndsTheProcess(t *testing.T) { } } -// TestTheMintsAnchorIsPassedAsAReaderNotASnapshot is the narrow thing this -// runtime owns about the mint's anchor. -// -// Re-reading it per handshake is the SDK's behaviour and cannot be mutated from -// here. What IS this runtime's is passing the READER through rather than -// resolving it once and handing over the answer — and a mutant that captures -// the pool at construction is invisible to every test that only asks the -// reader what it says now, including the configuration test above. -// -// So the reader's answer is changed between calls, which is what a rotation -// looks like from the SDK's side. -func TestTheMintsAnchorIsPassedAsAReaderNotASnapshot(t *testing.T) { - mint := newHostMint(t, &hostMint{}) - bootEnvironment(t, mint) - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - server := New(Manifest{ID: testSolutionID}) - ln, err := takeListener(server).start(ctx) - if err != nil { - t.Fatalf("boot: %v", err) - } - _ = ln.Close() - - first, second := x509.NewCertPool(), x509.NewCertPool() - second.AddCert(mint.Certificate()) - rotated := false - options := server.mintOptions("projection", - func() (*x509.CertPool, error) { - if rotated { - return second, nil - } - return first, nil - }, - func(*tls.CertificateRequestInfo) (*tls.Certificate, error) { return &tls.Certificate{}, nil }) +// localRun is the SDK's transport source for a test: a local run that asserts +// no mesh, so the only plaintext the mint client admits is loopback — the rule +// a booted runtime applies to its resolved gateway, answered the way a test's +// environment answers it. +type localRun struct{} - if got, _ := options.TrustAnchor(); got != first { - t.Fatal("the mint's trust-anchor reader does not answer from the reader this runtime passed") - } - rotated = true - got, err := options.TrustAnchor() - if err != nil { - t.Fatalf("the mint's trust-anchor reader refused after the rotation: %v", err) - } - if got == first { - t.Error("the mint's trust-anchor reader still answers the pool it first returned: the anchor was resolved once and the ANSWER handed over, so the SDK re-reading per handshake re-reads a snapshot and a removed root never stops this process dialling the endpoint that receives the projected service-account token") - } - if got != second { - t.Errorf("the mint's trust-anchor reader answered %v, want the rotated pool", got) - } -} +func (localRun) WorkspaceValueIfSet(string, string) (string, bool, error) { return "", false, nil } +func (localRun) IsLocalRun() bool { return true } diff --git a/broker_body_test.go b/broker_body_test.go deleted file mode 100644 index 19bf793..0000000 --- a/broker_body_test.go +++ /dev/null @@ -1,103 +0,0 @@ -package solution - -import ( - "context" - "crypto/tls" - "crypto/x509" - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "net/url" - "strings" - "testing" -) - -// The mint request carries the declared prefix and, beside it, the dynamic half -// of this runtime's registration: where it serves and the manifest it serves. -// Nothing else — a field the host does not read is a field nobody reviewed. -var brokerBodyKeys = map[string]bool{"prefix": true, "upstream": true, "manifest": true} - -func onlyBrokerBodyKeys(body map[string]string) bool { - for key := range body { - if !brokerBodyKeys[key] { - return false - } - } - return true -} - -// requireBrokerBody holds a mint body to the protocol: exactly the declared -// prefix, an https origin when an upstream is reported, and this solution's own -// manifest when one is reported. -func requireBrokerBody(t *testing.T, raw []byte, prefix string) map[string]string { - t.Helper() - var body map[string]string - if err := json.Unmarshal(raw, &body); err != nil { - t.Fatalf("broker body is not a flat JSON object of strings: %s", raw) - } - if body["prefix"] != prefix || !onlyBrokerBodyKeys(body) { - t.Fatalf("broker body must carry the declared prefix and only the dynamic half beside it: %s", raw) - } - if upstream, reported := body["upstream"]; reported { - parsed, err := url.Parse(upstream) - if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.Port() == "" || (parsed.Path != "" && parsed.Path != "/") || parsed.RawQuery != "" || parsed.User != nil { - t.Fatalf("a reported upstream must be an https origin and nothing more: %q", upstream) - } - } - if manifest, reported := body["manifest"]; reported { - var document struct { - ID string `json:"id"` - } - if err := json.Unmarshal([]byte(manifest), &document); err != nil || document.ID != prefix { - t.Fatalf("a reported manifest must be this solution's own: %s", manifest) - } - } - return body -} - -// The host records a solution's backend origin and frontend manifest from the -// mint, under the identity the projected token proves, since nothing -// self-registers. A runtime that knows where it serves therefore says so, and a -// runtime that does not — no resolved endpoint — reports no origin rather than -// a guessed one. -func TestTheMintReportsWhereThisRuntimeServesAndItsManifest(t *testing.T) { - for _, tc := range []struct { - name, upstream string - }{ - {"with a resolved endpoint", "https://localhost:22631"}, - {"without one", ""}, - } { - t.Run(tc.name, func(t *testing.T) { - s, host, _ := brokerFixture(t) - s.cfg.upstream = tc.upstream - options := s.mintOptions("accounts", func() (*x509.CertPool, error) { return x509.NewCertPool(), nil }, func(*tls.CertificateRequestInfo) (*tls.Certificate, error) { return &tls.Certificate{}, nil }) - options.Authority = fixedAuthority{audience: testAudience} - source, err := newBrokerCredentialSource(options) - if err != nil { - t.Fatal(err) - } - var sent map[string]string - source.client.Transport = mintRoundTrip(func(r *http.Request) (*http.Response, error) { - raw, err := io.ReadAll(r.Body) - if err != nil { - t.Fatal(err) - } - sent = requireBrokerBody(t, raw, testSolutionID) - r.Body = io.NopCloser(strings.NewReader(string(raw))) - response := httptest.NewRecorder() - host.serve(response, r) - return response.Result(), nil - }) - if _, err := source.Credential(context.Background()); err != nil { - t.Fatal(err) - } - if got, reported := sent["upstream"]; got != tc.upstream || reported != (tc.upstream != "") { - t.Fatalf("upstream reported as %q (present: %v), want %q", got, reported, tc.upstream) - } - if sent["manifest"] == "" { - t.Fatal("the mint did not report this solution's manifest") - } - }) - } -} diff --git a/broker_credential.go b/broker_credential.go index 7393932..39298a7 100644 --- a/broker_credential.go +++ b/broker_credential.go @@ -3,11 +3,8 @@ package solution import ( "bytes" "context" - "crypto/tls" - "crypto/x509" "encoding/json" "errors" - "fmt" "io" "net" "net/http" @@ -46,20 +43,12 @@ func (c brokerCredential) Attach(r *http.Request) error { return workcontext. type brokerMintOptions struct { URL, Prefix, InternalToken, ProjectionAudience string - // Upstream is the origin this runtime serves its backend at, reported with - // the mint as the dynamic half of its registration: the host records it - // only for the workload the projected token proves, and reaches it only - // under this workload's SVID. Empty reports nothing. - Upstream string - // Manifest is the solution's frontend manifest, as this runtime serves it: - // the other dynamic half of a solution's registration. Empty for a module. - Manifest string - Audience workcontext.AuthorityValue - Authority workcontext.AuthorityPin - ProjectedToken workcontext.ProjectedTokenSource - TrustAnchor func() (*x509.CertPool, error) - ClientCertificate func() (*tls.Certificate, error) - AdmittedPeers func() ([]string, error) + Audience workcontext.AuthorityValue + Authority workcontext.AuthorityPin + ProjectedToken workcontext.ProjectedTokenSource + // Transport answers whether a plaintext URL is admitted — the SDK's rule, + // asked with the composition's mesh assertion as loadConfig read it. + Transport workcontext.TransportSource } type brokerCredentialSource struct { @@ -75,44 +64,35 @@ type brokerCredentialSource struct { } func newBrokerCredentialSource(options brokerMintOptions) (*brokerCredentialSource, error) { - u, err := url.Parse(options.URL) - if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.Path != credentialMintPath || u.RawPath != "" { - return nil, errors.New("credential mint URL must be an https module broker endpoint at /modules/_work-context without userinfo, query or fragment") + // The SDK's rule decides whether the projected token may travel to this + // URL: https anywhere; http only to a loopback host on a local run, or to + // an in-cluster Service address under the composition's mesh assertion. + // The path is the host's published route and nothing else. + u, err := workcontext.AdmitEndpoint(options.URL, options.Transport, "credential mint") + if err != nil { + return nil, err + } + if u.Path != credentialMintPath || u.RawPath != "" { + return nil, errors.New("credential mint URL must name the module broker endpoint " + credentialMintPath) } - if options.Prefix == "" || options.InternalToken == "" || options.ProjectedToken == nil || options.Authority == nil || options.TrustAnchor == nil || options.ClientCertificate == nil || options.AdmittedPeers == nil { - return nil, errors.New("module broker requires a declared solution prefix, module-authority/INTERNAL_TOKEN, projected token, frozen authority and TLS identity readers") + if options.Prefix == "" || options.InternalToken == "" || options.ProjectedToken == nil || options.Authority == nil { + return nil, errors.New("module broker requires a declared solution prefix, module-authority/INTERNAL_TOKEN, a projected token and the frozen authority") } if options.ProjectionAudience != "accounts" { return nil, errors.New("module-authority/PROJECTION_AUDIENCE must be accounts for the module broker") } - // At boot, where it names the cause. The host bounds what it reads, and an - // oversize body is answered there as a malformed request — which this - // client can only classify as a refusal, at boot and again at every - // renewal: a crash loop that says "request failed" about a manifest. - if size := len(options.body()); size > brokerMintBodyMaxBytes { - return nil, fmt.Errorf("the mint request is %d bytes and the module broker reads at most %d: the frontend manifest this solution reports with it is too large", size, brokerMintBodyMaxBytes) - } c := &brokerCredentialSource{options: options} c.client = c.httpClient(u) return c, nil } -// brokerMintBodyMaxBytes bounds the mint request this client will send. It is -// under what the host reads, so the bound that fires is the one that can say -// why. -const brokerMintBodyMaxBytes = 64 << 10 - -// body is the mint request: the declared prefix, and the dynamic half of this -// runtime's registration when it has one to report. +// body is the mint request: the declared prefix and nothing else. Where this +// runtime serves and what its frontend declares are not reported here: the +// gateway resolves this solution's address from the delivered presence +// document's endpoints, and the frontend travels in the same document, read by +// the CLI from the file this runtime embeds (FrontendFileName). func (o brokerMintOptions) body() []byte { - body := map[string]string{"prefix": o.Prefix} - if o.Upstream != "" { - body["upstream"] = o.Upstream - } - if o.Manifest != "" { - body["manifest"] = o.Manifest - } - payload, _ := json.Marshal(body) + payload, _ := json.Marshal(map[string]string{"prefix": o.Prefix}) return payload } @@ -232,69 +212,42 @@ func (c *brokerCredentialSource) obtain(ctx context.Context) (executionCredentia return credential, nil } -// This transport is private, uses a fresh mutually authenticated handshake for -// every mint and rechecks trust after it, before any HTTP bytes can be sent. +// httpClient is the transport the mint goes out on: owned, never the default +// one (which carries Proxy: ProxyFromEnvironment), pinned to the one origin the +// mint URL names, following no redirect and reusing no connection. Plain HTTP +// inside the cell, where the mesh carries the hop; https where the URL says +// so, verified against the system roots, since an https gateway is an edge +// presenting a public certificate. The transportReading slot bounds the dial +// as boundedBrokerRead bounds the token read: at most one outstanding, +// cancelled by the ask's own deadline. func (c *brokerCredentialSource) httpClient(endpoint *url.URL) *http.Client { - o := c.options - trust := func() (*x509.CertPool, error) { - pool, err := o.TrustAnchor() - if err != nil || pool == nil { - return nil, errors.New("broker trust anchor unavailable") - } - return pool, nil - } - // Build an owned transport; never inherit mutable global transport hooks. - transport := &http.Transport{DisableKeepAlives: true} + transport := newPlatformTransport() + transport.DisableKeepAlives = true port := endpoint.Port() if port == "" { - port = "443" + port = "80" + if endpoint.Scheme == "https" { + port = "443" + } } address := net.JoinHostPort(endpoint.Hostname(), port) - transport.DialTLSContext = func(ctx context.Context, network, addr string) (net.Conn, error) { + transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { if addr != address { return nil, errors.New("broker origin mismatch") } - ctx, cancel := context.WithTimeout(ctx, platformHandshakeTimeout) + ctx, cancel := context.WithTimeout(ctx, brokerDialTimeout) defer cancel() return boundedBrokerRead(ctx, &c.transportReading, func() (net.Conn, error) { - anchor, err := trust() - if err != nil || anchor == nil { - return nil, errors.New("broker trust anchor unavailable") - } - presented := false - config := &tls.Config{MinVersion: tls.VersionTLS13, RootCAs: anchor, ServerName: endpoint.Hostname(), SessionTicketsDisabled: true} - config.GetClientCertificate = func(*tls.CertificateRequestInfo) (*tls.Certificate, error) { - pair, err := o.ClientCertificate() - if err != nil || pair == nil || len(pair.Certificate) == 0 || pair.PrivateKey == nil { - return nil, errors.New("broker client identity unavailable") - } - presented = true - return pair, nil - } - admitOnlyPlatform(config, o.AdmittedPeers) - raw, err := (&net.Dialer{}).DialContext(ctx, network, addr) - if err != nil { - return nil, err - } - conn := tls.Client(raw, config) - if err := conn.HandshakeContext(ctx); err != nil { - _ = conn.Close() - return nil, err - } - if !presented { - _ = conn.Close() - return nil, errors.New("broker did not request a client identity") - } - if err := peerStillTrusted(conn.ConnectionState(), endpoint.Hostname(), trust, o.AdmittedPeers); err != nil { - _ = conn.Close() - return nil, err - } - return conn, nil + return (&net.Dialer{}).DialContext(ctx, network, addr) }) } return &http.Client{Transport: transport, Timeout: platformRequestTimeout, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} } +// brokerDialTimeout bounds one dial of the mint on its own: a mint request may +// wait on the host, a dial never does. +const brokerDialTimeout = 10 * time.Second + func boundedBrokerRead[T any](ctx context.Context, busy *atomic.Bool, read func() (T, error)) (T, error) { var zero T if err := ctx.Err(); err != nil { diff --git a/broker_credential_test.go b/broker_credential_test.go index 304cd3f..d7a22df 100644 --- a/broker_credential_test.go +++ b/broker_credential_test.go @@ -2,8 +2,8 @@ package solution import ( "context" - "crypto/tls" "crypto/x509" + "encoding/json" "errors" "fmt" "io" @@ -34,7 +34,7 @@ func brokerFixture(t *testing.T) (*Server, *hostMint, string) { s.cfg.mintURL = "https://broker.example/modules/_work-context" s.cfg.brokerInternalToken = "broker-perimeter-token" s.cfg.projectedTokenPath = path - options := s.mintOptions("accounts", func() (*x509.CertPool, error) { return x509.NewCertPool(), nil }, func(*tls.CertificateRequestInfo) (*tls.Certificate, error) { return &tls.Certificate{}, nil }) + options := s.mintOptions("accounts") options.Authority = fixedAuthority{audience: testAudience} c, err := newBrokerCredentialSource(options) if err != nil { @@ -70,7 +70,12 @@ func TestBrokerExecutionProtocolConformance(t *testing.T) { if err != nil { t.Fatal(err) } - requireBrokerBody(t, raw, "widgets-go") + // The prefix and nothing else: where this runtime serves and what + // its frontend declares travel in the presence document, not here. + var body map[string]any + if err := json.Unmarshal(raw, &body); err != nil || len(body) != 1 || body["prefix"] != "widgets-go" { + t.Fatalf("the mint body is %s, want exactly {\"prefix\":\"widgets-go\"}", raw) + } r.Body = io.NopCloser(strings.NewReader(string(raw))) return wire.RoundTrip(r) }) @@ -229,7 +234,7 @@ func TestBrokerConfigurationUsesDeclaredSecretAndRefusesMissingValues(t *testing } s, _, _ := brokerFixture(t) options := s.brokerCredential.options - for _, field := range []string{"prefix", "secret", "audience", "path"} { + for _, field := range []string{"prefix", "secret", "audience", "path", "plaintext to a host the mesh does not carry", "plaintext to loopback in a deployment", "a misspelled mesh assertion"} { bad := options switch field { case "prefix": @@ -240,11 +245,33 @@ func TestBrokerConfigurationUsesDeclaredSecretAndRefusesMissingValues(t *testing bad.ProjectionAudience = "different" case "path": bad.URL = "https://broker.example/platform/_credential" + case "plaintext to a host the mesh does not carry": + bad.URL = "http://broker.example/modules/_work-context" + bad.Transport = meshTransport{value: "true", set: true} + case "plaintext to loopback in a deployment": + bad.URL = "http://127.0.0.1:8080/modules/_work-context" + bad.Transport = meshTransport{} + case "a misspelled mesh assertion": + bad.URL = "http://auth-gateway.platform.svc:8080/modules/_work-context" + bad.Transport = meshTransport{value: "yes", set: true} } if _, err := newBrokerCredentialSource(bad); err == nil { t.Fatalf("accepted missing/wrong %s", field) } } + // And admitted exactly where the SDK's rule admits plaintext: the mint + // hop carries the projected token, so it is held to the same rule the + // gateway URL is held to at validation, through the same function. + for name, admitted := range map[string]brokerMintOptions{ + "loopback on a local run": {URL: "http://127.0.0.1:8080/modules/_work-context", Transport: meshTransport{local: true}}, + "an in-cluster Service under the assertion": {URL: "http://auth-gateway.platform.svc.cluster.local:8080/modules/_work-context", Transport: meshTransport{value: "true", set: true}}, + } { + good := options + good.URL, good.Transport = admitted.URL, admitted.Transport + if _, err := newBrokerCredentialSource(good); err != nil { + t.Fatalf("%s was refused by the native mint client: %v", name, err) + } + } } type brokerTokenRead func() (string, error) diff --git a/consumer_identity_test.go b/consumer_identity_test.go index bb74b32..df7d37e 100644 --- a/consumer_identity_test.go +++ b/consumer_identity_test.go @@ -62,7 +62,6 @@ func TestABootThatNamesNoModuleIsRefused(t *testing.T) { // Every endpoint overridden, so nothing else can be the reason. t.Setenv("PORT", "8443") t.Setenv("GATEWAY_URL", "https://gateway.cell") - t.Setenv(CredentialMintURLEnvironmentVariable, "https://gateway.cell"+credentialMintPath) t.Cleanup(func() { _ = codefly.LoadEnvironmentVariables() }) _, initErr := codefly.Init(context.Background()) diff --git a/contract_test.go b/contract_test.go index 5756baf..86f7038 100644 --- a/contract_test.go +++ b/contract_test.go @@ -278,7 +278,7 @@ func TestAnUnusableProfileNameIsRefused(t *testing.T) { } cfg := config{brokerInternalToken: "broker-perimeter-token", port: "8080", gatewayURL: "https://gateway:42152", mintURL: "https://gateway:42152" + credentialMintPath, - identityCertFile: "c", identityKeyFile: "k", projectedTokenPath: "t", trustBundleFile: "b", profile: "../local", + projectedTokenPath: "t", profile: "../local", } if err := cfg.validate(); err == nil || !strings.Contains(err.Error(), ContractProfileEnvironmentVariable) { t.Errorf("validate = %v, want a refusal naming %s", err, ContractProfileEnvironmentVariable) @@ -598,7 +598,7 @@ func TestAViewerBearerAudienceCannotBeMintedFor(t *testing.T) { // has one, and which one it is depends on a caller. func TestAnEmptyManifestIdCannotDisableTheCeiling(t *testing.T) { t.Run("an empty id is refused at boot", func(t *testing.T) { - err := Manifest{ID: ""}.validateSurfaces() + err := Manifest{ID: ""}.validateID() if err == nil { t.Fatal("a manifest with no id was accepted: it is what the presence document names this solution by, what the published contract reports, and what every refusal here identifies this process as") } @@ -606,7 +606,7 @@ func TestAnEmptyManifestIdCannotDisableTheCeiling(t *testing.T) { t.Errorf("the refusal %q does not name the field that fixes it", err) } // And whitespace is not an id either. - if err := (Manifest{ID: " "}).validateSurfaces(); err == nil { + if err := (Manifest{ID: " "}).validateID(); err == nil { t.Error("a manifest whose id is whitespace was accepted") } }) diff --git a/credential.go b/credential.go index 47fa2c6..5c646eb 100644 --- a/credential.go +++ b/credential.go @@ -2,9 +2,6 @@ package solution import ( "context" - "crypto/tls" - "crypto/x509" - "encoding/json" "errors" "fmt" "log" @@ -84,62 +81,25 @@ func readAuthority(ctx context.Context) (*codefly.Authority, error) { return authority, nil } -// platformCredentialSource is the native module broker client, -// pointed at the host's mint endpoint, presenting the service-account token the -// platform projects for this workload. +// platformCredentialSource is the native module broker client, pointed at the +// host's broker route on the resolved gateway, presenting the service-account +// token the platform projects for this workload. // -// It carries this runtime's outbound client, so the mint presents this -// workload's own X.509-SVID, verifies the host against the anchor the platform -// projected, is not proxied, and does not follow redirects: the request carries -// the projected token that attests which workload this process is, and a -// Location would hand that to whatever answered at the mint URL. +// It carries no identity of its own. The hop is plain HTTP inside the cell, +// carried by the mesh, and the projected token is what attests which workload +// asks: the host establishes the principal, the installation and the build +// from that token and its own records, never from anything this process +// reports. The client is not proxied, follows no redirect and dials the one +// origin it was built for, because the request carries that token and a +// Location would hand it to whatever answered. func (s *Server) platformCredentialSource() (*brokerCredentialSource, error) { projectionAudience, err := s.authority.Value(AuthorityGroup, AuthorityProjectionAudienceKey) if err != nil { return nil, err } - identity, err := s.serverIdentity() + client, err := newBrokerCredentialSource(s.mintOptions(projectionAudience)) if err != nil { - return nil, err - } - // ONE long-lived reloader for the leaf, built here and closed over. - // - // The thing that keeps a rotated leaf presented is the reloader's own - // re-read, not a fresh reloader: the SDK's swallows a half-written pair - // and keeps the last good one, and a constructor per handshake would - // report that error instead of surviving it. outboundLeaf says the same - // about the dial path for the same reason. - leaf, err := s.outboundLeaf(identity) - if err != nil { - return nil, err - } - // HELD TO THE FROZEN PRINCIPAL, at the moment it is presented. - // - // outboundLeaf is the raw reader — on the default path, the SDK's reloader - // over the projected files — and handing it to the SDK unwrapped was the - // identity binding this hop lost. The gateway's configuration gets the - // same hold through holdPresentedCertificate; this is the other outbound - // reader, and it is the one that carries the projected service-account - // token. A same-CA rotation to a neighbouring workload's certificate was - // presented to the mint while the frozen principal was still this one, and - // the SDK cannot catch it: it checks that a certificate is non-empty and - // has a key, which is the right division of labour and not a statement - // about whose certificate it is. - held := heldToTheFrozenPrincipal(leaf, s.principal, "the credential mint") - // Refused at boot as well, for the reason holdPresentedCertificate - // records: a mismatch found at the first dial is a mismatch found after - // the projected token has gone out under it. Only a MISMATCH refuses here - // — a read error is the reloader's business and it keeps the last good - // pair, so failing the boot on one would refuse a rotation that is merely - // half-written. - if pair, readErr := leaf(&tls.CertificateRequestInfo{}); readErr == nil && pair != nil { - if err := pairPresentsThisWorkload(pair, s.principal); err != nil { - return nil, fmt.Errorf("this workload would present a certificate the host has not approved it under to the credential mint: %w", err) - } - } - client, err := newBrokerCredentialSource(s.mintOptions(projectionAudience, s.peerTrustAnchor(identity), held)) - if err != nil { - return nil, fmt.Errorf("configure this workload's credential mint at %s: %w", s.cfg.mintURL, err) + return nil, fmt.Errorf("configure this workload's credential mint at %s: %w", redactedURL(s.cfg.mintURL), err) } return client, nil } @@ -281,25 +241,17 @@ type sealedIdentity struct { } // mintOptions binds the declared prefix and perimeter credential to the frozen -// authority and live TLS readers. No environment is read after loadConfig. -func (s *Server) mintOptions( - projectionAudience string, - anchor func() (*x509.CertPool, error), - leaf func(*tls.CertificateRequestInfo) (*tls.Certificate, error), -) brokerMintOptions { +// authority and the resolved gateway. No environment is read after loadConfig. +func (s *Server) mintOptions(projectionAudience string) brokerMintOptions { return brokerMintOptions{ URL: s.cfg.mintURL, - Upstream: s.cfg.upstream, - Manifest: s.manifestJSON(), Prefix: s.manifest.ID, InternalToken: s.cfg.brokerInternalToken, Audience: workcontext.AuthorityValue{Name: AuthorityGroup, Key: AuthorityAudienceKey}, ProjectedToken: projectedTokenFile(s.cfg.projectedTokenPath), ProjectionAudience: projectionAudience, Authority: s.authority, - TrustAnchor: anchor, - ClientCertificate: func() (*tls.Certificate, error) { return leaf(&tls.CertificateRequestInfo{}) }, - AdmittedPeers: s.admittedMint(), + Transport: s.cfg.transport, } } @@ -450,19 +402,6 @@ func (s *Server) openCredential(ctx context.Context) error { } } -// manifestJSON is the frontend manifest this runtime serves, reported with -// the mint so the host's registration carries it without a self-registration. -func (s *Server) manifestJSON() string { - if s.manifest.ID == "" { - return "" - } - encoded, err := json.Marshal(s.manifestMap()) - if err != nil { - return "" - } - return string(encoded) -} - // awaitInstallation asks the host for this execution's credential for as long // as this process holds none because nothing has installed the solution, and // returns when ctx ends or the credential is refused for good. diff --git a/credential_test.go b/credential_test.go index 26f972a..24111ac 100644 --- a/credential_test.go +++ b/credential_test.go @@ -1,10 +1,7 @@ package solution import ( - "connectrpc.com/connect" "context" - "crypto/tls" - "crypto/x509" "errors" "fmt" "log" @@ -18,6 +15,8 @@ import ( "testing" "time" + "connectrpc.com/connect" + "github.com/codefly-dev/core/solution/manifest" corework "github.com/codefly-dev/core/workcontext" codefly "github.com/codefly-dev/sdk-go" @@ -308,21 +307,11 @@ func TestEveryCallerOfOneExecutionSharesOneCarrier(t *testing.T) { func TestABootedRuntimeRegistersNothing(t *testing.T) { mint := newHostMint(t, &hostMint{}) - // A listener of its own for every deleted URL, which boot cannot move. - // - // These pointed at mint.URL, captured before boot. The boot then calls - // hostMint.serveTLS, which CLOSES that server and starts another on a new - // port — so every deleted URL named an address nothing was listening on, - // and a surviving registration would have failed to connect and been - // counted by nobody. The test passed because nothing could be observed. - // - // This is the second time this test watched the wrong thing. The first - // version aimed the URLs at example.com, which a reviewer caught for the - // same reason: aimed somewhere nothing answers, it proved only that the - // old code never minted. The comment then said "every deleted URL points - // at the host this test counts requests on", which was true when it was - // written and stopped being true when serveTLS started replacing the - // server underneath it. + // A listener of its own for every deleted URL, so a surviving registration + // is observed rather than failing to connect and being counted by nobody. + // The first version aimed the URLs at example.com, which a reviewer caught + // for that reason: aimed somewhere nothing answers, it proved only that the + // old code never minted. var legacyHits atomic.Int64 var legacyPaths sync.Map legacy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -651,13 +640,9 @@ func mintClientFor(t *testing.T, mint *hostMint, tokenFile string) CredentialSou Authority: fixedAuthority{audience: testAudience}, ProjectedToken: workcontext.ProjectedTokenFile(tokenFile), ProjectionAudience: testProjectionAudience, - // The three readers sdk-go#51 takes, each asked during the handshake. - // The client still builds and owns the transport; what it no longer - // does is fix the anchor, present nothing, and admit any peer the - // anchor signed. - TrustAnchor: mint.roots, - ClientCertificate: func() (*tls.Certificate, error) { return mint.caller, nil }, - AdmittedPeers: func() ([]string, error) { return []string{testGatewayPrincipal}, nil }, + // Plaintext to loopback on a local run, which is what the SDK's rule + // admits and what the fake host serves. + Transport: localRun{}, }) if err != nil { t.Fatal(err) @@ -2082,9 +2067,7 @@ func expiredCredential(t *testing.T, mint *hostMint, tokenFile string) workconte Authority: fixedAuthority{audience: testAudience}, ProjectedToken: workcontext.ProjectedTokenFile(tokenFile), ProjectionAudience: testProjectionAudience, - TrustAnchor: mint.roots, - ClientCertificate: func() (*tls.Certificate, error) { return mint.caller, nil }, - AdmittedPeers: func() ([]string, error) { return []string{testGatewayPrincipal}, nil }, + Transport: localRun{}, Now: func() time.Time { return backdated }, }) if err != nil { @@ -2241,133 +2224,6 @@ func TestAnExecutionFreeCredentialIsRefusedEverywhere(t *testing.T) { }) } -// TestTheMintHopIsHeldToTheSameThingsTheGatewayHopIs is round thirteen's -// blocker, closed where it had to be closed: in the SDK. -// -// Round eleven asked for this and I could not do it. v0.3.0 took RootCAs as a -// VALUE and built its transport from it on the spot, presented no client -// certificate, and offered no peer hook — so the mint hop had no identity of -// its own going out, admitted any peer the anchor signed, and fixed its anchor -// for the client's lifetime. Reimplementing the transport here would have been -// the hack this repo's rule 2 forbids, so it went out as an sdk-go issue and -// came back as sdk-go#51: three READERS, each consulted during the handshake. -// -// So these are the negative controls round thirteen named. Each is about the -// mint hop specifically, driven through a real TLS handshake against the fake -// endpoint rather than by inspecting the options struct. -func TestTheMintHopIsHeldToTheSameThingsTheGatewayHopIs(t *testing.T) { - t.Run("an unreadable anchor refuses rather than dialling under the last good one", func(t *testing.T) { - mint := newHostMint(t, &hostMint{}) - tokenFile := filepath.Join(t.TempDir(), "token") - writeFile(t, tokenFile, "projected") - - var fail atomic.Bool - source := mintClientWith(t, mint, tokenFile, func(o *workcontext.MintOptions) { - o.TrustAnchor = func() (*x509.CertPool, error) { - if fail.Load() { - return nil, errors.New("the projected bundle is unreadable") - } - pool, _ := mint.roots() - return pool, nil - } - }) - held, err := source.Credential(context.Background()) - if err != nil { - t.Fatalf("the first ask was refused: %v", err) - } - // The anchor goes away. A client that captured the last good pool goes - // on dialling the endpoint that receives the projected - // service-account token under trust that no longer exists. - fail.Store(true) - if _, err := freshMint(t, source, held); err == nil { - t.Fatal("a mint succeeded while the trust anchor could not be read: the projected service-account token went to an endpoint verified against nothing this process can currently vouch for") - } - }) - - t.Run("a peer the anchor signed but the peer set does not name is refused", func(t *testing.T) { - // THE finding. Every workload in the trust domain holds a certificate - // from this anchor, the consumed modules included, so "signed by the - // right CA" is authentication and not authorisation. Before sdk-go#51 - // a neighbouring workload presenting a certificate valid for the - // mint's hostname received the projected token. - mint := newHostMint(t, &hostMint{}) - tokenFile := filepath.Join(t.TempDir(), "token") - writeFile(t, tokenFile, "projected") - - source := mintClientWith(t, mint, tokenFile, func(o *workcontext.MintOptions) { - // The endpoint's certificate is valid and issued by the accepted - // anchor. It simply is not the party this runtime talks to. - o.AdmittedPeers = func() ([]string, error) { return []string{rivalPrincipal}, nil } - }) - if _, err := source.Credential(context.Background()); err == nil { - t.Fatal("the mint was dialled and the projected service-account token handed over to a peer the admitted set does not name: a certificate from the cell's anchor says which trust domain answered, not which party") - } else if mint.count() != 0 { - t.Errorf("the endpoint answered %d requests before the identity was refused, want 0: the refusal has to happen in the handshake, not after the token is on the wire", mint.count()) - } - }) - - t.Run("peer withdrawal takes effect with the anchor unchanged", func(t *testing.T) { - // The second half of the same finding, and the one an anchor check - // cannot do: the CA never changes here. Only the provisioned set does. - mint := newHostMint(t, &hostMint{}) - tokenFile := filepath.Join(t.TempDir(), "token") - writeFile(t, tokenFile, "projected") - - var withdrawn atomic.Bool - source := mintClientWith(t, mint, tokenFile, func(o *workcontext.MintOptions) { - o.AdmittedPeers = func() ([]string, error) { - if withdrawn.Load() { - return []string{rivalPrincipal}, nil - } - return []string{testGatewayPrincipal}, nil - } - }) - held, err := source.Credential(context.Background()) - if err != nil { - t.Fatalf("the first ask was refused: %v", err) - } - withdrawn.Store(true) - if _, err := freshMint(t, source, held); err == nil { - t.Fatal("a mint succeeded after the endpoint's identity was withdrawn from the admitted set while its certificate and the anchor stayed exactly as they were: this is the revocation an anchor check cannot express, and the reason the set is read per handshake rather than once") - } - }) - - t.Run("the mint request presents this workload's own identity", func(t *testing.T) { - // The fixture REQUIRES a client certificate now, so a mint that - // completes at all proves one was presented — and the identity is - // asserted rather than expected to be empty, which is what the - // fixture was weakened to expect while the SDK presented nothing. - mint := newHostMint(t, &hostMint{}) - tokenFile := filepath.Join(t.TempDir(), "token") - writeFile(t, tokenFile, "projected") - - if _, err := mintClientFor(t, mint, tokenFile).Credential(context.Background()); err != nil { - t.Fatalf("the mint refused a caller presenting this workload's certificate: %v", err) - } - if len(mint.callers) == 0 { - t.Fatal("the endpoint recorded no caller at all") - } - if got := mint.callers[0]; got != testPrincipal { - t.Errorf("the mint endpoint saw the caller as %q, want %q: the projected service-account token is no longer the whole of what attests which workload is asking", got, testPrincipal) - } - }) - - t.Run("a certificate reader that cannot answer refuses the mint", func(t *testing.T) { - mint := newHostMint(t, &hostMint{}) - tokenFile := filepath.Join(t.TempDir(), "token") - writeFile(t, tokenFile, "projected") - - source := mintClientWith(t, mint, tokenFile, func(o *workcontext.MintOptions) { - o.ClientCertificate = func() (*tls.Certificate, error) { - return nil, errors.New("the projected pair is half-written") - } - }) - if _, err := source.Credential(context.Background()); err == nil { - t.Fatal("the mint was dialled with no certificate to present: an endpoint that requires one would refuse, and one that does not would attribute this request to nobody") - } - }) -} - // freshMint forces a NEW handshake against the endpoint. // // Calling Credential again would not: a mint client holds the credential it @@ -2395,30 +2251,6 @@ func freshMint(t *testing.T, source any, held executionCredential) (executionCre return client.Refresh(context.Background(), held.(workcontext.Credential)) } -// mintClientWith is a mint client against the fake endpoint, with one or more -// of the three handshake readers replaced. -func mintClientWith(t *testing.T, mint *hostMint, tokenFile string, with ...func(*workcontext.MintOptions)) CredentialSource { - t.Helper() - options := workcontext.MintOptions{ - URL: mint.URL + "/platform/_credential", - Audience: workcontext.AuthorityValue{Name: AuthorityGroup, Key: AuthorityAudienceKey}, - Authority: fixedAuthority{audience: testAudience}, - ProjectedToken: workcontext.ProjectedTokenFile(tokenFile), - ProjectionAudience: testProjectionAudience, - TrustAnchor: mint.roots, - ClientCertificate: func() (*tls.Certificate, error) { return mint.caller, nil }, - AdmittedPeers: func() ([]string, error) { return []string{testGatewayPrincipal}, nil }, - } - for _, apply := range with { - apply(&options) - } - client, err := workcontext.NewMintClient(options) - if err != nil { - t.Fatal(err) - } - return client -} - // TestARenewalSealedToADifferentExecutionIsRefused is the half of round // eleven's blocker my own fix did not cover. // @@ -2498,100 +2330,6 @@ func TestARenewalSealedToADifferentExecutionIsRefused(t *testing.T) { }) } -// TestTheMintRefusesToPresentARotatedIdentity is round fourteen's blocker, on -// the production path rather than through a hand-built client. -// -// platformCredentialSource handed outboundLeaf — the SDK's reloader over the -// projected files — straight to MintOptions.ClientCertificate. The gateway's -// configuration gets holdPresentedCertificate; this reader got nothing. So -// after a same-CA rotation to a neighbouring workload's certificate, the mint -// hop presented THAT identity while the frozen principal was still this one, -// and the SDK cannot catch it: it checks that a certificate is non-empty and -// has a key, which is the right division of labour and not a statement about -// whose certificate it is. -// -// The three steps the reviewer asked for, in order: A mints; a rotation to B -// produces NO mint request; a fresh certificate for A mints again. The middle -// one is the finding — and "no request" rather than "an error" is the -// assertion, because the refusal has to happen before the projected -// service-account token is on the wire. -func TestTheMintRefusesToPresentARotatedIdentity(t *testing.T) { - mint := newHostMint(t, &hostMint{}) - // The boot's own environment and projected material, under the anchor the - // fake host is served with — this test is about the production path, so it - // uses what a boot uses rather than a hand-built client. - certFile, keyFile, bundleFile, _, _, c := bootIdentityIn(t, mint, testPrincipal) - tokenFile := filepath.Join(t.TempDir(), "token") - writeFile(t, tokenFile, "projected") - - server := New(Manifest{ID: testSolutionID}) - server.cfg = config{ - identityCertFile: certFile, - identityKeyFile: keyFile, - trustBundleFile: bundleFile, - allowedCallersFile: identitiesFile(t, testGatewayPrincipal), - mintPeersFile: identitiesFile(t, testGatewayPrincipal), - gatewayPeersFile: identitiesFile(t, testGatewayPrincipal), - brokerInternalToken: "broker-perimeter-token", - mintURL: mint.URL + credentialMintPath, - projectedTokenPath: tokenFile, - } - server.principal = testPrincipal - // The frozen authority, read through the SDK the way a boot reads it. - server.authority = authorityFor(t, context.Background()) - source, err := server.platformCredentialSource() - if err != nil { - t.Fatalf("configure the production credential source: %v", err) - } - - // A mints. - held, err := source.Credential(context.Background()) - if err != nil { - t.Fatalf("the mint refused this workload's own identity: %v", err) - } - if got := mint.count(); got != 1 { - t.Fatalf("the issuer served %d requests for the first credential, want 1", got) - } - - // rotate replaces the projected pair in place, as a projected Secret - // update does, and advances the modification time so the SDK's reloader - // observes it (it refreshes on mtime, per handshake). - rotate := func(spiffeID string) { - t.Helper() - leafPEM, keyPEM, _ := issueLeaf(t, c.anchor, c.anchorKey, spiffeID) - writeFile(t, certFile, string(leafPEM)) - writeFile(t, keyFile, string(keyPEM)) - ahead := time.Now().Add(time.Minute) - for _, path := range []string{certFile, keyFile} { - if err := os.Chtimes(path, ahead, ahead); err != nil { - t.Fatal(err) - } - } - } - - // An ordinary rotation remains usable by this same client. - rotate(testPrincipal) - if _, err := freshMint(t, source, held); err != nil { - t.Fatalf("ordinary identity rotation refused: %v", err) - } - // A valid same-CA certificate for another workload is refused before HTTP. - rotate(rivalPrincipal) - before := mint.count() - if _, err := freshMint(t, source, held); !terminalCredentialFailure(err) { - t.Fatalf("wrong workload identity was not terminal: %v", err) - } - if got := mint.count(); got != before { - t.Fatalf("wrong identity sent %d requests", got-before) - } - // The new retry allowlist makes this permanent TLS/configuration failure - // terminal, even if the files are repaired. Only a restarted process retries. - rotate(testPrincipal) - if _, err := freshMint(t, source, held); !terminalCredentialFailure(err) || mint.count() != before { - t.Fatalf("terminal identity failure was retried: %v", err) - } - -} - // TestEachSealedFieldIsComparedOnItsOwn isolates the three comparisons that // hold this execution's credential to what it was first sealed to. // diff --git a/frontend.go b/frontend.go new file mode 100644 index 0000000..b412d6e --- /dev/null +++ b/frontend.go @@ -0,0 +1,447 @@ +package solution + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/url" + "regexp" + "strings" + "unicode" + + "gopkg.in/yaml.v3" +) + +// FrontendFileName is the one file a solution declares its frontend in, beside +// the backend service's service.codefly.yaml. The backend embeds it +// (Manifest.Frontend) and serves its manifest route from it; the CLI reads the +// same file into the presence document's frontend block. There is no other +// source: the manifest this runtime used to report to the host with its mint +// is gone, because a presence document is delivered and a process cannot +// declare what the composition did not. +// +// The file is YAML, decoded strictly — an unknown key is refused by name — and +// its top-level mapping is the block itself, no wrapper, so the CLI can decode +// it straight into core's type. Its keys are core's field names, field for +// field, and every scalar is declared: core refuses an omitted one rather than +// reading it as zero, and a file this runtime accepted that the CLI refuses +// would be the two readers disagreeing. +// +// title: Widgets # nav title; one non-empty line +// order: 10 # nav order; declared, zero included +// exposed_module: ./Page # Module Federation exposed module; one non-empty line +// host_contract: 1 # host page-contract major; at least 1 +// dashboard: {...} # optional; a mapping, served as JSON, bounded at 64 KiB +// surfaces: # optional; what the solution offers inside a client +// - id: footnote # lowercase letters and digits, dashes between +// client: word # word, powerpoint, excel or slack +// title: Footnote +// description: ... # optional +// module: /surfaces/word/footnote.js +// contract: 1 # surface contract major; at least 1 +// applies: always # declared: "always", or {tagged: [one or more tags]} +// events: [documents.entry] # optional; journal namespaces, lowercase dotted names, each once +const FrontendFileName = "solution.frontend.codefly.yaml" + +// frontendDashboardMaxBytes bounds the dashboard once encoded as JSON. The +// host carries the declaration inside a signed presence document and bounds it +// there too; the bound that fires is the one that can name the file. +const frontendDashboardMaxBytes = 64 << 10 + +// frontendDeclaration is the declared file as this runtime holds it. The +// struct mirrors core's frontend block rather than importing it: the runtime +// serves the fields, it does not sign or parse a presence document. +type frontendDeclaration struct { + Title string + Order int64 + ExposedModule string + HostContract uint64 + Dashboard any + Surfaces []frontendSurface +} + +// frontendFile is the file's shape on the way in. The scalars are pointers so +// an omitted one is told apart from a zero: core refuses an omitted scalar, +// and this runtime reads the same file. +type frontendFile struct { + Title *string `yaml:"title"` + Order *int64 `yaml:"order"` + ExposedModule *string `yaml:"exposed_module"` + HostContract *int64 `yaml:"host_contract"` + Dashboard any `yaml:"dashboard"` + Surfaces []frontendSurface `yaml:"surfaces"` +} + +// frontendSurface is one offering a solution makes inside a client application +// — a Word add-in, a Slack app — rather than as a page the host renders. The +// host's registry projects them so a client can discover, without a table of +// its own, which solutions offer it something. +type frontendSurface struct { + ID string `yaml:"id"` // unique per client, e.g. "footnote" + Client string `yaml:"client"` // client kind: word, powerpoint, excel, slack + Title string `yaml:"title"` // what the client labels the surface with + // Description is the longer explanation a client may show beside the + // title. Optional, and omitted from the manifest when empty so a client + // renders no description rather than an empty one. + Description string `yaml:"description"` + // Module is the path, on this solution's own origin, of a self-contained + // ES module the client loads, resolved against the origin the client loaded + // this solution from. The check on it catches an author reaching off that + // origin by mistake; it is not what keeps a hostile solution from doing so + // deliberately, since a solution serves its own manifest and need not use + // this runtime at all. The host's registry and the client's loader own + // that boundary and have to enforce it themselves. + Module string `yaml:"module"` + // Contract is the surface contract major this module is built against, as + // the host client's own surface documentation defines it, so a client + // refuses a surface it cannot run rather than loading it and failing + // inside. + Contract int64 `yaml:"contract"` + // Applies is "always" or a {tagged: [...]} selector, declared rather than + // defaulted: core carries no default, so a file that left it out would be + // accepted here and refused by the CLI. Events are the journal namespaces + // the surface reconciles on. What a tag selects and what a namespace names + // are the client's to interpret, not this runtime's; what is held to here + // is the shape core signs. + Applies any `yaml:"applies"` + Events []string `yaml:"events"` +} + +// surfaceAppliesAlways is the one selector keyword; the other shape is a +// {tagged: [...]} mapping. +const surfaceAppliesAlways = "always" + +// eventNamespace is a journal namespace as core signs it: lowercase dotted +// names, each segment letters and digits separated by a dot, dash or +// underscore. +var eventNamespace = regexp.MustCompile(`^[a-z0-9]+([._-][a-z0-9]+)*$`) + +// surfaceClients are the client kinds a surface may declare. A client loads +// only what names it, so an unknown kind is a surface nothing will ever load — +// caught at boot rather than showing up as a solution that simply never +// appears in the add-in. +var surfaceClients = map[string]bool{"word": true, "powerpoint": true, "excel": true, "slack": true} + +// parseFrontend decodes and checks the declared file. Every refusal names the +// file, because the author wrote it by hand and a boot is where a mistake in +// it should surface — not as a solution the host lists under no title. +func parseFrontend(raw []byte) (frontendDeclaration, error) { + if len(bytes.TrimSpace(raw)) == 0 { + return frontendDeclaration{}, fmt.Errorf("this solution declares no frontend: embed %s (go:embed) into Manifest.Frontend — it is the one source of the title, order, exposed module, host contract, dashboard and surfaces the host lists this solution under, and the CLI delivers the same file in the presence document", FrontendFileName) + } + decoder := yaml.NewDecoder(bytes.NewReader(raw)) + decoder.KnownFields(true) + var file frontendFile + if err := decoder.Decode(&file); err != nil { + return frontendDeclaration{}, fmt.Errorf("%s cannot be decoded: %w", FrontendFileName, err) + } + // One document. A second one would be silently dropped otherwise, and a + // file that looks like two declarations is not one somebody meant. + if err := decoder.Decode(new(any)); !errors.Is(err, io.EOF) { + return frontendDeclaration{}, fmt.Errorf("%s carries more than one YAML document; it declares one frontend", FrontendFileName) + } + // Every scalar declared. Core reads an omitted one as a refusal rather + // than as zero, so the file this runtime accepts is the file the CLI + // delivers, or the boot says which key is missing. + for _, scalar := range []struct { + key string + declared bool + }{ + {"title", file.Title != nil}, {"order", file.Order != nil}, + {"exposed_module", file.ExposedModule != nil}, {"host_contract", file.HostContract != nil}, + } { + if !scalar.declared { + return frontendDeclaration{}, fmt.Errorf("%s declares no %s: every scalar of the frontend block is declared, and the presence document the CLI reads from this file refuses an omitted one", FrontendFileName, scalar.key) + } + } + declared := frontendDeclaration{ + Title: strings.TrimSpace(*file.Title), Order: *file.Order, + ExposedModule: strings.TrimSpace(*file.ExposedModule), Surfaces: file.Surfaces, + } + if declared.Title == "" || strings.ContainsAny(declared.Title, "\r\n") { + return frontendDeclaration{}, fmt.Errorf("%s: title must be one non-empty line: the host lists this solution in its navigation under it", FrontendFileName) + } + if declared.ExposedModule == "" || strings.ContainsAny(declared.ExposedModule, "\r\n") { + return frontendDeclaration{}, fmt.Errorf("%s: exposed_module must be one non-empty line, the Module Federation module the host loads this solution's page from, e.g. ./Page", FrontendFileName) + } + // The host runs a page only when it recognises the contract major it + // declares, so a zero is not a lenient default: it is a solution every + // host refuses, in a boot that otherwise looks entirely healthy. + if *file.HostContract < 1 { + return frontendDeclaration{}, fmt.Errorf("%s: host_contract %d is not a host contract major; declare the major this solution's page is built against (>= 1)", FrontendFileName, *file.HostContract) + } + declared.HostContract = uint64(*file.HostContract) + if file.Dashboard != nil { + // Opaque, but it has to BE the JSON object the host reads: it is + // served as the manifest's dashboard slot and carried as JSON in the + // presence document, where core refuses what JSON spells more than + // one way. So it is a mapping, every mapping key is a string and no + // value is a fraction — held to here rather than left to the encoder, + // which stringifies an integer key and would serve a dashboard the + // file did not say. + dashboard, err := jsonValue(file.Dashboard) + if err != nil { + return frontendDeclaration{}, fmt.Errorf("%s: dashboard is not a JSON value the host can read (%v)", FrontendFileName, err) + } + if _, ok := dashboard.(map[string]any); !ok { + return frontendDeclaration{}, fmt.Errorf("%s: dashboard must be a mapping, the JSON object the host's data-graph validator reads", FrontendFileName) + } + encoded, err := json.Marshal(dashboard) + if err != nil { + return frontendDeclaration{}, fmt.Errorf("%s: dashboard is not a JSON value (%v)", FrontendFileName, err) + } + if len(encoded) > frontendDashboardMaxBytes { + return frontendDeclaration{}, fmt.Errorf("%s: dashboard is %d bytes as JSON and at most %d are carried", FrontendFileName, len(encoded), frontendDashboardMaxBytes) + } + declared.Dashboard = dashboard + } + for i := range declared.Surfaces { + if declared.Surfaces[i].Applies == nil { + continue + } + applies, err := jsonValue(declared.Surfaces[i].Applies) + if err != nil { + return frontendDeclaration{}, fmt.Errorf("%s: surface %q: applies is not a JSON value (%v)", FrontendFileName, declared.Surfaces[i].ID, err) + } + declared.Surfaces[i].Applies = applies + } + if err := validateSurfaces(declared.Surfaces); err != nil { + return frontendDeclaration{}, fmt.Errorf("%s: %w", FrontendFileName, err) + } + return declared, nil +} + +// jsonValue is a decoded YAML value as JSON holds it: every mapping keyed by +// strings, recursively. yaml.v3 decodes a mapping with a non-string key into a +// map keyed by interface values, and Go's JSON encoder then stringifies an +// integer key and refuses a boolean one — so a YAML `1:` would be served as +// "1" and a `true:` would fail at the encoder. Both are refused by name. +func jsonValue(value any) (any, error) { + switch v := value.(type) { + case map[string]any: + out := make(map[string]any, len(v)) + for key, inner := range v { + converted, err := jsonValue(inner) + if err != nil { + return nil, err + } + out[key] = converted + } + return out, nil + case map[any]any: + out := make(map[string]any, len(v)) + for key, inner := range v { + name, ok := key.(string) + if !ok { + return nil, fmt.Errorf("mapping key %v is not a string", key) + } + converted, err := jsonValue(inner) + if err != nil { + return nil, err + } + out[name] = converted + } + return out, nil + case []any: + out := make([]any, len(v)) + for i, inner := range v { + converted, err := jsonValue(inner) + if err != nil { + return nil, err + } + out[i] = converted + } + return out, nil + case float64, float32: + return nil, fmt.Errorf("value %v is a fraction, which JSON spells more than one way", v) + } + return value, nil +} + +// validateSurfaces refuses a surface declaration no client could use. +func validateSurfaces(surfaces []frontendSurface) error { + // Keyed by client and id together: two clients never see each other's + // surfaces, so the same offering carries the same id in Word and in + // PowerPoint, and only a collision within one client is ambiguous. The id + // and client checks below run first, so neither half of the key can contain + // the separator and no two distinct pairs can collide into one string. + seen := make(map[string]bool, len(surfaces)) + for i, surface := range surfaces { + switch { + case surface.ID == "": + return fmt.Errorf("surface %d has no id", i) + case !isSurfaceID(surface.ID): + return fmt.Errorf("surface %q: id must be lowercase letters and digits, separated by dashes", surface.ID) + case surface.Title == "": + return fmt.Errorf("surface %q has no title: a client labels the surface with it, and renders a blank control without one", surface.ID) + case !surfaceClients[surface.Client]: + return fmt.Errorf("surface %q: unknown client kind %q", surface.ID, surface.Client) + case seen[surface.Client+"/"+surface.ID]: + return fmt.Errorf("surface %q declared twice for client %q: a client addresses a surface by id, so two of its own cannot share one", + surface.ID, surface.Client) + case surface.Contract < 1: + // A client runs a surface only when it recognises the contract + // major it declares, so the zero an author leaves behind is not a + // lenient default: it is a surface every client refuses, in a boot + // that otherwise looks entirely healthy. + return fmt.Errorf("surface %q: contract %d is not a surface contract major; declare the major this module is built against (>= 1)", + surface.ID, surface.Contract) + case !isSameOriginPath(surface.Module): + return fmt.Errorf("surface %q: module %q must be a path on this solution's own origin, e.g. %q", + surface.ID, surface.Module, "/surfaces/"+surface.Client+"/"+surface.ID+".js") + } + if err := validateApplies(surface.Applies); err != nil { + return fmt.Errorf("surface %q: %w", surface.ID, err) + } + if err := validateEvents(surface.Events); err != nil { + return fmt.Errorf("surface %q: %w", surface.ID, err) + } + seen[surface.Client+"/"+surface.ID] = true + } + return nil +} + +// validateApplies holds a surface's selector to the two shapes the host client +// reads: the keyword "always", or {tagged: [one or more non-empty tags]}. +// Declared, never defaulted: core refuses a surface declaring none. +func validateApplies(applies any) error { + switch selector := applies.(type) { + case nil: + return fmt.Errorf("declares no applies: write `applies: always`, or `applies: {tagged: [...]}` naming one or more tags") + case string: + if selector != surfaceAppliesAlways { + return fmt.Errorf("applies %q is neither %q nor a {tagged: [...]} selector", selector, surfaceAppliesAlways) + } + return nil + case map[string]any: + tagged, ok := selector["tagged"] + if !ok || len(selector) != 1 { + return fmt.Errorf("applies must be %q or a mapping with the one key tagged", surfaceAppliesAlways) + } + tags, ok := tagged.([]any) + if !ok || len(tags) == 0 { + return fmt.Errorf("applies.tagged must name one or more tags") + } + for _, tag := range tags { + if name, ok := tag.(string); !ok || strings.TrimSpace(name) == "" { + return fmt.Errorf("applies.tagged names an empty tag, which selects nothing") + } + } + return nil + } + return fmt.Errorf("applies must be %q or a {tagged: [...]} selector", surfaceAppliesAlways) +} + +// validateEvents holds a surface's journal namespaces to the grammar core +// signs: lowercase dotted names, each named once. +func validateEvents(events []string) error { + seen := make(map[string]bool, len(events)) + for _, event := range events { + if !eventNamespace.MatchString(event) { + return fmt.Errorf("event %q is not a journal namespace: lowercase letters and digits, in segments separated by a dot, dash or underscore", event) + } + if seen[event] { + return fmt.Errorf("event %q is named twice", event) + } + seen[event] = true + } + return nil +} + +// isSurfaceID accepts lowercase letters and digits separated by dashes. A +// leading or trailing dash is refused so that an id is never a prefix game, and +// so the empty string cannot pass by having no characters to object to. +func isSurfaceID(id string) bool { + if id == "" { + return false + } + for i, r := range id { + if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' { + continue + } + if r != '-' || i == 0 || i == len(id)-1 { + return false + } + } + return true +} + +// isSameOriginPath reports whether p addresses this solution's own origin: a +// rooted path and nothing else. +// +// Whether a path is rooted is decided by the parser that will resolve it — the +// client's, not Go's — and the two disagree on three inputs. A browser follows +// WHATWG URL, where a backslash beside the leading slash is read as a slash and +// what follows it as an authority, so "/\host/x.js" resolves to https://host/x.js +// while net/url reads that backslash as an ordinary path byte and reports no +// host at all; and where ASCII tab and newline are removed from the input before +// it is parsed, so "//host/x.js" becomes protocol-relative on arrival. None +// of the three can be seen in net/url's answer, so they are refused here by +// inspecting the bytes rather than by asking net/url what it made of them. +func isSameOriginPath(p string) bool { + if !strings.HasPrefix(p, "/") || strings.HasPrefix(p, "//") { + return false + } + for _, r := range p { + if r == '\\' || unicode.IsControl(r) { + return false + } + } + u, err := url.Parse(p) + return err == nil && u.Scheme == "" && u.Host == "" +} + +// manifestMap is the manifest this runtime serves at /.well-known/solution.json: +// the declared frontend, and the constants of this runtime's own layout — the +// federation manifest path, the backend's service alias and the capability +// path. The host builds its registry entry from the delivered presence and the +// same constants; this route is what the solution says about itself to +// whoever asks it directly. +func (s *Server) manifestMap() map[string]any { + m := map[string]any{ + "id": s.manifest.ID, + "schemaVersion": solutionManifestSchemaMajor, + "nav": map[string]any{"title": s.frontend.Title, "path": "/s/" + s.manifest.ID, "order": s.frontend.Order}, + "frontend": map[string]any{ + "type": "module-federation", + "manifestUrl": s.frontendManifestURL(), + "exposedModule": s.frontend.ExposedModule, + "hostContract": s.frontend.HostContract, + "reactRange": "^19", + }, + "backend": map[string]any{"serviceAlias": s.manifest.ID, "capabilityPath": "/.well-known/capabilities"}, + } + // Only emit the slot when declared: a null dashboard would break a host that + // feeds a present slot straight into its data-graph validator, and it keeps + // the wire byte-identical for solutions that declare no dashboard. + if s.frontend.Dashboard != nil { + m["dashboard"] = s.frontend.Dashboard + } + if len(s.frontend.Surfaces) > 0 { + surfaces := make([]any, 0, len(s.frontend.Surfaces)) + for _, surface := range s.frontend.Surfaces { + entry := map[string]any{ + "id": surface.ID, + "client": surface.Client, + "title": surface.Title, + "module": surface.Module, + "contract": surface.Contract, + "applies": surface.Applies, + } + // Absent, these two say something a client can act on — no + // description to show, nothing to reconcile on — where a null would + // only be a slot the client still has to interpret. + if surface.Description != "" { + entry["description"] = surface.Description + } + if len(surface.Events) > 0 { + entry["events"] = surface.Events + } + surfaces = append(surfaces, entry) + } + m["surfaces"] = surfaces + } + return m +} diff --git a/go.mod b/go.mod index e679603..212aaae 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( connectrpc.com/connect v1.21.0 github.com/codefly-dev/core v0.19.1 github.com/codefly-dev/sdk-go v0.3.2 - github.com/codefly-dev/sdk-go/workcontext v0.1.0 + github.com/codefly-dev/sdk-go/workcontext v0.1.1-0.20261013172033-aabfb4cf5359 github.com/google/uuid v1.6.0 github.com/modelcontextprotocol/go-sdk v1.8.0 golang.org/x/tools v0.51.0 diff --git a/go.sum b/go.sum index 0f9d1aa..2342db5 100644 --- a/go.sum +++ b/go.sum @@ -36,8 +36,8 @@ github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1 github.com/codefly-dev/gortk v0.2.0/go.mod h1:dDWUMFgAP063OCGhTEpV7FFUj9+zTcxxO/nV80VKEwg= github.com/codefly-dev/sdk-go v0.3.2 h1:H1Tstn5f2HWO/Hp813RFxxDPAlWZEIN6gKtxpzai1CY= github.com/codefly-dev/sdk-go v0.3.2/go.mod h1:B7ULHzZo29FCvE8pfCa90aPVUlL2PlHNUXrIBXuKQ4g= -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/workcontext v0.1.1-0.20261013172033-aabfb4cf5359 h1:T+xMCxRDnTSV6jjMTqDCVORLbpHoIYdL9IR3O6330oo= +github.com/codefly-dev/sdk-go/workcontext v0.1.1-0.20261013172033-aabfb4cf5359/go.mod h1:qyHZ7bvnxXWlJHt8PQ9u0gCLTClASPtmIgqUQIrigXo= 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= diff --git a/handshake_test.go b/handshake_test.go deleted file mode 100644 index c57b67a..0000000 --- a/handshake_test.go +++ /dev/null @@ -1,2337 +0,0 @@ -package solution - -import ( - "context" - "crypto/ed25519" - "crypto/rand" - "crypto/tls" - "crypto/x509" - "crypto/x509/pkix" - "errors" - "io" - "math/big" - "net" - "net/http" - "net/http/httptest" - "net/http/httptrace" - "net/url" - "os" - "path/filepath" - "strings" - "sync/atomic" - "testing" - "time" -) - -// What this file is for: every test here completes, or fails to complete, a -// real TLS 1.3 mutual-authentication handshake against the configuration this -// runtime would actually serve or dial with, and then asks what identity was on -// the wire. -// -// That is the only way to answer the question the posture checks kept getting -// wrong. A check that reads a *tls.Config and approves it is answering "is this -// configuration's description acceptable", and four separate shapes were -// demonstrated describing one identity and serving another: an SNI-keyed -// callback, a NameToCertificate map, a static certificate Go reaches without -// consulting the callback, and a tls.Certificate.Leaf that did not match its -// own DER. Nothing short of a handshake distinguishes those from a conforming -// configuration, so the tests do handshakes. -// -// net.Pipe rather than a socket, for the inbound ones: the subject is which -// certificate crypto/tls selected, which is settled before a byte of -// application data moves, and an in-memory pipe keeps it that way with no port -// to bind. The outbound ones use a real httptest TLS server, because what they -// assert is what the production http.Transport does with a connection. - -// rivalPrincipal is a second workload in the same cell: a valid identity, under -// the same anchor, that this workload must never be able to present or serve. -const rivalPrincipal = "spiffe://codefly.test/ns/solutions/sa/rival" - -// handshook is one handshake's outcome from both ends. Both are needed: in TLS -// 1.3 the client finishes before it learns whether the server accepted its -// certificate, so "the server refused this caller" is only visible on the -// server's side, while "which certificate was served" is only visible on the -// client's. -type handshook struct { - served *x509.Certificate - serverErr, cliErr error -} - -// refused reports that the handshake did not complete as an authenticated -// connection, from whichever side noticed. -func (h handshook) refused() bool { return h.serverErr != nil || h.cliErr != nil } - -func (h handshook) reason() string { - switch { - case h.serverErr != nil: - return h.serverErr.Error() - case h.cliErr != nil: - return h.cliErr.Error() - } - return "" -} - -// servedTo performs one handshake against config, as a caller presenting -// caller, and reports the identity the caller was shown. -// -// The caller skips verification deliberately: the question is which certificate -// this listener selected, not whether a test client trusts it, and a client -// that verified would refuse before reporting what it saw. An empty serverName -// is a no-SNI hello — what a peer addressing a pod by IP sends, and the case -// the previous synthetic "localhost" probe never exercised. -func servedTo(t *testing.T, config *tls.Config, caller *tls.Certificate, serverName string) handshook { - t.Helper() - callerSide, listenerSide := net.Pipe() - done := make(chan error, 1) - go func() { - listener := tls.Server(listenerSide, config) - done <- listener.Handshake() - _ = listener.Close() - }() - - client := tls.Client(callerSide, &tls.Config{ - InsecureSkipVerify: true, - Certificates: []tls.Certificate{*caller}, - ServerName: serverName, - MinVersion: tls.VersionTLS13, - }) - outcome := handshook{cliErr: client.Handshake()} - if outcome.cliErr == nil { - if state := client.ConnectionState(); len(state.PeerCertificates) > 0 { - outcome.served = state.PeerCertificates[0] - } - // Drained while the listener finishes, because net.Pipe is unbuffered: - // a TLS 1.3 server writes session tickets after the client's Finished, - // and with nobody reading them its Handshake never returns. - go func() { _, _ = io.Copy(io.Discard, client) }() - } - outcome.serverErr = <-done - _ = callerSide.Close() - return outcome -} - -// identityOf is the SPIFFE ID a served certificate names, or "" for none. -func identityOf(leaf *x509.Certificate) string { - if leaf == nil || len(leaf.URIs) == 0 { - return "" - } - return leaf.URIs[0].String() -} - -// shapeShifter is a consumer's IdentitySource that describes one identity and -// can serve another. Each field turns on one of the four demonstrated shapes; -// none of them is exotic, and all four passed the posture checks. -type shapeShifter struct { - approved, rival *tls.Certificate - roots *x509.CertPool - - // keyedOnSNI serves approved for a hello naming "localhost" and rival for - // anything else, including the no-SNI hello a peer addressed by IP sends. - keyedOnSNI bool - // rivalForNamed is the same trick the other way round: conforming for the - // no-SNI hello a boot-time check can ask with, and the rival for a hello - // that names anything. It is the case no probe can catch, whatever name the - // probe uses, and the reason the selection is wrapped rather than sampled. - rivalForNamed bool - // byName puts both certificates in NameToCertificate, which crypto/tls - // consults when there is more than one. - byName bool - // staticRivalBeside lists the rival first among several certificates and - // returns approved from GetCertificate. Go consults the callback only when - // the hello carries a name, and picks the first compatible chain from the - // list otherwise — so this is a configuration whose served identity is - // decided by selection, which is why selection is refused outright. - staticRivalBeside bool - // staticRival carries rival in Certificates while GetCertificate returns - // approved. Go consults GetCertificate only when there is no certificate - // list or the hello carries a name, so a no-SNI caller gets the static one. - staticRival bool - // lyingLeaf hands back a pair whose Leaf field describes approved and whose - // DER is rival's. - lyingLeaf bool - // perConnection moves whichever shape is selected behind - // GetConfigForClient, the callback this runtime's own default source uses. - perConnection bool -} - -func (s shapeShifter) ServerTLSConfig() (*tls.Config, error) { - config := &tls.Config{ - MinVersion: tls.VersionTLS13, - ClientAuth: tls.RequireAndVerifyClientCert, - ClientCAs: s.roots, - } - s.apply(config) - if !s.perConnection { - return config, nil - } - // The conforming shape at the top level, the shifting one per connection. - base := &tls.Config{ - MinVersion: tls.VersionTLS13, - ClientAuth: tls.RequireAndVerifyClientCert, - ClientCAs: s.roots, - GetCertificate: func(*tls.ClientHelloInfo) (*tls.Certificate, error) { return s.approved, nil }, - } - base.GetConfigForClient = func(*tls.ClientHelloInfo) (*tls.Config, error) { return config, nil } - return base, nil -} - -// PeerAnchor is what makes the per-connection cases of -// TestTheCertificateServedIsTheCertificateChecked run at all. -// -// Without it, a source setting GetConfigForClient is refused at boot for not -// implementing PeerAnchorSource — and that refusal contains the words "identity -// source", which mentionsTheCertificate accepted, so the two perConnection -// cases took the early return and asserted nothing. Mutant C2-2 (the -// per-connection answer's certificate is not held to the frozen principal) -// survived the whole suite because of it. -// -// This source's defect is which CERTIFICATE it serves for which hello; it has -// no quarrel with the anchor, so answering the real one here leaves the -// certificate shape as the only thing under test. -func (s shapeShifter) PeerAnchor() (*x509.CertPool, error) { return s.roots, nil } - -func (s shapeShifter) apply(config *tls.Config) { - switch { - case s.byName: - config.Certificates = []tls.Certificate{*s.approved, *s.rival} - //nolint:staticcheck // Deliberately sets the deprecated field, to prove the listener refuses a source that does. - config.NameToCertificate = map[string]*tls.Certificate{"localhost": s.rival} - case s.staticRivalBeside: - config.Certificates = []tls.Certificate{*s.rival, *s.approved} - config.GetCertificate = func(*tls.ClientHelloInfo) (*tls.Certificate, error) { return s.approved, nil } - case s.staticRival: - config.Certificates = []tls.Certificate{*s.rival} - config.GetCertificate = func(*tls.ClientHelloInfo) (*tls.Certificate, error) { return s.approved, nil } - case s.lyingLeaf: - config.GetCertificate = func(*tls.ClientHelloInfo) (*tls.Certificate, error) { - lying := *s.rival - lying.Leaf = leafOf(s.approved) - return &lying, nil - } - case s.keyedOnSNI: - config.GetCertificate = func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { - if hello.ServerName == "localhost" { - return s.approved, nil - } - return s.rival, nil - } - case s.rivalForNamed: - config.GetCertificate = func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { - if hello.ServerName == "" { - return s.approved, nil - } - return s.rival, nil - } - default: - config.GetCertificate = func(*tls.ClientHelloInfo) (*tls.Certificate, error) { return s.approved, nil } - } -} - -func leafOf(pair *tls.Certificate) *x509.Certificate { - leaf, err := x509.ParseCertificate(pair.Certificate[0]) - if err != nil { - panic(err) - } - return leaf -} - -// TestTheCertificateServedIsTheCertificateChecked is the blocker, reproduced. -// -// The check it replaces called GetCertificate with a hello naming "localhost", -// parsed what came back, compared it to the frozen principal, and left the -// configuration untouched. Four handshakes completed under configurations that -// had passed it, serving a neighbouring workload's leaf — which is the identity -// the host, the gateway and the issuer hold this process to, and the identity a -// presence document names. -// -// Each case must now either be refused at boot or refuse the handshake. What it -// must never do is complete a handshake presenting the rival: a listener whose -// served identity depends on the hello it receives is a listener whose identity -// is the caller's choice. -func TestTheCertificateServedIsTheCertificateChecked(t *testing.T) { - c := newCell(t) - approved := c.identity(t, testPrincipal) - rival := c.identity(t, rivalPrincipal) - caller := c.identity(t, testGatewayPrincipal) - - for _, tc := range []struct { - name string - shape shapeShifter - // sni is the hello this shape serves the rival for. - sni string - }{ - { - name: "a callback keyed on the server name, asked with the name nothing dials by", - shape: shapeShifter{keyedOnSNI: true}, - }, - { - name: "a callback keyed on the server name, behind the per-connection callback", - shape: shapeShifter{keyedOnSNI: true, perConnection: true}, - }, - { - name: "a callback that answers any boot-time probe correctly and the real hello otherwise", - shape: shapeShifter{rivalForNamed: true}, - sni: "localhost", - }, - { - name: "the same, behind the per-connection callback", - shape: shapeShifter{rivalForNamed: true, perConnection: true}, - sni: "localhost", - }, - { - name: "name selection among several certificates", - shape: shapeShifter{byName: true}, - sni: "localhost", - }, - { - name: "selection among several certificates beside a conforming callback", - shape: shapeShifter{staticRivalBeside: true}, - }, - { - name: "a static certificate Go reaches without consulting the callback", - shape: shapeShifter{staticRival: true}, - }, - { - name: "a cached Leaf that does not describe its own DER", - shape: shapeShifter{lyingLeaf: true}, - }, - } { - t.Run(tc.name, func(t *testing.T) { - shape := tc.shape - shape.approved, shape.rival, shape.roots = approved, rival, c.roots - server := New(Manifest{ID: testSolutionID}).Identity(shape) - server.cfg = config{allowedCallersFile: identitiesFile(t, testGatewayPrincipal), mintPeersFile: identitiesFile(t, testGatewayPrincipal), - gatewayPeersFile: identitiesFile(t, testGatewayPrincipal)} - server.principal = testPrincipal - - config, err := server.serverIdentity() - if err != nil { - // Refused at boot, which is the better of the two outcomes: - // nothing is served and the mint has not happened yet. But - // only for the right reason — returning on *any* boot error - // made every case here pass the moment serverIdentity failed - // over an unresolved path or an unreadable admission set, - // which is a test reporting success for a cause it never - // exercised. - if !mentionsTheCertificate(err) { - t.Fatalf("the boot refused this source for something other than the certificate it would serve, so this case never ran: %v", err) - } - return - } - outcome := servedTo(t, config, caller, tc.sni) - if got := identityOf(outcome.served); got == rivalPrincipal { - t.Fatalf("the listener served %s: the configuration passed every posture check describing %s, and the certificate on the wire is the identity the host holds this process to", - got, testPrincipal) - } - if !outcome.refused() && identityOf(outcome.served) != testPrincipal { - t.Fatalf("a handshake completed serving %q, which is neither this workload's principal nor a refusal", identityOf(outcome.served)) - } - if outcome.refused() && !strings.Contains(outcome.reason(), "certificate") { - t.Errorf("the handshake was refused with %q, which does not say it was about the certificate", outcome.reason()) - } - }) - } - - t.Run("and the conforming shape still serves", func(t *testing.T) { - // The control. Without it, refusing every handshake would pass every - // case above, which is not a listener. - shape := shapeShifter{approved: approved, rival: rival, roots: c.roots} - server := New(Manifest{ID: testSolutionID}).Identity(shape) - server.cfg = config{allowedCallersFile: identitiesFile(t, testGatewayPrincipal), mintPeersFile: identitiesFile(t, testGatewayPrincipal), - gatewayPeersFile: identitiesFile(t, testGatewayPrincipal)} - server.principal = testPrincipal - config, err := server.serverIdentity() - if err != nil { - t.Fatalf("a conforming source was refused: %v", err) - } - outcome := servedTo(t, config, caller, "") - if outcome.refused() { - t.Fatalf("a conforming listener refused a handshake from an admitted caller: %v", outcome.reason()) - } - if got := identityOf(outcome.served); got != testPrincipal { - t.Errorf("the listener served %q, want this workload's own %q", got, testPrincipal) - } - }) -} - -// TestANilPerConnectionAnswerCannotDropTheTrustAnchor: a callback answering nil -// means "serve the base configuration", and the base was admitted at boot only -// because a callback would resolve the anchor per handshake. A source that -// answers for one hello and not another therefore served a listener that -// requires a caller's certificate and verifies it against this host's system -// roots — mutual TLS in every log line, and a client certificate from any -// public CA admitted. -func TestANilPerConnectionAnswerCannotDropTheTrustAnchor(t *testing.T) { - c := newCell(t) - approved := c.identity(t, testPrincipal) - caller := c.identity(t, testGatewayPrincipal) - - server := New(Manifest{ID: testSolutionID}).Identity(conditionalAnchor{approved: approved, roots: c.roots}) - server.cfg = config{allowedCallersFile: identitiesFile(t, testGatewayPrincipal), mintPeersFile: identitiesFile(t, testGatewayPrincipal), - gatewayPeersFile: identitiesFile(t, testGatewayPrincipal)} - server.principal = testPrincipal - config, err := server.serverIdentity() - // THIS FIXTURE MUST BOOT. The early return here read "refused at boot is - // also fail-closed" and let ANY boot error pass the test — which is how - // the missing PeerAnchor made every assertion below unreachable for - // several rounds. That fixture defect is fixed; this closes the door it - // came through, so an unrelated boot regression fails here instead of - // quietly passing. - if err != nil { - t.Fatalf("this source boots: it implements PeerAnchorSource and carries a usable anchor, so a refusal here means something unrelated broke and every assertion below would otherwise be skipped: %v", err) - } - outcome := servedTo(t, config, caller, "") - if !outcome.refused() { - t.Fatalf("a handshake completed under a configuration with no trust anchor: the callback answered nothing for this hello, so the base configuration served — and it verifies a caller's certificate against this host's system roots") - } - if !strings.Contains(outcome.reason(), "anchor") { - t.Errorf("the refusal %q does not name the missing anchor", outcome.reason()) - } - // The answered case still works, or this is a listener that refuses - // everything rather than one that refuses the hole. - if outcome := servedTo(t, config, caller, "localhost"); outcome.refused() { - t.Errorf("the handshake the source does answer for was refused: %v", outcome.reason()) - } -} - -// conditionalAnchor resolves the anchor per handshake for a hello naming -// "localhost" and answers nothing for any other, with no anchor on the base -// configuration to fall back to. -type conditionalAnchor struct { - approved *tls.Certificate - roots *x509.CertPool -} - -func (a conditionalAnchor) ServerTLSConfig() (*tls.Config, error) { - base := &tls.Config{ - MinVersion: tls.VersionTLS13, - ClientAuth: tls.RequireAndVerifyClientCert, - GetCertificate: func(*tls.ClientHelloInfo) (*tls.Certificate, error) { return a.approved, nil }, - } - base.GetConfigForClient = func(hello *tls.ClientHelloInfo) (*tls.Config, error) { - if hello.ServerName != "localhost" { - return nil, nil - } - answered := base.Clone() - answered.ClientCAs = a.roots - answered.GetConfigForClient = nil - return answered, nil - } - return base, nil -} - -// PeerAnchor makes this source usable at boot, which is the whole reason -// TestANilPerConnectionAnswerCannotDropTheTrustAnchor can assert anything. -// -// Without it serverIdentity refuses the boot — a source resolving its -// configuration per handshake has to be able to answer what its anchor is -// without a handshake — and that test's "refused at boot is also fail-closed" -// early return swallowed it, so every assertion below the return had been dead -// code since PeerAnchorSource landed. Mutant C2-1 (a nil per-connection answer -// serves a base configuration with no anchor) survived the whole suite because -// of it. -// -// The anchor is the real one: this source's defect is what it answers PER -// HANDSHAKE, not what it can say about its trust, so answering honestly here -// is what leaves the per-handshake hole as the only thing under test. -func (a conditionalAnchor) PeerAnchor() (*x509.CertPool, error) { - return a.roots, nil -} - -// TestTheAdmittedCallerSetIsResolvedPerHandshake: the set was read once at -// boot, and a snapshotted admission decision has the defect this runtime -// already refuses to accept for the trust anchor. Removing a compromised -// consumed module from the provisioned set left this listener admitting it -// until somebody restarted the process. -// -// The drift here is a rewrite of the provisioned file, which is what a platform -// rotating an admission set actually does. The previous version of this test -// drifted the set by mutating a closure the test itself supplied, and passed -// against a runtime where no deployment could drift it at all: the set was read -// through the SDK's value accessor, which is fixed at process start, so the -// production answer never changed and only the test's closure did. -func TestTheAdmittedCallerSetIsResolvedPerHandshake(t *testing.T) { - c := newCell(t) - approved := c.identity(t, testPrincipal) - caller := c.identity(t, testGatewayPrincipal) - - provisioned := identitiesFile(t, testGatewayPrincipal) - server := New(Manifest{ID: testSolutionID}).Identity(shapeShifter{approved: approved, roots: c.roots}) - server.cfg = config{ - allowedCallersFile: provisioned, - mintPeersFile: identitiesFile(t, testGatewayPrincipal), - gatewayPeersFile: identitiesFile(t, testGatewayPrincipal), - } - server.principal = testPrincipal - config, err := server.serverIdentity() - if err != nil { - t.Fatalf("boot: %v", err) - } - if outcome := servedTo(t, config, caller, ""); outcome.refused() { - t.Fatalf("an admitted caller was refused: %v", outcome.reason()) - } - - // The platform removes that caller from the file. No restart, no new - // listener. - writeFile(t, provisioned, "spiffe://codefly.test/ns/platform/sa/somebody-else\n") - outcome := servedTo(t, config, caller, "") - if !outcome.refused() { - t.Fatal("a caller removed from the provisioned set was still admitted: the listener is judging by the set it booted with, so revocation needs a restart somebody has to remember to perform") - } - - // A set that resolves to nothing refuses, rather than falling back to the - // set this process booted with. - writeFile(t, provisioned, "\n") - if outcome := servedTo(t, config, caller, ""); !outcome.refused() { - t.Fatal("a caller was admitted while the provisioned set resolved to nothing: an admission decision that cannot be resolved is not one to guess at") - } - - // And so does one that cannot be read at all, for the same reason: an - // unreadable set is not an empty set and it is not the boot set either. - if err := os.Remove(provisioned); err != nil { - t.Fatal(err) - } - if outcome := servedTo(t, config, caller, ""); !outcome.refused() { - t.Fatal("a caller was admitted while the provisioned set could not be read: the only safe reading of an unreadable admission set is to refuse") - } -} - -// TestTheProvisionedCallerSetIsReadThroughLoadConfig is the other half: the -// path production reads has to be the one loadConfig resolves, through the -// documented override, and the content has to be re-read rather than captured. -// -// It is the test that could not have been written before. The set was a -// configuration *value*, and a configuration value is fixed at process start — -// inline or file-carried, the SDK reads it once and keeps it (sdk-go -// file_carrier.go) — so "re-resolved per handshake" returned the boot answer -// forever, and the only way to make a test show drift was to drift something no -// platform touches. Here the test changes what the platform changes. -func TestTheProvisionedCallerSetIsReadThroughLoadConfig(t *testing.T) { - callers := identitiesFile(t, testGatewayPrincipal) - t.Setenv(IdentityAllowedCallersFileEnvironmentVariable, callers) - peers := identitiesFile(t, testGatewayPrincipal) - t.Setenv(IdentityMintPeersFileEnvironmentVariable, peers) - - cfg := loadConfig(context.Background(), testSolutionID, nil) - if cfg.allowedCallersFile != callers { - t.Fatalf("loadConfig resolved the caller set from %q, want the provisioned %q", cfg.allowedCallersFile, callers) - } - if cfg.mintPeersFile != peers { - t.Fatalf("loadConfig resolved the mint peer set from %q, want the provisioned %q", cfg.mintPeersFile, peers) - } - - server := New(Manifest{ID: testSolutionID}) - server.cfg = cfg - admits := server.admittedCallers() - got, err := admits() - if err != nil { - t.Fatalf("resolve the admitted callers: %v", err) - } - if len(got) != 1 || got[0] != testGatewayPrincipal { - t.Fatalf("the admitted callers resolved to %v, want %q", got, testGatewayPrincipal) - } - - // The platform rewrites it. Nothing restarts. - writeFile(t, callers, "spiffe://codefly.test/ns/platform/sa/somebody-else\n") - got, err = admits() - if err != nil { - t.Fatalf("resolve the admitted callers after the set was rewritten: %v", err) - } - if len(got) != 1 || got[0] != "spiffe://codefly.test/ns/platform/sa/somebody-else" { - t.Fatalf("the admitted callers resolved to %v after the provisioned set was rewritten: it is reading something it captured", got) - } -} - -// TestAnAdmissionSetIsNeitherCachedNorDefaulted pins the two answers that are -// not an identity list, since each one has a plausible-looking wrong behaviour: -// falling back to the boot set, or treating an empty file as "admit nobody" and -// carrying on. -func TestAnAdmissionSetIsNeitherCachedNorDefaulted(t *testing.T) { - for _, tc := range []struct { - name string - content string - write bool - }{ - {"an empty file", "", true}, - {"a file of only separators", ",,\n\n", true}, - {"a file of only comments", "# the gateway, once\n", true}, - {"no file at all", "", false}, - } { - t.Run(tc.name, func(t *testing.T) { - path := filepath.Join(t.TempDir(), "identities") - if tc.write { - writeFile(t, path, tc.content) - } - if _, err := resolvedIdentities(path, "unresolved")(); err == nil { - t.Error("an unusable admission set resolved without an error, so a handshake would be judged by something other than the provisioned set") - } - }) - } - // And the path itself being unset is refused, not read as "no restriction". - if _, err := resolvedIdentities("", "unresolved")(); err == nil { - t.Error("an unset admission path resolved without an error") - } -} - -// platformHost is a platform endpoint — the mint, the gateway — served under -// the cell's anchor, requiring the caller's certificate, recording who called. -type platformHost struct { - *httptest.Server - callers chan string -} - -func newPlatformHost(t *testing.T, c *cell, identity string) *platformHost { - t.Helper() - host := &platformHost{callers: make(chan string, 64)} - host.Server = httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - select { - case host.callers <- callerIdentity(r): - default: - } - writeJSON(w, http.StatusOK, map[string]string{"ok": "yes"}) - })) - host.TLS = &tls.Config{ - MinVersion: tls.VersionTLS13, - Certificates: []tls.Certificate{*c.identity(t, identity)}, - ClientCAs: c.roots, - ClientAuth: tls.RequireAndVerifyClientCert, - } - host.StartTLS() - t.Cleanup(host.Close) - return host -} - -func (h *platformHost) called() []string { - var seen []string - for { - select { - case caller := <-h.callers: - seen = append(seen, caller) - default: - return seen - } - } -} - -// TestTheOutboundLeafIsHeldWhenItIsPresented: a projection that rotates to a -// neighbouring workload's identity before anything is dialled is refused, and -// the rival never reaches the platform. -// -// What this does NOT prove, despite its name, is that the hold runs at the -// moment of presentation. The outbound configuration is rebuilt per dial, so -// the check made while building it is already at-use for this case, and -// removing the wrapper around the callback leaves this test passing. The -// at-use half — a rotation landing between that check and the handshake — is -// TestARotationBetweenTheCheckAndTheHandshakeIsRefused, and the wrapper is -// driven directly by TestTheOutboundHoldRefusesTheAnswerAHandshakeGets. -// Saying so here because a test whose name claims the stronger property is -// how the weaker one gets mistaken for coverage. -func TestTheOutboundLeafIsHeldWhenItIsPresented(t *testing.T) { - c := newCell(t) - certFile, keyFile, bundleFile, _, _ := c.workload(t, testPrincipal) - host := newPlatformHost(t, c, testGatewayPrincipal) - - server := New(Manifest{ID: testSolutionID}) - server.cfg = config{identityCertFile: certFile, identityKeyFile: keyFile, trustBundleFile: bundleFile, - mintPeersFile: identitiesFile(t, testGatewayPrincipal), - gatewayPeersFile: identitiesFile(t, testGatewayPrincipal), - // The destination this test dials, so the authorization set for it is - // the mint's: the set is chosen per destination now. - mintURL: host.URL + credentialMintPath} - server.principal = testPrincipal - client, err := server.outboundClient(nil) - if err != nil { - t.Fatalf("outboundClient: %v", err) - } - - // The projection is replaced after the client was built and before anything - // was dialled: the window the preflight left open. - rivalCert, rivalKey, _, _, _ := c.workload(t, rivalPrincipal) - writeFile(t, certFile, readFile(t, rivalCert)) - writeFile(t, keyFile, readFile(t, rivalKey)) - - resp, err := client.Get(host.URL + credentialMintPath) - if err == nil { - _ = resp.Body.Close() - t.Fatal("the mint was called after the projected pair rotated to another workload's identity: the check ran before the handshake, and the handshake presented whatever the reloader held by then") - } - if !strings.Contains(err.Error(), rivalPrincipal) || !strings.Contains(err.Error(), testPrincipal) { - t.Errorf("the refusal %q does not name both the identity that would have been presented and this workload's frozen principal", err) - } - for _, caller := range host.called() { - if caller == rivalPrincipal { - t.Error("the platform saw a request from the rival identity") - } - } -} - -// TestTheOutboundPeerMustBeAProvisionedPlatformIdentity: verifying the chain -// and the hostname says a destination holds a certificate this cell issued, -// which every workload in the cell does. A neighbouring workload answering at -// the gateway's address under a certificate valid for that address completed -// this handshake and was handed the projected token, the viewer's bearer and -// this workload's own credential. -func TestTheOutboundPeerMustBeAProvisionedPlatformIdentity(t *testing.T) { - c := newCell(t) - certFile, keyFile, bundleFile, _, _ := c.workload(t, testPrincipal) - - build := func(t *testing.T, host *platformHost) *http.Client { - t.Helper() - server := New(Manifest{ID: testSolutionID}) - server.cfg = config{identityCertFile: certFile, identityKeyFile: keyFile, trustBundleFile: bundleFile, - mintPeersFile: identitiesFile(t, testGatewayPrincipal), - gatewayPeersFile: identitiesFile(t, testGatewayPrincipal), - mintURL: host.URL + credentialMintPath} - server.principal = testPrincipal - client, err := server.outboundClient(nil) - if err != nil { - t.Fatalf("outboundClient: %v", err) - } - return client - } - - t.Run("the provisioned platform identity is dialled", func(t *testing.T) { - host := newPlatformHost(t, c, testGatewayPrincipal) - resp, err := build(t, host).Get(host.URL + credentialMintPath) - if err != nil { - t.Fatalf("the provisioned platform destination was refused: %v", err) - } - _ = resp.Body.Close() - }) - - t.Run("another workload at the same address is not", func(t *testing.T) { - // A valid certificate from the same anchor, valid for this address, - // naming a workload that is not the platform. - host := newPlatformHost(t, c, rivalPrincipal) - resp, err := build(t, host).Get(host.URL + credentialMintPath) - if err == nil { - _ = resp.Body.Close() - t.Fatal("this workload's credentials were presented to a destination that is not a provisioned platform identity: a chain and a hostname do not distinguish the gateway from any other workload in the cell") - } - if !strings.Contains(err.Error(), rivalPrincipal) { - t.Errorf("the refusal %q does not name the identity that answered", err) - } - }) -} - -// TestABusyConnectionDoesNotOutliveTheTrustThatAuthenticatedIt is the second -// blocker, reproduced. -// -// Per-dial reloading was documented as bounding outbound trust staleness to -// IdleConnTimeout, and that bound only exists for a connection nobody is using. -// A connection carrying a request every 100ms was demonstrated still answering -// 31 seconds after the server's root was removed from the bundle, over a single -// handshake: traffic held the connection, and the trust decision behind it, -// open indefinitely. The committed test for per-dial reloading closed idle -// connections, which is exactly the case this one does not. -func TestABusyConnectionDoesNotOutliveTheTrustThatAuthenticatedIt(t *testing.T) { - c := newCell(t) - certFile, keyFile, bundleFile, _, _ := c.workload(t, testPrincipal) - host := newPlatformHost(t, c, testGatewayPrincipal) - - server := New(Manifest{ID: testSolutionID}) - server.cfg = config{identityCertFile: certFile, identityKeyFile: keyFile, trustBundleFile: bundleFile, - mintPeersFile: identitiesFile(t, testGatewayPrincipal), - gatewayPeersFile: identitiesFile(t, testGatewayPrincipal), - // The destination this test dials, so the authorization set for it is - // the mint's: the set is chosen per destination now. - mintURL: host.URL + credentialMintPath} - server.principal = testPrincipal - client, err := server.outboundClient(nil) - if err != nil { - t.Fatalf("outboundClient: %v", err) - } - - // The control first, and it is the half that was missing: while trust is - // intact, a connection kept this busy has to keep working across several - // recheck intervals. Without this, a recheck that closed every connection - // unconditionally — or one that closed them for an unrelated reason — - // satisfied the assertion below and the test reported the guarantee. - staysUsable(t, client, host.URL+credentialMintPath, 3*outboundTrustRecheckInterval) - - // The root is removed and replaced by an unrelated one: the bundle still - // parses and no longer contains the issuer of the certificate the platform - // presents. Nothing closes the connection, and traffic keeps arriving. - other := newCell(t) - writeFile(t, bundleFile, string(other.anchorPEM)) - - err = busyUntilRefused(t, client, host.URL+credentialMintPath, 3*outboundTrustRecheckInterval) - if err == nil { - t.Fatalf("requests still succeeded %s after the platform's root was removed from the bundle, on a connection kept busy throughout: an inactivity timeout cannot bound a connection that is never inactive, so the documented staleness window held only for a quiet process", - 3*outboundTrustRecheckInterval) - } - // And it ended for the reason under test. Any-error was what let two - // mutants through: a recheck broken in a way that fails requests for some - // other cause looks identical to one that works. - if !mentionsTrustFailure(err) { - t.Errorf("the connection stopped being usable, but not over the peer's trust: %v", err) - } -} - -// staysUsable keeps ONE connection busy for d and fails if a request stops -// succeeding — or if the connection underneath is replaced. -// -// Counting connections is the half that was missing, and without it the control -// proves much less than it looks: net/http re-dials transparently, so a recheck -// that closed every connection once a second kept every request succeeding and -// satisfied this. The defect would have been invisible in exactly the test -// written to rule it out. -func staysUsable(t *testing.T, client *http.Client, url string, d time.Duration) { - t.Helper() - // Any connection dialled after the first request is a replacement. - // - // Counting non-reused dials outright is not enough: the caller may already - // hold a connection, so the first request contributes nothing, and a - // recheck closing it once would then look like the single dial a fresh - // client makes. Replacements are counted from after the first observation, - // which is the only way to tell "nothing changed" from "it was replaced". - var seen, replaced atomic.Int64 - trace := &httptrace.ClientTrace{ - GotConn: func(info httptrace.GotConnInfo) { - if seen.Add(1) > 1 && !info.Reused { - replaced.Add(1) - } - }, - } - deadline := time.Now().Add(d) - for time.Now().Before(deadline) { - request, err := http.NewRequestWithContext(httptrace.WithClientTrace(context.Background(), trace), http.MethodGet, url, nil) - if err != nil { - t.Fatal(err) - } - resp, err := client.Do(request) - if err != nil { - t.Fatalf("a request failed while trust was intact, so nothing this test goes on to assert is about trust being withdrawn: %v", err) - } - _ = resp.Body.Close() - time.Sleep(outboundTrustRecheckInterval / 20) - } - if got := replaced.Load(); got > 0 { - t.Fatalf("the connection was replaced %d times over %s while trust was intact: it is being closed and re-dialled underneath, which keeps every request succeeding and makes the recheck's own behaviour invisible", got, d) - } - if seen.Load() < 2 { - t.Fatalf("only %d requests were observed over %s, which is too few for the replacement check to mean anything", seen.Load(), d) - } -} - -// busyUntilRefused keeps one connection busy until a request fails, and returns -// that error — or nil if requests were still succeeding after d, which is the -// defect these tests exist to catch. -func busyUntilRefused(t *testing.T, client *http.Client, url string, d time.Duration) error { - t.Helper() - deadline := time.Now().Add(d) - for { - resp, err := client.Get(url) - if err != nil { - return err - } - _ = resp.Body.Close() - if time.Now().After(deadline) { - return nil - } - time.Sleep(outboundTrustRecheckInterval / 20) - } -} - -// mentionsTrustFailure reports whether an error is the recheck's own judgement -// about the peer, rather than any other way a request can fail. -func mentionsTrustFailure(err error) bool { - // Both the recheck's own judgement and the refusal a *re-dial* makes - // count: once the recheck closes the connection, net/http dials again, and - // that dial is refused by the same anchor and peer set. What must not pass - // is an error from anywhere else. - for _, named := range []string{ - "no longer verifies", - "no longer one this runtime presents", - "no longer one this solution admits", - "refusing to present this workload's credentials", - "certificate signed by unknown authority", - "certificate is not trusted", - } { - if strings.Contains(err.Error(), named) { - return true - } - } - return false -} - -// TestEveryPlatformConnectionIsWatched is the narrow half of the test above: it -// is the production transport's own dialler that has to attach the recheck, not -// a wrapper a test builds. -// -// It asserts the recheck's *behaviour* on the dialled connection, not its type. -// A type assertion on the wrapper proves the transport returned something -// named right, which survives a recheck that never fires — and a mutant that -// stopped the ticker left this test green. -func TestEveryPlatformConnectionIsWatched(t *testing.T) { - c := newCell(t) - certFile, keyFile, bundleFile, _, _ := c.workload(t, testPrincipal) - host := newPlatformHost(t, c, testGatewayPrincipal) - - server := New(Manifest{ID: testSolutionID}) - server.cfg = config{identityCertFile: certFile, identityKeyFile: keyFile, trustBundleFile: bundleFile, - mintPeersFile: identitiesFile(t, testGatewayPrincipal), - gatewayPeersFile: identitiesFile(t, testGatewayPrincipal), - // The destination this test dials, so the authorization set for it is - // the mint's: the set is chosen per destination now. - mintURL: host.URL + credentialMintPath} - server.principal = testPrincipal - client, err := server.outboundClient(nil) - if err != nil { - t.Fatalf("outboundClient: %v", err) - } - transport, ok := client.Transport.(*http.Transport) - if !ok { - t.Fatalf("outbound transport is %T, want *http.Transport", client.Transport) - } - if transport.DialTLSContext == nil { - t.Fatal("the outbound transport has no TLS dialler of its own, so every connection it makes is configured once and never re-judged") - } - address := strings.TrimPrefix(host.URL, "https://") - conn, err := transport.DialTLSContext(context.Background(), "tcp", address) - if err != nil { - t.Fatalf("dial the platform: %v", err) - } - defer func() { _ = conn.Close() }() - - // The connection is usable, and stays usable while trust holds. - // - // HTTP/1.1 with keep-alive, and the response drained, because the point of - // the exchange is to leave the connection OPEN. This asked over HTTP/1.0, - // so the server closed it as soon as it had answered — and the loop below - // returns on io.EOF, which means it returned on the server's own close and - // passed identically with no watcher installed at all. Mutant C2-3 (an - // established outbound connection is not watched) survived the whole - // suite on exactly that. - request := "GET " + credentialMintPath + " HTTP/1.1\r\nHost: " + address + "\r\nConnection: keep-alive\r\n\r\n" - if _, err := conn.Write([]byte(request)); err != nil { - t.Fatalf("the dialled connection was not usable: %v", err) - } - // Drain what the host answered. A read that times out means there is - // nothing more to read AND the connection is still open, which is the - // state this test needs before it can attribute a later close. - answered := 0 - for { - _ = conn.SetReadDeadline(time.Now().Add(outboundTrustRecheckInterval)) - var buf [512]byte - read, err := conn.Read(buf[:]) - answered += read - if errors.Is(err, os.ErrDeadlineExceeded) { - break - } - if err != nil { - t.Fatalf("the connection closed while the host was answering (%v), so a close observed after the root is removed would not be the watcher's doing and this test could not tell the two apart", err) - } - } - if answered == 0 { - t.Fatal("the host answered nothing, so this connection was never a working one and what closes it later is unattributable") - } - time.Sleep(2 * outboundTrustRecheckInterval) - - // Now the root goes, and this connection — which no transport is polling, - // so nothing but its own watcher can notice — has to close itself. - other := newCell(t) - writeFile(t, bundleFile, string(other.anchorPEM)) - - deadline := time.Now().Add(5 * outboundTrustRecheckInterval) - for { - _ = conn.SetReadDeadline(time.Now().Add(outboundTrustRecheckInterval / 4)) - var buf [1]byte - _, err := conn.Read(buf[:]) - if errors.Is(err, net.ErrClosed) || errors.Is(err, io.EOF) { - return // the watcher closed it, which is the whole point - } - if time.Now().After(deadline) { - t.Fatalf("a connection the transport dialled was still open %s after the platform's root was removed: a connection whose trust is never re-verified is bounded only by how long it stays idle, which traffic prevents", - 5*outboundTrustRecheckInterval) - } - } -} - -// rotatingIdentity is a consumer's source whose leaf changes between calls: -// conforming the first time it is asked, a neighbouring workload's after that. -// -// It is the shape of a real rotation — the platform replaces the projected pair -// under a running process, deliberately and often — compressed so a test can -// land it in the window between a check and the handshake that check was meant -// to cover. Nothing about it is adversarial; the first call being the one a -// preflight makes is the whole defect. -type rotatingIdentity struct { - approved, rival *tls.Certificate - roots *x509.CertPool - asked *int -} - -func (r rotatingIdentity) ServerTLSConfig() (*tls.Config, error) { - return &tls.Config{ - MinVersion: tls.VersionTLS13, - ClientAuth: tls.RequireAndVerifyClientCert, - ClientCAs: r.roots, - GetCertificate: func(*tls.ClientHelloInfo) (*tls.Certificate, error) { - *r.asked++ - if *r.asked == 1 { - return r.approved, nil - } - return r.rival, nil - }, - }, nil -} - -// TestARotationBetweenTheCheckAndTheHandshakeIsRefused is the outbound -// check/use gap on its own. -// -// The outbound configuration is rebuilt per dial, so its preflight runs close -// to the handshake — close enough that a file rotation rarely lands between -// them, which is why the busy-connection and rotation tests above cannot tell -// the preflight from the wrapper. A source asked twice can: the first answer is -// the one the check reads, the second is the one the handshake sends. Only -// holding the pair at presentation refuses that. -func TestARotationBetweenTheCheckAndTheHandshakeIsRefused(t *testing.T) { - c := newCell(t) - host := newPlatformHost(t, c, testGatewayPrincipal) - asked := 0 - source := rotatingIdentity{approved: c.identity(t, testPrincipal), rival: c.identity(t, rivalPrincipal), roots: c.roots, asked: &asked} - - server := New(Manifest{ID: testSolutionID}).Identity(source) - server.cfg = config{allowedCallersFile: identitiesFile(t, testGatewayPrincipal), mintPeersFile: identitiesFile(t, testGatewayPrincipal), - gatewayPeersFile: identitiesFile(t, testGatewayPrincipal), - mintURL: host.URL + credentialMintPath} - server.principal = testPrincipal - identity, err := server.serverIdentity() - if err != nil { - t.Fatalf("the first answer is this workload's own, so the boot must not be refused: %v", err) - } - client, err := server.outboundClient(identity) - if err != nil { - t.Fatalf("outboundClient: %v", err) - } - resp, err := client.Get(host.URL + credentialMintPath) - if err == nil { - _ = resp.Body.Close() - t.Fatalf("the platform was dialled with the certificate the source produced *after* the one that was checked (asked %d times): a check that samples the source and leaves its callback installed is a check a rotation walks past", - asked) - } - if !strings.Contains(err.Error(), rivalPrincipal) { - t.Errorf("the refusal %q does not name the identity that would have been presented", err) - } - for _, caller := range host.called() { - if caller == rivalPrincipal { - t.Error("the platform saw a request from the rival identity") - } - } -} - -// TestTheOutboundHoldRefusesTheAnswerAHandshakeGets is the same property as a -// unit, because the end-to-end window is too small to wedge a test into on the -// default path: the outbound configuration is rebuilt per dial, so its -// boot-time refusal runs microseconds before the handshake it covers. What -// closes the window is that the callback itself is wrapped, so the pair a -// handshake asks for is the pair that is compared — and that is checkable -// directly. -func TestTheOutboundHoldRefusesTheAnswerAHandshakeGets(t *testing.T) { - c := newCell(t) - approved, rival := c.identity(t, testPrincipal), c.identity(t, rivalPrincipal) - asked := 0 - config := &tls.Config{ - MinVersion: tls.VersionTLS13, - RootCAs: c.roots, - GetClientCertificate: func(*tls.CertificateRequestInfo) (*tls.Certificate, error) { - asked++ - if asked == 1 { - return approved, nil - } - return rival, nil - }, - } - if err := holdPresentedCertificate(config, testPrincipal); err != nil { - t.Fatalf("the first answer is this workload's own, so the hold must accept it: %v", err) - } - pair, err := config.GetClientCertificate(&tls.CertificateRequestInfo{}) - if err == nil { - t.Fatalf("the certificate a handshake asked for (%s) was returned unchecked: the hold read an earlier answer and left the source's callback installed, which is a check a rotation walks past", - identityOf(leafOf(pair))) - } - if !strings.Contains(err.Error(), rivalPrincipal) { - t.Errorf("the refusal %q does not name the identity that would have been presented", err) - } -} - -// TestAnEstablishedConnectionFollowsTheProvisionedPeerSet: the recheck is -// supposed to apply the *current* allowed-peer set, not only the current -// anchor. Without that, a destination removed from the provisioned set keeps -// every connection it already has — which is the same staleness the anchor half -// of this was fixed for, one field along. -func TestAnEstablishedConnectionFollowsTheProvisionedPeerSet(t *testing.T) { - c := newCell(t) - certFile, keyFile, bundleFile, _, _ := c.workload(t, testPrincipal) - host := newPlatformHost(t, c, testGatewayPrincipal) - - provisioned := identitiesFile(t, testGatewayPrincipal) - server := New(Manifest{ID: testSolutionID}) - server.cfg = config{identityCertFile: certFile, identityKeyFile: keyFile, trustBundleFile: bundleFile, - mintPeersFile: provisioned, - gatewayPeersFile: provisioned, - mintURL: host.URL + credentialMintPath} - server.principal = testPrincipal - client, err := server.outboundClient(nil) - if err != nil { - t.Fatalf("outboundClient: %v", err) - } - staysUsable(t, client, host.URL+credentialMintPath, 3*outboundTrustRecheckInterval) - - // That destination is removed from the provisioned set, and traffic keeps - // arriving on the connection it already has. - writeFile(t, provisioned, "spiffe://codefly.test/ns/platform/sa/somebody-else\n") - err = busyUntilRefused(t, client, host.URL+credentialMintPath, 3*outboundTrustRecheckInterval) - if err == nil { - t.Fatalf("this workload kept presenting its credentials for %s after the destination was removed from the provisioned set, on the connection it already held", - 3*outboundTrustRecheckInterval) - } - if !mentionsTrustFailure(err) { - t.Errorf("the connection ended for something other than the peer set it is held to: %v", err) - } -} - -// TestALeafReadFailureDoesNotCloseATrustedConnection: the recheck may only -// close a connection over a judgement about the PEER. -// -// Rebuilding the whole outbound configuration on every recheck, this workload's -// own certificate included, and closing the connection on any error from it, -// means a rotation of this pod's own key pair that is not atomic — the -// certificate replaced, the key a moment behind — makes X509KeyPair fail and -// tears down every established platform connection, streams included, while -// every peer on them is still perfectly trusted. The SDK's reloader exists to -// swallow exactly that and keep the last good pair; building a new one per -// recheck throws the property away. -// -// It counts CONNECTIONS, not successful requests. Asserting that requests keep -// working cannot see this defect at all: the recheck closes the connection and -// net/http immediately dials another, which succeeds, so every request -// succeeds while the connection underneath is being torn down and replaced -// once a second. Two mutants lived in that gap. -func TestALeafReadFailureDoesNotCloseATrustedConnection(t *testing.T) { - c := newCell(t) - certFile, keyFile, bundleFile, _, _ := c.workload(t, testPrincipal) - host := newPlatformHost(t, c, testGatewayPrincipal) - - server := New(Manifest{ID: testSolutionID}) - server.cfg = config{identityCertFile: certFile, identityKeyFile: keyFile, trustBundleFile: bundleFile, - mintPeersFile: identitiesFile(t, testGatewayPrincipal), - gatewayPeersFile: identitiesFile(t, testGatewayPrincipal), - // The destination this test dials, so the authorization set for it is - // the mint's: the set is chosen per destination now. - mintURL: host.URL + credentialMintPath} - server.principal = testPrincipal - client, err := server.outboundClient(nil) - if err != nil { - t.Fatalf("outboundClient: %v", err) - } - - var dials atomic.Int64 - trace := &httptrace.ClientTrace{ - GotConn: func(info httptrace.GotConnInfo) { - if !info.Reused { - dials.Add(1) - } - }, - } - get := func(t *testing.T) { - t.Helper() - req, err := http.NewRequestWithContext(httptrace.WithClientTrace(context.Background(), trace), - http.MethodGet, host.URL+credentialMintPath, nil) - if err != nil { - t.Fatal(err) - } - resp, err := client.Do(req) - if err != nil { - t.Fatalf("a request failed while the peer was trusted throughout: %v", err) - } - _ = resp.Body.Close() - } - - get(t) - if dials.Load() != 1 { - t.Fatalf("the first request took %d connections, want 1", dials.Load()) - } - - // A rotation caught halfway: this workload's certificate is replaced by - // something that does not pair with the key on disk. Nothing about the - // peer has changed. - writeFile(t, certFile, "-----BEGIN CERTIFICATE-----\nhalf a rotation\n-----END CERTIFICATE-----\n") - - deadline := time.Now().Add(3 * outboundTrustRecheckInterval) - for time.Now().Before(deadline) { - get(t) - time.Sleep(outboundTrustRecheckInterval / 10) - } - if redialled := dials.Load(); redialled != 1 { - t.Errorf("the established connection was replaced %d times while its peer stayed trusted: a half-written copy of this workload's own key pair is not a judgement about the peer, and the recheck must not share a failure path with it", redialled-1) - } -} - -// TestAnEstablishedCallerDoesNotOutliveTheTrustThatAdmittedIt is the inbound -// half of the busy-connection finding, and it is the half that went unanswered. -// -// The argument — an inactivity timeout cannot bound a connection that is never -// inactive, so re-verify the established peer — was written down in this -// repository, reproduced outbound, fixed outbound, and documented as a property -// of the runtime. Inbound the listener was still `&http.Server{Handler: mux}`: -// no recheck, no IdleTimeout, no ReadHeaderTimeout. So a caller removed from -// the admitted set, or whose issuing root was pulled from the bundle, kept -// every keep-alive connection it already held — in the direction where the -// decision is whom to admit. -func TestAnEstablishedCallerDoesNotOutliveTheTrustThatAdmittedIt(t *testing.T) { - for _, tc := range []struct { - name string - // what the platform withdraws, after the caller is connected - withdraw func(t *testing.T, bundleFile, callersFile string, c *cell) - }{ - { - "its issuing root is removed from the bundle", - func(t *testing.T, bundleFile, _ string, _ *cell) { - other := newCell(t) - writeFile(t, bundleFile, string(other.anchorPEM)) - }, - }, - { - "it is removed from the admitted set", - func(t *testing.T, _, callersFile string, _ *cell) { - writeFile(t, callersFile, "spiffe://codefly.test/ns/platform/sa/somebody-else\n") - }, - }, - } { - t.Run(tc.name, func(t *testing.T) { - c := newCell(t) - certFile, keyFile, bundleFile, caller, roots := c.workload(t, testPrincipal) - callersFile := identitiesFile(t, testGatewayPrincipal) - - server := New(Manifest{ID: testSolutionID}) - server.cfg = config{port: listenOn(t, server), identityCertFile: certFile, identityKeyFile: keyFile, - trustBundleFile: bundleFile, allowedCallersFile: callersFile} - server.principal = testPrincipal - - ln, err := server.listen() - if err != nil { - t.Fatalf("listen: %v", err) - } - defer func() { _ = ln.Close() }() - watch, err := server.watchInboundTrust() - if err != nil { - t.Fatalf("watchInboundTrust: %v", err) - } - // The production server, so the hook under test is the one a - // deployment runs with rather than one this test installs. - srv := &http.Server{ - Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }), - ReadHeaderTimeout: inboundHandshakeTimeout, - IdleTimeout: inboundIdleTimeout, - ConnState: watch, - } - go func() { _ = srv.Serve(ln) }() - defer func() { _ = srv.Close() }() - - client := &http.Client{Transport: &http.Transport{ - TLSClientConfig: &tls.Config{RootCAs: roots, Certificates: []tls.Certificate{*caller}, - MinVersion: tls.VersionTLS13, ServerName: "localhost"}, - }} - url := "https://" + ln.Addr().String() + "/" - - // The control: an admitted caller is served, and keeps being - // served on one busy connection while trust holds. - resp, err := client.Get(url) - if err != nil { - t.Fatalf("an admitted caller was refused: %v", err) - } - _ = resp.Body.Close() - staysUsable(t, client, url, 2*inboundTrustRecheckInterval) - - tc.withdraw(t, bundleFile, callersFile, c) - - if err := busyUntilRefused(t, client, url, 4*inboundTrustRecheckInterval); err == nil { - t.Fatalf("this caller was still being served %s after the trust that admitted it was withdrawn, on the connection it already held: re-reading trust per handshake bounds nothing about a connection that has already handshaken", - 4*inboundTrustRecheckInterval) - } - }) - } -} - -// TestTheListenerBoundsAStalledPeer drives the stall through the production -// server, because the constants are not the thing under test. -// -// The previous version asserted the constants and then built an http.Server of -// its own with them set, so removing ReadHeaderTimeout from serve() — the only -// place that wires them — left it passing. A test that configures the thing it -// is checking is checking its own literals. -func TestTheListenerBoundsAStalledPeer(t *testing.T) { - // Neither of the two that would cut a conforming long-running stream. A - // declared stream may run to MaxStreamDurationLimit, so bounding the whole - // body would refuse conforming traffic to answer a question the recheck - // answers without refusing any. - if inboundHandshakeTimeout >= MaxStreamDurationLimit { - t.Fatalf("the handshake deadline %s is not shorter than the longest declared stream %s, so it is not bounding anything", inboundHandshakeTimeout, MaxStreamDurationLimit) - } - - mint := newHostMint(t, &hostMint{}) - solution := boot(t, New(Manifest{ID: testSolutionID}), mint) - - // A peer that completes the TCP connect and then sends half a ClientHello - // and nothing else. It presents no certificate and never will. - raw, err := net.Dial("tcp", strings.TrimPrefix(solution.base, "https://")) - if err != nil { - t.Fatalf("connect to the booted listener: %v", err) - } - defer func() { _ = raw.Close() }() - // A TLS record header claiming a handshake, then silence. - if _, err := raw.Write([]byte{0x16, 0x03, 0x01, 0x00, 0x40}); err != nil { - t.Fatalf("write a partial ClientHello: %v", err) - } - - // The server has to end it. Without a deadline it holds the goroutine and - // the descriptor for as long as the peer likes, and every hello costs a - // read of the trust bundle. - _ = raw.SetReadDeadline(time.Now().Add(inboundHandshakeTimeout + 5*time.Second)) - var buf [1]byte - _, err = raw.Read(buf[:]) - if err == nil { - t.Fatal("the listener answered a peer that sent half a ClientHello") - } - if errors.Is(err, os.ErrDeadlineExceeded) { - t.Fatalf("a peer that stalled mid-ClientHello was still connected after %s: with no handshake or header deadline on the booted server it holds a goroutine and a file descriptor for as long as it likes, and each attempt costs a read of the trust bundle", - inboundHandshakeTimeout) - } -} - -// TestANonAtomicRotationDoesNotFailANewDial is the other half of the -// last-good-pair property, and the half only a fresh dial can show. -// -// The SDK's reloader swallows a failed or half-written re-read and keeps -// serving the pair it already has, which is what makes a rotation invisible to -// whoever is dialling. Building a new reloader per dial throws that away, -// because the constructor loads eagerly and returns the error: with the -// certificate replaced and the key a moment behind, every new dial fails until -// both files settle. Nothing about the destination is wrong. -func TestANonAtomicRotationDoesNotFailANewDial(t *testing.T) { - c := newCell(t) - certFile, keyFile, bundleFile, _, _ := c.workload(t, testPrincipal) - host := newPlatformHost(t, c, testGatewayPrincipal) - - server := New(Manifest{ID: testSolutionID}) - server.cfg = config{identityCertFile: certFile, identityKeyFile: keyFile, trustBundleFile: bundleFile, - mintPeersFile: identitiesFile(t, testGatewayPrincipal), - gatewayPeersFile: identitiesFile(t, testGatewayPrincipal), - // The destination this test dials, so the authorization set for it is - // the mint's: the set is chosen per destination now. - mintURL: host.URL + credentialMintPath} - server.principal = testPrincipal - client, err := server.outboundClient(nil) - if err != nil { - t.Fatalf("outboundClient: %v", err) - } - transport, ok := client.Transport.(*http.Transport) - if !ok { - t.Fatalf("outbound transport is %T, want *http.Transport", client.Transport) - } - - resp, err := client.Get(host.URL + credentialMintPath) - if err != nil { - t.Fatalf("the platform was not reachable: %v", err) - } - // Drained, not just closed: an undrained body leaves the connection out of - // the pool, so CloseIdleConnections below closes nothing and the next - // request reuses it — which would make this test pass without ever - // dialling, which is the one thing it asserts. - _, _ = io.Copy(io.Discard, resp.Body) - _ = resp.Body.Close() - - // Halfway through a rotation: the certificate on disk no longer pairs with - // the key beside it. - writeFile(t, certFile, "-----BEGIN CERTIFICATE-----\nhalf a rotation\n-----END CERTIFICATE-----\n") - - // Force a genuinely new connection, so the dial path resolves the leaf - // rather than reusing one that already presented it. - transport.CloseIdleConnections() - ctx, dialled := freshDial(t) - request, err := http.NewRequestWithContext(ctx, http.MethodGet, host.URL+credentialMintPath, nil) - if err != nil { - t.Fatal(err) - } - resp, err = client.Do(request) - if err != nil { - t.Fatalf("a new dial failed while a rotation was half-written: the reloader keeps the last good pair for exactly this, and resolving a new one per dial reports the half-written state as a failure to reach the platform: %v", err) - } - _, _ = io.Copy(io.Discard, resp.Body) - _ = resp.Body.Close() - dialled() -} - -// freshDial asserts that the request made under the context it returns really -// dialled, rather than being served on a connection that was already open. -// -// Two tests here closed a response body without draining it, so the connection -// had not returned to the pool when CloseIdleConnections ran; the next request -// reused it and made no dial. One then flaked under -race (65 of 200), and the -// other passed vacuously — it asserted that a dial resolves the leaf, on a -// request that never dialled. A reused connection performs no TCP dial, which -// is what this watches for, and it works whether the request succeeds or is -// refused. -// -// Not "never handshook", which is what this comment and the failure message -// used to say: on this transport nothing observes a handshake at all (see -// below), so describing the symptom that way named a signal that is absent -// either way. -func freshDial(t *testing.T) (context.Context, func()) { - t.Helper() - var dialled atomic.Bool - // ConnectStart, not TLSHandshakeStart: this runtime's outbound transport - // sets DialTLSContext, and a custom DialTLSContext never reaches net/http's - // addTLS — which is what invokes the TLS trace hooks. So - // TLSHandshakeStart never fires on this transport at all, whether or not a - // connection was reused, and watching for it would be another signal that - // cannot distinguish the thing it was chosen to distinguish. ConnectStart - // fires on a real TCP dial, including one whose handshake is then refused, - // which is the case one of these two tests needs. - trace := &httptrace.ClientTrace{ - ConnectStart: func(string, string) { dialled.Store(true) }, - } - return httptrace.WithClientTrace(context.Background(), trace), func() { - t.Helper() - if !dialled.Load() { - t.Fatal("the request was served on a connection that was already open, so it made no TCP dial and this test asserted nothing about dialling: drain the previous response body before CloseIdleConnections, or the connection has not returned to the pool when it runs") - } - } -} - -// issueMultiURILeaf is a leaf naming two SPIFFE identities, which no SPIFFE -// certificate has. The rule it tests is not pedantry about a field: a leaf -// naming several identities could be admitted on whichever one happens to -// match, and the identity it would be held to anywhere else is unknowable from -// here. -func issueMultiURILeaf(t *testing.T, c *cell, first, second string) *tls.Certificate { - t.Helper() - public, private, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - t.Fatal(err) - } - one, err := url.Parse(first) - if err != nil { - t.Fatal(err) - } - two, err := url.Parse(second) - if err != nil { - t.Fatal(err) - } - template := &x509.Certificate{ - SerialNumber: big.NewInt(time.Now().UnixNano()), - Subject: pkix.Name{CommonName: first}, - NotBefore: time.Now().Add(-time.Minute), - NotAfter: time.Now().Add(time.Hour), - KeyUsage: x509.KeyUsageDigitalSignature, - ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, - DNSNames: []string{"localhost"}, - IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, - URIs: []*url.URL{one, two}, - } - der, err := x509.CreateCertificate(rand.Reader, template, c.anchor, public, c.anchorKey) - if err != nil { - t.Fatal(err) - } - return &tls.Certificate{Certificate: [][]byte{der}, PrivateKey: private} -} - -// TestALeafNamingSeveralIdentitiesIsRefusedInEveryDirection: a mutation that -// accepted the first URI SAN of several survived in three places, because -// every committed test used a conforming single-SAN leaf. -func TestALeafNamingSeveralIdentitiesIsRefusedInEveryDirection(t *testing.T) { - c := newCell(t) - doubled := issueMultiURILeaf(t, c, testGatewayPrincipal, rivalPrincipal) - - t.Run("an inbound caller", func(t *testing.T) { - approved := c.identity(t, testPrincipal) - server := New(Manifest{ID: testSolutionID}).Identity(staticIdentity{config: serverConfigFor(t, approved, c)}) - server.cfg = config{allowedCallersFile: identitiesFile(t, testGatewayPrincipal)} - server.principal = testPrincipal - config, err := server.serverIdentity() - if err != nil { - t.Fatalf("boot: %v", err) - } - outcome := servedTo(t, config, doubled, "") - if !outcome.refused() { - t.Fatal("a caller whose leaf names two identities was admitted: it can be accepted on whichever one matches, and the identity it is held to elsewhere is unknowable here") - } - }) - - t.Run("this workload's own leaf", func(t *testing.T) { - if err := pairPresentsThisWorkload(doubled, testGatewayPrincipal); err == nil { - t.Fatal("this workload's own leaf was accepted while naming two identities, one of which is another workload's") - } - }) - - t.Run("an outbound platform destination", func(t *testing.T) { - if _, err := oneURIIdentity([]*x509.Certificate{parsedLeaf(t, doubled)}, "platform destination"); err == nil { - t.Fatal("a platform destination naming two identities was accepted, so the one it is held to is whichever the check happened to read first") - } - }) -} - -// parsedLeaf is a pair's leaf, parsed from its own DER rather than read off the -// Leaf cache. -func parsedLeaf(t *testing.T, pair *tls.Certificate) *x509.Certificate { - t.Helper() - leaf, err := x509.ParseCertificate(pair.Certificate[0]) - if err != nil { - t.Fatal(err) - } - return leaf -} - -// serverConfigFor is a conforming listening configuration presenting pair, -// verifying callers against the cell. -func serverConfigFor(t *testing.T, pair *tls.Certificate, c *cell) *tls.Config { - t.Helper() - return &tls.Config{ - Certificates: []tls.Certificate{*pair}, - ClientAuth: tls.RequireAndVerifyClientCert, - ClientCAs: c.roots, - MinVersion: tls.VersionTLS13, - } -} - -// TestAPerConnectionAnswerIsHeldToTheAdmittedSet: holdPerConnectionPosture -// re-imposes admitOnly on the configuration a callback returns, because that -// configuration REPLACES the base one for the connection — so without it a -// source's callback serves that connection admitting every identity in the -// cell. The mutation removing it survived: every committed test drove the base -// configuration. -func TestAPerConnectionAnswerIsHeldToTheAdmittedSet(t *testing.T) { - c := newCell(t) - approved := c.identity(t, testPrincipal) - stranger := c.identity(t, rivalPrincipal) - - base := serverConfigFor(t, approved, c) - base.GetConfigForClient = func(*tls.ClientHelloInfo) (*tls.Config, error) { - // A conforming answer that simply says nothing about admission. - answer := serverConfigFor(t, approved, c) - return answer, nil - } - server := New(Manifest{ID: testSolutionID}).Identity(staticIdentity{config: base}) - server.cfg = config{allowedCallersFile: identitiesFile(t, testGatewayPrincipal)} - server.principal = testPrincipal - config, err := server.serverIdentity() - if err != nil { - t.Fatalf("boot: %v", err) - } - - // The control: the admitted caller is served over the per-connection answer. - admitted := c.identity(t, testGatewayPrincipal) - if outcome := servedTo(t, config, admitted, ""); outcome.refused() { - t.Fatalf("an admitted caller was refused over the per-connection answer, so nothing below is about admission: %s", outcome.reason()) - } - // And a caller nobody admitted is refused on that same answer. - if outcome := servedTo(t, config, stranger, ""); !outcome.refused() { - t.Fatal("a caller outside the provisioned set was served over a per-connection answer: the configuration a callback returns replaces the base one, so the admission has to be re-imposed on it or that connection admits every identity in the cell") - } -} - -// A source's own VerifyConnection used to be composed over this runtime's -// admission, and a test here asserted it still ran. That capability is gone: -// the hook handed a source pointers to the chain the rechecks read, so it is -// refused at boot (TestASourceCannotHoldTheChainTheRechecksRead). The -// composition in admitOnly is kept for the outbound direction, where the -// configuration being composed is this package's own. - -// mentionsTheCertificate reports whether a boot refusal is about the identity -// the listener would present, rather than any other way a boot can fail. -func mentionsTheCertificate(err error) bool { - // "identity source" is NOT in this list, and that is the correction. - // - // It was, and nearly every boot refusal in identity.go opens with those - // two words — including the PeerAnchorSource refusal, which is about the - // anchor and says nothing about a certificate. So the guard written to - // stop "returning on *any* boot error" was satisfied by a refusal for an - // unrelated reason, which is the same defect one layer up. - for _, named := range []string{ - "issued for another workload", - "certificate", - "principal", - } { - if strings.Contains(err.Error(), named) { - return true - } - } - return false -} - -// TestTheOutboundHandshakeIsBoundedWhateverTheCallerPassed: a custom -// DialTLSContext takes net/http out of the handshake, so -// Transport.TLSHandshakeTimeout does nothing and the only bound left is the -// caller's context. -// -// That is adequate for an ordinary request and wrong where it matters: -// Gateway.HTTPClient carries streams and therefore sets no client timeout on -// purpose, so a dial made for a stream was bounded by the method's declared -// MaxStreamDuration — up to thirty minutes — or by nothing at all. This dials -// with a context carrying no deadline at all, against a peer that accepts TCP -// and never speaks TLS. -func TestTheOutboundHandshakeIsBoundedWhateverTheCallerPassed(t *testing.T) { - c := newCell(t) - certFile, keyFile, bundleFile, _, _ := c.workload(t, testPrincipal) - - // A black hole: it accepts and says nothing, which is what a peer doing - // this deliberately looks like. - silent, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatal(err) - } - defer func() { _ = silent.Close() }() - go func() { - for { - conn, err := silent.Accept() - if err != nil { - return - } - // Held open, never answered. - t.Cleanup(func() { _ = conn.Close() }) - } - }() - - server := New(Manifest{ID: testSolutionID}) - server.cfg = config{identityCertFile: certFile, identityKeyFile: keyFile, trustBundleFile: bundleFile, - mintPeersFile: identitiesFile(t, testGatewayPrincipal), - gatewayPeersFile: identitiesFile(t, testGatewayPrincipal), - // The black hole is this test's "mint", so the dial has an - // authorization set at all: the set is chosen per destination now. - mintURL: "https://" + silent.Addr().String() + credentialMintPath} - server.principal = testPrincipal - server.handshakeTimeout = 750 * time.Millisecond - client, err := server.outboundClient(nil) - if err != nil { - t.Fatalf("outboundClient: %v", err) - } - transport, ok := client.Transport.(*http.Transport) - if !ok { - t.Fatalf("outbound transport is %T, want *http.Transport", client.Transport) - } - - dialled := make(chan error, 1) - go func() { - // context.Background(): no deadline, which is what a stream's dial can - // carry. - conn, err := transport.DialTLSContext(context.Background(), "tcp", silent.Addr().String()) - if conn != nil { - _ = conn.Close() - } - dialled <- err - }() - select { - case err := <-dialled: - if err == nil { - t.Fatal("the dial succeeded against a peer that never spoke TLS") - } - case <-time.After(10 * server.handshakeTimeout): - t.Fatalf("the handshake was still running %s after a dial with no deadline on its context: the custom dialler means net/http does not bound it and TLSHandshakeTimeout does nothing, so a dial made for a stream is bounded by the stream's duration or by nothing", - 10*server.handshakeTimeout) - } -} - -// TestTheInboundWatchStartsAtAuthentication: the watch covered the connection -// from the first *request*, which is later than authentication. A caller could -// complete its handshake, have its issuing root pulled, and sit unwatched for -// as long as the header deadline allows. -// -// This caller authenticates and then sends nothing at all, which is the window -// in question. -func TestTheInboundWatchStartsAtAuthentication(t *testing.T) { - c := newCell(t) - certFile, keyFile, bundleFile, caller, roots := c.workload(t, testPrincipal) - callersFile := identitiesFile(t, testGatewayPrincipal) - - server := New(Manifest{ID: testSolutionID}) - server.cfg = config{port: listenOn(t, server), identityCertFile: certFile, identityKeyFile: keyFile, - trustBundleFile: bundleFile, allowedCallersFile: callersFile} - server.principal = testPrincipal - ln, err := server.listen() - if err != nil { - t.Fatalf("listen: %v", err) - } - defer func() { _ = ln.Close() }() - watch, err := server.watchInboundTrust() - if err != nil { - t.Fatalf("watchInboundTrust: %v", err) - } - srv := &http.Server{ - Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }), - ReadHeaderTimeout: inboundHandshakeTimeout, - IdleTimeout: inboundIdleTimeout, - ConnState: watch, - } - go func() { _ = srv.Serve(ln) }() - defer func() { _ = srv.Close() }() - - conn, err := tls.Dial("tcp", ln.Addr().String(), &tls.Config{ - RootCAs: roots, Certificates: []tls.Certificate{*caller}, - MinVersion: tls.VersionTLS13, ServerName: "localhost", - }) - if err != nil { - t.Fatalf("an admitted caller could not connect: %v", err) - } - defer func() { _ = conn.Close() }() - if err := conn.Handshake(); err != nil { - t.Fatalf("handshake: %v", err) - } - - // Authenticated, and not one byte of a request sent. - // - // The control runs first, and it is also what makes the withdrawal below - // unambiguous: while admission holds, this connection stays open across - // several recheck intervals. Withdrawing immediately after tls.Dial raced - // the server's own side of the handshake — the client finishes before the - // server has evaluated admission, so the server read the *new* file and - // refused the handshake, and the test passed on "bad certificate" whether - // the watch worked or not. - stillOpen := func(t *testing.T) bool { - t.Helper() - _ = conn.SetReadDeadline(time.Now().Add(inboundTrustRecheckInterval / 4)) - var buf [1]byte - _, err := conn.Read(buf[:]) - return errors.Is(err, os.ErrDeadlineExceeded) - } - settle := time.Now().Add(2 * inboundTrustRecheckInterval) - for time.Now().Before(settle) { - if !stillOpen(t) { - t.Fatal("the connection was closed while this caller was still admitted, so nothing below is about the withdrawal") - } - } - - // The platform now removes this caller. - writeFile(t, callersFile, "spiffe://codefly.test/ns/platform/sa/somebody-else\n") - - // Well inside the header deadline, which is what would otherwise be the - // only thing to end this connection. - deadline := time.Now().Add(5 * inboundTrustRecheckInterval) - if 5*inboundTrustRecheckInterval >= inboundHandshakeTimeout { - t.Fatalf("this test needs to finish inside the header deadline (%s) to mean anything", inboundHandshakeTimeout) - } - for time.Now().Before(deadline) { - if !stillOpen(t) { - return // closed, which is the point - } - } - t.Fatalf("a caller that authenticated and then sent nothing was still connected %s after its admission was withdrawn: the watch begins at the first request, so the whole pre-request window is unwatched", 5*inboundTrustRecheckInterval) -} - -// TestAResumedConnectionCannotBeAdmittedWithoutACertificate is the executed -// bypass from round five, committed. -// -// A resumed TLS connection presents no certificate: the peer is accepted on a -// ticket. So anything able to forge a ticket is admitted as whoever the ticket -// was issued to, and a denylist could not close that — it caught -// SessionTicketKey and could not catch SetSessionTicketKeys, whose keys are -// unexported and unreadable from the configuration. The review drove it end to -// end: with a known key, a client holding only that key and an admitted -// caller's ticket resumed as that caller with DidResume=true, presenting -// nothing, and the per-second recheck then re-verified the stolen certificate -// and kept the connection open. -// -// Resumption is therefore off on everything this listener serves, which is why -// this test asserts a property of the configuration rather than replaying a -// ticket: there is no ticket to replay. -func TestAResumedConnectionCannotBeAdmittedWithoutACertificate(t *testing.T) { - c := newCell(t) - certFile, keyFile, bundleFile, caller, roots := c.workload(t, testPrincipal) - - // A source that deliberately fixes its ticket key, which is the HA-replica - // shape, and reaches the bypass through the field the denylist can see. - keyed, err := projectedIdentity{certFile: certFile, keyFile: keyFile, trustBundleFile: bundleFile}.ServerTLSConfig() - if err != nil { - t.Fatal(err) - } - //nolint:staticcheck // Deliberately sets the deprecated field, to prove the refusal fires. - keyed.SessionTicketKey = [32]byte{7} - if err := usableServerIdentity(keyed); err == nil { - t.Error("a source fixing its own resumption ticket key was accepted: anyone holding that key can forge a ticket this listener resumes, and a resumed connection presents no certificate") - } - - // And the half a check cannot see: keys installed through the method. - // Nothing can read them back, so the only sound answer is that this - // listener does not resume at all. - viaMethod, err := projectedIdentity{certFile: certFile, keyFile: keyFile, trustBundleFile: bundleFile}.ServerTLSConfig() - if err != nil { - t.Fatal(err) - } - viaMethod.SetSessionTicketKeys([][32]byte{{9}}) - server := New(Manifest{ID: testSolutionID}).Identity(staticIdentity{config: viaMethod}) - server.cfg = config{allowedCallersFile: identitiesFile(t, testGatewayPrincipal)} - server.principal = testPrincipal - served, err := server.serverIdentity() - if err != nil { - t.Fatalf("boot: %v", err) - } - if !served.SessionTicketsDisabled { - t.Fatal("the configuration this listener serves permits resumption while the source controls the ticket keys: a ticket forged with those keys is admitted as whoever it was issued to, with no certificate presented and nothing in this package able to notice") - } - - // Every per-connection answer too, since that configuration replaces the - // base one for its connection. - perConnection, err := projectedIdentity{certFile: certFile, keyFile: keyFile, trustBundleFile: bundleFile}.ServerTLSConfig() - if err != nil { - t.Fatal(err) - } - if perConnection.GetConfigForClient == nil { - t.Fatal("the projected source stopped answering per connection, so this case no longer covers anything") - } - answered, err := served.GetConfigForClient(&tls.ClientHelloInfo{ServerName: "localhost"}) - if err != nil { - t.Fatalf("the per-connection answer was refused: %v", err) - } - if answered != nil && !answered.SessionTicketsDisabled { - t.Error("a per-connection answer permits resumption: the configuration a callback returns replaces the base one for that connection, so disabling it on the base alone leaves the bypass open") - } - - // The control: an admitted caller is still served, so this is not a - // listener that refuses everything. - if outcome := servedTo(t, served, caller, "localhost"); outcome.refused() { - t.Fatalf("an admitted caller was refused with resumption off: %s", outcome.reason()) - } - _ = roots -} - -// TestEachDestinationHasItsOwnAuthorizationSet: the mint and the gateway are -// two parties, and one set spanning both authorises each to stand in for the -// other at the other's address. The set already excluded every other workload -// in the cell, which was the finding it was added for, and that is a different -// question from whether "the platform" is one party. -func TestEachDestinationHasItsOwnAuthorizationSet(t *testing.T) { - c := newCell(t) - certFile, keyFile, bundleFile, _, _ := c.workload(t, testPrincipal) - const gatewayIdentity = "spiffe://codefly.test/ns/platform/sa/gateway" - const mintIdentity = "spiffe://codefly.test/ns/platform/sa/mint" - - // Two hosts: one answering as the mint, one as the gateway. - mint := newPlatformHost(t, c, mintIdentity) - gateway := newPlatformHost(t, c, gatewayIdentity) - - server := New(Manifest{ID: testSolutionID}) - server.cfg = config{identityCertFile: certFile, identityKeyFile: keyFile, trustBundleFile: bundleFile, - mintURL: mint.URL + credentialMintPath, - gatewayURL: gateway.URL, - mintPeersFile: identitiesFile(t, mintIdentity), - gatewayPeersFile: identitiesFile(t, gatewayIdentity), - } - server.principal = testPrincipal - client, err := server.outboundClient(nil) - if err != nil { - t.Fatalf("outboundClient: %v", err) - } - - t.Run("each destination admits its own identity", func(t *testing.T) { - for _, at := range []struct{ name, url string }{ - {"the mint", mint.URL + credentialMintPath}, - {"the gateway", gateway.URL + "/anything"}, - } { - resp, err := client.Get(at.url) - if err != nil { - t.Errorf("%s was refused while answering under its own provisioned identity: %v", at.name, err) - continue - } - _ = resp.Body.Close() - } - }) - - t.Run("neither stands in for the other", func(t *testing.T) { - // The gateway's identity, answering at the mint's address. Under one - // shared set this was accepted, and the projected service-account - // token went to it. - impostor := newPlatformHost(t, c, gatewayIdentity) - server.cfg.mintURL = impostor.URL + credentialMintPath - impersonating, err := server.outboundClient(nil) - if err != nil { - t.Fatalf("outboundClient: %v", err) - } - resp, err := impersonating.Get(impostor.URL + credentialMintPath) - if err == nil { - _ = resp.Body.Close() - t.Fatal("the gateway's identity was accepted at the credential mint's address: a set spanning both parties lets either present itself as the other, and the mint is the destination that receives the projected service-account token") - } - if !strings.Contains(err.Error(), gatewayIdentity) { - t.Errorf("the refusal %q does not name the identity that answered", err) - } - }) - - t.Run("a third address has no set and is refused", func(t *testing.T) { - // This runtime dials the mint and the gateway and nothing else, so - // there is no set that should admit a third destination. - server.cfg.mintURL = mint.URL + credentialMintPath - fresh, err := server.outboundClient(nil) - if err != nil { - t.Fatalf("outboundClient: %v", err) - } - stranger := newPlatformHost(t, c, mintIdentity) - resp, err := fresh.Get(stranger.URL + credentialMintPath) - if err == nil { - _ = resp.Body.Close() - t.Fatal("a destination that is neither the mint nor the gateway was dialled with this workload's credentials") - } - if !strings.Contains(err.Error(), "no authorization set") { - t.Errorf("the refusal %q does not say the address has no set, so it may be refusing for some other reason", err) - } - }) -} - -// TestOneReaderDoesNotFallBackToItsLastGoodAnswer: the admission reader must -// refuse an unusable file even after it has successfully read a usable one. -// -// Every case in TestAnAdmissionSetIsNeitherCachedNorDefaulted builds a fresh -// reader, so no good read ever precedes the bad one — and a reader that cached -// its last good answer and served it whenever a later read failed passed all of -// them. That fallback is precisely the staleness this whole mechanism exists to -// remove: an admission set that cannot be re-read has no safe direction, -// because the set on disk may be narrower than the one in memory. -func TestOneReaderDoesNotFallBackToItsLastGoodAnswer(t *testing.T) { - path := filepath.Join(t.TempDir(), "identities") - writeFile(t, path, testGatewayPrincipal+"\n") - admits := resolvedIdentities(path, "unresolved") - - // A good read first, so a cache would have something to fall back to. - got, err := admits() - if err != nil { - t.Fatalf("a usable set was refused: %v", err) - } - if len(got) != 1 || got[0] != testGatewayPrincipal { - t.Fatalf("resolved %v, want %q", got, testGatewayPrincipal) - } - - for _, tc := range []struct { - name string - then func(t *testing.T) - }{ - {"the file is emptied", func(t *testing.T) { writeFile(t, path, "\n") }}, - {"the file is removed", func(t *testing.T) { - if err := os.Remove(path); err != nil { - t.Fatal(err) - } - }}, - {"the file is replaced by a directory", func(t *testing.T) { - if err := os.Remove(path); err != nil { - t.Fatal(err) - } - if err := os.Mkdir(path, 0o755); err != nil { - t.Fatal(err) - } - }}, - } { - t.Run(tc.name, func(t *testing.T) { - // Restored for each case, so each one is a good read followed by - // this bad one on the *same* reader. - _ = os.RemoveAll(path) - writeFile(t, path, testGatewayPrincipal+"\n") - if _, err := admits(); err != nil { - t.Fatalf("the restored set was refused: %v", err) - } - tc.then(t) - if got, err := admits(); err == nil { - t.Errorf("the reader resolved %v after a good read followed by an unusable file: it is serving a remembered answer, which is the staleness this mechanism exists to remove", got) - } - }) - } -} - -// TestAFreshDialFollowsTheProvisionedPeerSet is the per-dial half, and the one -// an established connection hides. -// -// TestAnEstablishedConnectionFollowsTheProvisionedPeerSet keeps a connection -// busy, so the recheck can carry it: a runtime that snapshotted the set when -// the client was built but still rechecked established connections passed it. -// This makes a genuinely new dial after the set changes, with nothing -// established to recheck. -func TestAFreshDialFollowsTheProvisionedPeerSet(t *testing.T) { - c := newCell(t) - certFile, keyFile, bundleFile, _, _ := c.workload(t, testPrincipal) - host := newPlatformHost(t, c, testGatewayPrincipal) - provisioned := identitiesFile(t, testGatewayPrincipal) - - server := New(Manifest{ID: testSolutionID}) - server.cfg = config{identityCertFile: certFile, identityKeyFile: keyFile, trustBundleFile: bundleFile, - mintPeersFile: provisioned, - gatewayPeersFile: provisioned, - mintURL: host.URL + credentialMintPath} - server.principal = testPrincipal - client, err := server.outboundClient(nil) - if err != nil { - t.Fatalf("outboundClient: %v", err) - } - transport, ok := client.Transport.(*http.Transport) - if !ok { - t.Fatalf("outbound transport is %T, want *http.Transport", client.Transport) - } - - resp, err := client.Get(host.URL + credentialMintPath) - if err != nil { - t.Fatalf("the provisioned destination was refused: %v", err) - } - // Drained, not just closed. Undrained, the connection has not returned to - // the pool when CloseIdleConnections runs, so the second request reuses it - // and is served without a handshake — the test then fails because nothing - // refused it, which is how this flaked 65 times in 200 under -race. - _, _ = io.Copy(io.Discard, resp.Body) - _ = resp.Body.Close() - - // The destination is removed, and every connection to it is dropped, so - // the next request must dial afresh and the recheck has nothing to carry. - writeFile(t, provisioned, "spiffe://codefly.test/ns/platform/sa/somebody-else\n") - transport.CloseIdleConnections() - - ctx, dialled := freshDial(t) - request, err := http.NewRequestWithContext(ctx, http.MethodGet, host.URL+credentialMintPath, nil) - if err != nil { - t.Fatal(err) - } - resp, err = client.Do(request) - if err == nil { - _, _ = io.Copy(io.Discard, resp.Body) - _ = resp.Body.Close() - t.Fatal("a fresh dial was made to a destination removed from the provisioned set: the set has to be read per dial, not captured when the client was built") - } - dialled() - if !strings.Contains(err.Error(), testGatewayPrincipal) { - t.Errorf("the refusal %q does not name the identity that answered", err) - } -} - -// TestASourceCarryingBothAPoolAndACallbackIsRefused is N4 again, and the -// regression this round's own fix introduced. -// -// The refusal lived in peerAnchorFrom, which reached it only when the base pool -// was nil — so a source carrying BOTH a base pool and a per-handshake callback -// was accepted at boot, and then had that pool frozen for every recheck and -// every outbound dial while its callback went on resolving a fresh anchor. -// Executed: after the bundle rotated A→B, a fresh A-caller was refused and the -// established A-caller served 30 requests over 3 seconds. The doc comment and -// the README both claimed such a source was refused. The only shape that was -// refused is the one nobody writes. -func TestASourceCarryingBothAPoolAndACallbackIsRefused(t *testing.T) { - boot := newCell(t) - current := newCell(t) - approved := boot.identity(t, testPrincipal) - - withCallback := func() *tls.Config { - return &tls.Config{ - Certificates: []tls.Certificate{*approved}, - ClientAuth: tls.RequireAndVerifyClientCert, - MinVersion: tls.VersionTLS13, - // A pool from boot *and* a callback resolving a fresh one. - ClientCAs: boot.roots, - GetConfigForClient: func(*tls.ClientHelloInfo) (*tls.Config, error) { - return &tls.Config{ - Certificates: []tls.Certificate{*approved}, - ClientAuth: tls.RequireAndVerifyClientCert, - MinVersion: tls.VersionTLS13, - ClientCAs: current.roots, - }, nil - }, - } - } - - server := New(Manifest{ID: testSolutionID}).Identity(unaskableIdentity{config: withCallback()}) - server.cfg = config{allowedCallersFile: identitiesFile(t, testGatewayPrincipal)} - server.principal = testPrincipal - if _, err := server.serverIdentity(); err == nil { - t.Fatal("a source resolving its configuration per handshake, carrying a base pool as well, was accepted: that pool is one object fixed when the source built it, so every recheck and every outbound dial would be judged by the anchor this process booted with while the callback resolved a fresh one") - } else if !strings.Contains(err.Error(), "PeerAnchor") { - t.Errorf("the refusal %q does not name the interface that fixes it", err) - } - - // The control: the same shape, askable, boots. - askable := New(Manifest{ID: testSolutionID}).Identity(staticIdentity{ - config: withCallback(), - anchor: func() *x509.CertPool { return current.roots }, - }) - askable.cfg = config{allowedCallersFile: identitiesFile(t, testGatewayPrincipal)} - askable.principal = testPrincipal - if _, err := askable.serverIdentity(); err != nil { - t.Fatalf("a source that can say what its anchor is was refused: %v", err) - } -} - -// TestASharedDestinationAddressIsHeldToBothSets: the per-destination split was -// silently not in force in the only configuration that exists. -// -// admittedAt matched the mint first, so when the mint and the gateway share a -// host and port — the brokered shape the PR proposes and every fixture here -// uses — every gateway dial answered to the mint's set, and the gateway's set -// was validated at boot and then never read. Executed: a gateway set naming -// somebody else still got 200, and rewriting it cut nothing. -func TestASharedDestinationAddressIsHeldToBothSets(t *testing.T) { - c := newCell(t) - certFile, keyFile, bundleFile, _, _ := c.workload(t, testPrincipal) - const other = "spiffe://codefly.test/ns/platform/sa/somebody-else" - host := newPlatformHost(t, c, testGatewayPrincipal) - - build := func(t *testing.T, mintSet, gatewaySet []string) *http.Client { - t.Helper() - server := New(Manifest{ID: testSolutionID}) - server.cfg = config{identityCertFile: certFile, identityKeyFile: keyFile, trustBundleFile: bundleFile, - // One address for both, which is the deployed shape. - mintURL: host.URL + credentialMintPath, - gatewayURL: host.URL, - mintPeersFile: identitiesFile(t, mintSet...), - gatewayPeersFile: identitiesFile(t, gatewaySet...), - } - server.principal = testPrincipal - client, err := server.outboundClient(nil) - if err != nil { - t.Fatalf("outboundClient: %v", err) - } - return client - } - - t.Run("both sets admit it", func(t *testing.T) { - resp, err := build(t, []string{testGatewayPrincipal}, []string{testGatewayPrincipal}).Get(host.URL + credentialMintPath) - if err != nil { - t.Fatalf("an identity both sets admit was refused: %v", err) - } - _ = resp.Body.Close() - }) - - // The two halves that used to pass because only the mint's set was read. - for _, tc := range []struct { - name string - mintSet, gatewaySet []string - }{ - {"only the mint's set admits it", []string{testGatewayPrincipal}, []string{other}}, - {"only the gateway's set admits it", []string{other}, []string{testGatewayPrincipal}}, - } { - t.Run(tc.name, func(t *testing.T) { - resp, err := build(t, tc.mintSet, tc.gatewaySet).Get(host.URL + credentialMintPath) - if err == nil { - _ = resp.Body.Close() - t.Fatal("an identity only one of the two sets admits was presented this workload's credentials at an address both destinations share: a dial there cannot be attributed to one of them, so only an identity both admit is safe — and reading one set alone means the other is provisioned, validated at boot, and never consulted again") - } - }) - } - - t.Run("an empty intersection is refused at boot", func(t *testing.T) { - server := New(Manifest{ID: testSolutionID}) - server.cfg = config{port: "8080", identityCertFile: certFile, identityKeyFile: keyFile, - projectedTokenPath: "t", trustBundleFile: bundleFile, profile: localProfile, - mintURL: host.URL + credentialMintPath, - gatewayURL: host.URL, - allowedCallersFile: identitiesFile(t, testGatewayPrincipal), - mintPeersFile: identitiesFile(t, testGatewayPrincipal), - gatewayPeersFile: identitiesFile(t, other), - } - server.principal = testPrincipal - err := server.validateSources() - if err == nil { - t.Fatal("a boot accepted two sets with nothing in common at an address both destinations share: every dial there would be refused, discovered at the first mint rather than named as the provisioning gap it is") - } - if !strings.Contains(err.Error(), "both provisioned sets") { - t.Errorf("the refusal %q does not say the sets share no identity", err) - } - }) -} - -// TestACallersIdentityComesFromTheBytesItSigned: admission is decided on the -// certificate's own DER, not on the parsed object a ConnectionState hands out. -// -// Those objects are shared — tls.Conn returns the same pointers every call — so -// anything that ever holds one can change what a later read sees. The hooks -// that could hold them are refused at boot now; this is the other half, and it -// is what makes the refusal belt rather than the only line. -func TestACallersIdentityComesFromTheBytesItSigned(t *testing.T) { - c := newCell(t) - rival := c.identity(t, rivalPrincipal) - - // A parsed leaf rewritten to claim an identity the set admits, over a DER - // that says otherwise. - leaf := parsedLeaf(t, rival) - claimed, err := url.Parse(testGatewayPrincipal) - if err != nil { - t.Fatal(err) - } - leaf.URIs = []*url.URL{claimed} - state := tls.ConnectionState{PeerCertificates: []*x509.Certificate{leaf}} - - admit := verifyCaller(func() ([]string, error) { return []string{testGatewayPrincipal}, nil }) - err = admit(state) - if err == nil { - t.Fatal("a caller was admitted on a rewritten parsed leaf: the identity has to come from the bytes the peer signed, which nothing between the handshake and the check holds a reference to") - } - if !strings.Contains(err.Error(), rivalPrincipal) { - t.Errorf("the refusal %q does not name the identity the certificate was actually issued for", err) - } - - // The control: the genuinely admitted caller passes. - intact := tls.ConnectionState{PeerCertificates: []*x509.Certificate{parsedLeaf(t, c.identity(t, testGatewayPrincipal))}} - if err := admit(intact); err != nil { - t.Fatalf("an admitted caller was refused: %v", err) - } -} - -// The ordering test that stood here drove a source-supplied VerifyConnection, -// which is refused at boot now, so the scenario is unreachable. Ordering alone -// was never the fix: it corrected the admission and left the rechecks reading -// objects the source still held. See -// TestASourceCannotHoldTheChainTheRechecksRead. - -// TestASourceCannotHoldTheChainTheRechecksRead is the blocker R7-5 only half -// closed. -// -// Running this runtime's admission before a source's verifier fixes the -// admission and nothing else. A tls.ConnectionState carries *pointers* to the -// parsed chain and tls.Conn hands out the same ones on every call — so a -// source's VerifyConnection does not merely see the certificates, it holds -// them, and the per-second recheck reads those same objects minutes later. An -// established caller whose leaf was rewritten afterwards survives withdrawal, -// because the recheck re-reads the mutated object and agrees with it. -// -// Two things close it: such a source is refused at boot, and nothing this -// runtime decides is computed from the shared objects at all. -func TestASourceCannotHoldTheChainTheRechecksRead(t *testing.T) { - c := newCell(t) - approved := c.identity(t, testPrincipal) - - t.Run("a source supplying its own connection verifier is refused", func(t *testing.T) { - base := serverConfigFor(t, approved, c) - base.VerifyConnection = func(tls.ConnectionState) error { return nil } - server := New(Manifest{ID: testSolutionID}).Identity(staticIdentity{config: base}) - server.cfg = config{allowedCallersFile: identitiesFile(t, testGatewayPrincipal)} - server.principal = testPrincipal - _, err := server.serverIdentity() - if err == nil { - t.Fatal("a source supplying its own VerifyConnection was accepted: it is handed pointers to the chain this listener authenticates callers by, and the rechecks read those same objects for as long as the connection lives, so anything written to them decides who stays admitted") - } - if !strings.Contains(err.Error(), "VerifyConnection") { - t.Errorf("the refusal %q does not name the field", err) - } - }) - - t.Run("a mutated chain cannot keep a withdrawn caller admitted", func(t *testing.T) { - // The recheck's own input, mutated the way a verifier holding the - // objects would: the leaf claims an identity the set admits while its - // DER says otherwise. - admitted, rival := c.identity(t, testGatewayPrincipal), c.identity(t, rivalPrincipal) - leaf := parsedLeaf(t, rival) - claimed, err := url.Parse(testGatewayPrincipal) - if err != nil { - t.Fatal(err) - } - leaf.URIs = []*url.URL{claimed} - state := tls.ConnectionState{PeerCertificates: []*x509.Certificate{leaf}} - - anchor := func() (*x509.CertPool, error) { return c.roots, nil } - set := func() ([]string, error) { return []string{testGatewayPrincipal}, nil } - if err := callerStillAdmitted(state, anchor, set, "", nil); err == nil { - t.Fatal("the recheck admitted a caller whose parsed leaf had been rewritten to claim an admitted identity: it has to decide on the bytes the peer signed, which nothing between the handshake and the check holds a reference to") - } - - // The control: the genuinely admitted caller passes the same recheck, - // so this is not a check that refuses everything. - intact := tls.ConnectionState{PeerCertificates: []*x509.Certificate{parsedLeaf(t, admitted)}} - if err := callerStillAdmitted(intact, anchor, set, "", nil); err != nil { - t.Fatalf("an admitted caller was refused by the recheck: %v", err) - } - }) - - t.Run("the private chain does not alias the handshake's objects", func(t *testing.T) { - leaf := parsedLeaf(t, approved) - state := tls.ConnectionState{PeerCertificates: []*x509.Certificate{leaf}} - chain, err := immutableChain(state) - if err != nil { - t.Fatalf("immutableChain: %v", err) - } - if chain[0] == leaf { - t.Fatal("the chain is the handshake's own object, so anything holding it can change what a recheck reads") - } - if &chain[0].Raw[0] == &leaf.Raw[0] { - t.Fatal("the chain shares the handshake's DER bytes: copying the struct without copying the bytes leaves the parse re-derivable from memory something else can write") - } - // And it still says the same thing. - if chain[0].URIs[0].String() != testPrincipal { - t.Errorf("the private copy names %v, want %q", chain[0].URIs, testPrincipal) - } - }) -} - -// TestAnEstablishedConnectionIsHeldToTheIdentityItWasAdmittedAs carries the -// executed round's two probes: the key swap and the Raw swap. -// -// tls.ConnectionState.PeerCertificates shares its backing array with the -// connection's own slice, and TLS 1.3 runs VerifyConnection BEFORE checking -// CertificateVerify against peerCertificates[0].PublicKey. A source verifier -// that swapped the leaf pointer therefore had the handshake signature verified -// against the RIVAL's key: an attacker holding only an admitted identity's -// public certificate plus its own private key was served 16 requests in 2.5s, -// against a control of 0. A Raw swap was the quieter half — the recheck -// re-parsed the swapped DER and agreed with it, so a de-admitted caller kept -// being served. -// -// Both hooks that could do this are refused at boot, and the recheck holds a -// connection to the identity it was admitted as rather than re-asking who it -// is now. -func TestAnEstablishedConnectionIsHeldToTheIdentityItWasAdmittedAs(t *testing.T) { - c := newCell(t) - anchor := func() (*x509.CertPool, error) { return c.roots, nil } - set := func() ([]string, error) { return []string{testGatewayPrincipal}, nil } - - t.Run("the key swap is refused at boot", func(t *testing.T) { - // The shape of the attack: a source verifier that replaces the leaf - // with one carrying an admitted identity and the attacker's own key. - base := serverConfigFor(t, c.identity(t, testPrincipal), c) - base.VerifyConnection = func(state tls.ConnectionState) error { - state.PeerCertificates[0] = parsedLeaf(t, c.identity(t, testGatewayPrincipal)) - return nil - } - server := New(Manifest{ID: testSolutionID}).Identity(staticIdentity{config: base}) - server.cfg = config{allowedCallersFile: identitiesFile(t, testGatewayPrincipal)} - server.principal = testPrincipal - if _, err := server.serverIdentity(); err == nil { - t.Fatal("a source verifier able to swap the leaf was accepted: TLS 1.3 checks CertificateVerify against peerCertificates[0].PublicKey after VerifyConnection runs, so the handshake would be authenticated against the swapped key") - } - }) - - t.Run("a Raw swap cannot keep a de-admitted caller served", func(t *testing.T) { - admitted := parsedLeaf(t, c.identity(t, testGatewayPrincipal)) - pinnedID, pinnedLeaf, err := admittedIdentity(tls.ConnectionState{PeerCertificates: []*x509.Certificate{admitted}}) - if err != nil { - t.Fatalf("admittedIdentity: %v", err) - } - - // The connection's own object swapped under the recheck for another - // certificate that still verifies and still names an admitted - // identity. Re-deriving the identity each tick agrees with it; holding - // the connection to what it was admitted as does not. - reissued := parsedLeaf(t, c.identity(t, testGatewayPrincipal)) - swapped := tls.ConnectionState{PeerCertificates: []*x509.Certificate{reissued}} - if err := callerStillAdmitted(swapped, anchor, set, pinnedID, pinnedLeaf); err == nil { - t.Fatal("the recheck accepted a connection whose caller certificate is not the one the handshake authenticated: an established connection's certificate does not change, so this is not a caller to re-authorise but one whose authentication means nothing") - } - - // An identity swap, the louder half. - rival := tls.ConnectionState{PeerCertificates: []*x509.Certificate{parsedLeaf(t, c.identity(t, rivalPrincipal))}} - if err := callerStillAdmitted(rival, anchor, set, pinnedID, pinnedLeaf); err == nil { - t.Fatal("the recheck accepted a connection presenting a different identity than the one it was admitted as") - } - - // The control: the connection as it actually was stays served, so this - // is not a check that closes everything. - intact := tls.ConnectionState{PeerCertificates: []*x509.Certificate{admitted}} - if err := callerStillAdmitted(intact, anchor, set, pinnedID, pinnedLeaf); err != nil { - t.Fatalf("the connection the handshake authenticated was closed by its own recheck: %v", err) - } - }) - - t.Run("withdrawal still closes a pinned connection", func(t *testing.T) { - // Pinning says which caller this is; it must not make the connection - // immortal. The admitted set is still read every tick. - admitted := parsedLeaf(t, c.identity(t, testGatewayPrincipal)) - state := tls.ConnectionState{PeerCertificates: []*x509.Certificate{admitted}} - pinnedID, pinnedLeaf, err := admittedIdentity(state) - if err != nil { - t.Fatal(err) - } - withdrawn := func() ([]string, error) { return []string{rivalPrincipal}, nil } - if err := callerStillAdmitted(state, anchor, withdrawn, pinnedID, pinnedLeaf); err == nil { - t.Fatal("a pinned connection survived the withdrawal of its caller from the admitted set") - } - }) -} diff --git a/identity.go b/identity.go deleted file mode 100644 index 27f8113..0000000 --- a/identity.go +++ /dev/null @@ -1,1286 +0,0 @@ -package solution - -import ( - "bytes" - "crypto/tls" - "crypto/x509" - "fmt" - "net" - "net/http" - "net/url" - "os" - "slices" - "strings" - "sync" - "time" - - codefly "github.com/codefly-dev/sdk-go" -) - -// IdentitySource is where this workload's listening identity comes from. -// -// It is a hook, and that is the point: issuing an X.509-SVID is the platform's -// job, not this runtime's. A deployment whose identities come from a mesh CA, a -// SPIFFE workload API, a cert-manager Certificate projected into the pod, or a -// pair the local toolchain mints for a developer run differ in every detail of -// how the leaf arrives and in none of what the listener does with it. The -// default source (the platform projection named by the workload-identity -// configuration group) covers the deployed case and the local one identically, -// because both are "files at a path somebody else wrote"; a consumer whose -// platform hands out leaves another way implements this instead. -// -// A source returns a server configuration rather than a certificate so that an -// issuer with its own rotation, revocation or peer-verification rules keeps -// them: everything the handshake needs is in the value it returns. -type IdentitySource interface { - // ServerTLSConfig is the configuration this solution's listener serves - // with. It is called once, at boot, before the listener exists; a source - // that rotates material does so inside the returned configuration (through - // GetCertificate), not by being called again. - ServerTLSConfig() (*tls.Config, error) -} - -// PeerAnchorSource is an IdentitySource that can say what its current trust -// anchor is, asked directly. -// -// Optional, and the only sound way for a source that resolves its anchor per -// handshake to be re-checkable. The alternative this replaces was to call the -// source's GetConfigForClient with a *fabricated* ClientHelloInfo and read the -// pool off the answer — which is the defect this whole cutover was blocked on -// one layer along: a source keyed on the hello answers a synthetic probe with -// one thing and the real handshake with another, and that was demonstrated -// serving a neighbouring workload's certificate. The same trick is worse for an -// anchor than for a certificate. A certificate obtained through a fabricated -// hello is still held to the frozen principal at the moment it is presented, so -// a wrong answer is caught; an anchor *is* the thing that catches wrong -// answers, and nothing downstream checks it. -// -// A source that resolves its anchor per handshake and does not implement this -// is refused at boot, because there is then nothing to re-verify an established -// caller against that this runtime could obtain honestly. -type PeerAnchorSource interface { - PeerAnchor() (*x509.CertPool, error) -} - -// Identity sets where the listener's workload identity comes from, replacing -// the platform projection the workload-identity configuration group names. -// Chainable. -func (s *Server) Identity(source IdentitySource) *Server { - s.identity = source - return s -} - -// projectedIdentity is the default source: the X.509-SVID the platform projects -// for this workload, as files, and the trust anchor its peers are verified -// against. -// -// It is read through the SDK's certificate reloader, which re-reads the pair -// when the files change and keeps serving the last good pair through a -// half-written replacement. That matters more here than anywhere else in this -// runtime: workload leaves are deliberately short-lived and rotated well before -// expiry, so a process that loaded its leaf once at boot — the shape almost -// every Go server has — serves a stale leaf while a valid one sits on disk, and -// then fails every handshake, with the issuer reporting nothing wrong. -type projectedIdentity struct { - certFile, keyFile, trustBundleFile string -} - -// PeerAnchor reads the projected bundle, now. It is what makes this source -// re-checkable: the anchor it serves with is resolved per handshake, so asking -// the configuration for it would mean fabricating a handshake, and this answers -// the question directly instead. See PeerAnchorSource. -func (p projectedIdentity) PeerAnchor() (*x509.CertPool, error) { - return peerAnchor(p.trustBundleFile) -} - -func (p projectedIdentity) ServerTLSConfig() (*tls.Config, error) { - // The anchor is read here only to fail the boot on an unusable one. What - // the handshake uses is read again, per handshake, below. - if _, err := peerAnchor(p.trustBundleFile); err != nil { - return nil, err - } - // The SDK owns the floor (TLS 1.3) and the leaf's reload; this runtime owns - // which files, which anchor, and that a peer is authenticated at all. - config, err := codefly.ServerTLSConfig(p.certFile, p.keyFile, nil) - if err != nil { - return nil, fmt.Errorf("load this workload's X.509-SVID from %q/%q: %w — the listener presents it and there is no plain-HTTP listener; see %s and %s", - p.certFile, p.keyFile, err, IdentityCertFileEnvironmentVariable, IdentityKeyFileEnvironmentVariable) - } - // Peer trust is resolved per handshake rather than snapshotted into - // ClientCAs at boot. - // - // A pool read once is a pool that cannot shrink: removing a compromised - // root from the projected bundle would leave it authenticating clients for - // the life of the process, and the SDK's reloader reloads the leaf this - // listener presents, not the anchor it judges callers by. Revocation that - // takes effect only on a restart is revocation the operator has to - // remember to finish. - // - // A bundle that has become unreadable or unusable fails the handshake - // rather than falling back to the last good pool. The two failures are not - // symmetric: serving a stale *leaf* refuses callers who should be let in, - // while judging by a stale *anchor* lets in callers who should be refused. - bundle := p.trustBundleFile - config.GetConfigForClient = func(*tls.ClientHelloInfo) (*tls.Config, error) { - anchor, err := peerAnchor(bundle) - if err != nil { - return nil, err - } - fresh := config.Clone() - fresh.ClientCAs = anchor - fresh.ClientAuth = tls.RequireAndVerifyClientCert - fresh.GetConfigForClient = nil - return fresh, nil - } - config.ClientAuth = tls.RequireAndVerifyClientCert - return config, nil -} - -// peerAnchor reads the trust anchor this listener verifies its callers against. -// -// There is no "no anchor" case. A listener that verifies no peer certificate -// accepts any caller that can route to the pod, which bypasses the admission -// and exposure decisions the host makes on its own routes — so an absent anchor -// is a boot refusal naming the value, not a listener that quietly authenticates -// nobody. It was conditional once, on the grounds that the gateway presents no -// client certificate today; that is a statement about a counterpart's current -// state, and leaving a hole open for as long as the counterpart takes is the -// concession this cutover exists to stop making. -func peerAnchor(bundleFile string) (*x509.CertPool, error) { - if bundleFile == "" { - return nil, fmt.Errorf("no workload identity trust anchor resolved: this listener requires and verifies a caller's certificate, so it cannot start without the anchor those certificates are issued under. Set %s, or have the platform provision %s/%s", - IdentityTrustBundleFileEnvironmentVariable, WorkloadIdentityGroup, WorkloadIdentityTrustBundleFileKey) - } - pem, err := os.ReadFile(bundleFile) - if err != nil { - return nil, fmt.Errorf("read workload identity trust anchor %q: %w — it is what this listener verifies its callers against, so an unreadable one refuses the handshake rather than admitting everyone", - bundleFile, err) - } - anchor := x509.NewCertPool() - if !anchor.AppendCertsFromPEM(pem) { - return nil, fmt.Errorf("workload identity trust anchor %q contains no certificate: the listener would otherwise verify no peer at all", bundleFile) - } - return anchor, nil -} - -// listen binds this solution's port and wraps it in the workload's identity. -// -// There is no plain-HTTP path. The host refuses a plain-HTTP destination, so a -// listener without an identity would be a solution that boots, looks healthy, -// and is refused at the edge for a reason only the edge can see — which is the -// failure the whole delivered-presence model exists to end. A configuration -// that cannot produce an identity is refused here, naming the material that is -// missing. -func (s *Server) listen() (net.Listener, error) { - config, err := s.serverIdentity() - if err != nil { - return nil, err - } - ln := s.boundListener - if ln == nil { - var err error - if ln, err = net.Listen("tcp", ":"+s.cfg.port); err != nil { - return nil, err - } - } - // The per-connection recheck is installed on the *server*, not here: see - // watchInboundTrust. A listener that wrapped its connections would hide - // the *tls.Conn from net/http, which decides from that concrete type alone - // whether a connection is TLS at all — so r.TLS would be nil in every - // handler, ALPN and HTTP/2 would not be negotiated, and the handshake - // deadline would never be set. The recheck must not cost the listener its - // TLS-ness to get it. - return tls.NewListener(ln, config), nil -} - -// inboundHandshakeTimeout bounds the TLS handshake and the request headers of -// an accepted connection. -// -// http.Server derives its handshake deadline from the smallest of -// ReadHeaderTimeout, ReadTimeout and WriteTimeout, so this one value bounds -// both. With all three unset, as they were, any peer that completes a TCP -// connect and then sends a partial ClientHello — or a header line at a byte a -// minute — holds a goroutine and a file descriptor indefinitely, and every -// ClientHello costs a read of the trust bundle, so the cheap half of that is -// the attacker's. Neither ReadTimeout nor WriteTimeout is set, deliberately: -// they bound the whole body, and a declared long-running stream (up to -// MaxStreamDurationLimit) is a conforming request, not a slow one. -const inboundHandshakeTimeout = 10 * time.Second - -// inboundIdleTimeout bounds a keep-alive connection nobody is using. It is pool -// hygiene, not the trust bound — what bounds the trust decision is the recheck, -// because an inactivity timeout cannot bound a connection that is never -// inactive, which is the finding this runtime already answered outbound. -const inboundIdleTimeout = 30 * time.Second - -// peerTrustAnchor resolves the anchor this runtime verifies its peers against, -// per call, in whichever direction. -// -// One resolution for both directions because it is one anchor: the listener -// verifies a caller against it and the client verifies the platform against it, -// which is what makes a removed root take effect on both halves at once. -func (s *Server) peerTrustAnchor(identityConfig *tls.Config) func() (*x509.CertPool, error) { - if s.identity != nil { - source := s.identity - return func() (*x509.CertPool, error) { - anchor, err := peerAnchorFrom(source, identityConfig) - if err != nil { - return nil, fmt.Errorf("resolve the anchor this workload verifies its peers against, from the supplied identity source: %w", err) - } - return anchor, nil - } - } - return func() (*x509.CertPool, error) { - anchor, err := peerAnchor(s.cfg.trustBundleFile) - if err != nil { - return nil, fmt.Errorf("resolve the anchor this workload verifies its peers against: %w", err) - } - return anchor, nil - } -} - -// watchInboundTrust re-verifies every served connection's caller against -// current trust for as long as the connection lives, and closes it when it -// stops verifying. -// -// It is the inbound half of an argument this file already made outbound, and -// made in one direction only. Re-reading trust per *handshake* bounds nothing -// about a connection that has already had its handshake: a caller whose -// identity was removed from the admitted set, or whose issuing root was pulled -// from the bundle, kept the keep-alive connection it already had and kept being -// served on it. Inbound is where the decision is whom to *admit*, which is the -// direction where judging by something stale lets in whoever should be refused. -// -// Installed through http.Server.ConnState so the connection stays the -// *tls.Conn net/http requires it to be. StateActive is the first point at which -// there is a peer chain to re-verify — crypto/tls handshakes lazily, so at -// Accept there is nothing yet — and StateClosed/StateHijacked is where the -// watch ends. -func (s *Server) watchInboundTrust() (func(net.Conn, http.ConnState), error) { - config, err := s.serverIdentity() - if err != nil { - return nil, err - } - trust := s.peerTrustAnchor(config) - admitted := s.admittedCallers() - var watching sync.Map - return func(conn net.Conn, state http.ConnState) { - switch state { - // StateNew, not StateActive: the watch begins when the connection is - // accepted, so it covers the connection from the moment it is - // authenticated. StateActive is the first point a *request* has been - // read, which is later — a caller could complete its handshake, - // have its issuing root pulled, and sit inside the header deadline - // unwatched. The recheck loop waits for the handshake itself rather - // than for traffic. - case http.StateNew: - tlsConn, ok := conn.(*tls.Conn) - if !ok { - return - } - watched := &watchedCaller{done: make(chan struct{})} - if _, already := watching.LoadOrStore(conn, watched); already { - return - } - go recheckCaller(tlsConn, watched, trust, admitted) - case http.StateActive: - // The identity is recorded HERE, synchronously, before the handler - // runs — not on the recheck's first tick. - // - // Pinning on the first tick left a one-second window, and I wrote - // that it was sound because "no in-process hook can run inside - // it". That was false, and executed round eight proved it: a - // RequestHandler is an in-process hook, it is handed r.TLS, and - // tls.ConnectionState shares its certificates with the connection. - // A handler rewriting PeerCertificates[0].Raw inside the first - // second had the pin record the rewritten identity, and a - // de-admitted caller was served 465 requests over 10 seconds on - // one connection. - // - // net/http calls this hook after readRequest and before - // ServeHTTP (server.go, setState(StateActive, runHooks)), so this - // runs ahead of any author code on the first request and every - // one after it. It does not run for HTTP/2, where net/http passes - // skipHooks — which is why TestTheListenerDoesNotNegotiateHTTP2 - // exists: this listener advertises no ALPN protocol and is served - // with Serve rather than ServeTLS, so nothing negotiates h2 today, - // and if that changes the pin has to move rather than quietly stop - // happening. - if watched, ok := watching.Load(conn); ok { - if tlsConn, isTLS := conn.(*tls.Conn); isTLS { - watched.(*watchedCaller).pin(tlsConn.ConnectionState()) - } - } - case http.StateClosed, http.StateHijacked: - if watched, ok := watching.LoadAndDelete(conn); ok { - watched.(*watchedCaller).stop() - } - } - }, nil -} - -// watchedCaller is one watched connection's lifetime and the identity it was -// admitted as. -// -// The identity is recorded once, by whichever of the two reaches it first: the -// ConnState hook at the first request, or the recheck's own first tick for a -// connection that handshakes and then sends nothing. Both read it from a -// private parse (see immutableChain); the difference is that the hook runs -// before any handler, and the tick does not. -type watchedCaller struct { - done chan struct{} - stopOnce sync.Once - mu sync.Mutex - identity string - leaf []byte - refused error -} - -// pin records the identity this connection was authenticated as, if nothing has -// yet. -func (w *watchedCaller) pin(state tls.ConnectionState) { - if !state.HandshakeComplete { - return - } - w.mu.Lock() - defer w.mu.Unlock() - if w.identity != "" || w.refused != nil { - return - } - identity, leaf, err := admittedIdentity(state) - if err != nil { - w.refused = err - return - } - w.identity, w.leaf = identity, leaf -} - -// pinned is what was recorded, and any error that recording it produced. -func (w *watchedCaller) pinned() (string, []byte, error) { - w.mu.Lock() - defer w.mu.Unlock() - return w.identity, w.leaf, w.refused -} - -func (w *watchedCaller) stop() { - w.stopOnce.Do(func() { close(w.done) }) -} - -// recheckCaller re-verifies one established caller until the connection ends. -func recheckCaller(conn *tls.Conn, watched *watchedCaller, trust func() (*x509.CertPool, error), admitted func() ([]string, error)) { - ticker := time.NewTicker(inboundTrustRecheckInterval) - defer ticker.Stop() - for { - select { - case <-watched.done: - return - case <-ticker.C: - // Until the handshake completes there is no peer chain to judge, - // and an unhandshaken connection is not a caller who should be - // refused — it is bounded by the handshake deadline instead. This - // is why the watch can start at accept: it waits for - // authentication rather than for the first request. - state := conn.ConnectionState() - if !state.HandshakeComplete { - continue - } - // For a connection that handshook and has sent nothing, no request - // has reached StateActive, so nothing has pinned it yet and no - // handler has run either. Pinning here is then reading the chain - // as the handshake left it. - watched.pin(state) - pinned, leaf, err := watched.pinned() - if err != nil { - _ = conn.Close() - return - } - if err := callerStillAdmitted(state, trust, admitted, pinned, leaf); err != nil { - _ = conn.Close() - return - } - } - } -} - -// inboundTrustRecheckInterval bounds how long an accepted connection may -// outlive the trust that admitted it. The outbound interval's reasoning, in the -// direction where being wrong means serving a caller who should be refused. -const inboundTrustRecheckInterval = time.Second - -// callerStillAdmitted re-verifies an established caller against the current -// anchor and the current admitted set. -// -// Client auth, not server auth, and no hostname: a caller is held to the chain -// and the identity it presented, which is the same pair of questions the -// handshake answered, asked again against trust as it is now. -func callerStillAdmitted(state tls.ConnectionState, trust func() (*x509.CertPool, error), admitted func() ([]string, error), pinned string, pinnedLeaf []byte) error { - anchor, err := trust() - if err != nil { - return err - } - chain, err := immutableChain(state) - if err != nil { - return err - } - if len(chain) == 0 { - return fmt.Errorf("the established connection presents no caller certificate to re-verify") - } - intermediates := x509.NewCertPool() - for _, cert := range chain[1:] { - intermediates.AddCert(cert) - } - if _, err := chain[0].Verify(x509.VerifyOptions{ - Roots: anchor, - Intermediates: intermediates, - KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, - }); err != nil { - return fmt.Errorf("the caller this connection was authenticated against no longer verifies: %w", err) - } - allowed, err := admitted() - if err != nil { - return err - } - identity, err := oneURIIdentity(chain, "caller") - if err != nil { - return err - } - // Still the caller this connection was admitted as. A connection whose - // certificate changed under it is not a caller whose authorisation can be - // re-evaluated — it is one whose authentication no longer means anything, - // so it is closed rather than judged. - if pinned != "" && identity != pinned { - return fmt.Errorf("this connection was admitted as %q and now presents %q: the certificate authenticating an established connection does not change, so there is nothing left to re-authorise", pinned, identity) - } - if pinnedLeaf != nil && !bytes.Equal(chain[0].Raw, pinnedLeaf) { - return fmt.Errorf("this connection's caller certificate is not the one the handshake authenticated, though it still names %q", identity) - } - if !slices.Contains(allowed, identity) { - return fmt.Errorf("the established connection's caller %q is no longer one this solution admits", identity) - } - return nil -} - -// admittedIdentity is the identity and leaf an established connection was -// authenticated as, taken from a private parse so what is recorded cannot be -// changed afterwards through the objects the connection shares. -func admittedIdentity(state tls.ConnectionState) (string, []byte, error) { - chain, err := immutableChain(state) - if err != nil { - return "", nil, err - } - identity, err := oneURIIdentity(chain, "caller") - if err != nil { - return "", nil, err - } - return identity, chain[0].Raw, nil -} - -// serverIdentity resolves this workload's identity once and holds it to every -// posture rule, memoised so the boot and the listener are the same identity -// rather than two resolutions that could differ. -// -// It is called *before* the first outbound act, which is the ordering half of a -// real bug: the credential mint used to run before listen(), so a leaf issued -// for a neighbouring workload had already been presented to the host — along -// with this workload's projected token — by the time the principal check -// refused the listener. The check that says "this is the workload the platform -// provisioned" is worth nothing if the mint has already happened under the -// wrong one. -func (s *Server) serverIdentity() (*tls.Config, error) { - if s.identityConfig != nil { - return s.identityConfig, nil - } - source := s.identity - if source == nil { - source = projectedIdentity{ - certFile: s.cfg.identityCertFile, - keyFile: s.cfg.identityKeyFile, - trustBundleFile: s.cfg.trustBundleFile, - } - } - answered, err := source.ServerTLSConfig() - if err != nil { - return nil, err - } - if err := usableServerIdentity(answered); err != nil { - return nil, err - } - // A source that resolves anything per handshake has to be askable, and - // carrying a base pool as well does not make it so. - // - // The refusal used to live in peerAnchorFrom, which reached it only when - // the base pool was nil — so a source with BOTH a base pool and a - // per-handshake callback was accepted, and then had the base pool frozen - // for every recheck and every outbound dial while its callback went on - // resolving a fresh one. Executed: after the bundle rotated A→B a fresh - // A-caller was refused and the ESTABLISHED A-caller served 30 requests - // over 3 seconds. This file and the README both said that source was - // refused at boot. It was not, and the half that was true was the half - // nobody writes. - if answered.GetConfigForClient != nil { - if _, askable := source.(PeerAnchorSource); !askable { - return nil, fmt.Errorf("the identity source resolves its configuration per handshake (GetConfigForClient is set) and does not implement PeerAnchorSource, so this runtime cannot obtain the anchor it re-verifies established peers against — or verifies the platform it dials against — except through a handshake nobody made. Carrying a pool on the base configuration does not answer it: that pool is one object fixed when the source built it, so it would freeze the anchor this process judges by. Implement PeerAnchor() (*x509.CertPool, error)") - } - } - // On a clone, because everything below writes to it: the certificate hold - // wraps GetCertificate, admitOnly composes over VerifyConnection, and the - // per-connection hold wraps GetConfigForClient. A source is under no - // obligation to hand back a fresh configuration — a consumer may return one - // it keeps and reuses, which is the ordinary way to write one — so mutating - // the answer is a write to a value another handshake may be reading, and a - // second call would wrap the wrapper. - // - // holdPerConnectionPosture already says this about a *per-connection* - // answer, in those words, and this function then did the thing that comment - // forbids one level up. - config := answered.Clone() - refuseResumption(config) - if err := holdServedCertificate(config, s.principal); err != nil { - return nil, err - } - admitOnly(config, s.admittedCallers()) - holdPerConnectionPosture(config, s.principal, s.admittedCallers()) - s.identityConfig = config - return config, nil -} - -// admitOnly decides which authenticated callers this listener actually serves. -// -// Verifying a caller's certificate against the cell's anchor answers "does this -// caller hold an identity the platform issued". It does not answer "may this -// caller call me", and in a cell those are very different sets: every workload -// in the trust domain holds such a certificate, including the modules this -// solution consumes. Without this, any of them could reach a handler or a -// passthrough route directly, bypassing the admission the host decides on its -// own routes — and because gatewayFor reads x-org-id and x-session-id as -// headers the gateway stamped from a verified bearer, such a caller could set -// them itself and have this runtime mint capabilities, under this workload's -// attestation, for an organization and session nobody authenticated. A confused -// deputy, where the deputy is the one process the issuer trusts to say which -// module is asking. -// -// So the admitted set is declared, provisioned, and refused at boot when -// absent (validateSources) — not derived from the anchor, which is exactly the -// set that is too wide. The comparison is the SPIFFE ID in the leaf's URI SAN, -// the same thing the listener's own identity is held to. -func admitOnly(config *tls.Config, allowed func() ([]string, error)) { - // Assigned, not composed, and that is a consequence rather than a - // preference. - // - // This composed over a source's own VerifyConnection, so that a consumer's - // check was not silently deleted by adding one. Then the order had to - // change — tls.ConnectionState carries pointers to the parsed chain, so a - // source's verifier running first could edit the certificate this - // admission reads, and rewriting the leaf's URI SANs was demonstrated - // getting a rival caller in. Then the hook was refused outright, because - // holding those pointers also let it change what the per-second rechecks - // read minutes later, and there is no safe way to lend a source the - // objects this listener authenticates by. - // - // So the composition had nothing left to compose: usableServerIdentity - // refuses a source that sets VerifyConnection, on the base configuration - // and on every per-connection answer, and clientTLSFrom builds the - // outbound configuration from scratch rather than copying one. The branch - // was kept here with a comment claiming it served the outbound direction. - // It did not, and a mutation that removed it survived the whole suite. - // Legacy means delete. - config.VerifyConnection = verifyCaller(allowed) -} - -// verifyCaller is the check itself, so it can be applied to a per-connection -// configuration as well as the base one. The set is resolved per handshake, not -// captured: see config.resolveAllowedCallers. -func verifyCaller(allowed func() ([]string, error)) func(tls.ConnectionState) error { - return func(state tls.ConnectionState) error { - admitted, err := allowed() - if err != nil { - return err - } - chain, err := immutableChain(state) - if err != nil { - return err - } - identity, err := oneURIIdentity(chain, "caller") - if err != nil { - return err - } - if slices.Contains(admitted, identity) { - return nil - } - // The refusal names what arrived, not the allowed set: a caller is not - // told who else may call. - return fmt.Errorf("refusing a caller whose identity %q is not one this solution admits: it holds a certificate from this cell's anchor, which is not the same as being a caller this solution serves — see %s and %s/%s", - identity, IdentityAllowedCallersFileEnvironmentVariable, WorkloadIdentityGroup, WorkloadIdentityAllowedCallersFileKey) - } -} - -// admitOnlyPlatform is admitOnly for the destinations this runtime dials: the -// mint and the gateway. -// -// Verifying the chain and the hostname answers "did this cell issue the -// certificate at this address", which every workload in the cell satisfies — -// and a workload holding a certificate valid for the gateway's hostname -// completed this handshake and was handed the projected service-account token, -// the viewer's bearer and this workload's own credential. Authentication is not -// authorisation outbound either, and outbound is the direction where being -// wrong means handing the credentials over rather than accepting a call. -// -// Composed over whatever the configuration already verifies, never assigned -// over it, for the reason admitOnly records. -func admitOnlyPlatform(config *tls.Config, expected func() ([]string, error)) { - // Assigned too, for the same reason: clientTLSFrom builds this - // configuration field by field and never carries a source's - // VerifyConnection onto it, so there was never anything here to compose - // with either. - config.VerifyConnection = func(state tls.ConnectionState) error { - peers, err := expected() - if err != nil { - return err - } - chain, err := immutableChain(state) - if err != nil { - return err - } - identity, err := oneURIIdentity(chain, "platform destination") - if err != nil { - return err - } - if slices.Contains(peers, identity) { - return nil - } - return fmt.Errorf("refusing to present this workload's credentials to %q: it holds a certificate from this cell's anchor for the address this runtime dialled, which is not the same as being the party that answers there — each destination has its own provisioned set under %s, so neither the mint nor the gateway can stand in for the other", - identity, WorkloadIdentityGroup) - } -} - -// immutableChain is a private copy of the chain a handshake authenticated, -// parsed from copied bytes. -// -// Nothing this runtime decides is computed from the *x509.Certificate objects -// crypto/tls put in the ConnectionState. Those are shared: tls.Conn returns the -// same pointers on every call, so anything that was ever handed them can change -// what a later read sees — and the rechecks read minutes after the handshake. -// The source-supplied verifiers that could hold them are refused outright, and -// this is the other half: even if something acquires them, the identity and the -// re-verification are computed from bytes nothing else has a reference to. -// -// The copy is of the DER, not of the parsed struct: copying the struct would -// copy the pointers inside it. -func immutableChain(state tls.ConnectionState) ([]*x509.Certificate, error) { - chain := make([]*x509.Certificate, 0, len(state.PeerCertificates)) - for i, presented := range state.PeerCertificates { - der := make([]byte, len(presented.Raw)) - copy(der, presented.Raw) - parsed, err := x509.ParseCertificate(der) - if err != nil { - return nil, fmt.Errorf("the certificate at position %d of this peer's chain cannot be parsed from the bytes it presented: %w", i, err) - } - chain = append(chain, parsed) - } - return chain, nil -} - -// oneURIIdentity is the SPIFFE ID a verified peer presented. -// -// Exactly one URI SAN, because that is what a SPIFFE certificate has. Accepting -// "any SAN that matches" lets a leaf naming several identities be admitted on -// whichever one is convenient, and a leaf naming several is not a SPIFFE -// identity at all — the one it would be held to elsewhere is unknowable from -// here. -func oneURIIdentity(chain []*x509.Certificate, side string) (string, error) { - if len(chain) == 0 { - return "", fmt.Errorf("refusing a %s that presented no certificate", side) - } - leaf := chain[0] - if len(leaf.URIs) != 1 { - return "", fmt.Errorf("refusing a %s whose certificate names %d URI identities: a SPIFFE certificate names exactly one, and a leaf naming several could be accepted on whichever happens to match", - side, len(leaf.URIs)) - } - return leaf.URIs[0].String(), nil -} - -// uriStrings renders a leaf's URI SANs for a refusal. -func uriStrings(uris []*url.URL) []string { - rendered := make([]string, 0, len(uris)) - for _, uri := range uris { - rendered = append(rendered, uri.String()) - } - return rendered -} - -// peerAnchorFrom is the pool this runtime verifies its peers against, for a -// consumer-supplied source, resolved without fabricating a handshake. -// -// The order is: ask the source (PeerAnchorSource), then the anchor it carried -// on the configuration it returned. There is no third option, and in -// particular there is no asking GetConfigForClient with a hello nobody sent — -// see PeerAnchorSource for why that is unsound for an anchor specifically. -// -// Preferring the hook over the base pool is what keeps a rotating source's -// anchor live: a *x509.CertPool on the base configuration is one object fixed -// when the source built it, so a source that rotates its anchor and carries a -// base pool had every peer judged by the pool this process booted with. -func peerAnchorFrom(source IdentitySource, config *tls.Config) (*x509.CertPool, error) { - if asked, ok := source.(PeerAnchorSource); ok { - anchor, err := asked.PeerAnchor() - if err != nil { - return nil, fmt.Errorf("the identity source could not say what trust anchor its peers are verified against: %w", err) - } - if anchor == nil { - return nil, fmt.Errorf("the identity source answered PeerAnchor with no pool, so there is nothing to verify a peer against") - } - return anchor, nil - } - if config.ClientCAs != nil { - return config.ClientCAs, nil - } - return nil, fmt.Errorf("the identity source resolves its trust anchor per handshake and does not implement PeerAnchorSource, so this runtime cannot obtain the anchor to re-verify an established caller — or to verify the platform it dials — without asking the source through a handshake nobody made. Carry the anchor on the configuration ServerTLSConfig returns, or implement PeerAnchor() (*x509.CertPool, error)") -} - -// clientTLSFrom derives the configuration this runtime dials the platform with -// from the one its listener serves: the same certificate, the same anchor. -// -// The certificate is taken through the source's own callback rather than copied, -// so a rotated leaf is presented outbound as well — snapshotting it here would -// mean the listener served a fresh leaf while the mint presented an expired one, -// and the failure would name neither. -func clientTLSFrom(source IdentitySource, config *tls.Config) (*tls.Config, error) { - anchor, err := peerAnchorFrom(source, config) - if err != nil { - return nil, err - } - client := &tls.Config{RootCAs: anchor, MinVersion: tls.VersionTLS13} - switch { - case config.GetCertificate != nil: - get := config.GetCertificate - client.GetClientCertificate = func(*tls.CertificateRequestInfo) (*tls.Certificate, error) { - // An empty hello. There is no real one to pass: this is the - // *client* side, where no ClientHelloInfo exists at all, and the - // source's only certificate callback is the server-side one. - // - // Sound here, unlike for an anchor, and the difference is worth - // stating because the same fabrication is a blocker one field - // along: whatever comes back is held to the frozen principal by - // holdPresentedCertificate, at the moment it is presented, so a - // source that answers this differently from the real handshake is - // caught by that hold. An anchor has no such check behind it — - // it *is* the check — which is why peerAnchorFrom refuses to - // obtain one this way and asks PeerAnchorSource instead. - return get(&tls.ClientHelloInfo{}) - } - case len(config.Certificates) > 0: - client.Certificates = config.Certificates - default: - return nil, fmt.Errorf("the identity source produces no certificate this runtime can present to the platform: it would dial the mint and the gateway as an anonymous client, which cannot be held to this installation") - } - return client, nil -} - -// usableServerIdentity refuses a configuration this listener must not serve -// under. An IdentitySource is consumer code — a mesh CA, a SPIFFE workload API, -// a pair the local toolchain minted — and every one of them has to arrive at -// the same posture, not just the one this package writes itself. -// -// Three things are checked, and each of them was a way in: -// -// - no certificate at all: a source that returns a bare &tls.Config{}, or one -// whose material it silently failed to load, produces a listener that -// completes no handshake and reports a TLS error naming nothing to every -// caller; -// - a floor below TLS 1.3: the default source gets its floor from the SDK, so -// checking only the certificate left a consumer's own config free to accept -// 1.2 — the strongest guarantee in this file silently optional for exactly -// the callers who wrote their own source; -// - a peer it does not authenticate: see peerAnchor. -func usableServerIdentity(config *tls.Config) error { - if config == nil { - return fmt.Errorf("the identity source returned no TLS configuration: this listener presents this workload's X.509-SVID and there is no plain-HTTP listener to fall back to") - } - if config.GetCertificate == nil && config.GetConfigForClient == nil && len(config.Certificates) == 0 { - return fmt.Errorf("the identity source returned a TLS configuration with no certificate: a handshake would be answered with none, so the listener is refused rather than started") - } - if config.MinVersion < tls.VersionTLS13 { - return fmt.Errorf("the identity source returned a TLS configuration whose floor is below TLS 1.3 (MinVersion 0x%04x): set MinVersion to tls.VersionTLS13, which is what every destination in this model is held to", - config.MinVersion) - } - if config.ClientAuth < tls.RequireAndVerifyClientCert { - return fmt.Errorf("the identity source returned a TLS configuration that does not require and verify a caller's certificate (ClientAuth %v): a listener that authenticates no peer accepts anything that can route to it, bypassing the admission its host decides on its routes", - config.ClientAuth) - } - // And it has to say *which* anchor a caller is verified against. This is - // not pedantry about a field: with RequireAndVerifyClientCert and a nil - // ClientCAs, Go verifies client certificates against the host's system - // roots, so the listener demands a certificate and then accepts one from - // any public CA — a posture that reads as mutual TLS in every log line and - // admits the internet. Requiring a certificate and not saying whose is - // strictly worse than requiring none, because it looks like the strong - // thing. - // - // A source may resolve the anchor per handshake instead, which is what the - // projected one does so that a removed root takes effect without a restart; - // then the callback's answer is where the pool must appear, and - // holdPerConnectionPosture checks it there. - if config.ClientCAs == nil && config.GetConfigForClient == nil { - return fmt.Errorf("the identity source returned a TLS configuration that requires a caller's certificate but names no trust anchor to verify it against (ClientCAs is nil): Go would fall back to this host's system roots, so the listener would accept a client certificate from any public CA. Set ClientCAs, or resolve it per handshake in GetConfigForClient") - } - return unsubvertedPosture(config) -} - -// refuseResumption turns session resumption off on the configuration this -// listener actually serves. -// -// Taken, not filtered, and this is the one place in this file where that -// distinction was load-bearing. A resumed TLS connection presents **no -// certificate**: the peer is accepted on the strength of a ticket. This -// listener's whole model is that a caller's certificate names them and the -// admitted set decides whether that name may call, so anything able to forge a -// ticket is able to be admitted as whoever the ticket was issued to, without a -// certificate ever being presented. -// -// A denylist could not close that. It caught `SessionTicketKey`, and it could -// not catch `SetSessionTicketKeys`, which installs unexported keys that cannot -// be read back off the configuration — and a review demonstrated the gap end to -// end: with a known key, a client holding only that key and an admitted -// caller's ticket resumed as that caller, `DidResume=true`, peer identity -// intact, presenting nothing. The per-connection recheck then re-verified the -// stolen certificate from the resumed state and kept the connection open, which -// is the recheck working correctly on a premise that was already false. -// -// So resumption is off, on the held clone and on every per-connection clone. -// What it costs is one round trip on a reconnect, for a service that re-reads -// its admission set every second anyway; what it buys is that every connection -// this listener serves had a certificate presented on it. The denylist entry -// stays, because a source that sets the field deserves to be told at boot -// rather than silently overridden. -func refuseResumption(config *tls.Config) { - config.SessionTicketsDisabled = true -} - -// unsubvertedPosture refuses the fields that leave every check above passing -// and the posture gone. -// -// It is a denylist over a struct this package does not own, and the claim that -// it was "complete for the fields that exist in the Go version in go.mod" was -// false twice: first for the session-ticket keys, then for -// VerifyPeerCertificate. A Clone() copies 29 of 32 fields unchanged, so what -// this does not name, a source keeps. Where a property matters it is taken -// rather than checked — resumption is disabled outright, and the caller's -// identity is read from a fresh parse of the raw DER — and this list is the -// part that only *tells* a source it has gone wrong. -// -// The checks above enumerate what a conforming listener must *have*, which -// answers the wrong question on its own: a source can satisfy all of them and -// still hand back a configuration whose certificate validity is judged against -// a clock it chose, whose session secrets it writes to a file, whose key -// material comes from a reader it supplies, or whose resumption it encodes and -// decodes itself — and resumption is the sharpest of them, because a resumed -// connection does not re-present a certificate, so a source that controls -// ticket encoding controls who gets in without the rest of this file ever -// seeing a handshake. -// -// Each of these has a safe zero value that means "use Go's own", so refusing a -// non-zero one is exact rather than a guess about intent. That is also the -// limit worth stating: this is a denylist over a struct this package does not -// own, so it is complete for the fields that exist in the Go version in go.mod -// and not by construction. A field added later that lowers posture would pass -// until it is named here, which is why the per-connection answer is checked by -// the same function rather than by a copy of its reasoning. -func unsubvertedPosture(config *tls.Config) error { - for _, field := range []struct { - name, why string - set bool - }{ - {"Time", "certificate expiry and validity are judged against this clock, so a source supplying one decides that an expired certificate is current", config.Time != nil}, - //nolint:staticcheck // Reading a deprecated field is the point: this refuses a source that set it. Naming it keeps the reference compile-checked, which reflection by field name would not — and a security check that stops working silently when a field is renamed is the one outcome worse than the warning. - {"Rand", "the handshake's key material comes from this reader, so a source supplying one decides how guessable this connection's secrets are", config.Rand != nil}, - {"KeyLogWriter", "the session secrets of every connection are written here in the clear, which is a decryption key for the traffic this listener exists to protect", config.KeyLogWriter != nil}, - {"WrapSession", "resumption tickets are encoded by this, and a resumed connection presents no certificate, so a source encoding its own tickets admits callers without a handshake this package sees", config.WrapSession != nil}, - {"UnwrapSession", "resumption tickets are decoded by this, with the same consequence as WrapSession", config.UnwrapSession != nil}, - {"InsecureSkipVerify", "the peer's certificate chain is not verified at all", config.InsecureSkipVerify}, - // VerifyPeerCertificate is handed the parsed chain this runtime then - // reads the caller's identity from, and it runs first. A source that - // rewrites leaf.URIs there had a rival caller admitted — and the - // per-second recheck read the same mutated leaf and agreed with it. - // The identity check now re-parses the raw DER, so the mutation no - // longer decides anything; this refusal is the other half, because a - // source has no business holding a pointer to the chain this - // listener's admission is computed from. - {"VerifyPeerCertificate", "it is handed the parsed certificate chain this listener authenticates the caller by, and it runs before the signature check — so a source can change the public key the handshake is verified against, as well as the identity the admission reads", config.VerifyPeerCertificate != nil}, - // And VerifyConnection, for the half that ordering could not close. - // - // A tls.ConnectionState carries *pointers* to the parsed chain, and - // tls.Conn hands out the same ones on every ConnectionState() call — - // so a source's verifier does not merely see the certificates, it - // holds them, and the per-second rechecks read them minutes later. - // Running this runtime's admission first fixes the admission and - // nothing else: an established caller whose identity was rewritten - // afterwards survives withdrawal, because the recheck re-reads the - // mutated object and agrees with it. - // - // There is no safe way to lend a source these objects, so it is not - // lent them. A consumer that needs its own connection check is a - // follow-up for a hook that passes an immutable copy; the composition - // below is kept for the outbound direction, where the configuration is - // this package's own. - {"VerifyConnection", "it is handed pointers to the certificate chain this listener authenticates its callers by, and the per-connection rechecks read those same objects for as long as the connection lives — so anything written to them there decides who stays admitted", config.VerifyConnection != nil}, - // The ticket *keys*, not just the ticket callbacks. Resumption is the - // sharpest of these because a resumed connection presents no - // certificate, and a source can reach it two ways: by encoding the - // tickets itself (WrapSession/UnwrapSession, above) or by choosing the - // key they are sealed with. A chosen key is the same capability with - // fewer lines — anyone holding it can forge a ticket this listener - // accepts — and the denylist named only the callbacks. - //nolint:staticcheck // As above: deprecated is why it is refused, and the reference stays compile-checked. - {"SessionTicketKey", "resumption tickets are sealed with a key the source chose, and anyone holding it can forge a ticket this listener resumes without any certificate being presented", config.SessionTicketKey != [32]byte{}}, - // SetSessionTicketKeys installs keys that cannot be read back off the - // configuration, so no entry here can catch it. That hole is closed by - // refuseResumption instead, which is why this list is advice to a - // source and not the control. - } { - if field.set { - return fmt.Errorf("the identity source returned a TLS configuration that sets %s: %s. Leave it unset and Go's own is used — this listener's posture is not a thing a source may replace, only a thing it may satisfy", field.name, field.why) - } - } - return nil -} - -// holdPerConnectionPosture holds a source's per-connection callback to the -// posture its base configuration was checked against. -// -// The checks above read the configuration a source returns *at boot*. Go hands -// each handshake to GetConfigForClient when one is set, and the configuration -// that callback returns replaces the base one for that connection — floor, peer -// requirement and certificate included. So without this, a source could conform -// at boot, pass every check in this file, and then answer each individual -// handshake with TLS 1.2, no peer authentication, or a neighbouring workload's -// leaf. -// -// That is not a theoretical shape: the projected source uses that very callback -// to re-read peer trust, so "a source with a per-connection callback" is the -// documented pattern here rather than an odd one — and a consumer following it -// is one line away from lowering the floor for every connection while the boot -// still reports a conforming listener. -// -// It is the same argument as the rest of this cutover, one layer along: -// boot-time approval is not authorization at use. A callback returning nil is -// Go's "serve the base configuration", which was already checked, so it passes -// through untouched; anything else is checked again, and a configuration below -// the posture fails that handshake rather than serving it weakened — the caller -// that happens to arrive is not what is in question, the configuration the -// listener would answer anyone with is. -func holdPerConnectionPosture(config *tls.Config, principal string, allowed func() ([]string, error)) { - // The base configuration's own certificate is already held at use by - // holdServedCertificate, which serverIdentity calls first — a rotation - // landing a certificate for another workload is refused as it is served, - // not as it was at boot. This function used to wrap GetCertificate a second - // time for that, and the duplicate quietly made the real wrapper - // untestable: a mutation that disabled it left the copy here passing every - // certificate-selection test, so the check this file argues for could have - // been broken with nothing to notice. - inner := config.GetConfigForClient - if inner == nil { - return - } - // Whether the base configuration stands on its own, decided here rather - // than at the handshake: a callback is allowed to answer nil, which is - // Go's "serve the base configuration", and the base was only admitted at - // boot *because* a callback would resolve the anchor per handshake. A - // source that answered nil for the hello that actually arrived therefore - // served a configuration requiring a caller's certificate against no - // anchor at all — system roots — while every check in this file had - // passed. A conditional nil answer was demonstrated doing exactly that. - baseStandsAlone := config.ClientCAs != nil - config.GetConfigForClient = func(hello *tls.ClientHelloInfo) (*tls.Config, error) { - answer, err := inner(hello) - if err != nil { - return nil, err - } - if answer == nil { - if !baseStandsAlone { - return nil, fmt.Errorf("the identity source answered this handshake with nothing, which serves its base configuration — and that one names no trust anchor to verify a caller against (ClientCAs is nil), so Go would accept a client certificate from any public CA. A source that resolves the anchor per handshake has to resolve it for every handshake, or carry a usable one on the configuration it returned at boot") - } - return nil, nil - } - if err := usableServerIdentity(answer); err != nil { - return nil, fmt.Errorf("the identity source answered this handshake with a configuration below the posture its boot configuration was held to: %w", err) - } - // On a per-connection answer the pool is not optional: this *is* the - // configuration the handshake runs under, so "resolved per handshake" - // has to mean resolved, not deferred again to system roots. - if answer.ClientCAs == nil { - return nil, fmt.Errorf("the identity source answered this handshake with a configuration that requires a caller's certificate but names no trust anchor (ClientCAs is nil): Go would verify it against this host's system roots, accepting a client certificate from any public CA") - } - // Who may call is re-imposed too: a source's callback that returned a - // configuration without it would serve that connection admitting every - // identity in the cell. - // - // On a clone, never on what the callback handed back. crypto/tls does - // not promise a fresh configuration per call — a source may return one - // it keeps, and the projected source here returns a clone only because - // it happens to — so mutating the answer is a write to a value another - // handshake may be reading, which is both a data race and a violation - // of the rule that a configuration in use is not modified. - admitted := answer.Clone() - refuseResumption(admitted) - // Held to the frozen principal here, on the clone, and not by probing - // the answer: a per-connection answer selects its own certificate, and - // an answer whose GetCertificate returned the approved leaf for a - // synthetic probe and another workload's for the hello that actually - // arrived was demonstrated completing the handshake as the other - // workload. holdServedCertificate wraps that selection instead. - if err := holdServedCertificate(admitted, principal); err != nil { - return nil, fmt.Errorf("the identity source answered this handshake with a configuration whose certificate is not this workload's: %w", err) - } - admitOnly(admitted, allowed) - return admitted, nil - } -} - -// holdServedCertificate rewrites config so that whichever certificate a -// handshake actually selects is this workload's. -// -// The check this replaces read one certificate and approved a configuration: -// it called GetCertificate with a synthetic hello naming "localhost", parsed -// what came back, compared it to the frozen principal, and left the -// configuration alone. Every part of that is a different certificate from the -// one a caller is served, and four separate shapes were demonstrated serving a -// neighbouring workload's leaf through a configuration that had passed: -// -// - a source keyed on SNI answered the synthetic "localhost" probe with the -// approved leaf and the real (no-SNI, IP-addressed) hello with another — -// the probe is a name nothing in a cell dials by; -// - NameToCertificate selected a second certificate by name; -// - the base configuration carried GetCertificate returning the approved leaf -// *and* a static Certificates[0] that was not, and Go consults -// GetCertificate only when there is a certificate list to fall back from or -// an SNI name to key on (crypto/tls, (*Config).getCertificate) — so a -// caller addressing this pod by IP was served the static one, unprobed. The -// SDK's own ServerTLSConfig documents that trap and sets GetCertificate -// alone for exactly this reason; a consumer's source need not; -// - tls.Certificate.Leaf described the approved identity while the DER beside -// it described another. Leaf is a parse cache the holder fills in, not the -// bytes the handshake sends. -// -// So nothing here trusts a probe. The selection surface is narrowed to one -// certificate, a static one is checked as itself, and the callback is wrapped -// so the pair *it returns for this handshake* is what gets compared — the -// certificate is validated at use and the validated pair is what is served. -// -// The principal is an authority-bearing value the platform provisioned and this -// process read once; the leaf is what callers actually see. Nothing else in the -// boot compares them, so a pair projected for another workload — the wrong -// Secret mounted, a Certificate issued for a neighbouring service — would be -// served happily, and the mismatch would surface at whatever verifies the -// destination's identity, as a refusal naming neither file. -func holdServedCertificate(config *tls.Config, principal string) error { - if principal == "" { - // Nothing to hold the leaf to. openAuthority runs before listen on - // every path, so this is a programming error rather than a - // deployment's, and it fails rather than skipping the check. - return fmt.Errorf("this listener has no frozen principal to hold its own certificate to: the authority-bearing values must be read before the listener is built") - } - if err := oneCertificateToChooseFrom(config, "this listener"); err != nil { - return err - } - // A static certificate is reachable whatever else is set, so it is checked - // as itself rather than through a callback that may not be consulted. - if len(config.Certificates) == 1 { - if err := pairPresentsThisWorkload(&config.Certificates[0], principal); err != nil { - return err - } - } - if serve := config.GetCertificate; serve != nil { - config.GetCertificate = func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { - pair, err := serve(hello) - if err != nil { - return nil, err - } - if pair == nil { - // Go's documented "nothing for this hello, use the list", - // which was checked above or does not exist. - return nil, nil - } - if err := pairPresentsThisWorkload(pair, principal); err != nil { - return nil, err - } - return pair, nil - } - // And asked once now, so a misprovisioned boot is refused at boot - // rather than at every handshake — which also keeps the ordering this - // check exists for: serverIdentity runs before the first outbound act, - // so a pair issued for a neighbouring workload is caught before the - // projected token has been presented to anyone under it. - // - // The hello is empty, which is not a synthetic name but the hello a - // peer addressing this pod by IP actually sends — the previous check - // made one up ("localhost") and a source keyed on it answered the probe - // and the real handshake differently. And it can only *refuse* a boot, - // never approve one: a source that cannot answer an empty hello is left - // to the wrapper above, which is where the guarantee is. - if pair, err := serve(&tls.ClientHelloInfo{}); err == nil && pair != nil { - if err := pairPresentsThisWorkload(pair, principal); err != nil { - return err - } - } - return nil - } - if len(config.Certificates) == 1 { - return nil - } - return fmt.Errorf("the identity source produces its certificate per connection (GetConfigForClient only), so the identity this listener presents cannot be held to this workload's: return a configuration with GetCertificate or Certificates as well") -} - -// oneCertificateToChooseFrom refuses a configuration that selects between -// several certificates. -// -// A workload has one X.509-SVID. A configuration offering a choice is one whose -// served identity is decided at handshake time — by the name a caller sends, by -// which chain happens to be compatible with their signature algorithms — and -// "the one that was checked" is then a property of the caller rather than of -// this configuration. Narrowing the surface is what makes validating at use -// complete instead of validating whichever branch a test happened to take. -func oneCertificateToChooseFrom(config *tls.Config, side string) error { - //nolint:staticcheck // Deprecated and still consulted by crypto/tls whenever more than one certificate is configured, which is exactly the case this refuses. Not reading it would reinstate the bug. - if config.NameToCertificate != nil { - return fmt.Errorf("the identity source selects %s's certificate by server name (NameToCertificate): this workload has exactly one identity, and a configuration that chooses between several serves whichever the caller's name selects, not the one held to the frozen principal", side) - } - if len(config.Certificates) > 1 { - return fmt.Errorf("the identity source offers %s %d certificates to choose from: a workload has one X.509-SVID, and with several Go selects one at handshake time by name and by what the caller supports, so the certificate checked is not the certificate served", - side, len(config.Certificates)) - } - return nil -} - -// pairPresentsThisWorkload holds one certificate — the exact pair a handshake -// selected — to the frozen principal. -func pairPresentsThisWorkload(pair *tls.Certificate, principal string) error { - if pair == nil || len(pair.Certificate) == 0 { - return fmt.Errorf("the identity source produced an empty certificate chain for this handshake") - } - // The DER that will be sent, never pair.Leaf. - // - // Leaf is a parse cache its holder fills in and nothing keeps it honest: a - // pair carrying the approved identity in Leaf and another workload's - // certificate in Certificate[0] was demonstrated passing this check and - // completing a handshake as the other workload. The bytes on the wire are - // the identity; a struct field describing them is a claim. Parsing costs a - // few microseconds per handshake, which is the same trade this file already - // makes to re-read the trust anchor. - leaf, err := x509.ParseCertificate(pair.Certificate[0]) - if err != nil { - return fmt.Errorf("the certificate this handshake would be answered with cannot be parsed: %w", err) - } - return leafPresentsThisWorkload(leaf, principal) -} - -// leafPresentsThisWorkload is the comparison itself. -// -// The comparison is the SPIFFE ID in a URI SAN, which is what names a workload -// in this model (a presence document's expected identity is one). A leaf -// carrying none, or none that matches, is refused. -func leafPresentsThisWorkload(leaf *x509.Certificate, principal string) error { - // Exactly one, for the same reason a caller's is: a leaf naming several - // identities is not a SPIFFE identity, and holding it to "one of them - // matches" means this workload is whichever of them the far end chooses. - if len(leaf.URIs) == 1 && leaf.URIs[0].String() == principal { - return nil - } - if len(leaf.URIs) > 1 { - return fmt.Errorf("the certificate this workload would present names %d URI identities (%s): a SPIFFE certificate names exactly one, and this workload cannot be held to a leaf that claims several", - len(leaf.URIs), strings.Join(uriStrings(leaf.URIs), ", ")) - } - return fmt.Errorf("the certificate this workload would present names %s, and its frozen principal is %q (%s/%s): a pair projected for another workload would otherwise be served, and the mismatch would surface at whatever verifies this destination rather than here", - presentedIdentities(uriStrings(leaf.URIs)), principal, AuthorityGroup, AuthorityPrincipalKey) -} - -// presentedIdentities renders what a leaf does name, so a refusal distinguishes -// "the wrong identity" from "no identity at all". -func presentedIdentities(uris []string) string { - if len(uris) == 0 { - return "no URI identity at all" - } - return "the identities [" + strings.Join(uris, ", ") + "]" -} - -// heldToTheFrozenPrincipal wraps ONE certificate reader so the pair it hands -// back is checked against the frozen principal every time it answers. -// -// This exists as a function rather than only inside holdPresentedCertificate -// because there are now two outbound readers and only one of them was a -// *tls.Config. The mint's transport belongs to the SDK and takes a reader -// directly (MintOptions.ClientCertificate), so the runtime passed -// outboundLeaf — the raw projected-file reloader — straight to it, and the hold -// that the gateway's configuration gets was simply absent on the hop that -// carries the projected service-account token. After a same-CA rotation to a -// neighbouring workload's identity, the mint presented that identity while the -// frozen principal was still this one; the SDK checks that a certificate is -// non-empty and has a key, which is the right division of labour and not a -// check of WHOSE it is. -// -// Both callers run through this, rather than one of them running through a copy -// of its reasoning, because that is how the half-covered check above happened -// in the first place. -func heldToTheFrozenPrincipal( - read func(*tls.CertificateRequestInfo) (*tls.Certificate, error), - principal, destination string, -) func(*tls.CertificateRequestInfo) (*tls.Certificate, error) { - return func(info *tls.CertificateRequestInfo) (*tls.Certificate, error) { - pair, err := read(info) - if err != nil { - return nil, fmt.Errorf("the identity source could not produce the certificate this runtime presents to %s: %w", destination, err) - } - if principal == "" { - return nil, fmt.Errorf("this workload has no frozen principal to hold the certificate it presents to %s against: the authority-bearing values must be read before any outbound act", destination) - } - if err := pairPresentsThisWorkload(pair, principal); err != nil { - return nil, err - } - return pair, nil - } -} - -// holdPresentedCertificate holds an *outbound* configuration's certificate to -// the frozen principal, at the moment it is presented. -// -// The listener's leaf was checked and the client's was not, on the default path -// where they come from two separate reloaders over the same files. So a pair -// that rotated to a neighbouring workload's identity was presented to the mint -// and the gateway by this client while the listener refused to serve it — the -// check existed, ran on one of the two things it had to cover, and the half it -// missed is the half that talks to the party holding the projected token. -// -// Then the check it grew was a *preflight*: it called GetClientCertificate -// once, compared what came back, and left the source's own callback installed -// for the handshake. Rotating the files between the two was demonstrated -// presenting the other workload's leaf successfully — the same check/use gap as -// the inbound side, and the reason this wraps the callback rather than calling -// it. What a dial presents is what gets compared. -func holdPresentedCertificate(config *tls.Config, principal string) error { - if principal == "" { - return fmt.Errorf("this workload's outbound client has no frozen principal to hold its own certificate to: the authority-bearing values must be read before any outbound act") - } - if err := oneCertificateToChooseFrom(config, "this workload's outbound client"); err != nil { - return err - } - if len(config.Certificates) == 1 { - if err := pairPresentsThisWorkload(&config.Certificates[0], principal); err != nil { - return err - } - } - if get := config.GetClientCertificate; get != nil { - config.GetClientCertificate = heldToTheFrozenPrincipal(get, principal, "the platform") - // Refused at boot as well, for the reason holdServedCertificate - // records: a mismatch found at the first dial is a mismatch found after - // the projected token has gone out under it. - if pair, err := get(&tls.CertificateRequestInfo{}); err == nil && pair != nil { - if err := pairPresentsThisWorkload(pair, principal); err != nil { - return err - } - } - return nil - } - if len(config.Certificates) == 1 { - return nil - } - return fmt.Errorf("this workload's outbound client would present no certificate, so the platform cannot tell this workload from anything else that reached it") -} diff --git a/identity_test.go b/identity_test.go deleted file mode 100644 index 5ec9923..0000000 --- a/identity_test.go +++ /dev/null @@ -1,1227 +0,0 @@ -package solution - -import ( - "context" - "crypto/ed25519" - "crypto/rand" - "crypto/tls" - "crypto/x509" - "crypto/x509/pkix" - "fmt" - "github.com/codefly-dev/sdk-go/workcontext" - "io" - "math/big" - "net" - "net/http" - "os" - "path/filepath" - "strings" - "testing" - "time" -) - -// TestABootWithoutAWorkloadIdentityIsRefusedByName is the acceptance criterion -// for removing plain HTTP: the runtime refuses a listener configuration it -// cannot present an identity for, and names the material that is missing. A -// solution that came up on plain HTTP would be refused at the edge instead, -// for a reason only the edge can see. -func TestABootWithoutAWorkloadIdentityIsRefusedByName(t *testing.T) { - certFile, keyFile, bundleFile, _, _ := workloadIdentity(t, testPrincipal) - for _, tc := range []struct { - name string - cert string - key string - token string - bundle string - callers string - peers string - names string - variable string - }{ - {name: "no certificate", peers: testGatewayPrincipal, key: keyFile, token: "t", bundle: bundleFile, callers: testGatewayPrincipal, names: "certificate", variable: IdentityCertFileEnvironmentVariable}, - {name: "no private key", peers: testGatewayPrincipal, cert: certFile, token: "t", bundle: bundleFile, callers: testGatewayPrincipal, names: "private key", variable: IdentityKeyFileEnvironmentVariable}, - {name: "no trust anchor", peers: testGatewayPrincipal, cert: certFile, key: keyFile, token: "t", callers: testGatewayPrincipal, names: "trust anchor", variable: IdentityTrustBundleFileEnvironmentVariable}, - {name: "no projected token", peers: testGatewayPrincipal, cert: certFile, key: keyFile, bundle: bundleFile, callers: testGatewayPrincipal, names: "projected service-account token", variable: ProjectedTokenFileEnvironmentVariable}, - // Not material this workload presents, but the other half of the - // posture: a listener that verifies every caller in the cell and - // admits all of them is a listener with no admission at all, so the - // admitted set is provisioned and named when it is absent. - {name: "no allowed callers", cert: certFile, key: keyFile, token: "t", bundle: bundleFile, peers: testGatewayPrincipal, names: "allowed caller identities", variable: IdentityAllowedCallersFileEnvironmentVariable}, - // And the same question outbound. Every workload in the cell holds a - // certificate from the same anchor, so a chain and a hostname do not - // say a destination is the platform — and outbound is the direction - // where being wrong hands over the projected token rather than - // accepting a call. - {name: "no platform peers", cert: certFile, key: keyFile, token: "t", bundle: bundleFile, callers: testGatewayPrincipal, names: "credential mint peer identity", variable: IdentityMintPeersFileEnvironmentVariable}, - } { - t.Run(tc.name, func(t *testing.T) { - server := New(Manifest{ID: testSolutionID}) - server.cfg = config{brokerInternalToken: "broker-perimeter-token", - port: "8080", - gatewayURL: "https://gateway:42152", - mintURL: "https://gateway:42152" + credentialMintPath, - identityCertFile: tc.cert, - identityKeyFile: tc.key, - projectedTokenPath: tc.token, - trustBundleFile: tc.bundle, - allowedCallersFile: optionalIdentitiesFile(t, tc.callers), - mintPeersFile: optionalIdentitiesFile(t, tc.peers), - gatewayPeersFile: optionalIdentitiesFile(t, tc.peers), - profile: localProfile, - } - if err := server.cfg.validate(); err != nil { - t.Fatalf("the resolved configuration itself is unusable: %v", err) - } - err := server.validateSources() - if err == nil { - t.Fatal("the boot accepted a configuration with no workload identity") - } - if !strings.Contains(err.Error(), tc.names) { - t.Errorf("refusal %q does not name the missing %s", err, tc.names) - } - if !strings.Contains(err.Error(), tc.variable) { - t.Errorf("refusal %q does not name %s, the override that sets it", err, tc.variable) - } - if !strings.Contains(err.Error(), WorkloadIdentityGroup) { - t.Errorf("refusal %q does not name the %q group the platform provisions", err, WorkloadIdentityGroup) - } - }) - } -} - -// TestACredentialBearingDestinationMustBeHTTPS: a TLS listener protects what -// callers send to this process and nothing it sends out. Over http:// the mint -// hands this workload's projected token to anything on the path, and a viewer's -// mint hands over their bearer and the capability minted for them. -func TestACredentialBearingDestinationMustBeHTTPS(t *testing.T) { - for _, tc := range []struct { - name string - gateway string - mint string - names string - variable string - }{ - {"a plaintext gateway", "http://gateway:42152", "https://gateway:42152" + credentialMintPath, "viewer's bearer", "GATEWAY_URL"}, - {"a plaintext mint", "https://gateway:42152", "http://gateway:42152" + credentialMintPath, "service-account token", CredentialMintURLEnvironmentVariable}, - } { - t.Run(tc.name, func(t *testing.T) { - cfg := config{brokerInternalToken: "broker-perimeter-token", port: "8080", gatewayURL: tc.gateway, mintURL: tc.mint, profile: localProfile} - err := cfg.validate() - if err == nil { - t.Fatal("validate accepted a plaintext credential-bearing destination") - } - if !strings.Contains(err.Error(), tc.names) { - t.Errorf("refusal %q does not say what a plaintext hop would disclose (%s)", err, tc.names) - } - if !strings.Contains(err.Error(), tc.variable) { - t.Errorf("refusal %q does not name %s", err, tc.variable) - } - }) - } -} - -// TestASuppliedSourceNeedsNoProvisioningItDoesNotRead: a consumer that supplied -// an identity source has said where its identity comes from, so requiring the -// projected file paths as well would be requiring provisioning nothing reads. -// What no source changes is the authority identity. -func TestASuppliedSourceNeedsNoProvisioningItDoesNotRead(t *testing.T) { - base := config{brokerInternalToken: "broker-perimeter-token", - port: "8080", - gatewayURL: "https://gateway:42152", - mintURL: "https://gateway:42152" + credentialMintPath, - profile: localProfile, - // Required whatever the identity source is: the source says who this - // workload is, this says which callers it serves — and which - // destinations are the platform. - allowedCallersFile: identitiesFile(t, testGatewayPrincipal), - mintPeersFile: identitiesFile(t, testGatewayPrincipal), - gatewayPeersFile: identitiesFile(t, testGatewayPrincipal), - } - t.Run("an identity source replaces the projected pair", func(t *testing.T) { - server := New(Manifest{ID: testSolutionID}).Identity(staticIdentity{}) - server.cfg = base - server.cfg.projectedTokenPath = "/var/run/token" - if err := server.validateSources(); err != nil { - t.Errorf("the boot required material the selected source does not read: %v", err) - } - }) - t.Run("a credential source replaces the projected token", func(t *testing.T) { - certFile, keyFile, bundleFile, _, _ := workloadIdentity(t, testPrincipal) - server := New(Manifest{ID: testSolutionID}).Credential(stubCredentialSource{}) - server.cfg = base - server.cfg.identityCertFile, server.cfg.identityKeyFile, server.cfg.trustBundleFile = certFile, keyFile, bundleFile - if err := server.validateSources(); err != nil { - t.Errorf("the boot required material the selected source does not read: %v", err) - } - }) -} - -// TestAnIdentitySourceReachesTheSamePostureAsTheProjectedOne: a source is -// consumer code — a mesh CA, a SPIFFE workload API, a pair the local toolchain -// minted — and every one of them has to arrive at the same posture. Checking -// only that a certificate was returned left the TLS floor and peer -// authentication silently optional for exactly the callers who wrote their own -// source. -func TestAnIdentitySourceReachesTheSamePostureAsTheProjectedOne(t *testing.T) { - certFile, keyFile, bundleFile, _, _ := workloadIdentity(t, testPrincipal) - usable, err := projectedIdentity{certFile: certFile, keyFile: keyFile, trustBundleFile: bundleFile}.ServerTLSConfig() - if err != nil { - t.Fatal(err) - } - for _, tc := range []struct { - name string - config *tls.Config - says string - }{ - {"no configuration at all", nil, "plain-HTTP"}, - {"no certificate", &tls.Config{MinVersion: tls.VersionTLS13, ClientAuth: tls.RequireAndVerifyClientCert}, "no certificate"}, - {"a floor below TLS 1.3", withFloor(usable, tls.VersionTLS12), "TLS 1.3"}, - {"no peer authentication", withClientAuth(usable, tls.NoClientCert), "authenticates no peer"}, - {"a peer it verifies but does not require", withClientAuth(usable, tls.VerifyClientCertIfGiven), "authenticates no peer"}, - // Requiring a certificate and naming no anchor is strictly worse than - // requiring none, because it reads as mutual TLS in every log line: - // with ClientCAs nil, Go verifies the caller against this host's - // system roots, so the listener demands a certificate and then accepts - // one from any public CA. - {"a required certificate with no anchor to verify it against", withoutAnchor(usable), "system roots"}, - } { - t.Run(tc.name, func(t *testing.T) { - server := New(Manifest{ID: testSolutionID}).Identity(staticIdentity{config: tc.config}) - server.cfg = config{brokerInternalToken: "broker-perimeter-token", port: listenOn(t, server)} - server.principal = testPrincipal - ln, err := server.listen() - if ln != nil { - _ = ln.Close() - } - if err == nil { - t.Fatal("listen came up on a configuration below this model's posture") - } - if !strings.Contains(err.Error(), tc.says) { - t.Errorf("refusal %q does not say %q", err, tc.says) - } - }) - } - - t.Run("a source that reaches the posture is accepted", func(t *testing.T) { - server := New(Manifest{ID: testSolutionID}).Identity(staticIdentity{config: usable}) - server.cfg = config{brokerInternalToken: "broker-perimeter-token", port: listenOn(t, server)} - server.principal = testPrincipal - ln, err := server.listen() - if err != nil { - t.Fatalf("listen refused a conforming source: %v", err) - } - _ = ln.Close() - }) -} - -// TestAListenerIsHeldToItsOwnFrozenPrincipal: the pair a platform projects and -// the principal it provisioned are two facts nothing else in the boot compares. -// A Secret mounted for a neighbouring workload would otherwise be served -// happily, and the mismatch would surface at whatever verifies this -// destination, as a refusal naming neither file. -func TestAListenerIsHeldToItsOwnFrozenPrincipal(t *testing.T) { - certFile, keyFile, bundleFile, _, _ := workloadIdentity(t, "spiffe://codefly.test/ns/solutions/sa/another-workload") - server := New(Manifest{ID: testSolutionID}) - server.cfg = config{brokerInternalToken: "broker-perimeter-token", port: listenOn(t, server), identityCertFile: certFile, identityKeyFile: keyFile, trustBundleFile: bundleFile} - server.principal = testPrincipal - ln, err := server.listen() - if ln != nil { - _ = ln.Close() - } - if err == nil { - t.Fatal("listen served a certificate issued for another workload") - } - for _, want := range []string{"another-workload", testPrincipal, AuthorityGroup} { - if !strings.Contains(err.Error(), want) { - t.Errorf("refusal %q does not name %q", err, want) - } - } - - t.Run("a leaf with no URI identity at all", func(t *testing.T) { - config, err := projectedIdentity{certFile: certFile, keyFile: keyFile, trustBundleFile: bundleFile}.ServerTLSConfig() - if err != nil { - t.Fatal(err) - } - stripped := config.Clone() - stripped.GetCertificate = nil - stripped.GetConfigForClient = nil - stripped.Certificates = []tls.Certificate{{Certificate: [][]byte{selfSignedWithoutURI(t)}}} - if err := holdServedCertificate(stripped, testPrincipal); err == nil || !strings.Contains(err.Error(), "no URI identity at all") { - t.Errorf("holdServedCertificate = %v, want a refusal distinguishing no identity from the wrong one", err) - } - }) -} - -// TestPeerTrustIsReadAtEveryHandshake: a pool read once cannot shrink. Removing -// a compromised root from the projected bundle has to stop it authenticating -// callers without a restart, because revocation that takes effect only on a -// restart is revocation an operator has to remember to finish. -func TestPeerTrustIsReadAtEveryHandshake(t *testing.T) { - certFile, keyFile, bundleFile, _, _ := workloadIdentity(t, testPrincipal) - config, err := projectedIdentity{certFile: certFile, keyFile: keyFile, trustBundleFile: bundleFile}.ServerTLSConfig() - if err != nil { - t.Fatal(err) - } - first, err := config.GetConfigForClient(&tls.ClientHelloInfo{}) - if err != nil { - t.Fatalf("first handshake: %v", err) - } - if first.ClientAuth != tls.RequireAndVerifyClientCert { - t.Errorf("ClientAuth = %v, want RequireAndVerifyClientCert", first.ClientAuth) - } - if first.ClientCAs == nil { - t.Fatal("the handshake was offered no peer anchor") - } - - // The anchor is replaced with a different one: a caller issued under the - // old root must no longer authenticate, and the pool the next handshake - // uses must be the new one. - replacement, _, _ := issueAnchor(t) - writeFile(t, bundleFile, string(replacement)) - second, err := config.GetConfigForClient(&tls.ClientHelloInfo{}) - if err != nil { - t.Fatalf("handshake after the anchor was replaced: %v", err) - } - if second.ClientCAs.Equal(first.ClientCAs) { - t.Error("the handshake still judges callers by the anchor that was removed: peer trust was snapshotted at boot") - } - - // An anchor that has become unreadable fails the handshake rather than - // falling back to the last good pool: judging by a stale anchor lets in - // callers who should be refused, which is not symmetric with serving a - // stale leaf. - if err := os.Remove(bundleFile); err != nil { - t.Fatal(err) - } - if _, err := config.GetConfigForClient(&tls.ClientHelloInfo{}); err == nil { - t.Error("a handshake was answered after the trust anchor became unreadable") - } -} - -// TestAnUnusableTrustAnchorIsRefusedNotDropped: dropped, the listener comes up -// verifying no peer at all — exactly what configuring an anchor was meant to -// prevent — and nothing says so. -func TestAnUnusableTrustAnchorIsRefusedNotDropped(t *testing.T) { - certFile, keyFile, _, _, _ := workloadIdentity(t, testPrincipal) - bundle := filepath.Join(t.TempDir(), "ca.crt") - writeFile(t, bundle, "not a certificate") - _, err := projectedIdentity{certFile: certFile, keyFile: keyFile, trustBundleFile: bundle}.ServerTLSConfig() - if err == nil || !strings.Contains(err.Error(), "no certificate") { - t.Errorf("an unparseable anchor was reported as %v, want a refusal saying it carries no certificate", err) - } - - _, err = projectedIdentity{certFile: certFile, keyFile: keyFile}.ServerTLSConfig() - if err == nil || !strings.Contains(err.Error(), IdentityTrustBundleFileEnvironmentVariable) { - t.Errorf("an absent anchor was reported as %v, want a refusal naming %s", err, IdentityTrustBundleFileEnvironmentVariable) - } -} - -// TestTheListenerServesTheRotatedLeaf: workload leaves are short-lived and -// rotated well before expiry, so a listener that loaded its pair once at boot -// serves a stale leaf while a valid one sits on disk, and then fails every -// handshake with the issuer reporting nothing wrong. -func TestTheListenerServesTheRotatedLeaf(t *testing.T) { - certFile, keyFile, bundleFile, _, _ := workloadIdentity(t, testPrincipal) - config, err := projectedIdentity{certFile: certFile, keyFile: keyFile, trustBundleFile: bundleFile}.ServerTLSConfig() - if err != nil { - t.Fatal(err) - } - first, err := config.GetCertificate(&tls.ClientHelloInfo{ServerName: "localhost"}) - if err != nil { - t.Fatal(err) - } - rotatedCert, rotatedKey, _, _, _ := workloadIdentity(t, testPrincipal) - writeFile(t, certFile, readFile(t, rotatedCert)) - writeFile(t, keyFile, readFile(t, rotatedKey)) - second, err := config.GetCertificate(&tls.ClientHelloInfo{ServerName: "localhost"}) - if err != nil { - t.Fatal(err) - } - if string(first.Certificate[0]) == string(second.Certificate[0]) { - t.Error("the listener served the boot-time leaf after the projection rotated") - } -} - -// staticIdentity is a consumer-supplied identity source, including the -// degenerate ones a consumer can write by mistake. -type staticIdentity struct { - config *tls.Config - // anchor, when set, is what this source says its current anchor is. Unset, - // it answers with the pool on the configuration — truthful for a source - // whose anchor really is static, which is every source in these tests. - anchor func() *x509.CertPool -} - -func (s staticIdentity) ServerTLSConfig() (*tls.Config, error) { return s.config, nil } - -// PeerAnchor makes this source askable, which a source setting -// GetConfigForClient now has to be: the runtime re-verifies established peers -// and verifies the platform it dials, and neither can be answered by probing a -// per-handshake callback with a hello nobody sent. -func (s staticIdentity) PeerAnchor() (*x509.CertPool, error) { - if s.anchor != nil { - return s.anchor(), nil - } - if s.config.ClientCAs != nil { - return s.config.ClientCAs, nil - } - // Mirrors what the projected source does: resolve it now rather than hold - // a pool from boot. - if s.config.GetConfigForClient != nil { - answer, err := s.config.GetConfigForClient(&tls.ClientHelloInfo{}) - if err == nil && answer != nil && answer.ClientCAs != nil { - return answer.ClientCAs, nil - } - } - return nil, fmt.Errorf("this test source names no anchor") -} - -// withoutAnchor is a source that requires a caller's certificate and names -// nothing to verify it against, with no per-connection callback to resolve one -// later either. -func withoutAnchor(base *tls.Config) *tls.Config { - changed := base.Clone() - changed.ClientCAs = nil - changed.GetConfigForClient = nil - return changed -} - -func withFloor(base *tls.Config, floor uint16) *tls.Config { - changed := base.Clone() - changed.MinVersion = floor - return changed -} - -func withClientAuth(base *tls.Config, auth tls.ClientAuthType) *tls.Config { - changed := base.Clone() - changed.ClientAuth = auth - return changed -} - -// selfSignedWithoutURI is a leaf whose identity is a DNS name and nothing else: -// the shape a pair projected by something that does not know about SPIFFE has. -func selfSignedWithoutURI(t *testing.T) []byte { - t.Helper() - _, anchor, anchorKey := issueAnchor(t) - public, _, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - t.Fatal(err) - } - der, err := x509.CreateCertificate(rand.Reader, &x509.Certificate{ - SerialNumber: big.NewInt(99), - Subject: pkix.Name{CommonName: "no-uri"}, - NotBefore: time.Now().Add(-time.Minute), - NotAfter: time.Now().Add(time.Hour), - KeyUsage: x509.KeyUsageDigitalSignature, - DNSNames: []string{"localhost"}, - }, anchor, public, anchorKey) - if err != nil { - t.Fatal(err) - } - return der -} - -func readFile(t *testing.T, path string) string { - t.Helper() - raw, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - return string(raw) -} - -// TestASourcesPerConnectionCallbackCannotLowerThePosture closes the last way -// into this file, and it is the same argument the rest of the cutover is built -// on: boot-time approval is not authorization at use. -// -// usableServerIdentity and holdServedCertificate read the configuration a source -// returns at boot. Go hands each handshake to GetConfigForClient when one is -// set, and the configuration that callback returns replaces the base one for -// that connection. So a source could conform at boot, pass every other check -// here, and serve each individual handshake under TLS 1.2, with no peer -// authentication, or holding a neighbouring workload's leaf. -// -// The projected source uses that callback to re-read peer trust, so this is the -// documented pattern in this package, not an odd one — a consumer following it -// is one line from lowering the floor on every connection while the boot still -// reports a conforming listener. -// -// A configuration below the posture fails the handshake outright rather than -// serving it in a weakened form: the caller that happens to arrive is not the -// thing in question, the configuration the listener would answer anyone with -// is. -func TestASourcesPerConnectionCallbackCannotLowerThePosture(t *testing.T) { - // One anchor for the whole test, so a rival leaf is one a caller would - // actually verify — issued by the same CA, naming another workload. - c := newCell(t) - certFile, keyFile, bundleFile, caller, roots := c.workload(t, testPrincipal) - rival := c.identity(t, "spiffe://codefly.test/ns/solutions/sa/another-workload") - conforming, err := projectedIdentity{certFile: certFile, keyFile: keyFile, trustBundleFile: bundleFile}.ServerTLSConfig() - if err != nil { - t.Fatal(err) - } - authenticated := func() *tls.Config { - return &tls.Config{RootCAs: roots, Certificates: []tls.Certificate{*caller}, MinVersion: tls.VersionTLS13, ServerName: "localhost"} - } - - // A source conforming at boot — certificate, TLS 1.3, - // RequireAndVerifyClientCert — that downgrades per connection. - downgrading := func(change func(*tls.Config)) *tls.Config { - base := conforming.Clone() - base.GetConfigForClient = func(*tls.ClientHelloInfo) (*tls.Config, error) { - answer := conforming.Clone() - answer.GetConfigForClient = nil - anchor, err := peerAnchor(bundleFile) - if err != nil { - return nil, err - } - answer.ClientCAs = anchor - answer.ClientAuth = tls.RequireAndVerifyClientCert - change(answer) - return answer, nil - } - return base - } - - for _, tc := range []struct { - name string - // what the callback does to each connection's configuration - change func(*tls.Config) - // a caller the lowered posture would have admitted, and that must not - // be, alongside the authenticated one which is refused with it - exploits func() *tls.Config - }{ - { - "a floor below TLS 1.3 per connection", - func(cfg *tls.Config) { cfg.MinVersion = tls.VersionTLS12 }, - func() *tls.Config { - return &tls.Config{RootCAs: roots, Certificates: []tls.Certificate{*caller}, MinVersion: tls.VersionTLS12, MaxVersion: tls.VersionTLS12, ServerName: "localhost"} - }, - }, - { - "no peer authentication per connection", - func(cfg *tls.Config) { cfg.ClientAuth = tls.NoClientCert }, - func() *tls.Config { - return &tls.Config{RootCAs: roots, MinVersion: tls.VersionTLS13, ServerName: "localhost"} - }, - }, - { - "a peer verified but not required per connection", - func(cfg *tls.Config) { cfg.ClientAuth = tls.VerifyClientCertIfGiven }, - func() *tls.Config { - return &tls.Config{RootCAs: roots, MinVersion: tls.VersionTLS13, ServerName: "localhost"} - }, - }, - { - // Resolving the anchor per handshake has to mean resolved: a - // callback that answers with no pool defers to the system roots - // for that connection, which is the same hole one layer along. - "no trust anchor per connection", - func(cfg *tls.Config) { cfg.ClientCAs = nil }, - func() *tls.Config { - return &tls.Config{RootCAs: roots, MinVersion: tls.VersionTLS13, ServerName: "localhost"} - }, - }, - { - // And the same with the answer carrying a further callback. Go - // calls GetConfigForClient once, on the base configuration, so a - // callback *on the answer* is never called and cannot be where the - // pool arrives — the connection runs on this configuration, with - // system roots. It is the one shape the boot-time check reads as - // "resolved later". - "no trust anchor per connection, deferred to a callback Go never calls", - func(cfg *tls.Config) { - cfg.ClientCAs = nil - cfg.GetConfigForClient = func(*tls.ClientHelloInfo) (*tls.Config, error) { return nil, nil } - }, - func() *tls.Config { - return &tls.Config{RootCAs: roots, MinVersion: tls.VersionTLS13, ServerName: "localhost"} - }, - }, - { - // Nothing extra is admitted here; what changes is whose identity - // the listener presents, so the authenticated caller is the one - // that reads the defect. - "another workload's leaf per connection", - func(cfg *tls.Config) { cfg.Certificates, cfg.GetCertificate = []tls.Certificate{*rival}, nil }, - nil, - }, - } { - t.Run(tc.name, func(t *testing.T) { - server := New(Manifest{ID: testSolutionID}).Identity(staticIdentity{config: downgrading(tc.change)}) - server.cfg = config{brokerInternalToken: "broker-perimeter-token", port: listenOn(t, server), allowedCallersFile: identitiesFile(t, testGatewayPrincipal)} - server.principal = testPrincipal - // The two failures listen() returns as one are separated here. - // Refusing the *identity* at boot is an acceptable answer — what - // must not happen is a handshake served under the lowered posture — - // but failing to *bind* is this test not running, and returning on - // it made every subtest below pass for a reason that has nothing to - // do with the posture. - config, refused := server.serverIdentity() - if refused != nil { - // A boot refusal is an acceptable answer, but only the right - // one. Returning on *any* error made every subtest below pass - // the moment serverIdentity failed for an unrelated reason — - // an unresolved path, a missing admission file, a mutated - // helper — which is the shape that makes a negative test - // report success for a cause it never exercised. - if !strings.Contains(refused.Error(), "identity source") { - t.Fatalf("the boot refused the identity for something other than its posture, so this case never ran: %v", refused) - } - return - } - // The listener this server was handed, not a second bind of the - // same port. The distinction the comment above cares about — a - // boot that refuses the identity versus a test that could not - // bind — is kept and sharpened: the bind happened in boundPort, - // which fails the test outright, so reaching here means a - // listener exists and only the posture is in question. - ln := tls.NewListener(server.boundListener, config) - defer func() { _ = ln.Close() }() - serve(t, ln) - - if tc.exploits != nil { - if conn, err := tls.Dial("tcp", ln.Addr().String(), tc.exploits()); err == nil { - state := conn.ConnectionState() - _ = conn.Close() - t.Errorf("a caller the boot configuration would have refused was served over TLS 0x%04x with %d peer certificate(s): the per-connection configuration is not held to the posture the boot checked", - state.Version, len(state.PeerCertificates)) - } - } - // And the lowered configuration is refused for everyone, not - // weakened for whoever asks: the configuration is what is wrong. - conn, err := tls.Dial("tcp", ln.Addr().String(), authenticated()) - if err == nil { - state := conn.ConnectionState() - served := "none" - if len(state.PeerCertificates) > 0 && len(state.PeerCertificates[0].URIs) > 0 { - served = state.PeerCertificates[0].URIs[0].String() - } - _ = conn.Close() - t.Errorf("the listener answered a handshake under a configuration below its boot posture (TLS 0x%04x, presenting %s)", state.Version, served) - } - }) - } - - // The other direction, or the check above is just a broken listener: a - // per-connection callback that keeps the posture is served normally. This - // is the projected source's own shape — it uses the callback to re-read - // peer trust on every handshake. - t.Run("a conforming callback is served", func(t *testing.T) { - server := New(Manifest{ID: testSolutionID}).Identity(staticIdentity{config: conforming}) - // The admitted set is provisioned, because listen() is the production - // path and a deployment reaching it has been through validateSources. - // Without it this subtest dialled a listener that refused the caller - // and read as a success anyway: in TLS 1.3 the client finishes before - // it learns whether the server accepted its certificate, so a - // server-side refusal is invisible to tls.Dial. - server.cfg = config{brokerInternalToken: "broker-perimeter-token", port: listenOn(t, server), allowedCallersFile: identitiesFile(t, testGatewayPrincipal)} - server.principal = testPrincipal - ln, err := server.listen() - if err != nil { - t.Fatalf("listen refused the projected source's own per-connection shape: %v", err) - } - // This one may fail for a bind, and says so rather than skipping. - defer func() { _ = ln.Close() }() - serve(t, ln) - - good, err := tls.Dial("tcp", ln.Addr().String(), authenticated()) - if err != nil { - t.Fatalf("a conforming caller was refused, so the per-connection check broke the listener: %v", err) - } - state := good.ConnectionState() - _ = good.Close() - if state.Version < tls.VersionTLS13 { - t.Errorf("the connection was served TLS 0x%04x, want a 1.3 floor", state.Version) - } - leaf := state.PeerCertificates[0] - if len(leaf.URIs) == 0 || leaf.URIs[0].String() != testPrincipal { - t.Errorf("the connection was served %v, want this workload's frozen principal %q", leaf.URIs, testPrincipal) - } - }) -} - -// serve accepts and handshakes until the listener closes, so a refused -// handshake is refused by the listener's own configuration rather than by -// nobody being there. -func serve(t *testing.T, ln net.Listener) { - t.Helper() - done := make(chan struct{}) - go func() { - defer close(done) - for { - conn, err := ln.Accept() - if err != nil { - return - } - go func() { - if tlsConn, ok := conn.(*tls.Conn); ok { - _ = tlsConn.Handshake() - } - _ = conn.Close() - }() - } - }() - t.Cleanup(func() { <-done }) -} - -// suppliedIdentity is a consumer's IdentitySource that happens to produce a -// conforming configuration — the shape the README's example implies, built over -// the same anchor the fake host is served under so the boot fails on the -// property under test and not on verifying the host. -type suppliedIdentity struct{ certFile, keyFile, bundleFile string } - -func (c suppliedIdentity) inner() projectedIdentity { - return projectedIdentity{certFile: c.certFile, keyFile: c.keyFile, trustBundleFile: c.bundleFile} -} - -func (c suppliedIdentity) ServerTLSConfig() (*tls.Config, error) { - return c.inner().ServerTLSConfig() -} - -// PeerAnchor is delegated too, and a consumer wrapping a source has to do the -// same: this source resolves its anchor per handshake, which is the right -// shape, and that is exactly the shape that has to be able to answer what the -// anchor currently is rather than be probed with a handshake nobody made. -func (c suppliedIdentity) PeerAnchor() (*x509.CertPool, error) { - return c.inner().PeerAnchor() -} - -// TestASuppliedSourceBootsAndStaysAuthenticatedOutbound drives the two bugs a -// second reviewer found behind the "custom sources work through the boot -// contract" answer. The test that answered it before stopped at -// validateSources, so neither was reachable by it. -// -// The outbound client was built `if s.credential == nil || s.identity == nil`: -// -// - supply **both** sources and it was never built, so s.outbound stayed nil -// and every gateway call fell through to the unauthenticated transport — -// system roots, no client certificate. The one posture this cutover exists -// to remove, reached by configuring more rather than less. -// - supply **only Identity** and the condition was true, so the client read -// the projected files validateSources had just declared unnecessary for -// that boot. The README's own example could not start. -func TestASuppliedSourceBootsAndStaysAuthenticatedOutbound(t *testing.T) { - // No projected identity files at all, so a boot that reads them fails and a - // boot that honours the supplied source does not. - withoutProjectedIdentity := func(t *testing.T) { - t.Helper() - t.Setenv(IdentityCertFileEnvironmentVariable, "") - t.Setenv(IdentityKeyFileEnvironmentVariable, "") - t.Setenv(IdentityTrustBundleFileEnvironmentVariable, "") - } - - t.Run("identity only, the README's own example", func(t *testing.T) { - mint := newHostMint(t, &hostMint{}) - certFile, keyFile, bundleFile, _, _ := bootIdentity(t, mint, testPrincipal) - withoutProjectedIdentity(t) - - server := New(Manifest{ID: testSolutionID}). - Identity(suppliedIdentity{certFile: certFile, keyFile: keyFile, bundleFile: bundleFile}) - ctx, cancel := context.WithCancel(context.Background()) - t.Cleanup(cancel) - ln, err := takeListener(server).start(ctx) - if err != nil { - t.Fatalf("a boot supplying only Identity(...) was refused: %v\nthe supplied source says where the identity comes from, so the projected files are provisioning this boot does not read", err) - } - defer func() { _ = ln.Close() }() - if server.outbound == nil { - t.Fatal("the boot came up with no outbound client at all") - } - if got := mint.count(); got != 1 { - t.Errorf("the host was asked %d times, want the one mint: the supplied-identity boot must still obtain its credential", got) - } - }) - - t.Run("both sources supplied: outbound still presents this workload", func(t *testing.T) { - mint := newHostMint(t, &hostMint{}) - certFile, keyFile, bundleFile, caller, roots := bootIdentity(t, mint, testPrincipal) - tokenFile := filepath.Join(t.TempDir(), "token") - writeFile(t, tokenFile, "projected-token") - withoutProjectedIdentity(t) - - // The fake host is served over TLS under the cell's anchor, and a - // supplied credential source brings its own transport — the runtime - // hands it none, which is the point of supplying one. - // - // It no longer brings a transport: the SDK's mint client builds and - // owns that, and takes only the roots. A consumer supplying a - // credential source therefore supplies the anchor the mint endpoint is - // verified against and nothing else about how it is dialled. - _, _ = caller, roots - server := New(Manifest{ID: testSolutionID}). - Identity(suppliedIdentity{certFile: certFile, keyFile: keyFile, bundleFile: bundleFile}). - Credential(mintClientFor(t, mint, tokenFile)) - ctx, cancel := context.WithCancel(context.Background()) - t.Cleanup(cancel) - ln, err := takeListener(server).start(ctx) - if err != nil { - t.Fatalf("a boot supplying both sources was refused: %v", err) - } - defer func() { _ = ln.Close() }() - - if server.outbound == nil { - t.Fatal("s.outbound is nil with both sources supplied, so every gateway call falls through to the unauthenticated transport: system roots, no client certificate") - } - transport, ok := server.outbound.Transport.(*http.Transport) - if !ok { - t.Fatalf("outbound transport is %T, want *http.Transport", server.outbound.Transport) - } - if transport.DialTLSContext == nil { - t.Fatal("the outbound client has no per-connection dialler, so its trust is snapshotted") - } - // What that dialler builds, checked directly: identity and anchor from - // the supplied source, floor at 1.3. - built, err := clientTLSFrom(server.identity, server.identityConfig) - if err != nil { - t.Fatalf("derive the outbound configuration from the supplied source: %v", err) - } - switch { - case built.RootCAs == nil: - t.Error("the outbound client verifies the platform against system roots, not the anchor the supplied source named") - case built.GetClientCertificate == nil && len(built.Certificates) == 0: - t.Error("the outbound client presents no identity, so the platform cannot tell this workload from anything else that reached it") - case built.MinVersion != tls.VersionTLS13: - t.Errorf("the outbound floor is 0x%04x, want TLS 1.3", built.MinVersion) - } - // And the gateway a handler is handed carries it, rather than the - // unauthenticated fallback. - if server.gatewayFor(http.Header{}).transport == nil { - t.Error("a handler's gateway carries no transport, so it dials the platform unauthenticated") - } - }) -} - -// TestAMismatchedLeafIsRefusedBeforeTheMint is the ordering half of the same -// finding: the principal check is worth nothing after the fact. -// -// openCredential used to run before listen(), so a leaf issued for a -// neighbouring workload — the wrong Secret mounted, a Certificate issued for -// another service — had already been presented to the host, together with this -// workload's projected service-account token, by the time the listener refused -// it. The refusal was real and the disclosure had already happened. -func TestAMismatchedLeafIsRefusedBeforeTheMint(t *testing.T) { - mint := newHostMint(t, &hostMint{}) - bootIdentity(t, mint, "spiffe://codefly.test/ns/solutions/sa/another-workload") - - // Not bootFails: that helper re-provisions the environment, which would - // replace the mismatched leaf with a matching one and assert nothing. - ln, err := takeListener(New(Manifest{ID: testSolutionID})).start(context.Background()) - if ln != nil { - _ = ln.Close() - } - if err == nil { - t.Fatal("the boot came up serving a leaf issued for another workload") - } - if !strings.Contains(err.Error(), "another-workload") { - t.Errorf("the boot was refused with %v, want a refusal naming the identity actually served", err) - } - if got := mint.count(); got != 0 { - t.Errorf("the host was asked %d time(s) before the leaf was checked: a mismatched identity must reach nothing, and the projected token travels on that request", got) - } -} - -// TestAnAuthenticatedCallerIsNotAutomaticallyAnAuthorisedOne is the confused -// deputy a second reviewer found behind "authenticated peers, both directions". -// -// Verifying a caller's certificate against the cell's anchor answers whether it -// holds an identity the platform issued. In a cell that is every workload, -// including the modules this solution consumes. So any of them could reach a -// handler or a passthrough route directly, bypassing the admission the host -// decides on its own routes — and since gatewayFor reads x-org-id and -// x-session-id as headers the gateway stamped from a verified bearer, such a -// caller could set them itself and have this runtime mint capabilities under -// *this workload's* attestation for an org and session nobody authenticated. -// The deputy being confused is the one process the issuer trusts to say which -// module is asking. -// -// Authentication was added in the previous round and authorisation was not, -// which is why the first reviewer asked for "authenticated, authorized peers". -func TestAnAuthenticatedCallerIsNotAutomaticallyAnAuthorisedOne(t *testing.T) { - mint := newHostMint(t, &hostMint{}) - cell := newCell(t) - certFile, keyFile, bundleFile, _, _ := cell.workload(t, testPrincipal) - mint.serveTLS(t, cell) - authorityValues(t) - tokenFile := filepath.Join(t.TempDir(), "token") - writeFile(t, tokenFile, "projected-token") - func() { ln, port := boundPort(t); provideListener(t, ln); t.Setenv("PORT", port) }() - t.Setenv("GATEWAY_URL", mint.URL) - t.Setenv(CredentialMintURLEnvironmentVariable, mint.URL+credentialMintPath) - t.Setenv(ProjectedTokenFileEnvironmentVariable, tokenFile) - t.Setenv(IdentityCertFileEnvironmentVariable, certFile) - t.Setenv(IdentityKeyFileEnvironmentVariable, keyFile) - t.Setenv(IdentityTrustBundleFileEnvironmentVariable, bundleFile) - t.Setenv(IdentityAllowedCallersFileEnvironmentVariable, identitiesFile(t, testGatewayPrincipal)) - t.Setenv(IdentityMintPeersFileEnvironmentVariable, identitiesFile(t, testGatewayPrincipal)) - t.Setenv(IdentityGatewayPeersFileEnvironmentVariable, identitiesFile(t, testGatewayPrincipal)) - t.Setenv(ContractProfileEnvironmentVariable, localProfile) - t.Setenv("ASSETS_DIR", t.TempDir()) - - ctx, cancel := context.WithCancel(context.Background()) - t.Cleanup(cancel) - reached := make(chan struct{}, 8) - server := New(Manifest{ID: testSolutionID}).Handle("/thing", func(context.Context, *Gateway) (any, error) { - reached <- struct{}{} - return map[string]string{"ok": "yes"}, nil - }) - ln, err := takeListener(server).start(ctx) - if err != nil { - t.Fatalf("boot: %v", err) - } - served := make(chan error, 1) - go func() { served <- server.serve(ctx, ln) }() - t.Cleanup(func() { cancel(); <-served }) - base := "https://127.0.0.1:" + os.Getenv("PORT") - - call := func(identity string) (*http.Response, error) { - client := &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{ - RootCAs: cell.roots, - Certificates: []tls.Certificate{*cell.identity(t, identity)}, - MinVersion: tls.VersionTLS13, - }}} - request, err := http.NewRequest(http.MethodGet, base+"/thing", nil) - if err != nil { - t.Fatal(err) - } - request.Header.Set("authorization", viewerBearer()) - // The headers a caller must not be able to assert for itself. - request.Header.Set(orgHeader, "org-the-caller-chose") - request.Header.Set(sessionHeader, "session-the-caller-chose") - request.Header.Set(workcontext.InstallationIDHeaderName, testInstallation) - return client.Do(request) - } - - t.Run("the gateway is served", func(t *testing.T) { - resp, err := call(testGatewayPrincipal) - if err != nil { - t.Fatalf("the admitted caller was refused, so this check is just a broken listener: %v", err) - } - _ = resp.Body.Close() - select { - case <-reached: - default: - t.Error("the admitted caller did not reach the handler") - } - }) - - for _, identity := range []string{ - // A module this solution consumes: holds a cell certificate, and is - // precisely the party that must not be able to drive this runtime's - // mints. - "spiffe://codefly.test/ns/modules/sa/things", - // Any other workload in the trust domain. - "spiffe://codefly.test/ns/solutions/sa/someone-else", - } { - t.Run("refused: "+identity, func(t *testing.T) { - resp, err := call(identity) - if err == nil { - defer func() { _ = resp.Body.Close() }() - t.Errorf("a caller authenticated as %s was served %d: holding a certificate from this cell's anchor is not the same as being a caller this solution serves, and this one set its own %s and %s", - identity, resp.StatusCode, orgHeader, sessionHeader) - } - select { - case <-reached: - t.Error("the handler ran for a caller this solution does not admit") - default: - } - }) - } -} - -// TestARotatedLeafIsStillHeldToTheFrozenPrincipal closes the half of the -// leaf check that only ran at boot. -// -// A source with no per-connection callback still rotates its leaf — that is what -// GetCertificate is for, and the projected pair is deliberately short-lived. The -// principal check ran once, against whatever was current then, so a rotation -// landing a certificate for a *different* workload (the wrong Secret replaced, a -// mesh CA re-issuing under a changed identity) would be served for the life of -// the process, with the one check that would have caught it having run before -// the file changed. -func TestARotatedLeafIsStillHeldToTheFrozenPrincipal(t *testing.T) { - c := newCell(t) - certFile, keyFile, bundleFile, _, _ := c.workload(t, testPrincipal) - server := New(Manifest{ID: testSolutionID}) - server.cfg = config{brokerInternalToken: "broker-perimeter-token", port: listenOn(t, server), identityCertFile: certFile, identityKeyFile: keyFile, - trustBundleFile: bundleFile, allowedCallersFile: identitiesFile(t, testGatewayPrincipal), mintPeersFile: identitiesFile(t, testGatewayPrincipal), - gatewayPeersFile: identitiesFile(t, testGatewayPrincipal)} - server.principal = testPrincipal - - config, err := server.serverIdentity() - if err != nil { - t.Fatalf("the boot refused a conforming pair: %v", err) - } - if _, err := config.GetCertificate(&tls.ClientHelloInfo{ServerName: "localhost"}); err != nil { - t.Fatalf("the leaf current at boot was refused: %v", err) - } - - // The projection is replaced with a pair for another workload, under the - // same anchor so nothing else about it is wrong. - rivalCert, rivalKey, _, _, _ := c.workload(t, "spiffe://codefly.test/ns/solutions/sa/another-workload") - writeFile(t, certFile, readFile(t, rivalCert)) - writeFile(t, keyFile, readFile(t, rivalKey)) - - _, err = config.GetCertificate(&tls.ClientHelloInfo{ServerName: "localhost"}) - if err == nil { - t.Fatal("a leaf issued for another workload was served after rotation: the principal check only ran at boot, against a file that has since changed") - } - if !strings.Contains(err.Error(), "another-workload") || !strings.Contains(err.Error(), testPrincipal) { - t.Errorf("refusal %q does not name both the identity served and this workload's frozen principal", err) - } -} - -// anchorAnnouncingIdentity is a source that says what its anchor is, which is -// the only sound way for one that rotates to be re-checkable. -type anchorAnnouncingIdentity struct { - config *tls.Config - anchor func() *x509.CertPool -} - -func (a anchorAnnouncingIdentity) ServerTLSConfig() (*tls.Config, error) { return a.config, nil } -func (a anchorAnnouncingIdentity) PeerAnchor() (*x509.CertPool, error) { return a.anchor(), nil } - -// TestASourcesAnchorIsAskedForAndNeverFabricated covers both halves of how a -// consumer's anchor is obtained, which pull in opposite directions. -// -// A *x509.CertPool carried on the base configuration is one object fixed when -// the source built it, so a source that rotates its anchor had every peer -// judged by the pool this process booted with. The previous answer to that was -// to call the source's GetConfigForClient with a fabricated ClientHelloInfo and -// read the pool off the answer — which is the blocker this cutover already has -// a name for: a source keyed on the hello answers a synthetic probe with one -// thing and the real handshake with another, demonstrably. Trading a stale -// anchor for a forged one is not a fix. -// -// So the source is asked directly, and a source that can only answer through a -// handshake is refused. -func TestASourcesAnchorIsAskedForAndNeverFabricated(t *testing.T) { - boot := newCell(t) - current := newCell(t) - - t.Run("a source that announces its anchor is followed", func(t *testing.T) { - announced := boot.roots - source := anchorAnnouncingIdentity{ - config: &tls.Config{MinVersion: tls.VersionTLS13, ClientAuth: tls.RequireAndVerifyClientCert, ClientCAs: boot.roots}, - anchor: func() *x509.CertPool { return announced }, - } - anchor, err := peerAnchorFrom(source, source.config) - if err != nil { - t.Fatalf("peerAnchorFrom: %v", err) - } - if anchor != boot.roots { - t.Fatal("the announced anchor was not used") - } - // It rotates, and nothing is rebuilt. - announced = current.roots - anchor, err = peerAnchorFrom(source, source.config) - if err != nil { - t.Fatalf("peerAnchorFrom after the rotation: %v", err) - } - if anchor != current.roots { - t.Error("the anchor came from the base configuration after the source rotated: a pool on the base is fixed when the source built it, so every peer would be judged by the one this process booted with") - } - }) - - t.Run("a source carrying only a base anchor uses it", func(t *testing.T) { - config := &tls.Config{MinVersion: tls.VersionTLS13, ClientAuth: tls.RequireAndVerifyClientCert, ClientCAs: boot.roots} - anchor, err := peerAnchorFrom(unaskableIdentity{config: config}, config) - if err != nil { - t.Fatalf("peerAnchorFrom: %v", err) - } - if anchor != boot.roots { - t.Error("a source that carries its anchor on the configuration it returned had it ignored") - } - }) - - t.Run("a per-handshake anchor with no way to ask for it is refused", func(t *testing.T) { - // The shape that used to be answered with a fabricated hello: the pool - // exists only inside the callback's answer. - config := &tls.Config{ - MinVersion: tls.VersionTLS13, - ClientAuth: tls.RequireAndVerifyClientCert, - GetConfigForClient: func(*tls.ClientHelloInfo) (*tls.Config, error) { - return &tls.Config{MinVersion: tls.VersionTLS13, ClientAuth: tls.RequireAndVerifyClientCert, ClientCAs: current.roots}, nil - }, - } - anchor, err := peerAnchorFrom(unaskableIdentity{config: config}, config) - if err == nil { - t.Fatal("an anchor was obtained from a source that can only answer through a handshake: the only way to get one is to fabricate a ClientHelloInfo, and a source keyed on the hello answers a probe and a real handshake differently — which is the blocker this runtime was already held up on, one field along") - } - if anchor != nil { - t.Error("a refusal returned a pool anyway") - } - if !strings.Contains(err.Error(), "PeerAnchor") { - t.Errorf("the refusal %q does not name the interface that fixes it", err) - } - }) -} - -// unaskableIdentity is a source that does NOT implement PeerAnchorSource, -// which the test sources here otherwise do. It exists so the refusal for a -// source that can only answer through a handshake stays reachable. -type unaskableIdentity struct{ config *tls.Config } - -func (u unaskableIdentity) ServerTLSConfig() (*tls.Config, error) { return u.config, nil } - -// TestASourceCannotReplaceThePostureItIsHeldTo: the posture checks enumerate -// what a conforming listener must have, which a source can satisfy completely -// while handing back a configuration whose clock, randomness, session secrets -// or ticket encoding it controls. -// -// Resumption is the sharpest of them: a resumed connection presents no -// certificate, so a source encoding its own tickets decides who gets in -// without any check in this file seeing a handshake. -func TestASourceCannotReplaceThePostureItIsHeldTo(t *testing.T) { - c := newCell(t) - certFile, keyFile, bundleFile, _, _ := c.workload(t, testPrincipal) - conforming := func(t *testing.T) *tls.Config { - t.Helper() - config, err := projectedIdentity{certFile: certFile, keyFile: keyFile, trustBundleFile: bundleFile}.ServerTLSConfig() - if err != nil { - t.Fatal(err) - } - return config - } - // The control: the shape every case below starts from is accepted. - if err := usableServerIdentity(conforming(t)); err != nil { - t.Fatalf("the conforming projected configuration was refused, so nothing below is about the field it changes: %v", err) - } - - for _, tc := range []struct { - name string - lower func(*tls.Config) - }{ - {"a clock of its own", func(cfg *tls.Config) { - cfg.Time = func() time.Time { return time.Unix(0, 0) } - }}, - //nolint:staticcheck // Deliberately sets the deprecated field, to prove the refusal fires. - {"a randomness source of its own", func(cfg *tls.Config) { cfg.Rand = strings.NewReader("not random") }}, - {"the session secrets written out", func(cfg *tls.Config) { cfg.KeyLogWriter = io.Discard }}, - {"resumption tickets it encodes", func(cfg *tls.Config) { - cfg.WrapSession = func(tls.ConnectionState, *tls.SessionState) ([]byte, error) { return nil, nil } - }}, - {"resumption tickets it decodes", func(cfg *tls.Config) { - cfg.UnwrapSession = func([]byte, tls.ConnectionState) (*tls.SessionState, error) { return nil, nil } - }}, - {"no verification at all", func(cfg *tls.Config) { cfg.InsecureSkipVerify = true }}, - {"a resumption ticket key of its own", func(cfg *tls.Config) { - //nolint:staticcheck // Deliberately sets the deprecated field, to prove the refusal fires. - cfg.SessionTicketKey = [32]byte{1} - }}, - } { - t.Run(tc.name, func(t *testing.T) { - config := conforming(t) - tc.lower(config) - if err := usableServerIdentity(config); err == nil { - t.Error("a configuration that keeps every enumerated requirement and replaces a posture Go owns was accepted: the checks answer whether the description is acceptable, which is not whether the handshake is") - } - }) - } - - // And a per-connection answer is held to the same list, not a copy of its - // reasoning: the answer replaces the base for that connection. - // - // The answer carries a real anchor, which is the part this case got wrong - // before: without one it was refused for the missing pool, so the - // assertion held whether or not the denylist was applied to the answer at - // all, and the mutant that stopped applying it survived. - anchor, err := peerAnchor(bundleFile) - if err != nil { - t.Fatal(err) - } - answered := func(lower func(*tls.Config)) *tls.Config { - base := conforming(t) - base.GetConfigForClient = func(*tls.ClientHelloInfo) (*tls.Config, error) { - answer := conforming(t) - answer.GetConfigForClient = nil - answer.ClientCAs = anchor - lower(answer) - return answer, nil - } - holdPerConnectionPosture(base, testPrincipal, func() ([]string, error) { return []string{testGatewayPrincipal}, nil }) - return base - } - - // The control: a conforming answer, with the anchor, is served. - if _, err := answered(func(*tls.Config) {}).GetConfigForClient(&tls.ClientHelloInfo{}); err != nil { - t.Fatalf("a conforming per-connection answer was refused, so nothing below is about the denylist: %v", err) - } - for _, tc := range []struct { - name, names string - lower func(*tls.Config) - }{ - {"the session secrets written out", "KeyLogWriter", func(cfg *tls.Config) { cfg.KeyLogWriter = io.Discard }}, - {"a clock of its own", "Time", func(cfg *tls.Config) { cfg.Time = func() time.Time { return time.Unix(0, 0) } }}, - {"its own chain verifier", "VerifyPeerCertificate", func(cfg *tls.Config) { - cfg.VerifyPeerCertificate = func([][]byte, [][]*x509.Certificate) error { return nil } - }}, - } { - t.Run("per connection: "+tc.name, func(t *testing.T) { - _, err := answered(tc.lower).GetConfigForClient(&tls.ClientHelloInfo{}) - if err == nil { - t.Fatalf("a per-connection answer setting %s was served: the configuration a callback returns replaces the base one, so it is held to the same posture", tc.names) - } - // And refused for THAT, not for something else the answer happens - // to be missing. - if !strings.Contains(err.Error(), tc.names) { - t.Errorf("the refusal %q does not name %s, so this case does not cover it", err, tc.names) - } - }) - } -} - -// TestASourcesConfigurationIsNotMutated: serverIdentity wraps GetCertificate, -// composes over VerifyConnection and wraps GetConfigForClient — all writes — -// and it did them to the configuration the source handed back. -// -// A source is under no obligation to return a fresh one. Returning a -// configuration it builds once and keeps is the ordinary way to write a source, -// and then those writes land on a value another handshake may be reading, and a -// second resolution wraps the wrapper. holdPerConnectionPosture says exactly -// this about a per-connection answer, in those words, and this function did the -// thing that comment forbids one level up. -func TestASourcesConfigurationIsNotMutated(t *testing.T) { - c := newCell(t) - certFile, keyFile, bundleFile, _, _ := c.workload(t, testPrincipal) - shared, err := projectedIdentity{certFile: certFile, keyFile: keyFile, trustBundleFile: bundleFile}.ServerTLSConfig() - if err != nil { - t.Fatal(err) - } - before := struct { - verify bool - perConn bool - getCert bool - numCerts int - }{ - verify: shared.VerifyConnection != nil, - perConn: shared.GetConfigForClient != nil, - getCert: shared.GetCertificate != nil, - numCerts: len(shared.Certificates), - } - - server := New(Manifest{ID: testSolutionID}).Identity(staticIdentity{config: shared}) - server.cfg = config{brokerInternalToken: "broker-perimeter-token", allowedCallersFile: identitiesFile(t, testGatewayPrincipal)} - server.principal = testPrincipal - config, err := server.serverIdentity() - if err != nil { - t.Fatalf("boot: %v", err) - } - if config == shared { - t.Fatal("serverIdentity returned the source's own configuration: everything it applies is a write, so the source's value is being modified while it may be in use elsewhere") - } - if (shared.VerifyConnection != nil) != before.verify { - t.Error("the source's VerifyConnection was written: the admission check belongs on this runtime's copy, not on the value the source keeps") - } - if (shared.GetConfigForClient != nil) != before.perConn { - t.Error("the source's GetConfigForClient was wrapped in place") - } - if (shared.GetCertificate != nil) != before.getCert || len(shared.Certificates) != before.numCerts { - t.Error("the source's certificate selection was rewritten in place") - } - // And the copy this runtime serves does carry the checks, or the test - // above is satisfied by a runtime that applies nothing at all. - if config.VerifyConnection == nil { - t.Error("the configuration this listener serves carries no caller admission") - } -} diff --git a/installation.go b/installation.go index f9a1ed1..6dbf04f 100644 --- a/installation.go +++ b/installation.go @@ -72,10 +72,10 @@ const ( installationFailure = "installation step failed" ) -// handleInstallation is mounted only by serve, on the workload-identity -// listener. Its allowed-callers file admits the gateway; FromHeaders checks -// the carrier's structure, not its authority. The host verifies the carried -// bearer on each outgoing call, just as it does for viewer calls. +// handleInstallation is mounted only by serve, on the one listener, whose +// port the cell's mesh admits only from the gateway. FromHeaders checks the +// carrier's structure, not its authority. The host verifies the carried bearer +// on each outgoing call, just as it does for viewer calls. func (s *Server) handleInstallation(w http.ResponseWriter, r *http.Request) { token, err := workcontext.FromHeaders(r.Header) if err != nil { diff --git a/installation_test.go b/installation_test.go index 728fb9e..d0fe8e6 100644 --- a/installation_test.go +++ b/installation_test.go @@ -2,7 +2,6 @@ package solution import ( "context" - "crypto/tls" "errors" "fmt" "io" @@ -236,9 +235,10 @@ func TestInstallStepReceivesCarriedContext(t *testing.T) { } } -// Exercise the production boot and mux, including the listener's caller -// admission. No exported handler or auxiliary listener mounts this route. -func TestInstallWorkloadIdentityListener(t *testing.T) { +// Exercise the production boot and mux: the route is served by the one +// listener, plain HTTP on the resolved port, and the mesh admits that port only +// from the gateway. No exported handler or auxiliary listener mounts it. +func TestInstallRouteIsServedByTheBoot(t *testing.T) { var calls atomic.Int32 s := New(Manifest{ID: testSolutionID}).OnInstall(func(_ context.Context, installation Installation) error { calls.Add(1) @@ -252,7 +252,7 @@ func TestInstallWorkloadIdentityListener(t *testing.T) { b.client.Timeout = 5 * time.Second for _, revision := range []int{7, 7, 8, 8} { r := installRequest(t, strings.Replace(installBody, `"revision":7`, fmt.Sprintf(`"revision":%d`, revision), 1)) - r.URL.Scheme, r.URL.Host = "https", strings.TrimPrefix(b.base, "https://") + r.URL.Scheme, r.URL.Host = "http", strings.TrimPrefix(b.base, "http://") r.RequestURI = "" resp, err := b.client.Do(r) if err != nil { @@ -275,38 +275,6 @@ func TestInstallWorkloadIdentityListener(t *testing.T) { if calls.Load() != 3 { t.Fatalf("step ran %d times, want 3", calls.Load()) } - admitted := calls.Load() - for _, tc := range []struct { - name string - scheme string - caller *tls.Certificate - }{ - {"plaintext", "http", nil}, - {"no client certificate", "https", nil}, - {"another workload under the same anchor", "https", b.cell.identity(t, rivalPrincipal)}, - } { - t.Run(tc.name, func(t *testing.T) { - cfg := &tls.Config{RootCAs: b.cell.roots, MinVersion: tls.VersionTLS13} - if tc.caller != nil { - cfg.Certificates = []tls.Certificate{*tc.caller} - } - client := &http.Client{Transport: &http.Transport{TLSClientConfig: cfg}, Timeout: 5 * time.Second} - defer client.CloseIdleConnections() - r := installRequest(t, installBody) - r.URL.Scheme, r.URL.Host = tc.scheme, strings.TrimPrefix(b.base, "https://") - r.RequestURI = "" - resp, err := client.Do(r) - if err == nil { - _ = resp.Body.Close() - if tc.scheme == "https" || resp.StatusCode != http.StatusBadRequest { - t.Fatalf("unadmitted caller received HTTP %d", resp.StatusCode) - } - } - if calls.Load() != admitted { - t.Fatal("unadmitted caller ran the step") - } - }) - } resp, err := b.client.Get(b.base + installationPath) if err != nil { t.Fatal(err) diff --git a/internal/seam/seam.go b/internal/seam/seam.go index e877767..2a6155f 100644 --- a/internal/seam/seam.go +++ b/internal/seam/seam.go @@ -5,11 +5,11 @@ // Server.PassthroughHandler used to be exported, and that was a public // production bypass: New(...).Consumes(...).Credential(source). // PassthroughHandler(gatewayURL) returned a usable, credential-bearing handler -// having skipped validate(), the mTLS boot, the caller allow-list, the -// published ceiling and authenticated outbound. A deployment that called it -// completed a viewer mint and a module call over plaintext and answered 200 — -// with the viewer's bearer and this workload's credential on the wire — and -// supplying a real credential source defeated the one mitigation it had. +// having skipped validate(), the boot, the published ceiling and the +// origin-pinned outbound client. A deployment that called it completed a +// viewer mint and a module call and answered 200 — with the viewer's bearer +// and this workload's credential sent wherever it was mounted — and supplying +// a real credential source defeated the one mitigation it had. // // It had to stay reachable from passthroughtest, which is a consumer-facing // package in this module and the documented seam for running the real diff --git a/mcp.go b/mcp.go index dd0c6d8..720a3b5 100644 --- a/mcp.go +++ b/mcp.go @@ -199,8 +199,8 @@ type MCPEnvironment struct { // callers are tests: an exported method that hands back a servable handler is // the PassthroughHandler defect again — whoever mounts it serves the viewer's // bearer and this workload's credential over whatever they mounted it on, -// having skipped validate(), the mTLS boot, the caller allow-list, the ceiling -// and authenticated outbound. This one also writes boot configuration from +// having skipped validate(), the boot, the ceiling and the origin-pinned +// outbound client. This one also writes boot configuration from // values its caller supplies, which is the one thing loadConfig exists to be // the only source of. // @@ -220,6 +220,16 @@ func (s *Server) mcpHandler(env MCPEnvironment) (http.Handler, error) { if s.mcp == nil { return nil, fmt.Errorf("solution %q declares no MCP server (ServeMCP)", s.manifest.ID) } + // The declared frontend, when the test supplied one: the metadata document + // names the resource by its title. A boot refuses a solution without one; + // this seam serves only the MCP surface and lets the title be absent. + if len(s.manifest.Frontend) > 0 { + frontend, err := parseFrontend(s.manifest.Frontend) + if err != nil { + return nil, fmt.Errorf("solution %q: %w", s.manifest.ID, err) + } + s.frontend = frontend + } s.cfg.gatewayURL, s.cfg.mcpIssuerURL, s.cfg.mcpPublicURL = env.GatewayURL, env.IssuerURL, env.PublicURL // And the derived set, because this overwrites what it was derived FROM. // @@ -228,7 +238,7 @@ func (s *Server) mcpHandler(env MCPEnvironment) (http.Handler, error) { // runs, so the net that stops a tool naming an address this runtime // dialled was silently off there. A derived field whose sources are // reassigned is stale until it is not. - s.cfg.dialled = dialledAddresses(s.cfg.gatewayURL, s.cfg.mintURL) + s.cfg.dialled = dialledAddresses(s.cfg.gatewayURL) // The caller named both, so they are as explicit as the composition's // declared values: what validate() refuses is an issuer nobody chose, and // what a refusal has to name for a public URL is whoever set it. @@ -567,7 +577,7 @@ func (s *Server) handleProtectedResourceMetadata(w http.ResponseWriter, r *http. Resource: s.mcpResource(r), AuthorizationServers: []string{s.cfg.mcpIssuerURL}, BearerMethodsSupported: []string{"header"}, - ResourceName: s.manifest.Title, + ResourceName: s.frontend.Title, }).ServeHTTP(w, r) } diff --git a/mcp_test.go b/mcp_test.go index 480bd1c..4b83e39 100644 --- a/mcp_test.go +++ b/mcp_test.go @@ -98,7 +98,7 @@ func serveMCP(t *testing.T, env MCPEnvironment, register func(*mcp.Server)) *htt // Before the cutover nothing here held one; the refusal is the point, and // a test seam that skipped it would be testing a path no deployment has. mint := newHostMint(t, &hostMint{}) - s := New(Manifest{ID: mcpServerName, Title: "Wiki"}). + s := New(Manifest{ID: mcpServerName, Frontend: []byte(wikiFrontend)}). ServeMCP(mcpServerName, "v1.2.3", register). Credential(stubCredentialSource{credential: mintedCredential(t, mint, testProjectionAudience)}) handler, err := s.mcpHandler(env) @@ -847,7 +847,7 @@ func TestAnMCPToolCallIsHeldToThePublishedCeiling(t *testing.T) { }) } - server := New(Manifest{ID: mcpServerName, Title: "Wiki"}). + server := New(Manifest{ID: mcpServerName, Frontend: []byte(wikiFrontend)}). ServeMCP(mcpServerName, "v1.2.3", register). Credential(stubCredentialSource{credential: mintedCredential(t, mint, testProjectionAudience)}) // A resolved contract with one audience, which is what the ceiling is read @@ -1181,7 +1181,7 @@ func TestAnMCPToolDoesNotRunWhileThisExecutionHoldsNoCredential(t *testing.T) { return nil, askOutput{Status: 200}, nil }) } - server := New(Manifest{ID: mcpServerName, Title: "Wiki"}). + server := New(Manifest{ID: mcpServerName, Frontend: []byte(wikiFrontend)}). ServeMCP(mcpServerName, "v1.2.3", register). Credential(refusingSource{err: fmt.Errorf("%w: the issuer said so, at https://mint.internal.example/modules/_work-context (dial tcp 10.4.1.9:8443: connection refused)", tc.err)}) @@ -1235,7 +1235,7 @@ func TestAnMCPToolDoesNotRunWhileThisExecutionHoldsNoCredential(t *testing.T) { // A withdrawn credential must not read as "this solution serves no // MCP": one is a back-off, the other sends a client looking for an // endpoint that does not exist. - server := New(Manifest{ID: mcpServerName, Title: "Wiki"}). + server := New(Manifest{ID: mcpServerName, Frontend: []byte(wikiFrontend)}). ServeMCP(mcpServerName, "v1.2.3", readCollectionTool). Credential(refusingSource{err: workcontext.ErrMintRefused}) handler, err := server.mcpHandler(MCPEnvironment{IssuerURL: testIssuer}) @@ -1323,7 +1323,7 @@ func TestAToolsOwnFailureIsSanitizedOnTheWayOut(t *testing.T) { return nil, askOutput{Status: 200}, nil }) } - server := New(Manifest{ID: mcpServerName, Title: "Wiki"}). + server := New(Manifest{ID: mcpServerName, Frontend: []byte(wikiFrontend)}). ServeMCP(mcpServerName, "v1.2.3", register). Credential(attestingSource(t)) server.cfg.gatewayURL = gw.URL @@ -1373,7 +1373,7 @@ func TestAToolsOwnFailureIsSanitizedOnTheWayOut(t *testing.T) { // through that handler — and it is the layer that knows the method name, so it // is where the allowlist lives. func TestTheMethodLayerGateRefusesOnItsOwn(t *testing.T) { - server := New(Manifest{ID: mcpServerName, Title: "Wiki"}). + server := New(Manifest{ID: mcpServerName, Frontend: []byte(wikiFrontend)}). ServeMCP(mcpServerName, "v1.2.3", readCollectionTool). Credential(refusingSource{err: fmt.Errorf("%w: at https://mint.internal.example/modules/_work-context", workcontext.ErrMintUnavailable)}) @@ -1612,7 +1612,7 @@ func TestAToolResultNeverCarriesTheRuntimesInternals(t *testing.T) { return nil, askOutput{Status: resp.StatusCode}, nil }) } - server := New(Manifest{ID: mcpServerName, Title: "Wiki"}). + server := New(Manifest{ID: mcpServerName, Frontend: []byte(wikiFrontend)}). ServeMCP(mcpServerName, "v1.2.3", register). Credential(attestingSource(t)) server.cfg.gatewayURL = gw.URL @@ -1662,7 +1662,7 @@ func TestAToolsOwnRefusalIsLeftAlone(t *testing.T) { return nil, askOutput{}, errors.New("no such collection in this wiki") }) } - server := New(Manifest{ID: mcpServerName, Title: "Wiki"}). + server := New(Manifest{ID: mcpServerName, Frontend: []byte(wikiFrontend)}). ServeMCP(mcpServerName, "v1.2.3", register). Credential(attestingSource(t)) server.cfg.gatewayURL = gw.URL @@ -1916,3 +1916,7 @@ func (v sabotagingMarshaler) MarshalJSON() ([]byte, error) { v.text.Text = v.to return []byte(`{}`), nil } + +// wikiFrontend is the declared frontend file the MCP fixtures embed: the title +// is what the metadata document names the resource by. +const wikiFrontend = "title: Wiki\norder: 1\nexposed_module: ./Page\nhost_contract: 1\n" diff --git a/mint_migration_test.go b/mint_migration_test.go index 74aefa3..4ee81d4 100644 --- a/mint_migration_test.go +++ b/mint_migration_test.go @@ -55,18 +55,17 @@ func TestRuntimeProjectedTokenReaderReadsFreshAndFailsClosed(t *testing.T) { } } -// Exercise the production source, including its TLS readers and error boundary. +// Exercise the production source, including its transport rule and error +// boundary. func productionMintSource(t *testing.T, mint *hostMint, tokenFile string) *brokerCredentialSource { t.Helper() - cert, key, bundle, _, _, _ := bootIdentityIn(t, mint, testPrincipal) + authorityValues(t) s := New(Manifest{ID: testSolutionID}) s.cfg = config{ - identityCertFile: cert, identityKeyFile: key, trustBundleFile: bundle, - allowedCallersFile: identitiesFile(t, testGatewayPrincipal), - mintPeersFile: identitiesFile(t, testGatewayPrincipal), - gatewayPeersFile: identitiesFile(t, testGatewayPrincipal), brokerInternalToken: "broker-perimeter-token", + gatewayURL: mint.URL, mintURL: mint.URL + credentialMintPath, projectedTokenPath: tokenFile, + transport: meshTransport{local: true}, } s.principal = testPrincipal s.authority = authorityFor(t, context.Background()) diff --git a/passthrough.go b/passthrough.go index b2a3129..d6c52fc 100644 --- a/passthrough.go +++ b/passthrough.go @@ -342,11 +342,11 @@ func init() { // "exported" part. // // Exported, it was a production bypass: a solution could build a usable -// credential-bearing handler that had skipped validate(), the mTLS boot, the -// caller allow-list, the published ceiling and authenticated outbound, and a -// deployment calling it completed a viewer mint and a module call over -// plaintext, 200, with the viewer's bearer and this workload's own credential -// on the wire. Supplying a credential source — which a real deployment does — +// credential-bearing handler that had skipped validate(), the boot, the +// published ceiling and the origin-pinned outbound client, and a deployment +// calling it completed a viewer mint and a module call, 200, with the +// viewer's bearer and this workload's own credential sent wherever it was +// mounted. Supplying a credential source — which a real deployment does — // defeated the "no source, nothing to mint with" mitigation it relied on. Fail // closed admits no exception for a shape that exists to make testing // convenient, so the shape moved behind the compiler instead: see diff --git a/passthroughtest/identity.go b/passthroughtest/identity.go deleted file mode 100644 index f66dbb3..0000000 --- a/passthroughtest/identity.go +++ /dev/null @@ -1,115 +0,0 @@ -package passthroughtest - -import ( - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rand" - "crypto/tls" - "crypto/x509" - "crypto/x509/pkix" - "fmt" - "math/big" - "net" - "net/url" - "time" -) - -// The trust domain and the two identities this fake host issues. -// -// sdk-go#51 holds the mint endpoint to WHICH party it is, read from the single -// URI SAN on its leaf, so the fake host needs a real cell rather than -// httptest's shared certificate: that one carries no SPIFFE ID at all, and a -// mint client configured with AdmittedPeers refuses it — correctly. The seam -// exists to run a consumer's passthrough against the posture a deployment has, -// so the fake host grew a CA instead of the client losing the check. -const ( - trustDomain = "passthroughtest" - mintPeerIdentity = "spiffe://" + trustDomain + "/ns/host/sa/mint" - workloadIdentitry = "spiffe://" + trustDomain + "/ns/solutions/sa/passthroughtest" -) - -// mintCell is the fake host's own certificate authority: a root, the mint -// endpoint's leaf, and the workload leaf the mint client presents. -type mintCell struct { - roots *x509.CertPool - mint tls.Certificate - workload tls.Certificate -} - -// newMintCell issues the cell. Everything is ephemeral and in memory: this is -// a test seam, and a key on disk would outlive the run that made it. -func newMintCell() (*mintCell, error) { - rootKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { - return nil, fmt.Errorf("the fake host could not generate its root key: %w", err) - } - rootTemplate := &x509.Certificate{ - SerialNumber: big.NewInt(1), - Subject: pkix.Name{CommonName: trustDomain + " root"}, - NotBefore: time.Now().Add(-time.Hour), - NotAfter: time.Now().Add(24 * time.Hour), - KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature, - BasicConstraintsValid: true, - IsCA: true, - } - rootDER, err := x509.CreateCertificate(rand.Reader, rootTemplate, rootTemplate, &rootKey.PublicKey, rootKey) - if err != nil { - return nil, fmt.Errorf("the fake host could not sign its root: %w", err) - } - root, err := x509.ParseCertificate(rootDER) - if err != nil { - return nil, fmt.Errorf("the fake host's root does not parse: %w", err) - } - roots := x509.NewCertPool() - roots.AddCert(root) - - // The mint endpoint. It is a server AND the thing whose identity the client - // admits, so it carries exactly one URI SAN and the names httptest serves - // on. - mint, err := leaf(root, rootKey, 2, mintPeerIdentity, - []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, - []string{"localhost"}, []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")}) - if err != nil { - return nil, err - } - // The workload. One URI SAN as well, because this is what the real mint - // endpoint reads to decide which solution is asking. - workload, err := leaf(root, rootKey, 3, workloadIdentitry, - []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, nil, nil) - if err != nil { - return nil, err - } - return &mintCell{roots: roots, mint: *mint, workload: *workload}, nil -} - -// leaf issues one end-entity certificate with a single SPIFFE URI SAN. -func leaf( - root *x509.Certificate, rootKey *ecdsa.PrivateKey, serial int64, identity string, - usages []x509.ExtKeyUsage, dnsNames []string, ips []net.IP, -) (*tls.Certificate, error) { - spiffeID, err := url.Parse(identity) - if err != nil { - return nil, fmt.Errorf("the fake host's identity %q does not parse: %w", identity, err) - } - key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { - return nil, fmt.Errorf("the fake host could not generate a key for %s: %w", identity, err) - } - template := &x509.Certificate{ - SerialNumber: big.NewInt(serial), - Subject: pkix.Name{CommonName: identity}, - NotBefore: time.Now().Add(-time.Hour), - NotAfter: time.Now().Add(24 * time.Hour), - KeyUsage: x509.KeyUsageDigitalSignature, - ExtKeyUsage: usages, - BasicConstraintsValid: true, - URIs: []*url.URL{spiffeID}, - DNSNames: dnsNames, - IPAddresses: ips, - } - der, err := x509.CreateCertificate(rand.Reader, template, root, &key.PublicKey, rootKey) - if err != nil { - return nil, fmt.Errorf("the fake host could not sign %s: %w", identity, err) - } - return &tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key}, nil -} diff --git a/passthroughtest/passthroughtest.go b/passthroughtest/passthroughtest.go index d5a6f5d..3dc90e6 100644 --- a/passthroughtest/passthroughtest.go +++ b/passthroughtest/passthroughtest.go @@ -6,10 +6,10 @@ // same boot check — over an httptest server, reached through internal/seam. // Only the host is fake. The seam is not public API: Server.PassthroughHandler // was exported once, and a deployment calling it completed a viewer mint and a -// module call over plaintext with no validate(), no mTLS boot, no caller -// allow-list, no ceiling and no authenticated outbound. This package is the one -// caller Go's internal rule lets reach it, and because any module can import -// *this* package, its constructors refuse a non-test binary. Host stands in for the gateway the passthrough calls through: +// module call with no validate(), no boot, no ceiling and no origin-pinned +// outbound. This package is the one caller Go's internal rule lets reach it, +// and because any module can import *this* package, its constructors refuse a +// non-test binary. Host stands in for the gateway the passthrough calls through: // it mints the viewer's Work Context (the accounts StartTask procedure the // gateway routes) and forwards each /v1//* call to the module address the // test gave it, typically an httptest server of the test's own that answers @@ -26,11 +26,8 @@ package passthroughtest import ( "context" - "crypto/tls" - "crypto/x509" "encoding/json" "fmt" - corework "github.com/codefly-dev/core/workcontext" "net/http" "net/http/httptest" "net/http/httputil" @@ -42,6 +39,8 @@ import ( "testing" "time" + corework "github.com/codefly-dev/core/workcontext" + "github.com/codefly-dev/core/solution/manifest" "github.com/codefly-dev/sdk-go/workcontext" solution "github.com/codefly-dev/solution-runtime-go" @@ -146,25 +145,6 @@ type Host struct { calls []Call // workloadMints counts the solution's own execution credentials. workloadMints int - // mintServer serves the workload mint over TLS, separately from the - // gateway's own plaintext server. - // - // The SDK's mint client refuses plain HTTP outright — the projected - // service-account token travels on that request — and it requires the - // roots that may sign the endpoint, with no system-pool fallback. Both are - // right, and neither can be satisfied by the plaintext server the rest of - // this host uses. - // - // Only the mint moves. Making the whole host TLS would need a SPIFFE leaf - // from a test CA for the gateway's own identity, because the runtime's - // outbound client admits a platform destination by its certificate — and - // the seam deliberately runs without that boot. That remains the - // fidelity gap seam.go already names; this closes the half the SDK now - // requires rather than pretending to close both. - mintServer *httptest.Server - // cell is the fake host's own CA, because the mint endpoint is now held to - // WHICH party it is and not merely to a certificate from an accepted root. - cell *mintCell } // NewHost starts a fake host, closed when the test ends. @@ -172,31 +152,12 @@ func NewHost(t testing.TB) *Host { mustBeATest() t.Helper() h := &Host{modules: map[string]*httputil.ReverseProxy{}, dir: t.TempDir()} + // Plain HTTP, the workload mint included: inside a cell every hop past the + // gateway is plaintext carried by the mesh, and the gateway's workload + // endpoint serves the mint beside every other route. The SDK's mint + // client admits a plaintext loopback URL on a local run, which a test is. h.server = httptest.NewServer(http.HandlerFunc(h.serveHTTP)) t.Cleanup(h.server.Close) - cell, err := newMintCell() - if err != nil { - t.Fatalf("start the fake host: %v", err) - } - h.cell = cell - // mTLS, with the client certificate REQUIRED and verified. - // - // The real endpoint receives the projected service-account token, and - // sdk-go#51 makes the client present this workload's X.509-SVID on that - // request. A fake host that merely tolerated a client certificate would - // let a consumer's green run mean less than it looks like: the seam is - // where a consumer finds out their passthrough works against the posture - // a deployment has. - mint := httptest.NewUnstartedServer(http.HandlerFunc(h.serveHTTP)) - mint.TLS = &tls.Config{ - MinVersion: tls.VersionTLS13, - Certificates: []tls.Certificate{cell.mint}, - ClientAuth: tls.RequireAndVerifyClientCert, - ClientCAs: cell.roots, - } - mint.StartTLS() - h.mintServer = mint - t.Cleanup(h.mintServer.Close) return h } @@ -449,31 +410,36 @@ func (h *Host) credentialSource() (solution.CredentialSource, error) { return nil, err } return workcontext.NewMintClient(workcontext.MintOptions{ - URL: h.mintServer.URL + workloadMintPath, + URL: h.server.URL + workloadMintPath, // The audience is named as a pinned value now, not passed as a string // beside the pin, so the seam supplies a pin that answers it. Audience: workcontext.AuthorityValue{Name: authorityGroup, Key: audienceKey}, Authority: fixedAuthority{audience: WorkloadAudience}, ProjectedToken: workcontext.ProjectedTokenFile(tokenFile), ProjectionAudience: "accounts", - // The three readers sdk-go#51 takes in place of a pool by value. They - // are readers here for the same reason they are readers in a booted - // runtime: each is consulted during the handshake, so what this seam - // exercises is the check and not a snapshot of it. - TrustAnchor: func() (*x509.CertPool, error) { return h.cell.roots, nil }, - ClientCertificate: func() (*tls.Certificate, error) { return &h.cell.workload, nil }, - AdmittedPeers: func() ([]string, error) { return []string{mintPeerIdentity}, nil }, + // A local run, with no mesh assertion: exactly what admits a plaintext + // URL to a loopback host, and nothing else — the rule a booted runtime + // applies to its resolved gateway, answered here the way a test's + // environment answers it. + Transport: localRun{}, }) } +// localRun is the SDK's transport source for a test: a local run that asserts +// no mesh, so the only plaintext the mint client admits is loopback. +type localRun struct{} + +func (localRun) WorkspaceValueIfSet(string, string) (string, bool, error) { return "", false, nil } +func (localRun) IsLocalRun() bool { return true } + // mustBeATest refuses to build any of this outside a test binary. // // This package is the one caller Go's internal-package rule lets reach the // passthrough seam, and that made it the bypass it was built to close: the root // package's exported constructor is gone, but Handler is exported from a package // any module can import, so a deployment importing it got the same -// credential-bearing handler with no validate(), no mTLS boot, no caller -// allow-list, no ceiling and no authenticated outbound. Taking a testing.TB is +// credential-bearing handler with no validate(), no boot, no ceiling and no +// origin-pinned outbound. Taking a testing.TB is // not the gate it resembles — testing.TB's unexported method only stops a // type *declaring* the interface, and a struct that EMBEDS testing.TB satisfies // it in any program, so the signature proves nothing about where the call came @@ -492,7 +458,7 @@ func mustBeATest() { refuseOutsideTest(testing.Testing()) } // can enter — a mutation that neutered it left every gate here passing. func refuseOutsideTest(isTest bool) { if !isTest { - panic("passthroughtest is a test seam and this is not a test binary: it builds a handler that holds a real execution credential without validate(), the mTLS boot, the caller allow-list, the published ceiling or authenticated outbound, so a deployment reaching it would serve the viewer's bearer and this workload's credential over whatever it was mounted on. Boot the runtime with solution.Serve instead") + panic("passthroughtest is a test seam and this is not a test binary: it builds a handler that holds a real execution credential without validate(), the boot, the published ceiling or the origin-pinned outbound client, so a deployment reaching it would serve the viewer's bearer and this workload's credential over whatever it was mounted on. Boot the runtime with solution.Serve instead") } } diff --git a/projection_boundary_test.go b/projection_boundary_test.go index 4309b5c..0c3823d 100644 --- a/projection_boundary_test.go +++ b/projection_boundary_test.go @@ -30,7 +30,6 @@ func bootableEnvironment(t *testing.T) { t.Helper() t.Setenv("PORT", "8090") t.Setenv("GATEWAY_URL", "https://gateway.cell.test:42152") - t.Setenv(CredentialMintURLEnvironmentVariable, "https://mint.cell.test/modules/_work-context") t.Setenv(ContractProfileEnvironmentVariable, "test") t.Setenv("CODEFLY__RUNTIME_CONTEXT", "") // The broker requires its declared workspace secret even when nothing is @@ -134,7 +133,7 @@ func TestADeclaredPassthroughIsCheckedAgainstTheProjection(t *testing.T) { declared := passthroughModule() // valid: an as, scopes, and a resolvable method t.Run("listed in the projection", func(t *testing.T) { - s := New(Manifest{ID: "solution-under-test", Title: "Solution Under Test"}) + s := New(Manifest{ID: "solution-under-test"}) s.consumed = []ConsumedModule{declared} s.cfg = bootConfig(t, projectionOf(declared.As)) if _, err := s.validatePassthrough(); err != nil { @@ -143,7 +142,7 @@ func TestADeclaredPassthroughIsCheckedAgainstTheProjection(t *testing.T) { }) t.Run("absent from the projection", func(t *testing.T) { - s := New(Manifest{ID: "solution-under-test", Title: "Solution Under Test"}) + s := New(Manifest{ID: "solution-under-test"}) s.consumed = []ConsumedModule{declared} s.cfg = bootConfig(t, projectionOf("something-else")) _, err := s.validatePassthrough() @@ -170,7 +169,7 @@ func TestADeclaredPassthroughIsCheckedAgainstTheProjection(t *testing.T) { // happily counted as success. A mutation that deleted the decode left it // green, which is the defect it now fails on. func TestTheSeamJudgesItsOwnProjection(t *testing.T) { - s := New(Manifest{ID: "solution-under-test", Title: "Solution Under Test"}) + s := New(Manifest{ID: "solution-under-test"}) s.consumed = []ConsumedModule{passthroughModule()} _, err := passthroughSeam(s, "https://gateway.cell.test", `{"not":"a list"}`) if err == nil { @@ -184,7 +183,7 @@ func TestTheSeamJudgesItsOwnProjection(t *testing.T) { // And the seam accepts the projection it should: the same declaration, listed. // Without this, the test above is satisfied by a seam that refuses everything. func TestTheSeamAcceptsAProjectionThatListsTheDeclaration(t *testing.T) { - s := New(Manifest{ID: "solution-under-test", Title: "Solution Under Test"}) + s := New(Manifest{ID: "solution-under-test"}) s.consumed = []ConsumedModule{passthroughModule()} if _, err := passthroughSeam(s, "https://gateway.cell.test", projectionOf(passthroughModule().As)); err != nil { t.Fatalf("the seam refused a projection that lists the declared module: %v", err) @@ -204,7 +203,7 @@ func startRefusal(t *testing.T, projection string) error { bootableEnvironment(t) t.Setenv(manifest.APIConsumesEnvironmentVariable, projection) - s := New(Manifest{ID: "solution-under-test", Title: "Solution Under Test"}) + s := New(Manifest{ID: "solution-under-test", Frontend: []byte(testFrontend)}) ln, err := s.start(context.Background()) if ln != nil { _ = ln.Close() @@ -238,7 +237,7 @@ func TestEveryProjectedTargetIsDecodedNotJustTheFirst(t *testing.T) { // And the later target is usable, which is what a dropped one costs: the // declaration names only the second module, so it agrees with the // projection exactly when that entry survived decoding. - s := New(Manifest{ID: "solution-under-test", Title: "Solution Under Test"}) + s := New(Manifest{ID: "solution-under-test"}) s.consumed = []ConsumedModule{passthroughModule()} s.cfg = cfg if _, err := s.validatePassthrough(); err != nil { diff --git a/redirect_test.go b/redirect_test.go index eaf07fc..d3474bb 100644 --- a/redirect_test.go +++ b/redirect_test.go @@ -2,18 +2,15 @@ package solution import ( "context" - "crypto/tls" - "io" "net/http" "net/http/httptest" - "net/http/httptrace" "path/filepath" "strings" "sync" - "sync/atomic" - "github.com/codefly-dev/sdk-go/workcontext" "testing" + + "github.com/codefly-dev/sdk-go/workcontext" ) // trap is a destination this runtime must never dial: it records anything that @@ -251,106 +248,3 @@ func TestASameOriginRedirectIsNotFollowedEither(t *testing.T) { t.Errorf("the mint made %d requests (%v), want exactly 1: the 307 must end the exchange", len(hops.paths), hops.paths) } } - -// TestOutboundTrustIsReReadPerConnection is the finding that went unanswered -// through two review rounds, and the argument against it was one I had already -// written down for the other direction. -// -// Inbound, peer trust is re-read per handshake so that removing a compromised -// root takes effect without a restart, on the stated grounds that judging by a -// stale anchor admits whoever should be refused — which is not symmetric with -// serving a stale leaf. Outbound the anchor was read once, into the client's -// RootCAs at boot. It applies with more force there, not less: the two -// destinations on the other side of that client are the mint and the gateway, -// and they receive the projected service-account token, the viewer's bearer and -// this workload's own credential. A root removed because it was compromised -// kept authenticating exactly the parties that are handed everything. -// -// The test replaces the bundle's contents with an unrelated anchor and asserts -// the next connection fails to verify the platform — no restart, no new client. -func TestOutboundTrustIsReReadPerConnection(t *testing.T) { - c := newCell(t) - certFile, keyFile, bundleFile, _, _ := c.workload(t, testPrincipal) - // A platform endpoint served under the cell's anchor, requiring the - // caller's certificate, which is what the real mint does. - host := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - writeJSON(w, http.StatusOK, map[string]string{"ok": "yes"}) - })) - host.TLS = &tls.Config{ - MinVersion: tls.VersionTLS13, - Certificates: []tls.Certificate{*c.identity(t, testGatewayPrincipal)}, - ClientCAs: c.roots, - ClientAuth: tls.RequireAndVerifyClientCert, - } - host.StartTLS() - t.Cleanup(host.Close) - - server := New(Manifest{ID: testSolutionID}) - server.cfg = config{identityCertFile: certFile, identityKeyFile: keyFile, trustBundleFile: bundleFile, - mintPeersFile: identitiesFile(t, testGatewayPrincipal), - gatewayPeersFile: identitiesFile(t, testGatewayPrincipal), - // The destination this test dials, so the authorization set for it is - // the mint's: the set is chosen per destination now. - mintURL: host.URL + credentialMintPath} - server.principal = testPrincipal - client, err := server.outboundClient(nil) - if err != nil { - t.Fatalf("outboundClient: %v", err) - } - - // Control: the platform is reachable while its root is in the bundle. - resp, err := client.Get(host.URL + "/platform/_credential") - if err != nil { - t.Fatalf("the platform was not reachable with its own root in the bundle: %v", err) - } - // Drained, not just closed. net/http returns a connection to the pool only - // once its body is consumed, so closing an unread body leaves the - // connection in no defined state for the CloseIdleConnections below — - // which was a flake, not a subtlety: when the connection had not yet - // become idle there was nothing to close, the next request reused it, and - // the reused connection answered without a handshake. The test then - // reported that revoking a root does nothing, intermittently. - _, _ = io.Copy(io.Discard, resp.Body) - _ = resp.Body.Close() - - // The root is removed and replaced by an unrelated one — the shape of a - // revocation: the bundle still parses, and no longer contains the issuer of - // the certificate the platform presents. - unrelated, _, _ := issueAnchor(t) - writeFile(t, bundleFile, string(unrelated)) - - // Idle connections would otherwise answer from the pool without a - // handshake, which is why per-dial reloading is only half the fix. - client.CloseIdleConnections() - - // And the second request has to actually dial, or it is testing nothing: - // a reused connection skips the handshake and would pass this whether - // trust was re-read or not. httptrace says which happened. - var reused atomic.Bool - trace := &httptrace.ClientTrace{ - GotConn: func(info httptrace.GotConnInfo) { reused.Store(info.Reused) }, - } - request, err := http.NewRequestWithContext( - httptrace.WithClientTrace(context.Background(), trace), - http.MethodGet, host.URL+"/platform/_credential", nil) - if err != nil { - t.Fatal(err) - } - if resp, err := client.Do(request); err == nil { - _ = resp.Body.Close() - if reused.Load() { - t.Fatal("the second request was served on a connection that was already open, so it never handshook and this test asserted nothing: the control's connection had not returned to the pool when CloseIdleConnections ran") - } - t.Fatal("the platform still authenticated after its root was removed from the bundle: outbound trust was snapshotted at boot, so revoking a root would need a restart — and this client carries the projected token, the viewer's bearer and this workload's credential") - } - - // And the bound exists, so a connection cannot outlive the reload - // indefinitely even without CloseIdleConnections. - transport, ok := client.Transport.(*http.Transport) - if !ok { - t.Fatalf("outbound transport is %T", client.Transport) - } - if transport.IdleConnTimeout == 0 || transport.IdleConnTimeout > outboundTrustReloadBound { - t.Errorf("idle connections live for %s: per-dial reloading bounds nothing without a bound on reuse", transport.IdleConnTimeout) - } -} diff --git a/served_test.go b/served_test.go index d1ed0d7..23a4b8d 100644 --- a/served_test.go +++ b/served_test.go @@ -26,7 +26,7 @@ import ( // the other announces the new one. func TestServedDocumentsDeclareOneSetOfMajors(t *testing.T) { mint := newHostMint(t, &hostMint{}) - solution := boot(t, New(Manifest{ID: testSolutionID, Title: testSolutionTitle}), mint) + solution := boot(t, New(Manifest{ID: testSolutionID}), mint) var capabilities map[string]any readServed(t, solution, "/.well-known/capabilities", &capabilities) diff --git a/solution.go b/solution.go index b6640bc..a80e4af 100644 --- a/solution.go +++ b/solution.go @@ -1,8 +1,8 @@ // Package solution is a generic runtime for "solution" modules — independently // deployed extensions that plug into a host at runtime with no build-time // coupling. It owns everything every solution needs identically: env/config, -// obtaining this execution's credential, a listener that presents the -// workload's own identity, CORS, static Module Federation asset serving, the +// obtaining this execution's credential, a plain-HTTP listener on the port +// Codefly resolves, CORS, static Module Federation asset serving, the // capability handshake, the solution manifest, the authority contract, and // serving the solution's MCP server to agent clients. A solution author // supplies a manifest and one or more handlers; each handler receives a Gateway @@ -41,8 +41,6 @@ package solution import ( "bytes" "context" - "crypto/tls" - "crypto/x509" "encoding/json" "errors" "fmt" @@ -56,13 +54,11 @@ import ( "os" "path" "regexp" - "slices" "strconv" "strings" "sync" "sync/atomic" "time" - "unicode" "connectrpc.com/connect" "github.com/codefly-dev/core/resources" @@ -73,24 +69,23 @@ import ( ) // Manifest is the small, solution-specific description the author provides. +// +// What the host lists the solution under — its title, nav order, exposed +// module, host contract, dashboard and surfaces — is not declared here. It is +// declared once, in the file FrontendFileName names, which Frontend embeds and +// the CLI delivers in the presence document: two readers of one file cannot +// disagree, where a Go field beside a file could. type Manifest struct { - ID string // logical id / gateway service alias, e.g. "widgets-go" - Title string // nav title, e.g. "Widgets · GO" - Order int // nav order - ExposedModule string // MF exposed module (default "./Page") - Contract string // capability contract id (default: ID) - Capabilities []string // capability feature ids - // Dashboard is an optional data-graph (events / metrics / dashboards, per - // @codefly/saas-plugin-manifest's DataGraph) the host renders. The runtime - // carries it verbatim and never interprets it: the graph's schema and its - // validation are owned by the host, so this stays an opaque declaration to - // keep the runtime host-agnostic. - Dashboard any - // Surfaces are what this solution offers inside a client application — a - // Word add-in, a Slack app — rather than as a page the host renders. The - // host's registry projects them so a client can discover, without a table - // of its own, which solutions offer it something. - Surfaces []Surface + ID string // logical id / gateway service alias, e.g. "widgets-go" + Contract string // capability contract id (default: ID) + Capabilities []string // capability feature ids + // Frontend is the declared frontend file, FrontendFileName, embedded with + // go:embed from beside the backend's service.codefly.yaml. Required: the + // manifest route serves from it, and a boot without one is refused naming + // the file. Contract and Capabilities stay Go fields because the + // capability handshake (/.well-known/capabilities) is the backend's own + // and the frontend block does not carry them. + Frontend []byte // Assets, when set, is what /assets/ serves: the built Module Federation // remote (mf-manifest.json at its root, and every chunk it names), usually // embedded in the binary with go:embed and narrowed with fs.Sub to the @@ -102,146 +97,25 @@ type Manifest struct { Assets fs.FS } -// Surface is one offering a solution makes inside a client application. -type Surface struct { - ID string // unique per client, e.g. "footnote" - Client string // client kind: word, powerpoint, excel, slack - Title string // what the client labels the surface with - // Description is the longer explanation a client may show beside the title. - // Optional, and omitted from the manifest when empty so a client renders no - // description rather than an empty one. - Description string - // Module is the path, on this solution's own origin, of a self-contained ES - // module the client loads, resolved against the origin the client loaded - // this solution from. The check on it catches an author reaching off that - // origin by mistake; it is not what keeps a hostile solution from doing so - // deliberately, since a solution serves its own manifest and need not use - // this runtime at all. The host's registry and the client's loader own that - // boundary and have to enforce it themselves. - Module string - // Contract is the surface contract major this module is built against, as - // the host client's own surface documentation defines it, so a client - // refuses a surface it cannot run rather than loading it and failing - // inside. Named generically on purpose: this runtime is the generic one, - // and the comment used to point at one product's repository. - Contract int - // Applies is "always" or a {"tagged": [...]} selector, and Events are the - // journal namespaces the surface reconciles on. Both are carried verbatim: - // what a tag selects and what a namespace names are the client's to - // interpret, not this runtime's. - Applies any - Events []string -} - -// surfaceAppliesAlways is what an undeclared Applies is served as. A surface is -// present in the manifest either way, so leaving the slot out would not say -// "no surface" the way an absent dashboard does — it would ask every client to -// invent the same default for a surface that is otherwise fully declared. -const surfaceAppliesAlways = "always" - -// surfaceClients are the client kinds a surface may declare. A client loads -// only what names it, so an unknown kind is a surface nothing will ever load — -// caught at boot rather than showing up as a solution that simply never -// appears in the add-in. -var surfaceClients = map[string]bool{"word": true, "powerpoint": true, "excel": true, "slack": true} - -// validateSurfaces refuses a surface declaration no client could use. The -// author writes these in code, so every failure here is a mistake a boot should -// name rather than a condition the runtime can recover from. -func (m Manifest) validateSurfaces() error { - // The solution's own id first. Nothing refused an empty one, and it was - // load-bearing in a place it does not look load-bearing: the published - // contract carries it as `Solution`, which was also the test for whether a - // contract had been resolved at all — so an empty id switched the scope - // ceiling off while every other part of the boot reported success, and a - // ForModule asking for no scopes against an undeclared audience reached the - // host as a viewer mint. The ceiling is keyed on its own flag now, and this - // refusal closes the way to produce the state. - // - // It is also what the host's presence document names this solution by, and - // what every refusal in this package identifies the process as, so an empty - // one makes the logs of a misconfigured deployment anonymous. +// validateID refuses an empty id. Nothing refused one, and it was load-bearing +// in a place it does not look load-bearing: the published contract carries it +// as `Solution`, which was also the test for whether a contract had been +// resolved at all — so an empty id switched the scope ceiling off while every +// other part of the boot reported success, and a ForModule asking for no +// scopes against an undeclared audience reached the host as a viewer mint. The +// ceiling is keyed on its own flag now, and this refusal closes the way to +// produce the state. +// +// It is also what the host's presence document names this solution by, and +// what every refusal in this package identifies the process as, so an empty +// one makes the logs of a misconfigured deployment anonymous. +func (m Manifest) validateID() error { if strings.TrimSpace(m.ID) == "" { return fmt.Errorf("this solution declares no id: Manifest.ID is what the host's presence document names it by, what the published contract reports as its own, and what every refusal here identifies this process as. Set it to the module id the composition deploys") } - // Keyed by client and id together: two clients never see each other's - // surfaces, so the same offering carries the same id in Word and in - // PowerPoint, and only a collision within one client is ambiguous. The id - // and client checks below run first, so neither half of the key can contain - // the separator and no two distinct pairs can collide into one string. - seen := make(map[string]bool, len(m.Surfaces)) - for i, surface := range m.Surfaces { - switch { - case surface.ID == "": - return fmt.Errorf("surface %d has no id", i) - case !isSurfaceID(surface.ID): - return fmt.Errorf("surface %q: id must be lowercase letters and digits, separated by dashes", surface.ID) - case surface.Title == "": - return fmt.Errorf("surface %q has no title: a client labels the surface with it, and renders a blank control without one", surface.ID) - case !surfaceClients[surface.Client]: - return fmt.Errorf("surface %q: unknown client kind %q", surface.ID, surface.Client) - case seen[surface.Client+"/"+surface.ID]: - return fmt.Errorf("surface %q declared twice for client %q: a client addresses a surface by id, so two of its own cannot share one", - surface.ID, surface.Client) - case surface.Contract < 1: - // A client runs a surface only when it recognises the contract - // major it declares, so the zero an author leaves behind is not a - // lenient default: it is a surface every client refuses, in a boot - // that otherwise looks entirely healthy. - return fmt.Errorf("surface %q: contract %d is not a surface contract major; declare the major this module is built against (>= 1)", - surface.ID, surface.Contract) - case !isSameOriginPath(surface.Module): - return fmt.Errorf("surface %q: module %q must be a path on this solution's own origin, e.g. %q", - surface.ID, surface.Module, "/surfaces/"+surface.Client+"/"+surface.ID+".js") - } - seen[surface.Client+"/"+surface.ID] = true - } return nil } -// isSurfaceID accepts lowercase letters and digits separated by dashes. A -// leading or trailing dash is refused so that an id is never a prefix game, and -// so the empty string cannot pass by having no characters to object to. -func isSurfaceID(id string) bool { - if id == "" { - return false - } - for i, r := range id { - if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' { - continue - } - if r != '-' || i == 0 || i == len(id)-1 { - return false - } - } - return true -} - -// isSameOriginPath reports whether p addresses this solution's own origin: a -// rooted path and nothing else. -// -// Whether a path is rooted is decided by the parser that will resolve it — the -// client's, not Go's — and the two disagree on three inputs. A browser follows -// WHATWG URL, where a backslash beside the leading slash is read as a slash and -// what follows it as an authority, so "/\host/x.js" resolves to https://host/x.js -// while net/url reads that backslash as an ordinary path byte and reports no -// host at all; and where ASCII tab and newline are removed from the input before -// it is parsed, so "//host/x.js" becomes protocol-relative on arrival. None -// of the three can be seen in net/url's answer, so they are refused here by -// inspecting the bytes rather than by asking net/url what it made of them. -func isSameOriginPath(p string) bool { - if !strings.HasPrefix(p, "/") || strings.HasPrefix(p, "//") { - return false - } - for _, r := range p { - if r == '\\' || unicode.IsControl(r) { - return false - } - } - u, err := url.Parse(p) - return err == nil && u.Scheme == "" && u.Host == "" -} - // Handler is a solution endpoint. It receives a Gateway bound to the caller's // bearer and returns any JSON-serializable value. ClientError, GatewayError and // Connect errors retain actionable statuses; untyped errors return 502. @@ -267,6 +141,9 @@ func (e *ClientError) Error() string { return e.Message } // Server wires a manifest and handlers into a running solution. type Server struct { manifest Manifest + // frontend is the declared file decoded at boot (parseFrontend), which the + // manifest route and the MCP metadata document serve from. + frontend frontendDeclaration handlers map[string]RequestHandler // installStep is declared before Serve, like the handlers. Outcomes and // in-flight calls are shared by installation ID and revision. @@ -302,14 +179,6 @@ type Server struct { // independent reasons this cannot recur; a value that is also a sentinel // has one. contractResolved bool - // identity is where the listener's workload identity comes from. Nil means - // the one the platform projects, read from the configured files; a consumer - // whose platform issues an identity another way supplies it with Identity. - identity IdentitySource - // identityConfig is the resolved, posture-checked configuration from that - // source: one resolution serving the listener and the outbound client, so - // the two cannot be different identities. - identityConfig *tls.Config // credential is the optional consumer-supplied SDK source. The native // broker is opened at boot when no consumer source was supplied. Both // feed the same controller through nextCredential. @@ -357,14 +226,10 @@ type Server struct { // could not close it. Handing over the listener removes the window // entirely, because nothing is ever released. boundListener net.Listener - // handshakeTimeout overrides platformHandshakeTimeout, for a test that - // would otherwise spend the real one. Unset means the constant, as - // firstMintWindow does for the credential window. - handshakeTimeout time.Duration // attestation throttles what a failed attestation says (see attestWorkload). attestation attestationReport - // outbound is the authenticated client this boot makes platform requests - // with (see outboundClient). Nil until the boot builds it. + // outbound is the client this boot makes platform requests with (see + // outboundClient). Nil until the boot builds it. outbound *http.Client // firstMintWindow bounds how long the boot waits for a credential the // issuer says is not available yet. Zero means firstMintWait. Per-server @@ -390,81 +255,25 @@ type config struct { // telling an MCP client an address this runtime dialled. dialled []string assetsDir string - // mintURL is the explicitly configured module broker endpoint. The prefix - // comes from Manifest.ID; authority audiences remain frozen separately. - mintURL string - // upstream is the https origin peers reach this runtime at, reported at - // mint (see brokerMintOptions.Upstream), and upstreamErr is why there is - // none. See advertisedOrigin. - upstream string - upstreamErr error + // mintURL is where this execution's credential is minted: the host's + // published broker route (credentialMintPath) on the gateway endpoint the + // composition resolved. Derived, not resolved separately, because the + // gateway's workload endpoint serves the gateway's whole handler, the mint + // included — and there is no override, since an override would be a + // second destination for the projected service-account token. + mintURL string brokerInternalToken string // projectedTokenPath is the file the platform projects this workload's // service-account token into. It is re-read before every renewal, because // the projection is rotated under the running process and a token read once // at boot stops verifying long before the process stops running. projectedTokenPath string - // identityCertFile, identityKeyFile and trustBundleFile are the workload's - // X.509-SVID and the anchor its peers are verified against. The listener - // presents the first pair; there is no plain-HTTP listener to fall back to. - - identityCertFile, identityKeyFile, trustBundleFile string - // allowedCallersFile is the path to the set of identities this listener - // admits, one per line or comma-separated. Authentication says a caller - // holds a certificate from the cell's anchor; this says which of them may - // call, which is a different question and the one the host's own route - // admission answers for everybody else. - // - // A path, read per handshake, for the reason the trust anchor beside it is - // one. A snapshot of an admission decision is the same defect as a - // snapshotted trust anchor: removing a compromised consumed module from the - // provisioned set would leave this listener admitting it for the life of the - // process. Resolving the set through the SDK's value accessor on every - // handshake is not live, whatever it looks like — a - // configuration *value*, inline or file-carried, is fixed at process start - // (sdk-go file_carrier.go reads the file once and keeps it), so - // re-resolution returned the boot answer forever and the refusal branch - // below was unreachable in any deployment. The file is this process's to - // read, like the bundle, which is what makes the claim true instead of - // documented. - // - // The asymmetry is the one peerAnchor records: serving a stale *leaf* - // refuses callers who should be let in, judging by a stale *admission* lets - // in callers who should be refused. - allowedCallersFile string - // admissionConflict is set when both an override and the platform answered - // for an admission set. See conflictingAdmissionSources. - admissionConflict error - // platformPeersFile is the path to the set of identities this runtime - // accepts *outbound*: the mint and the gateway. It is the mirror of - // allowedCallersFile and it exists for the same reason — every workload in - // the cell holds a certificate from the same anchor, so verifying the chain - // and the hostname says a destination is some workload the platform issued - // for, not that it is the platform. A neighbouring workload holding a - // certificate valid for the gateway's hostname completed this handshake and - // was handed the projected token. - // mintPeersFile and gatewayPeersFile are the identities this runtime - // accepts at each of the two destinations it dials, one set per - // destination. - // - // One set for both was the previous shape, and it meant the gateway's - // identity was accepted at the mint's address and the mint's at the - // gateway's. The set already excluded every other workload in the cell, - // which was the finding it was added for — but "the platform" is not one - // party, and a set that spans two parties authorises each of them to stand - // in for the other. The maximum-security answer is one authorization set - // per destination, and a dial to anything that is neither is refused - // outright: this runtime has exactly two destinations. - // - // mintPeersFile is KEPT rather than deleted because it is consumed rather - // than reserved: sdk-go v0.3.0 owns the mint's transport, so the per-dial - // peer re-read reaches the gateway alone — but where the mint and the - // gateway answer at the SAME address this runtime cannot attribute a dial, - // and what it admits there is the intersection of both sets, so a gateway - // dial at a shared address is refused unless the mint's set admits it too. - // It is not a claim about the mint hop's posture; `credential.go` states - // what that hop does and does not have. - mintPeersFile, gatewayPeersFile string + // transport is the composition's answer to whether a plaintext hop inside + // the cell is carried by a mutually authenticated mesh, and whether this is + // a local run — read once here and handed to the SDK's endpoint rule, which + // is the one copy of the rule that admits http:// to the gateway. See + // meshTransport. + transport meshTransport // profile is the configuration profile this process runs under, which the // published contract is keyed by. A deployed environment has a profile of // its own (codefly-dev/core#687, fixed in core v0.7.1), so a contract that @@ -524,134 +333,6 @@ func env(key, fallback string) string { return fallback } -// advertisedOrigin is the https origin this runtime reports to the host as -// where it serves: what the gateway dials to proxy to it. -// -// It is the address Codefly ADVERTISES for this service's http endpoint — the -// CODEFLY__SELF_ENDPOINT__ carrier, read through core's accessor — and never -// the one it listens on. The two differ exactly where it matters: a rendered -// cell localizes a service's own CODEFLY__ENDPOINT__ carrier to -// "localhost:8080", so a runtime that reported its listen address told the -// gateway to proxy the solution to the gateway's own loopback. -// -// Without the carrier, a process on a developer machine reports the loopback -// port it actually listens on — PORT included, since that is the port the -// listener binds — because the gateway beside it reaches it there. In a -// deployed runtime context that answer is a loopback address no peer can -// dial, and validate() refuses the boot naming the carrier. -// -// The scheme is always https: the listener is TLS-only, whatever scheme the -// carrier's value was written with. -func advertisedOrigin(ctx context.Context, self, port string, environment []string) (string, error) { - module, service := os.Getenv(resources.ModulePrefix), os.Getenv(resources.ServicePrefix) - info := &resources.EndpointInformation{Module: module, Service: service, Name: "http", API: "http"} - carrier := resources.SelfEndpointAsEnvironmentVariableKey(info) - if module != "" && service != "" { - instance, err := resources.FindSelfNetworkInstanceInEnvironmentVariables(ctx, info, environment) - switch { - case err == nil: - origin, err := httpsOrigin(instance.Address) - if err != nil { - return "", fmt.Errorf("the advertised address in %s is not one a peer can dial: %w", carrier, err) - } - return origin, nil - case !errors.Is(err, resources.ErrEndpointCarrierAbsent): - // Present and not an address. Never treated as absence: falling - // back to the listen address here is how a typo becomes a - // solution proxied to the gateway's own loopback. - return "", fmt.Errorf("the advertised address in %s cannot be read: %w", carrier, err) - } - } - // No carrier. The listen address, on the port actually bound. - host := "localhost" - if listening := hostnameOf(self); listening != "" { - host = listening - } - if port == "" { - // Nothing to report, and nothing to add: validate() refuses an - // unresolved listen port in its own words. - return "", nil - } - origin, err := httpsOrigin(net.JoinHostPort(host, port)) - if err != nil { - return "", fmt.Errorf("no advertised address in %s, and the listen address is not one a peer can dial: %w", carrier, err) - } - return origin, nil -} - -// httpsOrigin is "https://host:port" for an address written as host:port or as -// a URL, and an error for anything that is not exactly a host and a port. -// -// Strict, because this value is handed to another component to dial. A path, a -// query, userinfo, a missing port, a port that is not one, and a host that -// names no machine (the unspecified address a listener binds) are all refused -// rather than trimmed: each is a destination that is not the one somebody -// meant. -func httpsOrigin(raw string) (string, error) { - hostport := strings.TrimSpace(raw) - if strings.Contains(hostport, "://") { - u, err := url.Parse(hostport) - if err != nil { - return "", errors.New("it does not parse as a URL") - } - if u.User != nil || u.RawQuery != "" || u.Fragment != "" || strings.Trim(u.Path, "/") != "" { - return "", errors.New("it carries more than a host and a port") - } - hostport = u.Host - } - host, port, err := net.SplitHostPort(hostport) - if err != nil { - return "", errors.New("it is not a host and a port") - } - if n, err := strconv.Atoi(port); err != nil || n < 1 || n > 65535 { - return "", errors.New("its port is not a port") - } - if host == "" || strings.ContainsAny(host, "/@?# ") { - return "", errors.New("it names no host") - } - if ip := net.ParseIP(host); ip != nil && ip.IsUnspecified() { - return "", errors.New("it is the unspecified address, which names no machine") - } - return "https://" + net.JoinHostPort(host, port), nil -} - -// hostnameOf is the host of a listen address written as host:port or as a URL, -// or "" when it names none — a bare ":8080", or the unspecified address. -func hostnameOf(raw string) string { - hostport := raw - if strings.Contains(raw, "://") { - u, err := url.Parse(raw) - if err != nil { - return "" - } - hostport = u.Host - } - host, _, err := net.SplitHostPort(hostport) - if err != nil { - return "" - } - if ip := net.ParseIP(host); ip != nil && ip.IsUnspecified() { - return "" - } - return host -} - -// loopbackOrigin reports whether origin names this machine: localhost (or a -// name under .localhost) or a loopback IP. Such an origin is reachable only -// from the process's own host. -func loopbackOrigin(origin string) bool { - u, err := url.Parse(origin) - if err != nil { - return false - } - host := strings.ToLower(strings.TrimSuffix(u.Hostname(), ".")) - if host == "localhost" || strings.HasSuffix(host, ".localhost") { - return true - } - ip := net.ParseIP(host) - return ip != nil && ip.IsLoopback() -} - // listenPort is the port of this service's own endpoint address. Codefly hands a // service its own endpoint as a bare "host:port" (a rendered cell writes // "localhost:8080"), and other endpoints as URLs; url.Parse reads the bare form @@ -766,8 +447,15 @@ func hostAddress(ctx context.Context, module, service, endpoint, api string) str return address(ctx, module, service, endpoint, api) } -// resolveGateway resolves the host gateway's rest endpoint by role, independent -// of the host module's name (see hostAddress). +// resolveGateway resolves the host gateway's workload endpoint by role, +// independent of the host module's name (see hostAddress). +// +// The workload endpoint, not the public rest one: inside the cell every hop +// past the gateway is plain HTTP carried by the mesh, and the gateway serves +// its whole handler — the viewer routes this runtime calls through, and the +// mint that issues this execution's credential — on that endpoint. The public +// endpoint stops serving the mint, so a runtime pointed at it would boot and +// get 404 at the one request it cannot do without. // // One role, no fallback. It used to retry the pre-v0.0.49 `auth-sidecar` name // when the current one did not resolve, so a solution booted against either @@ -776,7 +464,7 @@ func hostAddress(ctx context.Context, module, service, endpoint, api string) str // a boot that fails naming the role. A composition that still exposes only the // old role is a composition to re-render, not a case to accommodate. func resolveGateway(ctx context.Context, module, gateway string) string { - return hostAddress(ctx, module, gateway, "rest", "rest") + return hostAddress(ctx, module, gateway, "workload", "http") } // projection is the api.consumes projection as this runtime holds it: the @@ -814,7 +502,9 @@ func newProjection(raw string) projection { // from the service-account token the platform projects for this workload (see // credential.go); the shared cluster-internal token and the per-solution // registration secret that preceded it are gone, along with the surfaces that -// accepted them. +// accepted them. Nothing here resolves an identity either: the listener is +// plain HTTP, and the cell's mesh carries transport security and destination +// identity for every hop inside it. func loadConfig(ctx context.Context, id string, initErr error) config { // Init fails for one of two reasons, and they are fixed in different // places: the carriers did not load, or the process named no module (or a @@ -846,10 +536,6 @@ func loadConfig(ctx context.Context, id string, initErr error) config { if port == "" && self != "" { port = listenPort(self) } - // Where peers reach this runtime, reported with the mint: the address - // Codefly ADVERTISES for this endpoint, never the one it listens on. See - // advertisedOrigin. - upstream, upstreamErr := advertisedOrigin(ctx, self, port, os.Environ()) // Host endpoints, resolved via the SDK (no localhost:port literals). gatewayURL := strings.TrimRight(env("GATEWAY_URL", resolveGateway(ctx, hostModule, hostGateway)), "/") @@ -858,31 +544,26 @@ func loadConfig(ctx context.Context, id string, initErr error) config { // resolve it are gone. frontendURL := strings.TrimRight(resolveFrontend(ctx, hostModule, hostFrontend), "/") - projectedTokenPath := workloadPath(ctx, ProjectedTokenFileEnvironmentVariable, WorkloadIdentityTokenFileKey) - if projectedTokenPath == "" { - projectedTokenPath = "/var/run/secrets/codefly/token" - } + // Whether a plaintext hop inside the cell is carried by the mesh, and + // whether this is a local run: the two facts the SDK's endpoint rule + // decides on. Read here, once, and answered from the snapshot wherever + // the rule asks — the rule is called at validation and at every mint, and + // a configuration value is fixed at process start anyway. + query := codefly.For(ctx) + transport := meshTransport{local: query.IsLocalRun()} + transport.value, transport.set, transport.err = query.WorkspaceValueIfSet(workcontext.MeshTransportGroup, workcontext.MeshTransportKey) - internalToken, _ := codefly.For(ctx).WorkspaceSecret("module-authority", "INTERNAL_TOKEN") + internalToken, _ := query.WorkspaceSecret("module-authority", "INTERNAL_TOKEN") cfg := config{ brokerInternalToken: strings.TrimSpace(internalToken), port: port, gatewayURL: gatewayURL, assetsDir: env("ASSETS_DIR", "../fe-remote/dist"), - // The broker path is published, but its origin remains explicit. - mintURL: strings.TrimSpace(env(CredentialMintURLEnvironmentVariable, "")), - upstream: upstream, - upstreamErr: upstreamErr, - dialled: dialledAddresses(gatewayURL, strings.TrimSpace(env(CredentialMintURLEnvironmentVariable, ""))), - projectedTokenPath: projectedTokenPath, - identityCertFile: workloadPath(ctx, IdentityCertFileEnvironmentVariable, WorkloadIdentityCertFileKey), - identityKeyFile: workloadPath(ctx, IdentityKeyFileEnvironmentVariable, WorkloadIdentityKeyFileKey), - trustBundleFile: workloadPath(ctx, IdentityTrustBundleFileEnvironmentVariable, WorkloadIdentityTrustBundleFileKey), - allowedCallersFile: workloadPath(ctx, IdentityAllowedCallersFileEnvironmentVariable, WorkloadIdentityAllowedCallersFileKey), - mintPeersFile: workloadPath(ctx, IdentityMintPeersFileEnvironmentVariable, WorkloadIdentityMintPeersFileKey), - gatewayPeersFile: workloadPath(ctx, IdentityGatewayPeersFileEnvironmentVariable, WorkloadIdentityGatewayPeersFileKey), - profile: strings.TrimSpace(env(ContractProfileEnvironmentVariable, codefly.Environment())), - runtimeContext: strings.TrimSpace(env(resources.RuntimeContextPrefix, "")), + dialled: dialledAddresses(gatewayURL), + projectedTokenPath: strings.TrimSpace(env(ProjectedTokenFileEnvironmentVariable, DefaultProjectedTokenFile)), + transport: transport, + profile: strings.TrimSpace(env(ContractProfileEnvironmentVariable, codefly.Environment())), + runtimeContext: strings.TrimSpace(env(resources.RuntimeContextPrefix, "")), // Carried here, which the refactor that changed this function's // signature dropped: three refusals and a boot log exist to tell an // operator "the SDK resolved nothing" apart from "loading the injected @@ -893,24 +574,12 @@ func loadConfig(ctx context.Context, id string, initErr error) config { environmentLoadErr: environmentLoadErr, consumerIdentityErr: consumerIdentityErr, } - // Two sources for one authorization fact are refused, not ranked. - // - // Every other path here takes the override first and the provisioned value - // second, which is right for a path: an operator with a deployment the - // resolver cannot see needs the escape hatch, and the worst case is this - // process reading its own material from somewhere else. Admission is not - // that. An override that silently outranks the platform's answer is how a - // caller set gets widened with nothing recording that the platform's - // decision was not the one in force — and logging which one won, which is - // what the previous revision did, reports the conflict without resolving - // it. - // - // So: either source may answer, and both answering is an error naming - // both. It is the stance the SDK already takes on the same question — a - // value delivered inline and by file carrier is "two sources for one fact - // and are refused" (file_carrier.go) — applied to the one kind of value - // where being wrong admits a caller. - cfg.admissionConflict = conflictingAdmissionSources(ctx, environmentLoadErr) + // The mint is a published route on the gateway this process already + // resolved: the workload endpoint serves the gateway's whole handler. One + // address resolved, one origin dialled; the path is the host's contract. + if gatewayURL != "" { + cfg.mintURL = gatewayURL + credentialMintPath + } // The host is the authorization server an MCP client authenticates against, // so its issuer is the host's own origin — resolved by role like every // other host endpoint, never typed. The resolved address is the one this @@ -924,133 +593,33 @@ func loadConfig(ctx context.Context, id string, initErr error) config { return cfg } -// conflictingAdmissionSources is the error validate() reports when any value in -// the identity group is answered by both an operator override and the -// platform's provisioning. -// -// All of them, not only the two admission sets. The override that outranks the -// platform silently was found first on the caller set, and the same argument -// reaches the rest of the group: a substituted *trust bundle* widens admission -// exactly as much as a substituted caller list, and a substituted key pair -// changes who this process is. The two sets were simply where it was noticed. -func conflictingAdmissionSources(ctx context.Context, loadErr error) error { - for _, admission := range []struct{ what, override, key string }{ - {"allowed callers", IdentityAllowedCallersFileEnvironmentVariable, WorkloadIdentityAllowedCallersFileKey}, - {"credential mint peer identity", IdentityMintPeersFileEnvironmentVariable, WorkloadIdentityMintPeersFileKey}, - {"gateway peer identity", IdentityGatewayPeersFileEnvironmentVariable, WorkloadIdentityGatewayPeersFileKey}, - {"peer trust anchor", IdentityTrustBundleFileEnvironmentVariable, WorkloadIdentityTrustBundleFileKey}, - {"workload identity certificate", IdentityCertFileEnvironmentVariable, WorkloadIdentityCertFileKey}, - {"workload identity private key", IdentityKeyFileEnvironmentVariable, WorkloadIdentityKeyFileKey}, - {"projected service-account token", ProjectedTokenFileEnvironmentVariable, WorkloadIdentityTokenFileKey}, - } { - overridden := strings.TrimSpace(env(admission.override, "")) - if overridden == "" { - continue - } - provisioned, err := codefly.For(ctx).WorkspaceConfiguration(WorkloadIdentityGroup, admission.key) - if err != nil { - // An error here is NOT evidence of a second answer, and that is - // the awkward part: the SDK returns the same "no workspace - // configuration value" whether the key was never provisioned or - // the carriers holding it never loaded. So this cannot refuse on - // the error alone — doing that refuses every legitimate override, - // which is what it did when first written. - // - // The one signal that separates them is whether the environment - // loaded at all, which is why loadConfig keeps that error. With a - // clean load, an error means genuinely absent and the override - // stands alone. With a failed load, nothing the SDK says is - // absent can be trusted to be absent, and an override put in force - // against provisioning this process could not see is the conflict - // the rule exists to refuse. - if loadErr != nil { - return fmt.Errorf("the %s is overridden by %s while loading Codefly's injected environment failed (%w), so the platform's own answer for %s/%s cannot be read: this process will not put an override in force against provisioning it cannot see, because the two may disagree and the one it ignored would be the decision nobody applied. Fix the environment load, or unset the override", - admission.what, admission.override, loadErr, WorkloadIdentityGroup, admission.key) - } - continue - } - if strings.TrimSpace(provisioned) == "" { - continue - } - return fmt.Errorf("the %s is answered twice: %s names %q and the platform provisioned %s/%s as %q. A value this process is held to with two sources is refused rather than ranked — whichever it picked, the other is a decision somebody made that is not in force. Unset the override, or remove the provisioning", - admission.what, admission.override, overridden, WorkloadIdentityGroup, admission.key, strings.TrimSpace(provisioned)) - } - return nil +// meshTransport is the SDK's TransportSource answered from values read once at +// boot, in loadConfig, rather than from the SDK at the moment the rule asks. +// +// A snapshot is the right shape twice over. The value is a composition +// setting, fixed at process start like every other configuration value, so +// re-reading it answers the same thing. And every environment read in this +// package sits in loadConfig's call tree, where validate() governs the result +// — the rule is asked at validation and again inside every mint, and a live +// accessor captured there would be a read after boot that nothing judges. +type meshTransport struct { + // value, set and err are internal-transport/mesh-protected exactly as the + // SDK answered it, so the SDK's own reading of the assertion (only the + // literal "true" asserts; any other value is an error) is the one in force. + value string + set bool + err error + local bool } -// WorkloadIdentityGroup is the workspace configuration group through which the -// platform tells this workload where its own identity material lives: the -// projected service-account token it mints its credential with, the X.509-SVID -// key pair its listener presents, and the anchor its peers are verified -// against. -// -// Paths, never material, and no exception: every value this group carries is a -// file path, and the file behind it is read by this process — the token on every -// renewal, the key pair and the anchor on every handshake, the two admission -// sets on every handshake and dial. The SDK's own value accessors would be the -// wrong tool for any of it: a file-carried configuration value is read once and -// kept for the life of the process (sdk-go file_carrier.go), which is exactly -// right for a configuration value and exactly wrong for anything the platform -// changes under a running process. -// -// The two admission sets were the exception, carried as values here and -// re-resolved through that accessor on every handshake, which looked live and -// was not: the accessor returns what the process started with, so revoking a -// caller took a restart while the code and the README both said it did not. -// Material the platform can change is a path in this group or it is a snapshot -// pretending otherwise. -const WorkloadIdentityGroup = "workload-identity" - -// The keys of WorkloadIdentityGroup. Each has an explicit environment override -// below, for an operator with a projection the resolver cannot see. -const ( - WorkloadIdentityTokenFileKey = "TOKEN_FILE" - WorkloadIdentityCertFileKey = "CERT_FILE" - WorkloadIdentityKeyFileKey = "KEY_FILE" - WorkloadIdentityTrustBundleFileKey = "TRUST_BUNDLE_FILE" - // WorkloadIdentityAllowedCallersFileKey names the *file* listing the - // identities allowed to call this solution. See allowedCallersFile for why - // it is a path and not the list: a provisioned value is fixed at process - // start, and an admission decision that cannot narrow while the process - // runs is a decision this listener would keep honouring after it was - // revoked. - WorkloadIdentityAllowedCallersFileKey = "ALLOWED_CALLERS_FILE" - // WorkloadIdentityMintPeersFileKey and - // WorkloadIdentityGatewayPeersFileKey name the *files* listing the - // identities this runtime will present its credentials to, one per - // destination. Paths, for the reason above. - // - // Two keys rather than one, because the mint and the gateway are two - // parties and a single set lets each stand in for the other at the other's - // address. - WorkloadIdentityMintPeersFileKey = "MINT_PEERS_FILE" - WorkloadIdentityGatewayPeersFileKey = "GATEWAY_PEERS_FILE" -) - -// The environment overrides for the paths above. Each names a file, and -// loadConfig logs which source answered for the two admission paths — an -// override that quietly outranked the platform's provisioning was the one way -// an operator could widen admission without it appearing anywhere. -const ( - ProjectedTokenFileEnvironmentVariable = "CODEFLY__WORKLOAD_TOKEN_FILE" - IdentityCertFileEnvironmentVariable = "CODEFLY__WORKLOAD_IDENTITY_CERT_FILE" - IdentityKeyFileEnvironmentVariable = "CODEFLY__WORKLOAD_IDENTITY_KEY_FILE" - IdentityTrustBundleFileEnvironmentVariable = "CODEFLY__WORKLOAD_IDENTITY_TRUST_BUNDLE_FILE" - // IdentityMintPeersFileEnvironmentVariable and - // IdentityGatewayPeersFileEnvironmentVariable override the files listing - // the identities this runtime accepts at each destination. - IdentityMintPeersFileEnvironmentVariable = "CODEFLY__WORKLOAD_IDENTITY_MINT_PEERS_FILE" - IdentityGatewayPeersFileEnvironmentVariable = "CODEFLY__WORKLOAD_IDENTITY_GATEWAY_PEERS_FILE" - // IdentityAllowedCallersFileEnvironmentVariable overrides the file listing - // the identities allowed to call this solution. - IdentityAllowedCallersFileEnvironmentVariable = "CODEFLY__WORKLOAD_IDENTITY_ALLOWED_CALLERS_FILE" -) +func (t meshTransport) WorkspaceValueIfSet(name, key string) (string, bool, error) { + if name != workcontext.MeshTransportGroup || key != workcontext.MeshTransportKey { + return "", false, fmt.Errorf("%s/%s is not a transport value this process read at boot", name, key) + } + return t.value, t.set, t.err +} -// CredentialMintURLEnvironmentVariable is the host endpoint this runtime mints -// its execution credential at. Required: there is no default, because the -// address this runtime sends its projected service-account token to is not an -// address it may assume. -const CredentialMintURLEnvironmentVariable = "CODEFLY__CREDENTIAL_MINT_URL" +func (t meshTransport) IsLocalRun() bool { return t.local } // ContractProfileEnvironmentVariable overrides the configuration profile the // published contract is read under. Unset, it is the Codefly environment's own @@ -1060,28 +629,22 @@ const CredentialMintURLEnvironmentVariable = "CODEFLY__CREDENTIAL_MINT_URL" // machine's — the gap codefly-dev/core#687 named and core v0.7.1 closed. const ContractProfileEnvironmentVariable = "CODEFLY__CONTRACT_PROFILE" -// credentialMintPath is the host's published module-broker execution endpoint. -// The origin remains explicitly resolved, never inferred from a solution name. +// credentialMintPath is the host's published module-broker execution endpoint, +// served on the gateway's workload endpoint beside every other gateway route. +// The origin is the resolved gateway's; only the path is this runtime's +// knowledge of the host, and it is the host's published contract. const credentialMintPath = "/modules/_work-context" -// workloadPath resolves one of the identity paths above: an explicit -// environment override first, then the workspace configuration group the -// platform populates. Empty means neither resolved, which validate() reports by -// name — there is no default, because a default would be a path that is true on -// one platform's pod spec and nowhere else. -func workloadPath(ctx context.Context, override, key string) string { - if value := strings.TrimSpace(env(override, "")); value != "" { - return value - } - // The SDK's error is dropped because it carries no information: - // WorkspaceConfiguration returns the same "no workspace configuration - // value" error whether the group was never declared or the carriers it - // lives in never loaded. The one signal that does distinguish them is - // whether loading the environment failed at all, which Serve records in - // config.environmentLoadErr for validate() to report. - value, _ := codefly.For(ctx).WorkspaceConfiguration(WorkloadIdentityGroup, key) - return strings.TrimSpace(value) -} +// ProjectedTokenFileEnvironmentVariable overrides where this workload's +// projected service-account token is read from, for an operator with a +// projection the resolver cannot see. DefaultProjectedTokenFile is the CLI's +// projection, which every Codefly-managed deployment mounts; the file is a +// path this process reads at every mint, never a value, because the +// projection is rotated under the running process. +const ( + ProjectedTokenFileEnvironmentVariable = "CODEFLY__WORKLOAD_TOKEN_FILE" + DefaultProjectedTokenFile = "/var/run/secrets/codefly/token" +) // validate rejects a config the runtime cannot actually serve under. When // neither the SDK nor an explicit env override resolves a value, loadConfig @@ -1098,12 +661,6 @@ func workloadPath(ctx context.Context, override, key string) string { // failed first", because one generic message sent an operator to inspect // endpoint resolution over a variable they had broken themselves. func (c config) validate() error { - // First, because it is the one refusal that says a decision somebody made - // is not the one in force, and every other message below would read as - // ordinary provisioning advice next to it. - if c.admissionConflict != nil { - return c.admissionConflict - } // Before any endpoint: with no consumer pinned, every one of them is empty // for that reason and not for the one its own refusal would name. if c.consumerIdentityErr != nil { @@ -1113,85 +670,31 @@ func (c config) validate() error { if p, err := strconv.Atoi(c.port); err != nil || p < 1 || p > 65535 { return fmt.Errorf("unresolved listen port %q: set PORT or ensure the SDK resolves this service's http endpoint", c.port) } - // Where the gateway is told to dial this runtime. Checked at boot because - // it is reported with the mint, where the host could only store it: a - // wrong origin is a solution that installs and then answers nothing. - if c.upstreamErr != nil { - if deployedRuntimeContext(c.runtimeContext) { - return fmt.Errorf("no origin to report to the host in the deployed runtime context %q: %w — Codefly's render injects the address peers reach this service at as %s______HTTP__HTTP, and a deployed runtime never reports a loopback address it only listens on", - c.runtimeContext, c.upstreamErr, resources.SelfEndpointPrefix) - } - return fmt.Errorf("no origin to report to the host: %w", c.upstreamErr) - } - // A loopback origin reported from a deployment is reachable by no peer: - // the gateway, dialling its own localhost, would proxy this solution to - // itself. - if deployedRuntimeContext(c.runtimeContext) && loopbackOrigin(c.upstream) { - return fmt.Errorf("the origin this runtime would report to the host, %q, is a loopback address in the deployed runtime context %q: the gateway would proxy this solution to itself. Codefly's render injects the reachable address as %s______HTTP__HTTP", - c.upstream, c.runtimeContext, resources.SelfEndpointPrefix) - } - // Both of these carry credentials: the mint presents the projected token - // that attests which workload this process is, and the gateway carries the - // viewer's bearer and the capability minted for them. So each has to be - // resolved *and* has to be a destination this process can authenticate and - // cannot be read off the wire. - for _, required := range []struct{ name, value, override string }{ - {"gateway URL", c.gatewayURL, "GATEWAY_URL"}, - {"credential mint URL", c.mintURL, CredentialMintURLEnvironmentVariable}, - } { - u, err := url.Parse(required.value) - if err != nil || !u.IsAbs() || u.Host == "" { - if c.environmentLoadErr != nil { - return fmt.Errorf("unresolved %s %q, and loading Codefly's injected environment failed first: %w — nothing the SDK resolves can be trusted to be absent until that is fixed", - required.name, redactedURL(required.value), c.environmentLoadErr) - } - if required.name == "credential mint URL" { - return fmt.Errorf("no credential mint URL resolved: this runtime POSTs the projected service-account token that attests which workload it is to this address, so there is no default for it — a path assumed on the resolved gateway would be sending that attestation to an endpoint nobody published. Set %s to the endpoint this host actually serves. The fix that removes the override is the host publishing its mint as a resolvable endpoint role, which belongs to the host and the SDK rather than here", - CredentialMintURLEnvironmentVariable) - } - return fmt.Errorf("unresolved %s %q: the SDK could not resolve the host endpoint and no explicit override was set", required.name, redactedURL(required.value)) - } - // Plaintext is refused rather than warned about. A TLS listener - // protects what callers send *to* this process and nothing this - // process sends out: over http://, the mint hands this workload's - // projected service-account token to anything on the path, and a - // viewer's mint hands over their bearer and the capability minted for - // them. The listener being TLS-only made the inbound hop safe and left - // the outbound ones exactly as they were. - if u.Scheme != "https" { - // Redacted here too: a refusal is a log line like any other, and - // the URL being refused is exactly the one most likely to carry - // something that should not be written down. - return fmt.Errorf("%s %q is not https: it carries %s, so a plaintext hop hands them to anything on the path. Set %s to an https destination", - required.name, redactedURL(required.value), credentialsCarriedOn(required.name), required.override) - } - // And nothing but a destination. These three were accepted, and the - // first two were then written to the log at every boot: - // - // - userinfo (https://ops:s3cr3t@mint.cell/…) is a credential in a - // URL, which net/http also strips from the request it sends, so it - // is a secret that is logged and never used; - // - a query (…?token=s3cr3t) is the same shape, and it travels; - // - a fragment is never sent at all, so a destination carrying one - // is not the destination somebody meant. - // - // A boot refusal is the right answer rather than quietly trimming - // them: an operator who put a token in this URL believes it is doing - // something. - switch { - case u.User != nil: - return fmt.Errorf("%s carries userinfo: credentials in a URL are a secret that this runtime would log at boot and net/http would strip from the request, so it is disclosed and never used. Put the host in %s and nothing else", - required.name, required.override) - case u.RawQuery != "": - return fmt.Errorf("%s carries a query string: this address is dialled, not templated, and anything secret in a query is logged by every hop that sees the URL. Put the host and path in %s and nothing else", - required.name, required.override) - case u.Fragment != "": - return fmt.Errorf("%s carries a fragment: a fragment is never sent, so this is not the destination it appears to be. Set %s to the address the host actually serves", - required.name, required.override) - } - } - if u, _ := url.Parse(c.mintURL); u.Path != credentialMintPath || u.RawPath != "" { - return fmt.Errorf("%s must name the broker endpoint /modules/_work-context", CredentialMintURLEnvironmentVariable) + // The gateway carries credentials: the mint presents the projected token + // that attests which workload this process is, and every viewer call + // carries the viewer's bearer and the capability minted for them. So it + // has to be resolved, and it has to be a destination those may travel to. + if u, err := url.Parse(c.gatewayURL); err != nil || !u.IsAbs() || u.Host == "" { + if c.environmentLoadErr != nil { + return fmt.Errorf("unresolved gateway URL %q, and loading Codefly's injected environment failed first: %w — nothing the SDK resolves can be trusted to be absent until that is fixed", + redactedURL(c.gatewayURL), c.environmentLoadErr) + } + return fmt.Errorf("unresolved gateway URL %q: the SDK could not resolve the host gateway's workload endpoint and no explicit GATEWAY_URL was set — declare the host's auth-gateway service, endpoint workload, as a service dependency of this backend", + redactedURL(c.gatewayURL)) + } + // Whether those credentials may travel to it is the SDK's rule, not one of + // this package's: https anywhere; http only to a loopback host on a local + // run, or to an in-cluster Service address when the composition asserts + // internal-transport/mesh-protected=true. It is the rule the composed + // modules apply to their own gateway value and the one the mint client + // applies to the same origin at every mint, so a plaintext gateway is + // refused here, at boot, by the same words that would refuse the mint. + // Userinfo, a query and a fragment are refused by it too: a credential in + // a URL is a secret this runtime would log at boot and net/http would + // strip from the request, so it is disclosed and never used. + if _, err := workcontext.AdmitEndpoint(c.gatewayURL, c.transport, "gateway"); err != nil { + return fmt.Errorf("gateway URL %q is not a destination the projected service-account token, the viewer's bearer and the capability minted for them may travel to: %w — set GATEWAY_URL, or the composition's gateway endpoint, to one that is", + redactedURL(c.gatewayURL), err) } if c.brokerInternalToken == "" { return fmt.Errorf("missing module-authority/INTERNAL_TOKEN: provision the broker perimeter credential as a workspace secret") @@ -1233,15 +736,11 @@ func (c config) logResolved() { // surprise. log.Printf("codefly: the injected environment did not load cleanly; every SDK-resolved value that is present came from an override: %v", c.environmentLoadErr) } - // Said at every boot: the operator set this address by hand, because - // nothing resolves it yet, so a 404 here is the endpoint not existing on - // this host rather than this build being refused. - // - // Redacted, always. validate() refuses userinfo and a query, so there + // Redacted, always. The SDK's rule refuses userinfo and a query, so there // should be nothing to hide — and a log line is the wrong place to depend // on a check that runs elsewhere. - log.Printf("codefly: this execution's credential will be minted at %q, which was set explicitly through %s — no resolver produces it yet, so a 404 here means that endpoint does not exist on this host, not that this build was refused.", - redactedURL(c.mintURL), CredentialMintURLEnvironmentVariable) + log.Printf("codefly: this execution's credential will be minted at %q, the host's broker route on the gateway's workload endpoint; a 404 there means this host does not serve its gateway's full handler on that endpoint, not that this build was refused", + redactedURL(c.mintURL)) } // redactedURL is a URL as it may appear in a log: url.Redacted() replaces a @@ -1276,344 +775,30 @@ func redactedURL(raw string) string { return parsed.String() } -// credentialsCarriedOn names what a plaintext destination would disclose, so -// the refusal says why https is not a preference. -func credentialsCarriedOn(name string) string { - if name == "credential mint URL" { - return "the service-account token the platform projects for this workload" - } - return "the viewer's bearer and the capability minted on their behalf" -} - -// validateSources checks the material the *selected* sources need, which is not -// the same set for every boot. -// -// A consumer that supplied an IdentitySource has said where its identity comes -// from — a mesh CA, a SPIFFE workload API — and requiring the projected file -// paths from it as well would be requiring provisioning nothing reads. The same -// goes for a credential source and the projected token. What no source changes -// is the authority identity: the principal, the mint audience and the -// projection audience are read once and frozen on every path, because the -// published contract names the principal and the listener is held to it, and a -// hook is not a way out of either. +// validateSources checks the material the *selected* source needs: a consumer +// that supplied its own credential source has said where its credential comes +// from, and requiring the projected token from it as well would be requiring +// provisioning nothing reads. What no source changes is the authority identity: +// the principal, the mint audience and the projection audience are read once +// and frozen on every path, because the published contract names the +// principal, and a hook is not a way out of that. func (s *Server) validateSources() error { - if s.identity == nil { - for _, path := range []struct{ name, value, override, key string }{ - {"workload identity certificate", s.cfg.identityCertFile, IdentityCertFileEnvironmentVariable, WorkloadIdentityCertFileKey}, - {"workload identity private key", s.cfg.identityKeyFile, IdentityKeyFileEnvironmentVariable, WorkloadIdentityKeyFileKey}, - {"workload identity trust anchor", s.cfg.trustBundleFile, IdentityTrustBundleFileEnvironmentVariable, WorkloadIdentityTrustBundleFileKey}, - } { - if err := s.cfg.requirePath(path.name, path.value, path.override, path.key); err != nil { - return err - } - } - } - if s.credential == nil { - if err := s.cfg.requirePath("projected service-account token", s.cfg.projectedTokenPath, - ProjectedTokenFileEnvironmentVariable, WorkloadIdentityTokenFileKey); err != nil { - return err - } - } - // Who may call is required whatever the identity source is, because it is - // not a property of the source: the source says who this workload *is*, and - // this says which callers it serves. - if err := s.cfg.requirePath("allowed caller identities", s.cfg.allowedCallersFile, - IdentityAllowedCallersFileEnvironmentVariable, WorkloadIdentityAllowedCallersFileKey); err != nil { - return fmt.Errorf("%w — this listener verifies a caller's certificate against the cell's trust anchor, which every workload in the cell holds one from, so without this it authenticates callers and authorizes all of them, including the modules this solution consumes, which could then set their own %s and %s and drive mints carrying this workload's attestation", - err, orgHeader, sessionHeader) - } - // And whom this runtime may present its credentials to, for the same - // reason in the other direction. It is required on every path, including a - // supplied identity source: a source says who this workload is, not which - // destinations are the platform. - for _, peers := range []struct{ name, value, override, key string }{ - {"credential mint peer identity", s.cfg.mintPeersFile, IdentityMintPeersFileEnvironmentVariable, WorkloadIdentityMintPeersFileKey}, - {"gateway peer identity", s.cfg.gatewayPeersFile, IdentityGatewayPeersFileEnvironmentVariable, WorkloadIdentityGatewayPeersFileKey}, - } { - if err := s.cfg.requirePath(peers.name, peers.value, peers.override, peers.key); err != nil { - return fmt.Errorf("%w — this runtime presents the projected service-account token, the viewer's bearer and its own credential to these destinations, and verifying their chain and hostname says only that a destination holds a certificate this cell issued, which every workload in the cell does. Each destination has its own set, so neither party can stand in for the other at the other's address", - err) - } - } - // And both files have to be readable and name somebody *now*. The per-use - // readers refuse a handshake over an unreadable set, which is the right - // answer while serving and the wrong first symptom at boot: a path nobody - // provisioned would otherwise start a listener that refuses every caller - // and a client that refuses every dial, and report it as an admission - // failure once traffic arrived rather than as the provisioning gap it is. - for _, set := range []struct { - what string - admits func() ([]string, error) - }{ - {"allowed callers", s.admittedCallers()}, - {"credential mint peer identity", s.admittedMint()}, - {"gateway peer identity", s.admittedGateway()}, - // And, when the two share an address, the set that actually governs a - // dial there: an empty intersection is a provisioning gap an operator - // can fix, and discovering it at the first mint instead would read as - // the mint being unreachable. - {"identities admitted at the address the mint and the gateway share", s.admittedAt(dialAddress(s.cfg.mintURL))}, - } { - if _, err := set.admits(); err != nil { - return fmt.Errorf("the provisioned %s are not usable at boot: %w", set.what, err) - } - } - return nil -} - -// admittedCallers resolves the identities this listener serves, for one -// handshake, failing closed. -// -// A set that resolves to nothing refuses the handshake, and so does a file that -// cannot be read. It is the same judgement peerAnchor makes about an unreadable -// trust bundle: the alternative is serving the set this process booted with, -// which is the staleness this resolution exists to remove, and an admission -// decision that cannot be resolved is not an admission decision to guess at. -func (s *Server) admittedCallers() func() ([]string, error) { - return resolvedIdentities(s.cfg.allowedCallersFile, fmt.Sprintf( - "whom this listener admits could not be resolved, so the handshake is refused rather than served against the set this process booted with. Provision %s/%s, or set %s", - WorkloadIdentityGroup, WorkloadIdentityAllowedCallersFileKey, IdentityAllowedCallersFileEnvironmentVariable)) -} - -// admittedMint and admittedGateway are admittedCallers for each of the two -// destinations this runtime dials, one authorization set each. -func (s *Server) admittedMint() func() ([]string, error) { - return resolvedIdentities(s.cfg.mintPeersFile, fmt.Sprintf( - "which identity answers for the credential mint could not be resolved, and that destination receives the projected service-account token, so the dial is refused. Provision %s/%s, or set %s", - WorkloadIdentityGroup, WorkloadIdentityMintPeersFileKey, IdentityMintPeersFileEnvironmentVariable)) -} - -func (s *Server) admittedGateway() func() ([]string, error) { - return resolvedIdentities(s.cfg.gatewayPeersFile, fmt.Sprintf( - "which identity answers for the gateway could not be resolved, and that destination receives the viewer's bearer and the capability minted for them, so the dial is refused. Provision %s/%s, or set %s", - WorkloadIdentityGroup, WorkloadIdentityGatewayPeersFileKey, IdentityGatewayPeersFileEnvironmentVariable)) -} - -// admittedAt is the authorization set for one destination address, and an error -// for an address that is neither. -// -// A dial to a third address is refused rather than held to some union: this -// runtime talks to the mint and the gateway and to nothing else, so an address -// that is neither is a misbuilt client or a redirect that got through, and -// there is no set that should admit it. -func (s *Server) admittedAt(addr string) func() ([]string, error) { - mint, gateway := dialAddress(s.cfg.mintURL), dialAddress(s.cfg.gatewayURL) - // One address for both is the brokered shape, and it is the shape this - // runtime is actually deployed in today: the mint has lived on the - // gateway's host and port. Switching on the mint first silently made every - // gateway dial answer to the mint's set, and the gateway's set was then - // validated at boot and never read again — so a gateway set naming - // somebody else let the gateway through, and rewriting it cut nothing. - // The split was not in force in the only configuration that exists. - // - // The intersection, because a dial to a shared address cannot be - // attributed: this runtime does not know whether it is talking to the mint - // or to the gateway, so the only identity it can safely present - // credentials to is one BOTH sets admit. An empty intersection admits - // nobody, which validate() reports at boot rather than leaving to the - // first dial. - switch { - case mint == gateway && addr == mint: - return intersectedIdentities(s.admittedMint(), s.admittedGateway(), addr) - case addr == mint: - return s.admittedMint() - case addr == gateway: - return s.admittedGateway() - } - return func() ([]string, error) { - return nil, fmt.Errorf("refusing to present this workload's credentials at %s: this runtime dials the credential mint (%s) and the gateway (%s) and nothing else, so an address that is neither has no authorization set and is not a destination this runtime has one for", - addr, mint, gateway) - } -} - -// intersectedIdentities is the identities both sets admit, for an address the -// two destinations share. -func intersectedIdentities(first, second func() ([]string, error), addr string) func() ([]string, error) { - return func() ([]string, error) { - mint, err := first() - if err != nil { - return nil, err - } - gateway, err := second() - if err != nil { - return nil, err - } - var both []string - for _, identity := range mint { - if slices.Contains(gateway, identity) { - both = append(both, identity) - } - } - if len(both) == 0 { - return nil, fmt.Errorf("the credential mint and the gateway are both at %s and no identity is in both provisioned sets, so there is none this runtime may present its credentials to there: a dial to a shared address cannot be attributed to one of them, so only an identity both admit is safe. Provision the party that answers there in both sets, or give them separate addresses", - addr) - } - return both, nil - } -} - -// dialAddress is the host:port a URL is dialled at, with the scheme's default -// port made explicit so it compares equal to what the dialler is handed. -// -// Host and port together, because the two destinations may well share a host -// and differ only by port — the mint has lived on the gateway's host — and a -// comparison on the host alone would then hold both to whichever set matched -// first, which is the merge this split exists to undo. -func dialAddress(raw string) string { - parsed, err := url.Parse(raw) - if err != nil || parsed.Host == "" { - return "" - } - if parsed.Port() != "" { - return parsed.Host - } - if parsed.Scheme == "http" { - return parsed.Host + ":80" - } - return parsed.Host + ":443" -} - -// resolvedIdentities reads an admission set from its file, per call. -// -// No caching and no last-good fallback, which is the opposite of how the leaf -// this workload presents is treated: a leaf that cannot be re-read keeps -// serving, because the old leaf is this workload's own identity and refusing it -// only turns away callers who should be let in. An admission set that cannot be -// re-read has no such safe direction — the set on disk may be narrower than the -// one in memory, and that is the case this reads per handshake for. -func resolvedIdentities(path, unresolved string) func() ([]string, error) { - return func() ([]string, error) { - if path == "" { - return nil, fmt.Errorf("%s", unresolved) - } - content, err := readAdmissionFile(path) - if err != nil { - return nil, fmt.Errorf("%s: reading %q: %w", unresolved, path, err) - } - identities, err := parsedIdentities(content) - if err != nil { - return nil, fmt.Errorf("%s: %q: %w", unresolved, path, err) - } - if len(identities) == 0 { - return nil, fmt.Errorf("%s: %q names none", unresolved, path) - } - return identities, nil - } -} - -// admissionFileLimit caps an admission file. -// -// A set this runtime reads on every handshake, on every dial, and once a second -// per established connection in each direction is not a file to read without a -// bound: a 10MB one measured 9.5ms per read, which is a cost an operator can -// inflict by accident and an attacker who can write that path can inflict on -// purpose. A realistic set is a handful of SPIFFE IDs. -const admissionFileLimit = 64 << 10 - -// readAdmissionFile reads an admission set with a size bound. -func readAdmissionFile(path string) (string, error) { - file, err := os.Open(path) - if err != nil { - return "", err - } - defer func() { _ = file.Close() }() - content, err := io.ReadAll(io.LimitReader(file, admissionFileLimit+1)) - if err != nil { - return "", err - } - if len(content) > admissionFileLimit { - return "", fmt.Errorf("the file is larger than %d bytes, which is not a set of identities: this is read on every handshake, every dial, and once a second per established connection", admissionFileLimit) - } - return string(content), nil -} - -// parsedIdentities is the admitted set a file names, and an error for a file -// this runtime will not guess at. -// -// Each entry must be a *complete* line, which is the part that matters and is -// not obvious. The previous reader split on whatever it was handed, so a file -// caught mid-write — the platform rewriting it without an atomic swap, an -// operator's `>` redirect — truncated an entry and the truncation was admitted -// as an identity of its own: ".../sa/gateway-internal" read as ".../sa/gateway" -// and let in a caller the set never named. A trailing newline is the only -// evidence a line is whole, so a final fragment is refused rather than taken. -// -// Each entry is then checked to be a SPIFFE ID, because that is what it is -// compared against and an entry that cannot match anything is a provisioning -// mistake this runtime should name at boot rather than carry silently. -func parsedIdentities(raw string) ([]string, error) { - if strings.HasPrefix(raw, "\ufeff") { - return nil, fmt.Errorf("the file begins with a byte-order mark, so its first entry can never match an identity") - } - if raw != "" && !strings.HasSuffix(raw, "\n") { - return nil, fmt.Errorf("the file does not end in a newline, so its last line may be a fragment of one — a set caught mid-rewrite would otherwise admit a truncated identity as an identity of its own") - } - var identities []string - for _, line := range strings.Split(raw, "\n") { - if hash := strings.Index(line, "#"); hash >= 0 { - line = line[:hash] - } - for _, entry := range strings.Split(line, ",") { - trimmed := strings.TrimSpace(entry) - if trimmed == "" { - continue - } - // Shape, not canonicalisation. An entry is matched by exact - // string equality against the peer's single URI SAN, so a - // percent-encoded, IDN, ported, userinfo-bearing or trailing-dot - // variant fails closed — it simply never matches anything. This - // refuses entries that could never match *anything at all*, which - // is a provisioning mistake worth naming at boot; it is not a - // claim that the entry is canonical, and describing it as - // "validated as a SPIFFE ID" overstated it. - if err := usableIdentity(trimmed); err != nil { - return nil, err - } - identities = append(identities, trimmed) - } - } - return identities, nil -} - -// usableIdentity refuses an entry that could never match a peer, so the refusal -// names the provisioning rather than appearing later as a caller nobody admits. -func usableIdentity(entry string) error { - for _, r := range entry { - if r < 0x20 || r == 0x7f || (r >= 0x80 && r <= 0x9f) || r == 0xfeff { - return fmt.Errorf("the entry %q contains a control character, so it can never match an identity a peer presents", entry) - } - } - parsed, err := url.Parse(entry) - if err != nil { - return fmt.Errorf("the entry %q is not a URI: an admitted identity is a SPIFFE ID, compared against the one URI SAN a peer's certificate carries", entry) - } - if parsed.Scheme != "spiffe" || parsed.Host == "" || parsed.Path == "" { - return fmt.Errorf("the entry %q is not a SPIFFE ID (spiffe:///): it is compared against the one URI SAN a peer's certificate carries, so an entry of another shape admits nobody and is a provisioning mistake rather than a narrower set", entry) + if s.credential != nil { + return nil } - return nil -} - -// requirePath refuses an unresolved path, naming the override and the group the -// platform provisions it through — and saying so differently when the -// environment never loaded, because sending an operator to provision something -// that is already provisioned is the worse of the two mistakes. -func (c config) requirePath(name, value, override, key string) error { - if value != "" { + if s.cfg.projectedTokenPath != "" { return nil } - if c.environmentLoadErr != nil { - return fmt.Errorf("no %s path resolved, and loading Codefly's injected environment failed first: %w — the projection may well be in place; fix the environment load before treating this as missing provisioning", - name, c.environmentLoadErr) + if s.cfg.environmentLoadErr != nil { + return fmt.Errorf("no projected service-account token path resolved, and loading Codefly's injected environment failed first: %w — the projection may well be in place; fix the environment load before treating this as missing provisioning", + s.cfg.environmentLoadErr) } - return fmt.Errorf("no %s path resolved: this listener presents this workload's X.509-SVID and requires its callers to present theirs, and there is no plain-HTTP listener, so this is required. Set %s, or have the platform provision %s/%s and declare that group as a workspace-configuration dependency of this backend", - name, override, WorkloadIdentityGroup, key) + return fmt.Errorf("no projected service-account token path resolved: this process presents that token to the host to obtain its one execution credential, so it is required. The platform projects it at %s; %s overrides the path", + DefaultProjectedTokenFile, ProjectedTokenFileEnvironmentVariable) } // New starts a solution builder for the given manifest. func New(manifest Manifest) *Server { - if manifest.ExposedModule == "" { - manifest.ExposedModule = "./Page" - } if manifest.Contract == "" { // The solution's own id, which is what the field's documentation has // always promised. It used to default to a product's name — a literal @@ -1640,7 +825,8 @@ func (s *Server) HandleRequest(path string, handler RequestHandler) *Server { } // Serve resolves this process's configuration, obtains the one credential this -// execution holds, and blocks serving the solution over TLS. +// execution holds, and blocks serving the solution over plain HTTP on the port +// Codefly resolved — the cell's mesh carries the hop. // // It registers nothing. Presence and authority are delivered to the host; this // runtime's only outbound act is the single mint below, and the only thing it @@ -1656,16 +842,21 @@ func (s *Server) Serve() error { // start is the whole boot up to the listener: the declaration checks, the // resolved configuration, the published contract, the one mint, and the -// listener that presents this workload's identity. Split from Serve so a test +// listener on the resolved port. Split from Serve so a test // boots the real thing on an ephemeral port — every refusal below is a refusal // of a real boot and not of a path only tests take. func (s *Server) start(ctx context.Context) (net.Listener, error) { - // Before anything the environment owns: a surface or a contract the author - // declared wrong is wrong in every environment, and saying so first keeps - // that mistake from reading as one more unresolved address. - if err := s.manifest.validateSurfaces(); err != nil { + // Before anything the environment owns: a declaration the author got wrong + // is wrong in every environment, and saying so first keeps that mistake + // from reading as one more unresolved address. + if err := s.manifest.validateID(); err != nil { + return nil, fmt.Errorf("solution %q: %w", s.manifest.ID, err) + } + frontend, err := parseFrontend(s.manifest.Frontend) + if err != nil { return nil, fmt.Errorf("solution %q: %w", s.manifest.ID, err) } + s.frontend = frontend if err := s.validateMCPDeclaration(); err != nil { return nil, fmt.Errorf("solution %q: %w", s.manifest.ID, err) } @@ -1708,32 +899,10 @@ func (s *Server) start(ctx context.Context) (net.Listener, error) { if err := s.openAuthority(ctx); err != nil { return nil, fmt.Errorf("solution %q: %w", s.manifest.ID, err) } - // This workload's identity, resolved and held to every posture rule — - // including that the leaf is the principal the platform provisioned — - // before the first outbound act. Two bugs lived in the four lines this - // replaces. - // - // It was conditional: `if s.credential == nil || s.identity == nil`. With - // *both* sources supplied the client was never built, so s.outbound stayed - // nil and every gateway call fell through to the unauthenticated transport - // — system roots, no client certificate — which is the one posture this - // cutover exists to remove, reached by supplying more configuration rather - // than less. And with only Identity() supplied, the condition was true, so - // the client read the projected files validateSources had just declared - // unnecessary for that boot: the README's own example could not start. - // - // It also ran after the mint. A leaf issued for a neighbouring workload had - // therefore already been presented to the host, with this workload's - // projected token, by the time listen() refused it. - identityConfig, err := s.serverIdentity() - if err != nil { - return nil, fmt.Errorf("solution %q: %w", s.manifest.ID, err) - } - outbound, err := s.outboundClient(identityConfig) - if err != nil { - return nil, fmt.Errorf("solution %q: %w", s.manifest.ID, err) - } - s.outbound = outbound + // The client every platform request goes out on: non-proxied, pinned to + // the gateway's origin, following no redirect. Built before the first + // outbound act so the mint and every viewer call share it. + s.outbound = s.outboundClient() // The contract this solution publishes is resolved from the same // declaration, under the profile this process runs in, and every value it // requires is named in the refusal when it is absent. @@ -1784,20 +953,13 @@ func (s *Server) serve(ctx context.Context, ln net.Listener) error { return err } - watch, err := s.watchInboundTrust() - if err != nil { - return err - } srv := &http.Server{ Handler: mux, - // See inboundHandshakeTimeout and inboundIdleTimeout: with all of - // these unset, a peer that never finishes its ClientHello held a - // goroutine and a descriptor for as long as it liked. - ReadHeaderTimeout: inboundHandshakeTimeout, + // See inboundHeaderTimeout and inboundIdleTimeout: with both unset, a + // peer that connects and never sends a request line holds a goroutine + // and a descriptor for as long as it likes. + ReadHeaderTimeout: inboundHeaderTimeout, IdleTimeout: inboundIdleTimeout, - // And every served connection is held to trust as it is now, not as - // it was when the handshake happened. - ConnState: watch, } go func() { select { @@ -1825,6 +987,36 @@ func (s *Server) serve(ctx context.Context, ln net.Listener) error { return nil } +// listen binds the resolved port, or serves on the listener a test handed over +// (see boundListener). +// +// Plain TCP. Inside the cell the mesh carries transport security and +// destination identity for every hop, and admits this port only from the +// gateway's principal — so there is no certificate to present and no caller +// to verify here, and a listener that did either would be a second copy of a +// decision the cell makes for every workload. What this process still decides +// is what a caller may do once admitted, which the stamped identity headers +// and this execution's credential govern route by route. +func (s *Server) listen() (net.Listener, error) { + if s.boundListener != nil { + return s.boundListener, nil + } + ln, err := net.Listen("tcp", ":"+s.cfg.port) + if err != nil { + return nil, fmt.Errorf("listen on :%s: %w", s.cfg.port, err) + } + return ln, nil +} + +// inboundHeaderTimeout bounds how long a connection may take to deliver a +// request's headers, and inboundIdleTimeout how long a keep-alive connection +// may sit between requests. Neither bounds a whole request, because a declared +// long-running stream is conforming traffic. +const ( + inboundHeaderTimeout = 10 * time.Second + inboundIdleTimeout = 30 * time.Second +) + // handleHealth answers the host's probe with what this process can actually do. // // It was an unconditional 200, which is only right while the one thing this @@ -2339,65 +1531,12 @@ func (s *Server) credentialRefusedC() <-chan struct{} { return s.terminal } // healthy, and nothing this process says can make it present. const HealthPath = "/health" -// solutionManifestSchemaMajor and solutionHostContractMajor are the majors -// this runtime's manifest and page contract are built against. The host checks -// both before activating a remote, and treats a silent manifest as 1 — so these -// say out loud what silence would assert, and become the place to bump when the -// runtime moves to a newer host contract. -const ( - solutionManifestSchemaMajor = 1 - solutionHostContractMajor = 1 -) - -func (s *Server) manifestMap() map[string]any { - m := map[string]any{ - "id": s.manifest.ID, - "schemaVersion": solutionManifestSchemaMajor, - "nav": map[string]any{"title": s.manifest.Title, "path": "/s/" + s.manifest.ID, "order": s.manifest.Order}, - "frontend": map[string]any{ - "type": "module-federation", - "manifestUrl": s.frontendManifestURL(), - "exposedModule": s.manifest.ExposedModule, - "hostContract": solutionHostContractMajor, - "reactRange": "^19", - }, - "backend": map[string]any{"serviceAlias": s.manifest.ID, "capabilityPath": "/.well-known/capabilities"}, - } - // Only emit the slot when declared: a null dashboard would break a host that - // feeds a present slot straight into its data-graph validator, and it keeps - // the wire byte-identical for solutions that declare no dashboard. - if s.manifest.Dashboard != nil { - m["dashboard"] = s.manifest.Dashboard - } - if len(s.manifest.Surfaces) > 0 { - surfaces := make([]any, 0, len(s.manifest.Surfaces)) - for _, surface := range s.manifest.Surfaces { - entry := map[string]any{ - "id": surface.ID, - "client": surface.Client, - "title": surface.Title, - "module": surface.Module, - "contract": surface.Contract, - "applies": surface.Applies, - } - if surface.Applies == nil { - entry["applies"] = surfaceAppliesAlways - } - // Absent, these two say something a client can act on — no - // description to show, nothing to reconcile on — where a null would - // only be a slot the client still has to interpret. - if surface.Description != "" { - entry["description"] = surface.Description - } - if len(surface.Events) > 0 { - entry["events"] = surface.Events - } - surfaces = append(surfaces, entry) - } - m["surfaces"] = surfaces - } - return m -} +// solutionManifestSchemaMajor is the major of the manifest document this +// runtime serves. The host checks it before activating a remote and treats a +// silent manifest as 1, so this says out loud what silence would assert. The +// host contract major beside it is the author's to declare, in the frontend +// file: it is the page that is built against a host contract, not this runtime. +const solutionManifestSchemaMajor = 1 // assetsHandler serves the frontend build under /assets/ (the prefix already // stripped): from Manifest.Assets when the solution ships one, else from the @@ -2491,15 +1630,15 @@ func (s *Server) handleManifest(w http.ResponseWriter, _ *http.Request) { } func (s *Server) handleCapabilities(w http.ResponseWriter, _ *http.Request) { - // The same majors the registration manifest declares, from the same - // constants. Hardcoded here, a bump of either constant would leave this - // document announcing the old major while the manifest announced the new - // one, and a host that checks both would see one solution claiming two - // different contracts. + // The same majors the manifest declares, from the same places: the schema + // major from the one constant, the host contract from the declared file. + // Typed here, a bump of either would leave this document announcing the + // old major while the manifest announced the new one, and a host that + // checks both would see one solution claiming two different contracts. writeJSON(w, http.StatusOK, map[string]any{ "schemaVersion": solutionManifestSchemaMajor, "contract": s.manifest.Contract, - "contractMajor": solutionHostContractMajor, + "contractMajor": s.frontend.HostContract, "capabilities": s.manifest.Capabilities, }) } @@ -2749,12 +1888,10 @@ func (g *Gateway) platformClient(transport http.RoundTripper, timeout time.Durat // their behalf, and this workload's own credential. Every target is // composition-local, so none of it may be proxied. // -// A gateway built by a boot carries the transport that boot configured, which -// presents this workload's identity and verifies the far end against the anchor -// the platform projected. One built outside a boot — the passthrough test seam, -// a unit test — falls back to the unauthenticated non-proxied transport, and -// that is the only place it is used: there is no path on which a *booted* -// runtime dials the platform without presenting its identity. +// A gateway built by a boot carries the transport that boot configured +// (outboundClient): non-proxied, with its pooled connections bounded. One +// built outside a boot — the passthrough test seam, a unit test — falls back +// to the non-proxied default transport, and that is the only place it is used. func (g *Gateway) roundTripper() http.RoundTripper { var base http.RoundTripper = unauthenticatedTransport if g.transport != nil { @@ -2814,321 +1951,35 @@ func newPlatformTransport() *http.Transport { return transport } -// outboundClient is the client this runtime makes platform requests with: the -// credential mint, and every call a handler's gateway issues. -// -// This is the other half of refusing a plaintext destination. An https URL says -// only that the scheme is https: without a trust anchor the connection is -// verified against whatever the image's system roots happen to contain, and -// without a client certificate the far end cannot tell this workload from -// anything else that reached it. So the client presents this workload's -// X.509-SVID and verifies the far end against the anchor the platform -// projected — the same pair and the same anchor the listener uses, which is the -// point: one identity, used in both directions. -// -// It refuses to follow redirects. net/http strips only Authorization, -// WWW-Authenticate and Cookie when a redirect crosses hosts; every other header -// — the projected token on a mint, a viewer's capability on a module read — is -// copied to the new target verbatim, and a 307/308 re-sends the body with them. -// So anything able to answer with a Location would be handed the credentials -// that prove who this workload is, and the theft would look like an ordinary -// successful request. -func (s *Server) outboundClient(identityConfig *tls.Config) (*http.Client, error) { - // A fresh configuration per connection, so the anchor this runtime judges - // the platform by is re-read rather than snapshotted at boot. - // - // Judging by a stale anchor admits whoever should be refused, which is not - // symmetric with serving a stale leaf — the inbound argument, and it - // applies outbound with more force, not less. The two destinations on the - // other side of this - // client are the mint and the gateway — they receive the projected - // service-account token, the viewer's bearer and this workload's own - // credential — so a root removed from the bundle because it was compromised - // kept authenticating exactly the parties that are handed everything. - // - // Reloading per connection is the whole story only if connections do not - // outlive the reload, so established connections are re-verified too (see - // below). - // - // What is *not* rebuilt per connection is this workload's own leaf source. - // Building a whole new SDK reloader per dial, and again per recheck of - // every connection, looks like "nothing is snapshotted" and is a - // regression: NewCertificateReloader loads - // eagerly and returns the error, where a long-lived reloader swallows a - // failed re-read and keeps serving the last good pair. So a cert rotated - // non-atomically — the certificate written, the key a moment behind — - // failed every new dial, and failed every recheck, which closed - // established connections whose peers were still perfectly trusted. A - // half-written copy of this workload's own key is not a judgement about - // the peer, and the two must not share a failure path. - leaf, err := s.outboundLeaf(identityConfig) - if err != nil { - return nil, err - } - trust := s.peerTrustAnchor(identityConfig) - // Per destination, not per "platform": the authorization set is chosen by - // the address being dialled, so the mint's identity is not accepted at the - // gateway's address or the other way round, and a third address has no set - // and is refused. - newConfig := func(addr string) (*tls.Config, error) { - anchor, err := trust() - if err != nil { - return nil, err - } - config := &tls.Config{ - MinVersion: tls.VersionTLS13, - RootCAs: anchor, - GetClientCertificate: leaf, - } - // Held to the frozen principal at presentation, and pointed at the one - // identity allowed to answer at this address. Both apply to a - // consumer's source and to the projected pair: one of the two paths - // carrying a check is not the check. - if err := holdPresentedCertificate(config, s.principal); err != nil { - return nil, err - } - admitOnlyPlatform(config, s.admittedAt(addr)) - return config, nil - } - // Refused at boot rather than at the first dial: a configuration this - // runtime cannot build is a boot failure, not a mint that fails later. - if _, err := newConfig(dialAddress(s.cfg.gatewayURL)); err != nil { - return nil, err - } - +// outboundClient is the client this runtime makes platform requests with: +// every call a handler's gateway issues, and the viewer mints they run. +// +// Plain HTTP to the gateway's workload endpoint — or https, verified against +// the system roots, where GATEWAY_URL names an edge that presents a public +// certificate. Inside the cell the mesh carries transport security and the +// destination's identity, which is why there is no anchor, no client +// certificate and no admitted-peer set here any more: each was this process +// re-deciding what the cell decides for every workload. What the mesh does not +// decide is still here. Nothing is proxied (newPlatformTransport), no redirect +// is followed, and the transport refuses a destination that is not the +// gateway's origin (originPinned): a 307 to elsewhere would otherwise hand the +// viewer's bearer, the capability minted for them and this workload's own +// credential to whatever answered. +func (s *Server) outboundClient() *http.Client { transport := newPlatformTransport() - // No TLSClientConfig: every connection builds its own below, and leaving a - // snapshot here would be the bug this function exists to avoid. - transport.TLSClientConfig = nil - transport.DialTLSContext = func(ctx context.Context, network, addr string) (net.Conn, error) { - config, err := newConfig(addr) - if err != nil { - return nil, err - } - host, _, err := net.SplitHostPort(addr) - if err != nil { - return nil, err - } - config = config.Clone() - config.ServerName = host - raw, err := (&net.Dialer{Timeout: platformRequestTimeout}).DialContext(ctx, network, addr) - if err != nil { - return nil, err - } - // The handshake carries its own deadline, independent of whatever the - // caller's context says. - // - // A custom DialTLSContext takes net/http out of the handshake entirely: - // it calls customDialTLS and never reaches addTLS, so - // Transport.TLSHandshakeTimeout — inherited as 10s from - // DefaultTransport — silently does nothing, and the only bound left is - // the request context's. That is adequate for an ordinary request and - // wrong exactly where it matters: Gateway.HTTPClient carries streams - // and therefore sets no client timeout on purpose, so a dial made for - // a stream was bounded by the method's declared MaxStreamDuration (up - // to thirty minutes) or by nothing at all. A handshake is not a - // stream: it completes in milliseconds or the peer is not answering. - bound := s.handshakeTimeout - if bound == 0 { - bound = platformHandshakeTimeout - } - handshake, cancelHandshake := context.WithTimeout(ctx, bound) - defer cancelHandshake() - conn := tls.Client(raw, config) - if err := conn.HandshakeContext(handshake); err != nil { - _ = raw.Close() - return nil, err - } - // Watched on the two inputs that are a judgement about the peer — the - // anchor and the admitted set — and on nothing else. - return watchOutboundTrust(conn, host, trust, s.admittedAt(addr)), nil - } - // Pool hygiene, not the trust bound: a connection nothing sends on is - // dropped rather than held open indefinitely. What bounds the trust - // decision is the recheck above, because an inactivity timeout cannot. - transport.IdleConnTimeout = outboundTrustReloadBound + // Pool hygiene: a connection nothing sends on is dropped rather than held + // open indefinitely. + transport.IdleConnTimeout = outboundIdleTimeout return &http.Client{ Timeout: platformRequestTimeout, Transport: transport, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, - }, nil -} - -// outboundLeaf is the certificate this runtime presents to the platform, -// resolved once and re-read by whatever produces it. -// -// Once, because the thing that keeps a rotated leaf presented is the reloader's -// own re-read, not a new reloader: the SDK's swallows a failed or half-written -// re-read and keeps serving the last good pair (sdk-go tls.go, refresh), which -// is the property that makes a rotation invisible to a caller. Building a new -// one per dial threw exactly that away, because the constructor loads eagerly -// and reports the error instead. -func (s *Server) outboundLeaf(identityConfig *tls.Config) (func(*tls.CertificateRequestInfo) (*tls.Certificate, error), error) { - if s.identity != nil { - // A consumer's source: the identity it returned, in both directions, - // rather than projected files it never said it uses. Its own callback - // is what gets asked, so a source that rotates is followed. - client, err := clientTLSFrom(s.identity, identityConfig) - if err != nil { - return nil, fmt.Errorf("configure this workload's outbound identity from the supplied identity source: %w", err) - } - if client.GetClientCertificate != nil { - return client.GetClientCertificate, nil - } - pairs := client.Certificates - return func(*tls.CertificateRequestInfo) (*tls.Certificate, error) { - if len(pairs) == 0 { - return nil, fmt.Errorf("the identity source produces no certificate this runtime can present to the platform") - } - return &pairs[0], nil - }, nil - } - reloader, err := codefly.NewCertificateReloader(s.cfg.identityCertFile, s.cfg.identityKeyFile) - if err != nil { - return nil, fmt.Errorf("configure this workload's outbound identity from %q/%q: %w — the same pair the listener presents is what names this workload to the platform it calls", - s.cfg.identityCertFile, s.cfg.identityKeyFile, err) - } - return reloader.GetClientCertificate, nil -} - -// outboundTrustReloadBound is how long a connection nobody is using is kept in -// the pool. Pool hygiene, nothing more. -// -// It used to be described as the bound on outbound trust staleness, and it was -// not one: an inactivity timeout bounds only a connection that goes inactive, -// so a connection carrying traffic was never dropped and the documented window -// held for a quiet process only. What bounds the trust decision is -// outboundTrustRecheckInterval below. -const outboundTrustReloadBound = 30 * time.Second - -// outboundTrustRecheckInterval bounds how long an established platform -// connection may outlive the trust that authenticated it. -// -// It is the number the reload bound above could not deliver on its own. Peer -// trust is re-read on every dial, and that was documented as a 30-second -// window on the strength of IdleConnTimeout — but an *inactivity* timeout -// bounds only a connection nobody is using. A connection carrying a request -// every 100ms never becomes idle, and one was demonstrated still answering 31 -// seconds after the server's trust root was removed, over a single handshake: -// traffic kept the connection, and the trust decision behind it, alive for as -// long as it kept arriving. A stream is the same shape by construction, held -// open for as long as its declared duration allows (up to -// MaxStreamDurationLimit). -// -// A second, not the idle bound's thirty: the recheck is a file read and a chain -// verification against a chain already in hand, a few hundred microseconds on a -// handful of platform connections, so the window is set by what the guarantee -// should be rather than by what the work costs. It is also what makes the -// busy-connection case testable in about a second instead of half a minute. -const outboundTrustRecheckInterval = time.Second - -// watchOutboundTrust re-verifies an established connection's peer against -// current trust, and closes the connection when it stops verifying. -// -// Re-verifying rather than imposing a maximum connection age, which was the -// other way to bound this. A maximum age cuts whatever is in flight when it -// expires, and a declared 30-minute stream over a peer that is still perfectly -// trusted would be cut every bound — the runtime would be ending conforming -// streams to answer a question it can answer without ending them. The chain the -// peer presented is already in hand, so the anchor and the allowed-peer set can -// be re-read and applied to it: a connection whose peer still verifies is left -// alone, and one whose peer no longer does is closed, busy or idle, stream or -// request. -// -// A closed connection surfaces as a failed request or a cut stream, which is -// the intended outcome: the alternative is continuing to present this -// workload's credentials — and the viewer's — to a destination this cell has -// stopped vouching for. -func watchOutboundTrust(conn *tls.Conn, host string, trust func() (*x509.CertPool, error), peers func() ([]string, error)) net.Conn { - watched := &recheckedConn{Conn: conn, done: make(chan struct{})} - go func() { - ticker := time.NewTicker(outboundTrustRecheckInterval) - defer ticker.Stop() - for { - select { - case <-watched.done: - return - case <-ticker.C: - if err := peerStillTrusted(conn.ConnectionState(), host, trust, peers); err != nil { - _ = watched.Close() - return - } - } - } - }() - return watched -} - -// recheckedConn is the connection the transport holds, so the recheck stops -// when net/http drops it. -type recheckedConn struct { - net.Conn - stop sync.Once - done chan struct{} -} - -func (c *recheckedConn) Close() error { - c.stop.Do(func() { close(c.done) }) - return c.Conn.Close() -} - -// peerStillTrusted holds the chain this connection was authenticated by to -// trust as it is now: the current anchor, the current allowed-peer set, and the -// current time, which is also how a peer certificate that expired mid-stream -// stops being accepted. -// It takes the anchor and the admitted set, not a whole configuration to -// rebuild: a recheck that also resolved this workload's own leaf would close a -// trusted connection over a half-written copy of this process's own key, which -// is not a statement about the peer at all. -func peerStillTrusted(state tls.ConnectionState, host string, trust func() (*x509.CertPool, error), peers func() ([]string, error)) error { - anchor, err := trust() - if err != nil { - return err - } - // The private chain, for the reason immutableChain records: the objects in - // a ConnectionState are shared, and this runs for as long as the - // connection lives. - chain, err := immutableChain(state) - if err != nil { - return err } - if len(chain) == 0 { - return fmt.Errorf("the established platform connection presents no peer certificate to re-verify") - } - intermediates := x509.NewCertPool() - for _, cert := range chain[1:] { - intermediates.AddCert(cert) - } - if _, err := chain[0].Verify(x509.VerifyOptions{ - Roots: anchor, - Intermediates: intermediates, - DNSName: host, - KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, - }); err != nil { - return fmt.Errorf("the platform destination this connection was authenticated against no longer verifies: %w", err) - } - // And the same identity check a dial makes, since the allowed-peer set is - // read per dial and a connection older than a change to it would otherwise - // keep a de-authorised destination. - admitted, err := peers() - if err != nil { - return err - } - identity, err := oneURIIdentity(chain, "platform destination") - if err != nil { - return err - } - if !slices.Contains(admitted, identity) { - return fmt.Errorf("the established connection's destination %q is no longer one this runtime presents its credentials to", identity) - } - return nil } -// platformHandshakeTimeout bounds one outbound TLS handshake, on its own, -// because the custom dialler means nothing else does. It is deliberately not -// derived from platformRequestTimeout: a request may legitimately be long (a -// declared stream), and a handshake never is. -const platformHandshakeTimeout = 10 * time.Second +// outboundIdleTimeout is how long a pooled platform connection nobody is using +// is kept. +const outboundIdleTimeout = 30 * time.Second // platformRequestTimeout bounds one request this runtime makes on its own // behalf, end to end, so a host that accepts a request and never answers diff --git a/solution_test.go b/solution_test.go index 1e8da44..eb7f486 100644 --- a/solution_test.go +++ b/solution_test.go @@ -6,7 +6,6 @@ import ( "encoding/json" "errors" "fmt" - corework "github.com/codefly-dev/core/workcontext" "io" "log" "net/http" @@ -16,9 +15,13 @@ import ( "reflect" "strings" "sync" + "sync/atomic" "testing" "time" + corework "github.com/codefly-dev/core/workcontext" + "gopkg.in/yaml.v3" + "github.com/codefly-dev/core/resources" codefly "github.com/codefly-dev/sdk-go" "github.com/codefly-dev/sdk-go/workcontext" @@ -132,11 +135,45 @@ func sampleDataGraph() map[string]any { } } +// frontendYAML is a declared frontend file (FrontendFileName) with extra YAML +// — a dashboard, surfaces — under the required fields. +func frontendYAML(extra string) []byte { + return []byte("title: Notes\norder: 3\nexposed_module: ./Page\nhost_contract: 1\n" + extra) +} + +// declaredFrontend is the fixture above as a boot decodes it. +func declaredFrontend(t *testing.T, extra string) frontendDeclaration { + t.Helper() + frontend, err := parseFrontend(frontendYAML(extra)) + if err != nil { + t.Fatalf("parse the declared frontend: %v", err) + } + return frontend +} + +// footnoteSurface is the surface shape the notes solution declares for a Word +// document: the whole set of slots a client reads, so a test can assert on all +// of them. +const footnoteSurface = `surfaces: + - id: footnote + client: word + title: Footnote + description: Cite a claim from the notes solution. + module: /surfaces/word/footnote.js + contract: 1 + applies: always + events: [documents.entry] +` + // The served manifest must carry a declared dashboard data-graph verbatim // through JSON, so the host receives exactly what the solution declared. func TestServedManifestCarriesDashboardVerbatim(t *testing.T) { graph := sampleDataGraph() - s := &Server{manifest: Manifest{ID: testSolutionID, Dashboard: graph}} + declared, err := yaml.Marshal(map[string]any{"dashboard": graph}) + if err != nil { + t.Fatal(err) + } + s := &Server{manifest: Manifest{ID: testSolutionID}, frontend: declaredFrontend(t, string(declared))} var got map[string]any body, err := json.Marshal(s.manifestMap()) @@ -155,32 +192,16 @@ func TestServedManifestCarriesDashboardVerbatim(t *testing.T) { // not even a null one, which would break a host that feeds a present slot into // its data-graph validator and would perturb the existing wire contract. func TestManifestOmitsAbsentDashboard(t *testing.T) { - s := &Server{manifest: Manifest{ID: testSolutionID}} + s := &Server{manifest: Manifest{ID: testSolutionID}, frontend: declaredFrontend(t, "")} if _, ok := s.manifestMap()["dashboard"]; ok { t.Errorf("emitted a dashboard key with no dashboard declared") } } -// wordFootnote is the surface shape the notes solution declares for a Word document: the -// whole set of slots a client reads, so a test can assert on all of them. -func wordFootnote() Surface { - return Surface{ - ID: "footnote", - Client: "word", - Title: "Footnote", - Description: "Cite a claim from the notes solution.", - Module: "/surfaces/word/footnote.js", - Contract: 1, - Applies: "always", - Events: []string{"documents.entry.*"}, - } -} - // A client discovers what a solution offers it from the served manifest alone, -// so every declared slot must survive the trip through JSON — including the -// applies selector, which the runtime carries without interpreting. +// so every declared slot must survive the trip through JSON. func TestServedManifestCarriesDeclaredSurfaces(t *testing.T) { - s := &Server{manifest: Manifest{ID: "notes", Surfaces: []Surface{wordFootnote()}}} + s := &Server{manifest: Manifest{ID: "notes"}, frontend: declaredFrontend(t, footnoteSurface)} var got map[string]any body, err := json.Marshal(s.manifestMap()) @@ -198,7 +219,7 @@ func TestServedManifestCarriesDeclaredSurfaces(t *testing.T) { "module": "/surfaces/word/footnote.js", "contract": float64(1), "applies": "always", - "events": []any{"documents.entry.*"}, + "events": []any{"documents.entry"}, }} if !reflect.DeepEqual(got["surfaces"], want) { t.Errorf("served surfaces = %#v, want %#v", got["surfaces"], want) @@ -208,10 +229,8 @@ func TestServedManifestCarriesDeclaredSurfaces(t *testing.T) { // A tagged selector is a structure, not a keyword, and the runtime must hand it // to the client exactly as declared rather than flattening it. func TestServedSurfaceCarriesTaggedAppliesVerbatim(t *testing.T) { - applies := map[string]any{"tagged": []any{"legal", "finance"}} - surface := wordFootnote() - surface.Applies = applies - s := &Server{manifest: Manifest{ID: "notes", Surfaces: []Surface{surface}}} + declared := strings.Replace(footnoteSurface, "applies: always", "applies: {tagged: [legal, finance]}", 1) + s := &Server{manifest: Manifest{ID: "notes"}, frontend: declaredFrontend(t, declared)} var got map[string]any body, _ := json.Marshal(s.manifestMap()) @@ -219,34 +238,26 @@ func TestServedSurfaceCarriesTaggedAppliesVerbatim(t *testing.T) { t.Fatalf("unmarshal manifest: %v", err) } entry := got["surfaces"].([]any)[0].(map[string]any) - if !reflect.DeepEqual(entry["applies"], applies) { - t.Errorf("served applies = %#v, want %#v", entry["applies"], applies) + if want := map[string]any{"tagged": []any{"legal", "finance"}}; !reflect.DeepEqual(entry["applies"], want) { + t.Errorf("served applies = %#v, want %#v", entry["applies"], want) } } -// A solution that offers nothing inside a client must not gain the key at all. -func TestManifestOmitsAbsentSurfaces(t *testing.T) { - bare := &Server{manifest: Manifest{ID: testSolutionID}} +// A solution that offers nothing inside a client must not gain the key at all, +// and a surface's optional slots say what they mean by being absent: nothing +// to show and nothing to reconcile on are different from a null the client +// still has to interpret. The selector is not optional — core carries no +// default — so the minimal surface spells it. +func TestManifestOmitsAbsentSurfacesAndTheirEmptySlots(t *testing.T) { + bare := &Server{manifest: Manifest{ID: testSolutionID}, frontend: declaredFrontend(t, "")} if _, ok := bare.manifestMap()["surfaces"]; ok { t.Errorf("emitted a surfaces key with no surface declared") } -} - -// A surface is in the manifest whether or not it declares a selector, so an -// absent applies is not the absence of the surface — it is the default the -// contract names, and every client would otherwise have to invent it alike. -// Nothing to show and nothing to reconcile on are different: those slots say -// what they mean by being absent. -func TestManifestDefaultsAppliesAndOmitsEmptySurfaceSlots(t *testing.T) { - surface := wordFootnote() - surface.Applies = nil - surface.Events = nil - surface.Description = "" - s := &Server{manifest: Manifest{ID: "notes", Surfaces: []Surface{surface}}} - + minimal := "surfaces:\n - id: footnote\n client: word\n title: Footnote\n module: /surfaces/word/footnote.js\n contract: 1\n applies: always\n" + s := &Server{manifest: Manifest{ID: "notes"}, frontend: declaredFrontend(t, minimal)} entry := s.manifestMap()["surfaces"].([]any)[0].(map[string]any) - if got := entry["applies"]; got != "always" { - t.Errorf("applies with no selector declared = %#v, want %q", got, "always") + if got := entry["applies"]; got != surfaceAppliesAlways { + t.Errorf("applies = %#v, want %q as declared", got, surfaceAppliesAlways) } if _, ok := entry["description"]; ok { t.Errorf("emitted a description key with no description declared") @@ -256,65 +267,72 @@ func TestManifestDefaultsAppliesAndOmitsEmptySurfaceSlots(t *testing.T) { } } +// wordFootnote is the declared surface as the file decodes it. +func wordFootnote() frontendSurface { + return frontendSurface{ + ID: "footnote", + Client: "word", + Title: "Footnote", + Description: "Cite a claim from the notes solution.", + Module: "/surfaces/word/footnote.js", + Contract: 1, + Applies: "always", + Events: []string{"documents.entry"}, + } +} + // A surface no client could load must fail the boot, not register quietly and // show up as a solution that simply never appears in the add-in. func TestSurfaceValidationRejectsUnusableDeclarations(t *testing.T) { + with := func(edit func(*frontendSurface)) []frontendSurface { + s := wordFootnote() + edit(&s) + return []frontendSurface{s} + } cases := []struct { name string - surfaces []Surface + surfaces []frontendSurface want string }{ - {"no id", []Surface{func() Surface { s := wordFootnote(); s.ID = ""; return s }()}, "no id"}, - {"id with uppercase", []Surface{func() Surface { s := wordFootnote(); s.ID = "Footnote"; return s }()}, "id must be"}, - {"id with a slash", []Surface{func() Surface { s := wordFootnote(); s.ID = "word/footnote"; return s }()}, "id must be"}, - {"id with a leading dash", []Surface{func() Surface { s := wordFootnote(); s.ID = "-footnote"; return s }()}, "id must be"}, - {"id with a trailing dash", []Surface{func() Surface { s := wordFootnote(); s.ID = "footnote-"; return s }()}, "id must be"}, - {"id of one dash", []Surface{func() Surface { s := wordFootnote(); s.ID = "-"; return s }()}, "id must be"}, - {"duplicate id in one client", []Surface{wordFootnote(), wordFootnote()}, "declared twice"}, - {"no title", []Surface{func() Surface { s := wordFootnote(); s.Title = ""; return s }()}, "no title"}, - {"unknown client", []Surface{func() Surface { s := wordFootnote(); s.Client = "notion"; return s }()}, "unknown client kind"}, - {"no client", []Surface{func() Surface { s := wordFootnote(); s.Client = ""; return s }()}, "unknown client kind"}, - {"undeclared contract", []Surface{func() Surface { s := wordFootnote(); s.Contract = 0; return s }()}, "contract"}, - {"negative contract", []Surface{func() Surface { s := wordFootnote(); s.Contract = -1; return s }()}, "contract"}, - {"absolute module URL", []Surface{func() Surface { - s := wordFootnote() - s.Module = "https://evil.example/footnote.js" - return s - }()}, "own origin"}, - {"protocol-relative module", []Surface{func() Surface { - s := wordFootnote() - s.Module = "//evil.example/footnote.js" - return s - }()}, "own origin"}, + {"no id", with(func(s *frontendSurface) { s.ID = "" }), "no id"}, + {"id with uppercase", with(func(s *frontendSurface) { s.ID = "Footnote" }), "id must be"}, + {"id with a slash", with(func(s *frontendSurface) { s.ID = "word/footnote" }), "id must be"}, + {"id with a leading dash", with(func(s *frontendSurface) { s.ID = "-footnote" }), "id must be"}, + {"id with a trailing dash", with(func(s *frontendSurface) { s.ID = "footnote-" }), "id must be"}, + {"id of one dash", with(func(s *frontendSurface) { s.ID = "-" }), "id must be"}, + {"duplicate id in one client", []frontendSurface{wordFootnote(), wordFootnote()}, "declared twice"}, + {"no title", with(func(s *frontendSurface) { s.Title = "" }), "no title"}, + {"unknown client", with(func(s *frontendSurface) { s.Client = "notion" }), "unknown client kind"}, + {"no client", with(func(s *frontendSurface) { s.Client = "" }), "unknown client kind"}, + {"undeclared contract", with(func(s *frontendSurface) { s.Contract = 0 }), "contract"}, + {"negative contract", with(func(s *frontendSurface) { s.Contract = -1 }), "contract"}, + // The selector is declared, in one of the two shapes the host client + // reads; core signs no default and refuses every other shape. + {"no applies", with(func(s *frontendSurface) { s.Applies = nil }), "no applies"}, + {"an applies keyword that is not always", with(func(s *frontendSurface) { s.Applies = "sometimes" }), "applies"}, + {"a tagged selector naming no tag", with(func(s *frontendSurface) { s.Applies = map[string]any{"tagged": []any{}} }), "one or more tags"}, + {"a tagged selector with an empty tag", with(func(s *frontendSurface) { s.Applies = map[string]any{"tagged": []any{""}} }), "empty tag"}, + {"a selector mapping with another key", with(func(s *frontendSurface) { s.Applies = map[string]any{"tagged": []any{"legal"}, "also": true} }), "one key tagged"}, + {"a selector of another type", with(func(s *frontendSurface) { s.Applies = 7 }), "applies"}, + // Events are journal namespaces as core signs them. + {"an event with a wildcard", with(func(s *frontendSurface) { s.Events = []string{"documents.entry.*"} }), "journal namespace"}, + {"an event with uppercase", with(func(s *frontendSurface) { s.Events = []string{"Documents.entry"} }), "journal namespace"}, + {"an event named twice", with(func(s *frontendSurface) { s.Events = []string{"documents.entry", "documents.entry"} }), "named twice"}, + {"absolute module URL", with(func(s *frontendSurface) { s.Module = "https://evil.example/footnote.js" }), "own origin"}, + {"protocol-relative module", with(func(s *frontendSurface) { s.Module = "//evil.example/footnote.js" }), "own origin"}, // A browser reads the backslash beside the leading slash as a slash and // what follows as a host, so each of these loads third-party code even // though net/url reports no host in any of them. - {"backslash authority module", []Surface{func() Surface { - s := wordFootnote() - s.Module = `/\evil.example/footnote.js` - return s - }()}, "own origin"}, - {"backslash-slash authority module", []Surface{func() Surface { - s := wordFootnote() - s.Module = `/\/evil.example/footnote.js` - return s - }()}, "own origin"}, - {"tab-smuggled authority module", []Surface{func() Surface { - s := wordFootnote() - s.Module = "/\t/evil.example/footnote.js" - return s - }()}, "own origin"}, - {"newline-smuggled authority module", []Surface{func() Surface { - s := wordFootnote() - s.Module = "/\n//evil.example/footnote.js" - return s - }()}, "own origin"}, - {"relative module", []Surface{func() Surface { s := wordFootnote(); s.Module = "surfaces/footnote.js"; return s }()}, "own origin"}, - {"no module", []Surface{func() Surface { s := wordFootnote(); s.Module = ""; return s }()}, "own origin"}, + {"backslash authority module", with(func(s *frontendSurface) { s.Module = `/\evil.example/footnote.js` }), "own origin"}, + {"backslash-slash authority module", with(func(s *frontendSurface) { s.Module = `/\/evil.example/footnote.js` }), "own origin"}, + {"tab-smuggled authority module", with(func(s *frontendSurface) { s.Module = "/\t/evil.example/footnote.js" }), "own origin"}, + {"newline-smuggled authority module", with(func(s *frontendSurface) { s.Module = "/\n//evil.example/footnote.js" }), "own origin"}, + {"relative module", with(func(s *frontendSurface) { s.Module = "surfaces/footnote.js" }), "own origin"}, + {"no module", with(func(s *frontendSurface) { s.Module = "" }), "own origin"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - err := Manifest{ID: "notes", Surfaces: tc.surfaces}.validateSurfaces() + err := validateSurfaces(tc.surfaces) if err == nil { t.Fatalf("validateSurfaces accepted %s", tc.name) } @@ -325,21 +343,22 @@ func TestSurfaceValidationRejectsUnusableDeclarations(t *testing.T) { } } -// The same offering in two clients carries the same id — the notes solution's footnote is -// "footnote" in Word and in PowerPoint — and neither client can see the other's, -// so that is not a collision. Only two surfaces of one client are ambiguous. +// The same offering in two clients carries the same id — the notes solution's +// footnote is "footnote" in Word and in PowerPoint — and neither client can see +// the other's, so that is not a collision. Only two surfaces of one client are +// ambiguous. func TestSurfaceIDsAreUniquePerClientNotPerSolution(t *testing.T) { powerpoint := wordFootnote() powerpoint.Client = "powerpoint" powerpoint.Module = "/surfaces/powerpoint/footnote.js" - if err := (Manifest{ID: "notes", Surfaces: []Surface{wordFootnote(), powerpoint}}).validateSurfaces(); err != nil { + if err := validateSurfaces([]frontendSurface{wordFootnote(), powerpoint}); err != nil { t.Errorf("validateSurfaces refused one id shared across two clients: %v", err) } second := wordFootnote() second.Title = "Footnote, again" second.Module = "/surfaces/word/footnote-2.js" - err := (Manifest{ID: "notes", Surfaces: []Surface{wordFootnote(), second}}).validateSurfaces() + err := validateSurfaces([]frontendSurface{wordFootnote(), second}) if err == nil { t.Fatal("validateSurfaces accepted two surfaces sharing an id within one client") } @@ -348,14 +367,13 @@ func TestSurfaceIDsAreUniquePerClientNotPerSolution(t *testing.T) { } } -// Serve must refuse a manifest whose surface no client could load, and name the -// surface — the mistake is in the author's code, and it is the same mistake in -// every environment, so it must not wait on the environment resolving first. +// Serve must refuse a declaration whose surface no client could load, and name +// the surface — the mistake is in the author's file, and it is the same +// mistake in every environment, so it must not wait on the environment +// resolving first. func TestServeRejectsUnusableSurface(t *testing.T) { - surface := wordFootnote() - surface.Module = `/\evil.example/footnote.js` - - s := New(Manifest{ID: "notes", Title: "Notes", Surfaces: []Surface{surface}}) + declared := frontendYAML(strings.Replace(footnoteSurface, "/surfaces/word/footnote.js", `"/\\evil.example/footnote.js"`, 1)) + s := New(Manifest{ID: "notes", Frontend: declared}) err := s.Serve() if err == nil { t.Fatal("Serve returned nil for an off-origin surface module; expected a boot error and no bind") @@ -365,6 +383,57 @@ func TestServeRejectsUnusableSurface(t *testing.T) { } } +// TestTheDeclaredFrontendIsStrictAndNamed: the file is the one source of what +// the host lists a solution under, and the author writes it by hand, so every +// way of getting it wrong is refused at boot naming the file — and a file the +// CLI would read differently (an unknown key, a second document) is refused +// rather than read as whatever this runtime makes of it. +func TestTheDeclaredFrontendIsStrictAndNamed(t *testing.T) { + complete := "title: Notes\norder: 3\nexposed_module: ./Page\nhost_contract: 1\n" + for _, tc := range []struct{ name, file, want string }{ + {"no file", "", FrontendFileName}, + {"whitespace only", "\n \n", FrontendFileName}, + {"an unknown key", complete + "nav_order: 3\n", "nav_order"}, + {"no title", strings.Replace(complete, "title: Notes\n", "", 1), "no title"}, + {"an empty title", strings.Replace(complete, "title: Notes\n", "title: ''\n", 1), "title"}, + {"a title of two lines", strings.Replace(complete, "title: Notes\n", "title: \"Notes\\nmore\"\n", 1), "title"}, + {"no order", strings.Replace(complete, "order: 3\n", "", 1), "no order"}, + {"no exposed module", strings.Replace(complete, "exposed_module: ./Page\n", "", 1), "no exposed_module"}, + {"no host contract", strings.Replace(complete, "host_contract: 1\n", "", 1), "no host_contract"}, + {"a host contract of zero", strings.Replace(complete, "host_contract: 1\n", "host_contract: 0\n", 1), "host_contract"}, + {"a negative host contract", strings.Replace(complete, "host_contract: 1\n", "host_contract: -1\n", 1), "host_contract"}, + {"a dashboard with a non-string key", complete + "dashboard:\n 1: widget\n", "dashboard"}, + {"a dashboard that is not a mapping", complete + "dashboard: [1, 2]\n", "dashboard"}, + {"a dashboard carrying a fraction", complete + "dashboard:\n ratio: 0.5\n", "dashboard"}, + {"a dashboard over the bound", complete + "dashboard:\n pad: " + strings.Repeat("a", frontendDashboardMaxBytes) + "\n", "dashboard"}, + {"a surface with no contract", complete + strings.Replace(footnoteSurface, " contract: 1\n", "", 1), "contract"}, + {"two documents", complete + "---\ntitle: Other\n", "more than one"}, + {"not a mapping", "- title: Notes\n", "cannot be decoded"}, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := parseFrontend([]byte(tc.file)) + if err == nil { + t.Fatalf("a frontend file with %s was accepted", tc.name) + } + if !strings.Contains(err.Error(), tc.want) { + t.Errorf("the refusal %q does not say %q", err, tc.want) + } + if !strings.Contains(err.Error(), FrontendFileName) { + t.Errorf("the refusal %q does not name the file the author has to fix", err) + } + }) + } + t.Run("a complete declaration decodes as written", func(t *testing.T) { + frontend, err := parseFrontend([]byte("title: Notes\norder: 0\nexposed_module: ./Entry\nhost_contract: 2\n")) + if err != nil { + t.Fatal(err) + } + if frontend.Title != "Notes" || frontend.Order != 0 || frontend.HostContract != 2 || frontend.ExposedModule != "./Entry" { + t.Errorf("decoded %+v, want the declared title, order, exposed module and host contract", frontend) + } + }) +} + // setEndpoint injects a single Codefly endpoint into the SDK's env-var snapshot // for the duration of the test, then restores the snapshot on cleanup. func setEndpoint(t *testing.T, key, addr string) { @@ -393,17 +462,26 @@ func setEndpoint(t *testing.T, key, addr string) { func TestOnlyTheCurrentGatewayRoleResolves(t *testing.T) { const addr = "https://gateway:42152" t.Run("the current role resolves", func(t *testing.T) { - setEndpoint(t, "CODEFLY__ENDPOINT__SAAS__AUTH_GATEWAY__REST__REST", addr) + setEndpoint(t, "CODEFLY__ENDPOINT__SAAS__AUTH_GATEWAY__WORKLOAD__HTTP", addr) cfg := loadConfig(context.Background(), testSolutionID, nil) if cfg.gatewayURL != addr { t.Fatalf("gatewayURL = %q, want %q resolved from the current role without an override", cfg.gatewayURL, addr) } - // And the mint URL is NOT derived from it. A resolved gateway says - // where the gateway is, not where the host mints a credential: that - // endpoint is unsettled and undeployed, and the address this runtime - // POSTs its projected service-account token to is not one to assume. - if cfg.mintURL != "" { - t.Errorf("mintURL = %q, want empty: a resolved gateway must not produce a mint address, because the attestation this runtime sends there would be going somewhere nobody published", cfg.mintURL) + // And the mint is the host's broker route on that same origin: the + // workload endpoint serves the gateway's whole handler, so there is + // no second address to resolve for the projected token to go to. + if cfg.mintURL != addr+credentialMintPath { + t.Errorf("mintURL = %q, want %q: the mint is the published route on the resolved gateway", cfg.mintURL, addr+credentialMintPath) + } + }) + + t.Run("the public rest endpoint does not resolve the gateway", func(t *testing.T) { + // The public endpoint stops serving the mint, so resolving it would + // boot a solution that 404s at the one request it cannot do without. + t.Chdir(t.TempDir()) + setEndpoint(t, "CODEFLY__ENDPOINT__SAAS__AUTH_GATEWAY__REST__REST", addr) + if got := resolveGateway(context.Background(), "", "auth-gateway"); got != "" { + t.Fatalf("resolveGateway = %q from the rest endpoint, want nothing: the workload endpoint is the one that serves the mint", got) } }) @@ -436,7 +514,7 @@ func TestLoadConfigResolvesHostByRole(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - setEndpoint(t, "CODEFLY__ENDPOINT__"+tc.module+"__"+tc.gateway+"__REST__REST", gatewayAddr) + setEndpoint(t, "CODEFLY__ENDPOINT__"+tc.module+"__"+tc.gateway+"__WORKLOAD__HTTP", gatewayAddr) cfg := loadConfig(context.Background(), testSolutionID, nil) if cfg.gatewayURL != gatewayAddr { @@ -446,17 +524,6 @@ func TestLoadConfigResolvesHostByRole(t *testing.T) { } } -// identitiesFile writes an admission set to a file and returns its path, which -// is the form both admission sets are provisioned in: they are read per -// handshake and per dial, so they have to be something this process can re-read -// rather than a value fixed when it started. -func identitiesFile(t *testing.T, identities ...string) string { - t.Helper() - path := filepath.Join(t.TempDir(), "identities") - writeFile(t, path, strings.Join(identities, "\n")+"\n") - return path -} - // writeFile writes content to path, creating parent directories, for building // on-disk workspace fixtures. func writeFile(t *testing.T, path, content string) { @@ -480,15 +547,15 @@ func TestResolveGatewayAmbiguousModuleFailsLoud(t *testing.T) { t.Chdir(t.TempDir()) t.Run("single owning module resolves", func(t *testing.T) { - setEndpoint(t, "CODEFLY__ENDPOINT__SAAS__AUTH_GATEWAY__REST__REST", "https://gateway:42152") + setEndpoint(t, "CODEFLY__ENDPOINT__SAAS__AUTH_GATEWAY__WORKLOAD__HTTP", "https://gateway:42152") if got := resolveGateway(context.Background(), "", "auth-gateway"); got != "https://gateway:42152" { t.Fatalf("resolveGateway = %q, want the single module's address", got) } }) t.Run("two modules owning the same role are ambiguous", func(t *testing.T) { - setEndpoint(t, "CODEFLY__ENDPOINT__SAAS__AUTH_GATEWAY__REST__REST", "https://gateway-a:42152") - setEndpoint(t, "CODEFLY__ENDPOINT__SAAS_STARTER__AUTH_GATEWAY__REST__REST", "https://gateway-b:42152") + setEndpoint(t, "CODEFLY__ENDPOINT__SAAS__AUTH_GATEWAY__WORKLOAD__HTTP", "https://gateway-a:42152") + setEndpoint(t, "CODEFLY__ENDPOINT__SAAS_STARTER__AUTH_GATEWAY__WORKLOAD__HTTP", "https://gateway-b:42152") if got := resolveGateway(context.Background(), "", "auth-gateway"); got != "" { t.Fatalf("resolveGateway = %q, want %q so validate() fails loud on the ambiguous host", got, "") } @@ -524,7 +591,8 @@ agent: version: 0.0.1 publisher: codefly.dev endpoints: - - name: rest + - name: workload + api: http visibility: public exposure: public `) @@ -762,7 +830,7 @@ func serveHandler(t *testing.T, gatewayURL string, handler Handler) *httptest.Se // for an assertion about the credential itself. func serveHandlerWith(t *testing.T, gatewayURL string, source CredentialSource, handler Handler) *httptest.Server { t.Helper() - s := New(Manifest{ID: "notes", Title: "Notes"}).Credential(source) + s := New(Manifest{ID: "notes"}).Credential(source) s.cfg = config{brokerInternalToken: "broker-perimeter-token", gatewayURL: gatewayURL} server := httptest.NewServer(s.wrapRequest(func(r *http.Request, gw *Gateway) (any, error) { return handler(r.Context(), gw) @@ -1159,112 +1227,75 @@ func TestA409IsNotReadAsSupersessionAtAll(t *testing.T) { } } -// TestPlatformTrafficIsNeverProxiedAndPresentsThisWorkload keeps the viewer's -// credentials and this workload's own off an arbitrary egress host, and makes -// the outbound hop authenticated rather than merely https. -// -// Every platform target is composition-local, and these requests carry the -// viewer's bearer, the capability minted for them, and — on a mint — the -// projected token that attests which workload this process is. An https URL -// alone says only that the scheme is https: without a trust anchor the far end -// is verified against whatever the image's system roots happen to hold, and -// without a client certificate it cannot tell this workload from anything else -// that reached it. -func TestPlatformTrafficIsNeverProxiedAndPresentsThisWorkload(t *testing.T) { +// TestPlatformTrafficIsNeverProxiedOrRedirected: every destination a gateway +// has is composition-local, and every request to it carries the viewer's +// bearer, the capability minted for them or this workload's own credential. +// A proxy would dial those to an arbitrary egress host, and a redirect would +// hand them to whatever answered with a Location. +func TestPlatformTrafficIsNeverProxiedOrRedirected(t *testing.T) { if unauthenticatedTransport.Proxy != nil { t.Error("the platform transport carries a proxy: with HTTP(S)_PROXY set and a NO_PROXY that misses the in-cluster gateway, the viewer's bearer and capability would be dialled to an arbitrary egress host") } - - certFile, keyFile, bundleFile, _, _ := workloadIdentity(t, testPrincipal) server := New(Manifest{ID: testSolutionID}) - server.cfg = config{brokerInternalToken: "broker-perimeter-token", identityCertFile: certFile, identityKeyFile: keyFile, trustBundleFile: bundleFile, - mintPeersFile: identitiesFile(t, testGatewayPrincipal), - gatewayPeersFile: identitiesFile(t, testGatewayPrincipal), - // This test inspects the transport rather than dialling, so any - // resolved destination will do. - mintURL: "https://mint.cell:443" + credentialMintPath} - server.principal = testPrincipal - client, err := server.outboundClient(nil) - if err != nil { - t.Fatalf("outboundClient: %v", err) - } + client := server.outboundClient() transport, ok := client.Transport.(*http.Transport) if !ok { t.Fatalf("outbound transport is %T, want *http.Transport", client.Transport) } - switch { - case transport.Proxy != nil: + if transport.Proxy != nil { t.Error("the outbound client carries a proxy") - case transport.DialTLSContext == nil: - t.Fatal("the outbound client has no per-connection dialler, so its trust is whatever was snapshotted at boot") - case transport.TLSClientConfig != nil: - t.Error("the outbound transport carries a snapshotted TLS configuration: peer trust must be built per connection, or a removed root keeps authenticating the platform") - case transport.IdleConnTimeout == 0 || transport.IdleConnTimeout > outboundTrustReloadBound: - t.Errorf("idle connections live for %s, so per-dial reloading bounds nothing: want at most %s", transport.IdleConnTimeout, outboundTrustReloadBound) + } + if transport.IdleConnTimeout == 0 || transport.IdleConnTimeout > outboundIdleTimeout { + t.Errorf("idle connections live for %s, want at most %s", transport.IdleConnTimeout, outboundIdleTimeout) } if client.CheckRedirect == nil { t.Error("the outbound client follows redirects: net/http copies every header but three across hosts, so a Location would be handed this workload's credentials") } - // A pair that rotated to another workload's identity must not be presented - // to the platform, which the default path never checked: the listener's - // leaf was held to the principal and this second reloader over the same - // files was not. - rival, rivalKey, _, _, _ := workloadIdentity(t, "spiffe://codefly.test/ns/solutions/sa/another-workload") - writeFile(t, certFile, readFile(t, rival)) - writeFile(t, keyFile, readFile(t, rivalKey)) - if _, err := server.outboundClient(nil); err == nil { - t.Error("an outbound client was built presenting a leaf issued for another workload") - } - // A boot with no trust anchor cannot build one at all, which is the same - // refusal the listener makes. - server.cfg.trustBundleFile = "" - if _, err := server.outboundClient(nil); err == nil { - t.Error("an outbound client was built with no projected anchor to verify the platform against") - } -} - -// TestAHandlersGatewayDialsThroughTheBootsAuthenticatedTransport is the leg the -// test above was missing, and a reviewer showed it: everything there inspects -// the client outboundClient *returns*, and the defect this file's comments are -// about was the gateway handed to a handler not carrying it. Deleting -// gatewayFor's one assignment left every assertion above passing while a -// handler's reads went out over the unauthenticated fallback. -// -// So this drives the production gateway client at a platform host that requires -// a caller's certificate, and asks the host who called. The answer has to be -// this workload. -func TestAHandlersGatewayDialsThroughTheBootsAuthenticatedTransport(t *testing.T) { - c := newCell(t) - certFile, keyFile, bundleFile, _, _ := c.workload(t, testPrincipal) - host := newPlatformHost(t, c, testGatewayPrincipal) + if client.Timeout == 0 { + t.Error("the outbound client has no timeout, so a host that accepts a request and never answers parks a goroutine in Do forever") + } +} + +// TestAHandlersGatewayDialsThroughTheBootsTransport: everything above inspects +// the client outboundClient *returns*, and a reviewer showed the defect was the +// gateway handed to a handler not carrying it — deleting gatewayFor's one +// assignment left every assertion passing while a handler's reads went out over +// the fallback transport. So this drives the production gateway client and +// asks the boot's transport whether it carried the request. +func TestAHandlersGatewayDialsThroughTheBootsTransport(t *testing.T) { + host := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{"ok": "yes"}) + })) + t.Cleanup(host.Close) server := New(Manifest{ID: testSolutionID}) - server.cfg = config{brokerInternalToken: "broker-perimeter-token", identityCertFile: certFile, identityKeyFile: keyFile, trustBundleFile: bundleFile, - mintPeersFile: identitiesFile(t, testGatewayPrincipal), - gatewayPeersFile: identitiesFile(t, testGatewayPrincipal), gatewayURL: host.URL} - server.principal = testPrincipal - outbound, err := server.outboundClient(nil) - if err != nil { - t.Fatalf("outboundClient: %v", err) - } - server.outbound = outbound + server.cfg = config{brokerInternalToken: "broker-perimeter-token", gatewayURL: host.URL} + server.outbound = server.outboundClient() + carried := &countingTransport{base: server.outbound.Transport} + server.outbound.Transport = carried gateway := server.gatewayFor(http.Header{"authorization": {viewerBearer()}}) resp, err := gateway.HTTPClient().Get(host.URL + "/v1/things/search") if err != nil { - t.Fatalf("a handler's gateway could not reach a platform host that requires this workload's certificate: %v\n"+ - "a gateway that does not carry the boot's transport dials the platform as an anonymous client", err) - } - _ = resp.Body.Close() - called := host.called() - if len(called) == 0 { - t.Fatal("the platform host recorded no caller") + t.Fatalf("a handler's gateway could not reach the host: %v", err) } - if called[0] != testPrincipal { - t.Errorf("the platform saw a request from %q, want this workload's own %q", called[0], testPrincipal) + drainAndClose(resp) + if carried.count.Load() == 0 { + t.Fatal("the handler's gateway did not dial through the boot's transport: a gateway that does not carry it dials the platform through whatever transport it was built with") } } +// countingTransport counts the requests the transport it wraps carried. +type countingTransport struct { + base http.RoundTripper + count atomic.Int64 +} + +func (c *countingTransport) RoundTrip(r *http.Request) (*http.Response, error) { + c.count.Add(1) + return c.base.RoundTrip(r) +} + // A refused mint exposes its status, with no upstream diagnostics in either // the handler error or the browser response. func TestForModuleSurfacesARefusedMint(t *testing.T) { @@ -1560,237 +1591,38 @@ func TestWorkContextPrincipalsReportsWhomAccountsIssuedFor(t *testing.T) { } } -// optionalIdentitiesFile is identitiesFile, or no path at all for an empty set -// — the distinction a boot-refusal table needs, since "the platform never -// provisioned a path" and "the path is there and names nobody" are two -// different refusals and each has its own row. -func optionalIdentitiesFile(t *testing.T, identities string) string { - t.Helper() - if identities == "" { - return "" - } - return identitiesFile(t, identities) -} - -// TestABootWithoutAResolvedMintURLIsRefused: the previous revision derived the -// mint address from the resolved gateway and labelled the result a guess — the -// field was literally named mintURLGuessed, the code said the endpoint was "NOT -// settled" and that "neither endpoint exists yet", and the README and the boot -// log both said "settled". Both could not be true. -// -// This is the address the projected service-account token goes to, which is the -// strongest statement this process can make about which workload it is. A -// guessed address for that is what fail-closed forbids, and this package -// already refuses rather than guesses when it cannot pair a token-exchange URL. -func TestABootWithoutAResolvedMintURLIsRefused(t *testing.T) { - cfg := config{brokerInternalToken: "broker-perimeter-token", port: "8080", gatewayURL: "https://gateway:42152", profile: localProfile} +// TestABootWithoutAResolvedGatewayIsRefused: the gateway is the one address +// this runtime dials — every viewer call and the mint that issues this +// execution's credential go to it — so a boot that resolved none is refused +// naming the endpoint role the composition declares. +func TestABootWithoutAResolvedGatewayIsRefused(t *testing.T) { + cfg := config{brokerInternalToken: "broker-perimeter-token", port: "8080", profile: localProfile} err := cfg.validate() if err == nil { - t.Fatal("the boot accepted a configuration with no mint URL, so this runtime would POST its projected token to an address nobody resolved") - } - if !strings.Contains(err.Error(), CredentialMintURLEnvironmentVariable) { - t.Errorf("the refusal %q does not name %s, the variable that sets it", err, CredentialMintURLEnvironmentVariable) + t.Fatal("the boot accepted a configuration with no gateway, so this runtime would POST its projected token to nowhere and serve") } - // And the refusal must not read as a provisioning gap in the gateway, - // which resolved perfectly well. - if strings.Contains(err.Error(), "unresolved gateway") { - t.Errorf("the refusal %q blames the gateway, which resolved: that sends an operator to inspect endpoint resolution over a value that is simply not set", err) - } -} - -// TestNoProductionCodeDerivesTheMintAddress pins the absence. A default address -// is the thing this repository's rules single out, and the previous one came -// back as a "labelled stopgap" that the README then described as settled. -func TestNoProductionCodeDerivesTheMintAddress(t *testing.T) { - for _, name := range moduleSources(t) { - source, err := os.ReadFile(name) - if err != nil { - t.Fatalf("read %s: %v", name, err) - } - body := string(source) - for _, derived := range []string{"gatewayURL + credentialMintPath", "gatewayURL+credentialMintPath"} { - if strings.Contains(body, derived) { - t.Errorf("%s derives the credential mint address from the resolved gateway: the gateway's address says where the gateway is, not where this host mints a credential, and the projected token this runtime sends there is not something to address by assumption", name) - } - } - if strings.Contains(body, "mintURLGuessed") { - t.Errorf("%s still carries mintURLGuessed: a guessed address for the endpoint this runtime attests itself to is refused now, so there is nothing to label", name) + for _, named := range []string{"gateway URL", "GATEWAY_URL", "workload"} { + if !strings.Contains(err.Error(), named) { + t.Errorf("the refusal %q does not name %s", err, named) } } } -// TestTwoAdmissionSourcesAreRefusedRatherThanRanked: an admission set answered -// by both an operator override and the platform's provisioning is two sources -// for one authorization fact. The previous revision logged which one won, which -// reports the conflict without resolving it — whichever this process picked, the -// other is a decision somebody made that is not in force, and for admission -// being wrong admits a caller. -// -// It is the stance the SDK already takes on the same question: a value -// delivered inline and by file carrier is refused as two sources for one fact. -func TestTwoAdmissionSourcesAreRefusedRatherThanRanked(t *testing.T) { - base := func(t *testing.T) config { - t.Helper() - return config{brokerInternalToken: "broker-perimeter-token", - port: "8080", gatewayURL: "https://gateway:42152", - mintURL: "https://gateway:42152" + credentialMintPath, - profile: localProfile, - } +// TestTheMintAddressIsTheGatewaysBrokerRoute: the mint used to be a separate, +// required address, because nothing published where the host minted. The +// gateway's workload endpoint now serves the gateway's whole handler, the mint +// included, so the mint is the host's published route on the one origin this +// runtime resolved — and nothing else resolves or overrides it. +func TestTheMintAddressIsTheGatewaysBrokerRoute(t *testing.T) { + t.Setenv("PORT", "8080") + t.Setenv("GATEWAY_URL", "https://gateway.cell:8443/") + cfg := loadConfig(context.Background(), testSolutionID, nil) + if want := "https://gateway.cell:8443" + credentialMintPath; cfg.mintURL != want { + t.Errorf("mintURL = %q, want %q", cfg.mintURL, want) + } + if cfg.gatewayURL != "https://gateway.cell:8443" { + t.Errorf("gatewayURL = %q, want the override without its trailing slash", cfg.gatewayURL) } - - t.Run("an override alone is accepted", func(t *testing.T) { - cfg := base(t) - cfg.allowedCallersFile = identitiesFile(t, testGatewayPrincipal) - if err := cfg.validate(); err != nil { - t.Fatalf("an override with no provisioning behind it was refused: %v", err) - } - }) - - // Driven through the real resolver, not injected. The comment that used to - // stand here said a test cannot make the SDK's workspace configuration - // answer, and that was simply false — authorityValues does it for the - // authority group, the same way. So the conflict rule had no behavioural - // test at all and three mutants of it survived. - t.Run("both answering is refused, through the resolver", func(t *testing.T) { - provisionedWorkloadValue(t, WorkloadIdentityAllowedCallersFileKey, "/provisioned/callers") - t.Setenv(IdentityAllowedCallersFileEnvironmentVariable, "/override/callers") - conflict := conflictingAdmissionSources(context.Background(), nil) - if conflict == nil { - t.Fatal("the resolver saw no conflict while both the override and the platform answered for the caller set") - } - for _, named := range []string{"/override/callers", "/provisioned/callers"} { - if !strings.Contains(conflict.Error(), named) { - t.Errorf("the refusal %q does not name %s, so an operator cannot tell which two answers are in play", conflict, named) - } - } - - cfg := base(t) - cfg.admissionConflict = conflict - err := cfg.validate() - if err == nil { - t.Fatal("a boot accepted an admission set answered by two sources: ranking them silently is how a caller set gets widened with nothing recording that the platform's decision was not in force") - } - if !strings.Contains(err.Error(), "answered twice") { - t.Errorf("the refusal %q does not say the set has two answers", err) - } - // And it is reported before every other provisioning message, or it - // reads as ordinary advice next to them. - cfg.port = "not-a-port" - if again := cfg.validate(); again == nil || !strings.Contains(again.Error(), "answered twice") { - t.Errorf("with another refusal also pending, validate reported %v: the conflicting-authorization refusal is the one that says a decision is not in force", again) - } - }) - - t.Run("the resolver finds no conflict with no override set", func(t *testing.T) { - provisionedWorkloadValue(t, WorkloadIdentityAllowedCallersFileKey, "/provisioned/callers") - if err := conflictingAdmissionSources(context.Background(), nil); err != nil { - t.Errorf("a conflict was reported with no override set: %v", err) - } - }) - - // And an override against provisioning this process cannot READ is a - // conflict too. The resolver discarded the SDK's error, saw no second - // answer, and put the override in force — against provisioning it simply - // could not see. - t.Run("an unreadable platform answer is a conflict, not an absent one", func(t *testing.T) { - t.Setenv(IdentityAllowedCallersFileEnvironmentVariable, "/override/wide") - // No workspace configuration loaded at all, so the SDK errors rather - // than answering empty. - withoutWorkloadValues(t) - // With a failed environment load, which is the one signal that - // separates "never provisioned" from "could not be read": the SDK - // answers both with the same error. - err := conflictingAdmissionSources(context.Background(), errors.New("loading the injected environment failed")) - if err == nil { - t.Fatal("an override was put in force while the platform's own answer could not be read: an unreadable answer is still an answer somebody provisioned, and choosing between two is exactly what this rule refuses") - } - if !strings.Contains(err.Error(), "cannot be read") { - t.Errorf("the refusal %q does not say the platform's answer was unreadable", err) - } - }) -} - -// TestAnAdmissionFileIsReadWholeOrRefused: each entry has to be a complete -// line, which is the part that is not obvious. -// -// The previous reader split whatever it was handed, so a file caught mid-write -// — a platform rewriting it without an atomic swap, an operator's `>` redirect -// — truncated an entry and the truncation was admitted as an identity of its -// own. The executed review showed ".../sa/gateway-internal" read as -// ".../sa/gateway", admitting a caller the set never named. -func TestAnAdmissionFileIsReadWholeOrRefused(t *testing.T) { - const gateway = "spiffe://codefly.test/ns/platform/sa/gateway" - const internal = "spiffe://codefly.test/ns/platform/sa/gateway-internal" - - read := func(t *testing.T, content string) ([]string, error) { - t.Helper() - path := filepath.Join(t.TempDir(), "identities") - writeFile(t, path, content) - return resolvedIdentities(path, "unresolved")() - } - - t.Run("a truncated last line is refused, not admitted", func(t *testing.T) { - // What a reader sees partway through a non-atomic rewrite of a file - // whose only entry is the -internal identity. - got, err := read(t, internal[:len(internal)-len("-internal")]) - if err == nil { - t.Fatalf("a file caught mid-write resolved to %v: the fragment %q is a different identity from the one being written, and admitting it lets in a caller the set never named", got, gateway) - } - if !strings.Contains(err.Error(), "newline") { - t.Errorf("the refusal %q does not say why the file is not whole", err) - } - }) - - t.Run("a terminated file is read", func(t *testing.T) { - got, err := read(t, gateway+"\n"+internal+"\n") - if err != nil { - t.Fatalf("a complete file was refused: %v", err) - } - if len(got) != 2 || got[0] != gateway || got[1] != internal { - t.Errorf("resolved %v, want both identities", got) - } - }) - - t.Run("entries that can never match are refused at the file", func(t *testing.T) { - for _, tc := range []struct{ name, content string }{ - {"a byte-order mark", "\ufeff" + gateway + "\n"}, - {"a NUL in an entry", "spiffe://codefly.test/ns/platform/sa/gate\x00way\n"}, - {"not a URI at all", "gateway\n"}, - {"another scheme", "https://codefly.test/ns/platform/sa/gateway\n"}, - {"no path", "spiffe://codefly.test\n"}, - } { - t.Run(tc.name, func(t *testing.T) { - if got, err := read(t, tc.content); err == nil { - t.Errorf("resolved %v: an entry that cannot match any peer is a provisioning mistake, and carrying it silently turns into a caller nobody admits at a handshake far from the cause", got) - } - }) - } - }) - - t.Run("a file too large to re-read every second is refused", func(t *testing.T) { - big := strings.Repeat(gateway+"\n", (admissionFileLimit/(len(gateway)+1))+16) - got, err := read(t, big) - if err == nil { - t.Fatalf("resolved %d identities from a file over the limit: this is read on every handshake, every dial, and once a second per established connection in each direction", len(got)) - } - // And refused BY THE CAP. Without this the case passes for the wrong - // reason: the reader stops at the limit, so the last line it sees is a - // fragment and the whole-line rule refuses it — which means removing - // the cap entirely left this green. - if !strings.Contains(err.Error(), "larger than") { - t.Errorf("the refusal %q is not about the file's size, so this case does not cover the cap", err) - } - }) - - t.Run("comments, CRLF and commas still work", func(t *testing.T) { - got, err := read(t, "# the gateway\r\n"+gateway+", "+internal+"\r\n") - if err != nil { - t.Fatalf("a conforming file was refused: %v", err) - } - if len(got) != 2 { - t.Errorf("resolved %v, want two identities", got) - } - }) } // TestACredentialBearingURLCarriesNothingButADestination: userinfo, a query and @@ -1799,20 +1631,23 @@ func TestAnAdmissionFileIsReadWholeOrRefused(t *testing.T) { // and, in the userinfo case, that net/http strips before the request is even // sent, so it is never used for anything except being logged. func TestACredentialBearingURLCarriesNothingButADestination(t *testing.T) { - for _, tc := range []struct{ name, mint, names string }{ - {"userinfo", "https://ops:s3cr3t@mint.cell/modules/_work-context", "userinfo"}, - {"a query", "https://mint.cell/modules/_work-context?token=s3cr3t", "query"}, - {"a fragment", "https://mint.cell/modules/_work-context#part", "fragment"}, + for _, tc := range []struct{ name, gateway, names string }{ + {"userinfo", "https://ops:s3cr3t@gateway.cell", "credentials"}, + {"a query", "https://gateway.cell?token=s3cr3t", "query"}, + {"a fragment", "https://gateway.cell#part", "fragment"}, } { t.Run(tc.name, func(t *testing.T) { - cfg := config{brokerInternalToken: "broker-perimeter-token", port: "8080", gatewayURL: "https://gateway:42152", mintURL: tc.mint, profile: localProfile} + cfg := config{brokerInternalToken: "broker-perimeter-token", port: "8080", gatewayURL: tc.gateway, mintURL: tc.gateway + credentialMintPath, profile: localProfile} err := cfg.validate() if err == nil { - t.Fatalf("a credential mint URL carrying %s was accepted", tc.names) + t.Fatalf("a gateway URL carrying %s was accepted", tc.names) } if !strings.Contains(err.Error(), tc.names) { t.Errorf("the refusal %q does not say the URL carries %s", err, tc.names) } + if !strings.Contains(err.Error(), "s3cr3t") == false { + t.Errorf("the refusal %q carries the secret", err) + } }) } @@ -1833,17 +1668,17 @@ func TestACredentialBearingURLCarriesNothingButADestination(t *testing.T) { log.SetOutput(&captured) t.Cleanup(func() { log.SetOutput(os.Stderr) }) - cfg := config{brokerInternalToken: "broker-perimeter-token", port: "8080", gatewayURL: "https://gateway:42152", profile: localProfile, - mintURL: "https://ops:s3cr3t-password@mint.cell/modules/_work-context"} + cfg := config{brokerInternalToken: "broker-perimeter-token", port: "8080", profile: localProfile, + gatewayURL: "https://ops:s3cr3t-password@gateway.cell", mintURL: "https://ops:s3cr3t-password@gateway.cell/modules/_work-context"} // validate() refuses this URL, and the log line still must not carry // the secret: the refusal and the logging are independent, which is // the whole reason the log redacts regardless. _ = cfg.validate() - cfg.mintURL = "https://mint.cell/modules/_work-context" + cfg.gatewayURL, cfg.mintURL = "https://gateway.cell", "https://gateway.cell/modules/_work-context" if err := cfg.validate(); err != nil { t.Fatalf("a conforming configuration was refused: %v", err) } - cfg.mintURL = "https://ops:s3cr3t-password@mint.cell/modules/_work-context" + cfg.mintURL = "https://ops:s3cr3t-password@gateway.cell/modules/_work-context" cfg.logResolved() if got := captured.String(); strings.Contains(got, "s3cr3t-password") { t.Errorf("the boot log carries the secret from the mint URL:\n%s", got) @@ -1881,10 +1716,11 @@ func TestTheEnvironmentLoadErrorReachesTheRefusals(t *testing.T) { }) t.Run("a missing path says which it is", func(t *testing.T) { - cfg := config{brokerInternalToken: "broker-perimeter-token", environmentLoadErr: loadErr} - err := cfg.requirePath("workload identity certificate", "", "OVERRIDE", "CERT_FILE") + server := New(Manifest{ID: testSolutionID}) + server.cfg = config{brokerInternalToken: "broker-perimeter-token", environmentLoadErr: loadErr} + err := server.validateSources() if err == nil { - t.Fatal("an unresolved path was accepted") + t.Fatal("an unresolved projected token path was accepted") } if !strings.Contains(err.Error(), "environment failed first") { t.Errorf("the refusal %q does not distinguish an unprovisioned path from an environment that never loaded", err) diff --git a/uninstalled_test.go b/uninstalled_test.go index ba990d2..9b9dabc 100644 --- a/uninstalled_test.go +++ b/uninstalled_test.go @@ -2,8 +2,6 @@ package solution import ( "context" - "crypto/tls" - "crypto/x509" "io" "net/http" "net/http/httptest" @@ -12,8 +10,6 @@ import ( "sync/atomic" "testing" "time" - - "github.com/codefly-dev/core/resources" ) // postInstall sends one well-formed install step to a booted runtime as the @@ -21,7 +17,7 @@ import ( func postInstall(t *testing.T, b *booted) int { t.Helper() r := installRequest(t, installBody) - r.URL.Scheme, r.URL.Host = "https", strings.TrimPrefix(b.base, "https://") + r.URL.Scheme, r.URL.Host = "http", strings.TrimPrefix(b.base, "http://") r.RequestURI = "" resp, err := b.client.Do(r) if err != nil { @@ -183,127 +179,6 @@ func TestAwaitInstallationCadenceIsJitteredDownward(t *testing.T) { } } -// selfCarrier is the advertised-address carrier for the service these tests -// run as. -func selfCarrier(t *testing.T, value string) []string { - t.Helper() - t.Setenv(resources.ModulePrefix, "wiki") - t.Setenv(resources.ServicePrefix, "backend") - key := resources.SelfEndpointAsEnvironmentVariableKey(&resources.EndpointInformation{ - Module: "wiki", Service: "backend", Name: "http", API: "http", - }) - if value == "" { - return nil - } - return []string{key + "=" + value} -} - -// TestTheReportedOriginIsTheAdvertisedAddress: a rendered cell hands a service -// its own endpoint as the address it listens on, localized to localhost, and -// the address its peers reach it at under a separate carrier. Reporting the -// former tells the gateway to proxy the solution to the gateway's own loopback. -func TestTheReportedOriginIsTheAdvertisedAddress(t *testing.T) { - for _, tc := range []struct { - name, self, port, advertised, want string - }{ - {"a rendered cell", "localhost:8080", "8080", "http://backend.platform-wiki.svc.cluster.local:8080", "https://backend.platform-wiki.svc.cluster.local:8080"}, - {"a cell whose listen port was overridden", "localhost:8080", "9443", "http://backend.platform-wiki.svc.cluster.local:8080", "https://backend.platform-wiki.svc.cluster.local:8080"}, - {"an advertised host and port with no scheme", "localhost:8080", "8080", "backend.platform-wiki.svc:8080", "https://backend.platform-wiki.svc:8080"}, - {"an advertised IPv6 address", "localhost:8080", "8080", "http://[fd00::12]:8080", "https://[fd00::12]:8080"}, - {"no carrier, on a developer machine", "localhost:22631", "22631", "", "https://localhost:22631"}, - {"no carrier, and a listen port overridden by PORT", "http://127.0.0.1:22631", "9443", "", "https://127.0.0.1:9443"}, - {"no carrier, and a listen address naming no host", ":8080", "8080", "", "https://localhost:8080"}, - {"no carrier, listening on the unspecified address", "0.0.0.0:8080", "8080", "", "https://localhost:8080"}, - {"no carrier and no resolved endpoint", "", "8080", "", "https://localhost:8080"}, - } { - t.Run(tc.name, func(t *testing.T) { - got, err := advertisedOrigin(context.Background(), tc.self, tc.port, selfCarrier(t, tc.advertised)) - if err != nil || got != tc.want { - t.Fatalf("advertisedOrigin = %q, %v; want %q", got, err, tc.want) - } - }) - } -} - -// TestAnAdvertisedAddressThatIsNotOneIsRefusedNotReplaced: a carrier that is -// present and wrong is never read as absent. Falling back to the listen -// address is how a typo becomes a loopback upstream. -func TestAnAdvertisedAddressThatIsNotOneIsRefusedNotReplaced(t *testing.T) { - for name, advertised := range map[string]string{ - "not an address": "backend", - "a path": "http://backend.platform-wiki.svc.cluster.local:8080/api", - "userinfo": "http://ops@backend.platform-wiki.svc.cluster.local:8080", - "a query": "http://backend.platform-wiki.svc.cluster.local:8080?x=1", - "the unspecified address": "http://0.0.0.0:8080", - "a port that is not one": "backend.platform-wiki.svc:0", - } { - t.Run(name, func(t *testing.T) { - got, err := advertisedOrigin(context.Background(), "localhost:8080", "8080", selfCarrier(t, advertised)) - if err == nil || got != "" { - t.Fatalf("advertisedOrigin(%q) = %q, %v; want a refusal and nothing to report", advertised, got, err) - } - }) - } -} - -// TestADeployedRuntimeRefusesToReportItsOwnLoopback is the boot refusal: in a -// deployed runtime context a loopback origin is reachable by no peer, whether -// it came from a missing carrier or was advertised. -func TestADeployedRuntimeRefusesToReportItsOwnLoopback(t *testing.T) { - load := func(t *testing.T, runtimeContext, advertised string) (config, error) { - t.Helper() - bootableEnvironment(t) - t.Setenv(resources.RuntimeContextPrefix, runtimeContext) - for _, entry := range selfCarrier(t, advertised) { - key, value, _ := strings.Cut(entry, "=") - t.Setenv(key, value) - } - c := loadConfig(context.Background(), "solution-under-test", nil) - return c, c.validate() - } - t.Run("a developer machine reports its loopback port", func(t *testing.T) { - c, err := load(t, resources.RuntimeContextNative, "") - if err != nil || c.upstream != "https://localhost:8090" { - t.Fatalf("upstream %q, %v; want the loopback port this process listens on", c.upstream, err) - } - }) - t.Run("a cell reports the advertised address", func(t *testing.T) { - c, err := load(t, "kubernetes", "http://backend.platform-wiki.svc.cluster.local:8080") - if err != nil || c.upstream != "https://backend.platform-wiki.svc.cluster.local:8080" { - t.Fatalf("upstream %q, %v; want the advertised cluster-local origin", c.upstream, err) - } - }) - for name, advertised := range map[string]string{ - "a cell with no carrier": "", - "a cell advertising localhost": "http://localhost:8080", - "a cell advertising a loopback IP": "http://127.0.0.1:8080", - } { - t.Run(name, func(t *testing.T) { - _, err := load(t, "kubernetes", advertised) - if err == nil || !strings.Contains(err.Error(), resources.SelfEndpointPrefix) { - t.Fatalf("validate = %v; want a refusal naming the %s carrier", err, resources.SelfEndpointPrefix) - } - }) - } -} - -// TestAnOversizeMintRequestIsRefusedAtBoot: the host bounds what it reads and -// answers an oversize body as malformed, which this client can only classify -// as a refusal — at boot and at every renewal after it. -func TestAnOversizeMintRequestIsRefusedAtBoot(t *testing.T) { - s, _, _ := brokerFixture(t) - options := s.mintOptions("accounts", func() (*x509.CertPool, error) { return x509.NewCertPool(), nil }, func(*tls.CertificateRequestInfo) (*tls.Certificate, error) { return &tls.Certificate{}, nil }) - options.Authority = fixedAuthority{audience: testAudience} - options.Manifest = `{"pad":"` + strings.Repeat("a", brokerMintBodyMaxBytes) + `"}` - if _, err := newBrokerCredentialSource(options); err == nil || !strings.Contains(err.Error(), "manifest") { - t.Fatalf("an oversize mint request was accepted at boot (%v); it must be refused naming the manifest", err) - } - options.Manifest = `{"pad":"` + strings.Repeat("a", brokerMintBodyMaxBytes/2) + `"}` - if _, err := newBrokerCredentialSource(options); err != nil { - t.Fatalf("a mint request inside the bound was refused: %v", err) - } -} - // TestTheAskerSurvivesATransientAnswerWhileUninstalled: one answer that // judges nothing — the host restarting, a pass in flight — leaves the solution // exactly as uninstalled as it was. An asker that stopped there left the first From 758e0cc8c3018f18d7fd0d8cccf5a7b0c9569d40 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:44:58 -0400 Subject: [PATCH 2/5] fix: stop credential acquisition from health probes and idle polling --- AGENTS.md | 20 ++++++------- README.md | 72 ++++++++++++++++----------------------------- credential.go | 66 +++++++---------------------------------- credential_test.go | 24 ++++++--------- solution.go | 59 ++++--------------------------------- uninstalled_test.go | 62 ++++++++++++++++++++++++++++---------- 6 files changed, 104 insertions(+), 199 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6d7427b..2db0f67 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,7 +3,7 @@ This repository owns the generic Go runtime for **codefly solutions** — modules deployed independently of the host they extend, with no build-time coupling. It owns what every solution needs identically: configuration resolution through the -codefly SDK, obtaining this execution's credential once from the projected +codefly SDK, obtaining this execution's credential at boot and on demand from the projected service-account token, a plain-HTTP listener on the port Codefly resolves — the cell's mesh carries transport security and destination identity for every hop inside it — CORS, Module Federation asset serving, the capability @@ -418,15 +418,13 @@ embedding it. anything about this process to the host: a process cannot declare what the composition did not. - **`412` is "admitted, installed nowhere", and it is a state, not an error.** - The host issues this execution's credential only after the install step is - acknowledged, so the step must run without it — it acts under the capability - the host carries in — and `/health` must answer 200, both from what the - controller already recorded (`uninstalled`) without asking again. - `awaitInstallation` is the one asker, through `askOnce` like every other - ask: an ask made around the controller left a terminal answer latched in the - source and reported by nobody. A viewer's route still answers 503. Do not - widen the exemption to any other unavailable state: a host that cannot be - reached is not an uninstalled solution. + The install step runs under the host's incoming capability before an execution + credential exists. Its acknowledgement can trigger a bounded retry sequence + to bridge activation; there is no idle installation poll. Health only reads + process state, never asks for or renews a credential. An idle process must + make zero further mints, even after its held credential expires. Actual viewer + work still uses the single-flight credential controller and the host's live + installation checks. Never turn probes back into authorization traffic. - **The execution mint follows the host's module broker contract natively, over plain HTTP to the gateway's workload endpoint.** `saas-sdk-go/moduleauthority` at `217bf9fe3f10` is the wire reference; its @@ -469,7 +467,7 @@ embedding it. only, with no raw diagnostic cause. Boot and renewal use the same decision; terminal answers are retained where they land, even after waiters leave. - **A terminal credential refusal ends the process; a transient one does not.** - `ErrMintRefused` or a terminal broker error at renewal fails `/health` and ends `serve` with the reason. + `ErrMintRefused` or a terminal broker error observed during actual work fails `/health` and ends `serve` with the reason. Health does not discover it by minting. `ErrMintUnavailable` must not: it is transient by construction and exiting on it is a crash loop. `ErrRevoked` is not in either set, and three review rounds found this file still saying it was: the mint client does not return it at all diff --git a/README.md b/README.md index 648ca7f..badbb65 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ workload identity; the host reconciles towards it. Authority is delivered the same way, by a signed document bound to one approved build. This runtime's only outbound act at boot is to obtain its own credential once, from the service-account token the platform projects for it; it renews that credential -when it expires and otherwise says nothing to anyone. Health is **answered**, +on demand when actual work needs it and otherwise says nothing. Health is **answered**, never pushed: the host probes the destination its own presence document names, and nothing this process answers can make it present. @@ -95,11 +95,11 @@ answers `412`. That is not a refusal. The runtime serves holding no credential: `/health` answers `200`, and the [install step](#install-step) runs, because it acts under the capability the host carries in and the host issues this execution's credential only after that step is acknowledged. Neither asks the -host again. The runtime asks every 20 seconds (jittered) for as long as it holds -no credential — through a transient answer too — and at once, then a few times -in quick succession, after it acknowledges an install step. It holds the first -credential issued. A viewer's route still answers `503` until then. A -`401`/`403` +host again. An acknowledged install step triggers a bounded sequence of up to +six attempts to bridge the host's activation-after-acknowledgement race. After +that sequence the runtime remains idle; an actual viewer operation can acquire +through the same controller. There is no periodic polling while uninstalled. +A `401`/`403` stays what it was: a judgement, which fails the boot and is never retried. The answer carries `token`, `expiresAt`, @@ -355,24 +355,18 @@ which establishes the principal, the installation and the build from that token and its own records, never from anything this process reports — answers with a credential sealed to this build incarnation and this installation. -The runtime runs **no registration heartbeat** — which is the claim this cutover is about. It does run other -timers, and saying otherwise here was wrong twice: the inbound and outbound -trust watchers re-verify an established peer once a second, the credential -acquisition holds off after a failure (capped, jittered), and the listener has -idle and header bounds. None of them tells the host this process exists, which -is the property; "no timers at all" was never it. Every use goes through the client, which hands back the -credential it holds while that one is current and renews it once it has entered -its renewal lead, re-reading the rotated projection and rechecking the frozen -authority values first. A process that runs for a week under credentials the -issuer chose to make long-lived mints once and renews a handful of times; it -never mints per beat or per probe, and does not mint per request: acquisition is -single-flight, a failed ask quiets the next ones for a second, and a credential -already in hand and unexpired is what serves meanwhile. That last part is not a -detail — renewal begins inside a lead *before* expiry, so refusing while the -issuer is slow would answer 503 to every viewer over a dependency this process -does not yet need. A route gate that asked per request turned a 503-ing issuer -into one audited mint per request, which is worse than the heartbeat this -runtime replaced, because the heartbeat at least minted on a timer. +An idle runtime makes **no credential acquisition or renewal requests** after +boot and installation processing. Health probes read process state only. Real +viewer operations acquire on demand through the existing single-flight controller: +it returns a held current credential, and renews only when work needs a credential +inside its renewal lead. Renewal rereads the rotated projection and checks the +frozen authority before sending. A transient failure backs off while a held, +unexpired credential remains usable; an expired one is never used. + +Presence and build admission belong to delivered declarations and installation +transitions. A short-lived Work Context authorizes actual work; it does not +register this process or extend registry presence. The host still checks live +installation consent, revisions and revocation on protected requests. **A refused first mint fails the boot and is never retried. An unavailable one is waited for, within a bound.** The two are different answers: @@ -606,29 +600,13 @@ Publishing is not announcing — nothing is pushed, and answering makes this solution present to nobody: the host builds its registry entry from the delivered presence document and its own constants, never from this route. -`/health` **asks the credential** rather than reporting only what some other -request happened to discover. Renewal is lazy by design, so a solution serving -only ViewerBearer routes, plain handlers and assets would otherwise never ask and -never learn that the issuer had stopped approving its build. Asking is not a -heartbeat: the client holds one credential and returns the same one until its own -renewal point, so a probe is a mutex and a comparison except at the renewal the -credential's expiry dictates. A credential that cannot currently be obtained is -503 and nothing ends; a refusal is 503 **and** ends the process. - -So `/health` answers 200 while this process can do its job, and **503 once the -issuer has refused this execution's credential for good** — `ErrMintRefused` at -renewal, which is a judgement about this execution that no retry changes. -(`ErrRevoked` is not one of these: it never comes back from the mint, only from -a callee rejecting a capability this runtime minted for a viewer, which is a 409 -on that request.) The process then stops serving and returns that reason, for the -orchestrator to restart it against the delivery as it stands. An unconditional -200 was wrong in exactly the case the probe exists for: the boot already treats a -refusal as terminal, while at renewal the same judgement reached a page as -"unavailable, one renewal fixes it", so a solution would answer 503 to every -request forever and report itself healthy throughout — a solution that serves -nothing and looks alive, which is what the delivered-presence model replaced the -heartbeat to avoid. An *unavailable* mint is not a judgement and deliberately -does not do this: exiting on a transient dependency failure is a crash loop. +`/health` reports process health without acquiring or renewing a credential. +It answers 200 while idle, including when installed nowhere or a held credential +has expired. This is not an authorization verdict: every actual viewer operation +passes the credential gate and the host's live consent and revocation checks. +A terminal refusal observed during actual work is retained, makes health answer +503 and ends the process. A transient mint outage refuses work that has no +usable credential but does not turn health probes into repeated mint attempts. The manifest declares the majors it is built against rather than leaving a reader to assume them — `schemaVersion: 1`, this runtime's manifest schema, and diff --git a/credential.go b/credential.go index 5c646eb..507d4cb 100644 --- a/credential.go +++ b/credential.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "log" - "math/rand/v2" "net/http" "sync" "time" @@ -402,54 +401,29 @@ func (s *Server) openCredential(ctx context.Context) error { } } -// awaitInstallation asks the host for this execution's credential for as long -// as this process holds none because nothing has installed the solution, and -// returns when ctx ends or the credential is refused for good. -// -// It exists because an uninstalled solution has no other asker. Health and the -// install step answer from what is already known (see uninstalled), and a -// viewer's request cannot arrive: the host routes nobody to a solution -// installed nowhere. So without this the first credential would wait for a -// request the host will not send. -// -// Every ask goes through the controller (askOnce): single-flight with any -// viewer's ask, the answer recorded where every other reader looks, and a -// terminal answer ending the process exactly as it does on any other path. An -// ask made around the controller left a refusal latched in the source and -// reported by nobody. -// -// Two cadences. Uninstalled and idle it asks slowly, with jitter so replicas -// that booted together stop asking together. After an install step has been -// acknowledged (installAcknowledged) it asks at once and then a few times in -// quick succession, because the host activates the installation only after it -// has read this runtime's answer — the first ask can race it. +// awaitInstallation reacts only to an acknowledged installation. The host +// activates it after reading the acknowledgement, so a bounded retry sequence +// may bridge that transition. No idle timer acquires or renews credentials; +// after the sequence, an actual viewer request can acquire through the same +// single-flight controller. Presence and admission never depend on this loop. func (s *Server) awaitInstallation(ctx context.Context) { - idle := time.NewTimer(awaitInstallationWait()) - defer idle.Stop() for { - followUps := []time.Duration(nil) select { case <-ctx.Done(): return case <-s.credentialRefusedC(): return case <-s.installed: - followUps = awaitInstallationFollowUps - case <-idle.C: } for ask := 0; s.awaitsInstallation(); ask++ { credential, err := s.askOnce(ctx, platformRequestTimeout) if err == nil { seal := credential.Seal() - log.Printf("solution %q: installed; holding one execution credential, sealed to installation %s revision %d and build incarnation %d, valid until %s", - s.manifest.ID, seal.GetInstallationId(), seal.GetInstallationRevision(), seal.GetBuildIncarnation(), - credential.ExpiresAt().UTC().Format(time.RFC3339)) + log.Printf("solution %q: installation acknowledged; credential sealed to installation %s revision %d and build incarnation %d, valid until %s", + s.manifest.ID, seal.GetInstallationId(), seal.GetInstallationRevision(), seal.GetBuildIncarnation(), credential.ExpiresAt().UTC().Format(time.RFC3339)) break } - if errors.Is(err, ErrCredentialRefused) || ask >= len(followUps) { - // Refused: askOnce has recorded it and the process is - // ending. Otherwise still uninstalled, or the host is not - // answering — the idle cadence asks again. + if errors.Is(err, ErrCredentialRefused) || ask >= len(awaitInstallationFollowUps) { break } select { @@ -457,37 +431,17 @@ func (s *Server) awaitInstallation(ctx context.Context) { return case <-s.credentialRefusedC(): return - case <-time.After(followUps[ask]): - } - } - if !idle.Stop() { - select { - case <-idle.C: - default: + case <-time.After(awaitInstallationFollowUps[ask]): } } - idle.Reset(awaitInstallationWait()) } } -// awaitInstallationEvery is how often an uninstalled solution asks again, and -// awaitInstallationJitter the fraction of it one ask may arrive early by. -const ( - awaitInstallationEvery = 20 * time.Second - awaitInstallationJitter = 0.2 -) - -// awaitInstallationFollowUps paces the asks after an acknowledged install -// step: the host activates the installation once it has read the answer, so -// the credential is a moment away rather than a cadence away. +// Bounded retries for the activation race after an installation acknowledgement. var awaitInstallationFollowUps = []time.Duration{ 250 * time.Millisecond, 500 * time.Millisecond, time.Second, 2 * time.Second, 4 * time.Second, } -func awaitInstallationWait() time.Duration { - return time.Duration(float64(awaitInstallationEvery) * (1 - awaitInstallationJitter*rand.Float64())) -} - // awaitingInstallation reports the host's answer that nobody has installed // this solution yet. func awaitingInstallation(err error) bool { diff --git a/credential_test.go b/credential_test.go index 24111ac..6a6cee2 100644 --- a/credential_test.go +++ b/credential_test.go @@ -714,12 +714,9 @@ func TestATerminalRenewalRefusalEndsTheProcessRatherThanServing503Forever(t *tes server := New(Manifest{ID: testSolutionID}).Credential(refusingSource{err: tc.err}) server.cfg.gatewayURL = gw.URL - // Health asks the credential, so with a source that refuses it is - // already unhealthy before any request — which is the point of - // C5: a solution serving no module calls must not look alive while - // the issuer has stopped approving its build. - if status, _ := healthStatus(t, server); status != http.StatusServiceUnavailable { - t.Errorf("health = %d while the credential cannot be obtained, want 503: the probe is the only channel this runtime has, and lazy renewal means nothing else may ever ask", status) + // Health observes; only actual work may acquire a credential. + if status, _ := healthStatus(t, server); status != http.StatusOK { + t.Errorf("health before any credential request = %d, want 200", status) } header := http.Header{} @@ -753,13 +750,10 @@ func TestATerminalRenewalRefusalEndsTheProcessRatherThanServing503Forever(t *tes t.Error("the reason this process is ending was not recorded, so its exit would say nothing") } } else { - // Unhealthy, because this process cannot act for a viewer - // until the credential can be obtained — but NOT ending, which - // is the distinction that matters: the host stops routing to - // it, and it recovers when the issuer does. Exiting here would - // turn a dependency blip into a crash loop. - if status != http.StatusServiceUnavailable { - t.Errorf("health = %d while the credential cannot be obtained, want 503", status) + // The failed operation is unavailable; the process stays alive + // without health probes retrying the mint. + if status != http.StatusOK { + t.Errorf("health = %d after transient request failure, want 200 without an idle retry", status) } if strings.Contains(body, "refused") { t.Errorf("the probe says %q, which reads as a judgement on this build: this condition is transient", body) @@ -802,8 +796,8 @@ func TestHealthIsHonestAboutTheCredential(t *testing.T) { for range 5 { healthStatus(t, server) } - if got := mint.count(); got != 1 { - t.Errorf("six probes produced %d mints, want 1: the client holds one credential and the probe must not become a heartbeat", got) + if got := mint.count(); got != 0 { + t.Errorf("six probes produced %d mints, want 0: probes never acquire credentials", got) } }) diff --git a/solution.go b/solution.go index a80e4af..cb641a1 100644 --- a/solution.go +++ b/solution.go @@ -198,7 +198,7 @@ type Server struct { installed chan struct{} // awaiting is set once the host has answered that nothing has installed // this solution, and cleared when a credential is held. It is what keeps - // awaitInstallation asking through a transient answer in between; whether + // installation-triggered retries going through a transient answer; whether // the process may SERVE uninstalled is a stricter question (uninstalled). awaiting atomic.Bool terminalErr atomic.Pointer[error] @@ -1017,68 +1017,19 @@ const ( inboundIdleTimeout = 30 * time.Second ) -// handleHealth answers the host's probe with what this process can actually do. -// -// It was an unconditional 200, which is only right while the one thing this -// runtime needs is still true. Once the issuer has refused this execution's -// credential for good, the process can serve no module call for any viewer: a -// 200 then invites the host to keep routing to a binding that answers 503 to -// everything, and the probe is the only channel this runtime has to say -// otherwise — it does not push liveness, so it has to answer honestly. -func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { +// handleHealth reports process health without acquiring a credential. An idle +// process must not renew merely because an orchestrator probes it. Every real +// viewer operation still passes actingForAViewer and the host's live checks. +func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) { if err := s.terminalErr.Load(); err != nil { - // Named, not detailed: a probe is not a place to put the issuer's text. http.Error(w, "this execution's credential has been refused and will not be renewed; this process is ending", http.StatusServiceUnavailable) return } - // The probe asks the credential, rather than only reporting a refusal some - // *other* request happened to discover. - // - // Renewal is lazy by design — the client renews when a caller asks for the - // credential — so a solution that serves only ViewerBearer routes, plain - // handlers and assets never asks, and therefore never learns that the - // issuer has stopped approving this build. It would keep answering 200 and - // keep serving, with the refusal arriving only if some request eventually - // needed a capability. That is the same "serves nothing, looks alive" shape - // one layer out, and the probe is the one channel this runtime has. - // - // Asking is cheap and is not a heartbeat: the client holds one credential - // and hands the same one back until its renewal point, so this is a - // mutex and a comparison except at the renewal the credential's own expiry - // dictates. Nothing here runs on a timer this runtime chose. - // Admitted and installed nowhere is healthy: this process serves the one - // thing it can be asked for, its install step, and a probe that failed it - // would have the orchestrator restart a solution for not being installed. - // Answered from what the host last said, without asking it again. - if s.uninstalled() { - w.WriteHeader(http.StatusOK) - return - } - if s.credential != nil || s.brokerCredential != nil { - // Through credentialWithin, so the deadline is real: passing a context - // to the SDK's client does not bound it, because what blocks is a - // mutex held across the mint. - if _, err := s.credentialWithin(r.Context(), healthCredentialTimeout); err != nil { - if errors.Is(err, ErrCredentialRefused) { - http.Error(w, "this execution's credential has been refused and will not be renewed; this process is ending", http.StatusServiceUnavailable) - return - } - // Transient: the issuer cannot answer right now. Still unhealthy — - // this process cannot act for a viewer until it can — but it is not - // a judgement, so nothing ends. - http.Error(w, "this execution's credential cannot currently be obtained", http.StatusServiceUnavailable) - return - } - } w.WriteHeader(http.StatusOK) } const routeCredentialTimeout = 500 * time.Millisecond -// healthCredentialTimeout bounds what a probe will wait for the credential, so -// a hung issuer makes the probe fail rather than parking it. -const healthCredentialTimeout = 2 * time.Second - // actingForAViewer refuses to act on a viewer's behalf while this process holds // no credential the issuer still approves. // diff --git a/uninstalled_test.go b/uninstalled_test.go index 9b9dabc..d5960ac 100644 --- a/uninstalled_test.go +++ b/uninstalled_test.go @@ -9,7 +9,10 @@ import ( "strings" "sync/atomic" "testing" + "testing/synctest" "time" + + "github.com/codefly-dev/sdk-go/workcontext" ) // postInstall sends one well-formed install step to a booted runtime as the @@ -95,13 +98,13 @@ func TestAnUninstalledSolutionServesItsInstallStepAndThenHoldsACredential(t *tes // Held promptly — well inside the idle cadence, because the acknowledged // step is what says the credential is now worth asking for. - deadline := time.Now().Add(awaitInstallationEvery / 2) + deadline := time.Now().Add(10 * time.Second) for { if _, held := s.heldCredential(); held { break } if time.Now().After(deadline) { - t.Fatalf("no credential held %s after the install step was acknowledged and the host began issuing", awaitInstallationEvery/2) + t.Fatalf("no credential held %s after the install step was acknowledged and the host began issuing", 10*time.Second) } time.Sleep(20 * time.Millisecond) } @@ -164,21 +167,48 @@ func TestAnInstalledRuntimeStillHoldsTheInstallStepToItsCredential(t *testing.T) } } -func TestAwaitInstallationCadenceIsJitteredDownward(t *testing.T) { - low := time.Duration(float64(awaitInstallationEvery) * (1 - awaitInstallationJitter)) - distinct := map[time.Duration]bool{} - for range 200 { - wait := awaitInstallationWait() - if wait > awaitInstallationEvery || wait < low { - t.Fatalf("wait %s outside [%s, %s]", wait, low, awaitInstallationEvery) - } - distinct[wait] = true - } - if len(distinct) < 2 { - t.Fatal("every replica waits the same time, so replicas that boot together ask together") +// Advancing beyond both the former polling and renewal intervals must make no +// acquisition. The same controller is then exercised by a real request. +func TestIdleRuntimeAndHealthNeverMint(t *testing.T) { + for _, uninstalled := range []bool{false, true} { + t.Run(map[bool]string{false: "registered", true: "uninstalled"}[uninstalled], func(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + var calls atomic.Int32 + s := New(Manifest{ID: testSolutionID}) + s.credential = countingIdleSource{calls: &calls} + if uninstalled { + s.noteCredentialAnswer(&brokerMintError{class: brokerAwaitingInstallation}) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go s.awaitInstallation(ctx) + for range 120 { + time.Sleep(30 * time.Second) + if status, _ := healthStatus(t, s); status != http.StatusOK { + t.Fatalf("idle health = %d", status) + } + } + if got := calls.Load(); got != 0 { + t.Fatalf("one idle hour produced %d credential acquisitions", got) + } + if err := s.actingForAViewer(ctx); err == nil { + t.Fatal("actual work was not refused without a credential") + } + if got := calls.Load(); got != 1 { + t.Fatalf("actual work made %d acquisitions, want 1", got) + } + }) + }) } } +type countingIdleSource struct{ calls *atomic.Int32 } + +func (s countingIdleSource) Credential(context.Context) (workcontext.Credential, error) { + s.calls.Add(1) + return workcontext.Credential{}, workcontext.ErrMintUnavailable +} + // TestTheAskerSurvivesATransientAnswerWhileUninstalled: one answer that // judges nothing — the host restarting, a pass in flight — leaves the solution // exactly as uninstalled as it was. An asker that stopped there left the first @@ -204,13 +234,13 @@ func TestTheAskerSurvivesATransientAnswerWhileUninstalled(t *testing.T) { t.Fatalf("install step = %d, want 204", status) } phase.Store(1) - deadline := time.Now().Add(awaitInstallationEvery / 2) + deadline := time.Now().Add(10 * time.Second) for { if _, held := s.heldCredential(); held { return } if time.Now().After(deadline) { - t.Fatalf("no credential held %s after one transient answer: the asker stopped, and nothing but the host's next install delivery would start it again", awaitInstallationEvery/2) + t.Fatalf("no credential held %s after one transient answer: the asker stopped, and nothing but the host's next install delivery would start it again", 10*time.Second) } time.Sleep(20 * time.Millisecond) } From 85873a97d9feea349e348f46da33f2d69a2e345c Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:13:46 -0400 Subject: [PATCH 3/5] fix: bind native component listeners to their resolved loopback host --- AGENTS.md | 1 + README.md | 13 +++++++++++ boot_test.go | 4 +++- listen_port_test.go | 57 ++++++++++++++++++++++++++++++++++++++++++++- solution.go | 35 ++++++++++++++++++++++++++-- 5 files changed, 106 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2db0f67..8aabe39 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -162,6 +162,7 @@ embedding it. `RequestHandler`) as much as the calls it makes. Read that list before you change a signature, a struct field or an exported constant; a change that breaks a consumer carries a `!` in the commit type and says so in the PR body. +- Native local listeners bind their SDK-resolved loopback host; `PORT` overrides only the port. Container and Kubernetes listeners retain container-wide binding under platform network admission. - Boot configuration is resolved in one place, `loadConfig`, and checked in one place, `validate()`: resolved through the SDK, with an explicit env override, refused loudly when unresolved. New configuration follows both rather than diff --git a/README.md b/README.md index badbb65..74a879e 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,19 @@ The CLI projects the service-account token read-only at still available. Token reads are bounded to 64 KiB and repeated for every mint; missing, empty, unreadable or oversized files fail closed before a request. +## Native listener isolation + +On a local native run the runtime binds the loopback host and port of its own +SDK-resolved `http` endpoint. `PORT` can change the port, but cannot broaden the +listener to other interfaces. An absent or non-loopback native endpoint fails +configuration validation. The CLI supplies the endpoint automatically; there is +no separate host override to copy into each component. + +Container and Kubernetes processes retain their container-wide listener, where +the platform owns network admission. A local environment name alone does not +turn a container listener into a loopback listener. Production admission still +requires the declared mesh boundary; this change does not qualify that boundary. + ## Handler errors Return `ClientError` for an explicitly public validation message. Connect errors diff --git a/boot_test.go b/boot_test.go index 58714ef..ccc8f66 100644 --- a/boot_test.go +++ b/boot_test.go @@ -396,6 +396,8 @@ func bootEnvironment(t *testing.T, mint *hostMint) { ln, port := boundPort(t) provideListener(t, ln) t.Setenv("PORT", port) + t.Setenv(resources.ServicePrefix, "backend") + t.Setenv("CODEFLY__ENDPOINT__WIDGETS__BACKEND__HTTP__HTTP", ln.Addr().String()) // The gateway's workload endpoint, which serves the mint beside every // other route: one resolved origin, and the mint address derived on it. // Plaintext to loopback, which the SDK's rule admits on a local run — and @@ -752,7 +754,7 @@ func TestABootRefusesAPlaintextGatewayTheMeshDoesNotCarry(t *testing.T) { // derived on the same origin is held by the same words at every mint. func TestAPlaintextGatewayIsAdmittedExactlyWhereTheSDKsRuleAdmitsIt(t *testing.T) { base := func(gateway string, transport meshTransport) config { - return config{brokerInternalToken: "broker-perimeter-token", port: "8080", profile: localProfile, + return config{brokerInternalToken: "broker-perimeter-token", port: "8080", listenHost: "127.0.0.1", profile: localProfile, gatewayURL: gateway, mintURL: gateway + credentialMintPath, transport: transport} } asserted := meshTransport{value: "true", set: true} diff --git a/listen_port_test.go b/listen_port_test.go index 75e8561..08edca1 100644 --- a/listen_port_test.go +++ b/listen_port_test.go @@ -1,6 +1,13 @@ package solution -import "testing" +import ( + "context" + "net" + "testing" + + "github.com/codefly-dev/core/resources" + codefly "github.com/codefly-dev/sdk-go" +) // TestListenPortReadsEitherAddressForm pins the two shapes Codefly hands a // service for its own endpoint: a rendered cell writes the bare host:port, a @@ -19,3 +26,51 @@ func TestListenPortReadsEitherAddressForm(t *testing.T) { } } } + +func TestNativeListenerUsesTheAllocatedLoopbackInterface(t *testing.T) { + for _, address := range []string{"127.0.0.1:0", "http://127.0.0.1:0"} { + s := New(Manifest{ID: testSolutionID}) + s.cfg = config{port: listenPort(address), listenHost: listenHost(address), transport: meshTransport{local: true}} + ln, err := s.listen() + if err != nil { + t.Fatal(err) + } + bound := ln.Addr().(*net.TCPAddr) + _ = ln.Close() + if !bound.IP.IsLoopback() { + t.Fatalf("native listener exposed on %s", bound) + } + } +} + +func TestLoadConfigPreservesNativeListenerHostWithAPortOverride(t *testing.T) { + t.Setenv(resources.ServicePrefix, "backend") + t.Setenv(resources.EnvironmentPrefix, "local") + t.Setenv(resources.RuntimeContextPrefix, "native") + t.Setenv("CODEFLY__ENDPOINT__WIDGETS__BACKEND__HTTP__HTTP", "http://127.0.0.1:41234") + t.Setenv("PORT", "41235") + if err := codefly.LoadEnvironmentVariables(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = codefly.LoadEnvironmentVariables() }) + c := loadConfig(context.Background(), testSolutionID, nil) + if !c.nativeListener() || c.listenHost != "127.0.0.1" || c.port != "41235" { + t.Fatalf("native endpoint was not preserved: host=%q port=%q", c.listenHost, c.port) + } +} + +func TestNativeListenerRefusesAnUnresolvedOrNonLoopbackHost(t *testing.T) { + c := config{port: "8080", gatewayURL: "http://127.0.0.1:8081", brokerInternalToken: "perimeter", profile: "local", transport: meshTransport{local: true}} + for _, host := range []string{"", "0.0.0.0", "::", "192.0.2.10"} { + c.listenHost = host + if err := c.validate(); err == nil { + t.Fatalf("native host %q admitted", host) + } + } + for _, host := range []string{"127.0.0.1", "::1", "localhost"} { + c.listenHost = host + if err := c.validate(); err != nil { + t.Fatalf("loopback host %q refused: %v", host, err) + } + } +} diff --git a/solution.go b/solution.go index cb641a1..ade91bc 100644 --- a/solution.go +++ b/solution.go @@ -244,6 +244,7 @@ type Server struct { } type config struct { + listenHost string port string gatewayURL string // dialled is every address this process talks to, assembled at boot. @@ -351,6 +352,21 @@ func listenPort(self string) string { return "" } +// listenHost preserves the owning endpoint's allocated interface on native runs. +func listenHost(self string) string { + if strings.Contains(self, "://") { + if u, err := url.Parse(self); err == nil { + return u.Hostname() + } + return "" + } + host, _, err := net.SplitHostPort(self) + if err != nil { + return "" + } + return host +} + // address returns a resolved endpoint's "scheme://host:port", or "" if the SDK // could not resolve it. Using the SDK keeps addresses and ports out of the // runtime: they come from Codefly's runtime-injected endpoint map (or, in a @@ -557,6 +573,7 @@ func loadConfig(ctx context.Context, id string, initErr error) config { cfg := config{ brokerInternalToken: strings.TrimSpace(internalToken), port: port, + listenHost: listenHost(self), gatewayURL: gatewayURL, assetsDir: env("ASSETS_DIR", "../fe-remote/dist"), dialled: dialledAddresses(gatewayURL), @@ -718,6 +735,12 @@ func (c config) validate() error { if err := c.validateMCP(); err != nil { return err } + if c.nativeListener() { + ip := net.ParseIP(c.listenHost) + if c.listenHost != "localhost" && (ip == nil || !ip.IsLoopback()) { + return fmt.Errorf("native listener requires the SDK-resolved loopback host of this service's http endpoint; PORT overrides only its port") + } + } c.logResolved() return nil } @@ -974,7 +997,7 @@ func (s *Server) serve(ctx context.Context, ln net.Listener) error { defer stopped() go s.awaitInstallation(serving) - log.Printf("solution %q listening on :%s (gateway=%s, profile=%s)", s.manifest.ID, s.cfg.port, redactedURL(s.cfg.gatewayURL), s.cfg.profile) + log.Printf("solution %q listening on %s (gateway=%s, profile=%s)", s.manifest.ID, ln.Addr(), redactedURL(s.cfg.gatewayURL), s.cfg.profile) if serveErr := srv.Serve(ln); !errors.Is(serveErr, http.ErrServerClosed) { return serveErr } @@ -997,11 +1020,19 @@ func (s *Server) serve(ctx context.Context, ln net.Listener) error { // decision the cell makes for every workload. What this process still decides // is what a caller may do once admitted, which the stamped identity headers // and this execution's credential govern route by route. +func (c config) nativeListener() bool { + return c.transport.local && c.runtimeContext != resources.RuntimeContextContainer && c.runtimeContext != resources.RuntimeContextKubernetes +} + func (s *Server) listen() (net.Listener, error) { if s.boundListener != nil { return s.boundListener, nil } - ln, err := net.Listen("tcp", ":"+s.cfg.port) + host := "" + if s.cfg.nativeListener() { + host = s.cfg.listenHost + } + ln, err := net.Listen("tcp", net.JoinHostPort(host, s.cfg.port)) if err != nil { return nil, fmt.Errorf("listen on :%s: %w", s.cfg.port, err) } From b71f929636a2d1d2c66232bb71c1dc093575e6d1 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:29:55 -0400 Subject: [PATCH 4/5] fix: share frontend validation and allow headless components --- AGENTS.md | 4 + README.md | 21 ++- boot_test.go | 26 +++- frontend.go | 359 ++--------------------------------------------- go.mod | 34 +++-- go.sum | 141 ++++++++++--------- solution.go | 22 +-- solution_test.go | 49 +++++-- 8 files changed, 196 insertions(+), 460 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8aabe39..3711e84 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -672,3 +672,7 @@ Keep this file short; add depth to the file that owns the subject. - `.claude/skills/` — procedures an agent repeats, loaded only when relevant. None exist yet, because nothing here repeats beyond the four commands above. Add one instead of growing this file. + +The optional frontend declaration uses Core's `solutionhost.ParseFrontend` and +its types directly. Nil is headless; a present malformed file refuses boot. +Do not introduce a second parser, size limits or a fixed client-kind inventory. diff --git a/README.md b/README.md index 74a879e..62c13fb 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ handshake, the manifest served from the declared frontend file, the published authority contract, and the gateway client each handler uses to read composed modules on the viewer's behalf. -A solution author writes a manifest, embeds the frontend it declares, and +A component author writes a manifest, optionally embeds a frontend, and writes one handler: ```go @@ -638,8 +638,9 @@ The backend embeds it — `//go:embed solution.frontend.codefly.yaml` into file into the presence document's `frontend` block (core's `solutionhost.Frontend`), which is what the host's registry projects. Two readers of one file cannot disagree, where a Go field beside a file could, and -nothing is reported at mint any more. A boot without the file is refused -naming it; so is a file the CLI would refuse. +nothing is reported at mint any more. The declaration is optional for either +component kind: a nil `Manifest.Frontend` is headless. A present malformed file +is refused by the same Core reader used by the CLI. ```yaml title: Widgets # nav title; one non-empty line @@ -1362,3 +1363,17 @@ descriptor, with protobuf JSON names. > that leaf was tagged). Every capability's format changed in the v0.9.0 > cutover, so verifiers upgrade before minters or calls fail closed in the > window — which is a statement about the rollout order, not about these pins. + +### Shared frontend validation and headless components + +`Manifest.Frontend == nil` declares a headless component: it boots normally and +its frontend manifest route returns 404. A present file, including an empty one, +is validated by Core's `solutionhost.ParseFrontend`, the same reader the CLI uses +for modules and solutions before signed delivery. The runtime carries Core's +frontend types and bounds directly; it no longer maintains a client-kind list +or a second set of field validators. Client kinds are declared names, with +support decided by the consuming client. + +Core limits text to 1 KiB, surface/event/tag collections to 64 entries, a +dashboard to 64 KiB and the complete canonical frontend to 128 KiB. These are +file/declaration constraints, independent of per-organisation page admission. diff --git a/boot_test.go b/boot_test.go index ccc8f66..b4c8558 100644 --- a/boot_test.go +++ b/boot_test.go @@ -417,11 +417,20 @@ func bootEnvironment(t *testing.T, mint *hostMint) { // boot runs the whole boot a deployed solution runs — configuration, the // contract, the one mint, the TLS listener — on an ephemeral port. func boot(t *testing.T, server *Server, mint *hostMint) *booted { + t.Helper() + return bootComponent(t, server, mint, false) +} + +func bootComponent(t *testing.T, server *Server, mint *hostMint, headless bool) *booted { t.Helper() bootEnvironment(t, mint) ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) - ln, err := takeListener(server).start(ctx) + takeListener(server) + if headless { + server.manifest.Frontend = nil + } + ln, err := server.start(ctx) if err != nil { t.Fatalf("boot: %v", err) } @@ -953,8 +962,8 @@ func provideListener(t *testing.T, ln net.Listener) { // the boot binds for itself. // // It also embeds the fixture's declared frontend into a server that declared -// none, the way a solution's go:embed does: every boot requires the file, and -// a test about anything else should not have to spell it out. +// none, the way a solution's go:embed does. Existing fixtures include a page; +// bootComponent explicitly opts out when exercising a headless component. func takeListener(server *Server) *Server { if parkedListener != nil { server.boundListener = parkedListener @@ -1123,3 +1132,14 @@ type localRun struct{} func (localRun) WorkspaceValueIfSet(string, string) (string, bool, error) { return "", false, nil } func (localRun) IsLocalRun() bool { return true } + +func TestHeadlessComponentBootsWithoutAdvertisingAFrontend(t *testing.T) { + mint := newHostMint(t, &hostMint{}) + running := bootComponent(t, New(Manifest{ID: testSolutionID}), mint, true) + if status := getStatus(t, running.client, running.base+HealthPath); status != http.StatusOK { + t.Fatalf("headless health: %d", status) + } + if status := getStatus(t, running.client, running.base+"/.well-known/solution.json"); status != http.StatusNotFound { + t.Fatalf("headless component advertised a page: %d", status) + } +} diff --git a/frontend.go b/frontend.go index b412d6e..5188467 100644 --- a/frontend.go +++ b/frontend.go @@ -1,17 +1,9 @@ package solution import ( - "bytes" - "encoding/json" - "errors" "fmt" - "io" - "net/url" - "regexp" - "strings" - "unicode" - "gopkg.in/yaml.v3" + "github.com/codefly-dev/core/solutionhost" ) // FrontendFileName is the one file a solution declares its frontend in, beside @@ -45,352 +37,19 @@ import ( // events: [documents.entry] # optional; journal namespaces, lowercase dotted names, each once const FrontendFileName = "solution.frontend.codefly.yaml" -// frontendDashboardMaxBytes bounds the dashboard once encoded as JSON. The -// host carries the declaration inside a signed presence document and bounds it -// there too; the bound that fires is the one that can name the file. -const frontendDashboardMaxBytes = 64 << 10 +const frontendDashboardMaxBytes = solutionhost.FrontendDashboardMaxBytes -// frontendDeclaration is the declared file as this runtime holds it. The -// struct mirrors core's frontend block rather than importing it: the runtime -// serves the fields, it does not sign or parse a presence document. -type frontendDeclaration struct { - Title string - Order int64 - ExposedModule string - HostContract uint64 - Dashboard any - Surfaces []frontendSurface -} - -// frontendFile is the file's shape on the way in. The scalars are pointers so -// an omitted one is told apart from a zero: core refuses an omitted scalar, -// and this runtime reads the same file. -type frontendFile struct { - Title *string `yaml:"title"` - Order *int64 `yaml:"order"` - ExposedModule *string `yaml:"exposed_module"` - HostContract *int64 `yaml:"host_contract"` - Dashboard any `yaml:"dashboard"` - Surfaces []frontendSurface `yaml:"surfaces"` -} - -// frontendSurface is one offering a solution makes inside a client application -// — a Word add-in, a Slack app — rather than as a page the host renders. The -// host's registry projects them so a client can discover, without a table of -// its own, which solutions offer it something. -type frontendSurface struct { - ID string `yaml:"id"` // unique per client, e.g. "footnote" - Client string `yaml:"client"` // client kind: word, powerpoint, excel, slack - Title string `yaml:"title"` // what the client labels the surface with - // Description is the longer explanation a client may show beside the - // title. Optional, and omitted from the manifest when empty so a client - // renders no description rather than an empty one. - Description string `yaml:"description"` - // Module is the path, on this solution's own origin, of a self-contained - // ES module the client loads, resolved against the origin the client loaded - // this solution from. The check on it catches an author reaching off that - // origin by mistake; it is not what keeps a hostile solution from doing so - // deliberately, since a solution serves its own manifest and need not use - // this runtime at all. The host's registry and the client's loader own - // that boundary and have to enforce it themselves. - Module string `yaml:"module"` - // Contract is the surface contract major this module is built against, as - // the host client's own surface documentation defines it, so a client - // refuses a surface it cannot run rather than loading it and failing - // inside. - Contract int64 `yaml:"contract"` - // Applies is "always" or a {tagged: [...]} selector, declared rather than - // defaulted: core carries no default, so a file that left it out would be - // accepted here and refused by the CLI. Events are the journal namespaces - // the surface reconciles on. What a tag selects and what a namespace names - // are the client's to interpret, not this runtime's; what is held to here - // is the shape core signs. - Applies any `yaml:"applies"` - Events []string `yaml:"events"` -} +type frontendDeclaration = solutionhost.Frontend +type frontendSurface = solutionhost.FrontendSurface -// surfaceAppliesAlways is the one selector keyword; the other shape is a -// {tagged: [...]} mapping. -const surfaceAppliesAlways = "always" - -// eventNamespace is a journal namespace as core signs it: lowercase dotted -// names, each segment letters and digits separated by a dot, dash or -// underscore. -var eventNamespace = regexp.MustCompile(`^[a-z0-9]+([._-][a-z0-9]+)*$`) - -// surfaceClients are the client kinds a surface may declare. A client loads -// only what names it, so an unknown kind is a surface nothing will ever load — -// caught at boot rather than showing up as a solution that simply never -// appears in the add-in. -var surfaceClients = map[string]bool{"word": true, "powerpoint": true, "excel": true, "slack": true} - -// parseFrontend decodes and checks the declared file. Every refusal names the -// file, because the author wrote it by hand and a boot is where a mistake in -// it should surface — not as a solution the host lists under no title. +// parseFrontend uses the same reader the CLI uses before signed delivery. +// Core owns field semantics and limits; this runtime adds the author's filename. func parseFrontend(raw []byte) (frontendDeclaration, error) { - if len(bytes.TrimSpace(raw)) == 0 { - return frontendDeclaration{}, fmt.Errorf("this solution declares no frontend: embed %s (go:embed) into Manifest.Frontend — it is the one source of the title, order, exposed module, host contract, dashboard and surfaces the host lists this solution under, and the CLI delivers the same file in the presence document", FrontendFileName) - } - decoder := yaml.NewDecoder(bytes.NewReader(raw)) - decoder.KnownFields(true) - var file frontendFile - if err := decoder.Decode(&file); err != nil { - return frontendDeclaration{}, fmt.Errorf("%s cannot be decoded: %w", FrontendFileName, err) - } - // One document. A second one would be silently dropped otherwise, and a - // file that looks like two declarations is not one somebody meant. - if err := decoder.Decode(new(any)); !errors.Is(err, io.EOF) { - return frontendDeclaration{}, fmt.Errorf("%s carries more than one YAML document; it declares one frontend", FrontendFileName) - } - // Every scalar declared. Core reads an omitted one as a refusal rather - // than as zero, so the file this runtime accepts is the file the CLI - // delivers, or the boot says which key is missing. - for _, scalar := range []struct { - key string - declared bool - }{ - {"title", file.Title != nil}, {"order", file.Order != nil}, - {"exposed_module", file.ExposedModule != nil}, {"host_contract", file.HostContract != nil}, - } { - if !scalar.declared { - return frontendDeclaration{}, fmt.Errorf("%s declares no %s: every scalar of the frontend block is declared, and the presence document the CLI reads from this file refuses an omitted one", FrontendFileName, scalar.key) - } - } - declared := frontendDeclaration{ - Title: strings.TrimSpace(*file.Title), Order: *file.Order, - ExposedModule: strings.TrimSpace(*file.ExposedModule), Surfaces: file.Surfaces, - } - if declared.Title == "" || strings.ContainsAny(declared.Title, "\r\n") { - return frontendDeclaration{}, fmt.Errorf("%s: title must be one non-empty line: the host lists this solution in its navigation under it", FrontendFileName) - } - if declared.ExposedModule == "" || strings.ContainsAny(declared.ExposedModule, "\r\n") { - return frontendDeclaration{}, fmt.Errorf("%s: exposed_module must be one non-empty line, the Module Federation module the host loads this solution's page from, e.g. ./Page", FrontendFileName) - } - // The host runs a page only when it recognises the contract major it - // declares, so a zero is not a lenient default: it is a solution every - // host refuses, in a boot that otherwise looks entirely healthy. - if *file.HostContract < 1 { - return frontendDeclaration{}, fmt.Errorf("%s: host_contract %d is not a host contract major; declare the major this solution's page is built against (>= 1)", FrontendFileName, *file.HostContract) - } - declared.HostContract = uint64(*file.HostContract) - if file.Dashboard != nil { - // Opaque, but it has to BE the JSON object the host reads: it is - // served as the manifest's dashboard slot and carried as JSON in the - // presence document, where core refuses what JSON spells more than - // one way. So it is a mapping, every mapping key is a string and no - // value is a fraction — held to here rather than left to the encoder, - // which stringifies an integer key and would serve a dashboard the - // file did not say. - dashboard, err := jsonValue(file.Dashboard) - if err != nil { - return frontendDeclaration{}, fmt.Errorf("%s: dashboard is not a JSON value the host can read (%v)", FrontendFileName, err) - } - if _, ok := dashboard.(map[string]any); !ok { - return frontendDeclaration{}, fmt.Errorf("%s: dashboard must be a mapping, the JSON object the host's data-graph validator reads", FrontendFileName) - } - encoded, err := json.Marshal(dashboard) - if err != nil { - return frontendDeclaration{}, fmt.Errorf("%s: dashboard is not a JSON value (%v)", FrontendFileName, err) - } - if len(encoded) > frontendDashboardMaxBytes { - return frontendDeclaration{}, fmt.Errorf("%s: dashboard is %d bytes as JSON and at most %d are carried", FrontendFileName, len(encoded), frontendDashboardMaxBytes) - } - declared.Dashboard = dashboard - } - for i := range declared.Surfaces { - if declared.Surfaces[i].Applies == nil { - continue - } - applies, err := jsonValue(declared.Surfaces[i].Applies) - if err != nil { - return frontendDeclaration{}, fmt.Errorf("%s: surface %q: applies is not a JSON value (%v)", FrontendFileName, declared.Surfaces[i].ID, err) - } - declared.Surfaces[i].Applies = applies - } - if err := validateSurfaces(declared.Surfaces); err != nil { + declared, err := solutionhost.ParseFrontend(raw) + if err != nil { return frontendDeclaration{}, fmt.Errorf("%s: %w", FrontendFileName, err) } - return declared, nil -} - -// jsonValue is a decoded YAML value as JSON holds it: every mapping keyed by -// strings, recursively. yaml.v3 decodes a mapping with a non-string key into a -// map keyed by interface values, and Go's JSON encoder then stringifies an -// integer key and refuses a boolean one — so a YAML `1:` would be served as -// "1" and a `true:` would fail at the encoder. Both are refused by name. -func jsonValue(value any) (any, error) { - switch v := value.(type) { - case map[string]any: - out := make(map[string]any, len(v)) - for key, inner := range v { - converted, err := jsonValue(inner) - if err != nil { - return nil, err - } - out[key] = converted - } - return out, nil - case map[any]any: - out := make(map[string]any, len(v)) - for key, inner := range v { - name, ok := key.(string) - if !ok { - return nil, fmt.Errorf("mapping key %v is not a string", key) - } - converted, err := jsonValue(inner) - if err != nil { - return nil, err - } - out[name] = converted - } - return out, nil - case []any: - out := make([]any, len(v)) - for i, inner := range v { - converted, err := jsonValue(inner) - if err != nil { - return nil, err - } - out[i] = converted - } - return out, nil - case float64, float32: - return nil, fmt.Errorf("value %v is a fraction, which JSON spells more than one way", v) - } - return value, nil -} - -// validateSurfaces refuses a surface declaration no client could use. -func validateSurfaces(surfaces []frontendSurface) error { - // Keyed by client and id together: two clients never see each other's - // surfaces, so the same offering carries the same id in Word and in - // PowerPoint, and only a collision within one client is ambiguous. The id - // and client checks below run first, so neither half of the key can contain - // the separator and no two distinct pairs can collide into one string. - seen := make(map[string]bool, len(surfaces)) - for i, surface := range surfaces { - switch { - case surface.ID == "": - return fmt.Errorf("surface %d has no id", i) - case !isSurfaceID(surface.ID): - return fmt.Errorf("surface %q: id must be lowercase letters and digits, separated by dashes", surface.ID) - case surface.Title == "": - return fmt.Errorf("surface %q has no title: a client labels the surface with it, and renders a blank control without one", surface.ID) - case !surfaceClients[surface.Client]: - return fmt.Errorf("surface %q: unknown client kind %q", surface.ID, surface.Client) - case seen[surface.Client+"/"+surface.ID]: - return fmt.Errorf("surface %q declared twice for client %q: a client addresses a surface by id, so two of its own cannot share one", - surface.ID, surface.Client) - case surface.Contract < 1: - // A client runs a surface only when it recognises the contract - // major it declares, so the zero an author leaves behind is not a - // lenient default: it is a surface every client refuses, in a boot - // that otherwise looks entirely healthy. - return fmt.Errorf("surface %q: contract %d is not a surface contract major; declare the major this module is built against (>= 1)", - surface.ID, surface.Contract) - case !isSameOriginPath(surface.Module): - return fmt.Errorf("surface %q: module %q must be a path on this solution's own origin, e.g. %q", - surface.ID, surface.Module, "/surfaces/"+surface.Client+"/"+surface.ID+".js") - } - if err := validateApplies(surface.Applies); err != nil { - return fmt.Errorf("surface %q: %w", surface.ID, err) - } - if err := validateEvents(surface.Events); err != nil { - return fmt.Errorf("surface %q: %w", surface.ID, err) - } - seen[surface.Client+"/"+surface.ID] = true - } - return nil -} - -// validateApplies holds a surface's selector to the two shapes the host client -// reads: the keyword "always", or {tagged: [one or more non-empty tags]}. -// Declared, never defaulted: core refuses a surface declaring none. -func validateApplies(applies any) error { - switch selector := applies.(type) { - case nil: - return fmt.Errorf("declares no applies: write `applies: always`, or `applies: {tagged: [...]}` naming one or more tags") - case string: - if selector != surfaceAppliesAlways { - return fmt.Errorf("applies %q is neither %q nor a {tagged: [...]} selector", selector, surfaceAppliesAlways) - } - return nil - case map[string]any: - tagged, ok := selector["tagged"] - if !ok || len(selector) != 1 { - return fmt.Errorf("applies must be %q or a mapping with the one key tagged", surfaceAppliesAlways) - } - tags, ok := tagged.([]any) - if !ok || len(tags) == 0 { - return fmt.Errorf("applies.tagged must name one or more tags") - } - for _, tag := range tags { - if name, ok := tag.(string); !ok || strings.TrimSpace(name) == "" { - return fmt.Errorf("applies.tagged names an empty tag, which selects nothing") - } - } - return nil - } - return fmt.Errorf("applies must be %q or a {tagged: [...]} selector", surfaceAppliesAlways) -} - -// validateEvents holds a surface's journal namespaces to the grammar core -// signs: lowercase dotted names, each named once. -func validateEvents(events []string) error { - seen := make(map[string]bool, len(events)) - for _, event := range events { - if !eventNamespace.MatchString(event) { - return fmt.Errorf("event %q is not a journal namespace: lowercase letters and digits, in segments separated by a dot, dash or underscore", event) - } - if seen[event] { - return fmt.Errorf("event %q is named twice", event) - } - seen[event] = true - } - return nil -} - -// isSurfaceID accepts lowercase letters and digits separated by dashes. A -// leading or trailing dash is refused so that an id is never a prefix game, and -// so the empty string cannot pass by having no characters to object to. -func isSurfaceID(id string) bool { - if id == "" { - return false - } - for i, r := range id { - if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' { - continue - } - if r != '-' || i == 0 || i == len(id)-1 { - return false - } - } - return true -} - -// isSameOriginPath reports whether p addresses this solution's own origin: a -// rooted path and nothing else. -// -// Whether a path is rooted is decided by the parser that will resolve it — the -// client's, not Go's — and the two disagree on three inputs. A browser follows -// WHATWG URL, where a backslash beside the leading slash is read as a slash and -// what follows it as an authority, so "/\host/x.js" resolves to https://host/x.js -// while net/url reads that backslash as an ordinary path byte and reports no -// host at all; and where ASCII tab and newline are removed from the input before -// it is parsed, so "//host/x.js" becomes protocol-relative on arrival. None -// of the three can be seen in net/url's answer, so they are refused here by -// inspecting the bytes rather than by asking net/url what it made of them. -func isSameOriginPath(p string) bool { - if !strings.HasPrefix(p, "/") || strings.HasPrefix(p, "//") { - return false - } - for _, r := range p { - if r == '\\' || unicode.IsControl(r) { - return false - } - } - u, err := url.Parse(p) - return err == nil && u.Scheme == "" && u.Host == "" + return *declared, nil } // manifestMap is the manifest this runtime serves at /.well-known/solution.json: diff --git a/go.mod b/go.mod index 212aaae..81aa21e 100644 --- a/go.mod +++ b/go.mod @@ -4,13 +4,13 @@ go 1.27.0 require ( connectrpc.com/connect v1.21.0 - github.com/codefly-dev/core v0.19.1 + github.com/codefly-dev/core v0.19.5-0.20261013191419-d82010241d74 github.com/codefly-dev/sdk-go v0.3.2 github.com/codefly-dev/sdk-go/workcontext v0.1.1-0.20261013172033-aabfb4cf5359 github.com/google/uuid v1.6.0 github.com/modelcontextprotocol/go-sdk v1.8.0 golang.org/x/tools v0.51.0 - google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 + google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc google.golang.org/protobuf v1.36.12 gopkg.in/yaml.v3 v3.0.1 ) @@ -24,41 +24,45 @@ require ( github.com/Masterminds/semver/v3 v3.5.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/go-openapi/analysis v1.0.0 // indirect - github.com/go-openapi/errors v0.22.8 // indirect + github.com/go-openapi/errors v0.22.9 // indirect github.com/go-openapi/jsonpointer v1.0.1 // indirect github.com/go-openapi/jsonreference v1.0.2 // indirect github.com/go-openapi/loads v0.25.3 // indirect github.com/go-openapi/spec v1.0.1 // indirect github.com/go-openapi/strfmt v0.27.2 // indirect - github.com/go-openapi/swag/conv v0.29.1 // indirect - github.com/go-openapi/swag/jsonutils v0.29.1 // indirect - github.com/go-openapi/swag/loading v0.29.1 // indirect - github.com/go-openapi/swag/mangling v0.29.1 // indirect - github.com/go-openapi/swag/pools v0.29.1 // indirect - github.com/go-openapi/swag/stringutils v0.29.1 // indirect - github.com/go-openapi/swag/typeutils v0.29.1 // indirect - github.com/go-openapi/swag/yamlutils v0.29.1 // indirect + github.com/go-openapi/swag/conv v0.29.2 // indirect + github.com/go-openapi/swag/jsonutils v0.29.2 // indirect + github.com/go-openapi/swag/loading v0.29.2 // indirect + github.com/go-openapi/swag/mangling v0.29.2 // indirect + github.com/go-openapi/swag/pools v0.29.2 // indirect + github.com/go-openapi/swag/stringutils v0.29.2 // indirect + github.com/go-openapi/swag/typeutils v0.29.2 // indirect + github.com/go-openapi/swag/yamlutils v0.29.2 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/gofrs/flock v0.13.1 // indirect + github.com/google/go-github/v89 v89.0.0 // indirect + github.com/google/go-querystring v1.2.0 // indirect github.com/google/jsonschema-go v0.4.3 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/oklog/ulid/v2 v2.1.2 // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect github.com/segmentio/asm v1.1.3 // indirect github.com/segmentio/encoding v0.5.4 // indirect github.com/yoheimuta/go-protoparser/v4 v4.14.2 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect - golang.org/x/exp v0.0.0-20260820142414-ca536658362e // indirect + golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba // indirect golang.org/x/mod v0.41.0 // indirect golang.org/x/net v0.60.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.23.0 // indirect - golang.org/x/sys v0.48.0 // indirect + golang.org/x/sys v0.49.0 // indirect golang.org/x/term v0.46.0 // indirect golang.org/x/text v0.42.0 // indirect golang.org/x/time v0.15.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5 // indirect - google.golang.org/grpc v1.83.2 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc // indirect + google.golang.org/grpc v1.84.0 // indirect ) diff --git a/go.sum b/go.sum index 2342db5..ff2a160 100644 --- a/go.sum +++ b/go.sum @@ -30,8 +30,8 @@ github.com/cheggaaa/pb/v3 v3.2.1 h1:aprZbFRG+B7+ug76S8QZ6Y1PW168UHzOmbC3wa+aU6I= github.com/cheggaaa/pb/v3 v3.2.1/go.mod h1:U9hSVxoKqJrZIE3PkFG1xXXNaK/Ilzg+EF/scpXzEdw= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= -github.com/codefly-dev/core v0.19.1 h1:DLzrbgQu5Io6cnhD2QD9dAqNfh1HVwCs+G3BHP+JQFE= -github.com/codefly-dev/core v0.19.1/go.mod h1:FKBqn6GMGIsx9b3LNY6kKCRxCEjjeswvqt2CxSZWq20= +github.com/codefly-dev/core v0.19.5-0.20261013191419-d82010241d74 h1:tUEPZ1SJlpRUmEHasKr5++LHay8ZuoJxfd2xp9aiO+k= +github.com/codefly-dev/core v0.19.5-0.20261013191419-d82010241d74/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/sdk-go v0.3.2 h1:H1Tstn5f2HWO/Hp813RFxxDPAlWZEIN6gKtxpzai1CY= @@ -42,14 +42,16 @@ github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= +github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= -github.com/docker/go-connections v0.8.1 h1:JibmG5hULs5qXSr/cp/w3Pw5fZuStt4MOHMUExb29/M= -github.com/docker/go-connections v0.8.1/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= +github.com/docker/go-connections v0.8.2 h1:9d9DEokx5W7qlr0shkFXIxEA7+TTD+llwzcykSlxMNc= +github.com/docker/go-connections v0.8.2/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= @@ -58,8 +60,8 @@ github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeO github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= -github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= -github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk= +github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= @@ -68,8 +70,8 @@ github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-openapi/analysis v1.0.0 h1:sNvbAGCJqUTqIAodr9IVqJMmuZas3YS9ms1dGK9yiJ4= github.com/go-openapi/analysis v1.0.0/go.mod h1:NhYjJ57fnE+bcE7UwrJyMkhWA3Dfz7TdiBfTVAnos4Y= -github.com/go-openapi/errors v0.22.8 h1:oP7sW7TWc3wFFjrzzj0nI83H2qMBkNjNfSd+XRejk/I= -github.com/go-openapi/errors v0.22.8/go.mod h1:BuUoHcYrU6E7V9gfj1I5wLQqgtIHnup/alXZ8KdgQ0w= +github.com/go-openapi/errors v0.22.9 h1:HI9+SyVYiRzyeBQGv0CbWbQa+u2S0nny7G7AhY+UMiM= +github.com/go-openapi/errors v0.22.9/go.mod h1:R73q66sXP2smzAGgFOI4nqtusRK2xN5pYCjsij+KX58= github.com/go-openapi/jsonpointer v1.0.1 h1:2KxywRmNwJkT/FMBa3iRNHEaAxSJvjqoufQZy3au1Mg= github.com/go-openapi/jsonpointer v1.0.1/go.mod h1:wI7ZYsFmbIi9nBXOZqgDaS/bqOchRGZjqxFli7FBYxY= github.com/go-openapi/jsonreference v1.0.2 h1:oS4et8FOf3p3UQxEo4Xt0esijmBUM+F259Xl72OSZsc= @@ -80,36 +82,36 @@ github.com/go-openapi/spec v1.0.1 h1:lj2vdGpNDcVgwRc6qXdw6qt/KQpCtSa9tnUH6vpDPDk github.com/go-openapi/spec v1.0.1/go.mod h1:M//GWQGtDUAjnP37gE6fInLgaczB+FatoipV3H1fYw8= github.com/go-openapi/strfmt v0.27.2 h1:SG32SlbwNy92s0KJiVxt2joJeFdqIYHvwrA0OU6HqzQ= github.com/go-openapi/strfmt v0.27.2/go.mod h1:M4CKsMO0Fb8qR10+1Ra75wCKNNquy+Vj+4LWZrhTo2E= -github.com/go-openapi/swag v0.28.0 h1:xkgbOSKj6DZziNpyqRRAOt3GJGtgjgsd2RoyT30VWuw= -github.com/go-openapi/swag v0.28.0/go.mod h1:4qYnT3Cqr1p1VknOdPo70evN4rgQnAg6jwApHyxSGIg= -github.com/go-openapi/swag/cmdutils v0.28.0 h1:7TOeNtkYru1SG8Y34tDh9WBbLsMqGnptuxWiHREPZ4Q= -github.com/go-openapi/swag/cmdutils v0.28.0/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM= -github.com/go-openapi/swag/conv v0.29.1 h1:AC4Eh/5c/eUDOUCzzsRC9ghmFgOSBHeRMGIngY0ZUGA= -github.com/go-openapi/swag/conv v0.29.1/go.mod h1:S1X7/ZrBEZOC0Wc8AGxjbcGS92l3WEjA7aPtpl+RaqM= -github.com/go-openapi/swag/fileutils v0.28.0 h1:Z04XWQD7R8Eq+7GnOrjovBxPPmZzsS4gt2H2GPGIViU= -github.com/go-openapi/swag/fileutils v0.28.0/go.mod h1:VvJFZLTZS0AI854gEQz5tk7dBESdLjiNUMSZ/th2ry8= -github.com/go-openapi/swag/jsonutils v0.29.1 h1:AFCxs0eQZ24/QyfhVHM2t49rMz7Vv3XCsZQI6yrNy+c= -github.com/go-openapi/swag/jsonutils v0.29.1/go.mod h1:u3+sCfJpttDpcmS5kpm0yxL6GK0eWgODsx8Yw8fcqNM= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.29.1 h1:BiiXE31Bx9SfpsMmOQj5KYpUhTZBpLVriVhJDuLuY2o= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.29.1/go.mod h1:julgTUKZ9/D0j6O7GKajmRs+812FWxQg/mMpGunWSjg= -github.com/go-openapi/swag/loading v0.29.1 h1:FCv5fG8UhTdDJa2R7w+5O9Ekpcbw7tt0nFWvmDKGBjc= -github.com/go-openapi/swag/loading v0.29.1/go.mod h1:N0ESuem4p2oedKal8EJhciqnJ9Q9Wmt83L1CRB3Fouw= -github.com/go-openapi/swag/mangling v0.29.1 h1:lHALtvYCdxVnRl4GrHmFPwfBTZYIObqdGNSKyu/8D6I= -github.com/go-openapi/swag/mangling v0.29.1/go.mod h1:SAop9pB7PUjQ/CGCNf/JmCKTRK+GDO+RqE9UHqC/N6s= -github.com/go-openapi/swag/netutils v0.28.0 h1:YXN6TALEi2pzts8/8GNm6T61HTAZsieukGZidap989k= -github.com/go-openapi/swag/netutils v0.28.0/go.mod h1:J+WYyFMLtvtCGqa6jLv+YNUmIKI3ZRQRrvfNDMoQoEQ= -github.com/go-openapi/swag/pools v0.29.1 h1:NRogYxdEW9SjRM4mkAOji9iefO4MRXq3p/ZJcoQbUKg= -github.com/go-openapi/swag/pools v0.29.1/go.mod h1:leDcaghjkRAhCuCRv9NfJU5f0mjoU3cT/XZObhMk3pc= -github.com/go-openapi/swag/stringutils v0.29.1 h1:1ykunK7iJQk1uOO7+oUH1ukbsK85fFCOiCFMOVSY+F0= -github.com/go-openapi/swag/stringutils v0.29.1/go.mod h1:7fSqZ+z8Qc0tOfAAK0jVa5qFGrnIlRi6n7NeGGrr1vc= -github.com/go-openapi/swag/typeutils v0.29.1 h1:Nzv9nhnlLCRBPQqfOX+7lB6Guju370or8StT+lIOf6M= -github.com/go-openapi/swag/typeutils v0.29.1/go.mod h1:hxpgDZJVBkBsi/d3MIUosafoFdE5exaQRmVp0zwu3YE= -github.com/go-openapi/swag/yamlutils v0.29.1 h1:69w3tsBajm7MR/fejLy7HD/3J68Ys1SeeZMEzZ3w2sk= -github.com/go-openapi/swag/yamlutils v0.29.1/go.mod h1:rgsp3vT/QdWzKwn43CigDwjOGIenPyTZMKnxEM8jZOA= +github.com/go-openapi/swag v0.29.2 h1:9n8frkcsuQRA0INU31VbxNxt1zBaHxrHYGEf5BUjFi4= +github.com/go-openapi/swag v0.29.2/go.mod h1:RkCiX1gCVXWgLOavDXE3ALJrQ/VO2ziiIzOUs3z10xo= +github.com/go-openapi/swag/cmdutils v0.29.2 h1:cXEzX/nWCODon251f+1HgL75/R4XXML5IZxgLCWMoro= +github.com/go-openapi/swag/cmdutils v0.29.2/go.mod h1:XziaSVzYqkDvpWXzASfWajw2YvJrLscq610O2XiyqJw= +github.com/go-openapi/swag/conv v0.29.2 h1:8c9shoB8l0QRSR6ymq1llHdBWDqpIgJfjTGHx3Vuhm0= +github.com/go-openapi/swag/conv v0.29.2/go.mod h1:AZS0YigTNf8qNtD6WqJz/N4RUNmpr76SyjFGkq4+p6Q= +github.com/go-openapi/swag/fileutils v0.29.2 h1:mdUL+Vw5ah1fO1AvFCoHeIyv7YZsCz2MN3KNqd/lLVI= +github.com/go-openapi/swag/fileutils v0.29.2/go.mod h1:7QKmmodjAebaq61lGVJa8ldLdGeMF4DSgYv8tXOGGo0= +github.com/go-openapi/swag/jsonutils v0.29.2 h1:uZNSD2/rJDYAfvsLYkykTzXuyxM3QpDKV9jhRHrMu6k= +github.com/go-openapi/swag/jsonutils v0.29.2/go.mod h1:ONTdNvj3Y+IXRzfa17E+Rgt2ns/qiSOYjIo2K7v2yDs= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.29.2 h1:w+Fd6EBOMGlC74GYGHqCLGyb3Bpams8TtNrgM/SG4jo= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.29.2/go.mod h1:HC2egPORFzbj/gf05Zrfgz8Mgplx3oW7hgGi7pjZVhM= +github.com/go-openapi/swag/loading v0.29.2 h1:QU1ry24e6r6Shoxe380Nx0X5iKkpRO+hJffsyJ2dVlY= +github.com/go-openapi/swag/loading v0.29.2/go.mod h1:DqilDjuiJKETecsS4TRopWG1acUDsfqN39ZmQhPu6uI= +github.com/go-openapi/swag/mangling v0.29.2 h1:ltdo3K0tTP4P6zWhVVWDU9D37/YYBuMJRdZxiS2Dtog= +github.com/go-openapi/swag/mangling v0.29.2/go.mod h1:RjDi4TnItAenS3cgCXSpIl1kKbjG7il6pnmQVl/8bp8= +github.com/go-openapi/swag/netutils v0.29.2 h1:dBli6jyUa93sDS/XYWShJzf4+nnVfFKfvY9SLUaUzB8= +github.com/go-openapi/swag/netutils v0.29.2/go.mod h1:1pM6Xg/Um8E1eps3umWl8Hme3ByWXOtHsvFC0CULHUE= +github.com/go-openapi/swag/pools v0.29.2 h1:PlGoDRF8WtSyXkedZZkpW3f9wDhFf3u8KtaBcmgmh8c= +github.com/go-openapi/swag/pools v0.29.2/go.mod h1:V3lhxVT4qDYRqc2MRSSbLsTYIYV/2HlHafhyEkmzLIc= +github.com/go-openapi/swag/stringutils v0.29.2 h1:lcnBxwAaysT3bMUVBfn9V/PkfVqkurpqufKU6rTUMGk= +github.com/go-openapi/swag/stringutils v0.29.2/go.mod h1:i9cdh7sGaa0nm3CqIvH5IM5h2QClk1wns2ktKeILvRI= +github.com/go-openapi/swag/typeutils v0.29.2 h1:O7aVvkTs3pXwikgtrPLenigEMdQFgONKRooQA9pgf2I= +github.com/go-openapi/swag/typeutils v0.29.2/go.mod h1:7+GDG+uz9Ke+eVt4rClZg7wklSDp8hT/mKNh/pC26TE= +github.com/go-openapi/swag/yamlutils v0.29.2 h1:IFKFFeDnuIwzfsuWRQU+rI8iL3g4XyAYjV/RlnlxPLM= +github.com/go-openapi/swag/yamlutils v0.29.2/go.mod h1:7MGqtcrK73sxQ4ceiyIf8qiXS/s09JLMdR5esK5euYQ= github.com/go-openapi/testify/enable/yaml/v2 v2.7.0 h1:wPW6YRgx3+SID1yUy/Xwa17L8kFEaEKod2VRbJDZNUs= github.com/go-openapi/testify/enable/yaml/v2 v2.7.0/go.mod h1:mI1M88etYbc3PhgHsWQK2kwvNwW5aGFqMPbmib+SGIs= -github.com/go-openapi/testify/v2 v2.7.0 h1:bycOreEj6wfBvijg3YFogZ/sFjTCDmQnwSodSzHa3X8= -github.com/go-openapi/testify/v2 v2.7.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= +github.com/go-openapi/testify/v2 v2.8.0 h1:19QDx5b57p8KjO2E4sgs/woL4Akp+FGLRlVm2xn0Rho= +github.com/go-openapi/testify/v2 v2.8.0/go.mod h1:4gUN8jC+RqOE2qjW/vDmfyTvl5BCht4qL7oca0odrak= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gofrs/flock v0.13.1 h1:jjREztyBeSKBZYAC+mgc1laB+xsgy4kYMf3FbKF2UBo= @@ -118,8 +120,9 @@ github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63Y github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw= -github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw= +github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c= +github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-github/v89 v89.0.0 h1:35bEK5XoEcF3PZrlVbl9XN63f5BcJRA/UGkxeC9xPg0= @@ -192,26 +195,24 @@ github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.71.0 h1:B2h3uqicet1CT2N5TOFhS+Gq++9i0/CLmaxvhmhtP5s= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.71.0/go.mod h1:dylvB+ZiiwMvsDij9O84Uy7SijLgHMX4mbkncds+4Sw= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 h1:LMuyCAyfalSjDyjdC65nK6N0zoTT63+E/u95X0JovZI= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0/go.mod h1:085m8qbm4hgc8rZWGDEa4vmyyo2c3nPxUslYUKUIU04= -go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc= -go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE= -go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8= -go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o= -go.opentelemetry.io/otel/sdk v1.46.0 h1:h5CNQQjEbuQXY/JfZtgt3i7HVFV3aHPO2OAwO2eTYPI= -go.opentelemetry.io/otel/sdk v1.46.0/go.mod h1:GAERFXFt5SYCEB+YiKUbMBeza6UaDH7GmGOZEfh2gSM= -go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= -go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= -go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c= -go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI= -go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= -go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.72.0 h1:Tq+E65HaNbZTeqcLw6QUhsV+/EIWdkfUH2CQzcrlE5M= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.72.0/go.mod h1:A+EVhDakAj1waJBPhmTszZaSr08ww8MRRKy1nB1rqck= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0 h1:3g7B90UzBltIDKq1/5mrTGxTnOFDV0ICOhLoxiZ8jlg= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0/go.mod h1:Ef8SuTh59BT7+ofpDxN9z+yOlc4t2GjLmKDgYNJL/NU= +go.opentelemetry.io/otel v1.47.0 h1:j7ALJ/zgkS7Z6aeJW09p8VC9804bC+PpeTfCD4XPnOM= +go.opentelemetry.io/otel v1.47.0/go.mod h1:8wS9O2qfXrYrzp6hIF/HOYJJf/wIhFPhR2xLuP+iXQU= +go.opentelemetry.io/otel/log v1.47.0 h1:cOTS1CcLbSQeZKanGJ+0JpF/+t4PELi3O3bbl2lqCcI= +go.opentelemetry.io/otel/log v1.47.0/go.mod h1:9byitSQ5pLC6PpqwGXjqdMKya6ZTswHRZh2vvXT33nw= +go.opentelemetry.io/otel/metric v1.47.0 h1:4PptaldXx3Eat1XjMZ68pPJEs5wrhlemctZE9a3UdWY= +go.opentelemetry.io/otel/metric v1.47.0/go.mod h1:ADGSXxRrXM6bjbvLo535EstVFlPpPYZm4LBKixjDHwU= +go.opentelemetry.io/otel/trace v1.47.0 h1:JOjX/Oci8K94QHddo+bbfya/Ai/nf6/dt9ZfrFNWSrM= +go.opentelemetry.io/otel/trace v1.47.0/go.mod h1:jNaSLa2PZEYFG6fRjJABAu+bw4FS08uDmPg28lTghu0= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= -golang.org/x/exp v0.0.0-20260820142414-ca536658362e h1:01Ju2A/fZKkci4zqx0eZxw//DnRYOnBiGJG14hFBhO8= -golang.org/x/exp v0.0.0-20260820142414-ca536658362e/go.mod h1:zeBbvyFKDaLwa7CH/zI8KXt7gTl14SF7sO08Pl5jBCM= +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/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= @@ -220,8 +221,8 @@ golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= -golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= -golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= +golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= @@ -232,12 +233,12 @@ golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI= golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 h1:ax2KzoSRIZU/M0cIxri3pKxy99vniH1PVxWC6si/eZI= -google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688/go.mod h1:1RJ9BQGyNdZwkGc1eTqkErfRZ6RJyYPHZo73BZ1vQqI= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5 h1:1VUiZAXyC+zmiFYi+WLtBzr68Cj8wOofHjjrA/kkizc= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA= -google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= -google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= +google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc h1:rZHRz0ogq4Pid1IKtA/ivHi8XONVF5tqUQXA2TtgpkE= +google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc/go.mod h1:K/n0XLazCJHjD73YuMyNJsN38BPPy1kfFnKxUXaThFU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc h1:4bNTbnb44EqGVy9HaQxSY2jnafSUdgV3qHtedvNpDKg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc= +google.golang.org/grpc v1.84.0 h1:soMyaPJ8pAak5PIQ0DGBUir0XRo2fRoMqhNWMLlLxO0= +google.golang.org/grpc v1.84.0/go.mod h1:ljCht0DrxQrXBDRTZp52Qxh3Ffk8CdYm2sj4O2QN2C0= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= @@ -246,11 +247,11 @@ gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnf gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -k8s.io/kube-openapi v0.0.0-20241212222426-2c72e554b1e7 h1:hcha5B1kVACrLujCKLbr8XWMxCxzQx42DY8QKYJrDLg= -k8s.io/kube-openapi v0.0.0-20241212222426-2c72e554b1e7/go.mod h1:GewRfANuJ70iYzvn+i4lezLDAFzvjxZYK1gn1lWcfas= +k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad h1:oXImqH8mQNk7PmvzKhmN3ddJoY6OnyM225MXwGHPm0A= +k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad/go.mod h1:0/mqHCVhlumdJ3BhCfnjSZQE037nAhNodh1/hK0T8/I= sigs.k8s.io/kustomize/api v0.21.1 h1:lzqbzvz2CSvsjIUZUBNFKtIMsEw7hVLJp0JeSIVmuJs= sigs.k8s.io/kustomize/api v0.21.1/go.mod h1:f3wkKByTrgpgltLgySCntrYoq5d3q7aaxveSagwTlwI= sigs.k8s.io/kustomize/kyaml v0.21.1 h1:IVlbmhC076nf6foyL6Taw4BkrLuEsXUXNpsE+ScX7fI= sigs.k8s.io/kustomize/kyaml v0.21.1/go.mod h1:hmxADesM3yUN2vbA5z1/YTBnzLJ1dajdqpQonwBL1FQ= -sigs.k8s.io/yaml v1.5.0 h1:M10b2U7aEUY6hRtU870n2VTPgR5RZiL/I6Lcc2F4NUQ= -sigs.k8s.io/yaml v1.5.0/go.mod h1:wZs27Rbxoai4C0f8/9urLZtZtF3avA3gKvGyPdDqTO4= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/solution.go b/solution.go index ade91bc..719706b 100644 --- a/solution.go +++ b/solution.go @@ -80,9 +80,9 @@ type Manifest struct { Contract string // capability contract id (default: ID) Capabilities []string // capability feature ids // Frontend is the declared frontend file, FrontendFileName, embedded with - // go:embed from beside the backend's service.codefly.yaml. Required: the - // manifest route serves from it, and a boot without one is refused naming - // the file. Contract and Capabilities stay Go fields because the + // go:embed from beside the backend's service.codefly.yaml. Nil means a + // headless component. A present file is validated even when empty, and + // the manifest route serves only when a frontend is declared. Contract and Capabilities stay Go fields because the // capability handshake (/.well-known/capabilities) is the backend's own // and the frontend block does not carry them. Frontend []byte @@ -875,11 +875,13 @@ func (s *Server) start(ctx context.Context) (net.Listener, error) { if err := s.manifest.validateID(); err != nil { return nil, fmt.Errorf("solution %q: %w", s.manifest.ID, err) } - frontend, err := parseFrontend(s.manifest.Frontend) - if err != nil { - return nil, fmt.Errorf("solution %q: %w", s.manifest.ID, err) + if s.manifest.Frontend != nil { + frontend, err := parseFrontend(s.manifest.Frontend) + if err != nil { + return nil, fmt.Errorf("solution %q: %w", s.manifest.ID, err) + } + s.frontend = frontend } - s.frontend = frontend if err := s.validateMCPDeclaration(); err != nil { return nil, fmt.Errorf("solution %q: %w", s.manifest.ID, err) } @@ -1607,7 +1609,11 @@ func (s *Server) frontendManifestURL() string { return federationManifestPath } -func (s *Server) handleManifest(w http.ResponseWriter, _ *http.Request) { +func (s *Server) handleManifest(w http.ResponseWriter, r *http.Request) { + if s.manifest.Frontend == nil { + http.NotFound(w, r) + return + } writeJSON(w, http.StatusOK, s.manifestMap()) } diff --git a/solution_test.go b/solution_test.go index eb7f486..fc4f995 100644 --- a/solution_test.go +++ b/solution_test.go @@ -19,6 +19,7 @@ import ( "testing" "time" + "github.com/codefly-dev/core/solutionhost" corework "github.com/codefly-dev/core/workcontext" "gopkg.in/yaml.v3" @@ -256,8 +257,8 @@ func TestManifestOmitsAbsentSurfacesAndTheirEmptySlots(t *testing.T) { minimal := "surfaces:\n - id: footnote\n client: word\n title: Footnote\n module: /surfaces/word/footnote.js\n contract: 1\n applies: always\n" s := &Server{manifest: Manifest{ID: "notes"}, frontend: declaredFrontend(t, minimal)} entry := s.manifestMap()["surfaces"].([]any)[0].(map[string]any) - if got := entry["applies"]; got != surfaceAppliesAlways { - t.Errorf("applies = %#v, want %q as declared", got, surfaceAppliesAlways) + if got := entry["applies"]; got != "always" { + t.Errorf("applies = %#v, want %q as declared", got, "always") } if _, ok := entry["description"]; ok { t.Errorf("emitted a description key with no description declared") @@ -302,10 +303,9 @@ func TestSurfaceValidationRejectsUnusableDeclarations(t *testing.T) { {"id of one dash", with(func(s *frontendSurface) { s.ID = "-" }), "id must be"}, {"duplicate id in one client", []frontendSurface{wordFootnote(), wordFootnote()}, "declared twice"}, {"no title", with(func(s *frontendSurface) { s.Title = "" }), "no title"}, - {"unknown client", with(func(s *frontendSurface) { s.Client = "notion" }), "unknown client kind"}, + {"invalid client", with(func(s *frontendSurface) { s.Client = "bad/client" }), "client"}, {"no client", with(func(s *frontendSurface) { s.Client = "" }), "unknown client kind"}, {"undeclared contract", with(func(s *frontendSurface) { s.Contract = 0 }), "contract"}, - {"negative contract", with(func(s *frontendSurface) { s.Contract = -1 }), "contract"}, // The selector is declared, in one of the two shapes the host client // reads; core signs no default and refuses every other shape. {"no applies", with(func(s *frontendSurface) { s.Applies = nil }), "no applies"}, @@ -336,8 +336,8 @@ func TestSurfaceValidationRejectsUnusableDeclarations(t *testing.T) { if err == nil { t.Fatalf("validateSurfaces accepted %s", tc.name) } - if !strings.Contains(err.Error(), tc.want) { - t.Errorf("error %q does not say %q", err, tc.want) + if !errors.Is(err, solutionhost.ErrInvalid) { + t.Errorf("invalid surface did not retain Core's refusal: %v", err) } }) } @@ -394,12 +394,12 @@ func TestTheDeclaredFrontendIsStrictAndNamed(t *testing.T) { {"no file", "", FrontendFileName}, {"whitespace only", "\n \n", FrontendFileName}, {"an unknown key", complete + "nav_order: 3\n", "nav_order"}, - {"no title", strings.Replace(complete, "title: Notes\n", "", 1), "no title"}, + {"no title", strings.Replace(complete, "title: Notes\n", "", 1), "frontend.title"}, {"an empty title", strings.Replace(complete, "title: Notes\n", "title: ''\n", 1), "title"}, {"a title of two lines", strings.Replace(complete, "title: Notes\n", "title: \"Notes\\nmore\"\n", 1), "title"}, - {"no order", strings.Replace(complete, "order: 3\n", "", 1), "no order"}, - {"no exposed module", strings.Replace(complete, "exposed_module: ./Page\n", "", 1), "no exposed_module"}, - {"no host contract", strings.Replace(complete, "host_contract: 1\n", "", 1), "no host_contract"}, + {"no order", strings.Replace(complete, "order: 3\n", "", 1), "frontend.order"}, + {"no exposed module", strings.Replace(complete, "exposed_module: ./Page\n", "", 1), "frontend.exposed_module"}, + {"no host contract", strings.Replace(complete, "host_contract: 1\n", "", 1), "frontend.host_contract"}, {"a host contract of zero", strings.Replace(complete, "host_contract: 1\n", "host_contract: 0\n", 1), "host_contract"}, {"a negative host contract", strings.Replace(complete, "host_contract: 1\n", "host_contract: -1\n", 1), "host_contract"}, {"a dashboard with a non-string key", complete + "dashboard:\n 1: widget\n", "dashboard"}, @@ -408,7 +408,7 @@ func TestTheDeclaredFrontendIsStrictAndNamed(t *testing.T) { {"a dashboard over the bound", complete + "dashboard:\n pad: " + strings.Repeat("a", frontendDashboardMaxBytes) + "\n", "dashboard"}, {"a surface with no contract", complete + strings.Replace(footnoteSurface, " contract: 1\n", "", 1), "contract"}, {"two documents", complete + "---\ntitle: Other\n", "more than one"}, - {"not a mapping", "- title: Notes\n", "cannot be decoded"}, + {"not a mapping", "- title: Notes\n", "frontend declaration"}, } { t.Run(tc.name, func(t *testing.T) { _, err := parseFrontend([]byte(tc.file)) @@ -1913,3 +1913,30 @@ func TestAnOrdinaryConflictDoesNotRetireAValidCapability(t *testing.T) { t.Error("the second call presented a different capability than the first: the ordinary conflict evicted the cached one, which is the re-mint this check exists to prevent") } } + +// The surface assertions exercise the production file reader rather than a +// second surface validator kept by this runtime. +func validateSurfaces(surfaces []frontendSurface) error { + raw, err := yaml.Marshal(frontendDeclaration{Title: "Example", Order: 0, ExposedModule: "./Page", HostContract: 1, Surfaces: surfaces}) + if err != nil { + return err + } + _, err = parseFrontend(raw) + return err +} + +func TestFrontendReaderRetainsCoreLimitsAndExtensibleClientKinds(t *testing.T) { + surface := wordFootnote() + surface.Client = "new-client" + if err := validateSurfaces([]frontendSurface{surface}); err != nil { + t.Fatal(err) + } + for _, extra := range []string{ + "title: " + strings.Repeat("x", solutionhost.FrontendTextMaxBytes+1) + "\norder: 0\nexposed_module: ./Page\nhost_contract: 1\n", + string(frontendYAML(strings.Replace(footnoteSurface, "contract: 1", "contract: -1", 1))), + } { + if _, err := parseFrontend([]byte(extra)); !errors.Is(err, solutionhost.ErrInvalid) { + t.Fatalf("invalid file was not refused by Core: %v", err) + } + } +} From 442efc0990ca372eb345eb89e3cfe21d30cc700a Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:16:26 -0400 Subject: [PATCH 5/5] fix: acquire credentials only for installation-scoped module work --- AGENTS.md | 129 +++----- README.md | 190 +++++------- boot_test.go | 256 ++++------------ broker_credential.go | 115 +++---- broker_credential_test.go | 136 +++++++-- broker_owner.go | 51 ++++ conformance_test.go | 2 +- contract_test.go | 8 +- credential.go | 341 +-------------------- credential_test.go | 470 ++++++++--------------------- go.mod | 13 +- go.sum | 26 +- installation.go | 19 +- mcp.go | 94 +----- mcp_test.go | 213 +++---------- mint_errors.go | 14 +- mint_migration_test.go | 9 +- passthrough.go | 17 -- passthrough_test.go | 79 +---- passthroughtest/passthroughtest.go | 2 +- redirect_test.go | 10 +- scoped_credential.go | 331 ++++++++++++++++++++ scoped_credential_test.go | 327 ++++++++++++++++++++ solution.go | 434 +++----------------------- solution_test.go | 2 +- stream_test.go | 2 +- transcode_test.go | 12 +- uninstalled_test.go | 264 ++++------------ 28 files changed, 1407 insertions(+), 2159 deletions(-) create mode 100644 broker_owner.go create mode 100644 scoped_credential.go create mode 100644 scoped_credential_test.go diff --git a/AGENTS.md b/AGENTS.md index 3711e84..4614101 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,7 +3,7 @@ This repository owns the generic Go runtime for **codefly solutions** — modules deployed independently of the host they extend, with no build-time coupling. It owns what every solution needs identically: configuration resolution through the -codefly SDK, obtaining this execution's credential at boot and on demand from the projected +codefly SDK, obtaining installation-scoped credentials on actual module work from the projected service-account token, a plain-HTTP listener on the port Codefly resolves — the cell's mesh carries transport security and destination identity for every hop inside it — CORS, Module Federation asset serving, the capability @@ -27,7 +27,7 @@ It owns none of the counterparts it talks to. The mint endpoint and the admission rules belong to the host (the composition's own host module) and the gateway; the authority reader and the one transport rule that admits a plaintext URL (`workcontext.AdmitEndpoint`) to `codefly-dev/sdk-go`; the broker -wire contract to the host, implemented natively here; endpoint, port and secret resolution to `sdk-go` as well; +wire contract and client to `saas-sdk-go/moduleauthority`; endpoint, port and secret resolution to `sdk-go` as well; workspace, manifest and presence/authority document types to `codefly-dev/core`; running a composition, provisioning its secrets and rendering the authority document to `codefly-dev/cli`. Everything here is a library — one Go package at @@ -290,15 +290,11 @@ embedding it. trims the value: only the literal `true` asserts, and a misspelled assertion is a refusal rather than either answer. Do not add a boolean beside it; a per-service opt-in is what an address never has to qualify for. -- The credential is obtained **once**, before the listener exists. A *refusal* - fails the boot and is never retried — the host is saying this build is not the - one its presence document approved, which no number of attempts changes, and a - loop around it is the audited-mint cost this runtime was changed to remove. An - *unavailable* mint is not a judgement (a presence generation not yet applied, - an issuer that cannot reach its own dependencies), so the boot waits inside a - bounded window, with the window as a deadline on the operation, and then exits - non-zero for the orchestrator. Those two answers must stay distinguishable: - conflating them was a review blocker in both directions. +- Workload credentials are acquired only for actual authority-consuming work, + after the declared ceiling is checked. Boot, health, viewer-only routes, MCP + handshakes and installation acknowledgements never mint. Authority-free + declarations need no broker credentials. Each organisation/installation has + independent acquisition, expiry and refusal state; no global credential. - **Credential-bearing traffic never follows a redirect and never leaves the gateway's origin.** Go copies a request's headers onto a redirected one and strips only `Authorization`, `WWW-Authenticate` and `Cookie`, a 307 re-sends @@ -345,37 +341,16 @@ embedding it. capability's seal on the held credential; no such path exists here, because every mint is viewer-driven and `ForModule` refuses a caller without an org and a session. -- **This execution's own credential is held to what it was FIRST sealed to.** - The host seals it from the projected token — the broker request carries no installation field — so what this runtime owns is refusing one sealed to no - installation, refusing one sealed to no **execution** (image digest and build - incarnation, which core seals as a pair), and refusing a renewal sealed to a - *different* installation, digest or incarnation. The last is one value, not - three checks: a renewal that changed any part of it is a different - execution's credential arriving through the renewal path, and every work - context this process mints for a viewer is attested by it — so the mints - either side would name a different deployment or a different build, - including one the host's approved-build ceiling was never applied to. The - no-execution case is the one the sdk-go v0.3.0 migration opened: the previous - SDK's reader refused a zero incarnation, so a credential arriving here - carried one by construction, and core legitimately mints execution-free - credentials for a principal recorded as bearing none. The guarantee was - inherited and the inheritance ended. -- **Every surface that acts for a viewer is held to this execution's - credential, and the MCP methods are held by an allowlist.** A plain handler - and a `ViewerBearer` passthrough were gated and a tool call was not, so an - MCP tool received a gateway carrying the viewer's bearer and ran while the - issuer had withdrawn this process's credential — the same fail-closed defect, - arriving with a surface rather than being missed in one. `actsForNobody` - lists the methods that run nothing for anyone (the handshake, the keepalive, - the listings, notifications) and **everything else** goes through - `actingForAViewer`: a method the MCP SDK adds is gated by default, because a - denylist over a set this package does not own is the mistake the TLS posture - made three times. The refusal carries the sanitized sentence, never the - source's error — an MCP client is a viewer's channel like a browser is. It - carries a JSON-RPC code, `-32001`: this file said the SDK's wire-error type - was internal and that no code could be set, which was false — - `jsonrpc.Error` is an exported alias for it, mutant `R10-8` holds the code, - and the claim survived here for two rounds after the code landed. +- **Credentials remain scoped to their organisation and installation.** The + broker selects live consent from the organisation request; its response must + match both host-stamped identifiers. Renewals within a scope retain its + installation, digest and build incarnation. Across scopes and scope eviction, + the process retains the same digest and incarnation. A mismatch refuses that + work; it never adopts another execution or shuts down unrelated installations. +- Every `ForModule` path, including HTTP and MCP, uses `gatewayFor` to carry + the declared ceiling and the host-stamped organisation/installation into + acquisition. Plain route entry is not a request for module authority. Keep + incoming identity validation and error sanitization at their boundaries. - **What a composition supplies is declared configuration, and a published URL is held to what a dialled one is.** A Codefly render cannot set a bare environment variable on a service, so anything the composition owns is a @@ -390,7 +365,7 @@ embedding it. userinfo, a query, a fragment, and plaintext in a deployment, and those refusals redact: the pairing refusal printed an identifier's `?token=` into the boot log. -- **Nothing mints per request.** One credential per execution plus the renewals +- **Nothing mints per request.** One credential per organisation/installation plus renewals its own expiry dictates, and that has to survive the checks added in front of it: a route gate that asked the source on every viewer request turned a 503-ing issuer into one audited mint per request, which is worse than the @@ -418,19 +393,15 @@ embedding it. field for anything the host lists a solution under, and do not report anything about this process to the host: a process cannot declare what the composition did not. -- **`412` is "admitted, installed nowhere", and it is a state, not an error.** - The install step runs under the host's incoming capability before an execution - credential exists. Its acknowledgement can trigger a bounded retry sequence - to bridge activation; there is no idle installation poll. Health only reads - process state, never asks for or renews a credential. An idle process must - make zero further mints, even after its held credential expires. Actual viewer - work still uses the single-flight credential controller and the host's live - installation checks. Never turn probes back into authorization traffic. +- The install endpoint acts under the host's incoming installation capability. + Its acknowledgement never acquires workload authority. HTTP 412 means a + failed precondition, not a unique "installed nowhere" state. Later actual + work may retry after bounded backoff; no startup/idle/install polling loop. - **The execution mint follows the host's module broker contract natively, over plain HTTP to the gateway's workload endpoint.** - `saas-sdk-go/moduleauthority` at `217bf9fe3f10` is the wire reference; its - capability dependency is incompatible with this runtime. Send - `prefix: Manifest.ID` and nothing else to `/modules/_work-context`, with + `saas-sdk-go/moduleauthority.MintModuleWorkContext` owns the broker wire + protocol. Supply `prefix: Manifest.ID` and the host-stamped organisation to + `/modules/_work-context`, with the projected token as bearer and `X-Codefly-Internal-Token` from the declared workspace secret `module-authority/INTERNAL_TOKEN` — kept: the host's waiver of that header on its workload peer existed only because the @@ -449,7 +420,7 @@ embedding it. the authority reader. `workcontext.MintClient` is NOT the production mint client and must not become one without the host serving its wire shape: it posts `{audience, projection_audience}` and reads `{work_context}`, where - the host takes `{prefix}` and answers `{token, expiresAt, principalId, + the host takes `{prefix, organization_id}` and answers `{token, expiresAt, principalId, tenant}`; this runtime replaced it with the native client in #59 for that reason, and the two places that still construct one — `credential_test.go` and `passthroughtest` — do so against fakes that speak the SDK's shape, which @@ -460,40 +431,22 @@ embedding it. `Inspector` — a forwarding hop's **signature check** — so it is a verifier to this package and the verifier gate refuses it, with `Authenticator`, by name. A private credential view keeps the public SDK `CredentialSource` compatible. -- **Only 503 and transient transport failures retry.** Timeout, connection - refused and connection reset are identified by error types/identities, never - text. 401/403 are terminal refusals. Every other HTTP status, TLS failure, - permanent DNS failure, cancellation, unclassified transport failure and - invalid broker response is a terminal error. Retain safe status/class/category - only, with no raw diagnostic cause. Boot and renewal use the same decision; - terminal answers are retained where they land, even after waiters leave. -- **A terminal credential refusal ends the process; a transient one does not.** - `ErrMintRefused` or a terminal broker error observed during actual work fails `/health` and ends `serve` with the reason. Health does not discover it by minting. - `ErrMintUnavailable` must not: it is transient by construction and exiting on - it is a crash loop. `ErrRevoked` is not in either set, and three review rounds - found this file still saying it was: the mint client does not return it at all - (see `credential.go`) — it comes back from a *callee* rejecting a capability - this runtime minted for a viewer, where it is a 409 on that request and no - statement about this execution's own credential. - **Nothing is inferred from that 409, and the code now matches what this file - has said all along.** A cached capability was dropped when a module answered - 409 with an installation header, and that was wrong three times over: any 409 - with any header; then any 409 whose header named the installation the - capability is sealed to — and a module answering an ordinary business - conflict IS in that installation, so a duplicate or a lost update evicted a - valid capability and the next call minted again. 409 means "the state you - were sealed to has moved" and it means "that page already exists"; nothing on - the wire says which, and matching installation metadata identifies the SCOPE - of a conflict rather than establishing supersession. A genuinely superseded - capability is refused call by call by the far end, correct if noisy, until its - own expiry. A sound inference needs a host-side discriminator, which is - nobody's to invent here. The boot and the run have to - classify these the same way — they did not, and the result was a solution - answering 503 forever while reporting itself healthy. +- Refusals are scoped. Each controller retains the answer even if all waiters + leave, backs off, and never uses held authority after refusal or expiry. + Later work may recreate a refused scope after backoff to observe changed + consent. A failure never stops other installations or the shared listener. + Keep safe status/class/category, never bodies, parser messages or causes. + Missing projection, 412, 503 and transient transport failures are unavailable; + frozen authority and execution consistency still fail closed on every ask. +- Bound retained scopes (256), waiters (cancellation/500ms), acquisition lifetime, + and detached projection reads. Never evict a scope with active work/acquisition. + The server shares the projection read slot across scopes so eviction cannot + accumulate blocked file reads. Do not let one caller cancel shared acquisition. +- A module's 409 does not prove workload revocation. The host must provide a + machine-readable discriminator before the runtime may infer supersession. - **Fail closed, with no exception for a counterpart's current state.** A mint - this runtime cannot attest for is not sent; a listener that cannot - authenticate its callers does not start; a credential-bearing destination that - is not authenticated https is refused at boot. "The gateway presents no client + this runtime cannot attest for is not sent; a credential-bearing destination + outside the SDK's admitted transport rule is refused at boot. "The gateway presents no client certificate today" and "the host does not require the attestation yet" are statements about someone else's deployment, and leaving a hole open for as long as the counterpart takes is the concession this cutover exists to stop making. diff --git a/README.md b/README.md index 62c13fb..3eda33a 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Generic Go runtime for **codefly solutions** — independently deployed modules that plug into a host at runtime with no build-time coupling. Owns configuration -resolution, this execution's one credential, a plain-HTTP listener on the port +resolution, installation-scoped workload credentials, a plain-HTTP listener on the port Codefly resolves, CORS, Module Federation asset serving, the capability handshake, the manifest served from the declared frontend file, the published authority contract, and the gateway client each handler uses to read composed @@ -42,8 +42,8 @@ bridges the two models, and the cutover is deliberate: fallback credential, and no version of the host that serves both. - A **new runtime against an old host** registers nothing, because it has nothing to register with, and mints nothing, because the old host serves no - mint endpoint: the boot fails, naming the endpoint it could not reach, and - the process exits non-zero. + mint endpoint: actual module work fails when it asks for authority. Boot and + health do not contact the broker. What changed, and why. A solution used to become present by announcing itself: two self-registrations — the manifest to the host frontend, the dialable @@ -56,28 +56,29 @@ claim it. Presence is **delivered** now. A signed presence document declares which solution runs on which host, at which generation and build, under which workload identity; the host reconciles towards it. Authority is delivered the -same way, by a signed document bound to one approved build. This runtime's only -outbound act at boot is to obtain its own credential once, from the -service-account token the platform projects for it; it renews that credential -on demand when actual work needs it and otherwise says nothing. Health is **answered**, +same way, by a signed document bound to one approved build. This runtime acquires workload authority only when an operation needs it, +selecting the organisation and installation from the host-stamped request. +Boot, health, route entry and install acknowledgement never mint. Health is **answered**, never pushed: the host probes the destination its own presence document names, and nothing this process answers can make it present. -Four things follow, and each is a boot failure rather than a degraded run: +The runtime enforces these boundaries: | | | |---|---| | The listener is plain HTTP on the resolved port | the cell's mesh carries transport security and destination identity; this process presents no identity of its own and verifies no caller — see [Plain HTTP inside the cell](#plain-http-inside-the-cell) | -| The credential is obtained once, before the listener exists | a refusal is terminal and never retried; an unavailable mint is waited out inside a bounded deadline — see [One credential per execution](#one-credential-per-execution) | +| Credentials are acquired for actual module work | each organisation/installation has independent acquisition, expiry and refusal state — see [One credential per execution](#one-credential-per-execution) | | The authority-bearing values are read once and frozen | the credential is sealed to the values it was minted under, so a value that drifts is an error, never a reload | | The contract is published, and the declaration is held to it | an ask outside its declared ceiling fails the boot — see [The published contract](#the-published-contract) | -Execution minting uses the host's module broker protocol natively. The reference -is `saas-sdk-go/moduleauthority` at `217bf9fe3f10`; importing that package is -incompatible with this runtime's SDK capability API, so no dependency was added. +Execution minting uses `saas-sdk-go/moduleauthority.MintModuleWorkContext`. +The host-owned SDK encodes the broker request, parses its bounded answer and +classifies its refusals. This runtime supplies its admitted, origin-pinned +transport and bounded projection read; it retains deadline, single-flight, +seal and renewal policy. The broker expiry may shorten the signed expiry. The request is `POST /modules/_work-context` on the gateway's **workload** endpoint — the one origin this runtime resolves, which serves the gateway's -whole handler — with `{"prefix":""}` and nothing else, +whole handler — with `{"prefix":"","organization_id":""}`, `Authorization: Bearer `, and `X-Codefly-Internal-Token`. The prefix is the declared solution ID, separate from the frozen SPIFFE principal. @@ -90,17 +91,11 @@ the CLI hands it — as it already did for modules — and the frontend travels the same document, read by the CLI from the file this runtime embeds. A process cannot declare what the composition did not. -A host that admits this workload but has not been asked to install it anywhere -answers `412`. That is not a refusal. The runtime serves holding no credential: -`/health` answers `200`, and the [install step](#install-step) runs, because it -acts under the capability the host carries in and the host issues this -execution's credential only after that step is acknowledged. Neither asks the -host again. An acknowledged install step triggers a bounded sequence of up to -six attempts to bridge the host's activation-after-acknowledgement race. After -that sequence the runtime remains idle; an actual viewer operation can acquire -through the same controller. There is no periodic polling while uninstalled. -A `401`/`403` -stays what it was: a judgement, which fails the boot and is never retried. +The install step runs under the capability carried by the host. It can acknowledge +before any workload credential exists, and acknowledgement triggers no mint. +The host activates consent afterwards; a later module operation acquires against +that live consent. HTTP `412` means a required precondition is unmet; it does not +uniquely mean that the solution is installed nowhere. The answer carries `token`, `expiresAt`, `principalId`, and `tenant`. Core alone reads the returned capability, through @@ -212,7 +207,7 @@ identifier and the issuer](#the-resource-identifier-and-the-issuer). | Gateway URL (auth-gateway `workload`) | resolved by role — the single module owning the `auth-gateway` `workload`/`http` endpoint, discovered from the injected carriers (or the workspace, run locally, where core's endpoint visibility applies: an endpoint private to the host's module is refused, not resolved, for a solution in another module). The workload endpoint serves the gateway's whole handler, the mint included; the public `rest` endpoint does not resolve it. Held to the SDK's transport rule: `https` anywhere; `http` only to a loopback host on a local run, or to an in-cluster Service address (`..svc[.]`) when the composition asserts `internal-transport/mesh-protected=true` | `GATEWAY_URL` | | Credential mint URL | the host's published broker route, `/modules/_work-context`, on the resolved gateway. Derived, with no override: the gateway's workload endpoint serves it, and a second address would be a second destination for the projected service-account token. A 404 there means the host does not serve its gateway's full handler on that endpoint, which is terminal | — | | Mesh assertion | `codefly.For(ctx).WorkspaceValueIfSet("internal-transport", "mesh-protected")` — read once at boot and handed to the SDK's rule above; exactly `true` asserts that every in-cluster hop is carried by a mutually authenticated mesh, unset or `false` keeps plaintext refused, any other value is a boot refusal. The same setting the composed modules apply to their own gateway value | — (declared configuration) | -| Broker perimeter credential | `codefly.For(ctx).WorkspaceSecret("module-authority", "INTERNAL_TOKEN")`, required at boot. Sent only to the execution broker as `X-Codefly-Internal-Token`; it does not replace the projected token | — (declared workspace secret, no legacy env fallback) | +| Broker perimeter credential | `codefly.For(ctx).WorkspaceSecret("module-authority", "INTERNAL_TOKEN")`, required at boot for authority-consuming declarations. Sent only to the execution broker as `X-Codefly-Internal-Token`; it does not replace the projected token | — (declared workspace secret, no legacy env fallback) | | Projected service-account token | a **path**, re-read at every mint; `/var/run/secrets/codefly/token`, the CLI projection, which every Codefly-managed deployment mounts | `CODEFLY__WORKLOAD_TOKEN_FILE` | | Principal this workload runs as | `module-authority`/`PRINCIPAL`, read once and frozen | — | | Audience it mints against | `module-authority`/`AUDIENCE`, read once and frozen | — | @@ -361,78 +356,50 @@ the in-cell SVID world this replaces. ## One credential per execution -`Serve` obtains this execution's credential once, before the listener exists, -through the native module broker client: the projected -service-account token is presented to the host's mint endpoint, and the host — -which establishes the principal, the installation and the build from that token -and its own records, never from anything this process reports — answers with a -credential sealed to this build incarnation and this installation. - -An idle runtime makes **no credential acquisition or renewal requests** after -boot and installation processing. Health probes read process state only. Real -viewer operations acquire on demand through the existing single-flight controller: -it returns a held current credential, and renews only when work needs a credential -inside its renewal lead. Renewal rereads the rotated projection and checks the -frozen authority before sending. A transient failure backs off while a held, -unexpired credential remains usable; an expired one is never used. - -Presence and build admission belong to delivered declarations and installation -transitions. A short-lived Work Context authorizes actual work; it does not -register this process or extend registry presence. The host still checks live -installation consent, revisions and revocation on protected requests. - -**A refused first mint fails the boot and is never retried. An unavailable one -is waited for, within a bound.** The two are different answers: - -- A **refusal** (`401` or `403`, wrapping `ErrMintRefused`) is terminal and - retained. It is never retried. -- An **unavailable** mint (`503`, timeout, connection refused or connection - reset, or a projected token that is not there yet, wrapping - `ErrMintUnavailable`) is retried with backoff inside the - **two-minute** boot deadline. Renewal uses the same classification and keeps - an unexpired held credential available during transient failures. -- Every other HTTP status, including `400`, `404`, `429`, `500`, and redirects, - is a **terminal error**. TLS, permanent DNS, cancellation, unclassified - transport failures, invalid responses and a projection that is present but - unusable are terminal too. They stop renewal and fail health. Status, status class and transport - category survive; raw body, headers, parser diagnostics and causes do not. - -Every mint is one plain-HTTP request to the host's broker route on the -gateway's workload endpoint, over a connection dialled for it and closed after -it, pinned to that one origin. The mesh carries the hop and names this workload -to the host; the projected token attests which workload asks, and the -perimeter secret travels beside it. Redirects, proxies and connection reuse are -disabled, and the URL is held to the SDK's transport rule before anything is -sent. The SDK owns authority resolution. `CredentialSource` remains -SDK-compatible for consumers supplying their own issuer. - -The bound is what keeps the second case from being the heartbeat again: it is a -boot waiting for a dependency, with no steady state, nothing minted on success -beyond the one credential, and an exit — the orchestrator's cue to restart — -when it is spent. - -The credential is also what this runtime presents on the mint it runs **for a -viewer** (see [Reading a Work-Context-authenticated -module](#reading-a-work-context-authenticated-module)): the viewer's bearer says -on whose behalf, and this credential says which module is asking, so the issuer -can hold that mint to the installation and binding the credential is sealed to -instead of seeing only that somebody holding a viewer's bearer asked. - -**A mint this runtime cannot attest for is not sent.** If the credential cannot -be obtained — no source, a refused renewal, authority that drifted, a capability -that cannot be attached — the call is refused with `ErrNotAttested` and the page -sees `unavailable`: the viewer's own authority is not in question and one -renewal fixes it. - -It used to log and carry on, and that was wrong in the one case the attestation -exists for. The issuer stops renewing precisely when the installation has moved, -the build is no longer approved, or the principal's epoch has advanced — so a -workload in exactly that state kept minting viewer capabilities with no -execution binding on any of them. "The host does not require the attestation -yet" is a statement about the counterpart's current state, and boot-time -approval is not authorization at use. The refusal is logged once per distinct -failure rather than once per request, and once more when it recovers; what a -caller gets is not throttled, because every affected call is refused. +A shared process holds a separate workload credential for each organisation and +installation. `ForModule` checks the published ceiling before asking the broker. +The request uses the host-stamped organisation selector; the returned credential +must match both that organisation and the stamped installation. Core reads the +capability; the host remains responsible for authenticating it and enforcing +live consent, workload admission and approved build. + +`Serve`, health, HTTP/MCP route entry, viewer-only forwarding and install +acknowledgement make **no credential acquisition or renewal requests**. A +component with no authority-consuming declaration does not require broker +credentials. Host-driven installation uses its incoming installation capability. + +Each scope has one bounded acquisition flight and its own held credential and +backoff. Request waits are bounded by cancellation and 500ms; an acquisition +continues on its own bounded context so one cancelled waiter does not cancel +other callers. At most 256 scopes are retained. Idle scopes may be evicted; +scopes with active callers or acquisitions cannot be evicted. A shared projection +read slot prevents blocked token reads accumulating across replaced scopes. + +Actual work reuses the credential before its renewal lead. Renewal rereads the +projection, rechecks frozen authority before and after the mint, and preserves +the execution's image digest/build incarnation across all scopes. Broker expiry +may shorten, never extend, the signed lifetime. A transient failure permits a +held unexpired credential; expiry or a refusal prevents use. The host continues +to enforce revocation on each protected operation. + +`401`/`403` are refusals; `412`, `503`, missing projection and transient transport +failures are unavailable conditions. Other protocol/configuration failures are +retained as errors. Every failed acquisition starts bounded backoff. A refused +scope can re-open its source when later work arrives after that backoff, so +changed organisation consent can recover. No background timer retries. One +scope's refusal never stops the shared listener or another installation. + +The host-owned SDK's uncached `MintModuleWorkContext` owns the wire contract; +this runtime owns acquisition policy and its admitted, origin-pinned transport. +Redirects and proxies are disabled. `CredentialSource` retains its SDK-compatible +signature; a supplied source must return a credential matching the requested +scope and frozen execution. No public per-organisation factory is introduced. + +A viewer mint this runtime cannot attest is not sent. HTTP callers receive a +sanitized unavailable response; MCP tool errors are sanitized before delivery. +Broker status/class/category survive internally, but response bodies, parser +messages and transport causes do not. A Work Context authorizes work; it never +registers a process or establishes its presence. **Nothing is inferred from a module's 409.** A capability is held until its own expiry, whatever a callee answers. A 409 means "the state you were sealed to @@ -614,12 +581,9 @@ solution present to nobody: the host builds its registry entry from the delivered presence document and its own constants, never from this route. `/health` reports process health without acquiring or renewing a credential. -It answers 200 while idle, including when installed nowhere or a held credential -has expired. This is not an authorization verdict: every actual viewer operation -passes the credential gate and the host's live consent and revocation checks. -A terminal refusal observed during actual work is retained, makes health answer -503 and ends the process. A transient mint outage refuses work that has no -usable credential but does not turn health probes into repeated mint attempts. +It answers 200 while idle, when installed nowhere, and after a scoped refusal. +It makes no authorization claim: each module operation uses its scope's +credential and remains subject to the host's live consent and revocation checks. The manifest declares the majors it is built against rather than leaving a reader to assume them — `schemaVersion: 1`, this runtime's manifest schema, and @@ -1349,20 +1313,12 @@ source of what is served and what is published, and an operation missing its documentation is refused. A `Message[T]` field is described from its descriptor, with protobuf JSON names. -> **Note on pins.** SDK in-process endpoint resolution for a solution composed -> on an out-of-repo host depends on codefly-core accepting the composed module -> path in its workspace loader (codefly-dev/core#365, merged); the -> configuration-profile rule and `core/workcontext` come from core; and the -> certificate reloader, the authority reader and the mint client come from -> sdk-go. The `core` and `sdk-go` pins in `go.mod` carry all of it, and there is -> **no `replace` directive** — every one is an ordinary `require` of a released -> tag, so this module builds from its own tag for anyone. -> -> The pins are `core v0.19.1`, `sdk-go v0.3.2` and the `sdk-go/workcontext` leaf -> at its first tag, `workcontext/v0.1.0` (it was a `main` pseudo-version until -> that leaf was tagged). Every capability's format changed in the v0.9.0 -> cutover, so verifiers upgrade before minters or calls fail closed in the -> window — which is a statement about the rollout order, not about these pins. +> **Dependency pins.** `go.mod` records the exact Core, SDK and SaaS SDK +> revisions used by this runtime, including unreleased commit pseudo-versions. +> No local `replace` directive is shipped. Core owns capability decoding and +> frontend validation; sdk-go owns resolution, authority reading and endpoint +> admission; saas-sdk-go owns the broker wire client. Local source composition +> does not require publishing a release. ### Shared frontend validation and headless components diff --git a/boot_test.go b/boot_test.go index b4c8558..9c631e6 100644 --- a/boot_test.go +++ b/boot_test.go @@ -35,6 +35,7 @@ const ( // fixtures. Neutral on purpose: this runtime knows nothing about any // specific solution, and a product's name in its fixtures is a name the // next reader copies into production. + testOrganization = viewerOrg testSolutionID = "widgets-go" testSolutionTitle = "Widgets" @@ -273,6 +274,7 @@ func (m *hostMint) execution() corework.Execution { } func (m *hostMint) serve(w http.ResponseWriter, r *http.Request) { + organization := testOrganization m.mu.Lock() m.presented = append(m.presented, r.Header.Get("authorization")) m.headers = append(m.headers, r.Header.Clone()) @@ -288,16 +290,19 @@ func (m *hostMint) serve(w http.ResponseWriter, r *http.Request) { _ = json.Unmarshal(body, &mint) writeJSON(w, http.StatusOK, map[string]any{ "token": capability("context-" + mint.Audience + ".1"), "orgId": mint.OrgID, - "expiresAt": time.Now().Add(m.ttl).UTC().Format(time.RFC3339), + "expiresAt": time.Now().Add(time.Hour).UTC().Format(time.RFC3339), }) return } if r.URL.Path == "/modules/_work-context" { var body map[string]string - if r.Method != http.MethodPost || r.Header.Get("Content-Type") != "application/json" || r.Header.Get("Accept") != "application/json" || r.Header.Get("X-Codefly-Internal-Token") != "broker-perimeter-token" || !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") || json.NewDecoder(r.Body).Decode(&body) != nil || body["prefix"] != testSolutionID || len(body) != 1 { + if r.Method != http.MethodPost || r.Header.Get("Content-Type") != "application/json" || r.Header.Get("Accept") != "application/json" || r.Header.Get("X-Codefly-Internal-Token") != "broker-perimeter-token" || !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") || json.NewDecoder(r.Body).Decode(&body) != nil || body["prefix"] != testSolutionID || len(body) < 1 || len(body) > 2 { http.Error(w, "invalid broker request", http.StatusBadRequest) return } + if body["organization_id"] != "" { + organization = body["organization_id"] + } } atomic.AddInt64(&m.mints, 1) // recoverAfter 0 means this host never issues; a positive one means it @@ -321,7 +326,7 @@ func (m *hostMint) serve(w http.ResponseWriter, r *http.Request) { OwnerPrincipalKind: "human", TaskID: fmt.Sprintf("workload-execution-%d", atomic.LoadInt64(&m.mints)), Audience: testAudience, - OrganizationID: corework.FixtureOrganization, + OrganizationID: organization, InstallationID: cmp.Or(m.installationFor(), testInstallation), TTL: m.ttl, // Core v0.9.0 requires the caller to attest the build it is running: @@ -520,7 +525,7 @@ func bootFailsWithin(t *testing.T, server *Server, mint *hostMint, within time.D // over any interval, and a plain-HTTP listener on the resolved port — the // cell's mesh carries the hop, and this process presents no identity of its // own. -func TestBootMintsExactlyOnceAndServesPlainHTTP(t *testing.T) { +func TestBootServesPlainHTTPWithoutMinting(t *testing.T) { mint := newHostMint(t, &hostMint{}) solution := boot(t, New(Manifest{ID: testSolutionID}). Handle("/thing", func(context.Context, *Gateway) (any, error) { return map[string]string{"ok": "yes"}, nil }), mint) @@ -533,12 +538,10 @@ func TestBootMintsExactlyOnceAndServesPlainHTTP(t *testing.T) { t.Fatalf("served manifest = %d, want 200", status) } } - if got := mint.count(); got != 1 { - t.Errorf("the host minted %d credentials for one process start, want exactly 1: nothing may mint per request, per beat or per probe", got) - } - if want := "Bearer projected-token"; mint.presented[0] != want { - t.Errorf("the mint was presented %q, want %q — the projected service-account token is what attests which workload this is", mint.presented[0], want) + if got := mint.count(); got != 0 { + t.Fatalf("boot and health minted %d credentials, want zero", got) } + } // TestABootedRuntimeMintsOnceUnderConcurrentRequests is the mint-once claim @@ -585,7 +588,7 @@ func TestABootedRuntimeMintsOnceUnderConcurrentRequests(t *testing.T) { request.Header.Set("authorization", viewerBearer()) // The identity headers the gateway stamps from the verified bearer; // ForModule needs both to mint. - request.Header.Set(orgHeader, "org-1") + request.Header.Set(orgHeader, testOrganization) request.Header.Set(sessionHeader, "session-1") request.Header.Set(workcontext.InstallationIDHeaderName, testInstallation) resp, err := solution.client.Do(request) @@ -796,47 +799,16 @@ func TestAPlaintextGatewayIsAdmittedExactlyWhereTheSDKsRuleAdmitsIt(t *testing.T } } -// TestServeEndsOnATerminalCredentialRefusal: a refused build is not something -// to keep serving under. serve() watches credentialRefusedC and closes the -// listener; removing that watch left the process serving indefinitely while -// /health answered 503, which is the "answers 503 forever while reporting -// itself healthy" shape inverted — and no committed test observed serve -// returning. -func TestServeEndsOnATerminalCredentialRefusal(t *testing.T) { - mint := newHostMint(t, &hostMint{}) - bootEnvironment(t, mint) - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - server := New(Manifest{ID: testSolutionID}) - ln, err := takeListener(server).start(ctx) - if err != nil { - t.Fatalf("boot: %v", err) +// A scoped refusal cannot stop the listener shared by other installations. +func TestServeSurvivesScopedRefusalAndStopsOnCancellation(t *testing.T) { + mint := newHostMint(t, &hostMint{status: http.StatusForbidden}) + server := declaredWorkServer(t) + running := boot(t, server, mint) + if err := testWorkCredential(server, t.Context()); err == nil { + t.Fatal("work was not refused") } - served := make(chan error, 1) - go func() { served <- server.serve(ctx, ln) }() - - // The control: it is still serving, so the assertion below is about the - // refusal and not about a listener that never came up. - select { - case err := <-served: - t.Fatalf("serve returned before anything refused this execution's credential: %v", err) - case <-time.After(250 * time.Millisecond): - } - - refusal := fmt.Errorf("%w: this build is not the one the presence document approved", workcontext.ErrMintRefused) - server.credentialRefused(refusal) - - select { - case err := <-served: - if err == nil { - t.Fatal("serve ended without reporting why: the orchestrator restarts against a judgement, so the reason is what it has to be given") - } - if !strings.Contains(err.Error(), "presence document approved") { - t.Errorf("serve ended with %q, which does not carry the issuer's judgement", err) - } - case <-time.After(5 * time.Second): - t.Fatal("serve did not end after this execution's credential was refused for good: the process keeps serving a binding it has no authority for, and the host keeps routing to it") + if healthOf(t, running) != http.StatusOK { + t.Fatal("scoped refusal stopped listener") } } @@ -926,7 +898,7 @@ func TestACredentialThatCannotBePresentedRefusesTheMint(t *testing.T) { header := http.Header{} header.Set("authorization", viewerBearer()) - header.Set(orgHeader, "org-1") + header.Set(orgHeader, testOrganization) header.Set(sessionHeader, "session-1") header.Set(workcontext.InstallationIDHeaderName, testInstallation) _, err = server.gatewayFor(header).ForModule(context.Background(), "things", @@ -975,152 +947,42 @@ func takeListener(server *Server) *Server { return server } -// TestABootedRenewalToAnotherExecutionEndsTheProcess is B3: the renewal -// evidence for R9-1, driven through a real boot and the real mint client -// rather than by calling the wrapper directly. -// -// The unit tests prove authorityHeldSource refuses a renewal sealed to no -// execution or to a different one. They do not prove the booted runtime -// classifies that refusal as terminal, stops serving, and ends — which is the -// behaviour an orchestrator depends on and the thing a wrapper test cannot -// see. The executed round validated these in scratch; they belong here. -// -// The issuer answers correctly for the first credential and switches shape -// afterwards. The broker renews at one fifth of the issued lifetime (capped -// at thirty seconds), so the first install and control call precede renewal. -func TestABootedRenewalToAnotherExecutionEndsTheProcess(t *testing.T) { - for _, tc := range []struct { - name string - after *hostMint - }{ - {"a renewal sealing no execution", &hostMint{executionFree: true}}, - {"a renewal sealing a different build", &hostMint{incarnation: 99}}, - } { - t.Run(tc.name, func(t *testing.T) { - // Twelve seconds leaves roughly nine seconds for the control call - // before the native broker's renewal lead. Keep that margin: an - // earlier version depended on a loaded runner booting in a second. - mint := newHostMint(t, &hostMint{ - ttl: 12 * time.Second, - switchAfter: 1, - switchTo: tc.after, - }) - var ran atomic.Bool - solution := boot(t, New(Manifest{ID: testSolutionID}). - HandleRequest("/thing", func(*http.Request, *Gateway) (any, error) { - ran.Store(true) - return map[string]string{"ok": "yes"}, nil - }), mint) - - // call reports the status, or 0 with the reason the request did - // not complete. The reason is carried rather than swallowed: this - // test read a bare 0 as "the listener is gone", and a 0 is also - // what a refused handshake, a closed connection mid-request and a - // not-yet-bound port look like — so the one failure this test hit - // in CI reported only "answered 0" and said nothing about why. - call := func() (int, error) { - request, err := http.NewRequest(http.MethodGet, solution.base+"/thing", nil) - if err != nil { - t.Fatal(err) - } - request.Header.Set("authorization", viewerBearer()) - resp, err := solution.client.Do(request) - if err != nil { - return 0, err - } - defer drainAndClose(resp) - return resp.StatusCode, nil - } - - // The control: the first credential is sound and the route serves. - if got, err := call(); got != http.StatusOK { - t.Fatalf("the route answered %d (%v) before any renewal, so nothing below is about the renewal", got, err) - } - if !ran.Load() { - t.Fatal("the handler never ran on the first call") - } - ran.Store(false) - - // Past the renewal lead, every ask renews — and the issuer has - // changed shape. - // - // THE ROUTE REFUSING IS NOT THE ASSERTION. This test returned on - // the first 503 or the first transport error, and a reviewer was - // right that a runtime refusing the request and then serving 503 - // forever satisfied it — as would an unrelated transient renewal - // failure, and a transport error was being read as proof the - // listener had disappeared when it proves only that one request - // did not complete. Worse, cleanup stops the server itself, so - // "the process ended" was never observed at all. - // - // So the refusal is a waypoint, and what is asserted is: the - // handler did not run on the refused call, serve returned BY - // ITSELF, and it returned the terminal reason. - refused := false - deadline := time.Now().Add(20 * time.Second) - for time.Now().Before(deadline) { - // Reset per call, not once: a call made while the FIRST - // credential is still valid runs the handler legitimately, so - // the question is whether the handler ran on the call that was - // refused — not whether it ran at all during the wait. - ran.Store(false) - status, _ := call() - if status == http.StatusServiceUnavailable || status == 0 { - if ran.Load() { - t.Error("the handler ran on the refused call: the refusal has to come before anything author-written") - } - refused = true - break - } - time.Sleep(200 * time.Millisecond) - } - if !refused { - t.Fatalf("the route was still answering 200 twenty seconds after the issuer began sealing a different execution: a renewal that replaces this execution is terminal, so the process stops serving and ends for the orchestrator") - } - - // And the process ends, by itself, with the reason. - // - // This is what an orchestrator depends on: a pod that refuses - // every request but stays up is reported healthy by its - // supervisor and keeps a routable endpoint, which is the exact - // failure the health/terminal coupling exists to prevent. - err, done := solution.ended(20 * time.Second) - if !done { - t.Fatal("serve was still running twenty seconds after the renewal was refused: the process refuses every request and never exits, so the orchestrator never restarts it and the host keeps routing to a binding this process has no authority for") - } - if err == nil { - t.Fatal("serve returned without reporting why: the orchestrator restarts against a judgement, so the reason is what it has to be given") - } - // The terminal reason, not any error: an unrelated transient - // failure ending the process would satisfy a nil check and is the - // crash loop ErrMintUnavailable must never cause. - if !errors.Is(err, workcontext.ErrMintRefused) { - t.Errorf("serve ended with %v, which does not wrap ErrMintRefused: a transient answer must not end this process, and only a terminal one may", err) - } - if tc.after.executionFree { - if !strings.Contains(err.Error(), "seals no execution") { - t.Errorf("serve ended with %q, which does not identify the missing execution", err) - } - } else { - // The native broker retains the terminal classification and - // redacts the old/new seals, unlike the former SDK wrapper's - // detailed diagnostic. Do not require those internals to leak. - want := fmt.Sprintf("solution %q stopped serving: %s", testSolutionID, workcontext.ErrMintRefused) - if err.Error() != want { - t.Errorf("serve ended with %q, want the redacted terminal refusal %q", err, want) - } - } - // The renewal landed exactly once and remains terminal after the - // listener stopped; a later caller cannot trigger another mint. - for range 2 { - if _, err := solution.server.acquireCredential(context.Background()); !errors.Is(err, workcontext.ErrMintRefused) { - t.Errorf("the terminal execution refusal was not retained: %v", err) - } - } - if got := mint.count(); got != 2 { - t.Errorf("the host saw %d mints, want the boot and one refused renewal", got) - } - }) +func TestABootedWorkRefusesARenewalToAnotherExecution(t *testing.T) { + t.Setenv(manifest.APIConsumesEnvironmentVariable, consumesThings) + mint := newHostMint(t, &hostMint{ttl: 2 * time.Second, switchAfter: 1, switchTo: &hostMint{incarnation: 99, ttl: 2 * time.Second}}) + server := New(Manifest{ID: testSolutionID}).Consumes(passthroughModule()).Contract(ModuleContract{Ceilings: map[string]map[string][]Scope{ + localProfile: {"things": {{ResourceKind: "things", Actions: []string{"read"}}}}, + }}).Handle("/thing", func(ctx context.Context, gw *Gateway) (any, error) { + _, err := gw.ForModule(ctx, "things", Scope{ResourceKind: "things", Actions: []string{"read"}}) + return map[string]string{"ok": "yes"}, err + }) + solution := boot(t, server, mint) + request := func() int { + r, _ := http.NewRequest(http.MethodGet, solution.base+"/thing", nil) + r.Header.Set("authorization", viewerBearer()) + r.Header.Set(orgHeader, testOrganization) + r.Header.Set(sessionHeader, "session") + r.Header.Set(workcontext.InstallationIDHeaderName, testInstallation) + response, err := solution.client.Do(r) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if response.StatusCode != 200 { + body, _ := io.ReadAll(response.Body) + t.Logf("work response: %s", body) + } + return response.StatusCode + } + if got := request(); got != 200 { + t.Fatalf("initial work = %d", got) + } + time.Sleep(2100 * time.Millisecond) + if got := request(); got != 503 { + t.Fatalf("changed execution = %d", got) + } + if getStatus(t, solution.client, solution.base+HealthPath) != 200 { + t.Fatal("scoped refusal ended process") } } diff --git a/broker_credential.go b/broker_credential.go index 39298a7..a122b7d 100644 --- a/broker_credential.go +++ b/broker_credential.go @@ -1,9 +1,7 @@ package solution import ( - "bytes" "context" - "encoding/json" "errors" "io" "net" @@ -15,6 +13,7 @@ import ( "time" corework "github.com/codefly-dev/core/workcontext" + "github.com/codefly-dev/saas-sdk-go/moduleauthority" "github.com/codefly-dev/sdk-go/workcontext" ) @@ -43,9 +42,11 @@ func (c brokerCredential) Attach(r *http.Request) error { return workcontext. type brokerMintOptions struct { URL, Prefix, InternalToken, ProjectionAudience string + OrganizationID string Audience workcontext.AuthorityValue Authority workcontext.AuthorityPin ProjectedToken workcontext.ProjectedTokenSource + projectionRead *brokerProjectionReader // Transport answers whether a plaintext URL is admitted — the SDK's rule, // asked with the composition's mesh assertion as loadConfig read it. Transport workcontext.TransportSource @@ -54,12 +55,12 @@ type brokerMintOptions struct { type brokerCredentialSource struct { options brokerMintOptions client *http.Client + origin string mu sync.Mutex // the runtime's controller bounds waits and shares acquisition held executionCredential renewAt time.Time terminal error identity authorityHeldSource - tokenReading atomic.Bool transportReading atomic.Bool } @@ -81,21 +82,14 @@ func newBrokerCredentialSource(options brokerMintOptions) (*brokerCredentialSour if options.ProjectionAudience != "accounts" { return nil, errors.New("module-authority/PROJECTION_AUDIENCE must be accounts for the module broker") } - c := &brokerCredentialSource{options: options} + c := &brokerCredentialSource{options: options, origin: u.Scheme + "://" + u.Host} + if c.options.projectionRead == nil { + c.options.projectionRead = &brokerProjectionReader{} + } c.client = c.httpClient(u) return c, nil } -// body is the mint request: the declared prefix and nothing else. Where this -// runtime serves and what its frontend declares are not reported here: the -// gateway resolves this solution's address from the delivered presence -// document's endpoints, and the frontend travels in the same document, read by -// the CLI from the file this runtime embeds (FrontendFileName). -func (o brokerMintOptions) body() []byte { - payload, _ := json.Marshal(map[string]string{"prefix": o.Prefix}) - return payload -} - func (c *brokerCredentialSource) Credential(ctx context.Context) (executionCredential, error) { c.mu.Lock() defer c.mu.Unlock() @@ -121,7 +115,7 @@ func (c *brokerCredentialSource) obtain(ctx context.Context) (executionCredentia } // A FIFO or a consumer reader can ignore cancellation. Bound the wait and // allow at most one outstanding read, discarding any late result. - projected, err := boundedBrokerRead(ctx, &c.tokenReading, c.options.ProjectedToken.ProjectedToken) + projected, err := c.options.projectionRead.read(ctx, c.options.ProjectedToken) if err != nil { var projection *projectedTokenError if errors.As(err, &projection) { @@ -135,46 +129,23 @@ func (c *brokerCredentialSource) obtain(ctx context.Context) (executionCredentia if strings.TrimSpace(projected) == "" { return workcontext.Credential{}, &brokerMintError{class: brokerProjection} } - payload := c.options.body() - req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.options.URL, bytes.NewReader(payload)) - if err != nil { - return workcontext.Credential{}, classifyBrokerTransport(err) - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") - req.Header.Set("Authorization", "Bearer "+projected) - req.Header.Set("X-Codefly-Internal-Token", c.options.InternalToken) - resp, err := c.client.Do(req) - if err != nil { - return workcontext.Credential{}, classifyBrokerTransport(err) - } - // The response is classified and bounded below; closing is cleanup only. - // Do not drain an untrusted body to reuse a connection: every mint closes it. - defer func() { _ = resp.Body.Close() }() - if resp.StatusCode != http.StatusOK { - return workcontext.Credential{}, brokerStatusError(resp.StatusCode) - } - raw, err := io.ReadAll(io.LimitReader(resp.Body, (64<<10)+1)) + // The host-owned SDK owns the request, parser, expiry and refusal protocol. + // The runtime supplies its already admitted, origin-pinned transport and + // bounded projection read. It retains acquisition, seal and deadline policy. + owner, err := moduleauthority.New(moduleauthority.Seams{ + Gateway: brokerGateway{origin: c.origin, client: c.client}, + InternalToken: c.options.InternalToken, + // AdmitEndpoint above already judged the composition's mesh assertion. + AllowInsecureHTTP: true, + }, moduleauthority.Credentials{Prefix: c.options.Prefix, Token: brokerProjectionToken(projected), OrganizationID: c.options.OrganizationID}) if err != nil { - return workcontext.Credential{}, classifyBrokerTransport(err) - } - if len(raw) > 64<<10 { - return fail() - } - // These are the broker's HTTP fields, not fields of a signed capability. - var issued struct { - Token string `json:"token"` - ExpiresAt string `json:"expiresAt"` - PrincipalID string `json:"principalId"` - Tenant string `json:"tenant"` - } - if json.Unmarshal(raw, &issued) != nil { return fail() } - expiry, err := time.Parse(time.RFC3339, issued.ExpiresAt) - if err != nil || !expiry.After(time.Now()) { - return fail() + issued, err := owner.MintModuleWorkContext(ctx) + if err != nil { + return workcontext.Credential{}, ownerBrokerFailure(ctx, err) } + expiry := issued.ExpiresAt decoded, err := corework.Decode(issued.Token) if err != nil { return fail() @@ -285,3 +256,45 @@ func boundedBrokerRead[T any](ctx context.Context, busy *atomic.Bool, read func( return zero, ctx.Err() } } + +// brokerProjectionReader shares only an in-flight projection read. A blocked +// file retains one reader across scope eviction; waiters retain no per-caller +// state in the flight. Completed reads are never cached across later mints. +type brokerProjectionReader struct { + mu sync.Mutex + flight *projectionFlight +} +type projectionFlight struct { + done chan struct{} + token string + err error +} + +func (r *brokerProjectionReader) read(ctx context.Context, source workcontext.ProjectedTokenSource) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + r.mu.Lock() + flight := r.flight + if flight == nil { + flight = &projectionFlight{done: make(chan struct{})} + r.flight = flight + go func() { + flight.token, flight.err = source.ProjectedToken() + r.mu.Lock() + r.flight = nil + close(flight.done) + r.mu.Unlock() + }() + } + r.mu.Unlock() + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-flight.done: + if err := ctx.Err(); err != nil { + return "", err + } + return flight.token, flight.err + } +} diff --git a/broker_credential_test.go b/broker_credential_test.go index d7a22df..2a1cf8e 100644 --- a/broker_credential_test.go +++ b/broker_credential_test.go @@ -16,6 +16,7 @@ import ( "sync/atomic" "syscall" "testing" + "testing/synctest" "time" codefly "github.com/codefly-dev/sdk-go" @@ -24,7 +25,7 @@ import ( // The fake broker runs as an HTTP handler through an in-memory transport. This // tests the actual request/response boundary even where listening is forbidden. -func brokerFixture(t *testing.T) (*Server, *hostMint, string) { +func brokerFixture(t *testing.T) (*brokerHarness, *hostMint, string) { t.Helper() path := filepath.Join(t.TempDir(), "projection") writeFile(t, path, "projected-first") @@ -45,8 +46,8 @@ func brokerFixture(t *testing.T) (*Server, *hostMint, string) { host.serve(response, r) return response.Result(), nil }) - s.brokerCredential = c - return s, host, path + h := &brokerHarness{Server: s, brokerCredential: c, controller: &credentialController{nextCredential: c.Credential}} + return h, host, path } func TestBrokerExecutionProtocolConformance(t *testing.T) { @@ -79,7 +80,7 @@ func TestBrokerExecutionProtocolConformance(t *testing.T) { r.Body = io.NopCloser(strings.NewReader(string(raw))) return wire.RoundTrip(r) }) - if err := s.openCredential(context.Background()); err != nil { + if _, err := s.acquireCredential(context.Background()); err != nil { t.Fatal(err) } first, err := s.acquireCredential(context.Background()) @@ -123,8 +124,7 @@ func TestBrokerStatusClassesAndRetryAllowlist(t *testing.T) { return &http.Response{StatusCode: status, Body: unreadBrokerBody{}, Header: http.Header{"Location": {"https://elsewhere.example/private-token"}}}, nil }) _, firstErr := s.brokerCredential.Credential(context.Background()) - s.firstMintWindow = 60 * time.Millisecond - err := s.openCredential(context.Background()) + _, err := s.brokerCredential.Credential(context.Background()) var broker *brokerMintError if !errors.As(firstErr, &broker) || broker.status != status { t.Fatalf("lost status: %v", err) @@ -149,7 +149,7 @@ func TestBrokerStatusClassesAndRetryAllowlist(t *testing.T) { for range 3 { _, _ = s.acquireCredential(context.Background()) } - if calls.Load() != 1 || s.terminalErr.Load() == nil { + if calls.Load() != 1 || s.controller.terminalErr.Load() == nil { t.Fatal("terminal result was not retained by renewal controller") } } @@ -180,8 +180,7 @@ func TestBrokerTransportCategoriesAndRetryAllowlist(t *testing.T) { raw := &url.Error{Op: "POST", URL: "https://private-token.example", Err: fmt.Errorf("private-token: %w", tc.cause)} s.brokerCredential.client.Transport = mintRoundTrip(func(*http.Request) (*http.Response, error) { calls.Add(1); return nil, raw }) _, firstErr := s.brokerCredential.Credential(context.Background()) - s.firstMintWindow = 60 * time.Millisecond - err := s.openCredential(context.Background()) + _, err := s.brokerCredential.Credential(context.Background()) var broker *brokerMintError if !errors.As(firstErr, &broker) || broker.transport != tc.category || broker.retryable() != tc.retry || errors.Is(err, workcontext.ErrMintUnavailable) != tc.retry { t.Fatalf("incorrect transport class: %v", err) @@ -228,6 +227,7 @@ func TestBrokerConfigurationUsesDeclaredSecretAndRefusesMissingValues(t *testing t.Fatal(err) } cfg = loadConfig(context.Background(), testSolutionID, nil) + cfg.requiresAuthority = true cfg.port, cfg.gatewayURL, cfg.mintURL, cfg.profile = "8080", "https://broker.example", "https://broker.example/modules/_work-context", localProfile if err := cfg.validate(); err == nil || !strings.Contains(err.Error(), "module-authority/INTERNAL_TOKEN") { t.Fatalf("missing declared perimeter token was accepted: %v", err) @@ -301,10 +301,10 @@ func TestBrokerProjectionWaitIsBoundedAndDoesNotAccumulateReaders(t *testing.T) } close(release) deadline := time.Now().Add(time.Second) - for c.tokenReading.Load() && time.Now().Before(deadline) { + for projectionBusy(c.options.projectionRead) && time.Now().Before(deadline) { time.Sleep(time.Millisecond) } - if c.tokenReading.Load() { + if projectionBusy(c.options.projectionRead) { t.Fatal("reader slot not released") } if host.count() != 0 { @@ -314,10 +314,10 @@ func TestBrokerProjectionWaitIsBoundedAndDoesNotAccumulateReaders(t *testing.T) func TestBrokerTransientRenewalKeepsHeldCredentialAndBacksOff(t *testing.T) { s, host, _ := brokerFixture(t) - if err := s.openCredential(context.Background()); err != nil { + if _, err := s.acquireCredential(context.Background()); err != nil { t.Fatal(err) } - initial, _ := s.heldCredential() + initial, _ := s.controller.heldCredential() s.brokerCredential.renewAt = time.Time{} host.status = 503 for range 20 { @@ -326,14 +326,14 @@ func TestBrokerTransientRenewalKeepsHeldCredentialAndBacksOff(t *testing.T) { t.Fatalf("transient renewal discarded held credential: %v", err) } } - if host.count() != 2 || s.terminalErr.Load() != nil { + if host.count() != 2 || s.controller.terminalErr.Load() != nil { t.Fatalf("transient renewal did not back off: %d mints", host.count()) } } func TestBrokerTerminalRenewalDiscardsHeldAuthority(t *testing.T) { s, host, _ := brokerFixture(t) - if err := s.openCredential(context.Background()); err != nil { + if _, err := s.acquireCredential(context.Background()); err != nil { t.Fatal(err) } s.brokerCredential.renewAt = time.Time{} @@ -348,14 +348,12 @@ func TestBrokerTerminalRenewalDiscardsHeldAuthority(t *testing.T) { if host.count() != 2 { t.Fatalf("terminal renewal retried: %d", host.count()) } - if err := s.actingForAViewer(context.Background()); err == nil { - t.Fatal("viewer gate ignored terminal broker failure") - } recorder := httptest.NewRecorder() s.handleHealth(recorder, httptest.NewRequest("GET", "/health", nil)) - if recorder.Code == http.StatusOK { - t.Fatal("health accepted terminal broker failure") + if recorder.Code != 200 { + t.Fatal("scoped refusal affected process health") } + } type brokerAuthorityPin struct { @@ -417,7 +415,7 @@ func TestBrokerHoldsAuthorityAndExecutionAcrossRenewals(t *testing.T) { host.switchTo = tc.after host.switchTo.prepareAuthority(t) host.switchAfter = 1 - if err := s.openCredential(context.Background()); err != nil { + if _, err := s.acquireCredential(context.Background()); err != nil { t.Fatal(err) } s.brokerCredential.renewAt = time.Time{} @@ -432,14 +430,98 @@ func TestBrokerHoldsAuthorityAndExecutionAcrossRenewals(t *testing.T) { // Exercise the native broker through the controller without a // socket. The booted test separately proves serve observes this // signal and returns without cancellation by the test. - select { - case <-s.credentialRefusedC(): - default: - t.Fatal("the execution refusal did not signal serve to stop") - } - if terminal := s.terminalErr.Load(); terminal == nil || !errors.Is(*terminal, workcontext.ErrMintRefused) { + + if terminal := s.controller.terminalErr.Load(); terminal == nil || !errors.Is(*terminal, workcontext.ErrMintRefused) { t.Fatal("the execution refusal was not recorded for serve") } }) } } + +// brokerHarness supplies an in-memory wire to the production acquisition controller. +type brokerHarness struct { + *Server + brokerCredential *brokerCredentialSource + controller *credentialController +} + +func (h *brokerHarness) acquireCredential(ctx context.Context) (executionCredential, error) { + return h.controller.credentialWithin(ctx, routeCredentialTimeout) +} + +func TestProjectionReadSlotSurvivesScopeReplacement(t *testing.T) { + h, host, _ := brokerFixture(t) + release := make(chan struct{}) + var reads atomic.Int32 + options := h.brokerCredential.options + options.ProjectedToken = brokerTokenRead(func() (string, error) { reads.Add(1); <-release; return "late", nil }) + for range 8 { + source, err := newBrokerCredentialSource(options) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Millisecond) + _, err = source.Credential(ctx) + cancel() + if err == nil { + t.Fatal("blocked projection succeeded") + } + } + if reads.Load() != 1 || host.count() != 0 { + t.Fatalf("readers=%d mints=%d", reads.Load(), host.count()) + } + close(release) + deadline := time.Now().Add(time.Second) + for projectionBusy(options.projectionRead) && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + if projectionBusy(options.projectionRead) { + t.Fatal("late reader retained shared slot") + } +} + +func projectionBusy(r *brokerProjectionReader) bool { + r.mu.Lock() + defer r.mu.Unlock() + return r.flight != nil +} + +func TestConcurrentScopesShareSuccessfulProjectionRead(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + reader := &brokerProjectionReader{} + release := make(chan struct{}) + var calls atomic.Int32 + source := brokerTokenRead(func() (string, error) { calls.Add(1); <-release; return "rotated", nil }) + result := make(chan error, 2) + for range 2 { + go func() { + token, err := reader.read(t.Context(), source) + if err == nil && token != "rotated" { + err = errors.New("wrong token") + } + result <- err + }() + } + // Both callers are blocked on the same flight before the source completes. + synctest.Wait() + if calls.Load() != 1 { + t.Fatalf("concurrent source reads = %d, want 1", calls.Load()) + } + if len(result) != 0 { + t.Fatal("projection caller returned before source completion") + } + close(release) + for range 2 { + if err := <-result; err != nil { + t.Fatal(err) + } + } + // A later mint reads again, even if the projection has not changed. + if _, err := reader.read(t.Context(), source); err != nil { + t.Fatal(err) + } + if calls.Load() != 2 { + t.Fatalf("source reads after later mint = %d, want 2", calls.Load()) + } + }) +} diff --git a/broker_owner.go b/broker_owner.go new file mode 100644 index 0000000..904c3e1 --- /dev/null +++ b/broker_owner.go @@ -0,0 +1,51 @@ +package solution + +import ( + "context" + "errors" + "net/http" + + "github.com/codefly-dev/saas-sdk-go/moduleauthority" +) + +// These adapters supply the owner's existing seams. No broker wire encoding or +// response parsing belongs to the runtime. +type brokerGateway struct { + origin string + client *http.Client +} + +func (g brokerGateway) BaseURL() string { return g.origin } +func (g brokerGateway) HTTPClient() *http.Client { return g.client } + +type brokerProjectionToken string + +func (t brokerProjectionToken) ProjectedToken() (string, error) { return string(t), nil } + +// Map only owner-typed outcomes into the runtime's bounded retry policy. Keep +// no response, transport cause or parser diagnostic that could carry a secret. +func ownerBrokerFailure(ctx context.Context, err error) *brokerMintError { + if ctx.Err() != nil { + return classifyBrokerTransport(ctx.Err()) + } + var response *moduleauthority.BrokerError + if errors.As(err, &response) { + return brokerStatusError(response.StatusCode) + } + var transport *moduleauthority.BrokerTransportError + if errors.As(err, &transport) { + category := brokerTransportOther + switch transport.Category { + case moduleauthority.BrokerTransportTimeout: + category = brokerTransportTimeout + case moduleauthority.BrokerTransportConnectionRefused: + category = brokerTransportRefused + case moduleauthority.BrokerTransportConnectionReset: + category = brokerTransportReset + case moduleauthority.BrokerTransportTLS: + category = brokerTransportTLS + } + return &brokerMintError{class: brokerTransport, transport: category} + } + return &brokerMintError{class: brokerInvalidAnswer} +} diff --git a/conformance_test.go b/conformance_test.go index b11129e..d68ffe0 100644 --- a/conformance_test.go +++ b/conformance_test.go @@ -262,7 +262,7 @@ func carryThroughThisRuntime(t *testing.T, token string) (*http.Request, error) })) defer far.Close() - gateway := newGateway(far.URL, "Bearer viewer", "org-1", "session-1") + gateway := newGateway(far.URL, "Bearer viewer", testOrganization, "session-1") // The capability as a mint would have left it in the cache, so the // transport resolves this exact token rather than minting another. const key = "conformance" diff --git a/contract_test.go b/contract_test.go index 86f7038..a6e2f8d 100644 --- a/contract_test.go +++ b/contract_test.go @@ -402,7 +402,7 @@ func TestABootedRuntimeHoldsForModuleToThePublishedCeiling(t *testing.T) { t.Fatal(err) } request.Header.Set("authorization", viewerBearer()) - request.Header.Set(orgHeader, "org-1") + request.Header.Set(orgHeader, testOrganization) request.Header.Set(sessionHeader, "session-1") request.Header.Set(workcontext.InstallationIDHeaderName, testInstallation) resp, err := solution.client.Do(request) @@ -498,7 +498,7 @@ func TestTheCeilingRefusesAsksThatStateNoAuthority(t *testing.T) { t.Fatal(err) } request.Header.Set("authorization", viewerBearer()) - request.Header.Set(orgHeader, "org-1") + request.Header.Set(orgHeader, testOrganization) request.Header.Set(sessionHeader, "session-1") request.Header.Set(workcontext.InstallationIDHeaderName, testInstallation) resp, err := solution.client.Do(request) @@ -569,7 +569,7 @@ func TestAViewerBearerAudienceCannotBeMintedFor(t *testing.T) { header := http.Header{} header.Set("authorization", viewerBearer()) - header.Set(orgHeader, "org-1") + header.Set(orgHeader, testOrganization) header.Set(sessionHeader, "session-1") header.Set(workcontext.InstallationIDHeaderName, testInstallation) _, err = server.gatewayFor(header).ForModule(context.Background(), "pages", @@ -643,7 +643,7 @@ func TestAnEmptyManifestIdCannotDisableTheCeiling(t *testing.T) { header := http.Header{} header.Set("authorization", viewerBearer()) - header.Set(orgHeader, "org-1") + header.Set(orgHeader, testOrganization) header.Set(sessionHeader, "session-1") header.Set(workcontext.InstallationIDHeaderName, testInstallation) // With a stated scope, so the ask reaches the ceiling rather than the diff --git a/credential.go b/credential.go index 507d4cb..4380996 100644 --- a/credential.go +++ b/credential.go @@ -7,7 +7,6 @@ import ( "log" "net/http" "sync" - "time" codefly "github.com/codefly-dev/sdk-go" "github.com/codefly-dev/sdk-go/workcontext" @@ -80,29 +79,6 @@ func readAuthority(ctx context.Context) (*codefly.Authority, error) { return authority, nil } -// platformCredentialSource is the native module broker client, pointed at the -// host's broker route on the resolved gateway, presenting the service-account -// token the platform projects for this workload. -// -// It carries no identity of its own. The hop is plain HTTP inside the cell, -// carried by the mesh, and the projected token is what attests which workload -// asks: the host establishes the principal, the installation and the build -// from that token and its own records, never from anything this process -// reports. The client is not proxied, follows no redirect and dials the one -// origin it was built for, because the request carries that token and a -// Location would hand it to whatever answered. -func (s *Server) platformCredentialSource() (*brokerCredentialSource, error) { - projectionAudience, err := s.authority.Value(AuthorityGroup, AuthorityProjectionAudienceKey) - if err != nil { - return nil, err - } - client, err := newBrokerCredentialSource(s.mintOptions(projectionAudience)) - if err != nil { - return nil, fmt.Errorf("configure this workload's credential mint at %s: %w", redactedURL(s.cfg.mintURL), err) - } - return client, nil -} - // heldToTheFrozenAuthority rechecks the authority whenever a source hands back // a credential that is not the one it handed back last. // @@ -243,6 +219,7 @@ type sealedIdentity struct { // authority and the resolved gateway. No environment is read after loadConfig. func (s *Server) mintOptions(projectionAudience string) brokerMintOptions { return brokerMintOptions{ + projectionRead: &s.projectionRead, URL: s.cfg.mintURL, Prefix: s.manifest.ID, InternalToken: s.cfg.brokerInternalToken, @@ -254,278 +231,6 @@ func (s *Server) mintOptions(projectionAudience string) brokerMintOptions { } } -// openCredential obtains this execution's credential, once, before the listener -// exists. -// -// The two outcomes the SDK distinguishes are not the same outcome here, and -// conflating them was the first thing the host asked me to change: -// -// - A **refusal** is the host judging this workload: this build is not the one -// its presence document approved, the pod's identity does not match, the -// token attests to another subject. No number of attempts changes any of -// those answers, so the boot reports what the issuer said and returns — -// which is a non-zero exit for whoever called Serve, and a redeploy or a -// corrected delivery for whoever reads it. It is never retried: the old -// runtime answered every refusal by beating again, minting a fresh -// single-use token each time, so one undeployable solution produced an -// audited mint every 15 seconds for as long as it ran. -// -// - An **unavailable** mint is not a judgement at all. A workload may -// legitimately start before its presence generation has been applied, and -// then there is no incarnation to mint against yet; the issuer may also be -// unable to reach the Kubernetes API or its own policy log, and a host that -// issues nothing in those cases is a host behaving correctly. Each is -// transient by construction, so the boot waits and asks again, within a -// bound. -// -// The bound is what keeps this from being the heartbeat again. It is a boot -// waiting for a dependency, not a process announcing itself: there is no steady -// state in which it runs, nothing is minted on success beyond the one -// credential, and when the bound is spent the boot fails rather than settling -// into a loop. A process that cannot obtain a credential inside it has an -// orchestrator whose job is to restart it, and a restart is cheaper to read -// than a solution that serves nothing while logging forever. -func (s *Server) openCredential(ctx context.Context) error { - if s.credential == nil && s.brokerCredential == nil { - source, err := s.platformCredentialSource() - if err != nil { - return err - } - s.brokerCredential = source - } - // Consumer sources receive the same authority hold as the native broker. - if s.credential != nil { - s.credential = heldToTheFrozenAuthority(s.credential, s.authority) - } - window := s.firstMintWindow - if window == 0 { - window = firstMintWait - } - // The window is a deadline on the whole operation, not a stopwatch read - // between attempts. It used to be the latter: elapsed time was checked only - // after an unavailable answer, so an attempt could start at the deadline - // and a success after it was accepted — and a source that blocks until its - // context is cancelled was never cancelled at all, so a boot could wait - // forever inside one attempt. - deadline, cancel := context.WithDeadline(ctx, time.Now().Add(window)) - defer cancel() - // The first wait is a quarter of the window at most, so a window shorter - // than the backoff still gets more than one attempt. At the real window - // (two minutes) this is just firstMintRetry; it matters for a caller that - // sets a short one, where a fixed first backoff would spend the whole - // window waiting after a single ask. - wait := firstMintRetry - if quarter := window / 4; quarter < wait { - wait = quarter - } - for attempt := 1; ; attempt++ { - credential, err := s.nextCredential(deadline) - switch { - case err == nil && usableCredential(credential) != nil: - // A source may hand back the zero credential or an expired one, - // and this boot used to accept either: it logged a credential - // sealed to installation "" and served. The classification is - // usableCredential's — an empty token is a judgement, an expired - // one is not — so an unavailable answer here keeps waiting inside - // the window and a refused one fails the boot. - err = usableCredential(credential) - if !errors.Is(err, workcontext.ErrMintUnavailable) { - return fmt.Errorf("obtain this execution's credential from %s: %w", s.cfg.mintURL, err) - } - case err == nil && deadline.Err() == nil: - // Into the controller, which is the half that was missing: the - // boot obtained the credential and then nothing held it, so the - // first route after boot saw an empty controller, asked again, and - // got none of the backoff or held-credential behaviour the - // controller exists to provide. "One credential per execution" - // starts at the boot's own credential or it starts one request - // late. - s.holdCredential(credential) - // Through the generated GETTERS, not the fields. - // - // SealedValues is core's protobuf type now (an alias for - // basev0.WorkSealV1), so its scalars are POINTERS: formatting - // seal.BuildIncarnation with %d printed a pointer address into the - // boot log instead of the incarnation — the one line an operator - // reads to see which approved build this credential is sealed to. - // The getters return values and answer zero for a nil seal. - // - // golangci-lint's govet caught this and plain `go vet ./...` did - // not, which is worth knowing: the four gate commands would have - // shipped it. - seal := credential.Seal() - log.Printf("solution %q: holding one execution credential, sealed to installation %s revision %d and build incarnation %d, valid until %s", - s.manifest.ID, seal.GetInstallationId(), seal.GetInstallationRevision(), seal.GetBuildIncarnation(), - credential.ExpiresAt().UTC().Format(time.RFC3339)) - return nil - case err == nil: - // A credential that arrived after the window closed is not a - // credential this boot may act on: the deadline is what bounds the - // wait, and accepting a late success would make it advisory. - return fmt.Errorf("obtain this execution's credential from %s: a credential arrived after the %s window closed, so this boot does not hold one", - s.cfg.mintURL, window) - case awaitingInstallation(err): - // Admitted by the host, installed nowhere yet. The install step is - // delivered to this runtime's own listener and carries its own - // authority, so this boot serves — the install endpoint and health - // — holding no credential. serve keeps asking (awaitInstallation) - // and holds the first one the host issues. - s.noteCredentialAnswer(err) - log.Printf("solution %q: no organization has installed this solution yet; serving its install endpoint, and holding a credential once one has", s.manifest.ID) - return nil - case !errors.Is(err, workcontext.ErrMintUnavailable): - // A judgement, or a configuration this client will not even send. - return fmt.Errorf("obtain this execution's credential from %s: %w", s.cfg.mintURL, err) - case deadline.Err() != nil: - return fmt.Errorf("obtain this execution's credential from %s: still unavailable after %s and %d attempt(s): %w — the issuer answers this way while a presence generation has not been applied yet, or while it cannot reach the Kubernetes API or its policy log; all three are transient, so this is an exit for the orchestrator to retry rather than a judgement on this build", - s.cfg.mintURL, window, attempt, err) - } - remaining := time.Until(mustDeadline(deadline)) - if remaining <= 0 { - return fmt.Errorf("obtain this execution's credential from %s: still unavailable after %s (transient): %w", s.cfg.mintURL, window, err) - } - if wait > remaining { - wait = remaining - } - log.Printf("solution %q: this execution's credential is not available yet (attempt %d, retrying in %s): %v", - s.manifest.ID, attempt, wait, err) - select { - case <-deadline.Done(): - return fmt.Errorf("obtain this execution's credential from %s: still unavailable after %s and %d attempt(s): %w — the issuer answers this way while a presence generation has not been applied yet, or while it cannot reach the Kubernetes API or its policy log; all three are transient, so this is an exit for the orchestrator to retry rather than a judgement on this build", - s.cfg.mintURL, window, attempt, err) - case <-time.After(wait): - } - if wait *= 2; wait > firstMintRetryCap { - wait = firstMintRetryCap - } - } -} - -// awaitInstallation reacts only to an acknowledged installation. The host -// activates it after reading the acknowledgement, so a bounded retry sequence -// may bridge that transition. No idle timer acquires or renews credentials; -// after the sequence, an actual viewer request can acquire through the same -// single-flight controller. Presence and admission never depend on this loop. -func (s *Server) awaitInstallation(ctx context.Context) { - for { - select { - case <-ctx.Done(): - return - case <-s.credentialRefusedC(): - return - case <-s.installed: - } - for ask := 0; s.awaitsInstallation(); ask++ { - credential, err := s.askOnce(ctx, platformRequestTimeout) - if err == nil { - seal := credential.Seal() - log.Printf("solution %q: installation acknowledged; credential sealed to installation %s revision %d and build incarnation %d, valid until %s", - s.manifest.ID, seal.GetInstallationId(), seal.GetInstallationRevision(), seal.GetBuildIncarnation(), credential.ExpiresAt().UTC().Format(time.RFC3339)) - break - } - if errors.Is(err, ErrCredentialRefused) || ask >= len(awaitInstallationFollowUps) { - break - } - select { - case <-ctx.Done(): - return - case <-s.credentialRefusedC(): - return - case <-time.After(awaitInstallationFollowUps[ask]): - } - } - } -} - -// Bounded retries for the activation race after an installation acknowledgement. -var awaitInstallationFollowUps = []time.Duration{ - 250 * time.Millisecond, 500 * time.Millisecond, time.Second, 2 * time.Second, 4 * time.Second, -} - -// awaitingInstallation reports the host's answer that nobody has installed -// this solution yet. -func awaitingInstallation(err error) bool { - var broker *brokerMintError - return errors.As(err, &broker) && broker.class == brokerAwaitingInstallation -} - -// uninstalled reports that this process holds no credential because the host, -// when last asked, said nothing has installed this solution. -// -// It is a reading of what the controller already recorded and asks nobody. A -// probe or an install step that asked would be one more mint attempt per -// request against a host that has already answered. -func (s *Server) uninstalled() bool { - if s.terminalErr.Load() != nil { - return false - } - s.credentialMu.Lock() - defer s.credentialMu.Unlock() - if held := s.credentialHeld; held != nil && held.Token() != "" && time.Now().Before(held.ExpiresAt()) { - return false - } - return awaitingInstallation(s.credentialAskErr) -} - -// awaitsInstallation reports that this process holds no credential and the -// host has said, at some point since it last held one, that nothing has -// installed the solution. -// -// Wider than uninstalled on purpose. One transient answer after a 412 — the -// host restarting, a pass in flight — leaves the solution exactly as -// uninstalled as it was, and an asker that stopped there would leave the -// first credential waiting for the host's next install delivery. -func (s *Server) awaitsInstallation() bool { - if s.terminalErr.Load() != nil || !s.awaiting.Load() { - return false - } - _, held := s.heldCredential() - return !held -} - -// noteCredentialAnswer records an answer the controller did not itself -// receive — the boot's — so every reader sees the same last answer. -func (s *Server) noteCredentialAnswer(err error) { - s.awaiting.Store(awaitingInstallation(err)) - s.credentialMu.Lock() - s.credentialAskErr = err - s.credentialMu.Unlock() -} - -// installAcknowledged tells awaitInstallation that an install step has just -// been answered with success, so the credential is worth asking for now. -// Never blocks: one pending signal is as good as several. -func (s *Server) installAcknowledged() { - select { - case s.installed <- struct{}{}: - default: - } -} - -// mustDeadline is the deadline of a context that has one. openCredential sets -// it, so the fallback is only there to keep the arithmetic total. -func mustDeadline(ctx context.Context) time.Time { - if at, ok := ctx.Deadline(); ok { - return at - } - return time.Now() -} - -// firstMintWait bounds how long a boot waits for a credential the issuer says -// is not available yet, and firstMintRetry/firstMintRetryCap pace the asks -// inside it. -// -// The bound is deliberately short. What it covers is a workload that started -// before its own presence generation was applied, which is a reconcile away, -// not an outage to ride out — and anything longer is a process holding a pod -// slot while serving nothing, which an orchestrator reads less clearly than an -// exit. -const ( - firstMintWait = 2 * time.Minute - firstMintRetry = 2 * time.Second - firstMintRetryCap = 15 * time.Second -) - // openAuthority freezes the authority-bearing values this process runs under // and reports the principal it runs as. // @@ -534,8 +239,8 @@ const ( // value the platform never provisioned is a refusal that should not cost a // round trip to discover. // -// It happens on **every** path, including a boot that supplied its own -// credential source. A hook changes where a credential comes from; it does not +// It happens for every authority-consuming declaration, including one with +// a custom credential source. Authority-free declarations skip this step. A hook changes where a credential comes from; it does not // change who this workload is. Skipping this for a custom source left the // published contract naming no principal and the listener held to nothing — // so the two strongest properties in the boot were off for exactly the @@ -587,26 +292,9 @@ func (s *Server) openAuthority(ctx context.Context) error { // controller means one controller. type credentialAcquirer func(context.Context) (executionCredential, error) -// attestWorkloadReporting is attestWorkload with somewhere to report a -// *terminal* failure, which is the half that is easy to get backwards. -// -// The boot already separates the issuer's two answers and must: a refusal is a -// judgement on this build — the installation moved, the build is not the one -// the presence document approved, the principal's epoch advanced — and no -// number of attempts changes it, while an unavailable mint is a transient -// condition worth waiting out. At *renewal* both were collapsed into -// ErrNotAttested, which relayedError maps to unavailable with the note that one -// renewal fixes it. For a refusal that is false: nothing fixes it, so the -// process answered 503 to every request, forever, while reporting itself -// healthy — the one shape this cutover was supposed to end, because a solution -// that serves nothing while looking alive is exactly what the delivered-presence -// model replaced the heartbeat to avoid. -// -// A terminal refusal therefore ends the process. Health starts failing so the -// host's own probe takes the binding out, and serve returns non-zero so the -// orchestrator restarts it against the delivery as it now stands — which is -// where a build the host no longer approves gets resolved, and it is not here. -func attestWorkloadReporting(ctx context.Context, acquire credentialAcquirer, report *attestationReport, request *http.Request, id string, terminal func(error)) error { +// attestWorkloadReporting attaches the selected installation credential before +// any viewer mint. Errors retain safe classification and are logged once. +func attestWorkloadReporting(ctx context.Context, acquire credentialAcquirer, report *attestationReport, request *http.Request, id string) error { if acquire == nil { // No source at all is a programming error on a path that mints: every // boot opens one, and a consumer that supplied its own supplied a @@ -618,9 +306,6 @@ func attestWorkloadReporting(ctx context.Context, acquire credentialAcquirer, re credential, err := acquire(ctx) if err != nil { report.say(id, "refusing to mint for a viewer: this execution's credential could not be obtained: "+err.Error()) - if terminal != nil && terminalCredentialFailure(err) { - terminal(err) - } return fmt.Errorf("%w: %w", ErrNotAttested, err) } // Defence in depth, and unreachable by design since round seven: a @@ -687,21 +372,9 @@ func (r *attestationReport) recovered(id string) { } // terminalCredentialFailure preserves refusals separately from terminal broker -// protocol/configuration errors. Neither may cause another execution mint. +// protocol/configuration errors. Both refuse this acquisition and held fallback. func terminalCredentialFailure(err error) bool { var broker *brokerMintError return errors.Is(err, workcontext.ErrMintRefused) || (errors.As(err, &broker) && !broker.retryable()) } - -// Both sources enter the same acquisition controller; the public SDK source -// keeps its original signature while the broker carries Core's inspected value. -func (s *Server) nextCredential(ctx context.Context) (executionCredential, error) { - if s.credential != nil { - return s.credential.Credential(ctx) - } - if s.brokerCredential != nil { - return s.brokerCredential.Credential(ctx) - } - return workcontext.Credential{}, errors.New("no credential source") -} diff --git a/credential_test.go b/credential_test.go index 6a6cee2..f4e177c 100644 --- a/credential_test.go +++ b/credential_test.go @@ -23,115 +23,28 @@ import ( "github.com/codefly-dev/sdk-go/workcontext" ) -// TestARefusedMintFailsTheBootAndIsNeverRetried is the model's sharpest edge: a -// host that has judged this workload is not a condition to retry. The old -// runtime answered every refusal by beating again, minting a fresh single-use -// token each time, so one undeployable solution produced an audited mint every -// 15 seconds for as long as it ran. This boot reports the refusal and returns, -// which is a non-zero exit for the process that called Serve. -func TestARefusedMintFailsTheBootAndIsNeverRetried(t *testing.T) { - for _, tc := range []struct { - name string - status int - }{ - {"the host refuses this workload's build or identity", http.StatusForbidden}, - {"the projected token is not acceptable", http.StatusUnauthorized}, - } { - t.Run(tc.name, func(t *testing.T) { - mint := newHostMint(t, &hostMint{status: tc.status}) - err := bootFails(t, New(Manifest{ID: testSolutionID}), mint) - if !errors.Is(err, workcontext.ErrMintRefused) { - t.Fatalf("boot error = %v, want one wrapping %v so a caller can tell a judgement from an outage", err, workcontext.ErrMintRefused) - } - if !strings.Contains(err.Error(), mint.URL) { - t.Errorf("boot error %q does not name the mint endpoint it asked", err) - } - if got := mint.count(); got != 1 { - t.Errorf("the boot asked the host %d times, want exactly 1: a refusal is terminal", got) - } - }) - } -} - -// TestAnUnavailableMintIsWaitedForWithinABound is the other half, and the half -// the host asked for: a workload may legitimately start before its presence -// generation has been applied, and the issuer answers that — and its own -// unreachable dependencies — with a retryable status. Treating it as terminal -// would make a pod that is one reconcile early fail for a reason that is not -// about it. -func TestAnUnavailableMintIsWaitedForWithinABound(t *testing.T) { - t.Run("it recovers when the issuer catches up", func(t *testing.T) { - mint := newHostMint(t, &hostMint{status: http.StatusServiceUnavailable}) - mint.recoverAfter = 2 - server := New(Manifest{ID: testSolutionID}) - server.firstMintWindow = 20 * time.Second - solution := boot(t, server, mint) - if status := getStatus(t, solution.client, solution.base+HealthPath); status != http.StatusOK { - t.Fatalf("health = %d, want 200: the boot waited out an unapplied presence generation", status) - } - if got := mint.count(); got < 3 { - t.Errorf("the host was asked %d times, want the two refusals plus the one that answered", got) - } - }) - - t.Run("it gives up when the window is spent, and the window is a deadline", func(t *testing.T) { - mint := newHostMint(t, &hostMint{status: http.StatusServiceUnavailable}) - server := New(Manifest{ID: testSolutionID}) - // Long enough for more than one attempt over TLS, short enough that - // the test spends the window rather than waiting out the real two - // minutes. - server.firstMintWindow = 1500 * time.Millisecond - started := time.Now() - // Bounded here, not measured afterwards: against a runtime whose - // window is not a deadline this call never returns, and every - // assertion below is unreachable. - err := bootFailsWithin(t, server, mint, 4*server.firstMintWindow) - elapsed := time.Since(started) - - if !errors.Is(err, workcontext.ErrMintUnavailable) { - t.Fatalf("boot error = %v, want one wrapping %v", err, workcontext.ErrMintUnavailable) - } - if !strings.Contains(err.Error(), "transient") { - t.Errorf("boot error %q does not say the cause is transient, which is what makes the exit the orchestrator's to retry", err) +func TestBrokerRefusalDoesNotPreventIdleBoot(t *testing.T) { + for _, status := range []int{http.StatusUnauthorized, http.StatusForbidden} { + mint := newHostMint(t, &hostMint{status: status}) + solution := boot(t, New(Manifest{ID: testSolutionID}), mint) + if got := getStatus(t, solution.client, solution.base+HealthPath); got != 200 { + t.Fatalf("health = %d", got) } - if got := mint.count(); got < 2 { - t.Errorf("the host was asked %d times, want more than one: an unavailable mint is waited for, not judged — boot said: %v", got, err) + if mint.count() != 0 { + t.Fatal("idle boot contacted broker") } - // The window bounds the whole operation. It used to be read only - // between attempts, so an attempt could start at the deadline and run - // as long as it liked past it. - if elapsed > 4*server.firstMintWindow { - t.Errorf("the boot took %s for a %s window: the window is a deadline on the operation, not a stopwatch read between attempts", elapsed.Round(time.Millisecond), server.firstMintWindow) - } - }) + } +} - t.Run("a source that never answers is cut off by the window", func(t *testing.T) { - // The sharpest case for the deadline: a source that waits for its - // context rather than returning. Without the window on the context it - // is handed, the boot waits in one attempt forever — no listener, no - // log, and nothing for an orchestrator to act on. - mint := newHostMint(t, &hostMint{}) - blocking := &blockingCredentialSource{entered: make(chan struct{}, 1)} - server := New(Manifest{ID: testSolutionID}).Credential(blocking) - server.firstMintWindow = 400 * time.Millisecond - started := time.Now() - err := bootFailsWithin(t, server, mint, 10*server.firstMintWindow) - elapsed := time.Since(started) - if err == nil { - t.Fatal("the boot came up on a source that never answered") - } - if elapsed > 10*server.firstMintWindow { - t.Fatalf("the boot waited %s on a source that never answers, for a %s window", elapsed.Round(time.Millisecond), server.firstMintWindow) - } - select { - case <-blocking.entered: - default: - t.Error("the source was never called, so this test did not exercise the deadline") - } - if !blocking.sawCancellation() { - t.Error("the source's context was never cancelled: the window has to reach the attempt, not just the loop around it") - } - }) +func TestUnavailableBrokerDoesNotDelayIdleBoot(t *testing.T) { + mint := newHostMint(t, &hostMint{status: http.StatusServiceUnavailable}) + solution := boot(t, New(Manifest{ID: testSolutionID}), mint) + if got := getStatus(t, solution.client, solution.base+HealthPath); got != 200 { + t.Fatalf("health = %d", got) + } + if mint.count() != 0 { + t.Fatal("idle boot contacted unavailable broker") + } } // blockingCredentialSource waits for its context instead of answering: the @@ -443,28 +356,10 @@ func TestABootedRuntimeRegistersNothing(t *testing.T) { // about the runtime rather than about a port that closed with it. listening("after the shutdown") - if got := mint.count(); got != 1 { - t.Errorf("the host received %d requests, want exactly 1 (the mint): anything more is a registration that survived", got) - } - // The broker requires a perimeter header, but its value must come from the - // declared workspace secret, never the nonempty legacy variable above. - if mint.headers[0].Get("X-Codefly-Internal-Token") != "broker-perimeter-token" { - t.Error("broker perimeter header did not use the declared secret") - } - for _, header := range []string{ - "x-codefly-solution-registration", - "x-codefly-module-registration", - removedClaimShape(t, "header"), - } { - if value := mint.headers[0].Get(header); value != "" { - t.Errorf("the one request this runtime made presented %s=%q: this runtime has no registration credential to present", header, value) - } - } - for _, path := range mint.paths { - if path != credentialMintPath { - t.Errorf("the host was asked for %q: the only endpoint this runtime calls on its own behalf is the mint", path) - } + if got := mint.count(); got != 0 { + t.Fatalf("idle run made %d broker calls", got) } + } // TestTheMintCarriesThisWorkloadsCredentialForAViewersMint: the mint this @@ -486,7 +381,7 @@ func TestTheMintCarriesThisWorkloadsCredentialForAViewersMint(t *testing.T) { server.cfg.gatewayURL = gw.URL header := http.Header{} header.Set("authorization", viewerBearer()) - header.Set(orgHeader, "org-1") + header.Set(orgHeader, testOrganization) header.Set(sessionHeader, "session-1") header.Set(workcontext.InstallationIDHeaderName, testInstallation) gateway := server.gatewayFor(header) @@ -530,7 +425,7 @@ func TestAMintIsRefusedWhenThisWorkloadCannotAttest(t *testing.T) { header := http.Header{} header.Set("authorization", viewerBearer()) - header.Set(orgHeader, "org-1") + header.Set(orgHeader, testOrganization) header.Set(sessionHeader, "session-1") header.Set(workcontext.InstallationIDHeaderName, testInstallation) for range 3 { @@ -568,7 +463,7 @@ func TestAServerWithNoCredentialSourceMintsNothing(t *testing.T) { server.cfg.gatewayURL = gw.URL header := http.Header{} header.Set("authorization", viewerBearer()) - header.Set(orgHeader, "org-1") + header.Set(orgHeader, testOrganization) header.Set(sessionHeader, "session-1") header.Set(workcontext.InstallationIDHeaderName, testInstallation) _, err := server.gatewayFor(header).ForModule(context.Background(), "things", Scope{ResourceKind: "things", Actions: []string{"read"}}) @@ -689,85 +584,21 @@ func (r refusingSource) Credential(context.Context) (workcontext.Credential, err // forever while /health returned 200, which is the exact shape the // delivered-presence model replaced the heartbeat to avoid: a solution that // serves nothing and looks alive. -func TestATerminalRenewalRefusalEndsTheProcessRatherThanServing503Forever(t *testing.T) { - for _, tc := range []struct { - name string - err error - terminal bool - }{ - {"the host refuses this build", fmt.Errorf("mint: %w", workcontext.ErrMintRefused), true}, - // NOT terminal, and the earlier revision of this test asserted the - // opposite. ErrRevoked does not come back from the mint at all — it is - // a *callee* refusing a capability sealed to state that has moved — and - // the SDK's contract for it is a refresh and one retry. Ending the - // process on it would be ending on a condition the client recovers - // from. It is handled where it arrives: the far end's 409 drops the - // capability so the next call mints. - {"a capability a callee says is superseded", fmt.Errorf("mint: %w", workcontext.ErrRevoked), false}, - // The control, and the reason this is not just "exit on any error": an - // issuer that cannot reach its own policy log is behaving correctly, - // and exiting on it turns a dependency blip into a crash loop. - {"the issuer is briefly unavailable", fmt.Errorf("mint: %w", workcontext.ErrMintUnavailable), false}, - } { - t.Run(tc.name, func(t *testing.T) { - gw := newModuleGateway(t, http.StatusOK, `{"entry_id":"e1"}`) - server := New(Manifest{ID: testSolutionID}).Credential(refusingSource{err: tc.err}) - server.cfg.gatewayURL = gw.URL - - // Health observes; only actual work may acquire a credential. - if status, _ := healthStatus(t, server); status != http.StatusOK { - t.Errorf("health before any credential request = %d, want 200", status) - } - - header := http.Header{} - header.Set("authorization", viewerBearer()) - header.Set(orgHeader, "org-1") - header.Set(sessionHeader, "session-1") - header.Set(workcontext.InstallationIDHeaderName, testInstallation) - _, err := server.gatewayFor(header).ForModule(context.Background(), "things", - Scope{ResourceKind: "things", Actions: []string{"read"}}) - if !errors.Is(err, ErrNotAttested) { - t.Fatalf("ForModule error = %v, want ErrNotAttested", err) - } - if got := len(gw.observedMints()); got != 0 { - t.Errorf("observed %d viewer mints, want 0", got) - } - - status, body := healthStatus(t, server) - if tc.terminal { - if status != http.StatusServiceUnavailable { - t.Errorf("health = %d after a refusal the issuer will not reverse, want 503: a 200 invites the host to keep routing to a binding that answers nothing", status) - } - if !strings.Contains(body, "refused") { - t.Errorf("the probe says %q, want it to name the condition", body) - } - select { - case <-server.credentialRefusedC(): - default: - t.Error("the process was not asked to end: a credential the host has stopped honouring cannot be waited out") - } - if stored := server.terminalErr.Load(); stored == nil || !errors.Is(*stored, workcontext.ErrMintRefused) { - t.Error("the reason this process is ending was not recorded, so its exit would say nothing") - } - } else { - // The failed operation is unavailable; the process stays alive - // without health probes retrying the mint. - if status != http.StatusOK { - t.Errorf("health = %d after transient request failure, want 200 without an idle retry", status) - } - if strings.Contains(body, "refused") { - t.Errorf("the probe says %q, which reads as a judgement on this build: this condition is transient", body) - } - select { - case <-server.credentialRefusedC(): - t.Error("the process was ended on a transient condition") - default: - } - if server.terminalErr.Load() != nil { - t.Error("a transient condition was recorded as the reason this process is ending") - } - } - }) +func TestScopedRefusalDoesNotEndTheProcess(t *testing.T) { + for _, failure := range []error{workcontext.ErrMintRefused, workcontext.ErrRevoked, workcontext.ErrMintUnavailable} { + gw := newModuleGateway(t, http.StatusOK, `{}`) + server := declaredWorkServer(t).Credential(refusingSource{err: failure}) + server.cfg.gatewayURL = gw.URL + _, err := server.gatewayFor(workHeaders()).ForModule(t.Context(), "things", Scope{ResourceKind: "things", Actions: []string{"read"}}) + if !errors.Is(err, ErrNotAttested) || len(gw.observedMints()) != 0 { + t.Fatalf("unattested work escaped: %v", err) + } + if status, _ := healthStatus(t, server); status != 200 { + t.Fatal("scoped failure ended shared process") + } + if terminalCredentialFailure(failure) != (failure == workcontext.ErrMintRefused) { + t.Fatal("failure classification changed") + } } } @@ -826,68 +657,28 @@ func (f failingCredentialSource) Credential(context.Context) (workcontext.Creden // // handleHealth already made this argument in full and it was applied to the // probe alone: the probe answered honestly while the routes carried on. -func TestAPlainHandlerRefusesToActWithoutACredential(t *testing.T) { - for _, tc := range []struct { - name string - err error - ends bool - names error - }{ - { - "a refused build", - fmt.Errorf("%w: this build is not the one the presence document approved", workcontext.ErrMintRefused), - true, ErrCredentialRefused, - }, - { - "an issuer that cannot answer", - fmt.Errorf("%w: the issuer cannot reach its own dependencies", workcontext.ErrMintUnavailable), - false, ErrCredentialUnavailable, - }, - } { - t.Run(tc.name, func(t *testing.T) { - var reached atomic.Int64 - server := New(Manifest{ID: testSolutionID}). - Credential(failingCredentialSource{err: tc.err}) - handler := server.wrapRequest(func(*http.Request, *Gateway) (any, error) { - reached.Add(1) - return map[string]string{"ok": "yes"}, nil - }) - - request := httptest.NewRequest(http.MethodGet, "/thing", nil) - request.Header.Set("authorization", viewerBearer()) - recorder := httptest.NewRecorder() - handler(recorder, request) - - if recorder.Code != http.StatusServiceUnavailable { - t.Errorf("the handler answered %d while this process held no approved credential, want 503: it would forward the viewer's bearer to the gateway under an authority the issuer has withdrawn", recorder.Code) - } - if got := reached.Load(); got != 0 { - t.Errorf("the handler body ran %d times, want 0: the refusal has to come before anything acts for the viewer", got) - } - body := recorder.Body.String() - if !strings.Contains(body, "authority") { - t.Errorf("the 503 body %q does not say why this solution will not act", body) - } - // And it carries nothing the source said. The error wraps whatever - // came back — a mint URL, the issuer's own text, a dial failure - // naming an internal address — and this gate wrote it into the - // viewer's response verbatim, which is the disclosure - // handlerErrorResponse exists to prevent. A new gate in front of - // the handler put a path *around* the sanitization. - for _, leaked := range []string{"issuer", "presence document", "dependencies", "mint"} { - if strings.Contains(body, leaked) { - t.Errorf("the 503 body %q carries %q from the source's own error", body, leaked) - } - } - // Terminal and transient stay distinguishable: conflating them was - // a review blocker in both directions. - if err := server.actingForAViewer(context.Background()); !errors.Is(err, tc.names) { - t.Errorf("the route's refusal is %v, want one wrapping %v", err, tc.names) - } - if ended := server.terminalErr.Load() != nil; ended != tc.ends { - t.Errorf("the process ending = %v, want %v: a judgement about this build ends it, an issuer that cannot answer right now does not", ended, tc.ends) +func TestModuleWorkRefusesWithoutACredential(t *testing.T) { + for _, failure := range []error{workcontext.ErrMintRefused, workcontext.ErrMintUnavailable} { + server := declaredWorkServer(t).Credential(failingCredentialSource{err: fmt.Errorf("private issuer dependencies: %w", failure)}) + reached := false + handler := server.wrapRequest(func(r *http.Request, gw *Gateway) (any, error) { + _, err := gw.ForModule(r.Context(), "things", Scope{ResourceKind: "things", Actions: []string{"read"}}) + if err != nil { + return nil, err } + reached = true + return "ok", nil }) + request := httptest.NewRequest("GET", "/thing", nil) + request.Header = workHeaders() + response := httptest.NewRecorder() + handler(response, request) + if response.Code != 503 || reached { + t.Fatalf("unauthorised work = %d; reached=%v", response.Code, reached) + } + if strings.Contains(response.Body.String(), "private issuer") { + t.Fatal("source diagnostic leaked") + } } } @@ -944,7 +735,7 @@ func TestARouteIsNotParkedByASlowCredentialSource(t *testing.T) { server := New(Manifest{ID: testSolutionID}).Credential(slow) started := time.Now() done := make(chan error, 1) - go func() { done <- server.actingForAViewer(context.Background()) }() + go func() { done <- testWorkCredential(server, context.Background()) }() select { case err := <-done: if err == nil { @@ -968,9 +759,9 @@ func TestARouteIsNotParkedByASlowCredentialSource(t *testing.T) { mint := newHostMint(t, &hostMint{}) held := mintedCredential(t, mint, "held") server := New(Manifest{ID: testSolutionID}).Credential(slow) - server.credentialHeld = held + scopeController(t, server).credentialHeld = held done := make(chan error, 1) - go func() { done <- server.actingForAViewer(context.Background()) }() + go func() { done <- testWorkCredential(server, context.Background()) }() select { case err := <-done: if err != nil { @@ -990,9 +781,9 @@ func TestARouteIsNotParkedByASlowCredentialSource(t *testing.T) { // under. No waiting: see expiredCredential. expired := expiredCredential(t, mint, tokenFile) server := New(Manifest{ID: testSolutionID}).Credential(slow) - server.credentialHeld = expired + scopeController(t, server).credentialHeld = expired done := make(chan error, 1) - go func() { done <- server.actingForAViewer(context.Background()) }() + go func() { done <- testWorkCredential(server, context.Background()) }() select { case err := <-done: if err == nil { @@ -1029,14 +820,14 @@ func TestEverySourceIsHeldToTheFrozenAuthority(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - server := New(Manifest{ID: testSolutionID}).Credential(handing) + server := declaredWorkServer(t).Credential(handing) if _, err := takeListener(server).start(ctx); err != nil { t.Fatalf("boot: %v", err) } // The control: with the authority as this process froze it, a renewal is // handed over. - if _, err := server.nextCredential(ctx); err != nil { + if _, err := scopeController(t, server).nextCredential(ctx); err != nil { t.Fatalf("a renewal was refused while the authority had not drifted: %v", err) } @@ -1047,7 +838,7 @@ func TestEverySourceIsHeldToTheFrozenAuthority(t *testing.T) { if err := codefly.LoadEnvironmentVariables(); err != nil { t.Fatal(err) } - _, err := server.nextCredential(ctx) + _, err := scopeController(t, server).nextCredential(ctx) if err == nil { t.Fatal("a renewal proceeded after an authority-bearing value drifted from the one this process froze: that renewal is the one moment the drift would be laundered into a differently-sealed credential") } @@ -1200,7 +991,7 @@ func TestConcurrentViewerRequestsShareOneCredentialAsk(t *testing.T) { go func() { defer wg.Done() <-start - _, _ = server.credentialWithin(context.Background(), 5*time.Second) + _, _ = scopeController(t, server).credentialWithin(context.Background(), 5*time.Second) }() } close(start) @@ -1268,14 +1059,15 @@ func TestACredentialThatAuthorisesNothingIsRefused(t *testing.T) { // At the boot. boot := newHostMint(t, &hostMint{}) server := New(Manifest{ID: testSolutionID}).Credential(stubCredentialSource{credential: tc.credential}) - server.firstMintWindow = 750 * time.Millisecond - if err := bootFailsWithin(t, server, boot, 10*time.Second); err == nil { + running := bootComponent(t, server, boot, false) + _ = running + if err := testWorkCredential(server, t.Context()); err == nil { t.Fatal("the boot accepted a credential that authorises nothing: this process would serve, answer /health 200, and act for viewers under it") } // And at a route, for a source that only degrades later. serving := New(Manifest{ID: testSolutionID}).Credential(stubCredentialSource{credential: tc.credential}) - if err := serving.actingForAViewer(context.Background()); err == nil { + if err := testWorkCredential(serving, context.Background()); err == nil { t.Fatal("a route acted for a viewer under a credential that authorises nothing") } // And classified: nothing to mint with is a judgement, an expiry @@ -1332,11 +1124,11 @@ func TestATerminalRefusalIsRecordedWhereverItLands(t *testing.T) { source := &slowThenRefused{after: 750 * time.Millisecond, asked: make(chan struct{}, 1)} server := New(Manifest{ID: testSolutionID}).Credential(source) - server.credentialHeld = held + scopeController(t, server).credentialHeld = held // A route asks, times out well before the source answers, and is served // from the credential in hand — which is correct. - if err := server.actingForAViewer(context.Background()); err != nil { + if err := testWorkCredential(server, context.Background()); err != nil { t.Fatalf("a route was refused while this process held a valid credential and the source was merely slow: %v", err) } select { @@ -1347,16 +1139,16 @@ func TestATerminalRefusalIsRecordedWhereverItLands(t *testing.T) { // The refusal lands with nobody waiting. deadline := time.Now().Add(5 * time.Second) - for server.terminalErr.Load() == nil && time.Now().Before(deadline) { + for scopeController(t, server).terminalErr.Load() == nil && time.Now().Before(deadline) { time.Sleep(25 * time.Millisecond) } - if server.terminalErr.Load() == nil { + if scopeController(t, server).terminalErr.Load() == nil { t.Fatal("this execution's credential was refused and nothing recorded it, because the caller that started the ask had already been served: the process keeps acting for viewers under an authority its own source says is gone, and /health keeps answering 200") } // And the process now refuses to act, rather than carrying on with the // credential it still holds. - if err := server.actingForAViewer(context.Background()); !errors.Is(err, ErrCredentialRefused) { + if err := testWorkCredential(server, context.Background()); !errors.Is(err, ErrCredentialRefused) { t.Errorf("after a recorded refusal a route answered %v, want one wrapping %v: a held credential does not survive a judgement about the build that holds it", err, ErrCredentialRefused) } } @@ -1376,7 +1168,7 @@ func TestAWithdrawnAuthorityIsCaughtBeforeTheMint(t *testing.T) { bootEnvironment(t, mint) ctx, cancel := context.WithCancel(context.Background()) defer cancel() - server := New(Manifest{ID: testSolutionID}).Credential(handing) + server := declaredWorkServer(t).Credential(handing) if _, err := takeListener(server).start(ctx); err != nil { t.Fatalf("boot: %v", err) } @@ -1388,7 +1180,7 @@ func TestAWithdrawnAuthorityIsCaughtBeforeTheMint(t *testing.T) { if err := codefly.LoadEnvironmentVariables(); err != nil { t.Fatal(err) } - if _, err := server.nextCredential(ctx); err == nil { + if _, err := scopeController(t, server).nextCredential(ctx); err == nil { t.Fatal("an ask proceeded under a drifted authority") } // And the source was never reached, so nothing was minted under it. @@ -1441,7 +1233,7 @@ func TestNothingIsInferredFromAConflictOnAViewersMint(t *testing.T) { header := http.Header{} header.Set("authorization", viewerBearer()) - header.Set(orgHeader, "org-1") + header.Set(orgHeader, testOrganization) header.Set(sessionHeader, "session-1") header.Set(workcontext.InstallationIDHeaderName, testInstallation) if _, err := server.gatewayFor(header).ForModule(context.Background(), "things", @@ -1451,15 +1243,12 @@ func TestNothingIsInferredFromAConflictOnAViewersMint(t *testing.T) { // That refusal cost the viewer their call and nothing else: this process // still holds its credential and still acts for everybody else. - if _, ok := server.heldCredential(); !ok { + if _, ok := scopeController(t, server).heldCredential(); !ok { t.Error("a conflict on one viewer's mint dropped this process's own credential: the host does not say whether the conflict is about this workload's authority or that viewer's, so inferring the former makes one viewer's missing permission a process-wide outage") } - if err := server.actingForAViewer(context.Background()); err != nil { + if err := testWorkCredential(server, context.Background()); err != nil { t.Errorf("this process stopped acting for viewers after one ambiguous conflict: %v", err) } - if server.terminalErr.Load() != nil { - t.Error("an ambiguous conflict was recorded as a judgement about this build") - } // THE CONTROL a reviewer asked for: another viewer, asking for something // the issuer does not conflict on, is still served. Without it the three @@ -1468,7 +1257,7 @@ func TestNothingIsInferredFromAConflictOnAViewersMint(t *testing.T) { gw.conflict = "" another := http.Header{} another.Set("authorization", viewerBearer()) - another.Set(orgHeader, "org-2") + another.Set(orgHeader, testOrganization) another.Set(sessionHeader, "session-2") another.Set(workcontext.InstallationIDHeaderName, testInstallation) if _, err := server.gatewayFor(another).ForModule(context.Background(), "things", @@ -1504,7 +1293,7 @@ func TestAViewersMintGoesThroughTheOneController(t *testing.T) { header := http.Header{} header.Set("authorization", viewerBearer()) - header.Set(orgHeader, "org-1") + header.Set(orgHeader, testOrganization) header.Set(sessionHeader, "session-1") header.Set(workcontext.InstallationIDHeaderName, testInstallation) mintFor := func(t *testing.T) error { @@ -1547,7 +1336,7 @@ func TestTheBackoffAppliesWhenNothingIsHeld(t *testing.T) { const callers = 30 for range callers { - if err := server.actingForAViewer(context.Background()); err == nil { + if err := testWorkCredential(server, context.Background()); err == nil { t.Fatal("a route acted for a viewer with no credential at all") } } @@ -1588,7 +1377,7 @@ func TestAWaiterReadsItsOwnFlightsResult(t *testing.T) { go func(at int) { defer wg.Done() <-start - credential, err := server.credentialWithin(context.Background(), 5*time.Second) + credential, err := scopeController(t, server).credentialWithin(context.Background(), 5*time.Second) if err == nil { got[at] = credential.Token() } @@ -1668,11 +1457,11 @@ func TestDriftDuringAnAskIsCaughtAfterIt(t *testing.T) { }, } - server := New(Manifest{ID: testSolutionID}) + server := declaredWorkServer(t) server.authority = authorityFor(t, ctx) server.credential = heldToTheFrozenAuthority(source, server.authority) - _, err := server.nextCredential(ctx) + _, err := scopeController(t, server).nextCredential(ctx) <-drifted if err == nil { t.Fatal("a credential obtained while an authority-bearing value drifted mid-ask was handed over: the recheck before the ask cannot see a drift that lands after it, so the one after the ask is what catches this — and gating that on the token changing means a first credential, an unchanged one, and a mid-ask drift all go unchecked") @@ -1700,35 +1489,24 @@ func authorityFor(t *testing.T, ctx context.Context) *codefly.Authority { // // "One credential per execution" starts at the boot's own credential or it // starts one request late. -func TestTheBootsCredentialIsHeldByTheController(t *testing.T) { +func TestFirstWorkAcquiresAfterIdleBoot(t *testing.T) { mint := newHostMint(t, &hostMint{}) - bootEnvironment(t, mint) - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - server := New(Manifest{ID: testSolutionID}) - ln, err := takeListener(server).start(ctx) - if err != nil { - t.Fatalf("boot: %v", err) + server := declaredWorkServer(t) + running := boot(t, server, mint) + if mint.count() != 0 { + t.Fatal("boot minted") } - _ = ln.Close() - - held, ok := server.heldCredential() - if !ok { - t.Fatal("the boot obtained a credential and the controller holds nothing: the first route after boot therefore asks again, outside the single-flight and the backoff, which is the per-request minting this controller was built to stop") - } - if held.Token() == "" { - t.Fatal("the controller holds a credential with no token") + credential, err := server.acquireForInstallation(t.Context(), testOrganization, testInstallation) + if err != nil || credential.Token() == "" { + t.Fatalf("first work: %v", err) } - - // And the boot's mint is the only one: a route served immediately after - // boot must not produce a second. - before := mint.count() - if err := server.actingForAViewer(ctx); err != nil { - t.Fatalf("a route was refused immediately after a successful boot: %v", err) + for range 5 { + if _, err := server.acquireForInstallation(t.Context(), testOrganization, testInstallation); err != nil { + t.Fatal(err) + } } - if got := mint.count(); got != before { - t.Errorf("the host minted %d more times for the first route after boot: the boot's credential is what that route acts under", got-before) + if mint.count() != 1 || healthOf(t, running) != 200 { + t.Fatal("work did not reuse the credential") } } @@ -2096,21 +1874,25 @@ func expiredCredential(t *testing.T, mint *hostMint, tokenFile string) workconte // purpose is attesting which build is asking, so a host can hold a mint to the // build its presence document approved. func TestAnExecutionFreeCredentialIsRefusedEverywhere(t *testing.T) { - t.Run("the boot refuses it, terminally", func(t *testing.T) { + t.Run("first work refuses it", func(t *testing.T) { mint := newHostMint(t, &hostMint{executionFree: true}) bootEnvironment(t, mint) ctx, cancel := context.WithCancel(context.Background()) defer cancel() - server := New(Manifest{ID: testSolutionID}) + server := declaredWorkServer(t) // Short, so a refusal that were wrongly classified as transient shows // up as a bounded wait rather than hanging this test. - server.firstMintWindow = 2 * time.Second + ln, err := takeListener(server).start(ctx) if ln != nil { _ = ln.Close() } + if err != nil { + t.Fatal(err) + } + err = testWorkCredential(server, ctx) if err == nil { - t.Fatal("the boot accepted a credential sealing no execution: it attests no approved build, so the host has nothing to hold a mint to and the ceiling it is held to is nobody's") + t.Fatal("first work accepted a credential sealing no execution: it attests no approved build, so the host has nothing to hold a mint to and the ceiling it is held to is nobody's") } if !errors.Is(err, workcontext.ErrMintRefused) { t.Errorf("the boot failed with %v, want ErrMintRefused: the issuer will seal the same thing next time, so this is a judgement and not an outage", err) @@ -2134,13 +1916,16 @@ func TestAnExecutionFreeCredentialIsRefusedEverywhere(t *testing.T) { gw := newWorkContextGateway(t, &workContextGateway{}) var ran atomic.Bool - server := New(Manifest{ID: testSolutionID}). + server := declaredWorkServer(t). Credential(mintClientFor(t, mint, tokenFile)) - server.cfg = config{gatewayURL: gw.URL} + server.cfg.gatewayURL = gw.URL // wrapRequest is what Serve mounts each route through // (mux.HandleFunc(path, withCORS(s.wrapRequest(handler)))), so this // drives the route's own gate rather than calling the gate directly. - route := server.wrapRequest(func(*http.Request, *Gateway) (any, error) { + route := server.wrapRequest(func(r *http.Request, g *Gateway) (any, error) { + if _, err := g.ForModule(r.Context(), "things", Scope{ResourceKind: "things", Actions: []string{"read"}}); err != nil { + return nil, err + } ran.Store(true) return map[string]string{"ok": "yes"}, nil }) @@ -2178,7 +1963,7 @@ func TestAnExecutionFreeCredentialIsRefusedEverywhere(t *testing.T) { // And the gate itself, named, because the route's 503 does not say // WHICH refusal produced it. - if err := server.actingForAViewer(context.Background()); err == nil { + if err := testWorkCredential(server, context.Background()); err == nil { t.Fatal("a route acted for a viewer under a credential sealing no execution") } else if !errors.Is(err, ErrCredentialRefused) { t.Errorf("the route refused with %v, want one wrapping ErrCredentialRefused", err) @@ -2188,10 +1973,13 @@ func TestAnExecutionFreeCredentialIsRefusedEverywhere(t *testing.T) { // that does seal an execution. Without it the 503 above is satisfied // by a route that answers 503 to everything. sound := newHostMint(t, &hostMint{}) - healthy := New(Manifest{ID: testSolutionID}). + healthy := declaredWorkServer(t). Credential(mintClientFor(t, sound, tokenFile)) - healthy.cfg = config{gatewayURL: gw.URL} - controlRoute := healthy.wrapRequest(func(*http.Request, *Gateway) (any, error) { + healthy.cfg.gatewayURL = gw.URL + controlRoute := healthy.wrapRequest(func(r *http.Request, g *Gateway) (any, error) { + if _, err := g.ForModule(r.Context(), "things", Scope{ResourceKind: "things", Actions: []string{"read"}}); err != nil { + return nil, err + } return map[string]string{"ok": "yes"}, nil }) control := httptest.NewRecorder() @@ -2440,19 +2228,19 @@ func TestTheCredentialBackoffGrowsAndBreaksPhase(t *testing.T) { // leave the next failure starting from the ceiling. func TestASuccessfulAcquisitionResetsTheBackoff(t *testing.T) { server := New(Manifest{ID: testSolutionID}) - server.credentialAskFailures = 7 mint := newHostMint(t, &hostMint{}) tokenFile := filepath.Join(t.TempDir(), "token") writeFile(t, tokenFile, "projected") server.credential = mintClientFor(t, mint, tokenFile) + scopeController(t, server).credentialAskFailures = 7 - if _, err := server.acquireCredential(context.Background()); err != nil { + if _, err := server.acquireForInstallation(context.Background(), testOrganization, testInstallation); err != nil { t.Fatalf("the acquisition failed, so this says nothing about the reset: %v", err) } - if got := server.credentialAskFailures; got != 0 { + if got := scopeController(t, server).credentialAskFailures; got != 0 { t.Errorf("after a successful acquisition the failure count is %d, want 0: an issuer that recovers and fails again would start from the ceiling rather than from a second", got) } - if !server.credentialQuietUntil.IsZero() { - t.Errorf("a successful acquisition left a quiet period until %s, so the next ask is held off for no reason", server.credentialQuietUntil) + if !scopeController(t, server).credentialQuietUntil.IsZero() { + t.Errorf("a successful acquisition left a quiet period until %s, so the next ask is held off for no reason", scopeController(t, server).credentialQuietUntil) } } diff --git a/go.mod b/go.mod index 81aa21e..c862b60 100644 --- a/go.mod +++ b/go.mod @@ -5,11 +5,12 @@ go 1.27.0 require ( connectrpc.com/connect v1.21.0 github.com/codefly-dev/core v0.19.5-0.20261013191419-d82010241d74 + github.com/codefly-dev/saas-sdk-go v0.1.3-0.20261014043138-b7169d3c4095 github.com/codefly-dev/sdk-go v0.3.2 github.com/codefly-dev/sdk-go/workcontext v0.1.1-0.20261013172033-aabfb4cf5359 github.com/google/uuid v1.6.0 github.com/modelcontextprotocol/go-sdk v1.8.0 - golang.org/x/tools v0.51.0 + golang.org/x/tools v0.52.0 google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc google.golang.org/protobuf v1.36.12 gopkg.in/yaml.v3 v3.0.1 @@ -55,13 +56,13 @@ require ( github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba // indirect - golang.org/x/mod v0.41.0 // indirect - golang.org/x/net v0.60.0 // indirect + golang.org/x/mod v0.42.0 // indirect + golang.org/x/net v0.61.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect - golang.org/x/sync v0.23.0 // indirect + golang.org/x/sync v0.24.0 // indirect golang.org/x/sys v0.49.0 // indirect - golang.org/x/term v0.46.0 // indirect - golang.org/x/text v0.42.0 // indirect + golang.org/x/term v0.47.0 // indirect + golang.org/x/text v0.43.0 // indirect golang.org/x/time v0.15.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc // indirect google.golang.org/grpc v1.84.0 // indirect diff --git a/go.sum b/go.sum index ff2a160..1dec4aa 100644 --- a/go.sum +++ b/go.sum @@ -34,6 +34,8 @@ github.com/codefly-dev/core v0.19.5-0.20261013191419-d82010241d74 h1:tUEPZ1SJlpR github.com/codefly-dev/core v0.19.5-0.20261013191419-d82010241d74/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/saas-sdk-go v0.1.3-0.20261014043138-b7169d3c4095 h1:jkCbK8/r8vWAPajw5wrsCM378Cbps7kWwL0QTDJFHzA= +github.com/codefly-dev/saas-sdk-go v0.1.3-0.20261014043138-b7169d3c4095/go.mod h1:GpQGVcV6jh88PLcheey+QuxlTzYEMB3aRosQGhMpESg= github.com/codefly-dev/sdk-go v0.3.2 h1:H1Tstn5f2HWO/Hp813RFxxDPAlWZEIN6gKtxpzai1CY= github.com/codefly-dev/sdk-go v0.3.2/go.mod h1:B7ULHzZo29FCvE8pfCa90aPVUlL2PlHNUXrIBXuKQ4g= github.com/codefly-dev/sdk-go/workcontext v0.1.1-0.20261013172033-aabfb4cf5359 h1:T+xMCxRDnTSV6jjMTqDCVORLbpHoIYdL9IR3O6330oo= @@ -213,24 +215,24 @@ go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= 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/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= -golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +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.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw= +golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= -golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= -golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= +golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= -golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= -golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= -golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= -golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= +golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM= +golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY= +golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= +golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= -golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI= -golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA= +golang.org/x/tools v0.52.0 h1:ryxg4U63oaUYk7uOLTF37n43f4qgtGvqV4Ioit1BaEs= +golang.org/x/tools v0.52.0/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc h1:rZHRz0ogq4Pid1IKtA/ivHi8XONVF5tqUQXA2TtgpkE= diff --git a/installation.go b/installation.go index 6dbf04f..d1997af 100644 --- a/installation.go +++ b/installation.go @@ -91,19 +91,6 @@ func (s *Server) handleInstallation(w http.ResponseWriter, r *http.Request) { http.Error(w, "invalid installation", http.StatusBadRequest) return } - // An uninstalled solution holds no execution credential and needs none - // here: the step acts under the capability the host carried in, and the - // host issues this execution's credential only once the step has been - // acknowledged. Refusing the step for want of that credential is a - // solution nobody can ever install. Every other state is held to what a - // viewer's call is held to. - uninstalled := s.uninstalled() - if !uninstalled { - if err := s.actingForAViewer(r.Context()); err != nil { - installationOutcome{status: http.StatusServiceUnavailable}.write(w) - return - } - } // The supplied capability is the step's bearer. No viewer session is // involved, and no viewer capability is minted for an install step. gw := s.gatewayFor(nil) @@ -112,11 +99,7 @@ func (s *Server) handleInstallation(w http.ResponseWriter, r *http.Request) { ctx := context.WithValue(r.Context(), mcpViewerKey{}, gw) outcome := s.installOnce(ctx, installation) outcome.write(w) - if outcome.status == http.StatusNoContent { - // After the answer is written: the host activates the installation - // when it reads it, and only then is there a credential to hold. - s.installAcknowledged() - } + } // decodeInstallation bounds the entire body, including whitespace after the diff --git a/mcp.go b/mcp.go index 720a3b5..2af1020 100644 --- a/mcp.go +++ b/mcp.go @@ -12,7 +12,6 @@ import ( codefly "github.com/codefly-dev/sdk-go" "github.com/modelcontextprotocol/go-sdk/auth" - "github.com/modelcontextprotocol/go-sdk/jsonrpc" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/modelcontextprotocol/go-sdk/oauthex" ) @@ -286,46 +285,6 @@ func ViewerFromContext(ctx context.Context) (*Gateway, error) { return gw, nil } -// actsForNobody reports whether an MCP method runs nothing on a viewer's -// behalf: the handshake, the keepalive, and the listings that describe what -// this solution declares. -// -// Everything else is held to this execution's credential. The list is what a -// method must be ON to skip that check, so a method nobody here has heard of is -// checked. -func actsForNobody(method string) bool { - switch method { - // The handshake, the keepalive and the listings that describe what this - // solution declares. - case "initialize", "ping", - "tools/list", "prompts/list", "resources/list", "resources/templates/list", - // The notifications the SDK itself sends, named one by one. - // - // This was strings.HasPrefix(method, "notifications/"), which is a - // DENYLIST wearing an allowlist's clothes: the namespace is open, the - // SDK's extension API lets author code register methods under it, and - // one registered there would have run with a viewer's gateway and no - // credential check — the round-nine defect, still reachable. An exact - // list cannot be widened by someone else's naming. - "notifications/initialized", "notifications/cancelled", - "notifications/progress", "notifications/roots/list_changed": - return true - } - return false -} - -// mcpCredentialUnavailableCode is what an MCP client is told when this process -// will not act for a viewer: -32001, in JSON-RPC's implementation-defined -// server range, beside the SDK's own CodeResourceNotFound (-32002). -// -// I claimed this could not be set because the SDK's wire-error type was -// internal. That was wrong, and wrong because I grepped for the type and -// stopped: package jsonrpc exports it as an alias -// (jsonrpc.Error = jsonrpc2.WireError) precisely so a server can set a code. A -// client that only gets a generic protocol error cannot tell "this solution -// cannot act right now" from a broken tool, so it cannot back off. -const mcpCredentialUnavailableCode = -32001 - // mcpViewer binds each incoming MCP request to the viewer it arrived as, so a // tool handler reads composed modules on their behalf and not as the solution. // @@ -360,35 +319,7 @@ func (s *Server) mcpViewer(next mcp.MethodHandler) mcp.MethodHandler { // middleware, so the runtime's own errors are sanitized before the // author ever holds one. See Gateway.surfaced. gw.sanitizeErrors = true - // The credential gate, before anything author-written runs. - // - // This is R4-3 on the MCP path, and it arrived with the surface rather - // than being missed in it: a plain handler and a ViewerBearer - // passthrough are both held here, and a tool call was not. A tool - // receives a gateway carrying the viewer's bearer, so running one while - // the issuer has withdrawn this execution's credential is acting for a - // viewer without authority — and a tool that mints nothing still sends - // that bearer wherever it dials. - // - // An ALLOWLIST of the methods that act for nobody, not a denylist of - // the ones that act. A denylist over a method set this package does not - // own is the mistake the TLS posture made three times: whatever it does - // not name, the far side keeps. A method the SDK adds tomorrow is gated - // here by default, and the cost of that being wrong is a refusal rather - // than an ungated viewer action. - if !actsForNobody(method) { - if err := s.actingForAViewer(ctx); err != nil { - // The sanitized sentence, not the error: it wraps whatever the - // source said — a mint URL, an issuer's own text, a dial - // failure naming an internal address — and an MCP client is as - // much a viewer's channel as a browser is. - log.Printf("solution %q: refusing an MCP %s for a viewer: %v", s.manifest.ID, method, err) - return nil, &jsonrpc.Error{ - Code: mcpCredentialUnavailableCode, - Message: credentialRefusalForAViewer(err), - } - } - } + result, err := next(context.WithValue(ctx, mcpViewerKey{}, gw), method, req) // An error RESULT, which is not an error at all as far as this // middleware is concerned. @@ -518,29 +449,6 @@ func (s *Server) requireStampedViewer(next http.Handler) http.Handler { stamped, sessionHeader, detail), http.StatusForbidden) return } - // And this execution's own credential, at the HTTP boundary. - // - // The method-layer gate in mcpViewer refuses the same thing, and both - // are here on purpose. This one answers a real HTTP 503, which - // JSON-RPC has no equivalent for at all, and it holds anything that - // reaches this handler without going through the method middleware. - // - // The method layer is not codeless: it carries -32001 through - // jsonrpc.Error. This comment said the SDK's wire-error type was - // internal and a code could not be set, which was my own false claim — - // I grepped for the type, found internal/jsonrpc2, and stopped looking - // for the exported alias. Corrected in the code one commit and left - // standing here. - if err := s.actingForAViewer(r.Context()); err != nil { - // The sanitized sentence, never the source's error: it wraps - // whatever the issuer said, which is a mint URL and sometimes a - // dial failure naming an internal address. An agent client is a - // viewer's channel like a browser is, and this was the disclosure - // executed round eight reproduced. - log.Printf("solution %q: refusing an MCP request for a viewer: %v", s.manifest.ID, err) - http.Error(w, credentialRefusalForAViewer(err), http.StatusServiceUnavailable) - return - } next.ServeHTTP(w, r) }) } diff --git a/mcp_test.go b/mcp_test.go index 4b83e39..da956d6 100644 --- a/mcp_test.go +++ b/mcp_test.go @@ -19,7 +19,6 @@ import ( "github.com/codefly-dev/core/resources" codefly "github.com/codefly-dev/sdk-go" "github.com/codefly-dev/sdk-go/workcontext" - "github.com/modelcontextprotocol/go-sdk/jsonrpc" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -827,7 +826,6 @@ func TestAnMCPToolCallIsHeldToThePublishedCeiling(t *testing.T) { audience atomic.Bool acquirer atomic.Bool reporting atomic.Bool - terminal atomic.Bool ) register := func(srv *mcp.Server) { mcp.AddTool(srv, &mcp.Tool{Name: "inspect", Description: "reports its own viewer"}, @@ -842,7 +840,6 @@ func TestAnMCPToolCallIsHeldToThePublishedCeiling(t *testing.T) { audience.Store(declared) acquirer.Store(gw.workload != nil) reporting.Store(gw.report != nil) - terminal.Store(gw.terminal != nil) return nil, askOutput{Status: 200}, nil }) } @@ -891,9 +888,6 @@ func TestAnMCPToolCallIsHeldToThePublishedCeiling(t *testing.T) { if !reporting.Load() { t.Error("the gateway has no attestation report, so a refused mint is reported by nothing") } - if !terminal.Load() { - t.Error("the gateway has no terminal hook, so a refusal that should end the process does not") - } } // --- the composition's half: a derived identifier and one declared value --- @@ -1164,131 +1158,51 @@ func TestDeployedMCPRefusalsNameTheConfigurationThroughTheSDK(t *testing.T) { // connects and lists tools it can never call is worse served than one told to // back off. The method-layer gate stays as well, for anything reaching the // method middleware another way. -func TestAnMCPToolDoesNotRunWhileThisExecutionHoldsNoCredential(t *testing.T) { - for _, tc := range []struct { - name string - err error - }{ - {"a terminal refusal", workcontext.ErrMintRefused}, - {"a transient unavailability", workcontext.ErrMintUnavailable}, - } { - t.Run(tc.name, func(t *testing.T) { - var ran atomic.Bool +func TestMCPModuleWorkRequiresScopedCredential(t *testing.T) { + for _, failure := range []error{workcontext.ErrMintRefused, workcontext.ErrMintUnavailable} { + t.Run(failure.Error(), func(t *testing.T) { + var calls atomic.Int32 + source := credentialSourceFunc(func(context.Context) (workcontext.Credential, error) { + calls.Add(1) + return workcontext.Credential{}, fmt.Errorf("broker-reflected-credential: %w", failure) + }) + reached := false register := func(srv *mcp.Server) { - mcp.AddTool(srv, &mcp.Tool{Name: "act", Description: "must not run"}, - func(ctx context.Context, _ *mcp.CallToolRequest, _ askInput) (*mcp.CallToolResult, askOutput, error) { - ran.Store(true) - return nil, askOutput{Status: 200}, nil - }) + mcp.AddTool(srv, &mcp.Tool{Name: "act", Description: "Read declared module"}, func(ctx context.Context, _ *mcp.CallToolRequest, _ askInput) (*mcp.CallToolResult, askOutput, error) { + gw, err := ViewerFromContext(ctx) + if err != nil { + return nil, askOutput{}, err + } + if _, err := gw.ForModule(ctx, "things", Scope{ResourceKind: "things", Actions: []string{"read"}}); err != nil { + return nil, askOutput{}, err + } + reached = true + return nil, askOutput{Status: 200}, nil + }) } - server := New(Manifest{ID: mcpServerName, Frontend: []byte(wikiFrontend)}). - ServeMCP(mcpServerName, "v1.2.3", register). - Credential(refusingSource{err: fmt.Errorf("%w: the issuer said so, at https://mint.internal.example/modules/_work-context (dial tcp 10.4.1.9:8443: connection refused)", tc.err)}) - + server := declaredWorkServer(t).ServeMCP(mcpServerName, "v1.2.3", register).Credential(source) handler, err := server.mcpHandler(MCPEnvironment{IssuerURL: testIssuer}) - if err != nil { - t.Fatalf("mount MCP: %v", err) - } - host := httptest.NewServer(handler) - t.Cleanup(host.Close) - - // A tools/call the way a client sends it, by hand: the SDK's - // client cannot complete its handshake against a surface that is - // refusing, which is the point. - body := `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"act","arguments":{"question":"anything"}}}` - req, err := http.NewRequest(http.MethodPost, host.URL+MCPPath, strings.NewReader(body)) if err != nil { t.Fatal(err) } - req.Header.Set("content-type", "application/json") - stamp := viewerStamp() - req.Header.Set("authorization", stamp.bearer) - req.Header.Set(userHeader, stamp.userID) - req.Header.Set(orgHeader, stamp.orgID) - req.Header.Set(sessionHeader, stamp.sessionID) - req.Header.Set(workcontext.InstallationIDHeaderName, testInstallation) - resp, err := http.DefaultClient.Do(req) - if err != nil { - t.Fatalf("post a tool call: %v", err) - } - defer drainAndClose(resp) - - if resp.StatusCode != http.StatusServiceUnavailable { - t.Errorf("a tool call answered %d while this execution held no usable credential, want 503: the viewer's bearer would otherwise reach author code under authority this process does not have", resp.StatusCode) - } - if ran.Load() { - t.Error("the tool ran: the gate has to refuse before anything author-written executes") + host := httptest.NewServer(handler) + defer host.Close() + session := connectMCP(t, host, viewerStamp()) + if calls.Load() != 0 { + t.Fatal("MCP handshake minted") } - said, err := io.ReadAll(resp.Body) + result, err := session.CallTool(t.Context(), &mcp.CallToolParams{Name: "act", Arguments: map[string]any{"question": "anything"}}) if err != nil { t.Fatal(err) } - for _, leak := range []string{"mint.internal.example", "_credential", "10.4.1.9", "connection refused"} { - if strings.Contains(string(said), leak) { - t.Errorf("the refusal discloses %q to an MCP client: %s", leak, said) - } + if !result.IsError || reached || calls.Load() != 1 { + t.Fatal("unattested module work escaped") + } + if strings.Contains(toolErrorText(result), "broker-reflected-credential") { + t.Fatal("source error leaked") } }) } - - t.Run("the refusal is 503 and not 404", func(t *testing.T) { - // A withdrawn credential must not read as "this solution serves no - // MCP": one is a back-off, the other sends a client looking for an - // endpoint that does not exist. - server := New(Manifest{ID: mcpServerName, Frontend: []byte(wikiFrontend)}). - ServeMCP(mcpServerName, "v1.2.3", readCollectionTool). - Credential(refusingSource{err: workcontext.ErrMintRefused}) - handler, err := server.mcpHandler(MCPEnvironment{IssuerURL: testIssuer}) - if err != nil { - t.Fatalf("mount MCP: %v", err) - } - host := httptest.NewServer(handler) - t.Cleanup(host.Close) - - req, err := http.NewRequest(http.MethodPost, host.URL+MCPPath, strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`)) - if err != nil { - t.Fatal(err) - } - req.Header.Set("content-type", "application/json") - stamp := viewerStamp() - req.Header.Set("authorization", stamp.bearer) - req.Header.Set(orgHeader, stamp.orgID) - req.Header.Set(sessionHeader, stamp.sessionID) - req.Header.Set(workcontext.InstallationIDHeaderName, testInstallation) - resp, err := http.DefaultClient.Do(req) - if err != nil { - t.Fatal(err) - } - defer drainAndClose(resp) - if resp.StatusCode == http.StatusNotFound { - t.Error("a withdrawn credential answered 404: an MCP client cannot tell that from a solution that serves no MCP at all") - } - if resp.StatusCode != http.StatusServiceUnavailable { - t.Errorf("status = %d, want 503", resp.StatusCode) - } - }) - - t.Run("every method that is not allowlisted is gated at the method layer too", func(t *testing.T) { - // The method-layer gate's property, not an enumeration: a method this - // file has never heard of is held to the credential. A denylist over - // the SDK's method set is the mistake the TLS posture made three times. - for _, method := range []string{"tools/call", "resources/read", "prompts/get", "completion/complete", "some/method-added-next-release", - // The namespace that used to be exempted by prefix. It is open — - // the SDK's extension API lets author code register under it — so - // a method nobody here named must be gated, not waved through for - // looking like a notification. - "notifications/anything-an-author-registers", "notifications/tools/call"} { - if actsForNobody(method) { - t.Errorf("%q is treated as acting for nobody, so a viewer action on it would skip the credential gate", method) - } - } - for _, method := range []string{"initialize", "ping", "tools/list", "prompts/list", "resources/list", - "notifications/initialized", "notifications/cancelled", "notifications/progress", "notifications/roots/list_changed"} { - if !actsForNobody(method) { - t.Errorf("%q is gated at the method layer though it runs nothing for a viewer", method) - } - } - }) } // TestAToolsOwnFailureIsSanitizedOnTheWayOut is the other half of R8-1, and @@ -1372,54 +1286,23 @@ func TestAToolsOwnFailureIsSanitizedOnTheWayOut(t *testing.T) { // It is not redundant. It is what holds a method dispatched without passing // through that handler — and it is the layer that knows the method name, so it // is where the allowlist lives. -func TestTheMethodLayerGateRefusesOnItsOwn(t *testing.T) { - server := New(Manifest{ID: mcpServerName, Frontend: []byte(wikiFrontend)}). - ServeMCP(mcpServerName, "v1.2.3", readCollectionTool). - Credential(refusingSource{err: fmt.Errorf("%w: at https://mint.internal.example/modules/_work-context", workcontext.ErrMintUnavailable)}) - - var reached atomic.Bool - next := func(context.Context, string, mcp.Request) (mcp.Result, error) { - reached.Store(true) - return nil, nil - } - gated := server.mcpViewer(next) - - t.Run("a method that acts for a viewer is refused", func(t *testing.T) { - reached.Store(false) - _, err := gated(context.Background(), "tools/call", &mcp.CallToolRequest{}) - if err == nil { - t.Fatal("the method middleware admitted a tool call while this execution held no usable credential: it is what holds a method dispatched without the HTTP handler in front of it") - } - if reached.Load() { - t.Error("the call reached the next handler, so nothing was gated") - } - for _, leak := range []string{"mint.internal.example", "_credential"} { - if strings.Contains(err.Error(), leak) { - t.Errorf("the refusal discloses %q: %s", leak, err) - } - } - // And it carries the CODE, which is the whole point of item 8: a - // client that gets a generic protocol error cannot tell "this solution - // cannot act right now" from a broken tool, so it cannot back off. - // The mutation that drops the typed error survived until this. - var wire *jsonrpc.Error - if !errors.As(err, &wire) { - t.Fatalf("the refusal is %T, not a *jsonrpc.Error: without a code an MCP client cannot distinguish this from any other server error", err) - } - if wire.Code != mcpCredentialUnavailableCode { - t.Errorf("the refusal carries code %d, want %d", wire.Code, mcpCredentialUnavailableCode) - } - }) - - t.Run("a method that acts for nobody passes", func(t *testing.T) { - reached.Store(false) - if _, err := gated(context.Background(), "tools/list", &mcp.ListToolsRequest{}); err != nil { - t.Errorf("the method middleware refused tools/list, which runs nothing for a viewer: %v", err) - } - if !reached.Load() { - t.Error("tools/list never reached the next handler") +func TestMCPMethodEntryNeverAcquires(t *testing.T) { + var calls atomic.Int32 + server := New(Manifest{ID: mcpServerName}).Credential(countingIdleSource{calls: &calls}) + reached := 0 + next := func(ctx context.Context, _ string, _ mcp.Request) (mcp.Result, error) { + reached++ + _, err := ViewerFromContext(ctx) + return nil, err + } + for _, method := range []string{"tools/call", "tools/list", "initialize", "notifications/custom"} { + if _, err := server.mcpViewer(next)(t.Context(), method, &mcp.CallToolRequest{}); err != nil { + t.Fatal(err) } - }) + } + if calls.Load() != 0 || reached != 4 { + t.Fatalf("methods=%d mints=%d", reached, calls.Load()) + } } // TestNoMCPRefusalEverPrintsASecret is item 4, as a property rather than an diff --git a/mint_errors.go b/mint_errors.go index e961e92..b5281d8 100644 --- a/mint_errors.go +++ b/mint_errors.go @@ -82,10 +82,8 @@ const ( // platform has not projected it yet; nothing is sent, and the ask is // retried. brokerProjectionAbsent - // brokerAwaitingInstallation: the host admits this workload and has not - // been asked to install it anywhere yet. The install step arrives on this - // runtime's own listener; until then there is nothing to mint for. - brokerAwaitingInstallation + // brokerPrecondition: a mutable installation or policy prerequisite is unmet. + brokerPrecondition ) type brokerTransportCategory uint8 @@ -110,8 +108,8 @@ func (e *brokerMintError) Error() string { return fmt.Sprintf("module broker: HTTP %d: request refused", e.status) case brokerNotEstablished: return "module broker: HTTP 503: execution not established" - case brokerAwaitingInstallation: - return "module broker: HTTP 412: no organization has installed this solution yet" + case brokerPrecondition: + return "module broker: HTTP 412: a required precondition is not met" case brokerOtherStatus: return fmt.Sprintf("module broker: HTTP %d: request failed", e.status) case brokerInvalidAnswer: @@ -144,7 +142,7 @@ func (e *brokerMintError) Error() string { // is there and unusable — empty, oversized, not a file — is a different thing // and stays terminal: no amount of waiting repairs it. func (e *brokerMintError) retryable() bool { - return e.class == brokerNotEstablished || e.class == brokerAwaitingInstallation || e.class == brokerProjectionAbsent || + return e.class == brokerNotEstablished || e.class == brokerPrecondition || e.class == brokerProjectionAbsent || e.class == brokerTransport && (e.transport == brokerTransportTimeout || e.transport == brokerTransportRefused || e.transport == brokerTransportReset) } @@ -165,7 +163,7 @@ func brokerStatusError(status int) *brokerMintError { case http.StatusServiceUnavailable: e.class = brokerNotEstablished case http.StatusPreconditionFailed: - e.class = brokerAwaitingInstallation + e.class = brokerPrecondition } return e } diff --git a/mint_migration_test.go b/mint_migration_test.go index 4ee81d4..c4e047d 100644 --- a/mint_migration_test.go +++ b/mint_migration_test.go @@ -7,6 +7,7 @@ import ( "io" "log" "net/http" + "net/http/httptest" "os" "path/filepath" "strings" @@ -69,7 +70,9 @@ func productionMintSource(t *testing.T, mint *hostMint, tokenFile string) *broke } s.principal = testPrincipal s.authority = authorityFor(t, context.Background()) - source, err := s.platformCredentialSource() + options := s.mintOptions("accounts") + options.OrganizationID = testOrganization + source, err := newBrokerCredentialSource(options) if err != nil { t.Fatal(err) } @@ -146,7 +149,9 @@ func TestCredentialRefusalNeverLogsDiagnosticsOrRetries(t *testing.T) { source := &reflectedCredentialSource{} server := New(Manifest{ID: testSolutionID}).Credential(source) for range 3 { - err := server.actingForAViewer(context.Background()) + err := attestWorkloadReporting(t.Context(), func(ctx context.Context) (executionCredential, error) { + return server.acquireForInstallation(ctx, testOrganization, testInstallation) + }, &attestationReport{}, httptest.NewRequest("POST", "http://gateway.example", nil), testSolutionID) if !errors.Is(err, ErrCredentialRefused) || strings.Contains(err.Error(), "broker-reflected-credential") { t.Fatalf("credential refusal lost its class or exposed diagnostics: %v", err) } diff --git a/passthrough.go b/passthrough.go index d6c52fc..2fe3771 100644 --- a/passthrough.go +++ b/passthrough.go @@ -5,7 +5,6 @@ import ( "encoding/json" "errors" "fmt" - "log" "net/http" "os" "path/filepath" @@ -483,22 +482,6 @@ func (s *Server) forward(ctx context.Context, route passthroughRoute, req *conne // this method (or the bearer alone, for a ViewerBearer module), and the // declared pin merged into the request. func (s *Server) authorize(ctx context.Context, route passthroughRoute, header http.Header, msg *dynamicpb.Message) (*Gateway, error) { - // Whether or not this route mints anything. A ViewerBearer route forwards - // the viewer's bearer and asks the credential for nothing, so it was the - // one route that never consulted it — and it kept forwarding that bearer - // to the gateway after the issuer refused this build. See - // actingForAViewer: the credential is what authorises this process to act - // for a viewer, not merely what it mints with. - if err := s.actingForAViewer(ctx); err != nil { - // Sanitized, for the reason wrapRequest records: the error wraps what - // the source said, and this branch handed it to the page verbatim. - code := connect.CodeUnavailable - if errors.Is(err, ErrCredentialRefused) { - code = connect.CodeFailedPrecondition - } - log.Printf("solution: refusing to act for a viewer on %s: %v", route.module.As, err) - return nil, connect.NewError(code, errors.New(credentialRefusalForAViewer(err))) - } gw := s.gatewayFor(header) if !route.module.ViewerBearer { acting, err := gw.ForModule(ctx, route.module.As, route.scopes()...) diff --git a/passthrough_test.go b/passthrough_test.go index f457a1b..617366a 100644 --- a/passthrough_test.go +++ b/passthrough_test.go @@ -3,7 +3,6 @@ package solution import ( "context" "encoding/json" - "fmt" "io" "net/http" "net/http/httptest" @@ -11,6 +10,7 @@ import ( "path/filepath" "strings" "sync" + "sync/atomic" "testing" "time" @@ -126,7 +126,7 @@ func call(t *testing.T, s *Server, path, bearer, body string) (int, map[string]a if bearer != "" { req.Header.Set("authorization", bearer) } - req.Header.Set(orgHeader, "org-1") + req.Header.Set(orgHeader, testOrganization) req.Header.Set(sessionHeader, "session-1") req.Header.Set(workcontext.InstallationIDHeaderName, testInstallation) rec := httptest.NewRecorder() @@ -192,7 +192,7 @@ func TestPassthroughAnswersADeclaredMethodAsTheViewer(t *testing.T) { if gw.calls[0].Header.Get("authorization") != viewerBearer() { t.Fatal("the viewer's bearer was not forwarded") } - if len(gw.mints) != 1 || gw.mints[0].Audience != "things" || gw.mints[0].OrgID != "org-1" || gw.mints[0].SessionID != "session-1" || + if len(gw.mints) != 1 || gw.mints[0].Audience != "things" || gw.mints[0].OrgID != testOrganization || gw.mints[0].SessionID != "session-1" || len(gw.mints[0].AuthorityScopes) != 1 || gw.mints[0].AuthorityScopes[0].ResourceKind != "things" { t.Fatalf("mints = %+v, want one for the declared audience and scope, in the viewer's org and session", gw.mints) } @@ -426,69 +426,14 @@ func TestInterfaceArtifactCarriesTheManifestVersionAndBothSurfaces(t *testing.T) // The credential is what authorises this process to act for a viewer, not // merely what it mints with. A route that forwards a viewer's bearer under a // withdrawn credential is acting without authority, whatever it does not mint. -func TestAViewerBearerRouteRefusesToActWithoutACredential(t *testing.T) { - gw := newModuleGateway(t, http.StatusOK, `{"entry_id":"e1"}`) - bearerOnly := ConsumedModule{ - As: "pages", - ViewerBearer: true, - Methods: []ConsumedMethod{{Name: "/things.v1.Things/Search", Response: MustFieldMask(thingMessage("Thing"), "entry_id", "big")}}, - } - build := func(t *testing.T, source CredentialSource) (*Server, passthroughRoute) { - t.Helper() - server := New(Manifest{ID: testSolutionID}).Consumes(bearerOnly) - if source != nil { - server.Credential(source) - } - server.cfg.profile = localProfile - server.cfg.gatewayURL = gw.URL - route := passthroughRoute{module: bearerOnly, method: bearerOnly.Methods[0]} - return server, route +func TestViewerBearerRouteDoesNotAcquireModuleAuthority(t *testing.T) { + var calls atomic.Int32 + server := New(Manifest{ID: testSolutionID}).Credential(countingIdleSource{calls: &calls}) + route := passthroughRoute{module: ConsumedModule{As: "pages", ViewerBearer: true}} + if _, err := server.authorize(t.Context(), route, workHeaders(), nil); err != nil { + t.Fatal(err) + } + if calls.Load() != 0 { + t.Fatal("viewer-only route minted module authority") } - - header := http.Header{} - header.Set("authorization", viewerBearer()) - header.Set(orgHeader, "org-1") - header.Set(sessionHeader, "session-1") - header.Set(workcontext.InstallationIDHeaderName, testInstallation) - - t.Run("a refused build", func(t *testing.T) { - server, route := build(t, failingCredentialSource{ - err: fmt.Errorf("%w: this build is not the one the presence document approved", workcontext.ErrMintRefused), - }) - if _, err := server.authorize(context.Background(), route, header, nil); err == nil { - t.Fatal("a ViewerBearer route acted for a viewer while this execution's credential was refused: it forwards that viewer's bearer to the gateway under an authority the issuer has withdrawn") - } - }) - - t.Run("an issuer that cannot answer", func(t *testing.T) { - server, route := build(t, failingCredentialSource{ - err: fmt.Errorf("%w: the issuer cannot reach its own dependencies", workcontext.ErrMintUnavailable), - }) - if _, err := server.authorize(context.Background(), route, header, nil); err == nil { - t.Fatal("a ViewerBearer route acted for a viewer while this execution's credential could not be obtained") - } - }) - - // The survive-control: with a credential the issuer approves, the same - // route authorises normally. Without this, a route refusing everything - // passes both cases above. - t.Run("an approved credential", func(t *testing.T) { - tokenFile := filepath.Join(t.TempDir(), "token") - writeFile(t, tokenFile, "projected") - mint := newHostMint(t, &hostMint{}) - server, route := build(t, mintClientFor(t, mint, tokenFile)) - if _, err := server.authorize(context.Background(), route, header, nil); err != nil { - t.Fatalf("a ViewerBearer route was refused while this process holds an approved credential: %v", err) - } - }) - - // And a server with no credential source at all still serves: a solution - // that mints nothing and declares nothing to mint with is a valid shape, - // and the ask is only about a source that exists and is failing. - t.Run("no credential source declared", func(t *testing.T) { - server, route := build(t, nil) - if _, err := server.authorize(context.Background(), route, header, nil); err != nil { - t.Fatalf("a ViewerBearer route was refused on a solution that declares no credential source: %v", err) - } - }) } diff --git a/passthroughtest/passthroughtest.go b/passthroughtest/passthroughtest.go index 3dc90e6..2575316 100644 --- a/passthroughtest/passthroughtest.go +++ b/passthroughtest/passthroughtest.go @@ -84,7 +84,7 @@ var DefaultViewer = SealedViewer("passthroughtest-viewer") func SealedViewer(seed string) Viewer { return Viewer{ Bearer: "Bearer " + capability(seed), - OrgID: "passthroughtest-org", + OrgID: corework.FixtureOrganization, SessionID: "passthroughtest-session", // The installation the gateway stamps beside the org and the session. // Every mint the runtime runs names one and refuses without it. diff --git a/redirect_test.go b/redirect_test.go index d3474bb..469659c 100644 --- a/redirect_test.go +++ b/redirect_test.go @@ -87,7 +87,7 @@ func TestACredentialBearingRequestIsNeverRedirected(t *testing.T) { } if r.URL.Path == workContextStartTaskProcedure { writeJSON(w, http.StatusOK, map[string]any{ - "token": capability("context-things.1"), "orgId": "org-1", + "token": capability("context-things.1"), "orgId": testOrganization, "expiresAt": "2099-01-01T00:00:00Z", }) return @@ -104,7 +104,7 @@ func TestACredentialBearingRequestIsNeverRedirected(t *testing.T) { header := http.Header{} header.Set("authorization", viewerBearer()) - header.Set(orgHeader, "org-1") + header.Set(orgHeader, testOrganization) header.Set(sessionHeader, "session-1") header.Set(workcontext.InstallationIDHeaderName, testInstallation) @@ -148,7 +148,7 @@ func TestEveryGatewayClientRefusesAForeignDestination(t *testing.T) { server.cfg.gatewayURL = gw.URL header := http.Header{} header.Set("authorization", viewerBearer()) - header.Set(orgHeader, "org-1") + header.Set(orgHeader, testOrganization) header.Set(sessionHeader, "session-1") header.Set(workcontext.InstallationIDHeaderName, testInstallation) gateway := server.gatewayFor(header) @@ -214,7 +214,7 @@ func TestASameOriginRedirectIsNotFollowedEither(t *testing.T) { return } writeJSON(w, http.StatusOK, map[string]any{ - "token": capability("context-things.1"), "orgId": "org-1", + "token": capability("context-things.1"), "orgId": testOrganization, "expiresAt": "2099-01-01T00:00:00Z", }) })) @@ -228,7 +228,7 @@ func TestASameOriginRedirectIsNotFollowedEither(t *testing.T) { header := http.Header{} header.Set("authorization", viewerBearer()) - header.Set(orgHeader, "org-1") + header.Set(orgHeader, testOrganization) header.Set(sessionHeader, "session-1") header.Set(workcontext.InstallationIDHeaderName, testInstallation) if _, err := server.gatewayFor(header).ForModule(context.Background(), "things", diff --git a/scoped_credential.go b/scoped_credential.go new file mode 100644 index 0000000..e999959 --- /dev/null +++ b/scoped_credential.go @@ -0,0 +1,331 @@ +package solution + +import ( + "context" + "errors" + "fmt" + "sync" + "sync/atomic" + "time" + + corework "github.com/codefly-dev/core/workcontext" + "github.com/codefly-dev/sdk-go/workcontext" +) + +// credentialController bounds and shares acquisition for one installation. +// Its source and refusal state never cross an organisation boundary. +type credentialController struct { + nextCredential credentialAcquirer + terminalErr atomic.Pointer[error] + credentialMu sync.Mutex + credentialAsk *credentialFlight + credentialAskErr error + credentialHeld executionCredential + credentialQuietUntil time.Time + credentialAskFailures int +} + +func (c *credentialController) credentialRefused(err error) { + c.terminalErr.CompareAndSwap(nil, &err) +} + +type credentialScope struct{ organization, installation string } + +type scopedCredential struct { + controller *credentialController + used time.Time + users int +} + +// Bound both retained credentials and outstanding acquisitions. Idle entries +// are evicted only when no acquisition is running; there is no background mint. +const maxCredentialScopes = 256 + +func (s *Server) acquireForInstallation(ctx context.Context, organization, installation string) (executionCredential, error) { + if ctx.Err() != nil { + return nil, fmt.Errorf("%w: request ended", ErrCredentialUnavailable) + } + if organization == "" || installation == "" { + return nil, fmt.Errorf("%w: organisation and installation are required", ErrCredentialRefused) + } + key := credentialScope{organization, installation} + s.scopedMu.Lock() + entry, found := s.scoped[key] + if found && entry.controller.terminalErr.Load() != nil { + // A broker refusal can describe mutable organisation consent, not an + // execution-wide judgment. Retain its backoff, then re-read authority. + entry.controller.credentialMu.Lock() + quiet := time.Now().Before(entry.controller.credentialQuietUntil) + idle := entry.users == 0 && entry.controller.credentialAsk == nil + entry.controller.credentialMu.Unlock() + if !quiet && idle { + delete(s.scoped, key) + found = false + } + } + if !found { + if s.scoped == nil { + s.scoped = make(map[credentialScope]*scopedCredential) + } + if len(s.scoped) >= maxCredentialScopes { + var oldest credentialScope + var used time.Time + for k, candidate := range s.scoped { + candidate.controller.credentialMu.Lock() + idle := candidate.users == 0 && candidate.controller.credentialAsk == nil + candidate.controller.credentialMu.Unlock() + if idle && (used.IsZero() || candidate.used.Before(used)) { + oldest, used = k, candidate.used + } + } + if used.IsZero() { + s.scopedMu.Unlock() + return nil, ErrCredentialUnavailable + } + delete(s.scoped, oldest) + } + source, err := s.installationCredentialSource(key) + if err != nil { + s.scopedMu.Unlock() + return nil, err + } + entry = &scopedCredential{controller: &credentialController{nextCredential: source}} + } + entry.used = time.Now() + entry.users++ + s.scoped[key] = entry + s.scopedMu.Unlock() + defer func() { + s.scopedMu.Lock() + entry.users-- + s.scopedMu.Unlock() + }() + return entry.controller.credentialWithin(ctx, routeCredentialTimeout) +} + +func (s *Server) installationCredentialSource(scope credentialScope) (credentialAcquirer, error) { + var acquire credentialAcquirer + if s.credential != nil { + // The public hook remains usable, but its returned credential must + // belong to this request. A process-wide source cannot grant another + // organisation authority by returning its first installation's token. + source := heldToTheFrozenAuthority(s.credential, s.authority) + acquire = func(ctx context.Context) (executionCredential, error) { return source.Credential(ctx) } + } else { + if s.authority == nil { + return nil, ErrCredentialRefused + } + audience, err := s.authority.Value(AuthorityGroup, AuthorityProjectionAudienceKey) + if err != nil { + return nil, err + } + options := s.mintOptions(audience) + options.OrganizationID = scope.organization + source, err := newBrokerCredentialSource(options) + if err != nil { + return nil, err + } + acquire = source.Credential + } + return func(ctx context.Context) (executionCredential, error) { + credential, err := acquire(ctx) + if err != nil { + return nil, err + } + if err := usableCredential(credential); err != nil { + return nil, err + } + // Core alone reads the capability. This is consistency checking of + // the broker answer, never verification or a replacement for the + // host's live installation/build checks on each operation. + if err := matchCredentialScope(credential, scope); err != nil { + return nil, err + } + seal := credential.Seal() + build := sealedIdentity{digest: seal.GetImageDigest(), incarnation: seal.GetBuildIncarnation()} + s.executionMu.Lock() + defer s.executionMu.Unlock() + if s.execution == (sealedIdentity{}) { + s.execution = build + } + if s.execution != build { + return nil, workcontext.ErrMintRefused + } + return credential, nil + }, nil +} + +// credentialWithin bounds one waiter and permits a held unexpired credential +// during transient failure. A recorded refusal always takes precedence. +func (s *credentialController) credentialWithin(ctx context.Context, d time.Duration) (executionCredential, error) { + // Held and unexpired is the answer whenever the issuer is not answering, + // and this is the branch that was missing. + // + // The timeout path below already served on a held credential, and the + // error path refused outright — so with the credential inside its renewal + // window and the issuer answering 503, every viewer request asked, every + // ask minted, and every request got a 503 while a perfectly valid + // credential sat in hand. Forty requests, forty audited mints. That is not + // a smaller version of the heartbeat this runtime deleted, it is a larger + // one: the heartbeat minted on a timer, this minted on traffic. + // + // So a failed ask starts a backoff, and inside it nothing asks again. The + // renewal the credential's own expiry dictates still happens, because the + // backoff is shorter than the renewal lead; what cannot happen is one mint + // per request. + // The quiet window applies whether or not anything is held, which is the + // half that was missing: it was consulted only when a credential WAS in + // hand, so the one state that should ask least — nothing held, the issuer + // failing — was the state that asked on every single request. The branch + // meant to back off was the branch that hammered. + // A recorded judgement first, on every path. The quiet branch below would + // otherwise re-wrap a terminal refusal as "cannot currently be obtained", + // which is the one distinction the boot and the run must not disagree on. + if err := s.terminalErr.Load(); err != nil { + return workcontext.Credential{}, fmt.Errorf("%w: %w", ErrCredentialRefused, *err) + } + if held, quiet, last := s.credentialState(); quiet { + if held.Token() != "" { + return held, nil + } + if terminalCredentialFailure(last) { + return workcontext.Credential{}, fmt.Errorf("%w: %w", ErrCredentialRefused, last) + } + return workcontext.Credential{}, fmt.Errorf("%w: %w", ErrCredentialUnavailable, last) + } + credential, err := s.askOnce(ctx, d) + if err == nil { + return credential, nil + } + if errors.Is(err, ErrCredentialRefused) { + return credential, err + } + // Unavailable, for any reason including the ask not answering in time. A + // credential already in hand decides, because renewal begins inside a lead + // before expiry and refusing here would 503 every viewer over a slow + // issuer this process does not yet need. + if held, ok := s.heldCredential(); ok { + return held, nil + } + return credential, err +} + +// heldCredential returns only a nonempty, unexpired held credential. +func (s *credentialController) heldCredential() (executionCredential, bool) { + s.credentialMu.Lock() + defer s.credentialMu.Unlock() + if s.credentialHeld == nil || s.credentialHeld.Token() == "" { + return workcontext.Credential{}, false + } + if !time.Now().Before(s.credentialHeld.ExpiresAt()) { + return workcontext.Credential{}, false + } + return s.credentialHeld, true +} + +// credentialState reads held authority, backoff and last failure atomically. +func (s *credentialController) credentialState() (held executionCredential, quiet bool, last error) { + s.credentialMu.Lock() + defer s.credentialMu.Unlock() + held = s.credentialHeld + if held == nil || held.Token() == "" || !time.Now().Before(held.ExpiresAt()) { + held = workcontext.Credential{} + } + last = s.credentialAskErr + if last == nil { + last = fmt.Errorf("the credential source has not answered yet") + } + return held, time.Now().Before(s.credentialQuietUntil), last +} + +// askOnce shares one detached acquisition. Each waiter reads its own immutable +// flight result, never a later flight's result from controller state. +func (s *credentialController) askOnce(ctx context.Context, d time.Duration) (executionCredential, error) { + s.credentialMu.Lock() + inflight := s.credentialAsk + if inflight == nil { + inflight = &credentialFlight{done: make(chan struct{})} + s.credentialAsk = inflight + go s.resolveCredential(ctx, inflight) + } + s.credentialMu.Unlock() + + timer := time.NewTimer(d) + defer timer.Stop() + select { + case <-inflight.done: + case <-ctx.Done(): + return workcontext.Credential{}, fmt.Errorf("%w: request ended", ErrCredentialUnavailable) + case <-timer.C: + return workcontext.Credential{}, fmt.Errorf("%w: it did not answer within %s", ErrCredentialUnavailable, d) + } + + // From the flight this caller actually waited on, not from the server's + // current state. Reading the shared fields let a waiter observe a *later* + // flight's answer — or none at all, if the next flight had already started + // and cleared them — so the credential a caller acted on was not + // necessarily the one its own ask produced. A flight's result is written + // once, before its channel closes, and never again. + credential, err := inflight.credential, inflight.err + if err != nil { + if terminalCredentialFailure(err) { + // Keep this scope refused even when no other caller is waiting. + s.credentialRefused(err) + return workcontext.Credential{}, fmt.Errorf("%w: %w", ErrCredentialRefused, err) + } + return workcontext.Credential{}, fmt.Errorf("%w: %w", ErrCredentialUnavailable, err) + } + return credential, nil +} + +// resolveCredential records the result even when every waiter has left. +// Caller cancellation cannot cancel other callers' shared acquisition. +func (s *credentialController) resolveCredential(ctx context.Context, flight *credentialFlight) { + asked, cancel := context.WithTimeout(context.WithoutCancel(ctx), platformRequestTimeout) + defer cancel() + credential, err := s.nextCredential(asked) + if err == nil { + err = usableCredential(credential) + } + + // Recorded HERE, where the answer lands, and not only where a caller + // happens to be waiting for it. + // + // This ask runs on a detached context so a request that gives up does not + // cancel it — which means its answer can arrive after every caller has + // gone. A terminal refusal classified only by the waiting caller was + // therefore discarded whenever acquisition outlived its caller: the route + // served on the held credential, nothing called credentialRefused, and the + // process went on acting for viewers after its own source had reported + // this execution refused. The classification belongs to the answer, not to + // whoever was still listening. + if err != nil && terminalCredentialFailure(err) { + s.credentialRefused(err) + } + + // The flight's own result first, then the shared state. A waiter reads the + // former and never the latter. + flight.credential, flight.err = credential, err + + s.credentialMu.Lock() + s.credentialAskErr = err + if err != nil { + s.credentialAskFailures++ + s.credentialQuietUntil = time.Now().Add(credentialAskQuiet(s.credentialAskFailures)) + } else { + s.credentialHeld = credential + s.credentialQuietUntil = time.Time{} + s.credentialAskFailures = 0 + } + s.credentialAsk = nil + s.credentialMu.Unlock() + close(flight.done) +} + +func matchCredentialScope(credential executionCredential, scope credentialScope) error { + decoded, err := corework.Decode(credential.Token()) + if err != nil || decoded.Context().GetOrganizationId() != scope.organization || credential.Seal().GetInstallationId() != scope.installation { + return workcontext.ErrMintRefused + } + return nil +} diff --git a/scoped_credential_test.go b/scoped_credential_test.go new file mode 100644 index 0000000..a744f60 --- /dev/null +++ b/scoped_credential_test.go @@ -0,0 +1,327 @@ +package solution + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "path/filepath" + "sync" + "sync/atomic" + "testing" + "testing/synctest" + "time" + + "github.com/codefly-dev/core/solution/manifest" + corework "github.com/codefly-dev/core/workcontext" + "github.com/codefly-dev/sdk-go/workcontext" +) + +func TestSharedCredentialsSelectOrganisationAndInstallation(t *testing.T) { + const orgA = "11111111-1111-4111-8111-111111111111" + const orgB = "22222222-2222-4222-8222-222222222222" + installations := map[string]string{orgA: "install-a", orgB: "install-b"} + issuers := map[string]*hostMint{} + for org, id := range installations { + issuer := &hostMint{installation: id} + issuer.prepareAuthority(t) + issuers[org] = issuer + } + var mu sync.Mutex + calls := map[string]int{} + refuseA := false + broker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]string + if r.URL.Path != credentialMintPath || json.NewDecoder(r.Body).Decode(&body) != nil || + len(body) != 2 || body["prefix"] != testSolutionID || issuers[body["organization_id"]] == nil { + http.Error(w, "invalid selector", http.StatusBadRequest) + return + } + org := body["organization_id"] + mu.Lock() + calls[org]++ + n, refuse := calls[org], refuseA && org == orgA + mu.Unlock() + if refuse { + w.WriteHeader(http.StatusUnauthorized) + return + } + token, _, err := issuers[org].authority.Start(r.Context(), corework.StartInput{ + TenantID: corework.FixtureTenant, OwnerPrincipalID: corework.FixturePrincipal, + OwnerPrincipalKind: "human", TaskID: fmt.Sprintf("scoped-%s-%d", org, n), + Audience: testAudience, OrganizationID: org, InstallationID: installations[org], + TTL: time.Minute, Execution: issuers[org].execution(), + }) + if err != nil { + t.Errorf("fixture mint: %v", err) + w.WriteHeader(500) + return + } + writeJSON(w, 200, map[string]any{"token": token, "expiresAt": time.Now().Add(2 * time.Second).UTC()}) + })) + defer broker.Close() + authorityValues(t) + s := New(Manifest{ID: testSolutionID}) + s.cfg.mintURL, s.cfg.gatewayURL = broker.URL+credentialMintPath, broker.URL + s.cfg.transport.local = true + s.cfg.brokerInternalToken = "broker-perimeter-token" + s.cfg.projectedTokenPath = filepath.Join(t.TempDir(), "projection") + writeFile(t, s.cfg.projectedTokenPath, "projected-token") + if err := s.openAuthority(t.Context()); err != nil { + t.Fatal(err) + } + get := func(org string) error { + h := http.Header{} + h.Set(orgHeader, org) + h.Set("X-Codefly-Installation-Id", installations[org]) + credential, err := s.gatewayFor(h).workload(t.Context()) + if err == nil { + err = matchCredentialScope(credential, credentialScope{org, installations[org]}) + } + return err + } + var wg sync.WaitGroup + for range 12 { + for _, org := range []string{orgA, orgB} { + wg.Go(func() { + if err := get(org); err != nil { + t.Errorf("scoped work: %v", err) + } + }) + } + } + wg.Wait() + mu.Lock() + if calls[orgA] != 1 || calls[orgB] != 1 { + t.Errorf("calls = %v; want one per organisation", calls) + } + refuseA = true + mu.Unlock() + // Let both broker-reported lifetimes expire without health renewing them. + // The next work call must re-read the same broker through the held clients. + time.Sleep(2100 * time.Millisecond) + if err := get(orgA); err == nil { + t.Fatal("revoked A acquired a credential") + } + if err := get(orgB); err != nil { + t.Fatalf("A's refusal affected B: %v", err) + } + mu.Lock() + if calls[orgA] != 2 || calls[orgB] != 2 { + t.Errorf("renewal calls = %v; want two per organisation", calls) + } + mu.Unlock() + if status, _ := healthStatus(t, s); status != http.StatusOK { + t.Fatalf("health = %d", status) + } +} + +func TestViewerEntryAndInstallAcknowledgementNeverAcquire(t *testing.T) { + var calls int + s := New(Manifest{ID: testSolutionID}).Credential(credentialSourceFunc(func(context.Context) (workcontext.Credential, error) { + calls++ + return workcontext.Credential{}, workcontext.ErrMintRefused + })) + wentry := httptest.NewRecorder() + rentry := httptest.NewRequest("GET", "/entry", nil) + rentry.Header = workHeaders() + s.wrapRequest(func(*http.Request, *Gateway) (any, error) { return "ok", nil })(wentry, rentry) + if wentry.Code != 200 { + t.Fatal("plain entry refused") + } + s.OnInstall(func(context.Context, Installation) error { return nil }) + w := httptest.NewRecorder() + s.handleInstallation(w, installRequest(t, installBody)) + if w.Code != http.StatusNoContent || calls != 0 { + t.Fatalf("install = %d, mints = %d", w.Code, calls) + } +} + +type credentialSourceFunc func(context.Context) (workcontext.Credential, error) + +func (f credentialSourceFunc) Credential(ctx context.Context) (workcontext.Credential, error) { + return f(ctx) +} + +// These helpers set up the real scoped controller for tests which need to +// inspect its flight/backoff state. Acquisition still runs production code. +func scopeController(t *testing.T, s *Server) *credentialController { + t.Helper() + key := credentialScope{testOrganization, testInstallation} + s.scopedMu.Lock() + defer s.scopedMu.Unlock() + if s.scoped == nil { + s.scoped = make(map[credentialScope]*scopedCredential) + } + if entry := s.scoped[key]; entry != nil { + return entry.controller + } + source, err := s.installationCredentialSource(key) + if err != nil { + t.Fatal(err) + } + c := &credentialController{nextCredential: source} + s.scoped[key] = &scopedCredential{controller: c, used: time.Now()} + return c +} +func testWorkCredential(s *Server, ctx context.Context) error { + _, err := s.acquireForInstallation(ctx, testOrganization, testInstallation) + return err +} +func declaredWorkServer(t *testing.T) *Server { + t.Helper() + t.Setenv(manifest.APIConsumesEnvironmentVariable, consumesThings) + s := New(Manifest{ID: testSolutionID}).Consumes(passthroughModule()).Contract(ModuleContract{Ceilings: map[string]map[string][]Scope{ + localProfile: {"things": {{ResourceKind: "things", Actions: []string{"read"}}}}, + }}) + s.cfg.profile = localProfile + s.principal = testPrincipal + contract, err := s.resolveContract() + if err != nil { + t.Fatal(err) + } + s.contract, s.contractResolved = contract, true + return s +} +func workHeaders() http.Header { + h := http.Header{} + h.Set("Authorization", viewerBearer()) + h.Set(orgHeader, testOrganization) + h.Set(sessionHeader, "session-1") + h.Set(workcontext.InstallationIDHeaderName, testInstallation) + return h +} + +func TestScopedCredentialsRefuseAnotherOrganisationOrInstallation(t *testing.T) { + mint := newHostMint(t, &hostMint{}) + credential := mintedCredential(t, mint, "scoped") + for _, scope := range []credentialScope{ + {"22222222-2222-4222-8222-222222222222", testInstallation}, + {testOrganization, "another-installation"}, + } { + s := New(Manifest{ID: testSolutionID}).Credential(stubCredentialSource{credential: credential}) + if _, err := s.acquireForInstallation(t.Context(), scope.organization, scope.installation); !errors.Is(err, ErrCredentialRefused) { + t.Fatalf("wrong scope accepted: %v", err) + } + if err := testWorkCredential(s, t.Context()); err != nil { + t.Fatalf("wrong-scope refusal poisoned correct scope: %v", err) + } + } +} + +func TestUndeclaredModuleWorkNeverAcquires(t *testing.T) { + var calls atomic.Int32 + s := declaredWorkServer(t).Credential(countingIdleSource{calls: &calls}) + if _, err := s.gatewayFor(workHeaders()).ForModule(t.Context(), "undeclared", Scope{ResourceKind: "anything", Actions: []string{"read"}}); err == nil { + t.Fatal("undeclared work accepted") + } + if calls.Load() != 0 { + t.Fatal("undeclared request minted") + } +} + +func TestScopedAcquisitionRecoversAfterConsentChanges(t *testing.T) { + mint := newHostMint(t, &hostMint{}) + credential := mintedCredential(t, mint, "recover") + synctest.Test(t, func(t *testing.T) { + var calls atomic.Int32 + var refused atomic.Bool + refused.Store(true) + s := New(Manifest{ID: testSolutionID}).Credential(credentialSourceFunc(func(context.Context) (workcontext.Credential, error) { + calls.Add(1) + if refused.Load() { + return workcontext.Credential{}, workcontext.ErrMintRefused + } + return credential, nil + })) + if err := testWorkCredential(s, t.Context()); !errors.Is(err, ErrCredentialRefused) { + t.Fatalf("refusal=%v", err) + } + refused.Store(false) + if err := testWorkCredential(s, t.Context()); err == nil { + t.Fatal("refusal backoff was bypassed") + } + if calls.Load() != 1 { + t.Fatal("backoff minted") + } + time.Sleep(credentialAskBackoffCeiling + time.Second) + if calls.Load() != 1 { + t.Fatal("idle recovery minted") + } + if err := testWorkCredential(s, t.Context()); err != nil { + t.Fatalf("renewed consent stayed refused: %v", err) + } + if calls.Load() != 2 { + t.Fatalf("recovery calls=%d", calls.Load()) + } + }) +} + +func TestScopeLimitRetainsDetachedAcquisitions(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + release := make(chan struct{}) + var calls atomic.Int32 + s := New(Manifest{ID: testSolutionID}).Credential(credentialSourceFunc(func(context.Context) (workcontext.Credential, error) { + calls.Add(1) + <-release + return workcontext.Credential{}, workcontext.ErrMintUnavailable + })) + var waiters sync.WaitGroup + for i := range maxCredentialScopes { + waiters.Go(func() { _, _ = s.acquireForInstallation(t.Context(), testOrganization, fmt.Sprintf("install-%d", i)) }) + } + synctest.Wait() + time.Sleep(routeCredentialTimeout + time.Millisecond) + waiters.Wait() + if calls.Load() != maxCredentialScopes { + t.Fatalf("started=%d", calls.Load()) + } + if _, err := s.acquireForInstallation(t.Context(), testOrganization, "overflow"); !errors.Is(err, ErrCredentialUnavailable) { + t.Fatalf("capacity=%v", err) + } + if calls.Load() != maxCredentialScopes { + t.Fatal("detached flight evicted; unbounded acquisitions") + } + close(release) + synctest.Wait() + _, _ = s.acquireForInstallation(t.Context(), testOrganization, "after-idle") + if calls.Load() != maxCredentialScopes+1 || len(s.scoped) != maxCredentialScopes { + t.Fatal("idle scope was not evicted at fixed capacity") + } + }) +} + +func TestCancelledWaiterDoesNotCancelSharedAcquisition(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + release := make(chan struct{}) + var calls atomic.Int32 + s := New(Manifest{ID: testSolutionID}).Credential(credentialSourceFunc(func(ctx context.Context) (workcontext.Credential, error) { + calls.Add(1) + select { + case <-release: + return workcontext.Credential{}, workcontext.ErrMintRefused + case <-ctx.Done(): + t.Error("waiter cancelled shared ask") + return workcontext.Credential{}, ctx.Err() + } + })) + ctx, cancel := context.WithCancel(t.Context()) + result := make(chan error, 1) + go func() { result <- testWorkCredential(s, ctx) }() + synctest.Wait() + cancel() + if err := <-result; !errors.Is(err, ErrCredentialUnavailable) { + t.Fatalf("cancel=%v", err) + } + close(release) + synctest.Wait() + if err := testWorkCredential(s, t.Context()); !errors.Is(err, ErrCredentialRefused) { + t.Fatalf("late refusal lost: %v", err) + } + if calls.Load() != 1 { + t.Fatal("cancelled waiter caused another acquisition") + } + }) +} diff --git a/solution.go b/solution.go index 719706b..b33ceb3 100644 --- a/solution.go +++ b/solution.go @@ -57,7 +57,6 @@ import ( "strconv" "strings" "sync" - "sync/atomic" "time" "connectrpc.com/connect" @@ -182,37 +181,15 @@ type Server struct { // credential is the optional consumer-supplied SDK source. The native // broker is opened at boot when no consumer source was supplied. Both // feed the same controller through nextCredential. - credential CredentialSource - brokerCredential *brokerCredentialSource + credential CredentialSource // authority is the frozen set of authority-bearing values this process runs // under, read once at boot and rechecked before every credential renewal. - authority *codefly.Authority - // terminal is closed the first time the issuer answers a renewal with a - // judgement no retry can change. It makes health fail and serve return, - // because a process holding a credential the host has stopped honouring - // cannot act for anyone and nothing it does locally changes that. - terminal chan struct{} - terminalOnce sync.Once - // installed signals awaitInstallation that an install step was just - // acknowledged. One slot: a pending signal is as good as several. - installed chan struct{} - // awaiting is set once the host has answered that nothing has installed - // this solution, and cleared when a credential is held. It is what keeps - // installation-triggered retries going through a transient answer; whether - // the process may SERVE uninstalled is a stricter question (uninstalled). - awaiting atomic.Bool - terminalErr atomic.Pointer[error] - // The one in-flight credential ask and what it last produced. See askOnce: - // without single-flight, a credential entering its renewal window turns - // every concurrent viewer request into its own audited mint. - credentialMu sync.Mutex - credentialAsk *credentialFlight - credentialAskErr error - credentialHeld executionCredential - credentialQuietUntil time.Time - // credentialAskFailures counts consecutive failed acquisitions, which is - // what the backoff grows on and a success resets. - credentialAskFailures int + authority *codefly.Authority + projectionRead brokerProjectionReader + scopedMu sync.Mutex + scoped map[credentialScope]*scopedCredential + executionMu sync.Mutex + execution sealedIdentity // boundListener is a listener this process was handed instead of binding // one itself. Nil in every deployment: the port comes from the SDK and // listen() binds it. @@ -231,12 +208,6 @@ type Server struct { // outbound is the client this boot makes platform requests with (see // outboundClient). Nil until the boot builds it. outbound *http.Client - // firstMintWindow bounds how long the boot waits for a credential the - // issuer says is not available yet. Zero means firstMintWait. Per-server - // rather than a package value so a test can spend the window in - // milliseconds without every other server in the process reading the same - // variable. - firstMintWindow time.Duration // principal is the principal this workload runs as, as the authority-bearing // values the platform provisioned name it. It is what the published // contract reports and what a delivered authority document grants to. @@ -244,9 +215,10 @@ type Server struct { } type config struct { - listenHost string - port string - gatewayURL string + requiresAuthority bool + listenHost string + port string + gatewayURL string // dialled is every address this process talks to, assembled at boot. // // It exists so nothing has to re-read the configuration to ask "is this @@ -713,7 +685,7 @@ func (c config) validate() error { return fmt.Errorf("gateway URL %q is not a destination the projected service-account token, the viewer's bearer and the capability minted for them may travel to: %w — set GATEWAY_URL, or the composition's gateway endpoint, to one that is", redactedURL(c.gatewayURL), err) } - if c.brokerInternalToken == "" { + if c.requiresAuthority && c.brokerInternalToken == "" { return fmt.Errorf("missing module-authority/INTERNAL_TOKEN: provision the broker perimeter credential as a workspace secret") } if err := resources.ValidateConfigurationProfileName(c.profile); err != nil { @@ -830,7 +802,8 @@ func New(manifest Manifest) *Server { // unset announced that product's capability contract to the host. manifest.Contract = manifest.ID } - return &Server{manifest: manifest, handlers: make(map[string]RequestHandler), terminal: make(chan struct{}), installed: make(chan struct{}, 1)} + s := &Server{manifest: manifest, handlers: make(map[string]RequestHandler)} + return s } // Handle registers a solution endpoint. Chainable. @@ -905,11 +878,14 @@ func (s *Server) start(ctx context.Context) (net.Listener, error) { // solution serving no MCP is unaffected by a host whose issuer does not // resolve. s.cfg.mcp = s.mcp != nil + s.cfg.requiresAuthority = mintsAuthority(s.consumed) if err := s.cfg.validate(); err != nil { return nil, fmt.Errorf("solution %q: %w", s.manifest.ID, err) } - if err := s.validateSources(); err != nil { - return nil, fmt.Errorf("solution %q: %w", s.manifest.ID, err) + if s.cfg.requiresAuthority { + if err := s.validateSources(); err != nil { + return nil, fmt.Errorf("solution %q: %w", s.manifest.ID, err) + } } // After the environment is loaded: the declaration is checked against the // api.consumes projection Codefly injected. @@ -921,8 +897,10 @@ func (s *Server) start(ctx context.Context) (net.Listener, error) { // The authority-bearing values first: the principal is part of what the // contract publishes, and a value the platform never provisioned is named // here rather than discovered when a mint is refused. - if err := s.openAuthority(ctx); err != nil { - return nil, fmt.Errorf("solution %q: %w", s.manifest.ID, err) + if s.cfg.requiresAuthority { + if err := s.openAuthority(ctx); err != nil { + return nil, fmt.Errorf("solution %q: %w", s.manifest.ID, err) + } } // The client every platform request goes out on: non-proxied, pinned to // the gateway's origin, following no redirect. Built before the first @@ -937,16 +915,9 @@ func (s *Server) start(ctx context.Context) (net.Listener, error) { } s.contract = contract s.contractResolved = true - // One mint per execution, before the listener is up. A runtime that cannot - // obtain its credential has no authority to serve anything with, and the - // model it serves under has nothing for it to retry against: its presence - // was declared by a document it did not write, and a refusal here says that - // document does not name this build. So it reports the refusal and exits - // non-zero, for the orchestrator to restart or the delivery to be fixed — - // it never loops, and it never comes up serving without one. - if err := s.openCredential(ctx); err != nil { - return nil, fmt.Errorf("solution %q: %w", s.manifest.ID, err) - } + // Boot exposes the host-driven install endpoint without acquiring work + // authority. Actual authority-bearing work selects its organisation and + // installation before the first broker request. ln, err := s.listen() if err != nil { return nil, fmt.Errorf("solution %q: %w", s.manifest.ID, err) @@ -987,28 +958,15 @@ func (s *Server) serve(ctx context.Context, ln net.Listener) error { IdleTimeout: inboundIdleTimeout, } go func() { - select { - case <-ctx.Done(): - case <-s.credentialRefusedC(): - } + <-ctx.Done() _ = srv.Close() }() - // For as long as this server serves, and no longer: Serve hands this - // function a context nothing cancels. - serving, stopped := context.WithCancel(ctx) - defer stopped() - go s.awaitInstallation(serving) log.Printf("solution %q listening on %s (gateway=%s, profile=%s)", s.manifest.ID, ln.Addr(), redactedURL(s.cfg.gatewayURL), s.cfg.profile) if serveErr := srv.Serve(ln); !errors.Is(serveErr, http.ErrServerClosed) { return serveErr } - // A terminal credential failure is reported as the reason this process - // ended, so the orchestrator's restart is against a judgement rather than - // an unexplained exit. - if err := s.terminalErr.Load(); err != nil && ctx.Err() == nil { - return fmt.Errorf("solution %q stopped serving: %w", s.manifest.ID, *err) - } + return nil } @@ -1050,270 +1008,11 @@ const ( inboundIdleTimeout = 30 * time.Second ) -// handleHealth reports process health without acquiring a credential. An idle -// process must not renew merely because an orchestrator probes it. Every real -// viewer operation still passes actingForAViewer and the host's live checks. -func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) { - if err := s.terminalErr.Load(); err != nil { - http.Error(w, "this execution's credential has been refused and will not be renewed; this process is ending", http.StatusServiceUnavailable) - return - } - w.WriteHeader(http.StatusOK) -} +// handleHealth reports process health without acquiring work authority. +func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) } const routeCredentialTimeout = 500 * time.Millisecond -// actingForAViewer refuses to act on a viewer's behalf while this process holds -// no credential the issuer still approves. -// -// Every route that does anything for a viewer asks this first, and that was the -// hole: the *probe* asked the credential and the routes did not. The reasoning -// is written out at handleHealth — renewal is lazy, so a solution serving only -// plain handlers and ViewerBearer routes never asks and never learns the issuer -// has stopped approving this build — and it was applied to the one caller that -// reports health rather than to the callers that act. A booted server whose -// credential had been refused kept answering 200 and kept forwarding the -// viewer's bearer to the gateway, with the credential consulted zero times. -// -// A plain handler looks like it needs no credential, which is the trap. What -// authorises this process to act for a viewer at all *is* the credential: it is -// what the host approved for this build. Forwarding a viewer's bearer under a -// credential the issuer has withdrawn is acting without authority, whether or -// not a capability is minted on the way. -// -// Cheap, and not a heartbeat: the client holds one credential and hands the -// same one back until its own renewal point, so this is a mutex and a -// comparison except at the renewal the credential's expiry dictates. -func (s *Server) actingForAViewer(ctx context.Context) error { - if err := s.terminalErr.Load(); err != nil { - return fmt.Errorf("%w: %w", ErrCredentialRefused, *err) - } - if s.credential == nil && s.brokerCredential == nil { - return nil - } - _, err := s.credentialWithin(ctx, routeCredentialTimeout) - return err -} - -// credentialWithin asks for this execution's credential and never waits longer -// than d, whatever the source does. -// -// The bound has to be imposed here, because a context does not reach the thing -// that blocks. The SDK's mint client takes a sync.Mutex and holds it across the -// network mint (workcontext/mint.go, Credential), and sync.Mutex.Lock ignores -// contexts — so a caller arriving during a slow renewal waits on the lock for -// as long as the renewal takes, and the deadline it passed in bounds nothing. -// That made the two-second health deadline advisory, and it would have made -// every route's credential check park behind one slow issuer, which is a worse -// failure than the one the check was added for. -// -// The ask runs on a context detached from the caller's, deliberately: a request -// that gives up should not cancel a mint that other callers are waiting for, -// and the SDK caches the result, so the work is not wasted. It is bounded by -// its own deadline rather than by the caller's. -// -// On a timeout, the credential this process already holds decides. Refusing -// outright would turn a slow renewal — which happens while a perfectly valid -// credential is still in hand, since renewal starts inside a lead before expiry -// — into a 503 for every viewer. -func (s *Server) credentialWithin(ctx context.Context, d time.Duration) (executionCredential, error) { - // Held and unexpired is the answer whenever the issuer is not answering, - // and this is the branch that was missing. - // - // The timeout path below already served on a held credential, and the - // error path refused outright — so with the credential inside its renewal - // window and the issuer answering 503, every viewer request asked, every - // ask minted, and every request got a 503 while a perfectly valid - // credential sat in hand. Forty requests, forty audited mints. That is not - // a smaller version of the heartbeat this runtime deleted, it is a larger - // one: the heartbeat minted on a timer, this minted on traffic. - // - // So a failed ask starts a backoff, and inside it nothing asks again. The - // renewal the credential's own expiry dictates still happens, because the - // backoff is shorter than the renewal lead; what cannot happen is one mint - // per request. - // The quiet window applies whether or not anything is held, which is the - // half that was missing: it was consulted only when a credential WAS in - // hand, so the one state that should ask least — nothing held, the issuer - // failing — was the state that asked on every single request. The branch - // meant to back off was the branch that hammered. - // A recorded judgement first, on every path. The quiet branch below would - // otherwise re-wrap a terminal refusal as "cannot currently be obtained", - // which is the one distinction the boot and the run must not disagree on. - if err := s.terminalErr.Load(); err != nil { - return workcontext.Credential{}, fmt.Errorf("%w: %w", ErrCredentialRefused, *err) - } - if held, quiet, last := s.credentialState(); quiet { - if held.Token() != "" { - return held, nil - } - if terminalCredentialFailure(last) { - return workcontext.Credential{}, fmt.Errorf("%w: %w", ErrCredentialRefused, last) - } - return workcontext.Credential{}, fmt.Errorf("%w: %w", ErrCredentialUnavailable, last) - } - credential, err := s.askOnce(ctx, d) - if err == nil { - return credential, nil - } - if errors.Is(err, ErrCredentialRefused) { - return credential, err - } - // Unavailable, for any reason including the ask not answering in time. A - // credential already in hand decides, because renewal begins inside a lead - // before expiry and refusing here would 503 every viewer over a slow - // issuer this process does not yet need. - if held, ok := s.heldCredential(); ok { - return held, nil - } - return credential, err -} - -// holdCredential records a credential the controller did not itself acquire — -// the boot's — so the first caller after boot sees a process that holds one. -func (s *Server) holdCredential(credential executionCredential) { - s.awaiting.Store(false) - s.credentialMu.Lock() - s.credentialHeld = credential - s.credentialQuietUntil = time.Time{} - s.credentialMu.Unlock() -} - -// heldCredential is the credential this process last obtained, if it has not -// expired. The zero credential never counts as held: a source that hands back -// nothing has given this process no authority, which is the case a wrapper -// keyed on the token's *value* could not see. -func (s *Server) heldCredential() (executionCredential, bool) { - s.credentialMu.Lock() - defer s.credentialMu.Unlock() - if s.credentialHeld == nil || s.credentialHeld.Token() == "" { - return workcontext.Credential{}, false - } - if !time.Now().Before(s.credentialHeld.ExpiresAt()) { - return workcontext.Credential{}, false - } - return s.credentialHeld, true -} - -// credentialState is what this process holds, whether a recent ask failed -// recently enough that nobody should ask again, and that ask's error. -// -// All three under one lock, because a caller that reads them separately can act -// on a mixture of two different moments. -func (s *Server) credentialState() (held executionCredential, quiet bool, last error) { - s.credentialMu.Lock() - defer s.credentialMu.Unlock() - held = s.credentialHeld - if held == nil || held.Token() == "" || !time.Now().Before(held.ExpiresAt()) { - held = workcontext.Credential{} - } - last = s.credentialAskErr - if last == nil { - last = fmt.Errorf("the credential source has not answered yet") - } - return held, time.Now().Before(s.credentialQuietUntil), last -} - -// askOnce asks the source for the credential, at most one ask at a time, and -// never waits longer than d. -// -// Single-flight, because the alternative is one mint per concurrent caller the -// moment the credential enters its renewal window. The bound is imposed here -// rather than passed down, because a context does not reach what blocks: the -// SDK's client holds a sync.Mutex across the network mint and sync.Mutex.Lock -// ignores contexts (see the drafted sdk-go issue, which is the durable half of -// this). -func (s *Server) askOnce(ctx context.Context, d time.Duration) (executionCredential, error) { - s.credentialMu.Lock() - inflight := s.credentialAsk - if inflight == nil { - inflight = &credentialFlight{done: make(chan struct{})} - s.credentialAsk = inflight - go s.resolveCredential(ctx, inflight) - } - s.credentialMu.Unlock() - - timer := time.NewTimer(d) - defer timer.Stop() - select { - case <-inflight.done: - case <-timer.C: - return workcontext.Credential{}, fmt.Errorf("%w: it did not answer within %s", ErrCredentialUnavailable, d) - } - - // From the flight this caller actually waited on, not from the server's - // current state. Reading the shared fields let a waiter observe a *later* - // flight's answer — or none at all, if the next flight had already started - // and cleared them — so the credential a caller acted on was not - // necessarily the one its own ask produced. A flight's result is written - // once, before its channel closes, and never again. - credential, err := inflight.credential, inflight.err - if err != nil { - if terminalCredentialFailure(err) { - // Records it, which also ends the process: the first caller to - // discover a refused build is as good a place to learn it as the - // probe, and better than the next one. - s.credentialRefused(err) - return workcontext.Credential{}, fmt.Errorf("%w: %w", ErrCredentialRefused, err) - } - return workcontext.Credential{}, fmt.Errorf("%w: %w", ErrCredentialUnavailable, err) - } - return credential, nil -} - -// resolveCredential performs the one in-flight ask and records its outcome. -// -// On a context detached from the caller's, deliberately: a request that gives -// up must not cancel an ask other callers are waiting on, and the SDK caches -// the result so the work is not wasted. -func (s *Server) resolveCredential(ctx context.Context, flight *credentialFlight) { - asked, cancel := context.WithTimeout(context.WithoutCancel(ctx), platformRequestTimeout) - defer cancel() - credential, err := s.nextCredential(asked) - if err == nil { - err = usableCredential(credential) - } - - // Recorded HERE, where the answer lands, and not only where a caller - // happens to be waiting for it. - // - // This ask runs on a detached context so a request that gives up does not - // cancel it — which means its answer can arrive after every caller has - // gone. A terminal refusal classified only by the waiting caller was - // therefore discarded whenever acquisition outlived its caller: the route - // served on the held credential, nothing called credentialRefused, and the - // process went on acting for viewers after its own source had reported - // this execution refused. The classification belongs to the answer, not to - // whoever was still listening. - if err != nil && terminalCredentialFailure(err) { - s.credentialRefused(err) - } - - // The flight's own result first, then the shared state. A waiter reads the - // former and never the latter. - flight.credential, flight.err = credential, err - - switch { - case err == nil: - s.awaiting.Store(false) - case awaitingInstallation(err): - s.awaiting.Store(true) - } - s.credentialMu.Lock() - s.credentialAskErr = err - if err != nil { - s.credentialAskFailures++ - s.credentialQuietUntil = time.Now().Add(credentialAskQuiet(s.credentialAskFailures)) - } else { - s.credentialHeld = credential - s.credentialQuietUntil = time.Time{} - s.credentialAskFailures = 0 - } - s.credentialAsk = nil - s.credentialMu.Unlock() - close(flight.done) -} - // credentialFlight is one acquisition and the answer it produced. type credentialFlight struct { done chan struct{} @@ -1436,37 +1135,14 @@ func usableCredential(credential executionCredential) error { return nil } -// routeCredentialTimeout bounds what a route acting for a viewer will wait for -// the credential. Short, because the answer is almost always a comparison -// against one already held, and a route is not the place to wait out a mint. - -// acquireCredential is the one way anything in this process obtains the -// execution credential, including a viewer's mint. -// -// Nil when no source was supplied, so attestWorkloadReporting still refuses a -// mint rather than treating an absent source as nothing to attest with. -func (s *Server) acquireCredential(ctx context.Context) (executionCredential, error) { - return s.credentialWithin(ctx, routeCredentialTimeout) -} - -// credentialRefusalForAViewer is what a viewer is told when this process will -// not act for them: which of the two conditions it is, and nothing about the -// issuer, the destination or the source's own words. -func credentialRefusalForAViewer(err error) string { - if errors.Is(err, ErrCredentialRefused) { - return "this solution's authority for this execution has been refused and will not be renewed" - } - return "this solution cannot currently obtain the authority it needs to act for you" -} - // ErrCredentialRefused and ErrCredentialUnavailable are why a route refused to // act for a viewer: a judgement about this build, or an issuer that cannot // answer right now. Both are 503 — this process cannot act either way — and // they stay distinguishable because conflating them was a review blocker in // both directions. var ( - ErrCredentialRefused = errors.New("this execution's credential has been refused and will not be renewed, so this process will not act for a viewer") - ErrCredentialUnavailable = errors.New("this execution's credential cannot currently be obtained, so this process will not act for a viewer") + ErrCredentialRefused = errors.New("this installation's workload credential has been refused") + ErrCredentialUnavailable = errors.New("this installation's workload credential is currently unavailable") ) // Why this runtime infers nothing from a conflict on a viewer's mint. @@ -1494,21 +1170,6 @@ var ( // call by the far end, which is correct if noisy, and the renewal that replaces // it happens on the credential's own schedule. -// credentialRefused records a judgement the issuer will not reverse. -func (s *Server) credentialRefused(err error) { - s.terminalOnce.Do(func() { - s.terminalErr.Store(&err) - log.Printf("solution %q: this execution's credential was refused and will not be renewed, so this process is ending for the orchestrator to restart against the delivery as it stands: %v", - s.manifest.ID, err) - close(s.terminal) - }) -} - -// credentialRefusedC is the channel serve waits on. Created in New, never -// lazily: a request goroutine reporting a refusal and serve reading it would -// otherwise race on the field itself. -func (s *Server) credentialRefusedC() <-chan struct{} { return s.terminal } - // HealthPath is where this runtime answers a health probe. It is a plain 200 on // the listener the presence document names, which is the whole of this // runtime's part in being observed: the host decides whether this binding is @@ -1638,22 +1299,6 @@ func (s *Server) wrapRequest(handler RequestHandler) http.HandlerFunc { writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "missing bearer"}) return } - // Before the handler, because the handler is what acts. See - // actingForAViewer: a plain handler that mints nothing still receives a - // gateway carrying the viewer's bearer, and sending that anywhere under - // a credential the issuer has withdrawn is acting without authority. - if err := s.actingForAViewer(r.Context()); err != nil { - // The sanitized sentence, not the error. The error wraps whatever - // the source said — a mint URL, an issuer's own text, a dial - // failure naming an internal address — and this route gate wrote - // it straight into the viewer's response and the log, which is the - // disclosure handlerErrorResponse exists to prevent. Adding a gate - // in front of the handler put a new path around the sanitization - // rather than through it. - log.Printf("solution %q: refusing to act for a viewer: %v", s.manifest.ID, err) - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": credentialRefusalForAViewer(err)}) - return - } result, err := handler(r, s.gatewayFor(r.Header)) if err != nil { status, message := handlerErrorResponse(err) @@ -1727,9 +1372,7 @@ type Gateway struct { // transport is what this gateway dials with: the boot's authenticated // client, or nil on a gateway built outside a boot. transport http.RoundTripper - // terminal reports a credential failure the issuer will not reverse, so the - // process can stop claiming to be healthy. Nil outside a boot. - terminal func(error) + // ceilings is the published contract's ceiling per audience, which every // ForModule ask is held to. Non-nil exactly when a contract was resolved, // which only a boot does; a derived gateway inherits it, so chaining @@ -1756,12 +1399,11 @@ func (s *Server) gatewayFor(header http.Header) *Gateway { // is absent. gw.installationID, gw.installationErr = viewerInstallation(header) gw.id, gw.report = s.manifest.ID, &s.attestation - // Left nil when no source was supplied, so a mint is refused for want of - // one rather than panicking inside the controller. - if s.credential != nil || s.brokerCredential != nil { - gw.workload = s.acquireCredential + gw.workload = func(ctx context.Context) (executionCredential, error) { + return s.acquireForInstallation(ctx, gw.orgID, gw.installationID) } - gw.terminal = s.credentialRefused + // Broker denials can concern one organisation. They must not terminate + // unrelated installations or turn into a process-wide health failure. // The ceiling this process published, carried onto the gateway a handler // gets, so what it mints is held to what the contract claims. Resolved // contracts only, read off the flag the boot sets — see contractResolved @@ -2437,7 +2079,7 @@ func (g *Gateway) mint(ctx context.Context, ask startTaskRequest) (string, time. // request — the capability being minted is what the answer carries, and the // viewer has none yet — so attesting here collides with nothing the module // call later presents. - if err := attestWorkloadReporting(ctx, g.workload, g.report, post, g.id, g.terminal); err != nil { + if err := attestWorkloadReporting(ctx, g.workload, g.report, post, g.id); err != nil { return "", time.Time{}, err } resp, err := g.bearerClient().Do(post) diff --git a/solution_test.go b/solution_test.go index fc4f995..58792c9 100644 --- a/solution_test.go +++ b/solution_test.go @@ -53,7 +53,7 @@ func capability(seed string) string { OwnerPrincipalKind: "human", TaskID: seed, Audience: corework.FixtureAudience, - OrganizationID: corework.FixtureOrganization, + OrganizationID: testOrganization, InstallationID: corework.FixtureInstallation, TTL: 10 * time.Minute, Execution: corework.Execution{ diff --git a/stream_test.go b/stream_test.go index 20dbf80..7649aea 100644 --- a/stream_test.go +++ b/stream_test.go @@ -123,7 +123,7 @@ func watchRequest(entry string) *connect.Request[dynamicpb.Message] { setField(msg, "entry_id", protoreflect.ValueOfString(entry)) req := connect.NewRequest(msg) req.Header().Set("authorization", viewerBearer()) - req.Header().Set(orgHeader, "org-1") + req.Header().Set(orgHeader, testOrganization) req.Header().Set(sessionHeader, "session-1") req.Header().Set(workcontext.InstallationIDHeaderName, testInstallation) return req diff --git a/transcode_test.go b/transcode_test.go index 395fed9..5624f02 100644 --- a/transcode_test.go +++ b/transcode_test.go @@ -127,13 +127,13 @@ func transcodeServer(t *testing.T, status int, reply string) (*Gateway, *recorde _, _ = io.WriteString(w, reply) })) t.Cleanup(srv.Close) - return newGateway(srv.URL, "Bearer viewer", "org-1", "session-1"), got, hits + return newGateway(srv.URL, "Bearer viewer", testOrganization, "session-1"), got, hits } func TestTranscodedUsesTheBindingUnderThePrefix(t *testing.T) { gw, got, _ := transcodeServer(t, http.StatusOK, `{"entryId":"e/1","big":"9007199254740993","added":"by a newer owner"}`) req := thingMessage("GetThingRequest") - setField(req, "tenant", protoreflect.ValueOfString("org-1")) + setField(req, "tenant", protoreflect.ValueOfString(testOrganization)) setField(req, "entry_id", protoreflect.ValueOfString("e/1")) setField(req, "page_size", protoreflect.ValueOfInt32(100)) ids := req.Mutable(req.Descriptor().Fields().ByName("ids")).List() @@ -152,7 +152,7 @@ func TestTranscodedUsesTheBindingUnderThePrefix(t *testing.T) { // The additional binding is the one under the prefix; the path variable is // escaped so its slash cannot add a segment; every other set field is a // query parameter by proto name. - want := "/v1/things/e%2F1?filter.name=x&ids=a&ids=b&page_size=100&tenant=org-1" + want := "/v1/things/e%2F1?filter.name=x&ids=a&ids=b&page_size=100&tenant=" + testOrganization if got.uri != want { t.Fatalf("uri = %s, want %s", got.uri, want) } @@ -167,7 +167,7 @@ func TestTranscodedUsesTheBindingUnderThePrefix(t *testing.T) { func TestTranscodedSendsTheBodyTheBindingNames(t *testing.T) { gw, got, _ := transcodeServer(t, http.StatusOK, `{}`) req := thingMessage("GetThingRequest") - setField(req, "tenant", protoreflect.ValueOfString("org-1")) + setField(req, "tenant", protoreflect.ValueOfString(testOrganization)) setField(req, "page_size", protoreflect.ValueOfInt32(5)) if err := gw.Transcoded(context.Background(), "/v1/things", "/things.v1.Things/Search", req, thingMessage("Thing")); err != nil { t.Fatal(err) @@ -176,7 +176,7 @@ func TestTranscodedSendsTheBodyTheBindingNames(t *testing.T) { t.Fatalf("request = %s %s", got.method, got.uri) } var body map[string]any - if err := json.Unmarshal([]byte(got.body), &body); err != nil || body["tenant"] != "org-1" || body["pageSize"] != float64(5) { + if err := json.Unmarshal([]byte(got.body), &body); err != nil || body["tenant"] != testOrganization || body["pageSize"] != float64(5) { t.Fatalf("body = %s (%v)", got.body, err) } } @@ -249,7 +249,7 @@ func TestTranscodedPresentsTheViewersCredentials(t *testing.T) { _, _ = io.WriteString(w, `{}`) })) defer srv.Close() - gw := newGateway(srv.URL, "Bearer viewer", "org-1", "session-1") + gw := newGateway(srv.URL, "Bearer viewer", testOrganization, "session-1") req := thingMessage("GetThingRequest") setField(req, "entry_id", protoreflect.ValueOfString("e1")) if err := gw.Transcoded(context.Background(), "/v1/things", "/things.v1.Things/Get", req, thingMessage("Thing")); err != nil { diff --git a/uninstalled_test.go b/uninstalled_test.go index d5960ac..e0f66cd 100644 --- a/uninstalled_test.go +++ b/uninstalled_test.go @@ -4,7 +4,6 @@ import ( "context" "io" "net/http" - "net/http/httptest" "os" "strings" "sync/atomic" @@ -42,15 +41,9 @@ func healthOf(t *testing.T, b *booted) int { return resp.StatusCode } -// TestAnUninstalledSolutionServesItsInstallStepAndThenHoldsACredential is the -// order an installation actually happens in, against a host that behaves as -// the real one does: it answers 412 while nothing has installed the solution, -// delivers the install step, and issues only once that step was acknowledged. -// -// A runtime that needs its execution credential to answer the step can never -// be installed by such a host — each waits for the other — and one whose -// health fails while uninstalled is restarted for not being installed. -func TestAnUninstalledSolutionServesItsInstallStepAndThenHoldsACredential(t *testing.T) { +// Installation acknowledgement carries its own capability. It never acquires a +// workload credential; only later module work does, against current consent. +func TestInstallationAcknowledgementDoesNotAcquire(t *testing.T) { var active atomic.Bool var steps atomic.Int32 host := newHostMint(t, &hostMint{answer: func() int { @@ -59,147 +52,46 @@ func TestAnUninstalledSolutionServesItsInstallStepAndThenHoldsACredential(t *tes } return http.StatusPreconditionFailed }}) - s := New(Manifest{ID: testSolutionID}).OnInstall(func(context.Context, Installation) error { - steps.Add(1) - return nil - }) + s := declaredWorkServer(t).OnInstall(func(context.Context, Installation) error { steps.Add(1); return nil }) b := boot(t, s, host) - t.Cleanup(b.client.CloseIdleConnections) - b.client.Timeout = 5 * time.Second - - if !s.uninstalled() { - t.Fatal("a boot the host answered 412 does not read as uninstalled") - } - if _, held := s.heldCredential(); held { - t.Fatal("an uninstalled boot holds a credential") - } - asked := host.count() - - // Healthy, and answered from what the host already said. - for range 3 { - if status := healthOf(t, b); status != http.StatusOK { - t.Fatalf("health while uninstalled = %d, want 200: a failing probe has the orchestrator restart a solution for not being installed", status) - } - } - if got := host.count(); got != asked { - t.Fatalf("three probes asked the host %d more time(s); a probe of an uninstalled solution asks nobody", got-asked) + if host.count() != 0 || healthOf(t, b) != 200 { + t.Fatal("idle boot consulted issuer") } - - // The step is delivered while the host still answers 412, exactly as the - // host orders it, and the host activates only after reading the answer. - if status := postInstall(t, b); status != http.StatusNoContent { - t.Fatalf("install step while uninstalled = %d, want 204: the host issues nothing until this step is acknowledged, so refusing it is a solution nobody can install", status) + if status := postInstall(t, b); status != 204 || steps.Load() != 1 { + t.Fatalf("install = %d", status) } - if steps.Load() != 1 { - t.Fatalf("the install step ran %d time(s), want 1", steps.Load()) + if host.count() != 0 { + t.Fatal("acknowledgement minted") } - time.Sleep(100 * time.Millisecond) active.Store(true) - - // Held promptly — well inside the idle cadence, because the acknowledged - // step is what says the credential is now worth asking for. - deadline := time.Now().Add(10 * time.Second) - for { - if _, held := s.heldCredential(); held { - break - } - if time.Now().After(deadline) { - t.Fatalf("no credential held %s after the install step was acknowledged and the host began issuing", 10*time.Second) - } - time.Sleep(20 * time.Millisecond) - } - if s.uninstalled() { - t.Fatal("a runtime holding a credential still reads as uninstalled") - } - if status := healthOf(t, b); status != http.StatusOK { - t.Fatalf("health once installed = %d, want 200", status) - } -} - -// TestARefusalAfterAnUninstalledBootEndsTheProcess: the host admitted this -// workload at boot and judges it differently when next asked. That answer has -// to end the process the way it does on every other path; an ask made around -// the controller logged it and left a process serving on a refused build. -func TestARefusalAfterAnUninstalledBootEndsTheProcess(t *testing.T) { - var refuse atomic.Bool - host := newHostMint(t, &hostMint{answer: func() int { - if refuse.Load() { - return http.StatusForbidden - } - return http.StatusPreconditionFailed - }}) - s := New(Manifest{ID: testSolutionID}).OnInstall(func(context.Context, Installation) error { return nil }) - b := boot(t, s, host) - t.Cleanup(b.client.CloseIdleConnections) - b.client.Timeout = 5 * time.Second - - refuse.Store(true) - if status := postInstall(t, b); status != http.StatusNoContent { - t.Fatalf("install step = %d, want 204", status) - } - err, ended := b.ended(10 * time.Second) - if !ended { - t.Fatal("the host refused this execution's credential after an uninstalled boot and the process kept serving") - } - if err == nil || !strings.Contains(err.Error(), "stopped serving") { - t.Fatalf("serve returned %v; a refusal is reported as the reason the process ended", err) - } -} - -// TestAnInstalledRuntimeStillHoldsTheInstallStepToItsCredential: the exemption -// is for the one state that needs it. A runtime whose credential the host has -// stopped issuing for any other reason answers the step as it answers a viewer. -func TestAnInstalledRuntimeStillHoldsTheInstallStepToItsCredential(t *testing.T) { - var steps atomic.Int32 - s, _, _ := brokerFixture(t) - s.OnInstall(func(context.Context, Installation) error { steps.Add(1); return nil }) - s.noteCredentialAnswer(&brokerMintError{class: brokerTransport}) - s.credentialMu.Lock() - s.credentialQuietUntil = time.Now().Add(time.Minute) - s.credentialMu.Unlock() - if s.uninstalled() { - t.Fatal("a host that cannot be reached reads as an uninstalled solution") + if err := testWorkCredential(s, t.Context()); err != nil { + t.Fatal(err) } - w := httptest.NewRecorder() - s.handleInstallation(w, installRequest(t, installBody)) - if w.Code != http.StatusServiceUnavailable || steps.Load() != 0 { - t.Fatalf("install step with no obtainable credential = %d after %d run(s), want 503 and none", w.Code, steps.Load()) + if host.count() != 1 || healthOf(t, b) != 200 { + t.Fatal("first installed work did not acquire") } } -// Advancing beyond both the former polling and renewal intervals must make no -// acquisition. The same controller is then exercised by a real request. func TestIdleRuntimeAndHealthNeverMint(t *testing.T) { - for _, uninstalled := range []bool{false, true} { - t.Run(map[bool]string{false: "registered", true: "uninstalled"}[uninstalled], func(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - var calls atomic.Int32 - s := New(Manifest{ID: testSolutionID}) - s.credential = countingIdleSource{calls: &calls} - if uninstalled { - s.noteCredentialAnswer(&brokerMintError{class: brokerAwaitingInstallation}) - } - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - go s.awaitInstallation(ctx) - for range 120 { - time.Sleep(30 * time.Second) - if status, _ := healthStatus(t, s); status != http.StatusOK { - t.Fatalf("idle health = %d", status) - } - } - if got := calls.Load(); got != 0 { - t.Fatalf("one idle hour produced %d credential acquisitions", got) - } - if err := s.actingForAViewer(ctx); err == nil { - t.Fatal("actual work was not refused without a credential") - } - if got := calls.Load(); got != 1 { - t.Fatalf("actual work made %d acquisitions, want 1", got) - } - }) - }) - } + synctest.Test(t, func(t *testing.T) { + var calls atomic.Int32 + s := New(Manifest{ID: testSolutionID}).Credential(countingIdleSource{calls: &calls}) + for range 120 { + time.Sleep(30 * time.Second) + if status, _ := healthStatus(t, s); status != 200 { + t.Fatalf("health=%d", status) + } + } + if calls.Load() != 0 { + t.Fatal("idle health minted") + } + if err := testWorkCredential(s, t.Context()); err == nil { + t.Fatal("unavailable source accepted") + } + if calls.Load() != 1 { + t.Fatalf("actual work made %d acquisitions", calls.Load()) + } + }) } type countingIdleSource struct{ calls *atomic.Int32 } @@ -209,75 +101,45 @@ func (s countingIdleSource) Credential(context.Context) (workcontext.Credential, return workcontext.Credential{}, workcontext.ErrMintUnavailable } -// TestTheAskerSurvivesATransientAnswerWhileUninstalled: one answer that -// judges nothing — the host restarting, a pass in flight — leaves the solution -// exactly as uninstalled as it was. An asker that stopped there left the first -// credential waiting for the host's next install delivery. -func TestTheAskerSurvivesATransientAnswerWhileUninstalled(t *testing.T) { - var phase atomic.Int32 // 0: installed nowhere, 1: one transient answer, 2: issuing - host := newHostMint(t, &hostMint{answer: func() int { - switch phase.Load() { - case 0: - return http.StatusPreconditionFailed - case 1: - phase.Store(2) - return http.StatusServiceUnavailable - } - return 0 - }}) - s := New(Manifest{ID: testSolutionID}).OnInstall(func(context.Context, Installation) error { return nil }) - b := boot(t, s, host) - t.Cleanup(b.client.CloseIdleConnections) - b.client.Timeout = 5 * time.Second - - if status := postInstall(t, b); status != http.StatusNoContent { - t.Fatalf("install step = %d, want 204", status) - } - phase.Store(1) - deadline := time.Now().Add(10 * time.Second) - for { - if _, held := s.heldCredential(); held { - return - } - if time.Now().After(deadline) { - t.Fatalf("no credential held %s after one transient answer: the asker stopped, and nothing but the host's next install delivery would start it again", 10*time.Second) - } - time.Sleep(20 * time.Millisecond) - } -} - -// TestABootWaitsForAProjectionThatIsNotThereYet: the platform projects the -// token, and a process can be running before it has. Nothing is sent without -// one, so the boot waits inside its window instead of ending on a state the -// platform is about to leave. -func TestABootWaitsForAProjectionThatIsNotThereYet(t *testing.T) { +func TestMissingProjectionDoesNotDelayBootAndWorkRecovers(t *testing.T) { host := newHostMint(t, &hostMint{}) - s := New(Manifest{ID: testSolutionID}) + s := declaredWorkServer(t) bootEnvironment(t, host) - tokenFile := os.Getenv(ProjectedTokenFileEnvironmentVariable) - projected, err := os.ReadFile(tokenFile) + path := os.Getenv(ProjectedTokenFileEnvironmentVariable) + projected, err := os.ReadFile(path) if err != nil { t.Fatal(err) } - if err := os.Remove(tokenFile); err != nil { + if err := os.Remove(path); err != nil { t.Fatal(err) } - go func() { - time.Sleep(300 * time.Millisecond) - _ = os.WriteFile(tokenFile, projected, 0o600) - }() - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - ln, err := takeListener(s).start(ctx) + ln, err := takeListener(s).start(t.Context()) if err != nil { - t.Fatalf("a boot whose projected token arrived a moment late was refused: %v", err) + t.Fatal(err) } - _ = ln.Close() - if _, held := s.heldCredential(); !held { - t.Fatal("the boot came up holding no credential") + defer ln.Close() + if host.count() != 0 { + t.Fatal("boot attempted mint") + } + if err := testWorkCredential(s, t.Context()); err == nil { + t.Fatal("work accepted without projection") + } + if host.count() != 0 { + t.Fatal("missing projection sent mint") + } + if err := os.WriteFile(path, projected, 0600); err != nil { + t.Fatal(err) + } + // Retry only after the request-driven backoff; no timer mints by itself. + c := scopeController(t, s) + c.credentialMu.Lock() + quiet := c.credentialQuietUntil + c.credentialMu.Unlock() + time.Sleep(time.Until(quiet) + time.Millisecond) + if err := testWorkCredential(s, t.Context()); err != nil { + t.Fatal(err) } - if got := host.count(); got != 1 { - t.Fatalf("the host was asked %d time(s); nothing is sent without a token, so exactly one ask reaches it", got) + if host.count() != 1 { + t.Fatalf("mints=%d", host.count()) } }