diff --git a/configurations/local/datasource-keys.secret.env b/configurations/local/datasource-keys.secret.env new file mode 120000 index 000000000..2b231cdf6 --- /dev/null +++ b/configurations/local/datasource-keys.secret.env @@ -0,0 +1 @@ +../../module/configurations/local/datasource-keys.secret.env \ No newline at end of file diff --git a/configurations/local/identity-provider.secret.env b/configurations/local/identity-provider.secret.env new file mode 120000 index 000000000..70bf7a7cd --- /dev/null +++ b/configurations/local/identity-provider.secret.env @@ -0,0 +1 @@ +../../module/configurations/local/identity-provider.secret.env \ No newline at end of file diff --git a/module/AGENTS.md b/module/AGENTS.md index a9286c70f..b5abbc2ee 100644 --- a/module/AGENTS.md +++ b/module/AGENTS.md @@ -135,6 +135,20 @@ No group declares module authority: the module principal registry is derived from delivered authority inside the module envelope a platform super_admin writes ([services/accounts/AGENTS.md](./services/accounts/AGENTS.md#the-composed-module-service-principal)). +**A credential's blast radius is the set of services that DECLARE its group**, not +the set that reads it: a declared group is injected whether anything reads it or +not. So which group a credential lives in is the security decision, and +`module/tools/credential_group_scope_test.go` holds both halves of it — which +services may declare each credential-bearing group, and that the +gateway-provenance key appears in no group but `gateway-trust`. + +That is why the identity provider's own credentials live in `identity-provider` +rather than in `identity`: the frontend declares `identity` to render the login +page from its public facts (issuer, client id, display name, scope), so a +deployment putting the provider's client secret and management key there hands a +public-facing process credentials it never reads. Only accounts declares +`identity-provider`. + **Do not generalise about how an unset value behaves — read the group.** `federation` fails closed when its declared-presence keys are empty: no coordinate means no delivery surface and no execution binding, so no module may @@ -146,6 +160,18 @@ configuration here. Other groups ship a **working default**, which is the opposite posture: `local` carries `CODEFLY_INTERNAL_TOKEN=local-dev-only-replace-me` in `internal-auth.secret.env` and `IDENTITY_SIGNUP_MODE=open` in `identity.env`. -Nothing rejects a shipped placeholder that reaches a real deployment, so an -unprovisioned group is not a denied one — it is a live credential with a value -everybody knows. + +For the credentials that decide **perimeter membership** — the cluster-internal +token and the gateway-provenance token — a shipped default no longer reaches a +deployed runtime: accounts and `auth-gateway` each refuse to start when one +carries a marker from the same list the shipping gate requires of every secret +default, or is shorter than 32 characters, and the refusal names the +group-qualified key. The marker lists and the length floor live in +`shipped_placeholder.go` beside each service's entrypoint, and +`module/tools/shipped_placeholder_lockstep_test.go` holds all three copies +identical — one added on only one side leaves either a published value a cell +accepts or a default that side can no longer boot on. + +Every **other** group still ships a working default that a deployed runtime will +use: an unprovisioned optional group is not a denied one. Where that matters for a +credential, the loader's own "is this required" check is the place to say so. diff --git a/module/FEATURES.md b/module/FEATURES.md index 2e3ff8ecf..9fb9c2507 100644 --- a/module/FEATURES.md +++ b/module/FEATURES.md @@ -158,7 +158,7 @@ rather than at first login. | Rotation | One locked transaction consumes the family, resolves current authorization, and inserts one successor | | Org exchange | Authenticated target-only exchange; current membership/roles resolved under the active session lock; same device family and refresh credential | | Reuse defense | Reuse of a token consumed by rotation revokes every active user session; administrative revocation is not replay | -| Logout | Revokes the refresh family and deny-lists the current access-token JTI in Redis | +| Logout | The browser posts an empty body and its HttpOnly refresh cookie; the middleware moves the cookie's token into the request as it does for refresh, so the family is revoked without the credential ever reaching script. Deny-lists the presented access-token JTI and the verified session, so the access half dies on the strength of who is calling rather than of what the body carried | | Current state | Refresh requires current authorization; database triggers atomically revoke affected families on status, membership/role, platform-role, and verified-MFA changes | --- @@ -248,11 +248,11 @@ their next owning change. | Password login | ❌ | Intentional — provider-only; account recovery routed via OAuth | | Fixture login (dev) | ✅ | Click-to-login in dev mode; not exposed in prod build | | MFA (passkeys + TOTP + recovery) | ✅ | WebAuthn with exact RP/origin policy and encrypted credential state; durable one-use login challenges and recent AAL2 step-up | -| Refresh-token rotation | ✅ | OWASP family revocation on reuse | +| Refresh-token rotation | ✅ | OWASP family revocation on reuse. The credential never leaves as readable content: every authentication-completing surface — REST and Connect alike — moves it into the scoped HttpOnly SameSite=Strict cookie and strips the field, with one declared exemption, the OAuth 2.1 token endpoint, whose caller is a registered client and whose body carrier is the protocol's | | Session list + revoke | ✅ | Stable per-device family ids, device context, idle/absolute expiry, whole-family revoke | | Session lifetime policy | ✅ | Configurable fixed absolute TTL, idle TTL, and serialized active-device cap | -| Logout | ✅ | Revokes the presented device family | -| OAuth state / CSRF | 🟡 | Validated client-side in `sessionStorage`; no server-side double-check (gap) | +| Logout | ✅ | Revokes the presented device family, the presented access token and the verified session's access half; the browser's cookie is the credential carrier, so an empty body is a complete sign-out | +| OAuth state / CSRF | ✅ | Server-signed state bound to (provider, redirect_uri), single-use: the nonce is consumed on first verification and the state is refused when single use cannot be recorded | | OAuth PKCE | ❌ | Comments mention PKCE but exchanger uses `client_secret` (acceptable for confidential server-side; PKCE adds defense for SPA-driven flows) | | Account lockout (failed attempts) | ❌ | No counter on user table | | Email verification | 🟡 | `email_verified` flag stored; no flow that issues + checks | @@ -516,7 +516,7 @@ starters, and large-scale enterprise SaaS expectations. | OAuth login (multiple providers) | ✅ | Same | | Email/password login | ❌ | ✅ (most starters keep both) | | Magic-link login | 🟡 | ✅ (one-click) | -| MFA enforced on sensitive ops | ✅ | ✅ (2026-04-25 fix: `requireMFA` gates billing, GDPR delete, role grants, impersonation; opt-in per user) | +| MFA enforced on sensitive ops | ✅ | 🟡 `requireMFA` gates billing, GDPR delete, impersonation, organization deletion, waitlist review, the role/scope/principal grants (creation and revocation) and the platform-admin mutations (suspend, unsuspend, session revoke, entitlement override). Fail-closed for a privileged actor — any platform role, or ownership of the request's organization — where not being enrolled is a refusal, not a pass; opt-in per user for every other member, which an ENROLLED organization admin now meets on the grant paths (see below). **The method policy still declares `MFA_REQUIREMENT_NONE` for the newly gated methods, so the policy/handler lockstep gate does not yet demand it** — see the note below | | Multi-org tenancy | ✅ | Same | | Org invitations | ✅ | Same | | RBAC (built-in roles) | ✅ | Same | @@ -531,6 +531,38 @@ starters, and large-scale enterprise SaaS expectations. | OpenAPI / TS client autogen | ✅ | ✅ (Connect-ES is more typesafe than fetch-based clients) | | Real-stack e2e tests | ✅ | 🟡 (most starters mock — we're ahead) | +**What the MFA row does not yet have.** `module/services/accounts/proto/.../*.proto` +declares each method's `mfa:` requirement, and +`pkg/adapters/rpc_policy_lockstep_test.go` holds handlers to what their policy +declares. The methods gated in this change still declare `MFA_REQUIREMENT_NONE`, so +the gate is satisfied by them either way and a later edit could remove the gate +without reddening anything. `pkg/adapters/privileged_mutation_factor_test.go` is the +stopgap: it reads the source and requires the gate, before the mutation, for every +method on an explicit list. Aligning the declarations is the durable fix and is owed; +it is a descriptor change, so it moves the contract digests and the published client +and wants its own verified regeneration. + +Ordinary tenant mutations — dashboards, teams, invitations, notifications, user +settings — are deliberately NOT gated. Whether a second factor belongs there is a +product decision about an operator's whole roster, not a property of this host. + +**Who the gated set actually reaches, which is wider than "privileged actor".** The +role, scope and principal grants admit an organization ADMIN as well as an owner, and +`requireMFA` is fail-closed only for a privileged actor — for everyone else it keeps +the pre-existing opt-in rule, which refuses an ENROLLED member without recent +evidence. So an enrolled org admin who has not stepped up recently is now refused on +`AssignRole`, `RevokeRole`, `GrantScope`, `RevokeScope`, the role CRUD and +`CreateAgentPrincipal`, where before this change nothing asked. That is intended — +granting authority inside a tenant is not an ordinary tenant mutation, whoever does +it — and it is stated here because an operator reading only the paragraph above would +not predict the refusal. An admin who has enrolled nothing is still admitted, so no +roster is forced to enrol by this change alone. + +Containment actions carry the same requirement: `SuspendUser`, `RevokeSession` and +`RevokePrincipal` need fresh factor evidence, so a responder acting on a compromise +steps up first. A runbook for those operations should say so, because the refusal +arrives at the least convenient moment by design. + ### Nice-to-have (differentiators) | Feature | This module | World-class | @@ -609,7 +641,7 @@ _All previously-open gaps closed 2026-04-25._ - ✅ **User identity endpoints unauthenticated** — `AddIdentity`, `FindUserByIdentity`, `ListUserIdentities` would let any authenticated caller enumerate provider identities or attach attacker-controlled identities to any user. Now gated by `requireSelfOrPlatformAdmin` / `requirePlatformAdmin`. - ✅ **gRPC server had no in-process auth interceptor** — handlers assumed the auth-gateway ext_authz check was in front; direct port hits bypassed auth. Added `grpcAuthInterceptor` mirroring the Connect interceptor (defense in depth: api validates the bearer regardless of upstream). - ✅ **Connect server had no CORS** — browser preflight returned 405; every Connect-Web request from the FE failed in production-style architectures. Added `rs/cors` middleware. -- ✅ **MFA is enforced and refresh-safe** — enrolled users receive no normal session until a durable one-use challenge succeeds. JWT/session evidence carries `amr`, `auth_time`, `acr`, and `mfa_at`; refresh preserves rather than renews that evidence. Refresh re-resolves verified enrollment: newly enrolled MFA terminates AAL1 refresh families and requires login, while removed MFA strips factor methods and downgrades the successor to AAL1. General sensitive operations apply the configured recent-AAL2 policy to enrolled users; money-moving billing checkout/portal is stricter and always requires fresh AAL2, so lack of enrollment is not a bypass. Passkeys require WebAuthn user verification with exact Codefly-configured RP/origin policy; complete credentials and ceremony state use Vault Transit envelopes. TOTP seeds are encrypted and recovery codes are one-use bcrypt hashes. +- ✅ **MFA is enforced and refresh-safe** — enrolled users receive no normal session until a durable one-use challenge succeeds. JWT/session evidence carries `amr`, `auth_time`, `acr`, and `mfa_at`; refresh preserves rather than renews that evidence. Refresh re-resolves verified enrollment: newly enrolled MFA terminates AAL1 refresh families and requires login, while removed MFA strips factor methods and downgrades the successor to AAL1. General sensitive operations apply the configured recent-AAL2 policy to enrolled users, and to every privileged actor whether enrolled or not — a platform role of any grade, or ownership of the request's organization — so for the principals whose compromise is the whole platform or the whole tenant, lack of enrolment is a refusal rather than a bypass. Money-moving billing checkout/portal is stricter still and always requires fresh AAL2. Passkeys require WebAuthn user verification with exact Codefly-configured RP/origin policy; complete credentials and ceremony state use Vault Transit envelopes. TOTP seeds are encrypted and recovery codes are one-use bcrypt hashes. - ✅ **API key scopes forwarded but not enforced** — the auth-gateway ext_authz check set `X-Scopes`; handlers ignored. New `requireScope(ctx, "resource:action")` gate with wildcard support (`*`, `users:*`, `*:read`). Applied to `ListUsers`, `UpdateUser`, `DeleteUser` as a starter set; extend to other resources per business needs (JWT-authenticated callers bypass — RBAC handles them). - ✅ **Access tokens not individually revocable** — Logout only killed the refresh chain; old access tokens stayed valid up to 15 min. New `auth.TokenRevoker` interface + `cache.NewTokenRevoker` Redis impl. `Logout(refresh, accessToken)` now calls `JWTMinter.RevokeAccess` which adds the jti to the revocation list with TTL = remaining `exp`. `VerifyAccess` consults the list. Falls back to `NoopTokenRevoker` (no Redis) → original behavior. - ✅ **Impersonation had no time limit** — admin "view-as" sessions inherited the normal 15-min TTL. New `Config.ImpersonationTokenTTL` (default 5 min) auto-applied when minting tokens with `acting` claim set. diff --git a/module/KEY_ROTATION.md b/module/KEY_ROTATION.md index 6c8a33f00..ebff432ff 100644 --- a/module/KEY_ROTATION.md +++ b/module/KEY_ROTATION.md @@ -246,6 +246,29 @@ the gateway never pins a key at boot: a gateway started mid-overlap loads the whole published set and accepts both keys regardless of the order the document lists them in. +## One key per purpose, delivered + +The signing key above is one key with one purpose. Everything else this module +signs or authenticates with carries its own **delivered** key, in a group of its +own, so rotating one rotates one thing. + +Two purposes were brought under that rule by the 2026-10-04 audit: the datasource +**content-ticket** key and the **account-link state** key. Both now come from +`datasource-keys` (`DATASOURCE_CONTENT_TICKET_KEY`, +`DATASOURCE_ACCOUNT_LINK_KEY`), a group only accounts declares, instead of being +derived from a shared credential. + +An absent key leaves its purpose unavailable rather than falling back to a +derivation, because a fallback would make the property conditional on nobody +forgetting. **Rotating one of these is a flag day for the tokens it signed** — an +outstanding content ticket does not survive it — which is why each purpose has its +own, so the day is one purpose wide. + +The sealing keys are a separate axis and are no longer one key: a stored value +names the service that sealed it and that service's own key, and the envelope key +is per organization. Rotating and moving those is the next two sections; this one +is about the keys a purpose is DELIVERED, which rotate on their own. + ## Rotating the envelope key Unlike the signing key, this is invisible to users and needs no overlap window: diff --git a/module/WORK_CONTEXTS.md b/module/WORK_CONTEXTS.md index e87f03a19..642e61361 100644 --- a/module/WORK_CONTEXTS.md +++ b/module/WORK_CONTEXTS.md @@ -252,6 +252,34 @@ what it has installed. The host makes no such check, so a producer that means a grant to cover one exact definition enforces that itself: the id here says which resource is meant, never which version of it. +## The reserved module audience + +`module-capabilities` is the audience a composed module's own capability carries, +and every peer that verifies it reads a context bearing it as "a module, with no +person present". That premise is what lets the module-facing surface skip the +questions it asks of a delegated actor. + +So the audience is mintable only from a module identity. **Every mint and +exchange that takes its audience from a caller's request refuses it** — the task +mint, the installation mint, a root or child session, the audience exchange and a +renewal. The refusal is the structural half of `requireMintableAudience`, which +needs no state and therefore holds through a registry outage. + +On the four mints a signed-in person reaches it runs before the caller's +authority is resolved, so the spelling is unreachable rather than merely +unreachable for this person. The other two reach it differently, and each for a +reason that is the point: the installation mint demands an internal service +credential first, so it is not a person-driven mint at all; and a renewal gates +the audience it RESOLVES, after authenticating, because a renewal naming none +inherits the parent's — gating the request field would let the reserved spelling +travel one generation further while the request named nothing. + +The module mints are unaffected: they pass the audience themselves rather than +reading it off a message, as does the internal delegated-read exchange, whose +audience comes from a declaration. `TestPersonMintsRefuseTheModuleAudience` and +`TestEveryRequestSuppliedAudienceRefusesTheReservedOne` hold this, the second so +a mint added later cannot skip the refusal silently. + ## Operation contexts with no person present Both exchanges above need a parent: somebody signed in, and the child is diff --git a/module/configurations/local/application.env b/module/configurations/local/application.env index e21f0a51f..586d72f66 100644 --- a/module/configurations/local/application.env +++ b/module/configurations/local/application.env @@ -1,19 +1,36 @@ -# Interactive links use the verified frontend origin propagated from Codefly's -# SDK-discovered endpoint. A background-only production origin may be supplied -# by deployment configuration; local development never pins a runtime port. -BOOTSTRAP_ADMIN_EMAIL= - -# APP_BASE_URL is this host's own public origin, and since issue #1003 it is -# also its OAuth 2.1 issuer: the `iss` of every access token AND the `issuer` of -# the RFC 8414 document published at -# /.well-known/oauth-authorization-server. RFC 8414 §2 requires -# the issuer to be an https URL, so the two can only be equal if this is set — -# a client that discovers the metadata and then verifies `iss` against it -# refuses a token naming anything else. +# APP_BASE_URL is the one origin this deployment treats as its own: what binds an +# OAuth redirect, the authenticator relying-party origin, every emailed link, and +# the frontend solution proxy's same-origin judgement. +# +# It is OPERATOR configuration, and nothing in a request can establish it. A +# deployed cell that has not set it refuses those surfaces rather than resolving +# an origin some other way: accounts refuses to start without it, and the frontend +# refuses to serve sign-in or the solution proxy. Local development pins no +# runtime port, so it is empty here and the frontend uses its own discovered +# loopback endpoint, which is the host the browser reaches anyway. +# +# Since issue #1003 it is also this host's OAuth 2.1 ISSUER: the `iss` of every +# access token and the `issuer` of the RFC 8414 document published at +# /.well-known/oauth-authorization-server. RFC 8414 §2 requires that +# issuer to be an https URL, so the two can only be equal if this is set — a +# client that discovers the metadata and then verifies `iss` against it refuses a +# token naming anything else. Unset (the default here) means no metadata is +# published and tokens keep the pre-metadata literal issuer, so the two cannot +# disagree. A deployment that wants an MCP client to discover it MUST set this; +# the gateway reads the same value to publish a solution's protected-resource +# metadata and to require a token's resource audience to name this host. # -# Unset (the default here) means no metadata is published and tokens keep the -# pre-metadata literal issuer, so the two cannot disagree. A deployment that -# wants an MCP client to discover it MUST set this; the gateway reads the same -# value to publish a solution's protected-resource metadata and to require a -# token's resource audience to name this host. +# Write it as a hostname or a dotted-quad address. A numeric spelling of an +# address (`https://2130706433`, `https://0x7f000001`, `https://127.1`) is +# REFUSED: the browser's URL parser re-renders those as the dotted quad while Go +# preserves what was written, so the two services would compare different values +# from this one line and every same-origin check would fail. +# +# DECLARED ONCE. This file carried it twice after a merge — one key under two +# spellings — and the configuration loader refuses that outright, which takes down +# every database-backed suite and the local run with it. APP_BASE_URL= + +# Interactive links use the origin above. A background-only production origin may +# be supplied by deployment configuration. +BOOTSTRAP_ADMIN_EMAIL= diff --git a/module/configurations/local/datasource-keys.secret.env b/module/configurations/local/datasource-keys.secret.env new file mode 100644 index 000000000..97df3f00a --- /dev/null +++ b/module/configurations/local/datasource-keys.secret.env @@ -0,0 +1,19 @@ +# The message-authentication keys the datasource paths sign with, one per purpose. +# +# The invariant (SP-SEC-07): each signing or message-authentication purpose has its +# own key, derived from no credential shared beyond its owner. These are DELIVERED +# per purpose rather than derived from a shared credential, so rotating one rotates +# one thing. +# +# Unset leaves the purpose it serves unavailable rather than falling back to a +# derivation, because a fallback would make the property conditional on nobody +# forgetting. Provision both on every cell. + +# Signs the opaque content tickets a change-set job carries in place of an +# oversized blob. The same value must be present wherever a ticket is redeemed. +DATASOURCE_CONTENT_TICKET_KEY=local-dev-only-replace-me + +# Signs the account-link state that carries a person through a provider's sign-in +# and back. Domain-separated from the ticket key above, so neither token can pass +# for the other. +DATASOURCE_ACCOUNT_LINK_KEY=local-dev-only-replace-me diff --git a/module/configurations/local/gateway.env b/module/configurations/local/gateway.env index 648f9c8a6..5395229ec 100644 --- a/module/configurations/local/gateway.env +++ b/module/configurations/local/gateway.env @@ -1,3 +1,10 @@ # Comma-separated exact IPs or CIDRs allowed to supply X-Forwarded-For and -# X-Real-IP. Empty means the gateway trusts no forwarding headers. +# X-Real-IP. Empty means the gateway trusts no forwarding headers — so the client +# address is always the peer, which behind a frontend hop is the frontend's own +# pod, and every rate-limit bucket collapses onto it. +# +# A DEPLOYED gateway therefore refuses to start on an empty value: a cell's +# ingress and frontend reach the gateway from known ranges, and naming them is +# what makes the anonymous and authentication-factor budgets per-client rather +# than per-cell. Empty is correct here and only here, where the peer IS the client. TRUSTED_PROXY_CIDRS= diff --git a/module/configurations/local/identity-provider.secret.env b/module/configurations/local/identity-provider.secret.env new file mode 100644 index 000000000..1c013f6af --- /dev/null +++ b/module/configurations/local/identity-provider.secret.env @@ -0,0 +1,21 @@ +# The identity provider's own credentials: the ones that authenticate THIS HOST to +# the provider, as distinct from the `identity` group's public facts about it (the +# issuer, the client id, the display name, the scope) which the browser receives +# anyway. +# +# They live in a group of their own because of who declares what. The frontend +# declares `identity` — it renders the login page from those public facts — and a +# deployment that puts the provider's credentials in that same group delivers them +# to the frontend too, which never reads either. A public-facing process holding +# the provider's administration credential widens what a frontend compromise is +# worth, for nothing. Only accounts declares this group. + +# The client secret the authorization-code exchange authenticates with. Required +# by every provider that performs that exchange; a real value is supplied through +# Codefly's secret configuration provider. +IDENTITY_CLIENT_SECRET=local-dev-only-replace-me + +# The provider's management credential, used by the optional administration portal +# adapter. Unset leaves that adapter in stub mode; it is never required for +# sign-in. +IDENTITY_MANAGEMENT_API_KEY=local-dev-only-replace-me diff --git a/module/configurations/local/identity.env b/module/configurations/local/identity.env index c2980660f..aa90054af 100644 --- a/module/configurations/local/identity.env +++ b/module/configurations/local/identity.env @@ -1,6 +1,14 @@ # The ordinary local profile uses a Codefly-selected fixture for identity. # The fixture name is data only; this configuration selects the authentication # adapter explicitly. Real-provider dogfooding uses the dogfood profile. +# +# IDENTITY_PROVIDER also decides which credential the login route accepts, and +# the modes do not overlap. Only `header-jwt` wires the validator that reads an +# identity assertion out of the gateway-injected header; in every other mode a +# request body carrying `header_jwt` is refused by name rather than validated, +# because a validator built for the authorization-code exchange checks no nonce — +# the nonce is that exchange's binding to a single authorize request and lives in +# the state it holds. IDENTITY_PROVIDER=fixture # IDENTITY_SIGNUP_MODE gates signup only; login and invite are never gated. diff --git a/module/contracts/api/accounts/authority/contract.binpb b/module/contracts/api/accounts/authority/contract.binpb index ad7e5fb9e..28bc29cbd 100644 Binary files a/module/contracts/api/accounts/authority/contract.binpb and b/module/contracts/api/accounts/authority/contract.binpb differ diff --git a/module/contracts/api/accounts/authority/proto/saas/accounts/v1/api_keys.proto b/module/contracts/api/accounts/authority/proto/saas/accounts/v1/api_keys.proto index 2b953c59f..8890e1ef9 100644 --- a/module/contracts/api/accounts/authority/proto/saas/accounts/v1/api_keys.proto +++ b/module/contracts/api/accounts/authority/proto/saas/accounts/v1/api_keys.proto @@ -112,7 +112,12 @@ service APIKeyService { target: RESOURCE_TARGET_ORGANIZATION lookup: RESOURCE_LOOKUP_DIRECT_ID } - mfa: MFA_REQUIREMENT_NONE + // Issuing a credential is a privilege-changing operation: the key it + // returns authenticates as its creator, for as long as it lives, without + // the session that asked for it. It declared MFA_REQUIREMENT_NONE while + // every other privilege-changing mutation on this host declared a factor, + // so an organization owner with no enrolled factor could mint one. + mfa: MFA_REQUIREMENT_IF_ENROLLED_RECENT_STEP_UP audit: { events: "saas.api_key.created" emission: AUDIT_EMISSION_SUCCESS diff --git a/module/contracts/api/accounts/authority/proto/saas/accounts/v1/installations.proto b/module/contracts/api/accounts/authority/proto/saas/accounts/v1/installations.proto index 9b70895db..bc9c0e277 100644 --- a/module/contracts/api/accounts/authority/proto/saas/accounts/v1/installations.proto +++ b/module/contracts/api/accounts/authority/proto/saas/accounts/v1/installations.proto @@ -131,7 +131,16 @@ message InstallSolutionRequest { ]; // The agent's delegated-execution ceiling (migration 108): the Work Context // audiences it may be minted for and the resource kinds it may ever touch. - // Empty = unrestricted. + // + // Empty = unrestricted FOR THIS INSTALLATION'S OWN WORK, which is what an + // administrator consented to when they installed it. It is NOT unrestricted + // for a mint made with a credential delivered to a THIRD PARTY on a person's + // behalf — an MCP client the person authorized for this one solution. There + // the installation is the only statement of what the solution may act for, so + // an empty list is read as nothing and such a mint is REFUSED by name + // (accounts' holdConfinedMintToItsConsumer). The two readings are opposite on + // purpose: one bounds a consumer an admin trusts, the other bounds a + // credential they never saw. repeated string allowed_audiences = 10 [ (buf.validate.field).repeated.max_items = 64, (buf.validate.field).repeated.items.string.max_len = 512 diff --git a/module/contracts/api/accounts/connect/contract.binpb b/module/contracts/api/accounts/connect/contract.binpb index ad7e5fb9e..28bc29cbd 100644 Binary files a/module/contracts/api/accounts/connect/contract.binpb and b/module/contracts/api/accounts/connect/contract.binpb differ diff --git a/module/contracts/api/accounts/connect/proto/saas/accounts/v1/api_keys.proto b/module/contracts/api/accounts/connect/proto/saas/accounts/v1/api_keys.proto index 2b953c59f..8890e1ef9 100644 --- a/module/contracts/api/accounts/connect/proto/saas/accounts/v1/api_keys.proto +++ b/module/contracts/api/accounts/connect/proto/saas/accounts/v1/api_keys.proto @@ -112,7 +112,12 @@ service APIKeyService { target: RESOURCE_TARGET_ORGANIZATION lookup: RESOURCE_LOOKUP_DIRECT_ID } - mfa: MFA_REQUIREMENT_NONE + // Issuing a credential is a privilege-changing operation: the key it + // returns authenticates as its creator, for as long as it lives, without + // the session that asked for it. It declared MFA_REQUIREMENT_NONE while + // every other privilege-changing mutation on this host declared a factor, + // so an organization owner with no enrolled factor could mint one. + mfa: MFA_REQUIREMENT_IF_ENROLLED_RECENT_STEP_UP audit: { events: "saas.api_key.created" emission: AUDIT_EMISSION_SUCCESS diff --git a/module/contracts/api/accounts/connect/proto/saas/accounts/v1/installations.proto b/module/contracts/api/accounts/connect/proto/saas/accounts/v1/installations.proto index 9b70895db..bc9c0e277 100644 --- a/module/contracts/api/accounts/connect/proto/saas/accounts/v1/installations.proto +++ b/module/contracts/api/accounts/connect/proto/saas/accounts/v1/installations.proto @@ -131,7 +131,16 @@ message InstallSolutionRequest { ]; // The agent's delegated-execution ceiling (migration 108): the Work Context // audiences it may be minted for and the resource kinds it may ever touch. - // Empty = unrestricted. + // + // Empty = unrestricted FOR THIS INSTALLATION'S OWN WORK, which is what an + // administrator consented to when they installed it. It is NOT unrestricted + // for a mint made with a credential delivered to a THIRD PARTY on a person's + // behalf — an MCP client the person authorized for this one solution. There + // the installation is the only statement of what the solution may act for, so + // an empty list is read as nothing and such a mint is REFUSED by name + // (accounts' holdConfinedMintToItsConsumer). The two readings are opposite on + // purpose: one bounds a consumer an admin trusts, the other bounds a + // credential they never saw. repeated string allowed_audiences = 10 [ (buf.validate.field).repeated.max_items = 64, (buf.validate.field).repeated.items.string.max_len = 512 diff --git a/module/contracts/api/catalog.codefly.json b/module/contracts/api/catalog.codefly.json index 8f336fbcd..e604cff85 100644 --- a/module/contracts/api/catalog.codefly.json +++ b/module/contracts/api/catalog.codefly.json @@ -10,7 +10,7 @@ "kind": "protobuf", "package": "saas.accounts.v1", "path": "contracts/api/accounts/authority/contract.binpb", - "digest": "sha256:e4fbe4fd6bbd4d025ec8b3978fca2afbe0a5597c9f01eacd2ee095305a1b61f4", + "digest": "sha256:045c67283ce0dbce7dc8ab3b21a9bb23b3e9d461fb51972853b6217128417869", "services": [ { "name": "ModuleCapabilitiesService", @@ -64,7 +64,7 @@ "kind": "protobuf", "package": "saas.accounts.v1", "path": "contracts/api/accounts/connect/contract.binpb", - "digest": "sha256:e4fbe4fd6bbd4d025ec8b3978fca2afbe0a5597c9f01eacd2ee095305a1b61f4", + "digest": "sha256:045c67283ce0dbce7dc8ab3b21a9bb23b3e9d461fb51972853b6217128417869", "services": [ { "name": "APIKeyService", diff --git a/module/module.package.codefly.yaml b/module/module.package.codefly.yaml index 25c14413a..d3d21ea7d 100644 --- a/module/module.package.codefly.yaml +++ b/module/module.package.codefly.yaml @@ -18,12 +18,12 @@ services: kind: protobuf package: saas.accounts.v1 path: contracts/api/accounts/authority/contract.binpb - digest: sha256:e4fbe4fd6bbd4d025ec8b3978fca2afbe0a5597c9f01eacd2ee095305a1b61f4 + digest: sha256:045c67283ce0dbce7dc8ab3b21a9bb23b3e9d461fb51972853b6217128417869 - endpoint: connect kind: protobuf package: saas.accounts.v1 path: contracts/api/accounts/connect/contract.binpb - digest: sha256:e4fbe4fd6bbd4d025ec8b3978fca2afbe0a5597c9f01eacd2ee095305a1b61f4 + digest: sha256:045c67283ce0dbce7dc8ab3b21a9bb23b3e9d461fb51972853b6217128417869 - name: auth-gateway endpoints: [grpc, rest] api-contracts: diff --git a/module/services/accounts/AUTHZ_MATRIX.md b/module/services/accounts/AUTHZ_MATRIX.md index 9846c83f7..cc38f73fe 100644 --- a/module/services/accounts/AUTHZ_MATRIX.md +++ b/module/services/accounts/AUTHZ_MATRIX.md @@ -8,7 +8,7 @@ The compact tier is retained for compatibility. Guards, resource bindings, audit | Procedure | Shape | REST mapping | Tier | Guards | Permission / scope | Resource bindings | Audit | Idempotency / rate | Sensitivity req → res | Description | |---|---|---|---|---|---|---|---|---|---|---| -| `/saas.accounts.v1.APIKeyService/CreateAPIKey` | unary | `POST /v1/api-keys` | `org_admin` | exposure=AUTHENTICATED; tenant=ORG_ADMIN; impersonation=FORBIDDEN | perm=api_keys:write | organization_id → ORGANIZATION/DIRECT_ID | SUCCESS: saas.api_key.created | FORBIDDEN / STANDARD_WRITE | CONFIDENTIAL → SECRET | Mint an API key for an administered organization. | +| `/saas.accounts.v1.APIKeyService/CreateAPIKey` | unary | `POST /v1/api-keys` | `mfa` | exposure=AUTHENTICATED; tenant=ORG_ADMIN; mfa=IF_ENROLLED_RECENT_STEP_UP; impersonation=FORBIDDEN | perm=api_keys:write | organization_id → ORGANIZATION/DIRECT_ID | SUCCESS: saas.api_key.created | FORBIDDEN / STANDARD_WRITE | CONFIDENTIAL → SECRET | Mint an API key for an administered organization. | | `/saas.accounts.v1.APIKeyService/ListAPIKeys` | unary | `GET /v1/api-keys` | `org_member` | exposure=AUTHENTICATED; tenant=ORG_MEMBER | perm=api_keys:read; scope=api_keys:read | organization_id → ORGANIZATION/DIRECT_ID | — | FORBIDDEN / STANDARD_READ | CONFIDENTIAL → CONFIDENTIAL | List org's API keys. | | `/saas.accounts.v1.APIKeyService/RevokeAPIKey` | unary | `DELETE /v1/api-keys/{id}` | `org_admin` | exposure=AUTHENTICATED; tenant=ORG_ADMIN; impersonation=FORBIDDEN | perm=api_keys:write; scope=api_keys:write | organization_id → ORGANIZATION/DIRECT_ID | SUCCESS: saas.api_key.revoked | FORBIDDEN / STANDARD_WRITE | CONFIDENTIAL → CONFIDENTIAL | Revoke an API key in an administered organization. | | `/saas.accounts.v1.APIKeyService/ValidateAPIKey` | unary | `—` | `internal` | exposure=INTERNAL; tenant=NONE | — | — | — | FORBIDDEN / INTERNAL | SECRET → CONFIDENTIAL | Internal: plaintext key → key + org id. | @@ -275,8 +275,8 @@ The compact tier is retained for compatibility. Guards, resource bindings, audit - `auth`: 43 - `internal`: 52 -- `mfa`: 4 -- `org_admin`: 62 +- `mfa`: 5 +- `org_admin`: 61 - `org_member`: 54 - `platform_admin`: 29 - `public`: 18 diff --git a/module/services/accounts/code/datasource_keys_test.go b/module/services/accounts/code/datasource_keys_test.go new file mode 100644 index 000000000..a2e9ecc75 --- /dev/null +++ b/module/services/accounts/code/datasource_keys_test.go @@ -0,0 +1,117 @@ +package main + +import ( + "os" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// The datasource message-authentication keys: that they come from their OWN +// configuration group, that an unusable value leaves its purpose unavailable rather +// than falling back, and that boot wires them from the runtime. +// +// These lived in identity_provider_migration_test.go, because that is where the +// workspace-key helper happened to be written. Nothing broke; the next person looking +// for datasource-key coverage did not find it where they looked (R1019-N12/f7). + +// The shipped local defaults for the datasource keys carry placeholder markers, and +// this module's repository is public — so a deployed runtime holding one would sign +// with a published value. It is treated as ABSENT, which leaves the purpose +// unavailable: the state minting and redemption already define, and the reason this +// does not refuse at boot the way a perimeter credential does. +func TestR1019DeployedDatasourceDefaultsRefused(t *testing.T) { + keys := []string{"DATASOURCE_CONTENT_TICKET_KEY", "DATASOURCE_ACCOUNT_LINK_KEY"} + for _, key := range keys { + // Two guards run here, and only the last case isolates the marker one: the + // two shipped values are shorter than the length floor, so removing the + // marker check still refuses them. A long marker-carrying value is what + // proves the marker check itself fires. + for _, shipped := range []string{ + "local-dev-only-replace-me", + "LOCAL-DEV-ONLY-REPLACE-ME", + "some-change-me-value-long-enough-to-pass-a-length-floor", + } { + t.Run(key+"/"+shipped[:12], func(t *testing.T) { + blankWorkspaceKey(t, "datasource-keys", key) + t.Setenv("CODEFLY__WORKSPACE_SECRET_CONFIGURATION__DATASOURCE_KEYS__"+key, shipped) + + require.Nil(t, usableDatasourceKey(key, false), + "a deployed runtime must not sign with a published value") + require.Equal(t, []byte(shipped), usableDatasourceKey(key, true), + "local development uses its shipped default") + }) + } + } +} + +// A short value is refused for the same reason the perimeter floor exists, and a +// provisioned one is used — so the refusal is the value and not a key that is never +// usable. +func TestR1019DatasourceKeyFloorAndProvisionedValue(t *testing.T) { + const key = "DATASOURCE_CONTENT_TICKET_KEY" + blankWorkspaceKey(t, "datasource-keys", key) + + t.Setenv("CODEFLY__WORKSPACE_SECRET_CONFIGURATION__DATASOURCE_KEYS__"+key, "7Kq2Xp9Vb4Nf") + require.Nil(t, usableDatasourceKey(key, false)) + + provisioned := strings.Repeat("7Kq2Xp9Vb4Nf", 4) + t.Setenv("CODEFLY__WORKSPACE_SECRET_CONFIGURATION__DATASOURCE_KEYS__"+key, provisioned) + require.Equal(t, []byte(provisioned), usableDatasourceKey(key, false)) +} + +// R1019-N15: replacing the datasource keys' wiring with the perimeter credential +// survived the whole accounts suite. Every existing assertion was about the +// DERIVATION — that two keys differ, that a placeholder is refused — and none about +// WHERE the value comes from, so substituting another provisioned secret changed +// nothing any test looked at. +// +// This asserts the source: each key is read from the `datasource-keys` group, by its +// own name. The internal token is a provisioned 32+ character value with no +// placeholder marker, so it passes every other check in usableDatasourceKey — the +// group is the only thing that distinguishes it. +func TestR1019DatasourceKeysComeFromTheirOwnGroup(t *testing.T) { + // A deployed runtime with the perimeter credential provisioned and the datasource + // group blank. If the wiring read the perimeter credential, the purposes would be + // available; they must not be. + for _, key := range []string{"CODEFLY_INTERNAL_TOKEN", "CODEFLY_GATEWAY_TOKEN"} { + t.Setenv("CODEFLY__WORKSPACE_SECRET_CONFIGURATION__INTERNAL_AUTH__"+key, + strings.Repeat("7Kq2Xp9Vb4Nf", 4)) + t.Setenv("CODEFLY__WORKSPACE_SECRET_CONFIGURATION__GATEWAY_TRUST__"+key, + strings.Repeat("7Kq2Xp9Vb4Nf", 4)) + t.Setenv(key, strings.Repeat("7Kq2Xp9Vb4Nf", 4)) + } + for _, key := range []string{"DATASOURCE_CONTENT_TICKET_KEY", "DATASOURCE_ACCOUNT_LINK_KEY"} { + blankWorkspaceKey(t, "datasource-keys", key) + require.Nil(t, usableDatasourceKey(key, false), + "%s is absent from its own group, so its purpose must be unavailable — a "+ + "provisioned credential from another group is not this key", key) + } + + // And the converse: provisioned in its own group, it resolves. Without this the + // assertions above would pass for a function that always returned nil. + for _, key := range []string{"DATASOURCE_CONTENT_TICKET_KEY", "DATASOURCE_ACCOUNT_LINK_KEY"} { + t.Setenv("CODEFLY__WORKSPACE_SECRET_CONFIGURATION__DATASOURCE_KEYS__"+key, + strings.Repeat("9Zr4Lm7Td2Wc", 4)) + require.Equal(t, []byte(strings.Repeat("9Zr4Lm7Td2Wc", 4)), usableDatasourceKey(key, false), + "%s provisioned in datasource-keys must resolve", key) + } +} + +// The wiring itself, from the source: boot must pass each key through +// usableDatasourceKey with the deployment's own local flag. A key wired from a +// constant, or from another group's value, is the same defect as not wiring it. +func TestR1019DatasourceKeyWiringIsReadFromItsGroup(t *testing.T) { + source, err := os.ReadFile("work.go") + require.NoError(t, err) + text := string(source) + + for _, key := range []string{"DATASOURCE_CONTENT_TICKET_KEY", "DATASOURCE_ACCOUNT_LINK_KEY"} { + require.Contains(t, text, + `usableDatasourceKey("`+key+`", codefly.IsLocal())`, + "%s must be wired through usableDatasourceKey from the runtime's own local flag", key) + } + require.Contains(t, text, `workspaceEnv("datasource-keys", key)`, + "the keys must be read from the datasource-keys group, not from another group's credential") +} diff --git a/module/services/accounts/code/identity_provider_migration_test.go b/module/services/accounts/code/identity_provider_migration_test.go new file mode 100644 index 000000000..eaf6f530b --- /dev/null +++ b/module/services/accounts/code/identity_provider_migration_test.go @@ -0,0 +1,66 @@ +package main + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// A credential moving between configuration groups must not treat the DESTINATION +// group's shipped placeholder as the destination being ready: that defeats the +// fallback exactly on the cells that still deliver the value under the old group, +// which are the only ones the fallback exists for. +func TestR1019IdentityProviderMigration(t *testing.T) { + const provisioned = "an-operator-supplied-value-of-sufficient-length" + + for _, key := range []string{"IDENTITY_CLIENT_SECRET", "IDENTITY_MANAGEMENT_API_KEY"} { + t.Run(key, func(t *testing.T) { + blankWorkspaceKey(t, "identity-provider", key) + blankWorkspaceKey(t, "identity", key) + + // The cell has not migrated: the destination holds only the shipped + // placeholder, the legacy group holds the real value. + t.Setenv("CODEFLY__WORKSPACE_SECRET_CONFIGURATION__IDENTITY_PROVIDER__"+key, + "local-dev-only-replace-me") + t.Setenv("CODEFLY__WORKSPACE_SECRET_CONFIGURATION__IDENTITY__"+key, provisioned) + require.Equal(t, provisioned, identityProviderSecret(key), + "a shipped placeholder in the destination must not override a provisioned legacy value") + + // The cell has migrated: the destination wins. + t.Setenv("CODEFLY__WORKSPACE_SECRET_CONFIGURATION__IDENTITY_PROVIDER__"+key, + "the-migrated-"+key) + require.Equal(t, "the-migrated-"+key, identityProviderSecret(key)) + }) + } +} + +// The selection rule itself, in both directions, so the test above cannot pass by +// always preferring the legacy group. +func TestR1019ProvisionedCredentialExcludesShippedPlaceholders(t *testing.T) { + for _, shipped := range []string{ + "local-dev-only-replace-me", + "LOCAL-DEV-ONLY-REPLACE-ME", + "change-me", + "a-placeholder", + "REPLACE_ME", + "", + " ", + } { + require.False(t, isProvisionedCredential(shipped), + "%q is not an operator-supplied value", shipped) + } + require.True(t, isProvisionedCredential(strings.Repeat("7Kq2Xp9Vb4Nf", 3))) +} + +// Local development runs on the shipped defaults, and must still resolve to one — +// from the group this service now declares. +func TestR1019LocalDevelopmentKeepsTheShippedPlaceholder(t *testing.T) { + const key = "IDENTITY_CLIENT_SECRET" + blankWorkspaceKey(t, "identity-provider", key) + blankWorkspaceKey(t, "identity", key) + t.Setenv("CODEFLY__WORKSPACE_SECRET_CONFIGURATION__IDENTITY_PROVIDER__"+key, + "local-dev-only-replace-me") + + require.Equal(t, "local-dev-only-replace-me", identityProviderSecret(key)) +} diff --git a/module/services/accounts/code/pkg/adapters/api_key_scope_admission_test.go b/module/services/accounts/code/pkg/adapters/api_key_scope_admission_test.go index 98d43d3aa..b7f591ddf 100644 --- a/module/services/accounts/code/pkg/adapters/api_key_scope_admission_test.go +++ b/module/services/accounts/code/pkg/adapters/api_key_scope_admission_test.go @@ -275,6 +275,23 @@ func (s *apiKeyMintStore) GetOrgMembership(context.Context, string, string) (*ge return &gen.OrgMembership{UserId: scopeAdmissionUserID.String(), Role: gen.OrgRole_ORG_ROLE_ADMIN}, nil } +// The factor lookup. Credential issuance sits behind a recent second factor like +// every other privilege-changing mutation, so the handler consults the actor's +// enrolment after the authorization gates — and this fake has to answer it or +// the handler panics on the embedded nil interface before reaching the domain. +// +// Not enrolled, which is the state that PROCEEDS for an ordinary org admin: the +// gate is fail-closed only for a privileged actor (a platform role, or the +// organization's owner), and this membership is an admin. That keeps these cases +// asserting what they are about — the admission gates — rather than the factor. +func (s *apiKeyMintStore) WithUserTx(ctx context.Context, _ string, fn func(context.Context) error) error { + return fn(ctx) +} + +func (s *apiKeyMintStore) HasVerifiedMFA(context.Context, string) (bool, error) { + return false, nil +} + // Only an interactive session creates API keys. A key that could would mint a // copy of itself with no expiry — accounts cannot even see the calling key's // own — so CreateAPIKey declares no API-key scope, and the handler refuses a key @@ -380,7 +397,12 @@ func TestAPIKeyCannotCreateAPIKeys(t *testing.T) { reads, err := create(tc.caller, requesting(tc.requested...)) require.Error(t, err) require.NotEqual(t, codes.PermissionDenied, status.Code(err), "%v", err) - require.Equal(t, 1, reads) + // The organization WAS consulted, which is the property here. Not how + // many times: the handler reads the membership for the admin gate and + // again for the factor gate's privileged-role check, and pinning the + // count made this assert an implementation detail instead. The + // refusal cases above carry the other half with require.Zero. + require.NotZero(t, reads, "the organization-admin gate must have run") }) } } diff --git a/module/services/accounts/code/pkg/adapters/auth.go b/module/services/accounts/code/pkg/adapters/auth.go index 99dc05262..5d2c6dce7 100644 --- a/module/services/accounts/code/pkg/adapters/auth.go +++ b/module/services/accounts/code/pkg/adapters/auth.go @@ -878,18 +878,29 @@ func parseUnixTimestamp(raw string) time.Time { return time.Unix(seconds, 0) } -// requireMFA gates sensitive operations behind a satisfied MFA challenge -// when the actor has at least one MFA device enrolled. Users who never -// set up MFA still pass — enforcement is opt-in per user, not platform- -// wide. Returns codes.FailedPrecondition with the canonical -// "mfa_required" reason so frontends can branch on it without parsing -// strings. +// requireMFA gates sensitive operations behind a satisfied MFA challenge. +// Returns codes.FailedPrecondition with the canonical "mfa_required" reason so +// frontends can branch on it without parsing strings. // // Apply to: anything that, if hijacked, would let an attacker pivot to // admin scope, exfiltrate data, or move money. Concretely: billing // checkout/portal, role grants, GDPR delete, impersonation, API key // creation with broad scopes. // +// There are two populations, and the difference is what enrolment means for each. +// +// For a PRIVILEGED actor — any platform role, or ownership of the organization +// the request names — not being enrolled is not a pass. A privileged actor without +// recent AAL2 evidence is refused, which is what makes "a privileged principal +// holds a second factor" enforceable at all: for these operations the alternative +// to enrolling is not reaching them. The principal whose compromise is the whole +// platform or the whole tenant is the one that must not be able to opt out. +// +// For every other member the gate stays opt-in per user: enrolled means a recent +// factor is required, unenrolled means the operation proceeds and the audit row +// records mfa_satisfied=false. Making it unconditional there is an operator's +// policy decision about their whole roster, not a property of the host. +// // Refresh does not update MFAVerifiedAt. An enrolled user must therefore // have explicit AAL2 evidence no older than DefaultRecentStepUpMaxAge; // otherwise the frontend asks them to authenticate again. @@ -897,6 +908,13 @@ func requireMFA(ctx context.Context, actorID string) error { if auth.AssuranceFromContext(ctx).HasRecentMFA(time.Now(), recentStepUpMaxAge) { return nil } + privileged, err := actorHoldsPrivilegedRole(ctx, actorID) + if err != nil { + return err + } + if privileged { + return status.Error(codes.FailedPrecondition, "mfa_required") + } // mfa_devices is RLS-protected by user_id (Phase 2G); wrap in // the actor's WithUserTx so the count query sees their devices. var enrolled bool @@ -910,15 +928,47 @@ func requireMFA(ctx context.Context, actorID string) error { return status.Errorf(codes.Internal, "cannot verify MFA status: %v", err) } if !enrolled { - // No verified MFA device → can't enforce. Sensitive ops still - // run; audit captures mfa_satisfied=false. Operators who want - // to force-enroll go through a separate "MFA required" feature - // flag at the org level (not yet implemented). return nil } return status.Error(codes.FailedPrecondition, "mfa_required") } +// actorHoldsPrivilegedRole reports whether this actor is one whose sensitive +// operations must sit behind a second factor whatever they have enrolled: a +// platform administrator of any grade, or an owner of the organization the +// request names. +// +// Org ADMIN is deliberately not included. Owner is the role that cannot be +// removed by anyone else in the tenant and that carries the tenant's own +// lifecycle; admin is a delegated role an owner grants and withdraws, and +// sweeping it in here would change the gate for a population an operator did not +// choose. An operator who wants admins covered says so through org policy. +// +// A failure to resolve either role refuses: this runs only on the path to a +// sensitive operation, where not knowing is not a reason to proceed. +func actorHoldsPrivilegedRole(ctx context.Context, actorID string) (bool, error) { + role, err := platformRole(ctx, actorID) + if err != nil { + return false, status.Errorf(codes.Internal, "cannot resolve platform role: %v", err) + } + if role != "" { + return true, nil + } + // The organization comes from the verified identity the auth interceptor + // installed, not from the request message or a log field: it is the same + // tenant the membership read is scoped to, so the two cannot disagree. A + // request with no tenant has no owner role to hold. + orgID, _, ok := auth.VerifiedDatabaseIdentity(ctx) + if !ok || orgID == "" { + return false, nil + } + orgRole, err := lookupMembership(ctx, orgID, actorID) + if err != nil { + return false, membershipLookupStatus("cannot verify membership", err) + } + return orgRole == gen.OrgRole_ORG_ROLE_OWNER.String(), nil +} + // requireRecentMFA is the strict step-up gate for money-moving operations. // Unlike the general opt-in requireMFA helper, lack of enrollment does not // bypass this check: billing administrators must establish fresh AAL2 evidence. diff --git a/module/services/accounts/code/pkg/adapters/auth_error_surface_test.go b/module/services/accounts/code/pkg/adapters/auth_error_surface_test.go new file mode 100644 index 000000000..2835f9fe2 --- /dev/null +++ b/module/services/accounts/code/pkg/adapters/auth_error_surface_test.go @@ -0,0 +1,322 @@ +package adapters + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + + "accounts/pkg/auth" + "accounts/pkg/business" + gen "accounts/pkg/gen/saas/accounts/v1" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" +) + +// A refused refresh is the caller's credential being spent, not this host +// failing. The register's reproducer is a garbage refresh cookie: the session +// store answers ErrRefreshRevoked for every rejection it can reach (unknown +// token, revoked family, expired or idled-out session), the business layer +// wrapped it, and the wrapped chain left the adapter as an unmapped error — so +// the edge answered HTTP 500 carrying the internal call path, and the single +// most ordinary thing a browser can present counted as a server error. +type refusingRefreshMinter struct { + auth.JWTMinter + err error +} + +func (m *refusingRefreshMinter) VerifyRefresh(context.Context, string) (*auth.TokenPair, error) { + return nil, m.err +} + +type authErrorSurfaceStore struct{ business.Store } + +func installRefusingRefreshMinter(t *testing.T, err error) { + t.Helper() + previous := service + t.Cleanup(func() { service = previous }) + + svc, svcErr := business.NewService(&authErrorSurfaceStore{}) + require.NoError(t, svcErr) + svc.SetJWTMinter(&refusingRefreshMinter{err: err}) + WithService(svc) +} + +func TestRefreshRefusalIsUnauthenticatedWithNoInternalDetail(t *testing.T) { + for _, tc := range []struct { + name string + err error + }{ + {"an unknown or revoked token", auth.ErrRefreshRevoked}, + {"a reused token", auth.ErrRefreshReuse}, + } { + t.Run(tc.name, func(t *testing.T) { + installRefusingRefreshMinter(t, tc.err) + + resp, err := (&AuthServer{}).RefreshToken(context.Background(), + &gen.RefreshTokenRequest{RefreshToken: "not-a-refresh-token"}) + + require.Nil(t, resp) + require.Equal(t, codes.Unauthenticated, status.Code(err), + "a refused refresh must not read as a server error") + message := status.Convert(err).Message() + require.Equal(t, "invalid refresh token", message) + for _, leak := range []string{"rpc error", "RefreshToken", "verify refresh", "ed25519minter", "auth:"} { + require.NotContains(t, message, leak, + "the public reason must name no internal component or error chain") + } + }) + } +} + +// The counterpart: something that is NOT a refusal still reaches the caller as +// itself, so the mapping above cannot be satisfied by turning every refresh +// failure into a credential problem and sending an operator to the wrong half. +func TestRefreshInfrastructureFailureIsNotReportedAsACredentialProblem(t *testing.T) { + installRefusingRefreshMinter(t, auth.ErrSessionUnavailable) + + _, err := (&AuthServer{}).RefreshToken(context.Background(), + &gen.RefreshTokenRequest{RefreshToken: "not-a-refresh-token"}) + + require.Error(t, err) + require.NotEqual(t, codes.Unauthenticated, status.Code(err)) +} + +// BeginOAuth is reached by an unauthenticated caller, so its refusal answers a +// stable public reason. It echoed the wrapped internal chain, which named the +// call path; the probe that found it read `rpc error` back out of the body. +func TestBeginOAuthRefusalNamesNoInternalDetail(t *testing.T) { + previous := service + t.Cleanup(func() { service = previous }) + svc, err := business.NewService(&authErrorSurfaceStore{}) + require.NoError(t, err) + WithService(svc) + + resp, err := (&AuthServer{}).BeginOAuth(context.Background(), &gen.BeginOAuthRequest{ + Provider: "oidc", + RedirectUri: "https://evil.example/auth/callback", + }) + + require.Nil(t, resp) + require.Error(t, err) + message := status.Convert(err).Message() + for _, leak := range []string{"rpc error", "cannot mint oauth state", "BeginOAuth", "oauth-state"} { + require.NotContains(t, message, leak, + "the public reason must name no internal component or error chain") + } + require.False(t, strings.Contains(message, "auth: "), + "the sentinel's own text is internal detail") +} + +// A sign-out is acknowledged only when every revocation held. Reporting success +// while the access half failed tells the caller their session ended when it has +// not, which is the one answer a sign-out must never give. +type partiallyRevokingMinter struct { + auth.JWTMinter + accessErr error + sessionErr error + familyErr error + revoked []string +} + +func (m *partiallyRevokingMinter) RevokeAccess(context.Context, string) error { + m.revoked = append(m.revoked, "access") + return m.accessErr +} + +func (m *partiallyRevokingMinter) RevokeSessionAccess(context.Context, string) error { + m.revoked = append(m.revoked, "session") + return m.sessionErr +} + +func (m *partiallyRevokingMinter) Revoke(context.Context, string) error { + m.revoked = append(m.revoked, "family") + return m.familyErr +} + +func TestR1019LogoutRequiresCompleteRevocation(t *testing.T) { + boom := errors.New("revocation store unavailable") + for _, tc := range []struct { + name string + accessErr error + sessionErr error + }{ + {"the access marker fails", boom, nil}, + {"the session marker fails", nil, boom}, + {"both fail", boom, boom}, + } { + t.Run(tc.name, func(t *testing.T) { + minter := &partiallyRevokingMinter{accessErr: tc.accessErr, sessionErr: tc.sessionErr} + installLogoutMinter(t, minter) + + _, err := (&AuthServer{}).Logout(logoutContext(), &gen.LogoutRequest{RefreshToken: "rt"}) + + require.Error(t, err, "an incomplete sign-out must not be reported as success") + require.Equal(t, codes.Unavailable, status.Code(err)) + require.Contains(t, minter.revoked, "family", + "the durable half must still be attempted and kept") + }) + } +} + +// The healthy control: every revocation succeeds and the caller is told so. +func TestR1019LogoutSucceedsWhenRevocationIsComplete(t *testing.T) { + minter := &partiallyRevokingMinter{} + installLogoutMinter(t, minter) + + _, err := (&AuthServer{}).Logout(logoutContext(), &gen.LogoutRequest{RefreshToken: "rt"}) + + require.NoError(t, err) + require.Equal(t, []string{"access", "session", "family"}, minter.revoked) +} + +// A failed FAMILY revocation is the durable half failing, and reaches the caller as +// itself rather than as the incomplete-sign-out reason. +func TestR1019LogoutFamilyFailureIsNotMaskedAsIncomplete(t *testing.T) { + minter := &partiallyRevokingMinter{familyErr: errors.New("session store unavailable")} + installLogoutMinter(t, minter) + + _, err := (&AuthServer{}).Logout(logoutContext(), &gen.LogoutRequest{RefreshToken: "rt"}) + + require.Error(t, err) + require.NotEqual(t, codes.Unavailable, status.Code(err)) +} + +func installLogoutMinter(t *testing.T, minter auth.JWTMinter) { + t.Helper() + previous := service + t.Cleanup(func() { service = previous }) + svc, err := business.NewService(&authErrorSurfaceStore{}) + require.NoError(t, err) + svc.SetJWTMinter(minter) + WithService(svc) +} + +// A caller with a verified session and a bearer, so both access revocations run. +func logoutContext() context.Context { + ctx := metadata.NewIncomingContext(context.Background(), + metadata.Pairs("authorization", "Bearer an-access-token")) + return auth.WithVerifiedSessionID(ctx, logoutSessionID) +} + +var logoutSessionID = uuid.MustParse("00000000-0000-4000-8000-0000000000c1") + +// SP-GW-13: a failed request carries a stable public reason and the cause is logged, +// not returned. Only the recognized refusals were sanitized, so an injected storage +// failure returned its internal address and wrapped call chain. +func TestR1019RefreshFailureHasPublicReason(t *testing.T) { + installRefusingRefreshMinter(t, errors.New( + "dial tcp 10.4.0.9:5432: connect: connection refused (session_store.RotateRefresh)")) + + _, err := (&AuthServer{}).RefreshToken(context.Background(), + &gen.RefreshTokenRequest{RefreshToken: "a-refresh-token"}) + + require.Error(t, err) + require.Equal(t, codes.Unavailable, status.Code(err), + "a host-side failure is retryable, not a credential problem") + message := status.Convert(err).Message() + require.Equal(t, "refresh temporarily unavailable", message) + for _, leak := range []string{"10.4.0.9", "5432", "session_store", "dial tcp", "rpc error"} { + require.NotContains(t, message, leak, + "the public reason must name no internal address or call chain") + } +} + +// A deliberate status from further in keeps its own reason, so the mapping above does +// not flatten every answer into one. +func TestR1019RefreshKeepsADeliberateStatus(t *testing.T) { + installRefusingRefreshMinter(t, + status.Error(codes.FailedPrecondition, "device session is no longer active")) + + _, err := (&AuthServer{}).RefreshToken(context.Background(), + &gen.RefreshTokenRequest{RefreshToken: "a-refresh-token"}) + + require.Equal(t, codes.FailedPrecondition, status.Code(err)) + require.Equal(t, "device session is no longer active", status.Convert(err).Message()) +} + +// R1019-N10: being a status does not establish that its message is public-safe. A +// code that answers "this host failed" carries whatever the failing layer said, so +// the pass-through is restricted to codes that answer the CALLER. Each case here is +// a typed status whose message is exactly the internal detail a caller must not see. +func TestR1019RefreshSanitizesATypedHostFailure(t *testing.T) { + internal := "pgx: dial tcp 10.4.0.9:5432 failed in session_store.RotateRefresh" + for _, injected := range []struct { + name string + err error + }{ + {"Internal", status.Error(codes.Internal, internal)}, + {"Unavailable", status.Error(codes.Unavailable, internal)}, + {"Unknown", status.Error(codes.Unknown, internal)}, + {"DataLoss", status.Error(codes.DataLoss, internal)}, + {"Aborted", status.Error(codes.Aborted, internal)}, + {"DeadlineExceeded", status.Error(codes.DeadlineExceeded, internal)}, + {"wrapped Internal", fmt.Errorf("rotate refresh: %w", status.Error(codes.Internal, internal))}, + } { + t.Run(injected.name, func(t *testing.T) { + installRefusingRefreshMinter(t, injected.err) + + _, err := (&AuthServer{}).RefreshToken(context.Background(), + &gen.RefreshTokenRequest{RefreshToken: "a-refresh-token"}) + + require.Error(t, err) + require.Equal(t, codes.Unavailable, status.Code(err)) + message := status.Convert(err).Message() + require.Equal(t, "refresh temporarily unavailable", message) + for _, leak := range []string{"10.4.0.9", "5432", "session_store", "pgx", "dial tcp"} { + require.NotContains(t, message, leak, + "a typed host failure must not carry its own message out") + } + }) + } +} + +// Every code the pass-through admits, so narrowing the set to one code — or widening +// it back to "any status" — is detected. A refusal's own reason is the answer to the +// caller, and flattening these into one message would make a real policy refusal +// unreadable. +func TestR1019RefreshKeepsEveryCallerFacingRefusal(t *testing.T) { + for _, admitted := range []codes.Code{ + codes.Unauthenticated, codes.PermissionDenied, codes.FailedPrecondition, + codes.InvalidArgument, codes.NotFound, codes.AlreadyExists, codes.OutOfRange, + } { + t.Run(admitted.String(), func(t *testing.T) { + installRefusingRefreshMinter(t, status.Error(admitted, "the session is no longer active")) + + _, err := (&AuthServer{}).RefreshToken(context.Background(), + &gen.RefreshTokenRequest{RefreshToken: "a-refresh-token"}) + + require.Equal(t, admitted, status.Code(err)) + require.Equal(t, "the session is no longer active", status.Convert(err).Message()) + }) + } +} + +// An OK-coded status is a failure that must not read as a success. +// (*status.Status).Err() returns nil for OK, so admitting one would return a nil error +// beside a nil response and the caller would act on a refresh that never happened. +// +// status.Error(codes.OK, "") is itself nil and cannot be injected, so the carrier is +// written out: errors.As matches any type implementing GRPCStatus(), which is what +// makes the degenerate case reachable at all. +type okStatusError struct{} + +func (okStatusError) Error() string { return "a failure whose status is OK" } + +func (okStatusError) GRPCStatus() *status.Status { return status.New(codes.OK, "") } + +func TestR1019RefreshDoesNotTurnAFailureIntoASuccess(t *testing.T) { + installRefusingRefreshMinter(t, okStatusError{}) + + resp, err := (&AuthServer{}).RefreshToken(context.Background(), + &gen.RefreshTokenRequest{RefreshToken: "a-refresh-token"}) + + require.Error(t, err, "a failure must never leave the handler as a nil error") + require.Nil(t, resp) + require.Equal(t, codes.Unavailable, status.Code(err)) +} diff --git a/module/services/accounts/code/pkg/adapters/connect_auth_interceptor.go b/module/services/accounts/code/pkg/adapters/connect_auth_interceptor.go index bcae3f343..f973e15f0 100644 --- a/module/services/accounts/code/pkg/adapters/connect_auth_interceptor.go +++ b/module/services/accounts/code/pkg/adapters/connect_auth_interceptor.go @@ -34,10 +34,33 @@ var forwardedIdentityHeaders = []string{ // the gateway token is valid, refused if either arrives twice. "X-Codefly-Solution-Id", "X-Codefly-Solution-Publisher", + // The resource a credential is confined to, and the installation the + // perimeter admitted the request under. Together they are the ceiling a + // resource-confined Work Context mint is held to (pkg/auth's + // resource_confinement.go carries the whole argument), so a caller able to + // set either would pick its own: a confinement naming a consumer whose + // installation grants more, or no confinement at all. + // + // They belong on this list for the same reason X-Org-Id does — stripped + // unless the gateway token is valid, refused if either arrives twice — + // which is also what forwards them across the REST transcoding hop. + resourceConfinementHeader, + installationHeader, } const publicOriginHeader = "X-Codefly-Public-Origin" +// resourceConfinementHeader and installationHeader MUST stay byte-identical to +// the gateway's own constants (`gateway.go`, lowercased). The strip +// canonicalises its argument, so a near-miss spelling deletes a header nobody +// sends and leaves the real one in place, reporting no error at all — the exact +// shape of the `x-codefly-solution-identity` defect recorded beside those +// constants. A contract test in module/tools holds the two spellings together. +const ( + resourceConfinementHeader = "X-Codefly-Resource" + installationHeader = "X-Codefly-Installation-Id" +) + // errSolutionAttestationNotDelivered refuses a request that asserts a solution // identity. Nothing on this host can prove such a claim: the gateway stamps // neither header, and the attestation that would replace the deleted @@ -327,6 +350,8 @@ func stampForwardedHTTPIdentity(ctx context.Context, headers http.Header) (conte if scopedRoles := headers.Get("X-Scoped-Roles"); scopedRoles != "" { ctx = withScopedRoles(ctx, parseScopedRoles(scopedRoles)) } + ctx = auth.WithVerifiedResourceConfinement(ctx, headers.Get(resourceConfinementHeader)) + ctx = auth.WithVerifiedInstallation(ctx, headers.Get(installationHeader)) return withScopedRolesTruncated(ctx, headers.Get("X-Scoped-Roles-Truncated") == "true"), nil } diff --git a/module/services/accounts/code/pkg/adapters/connect_handlers.go b/module/services/accounts/code/pkg/adapters/connect_handlers.go index f16532028..0ea4b8da9 100644 --- a/module/services/accounts/code/pkg/adapters/connect_handlers.go +++ b/module/services/accounts/code/pkg/adapters/connect_handlers.go @@ -4,12 +4,15 @@ import ( "context" "errors" "net/http" + "strings" "time" "connectrpc.com/connect" "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoreflect" "google.golang.org/protobuf/types/known/emptypb" "google.golang.org/protobuf/types/known/timestamppb" @@ -68,6 +71,86 @@ func unary[Req, Resp any]( return connect.NewResponse(resp), nil } +// unaryCookieingRefreshToken serves a method that completes an authentication: +// it moves the minted refresh credential out of the response body and into the +// browser's cookie, exactly as the REST middleware does for the same methods. +// +// The invariant: the refresh credential reaches the browser only as a scoped, +// non-script-readable, transport-protected, same-site cookie, and never as +// readable content on a surface that completes an authentication (SP-IDENT-07). +// The Connect surface serves the same browser as REST, so it owes the same +// carrier. +// +// A caller that needs a refresh token in a body is a registered OAuth client using +// the token endpoint, a separate contract with its own audience and registry — see +// oauthTokenEndpointRESTPath for that exemption and its reason. +func unaryCookieingRefreshToken[Req, Resp any]( + ctx context.Context, + req *connect.Request[Req], + fn func(context.Context, *Req) (*Resp, error), +) (*connect.Response[Resp], error) { + resp, err := unary(ctx, req, fn) + if err != nil { + return nil, err + } + if message, ok := any(resp.Msg).(proto.Message); ok { + cookieTheRefreshToken(resp.Header(), message) + } + return resp, nil +} + +// refreshTokenFromCookie reads the refresh credential out of a Connect request's +// Cookie header and into the message, when the message does not already carry one. +// +// It is the request half of the cookie carrier, and without it the response half is +// a dead end: authentication stops returning the credential as readable content, so +// a Connect client has nowhere to read it from, and the refresh and logout calls +// that need it then fail field validation before reaching the verifier. A carrier +// that only works in one direction is not a carrier. +// +// The body still wins when it carries a token: the cookie is the browser's +// convenience, not an override of an explicit credential — the same precedence the +// REST middleware uses. +func refreshTokenFromCookie(header http.Header, current string, set func(string)) { + if strings.TrimSpace(current) != "" { + return + } + for _, cookie := range (&http.Request{Header: header}).Cookies() { + if cookie.Name == refreshTokenCookieName && cookie.Value != "" { + set(cookie.Value) + return + } + } +} + +// connectHandlersExemptFromTheRefreshCookie are the authentication-completing +// Connect methods whose response body carries the refresh credential by +// contract. One entry: the OAuth 2.1 token endpoint (see +// oauthTokenEndpointRESTPath). The coverage test requires the set to be exactly +// this, so an exemption cannot be added without being read. +var connectHandlersExemptFromTheRefreshCookie = map[string]bool{ + "ExchangeClientToken": true, +} + +// cookieTheRefreshToken clears a message's refresh_token field, if it has one +// carrying a value, and sets the same cookie the REST path sets. Reflection +// rather than a per-message setter: the field is named identically on every +// authentication-completing response, and a response type added later is covered +// without being enumerated here. +func cookieTheRefreshToken(header http.Header, message proto.Message) { + reflected := message.ProtoReflect() + field := reflected.Descriptor().Fields().ByName("refresh_token") + if field == nil || field.Kind() != protoreflect.StringKind { + return + } + token := reflected.Get(field).String() + if token == "" { + return + } + reflected.Clear(field) + header.Add("Set-Cookie", refreshCookie(token, refreshCookieMaxAgeSeconds).String()) +} + // translateGRPCError converts a google.golang.org/grpc/status error // into a connect.Error with the matching code. Pass-through for // errors that are already connect.Errors or don't carry a gRPC status. @@ -470,7 +553,7 @@ func (h *authConnectHandler) BeginOAuth(ctx context.Context, req *connect.Reques func (h *authConnectHandler) Authenticate(ctx context.Context, req *connect.Request[gen.AuthenticateRequest]) (*connect.Response[gen.AuthenticateResponse], error) { injectHeaderJWTCredential(req.Msg, req.Header()) - return unary(ctx, req, h.inner.Authenticate) + return unaryCookieingRefreshToken(ctx, req, h.inner.Authenticate) } // injectHeaderJWTCredential turns a gateway-injected identity header into the @@ -496,21 +579,27 @@ func injectHeaderJWTCredential(req *gen.AuthenticateRequest, h http.Header) { } } func (h *authConnectHandler) CompleteMFAChallenge(ctx context.Context, req *connect.Request[gen.CompleteMFAChallengeRequest]) (*connect.Response[gen.CompleteMFAChallengeResponse], error) { - return unary(ctx, req, h.inner.CompleteMFAChallenge) + return unaryCookieingRefreshToken(ctx, req, h.inner.CompleteMFAChallenge) } func (h *authConnectHandler) BeginWebAuthnMFAChallenge(ctx context.Context, req *connect.Request[gen.BeginWebAuthnMFAChallengeRequest]) (*connect.Response[gen.BeginWebAuthnMFAChallengeResponse], error) { return unary(ctx, req, h.inner.BeginWebAuthnMFAChallenge) } func (h *authConnectHandler) CompleteWebAuthnMFAChallenge(ctx context.Context, req *connect.Request[gen.CompleteWebAuthnMFAChallengeRequest]) (*connect.Response[gen.CompleteMFAChallengeResponse], error) { - return unary(ctx, req, h.inner.CompleteWebAuthnMFAChallenge) + return unaryCookieingRefreshToken(ctx, req, h.inner.CompleteWebAuthnMFAChallenge) } func (h *authConnectHandler) RefreshToken(ctx context.Context, req *connect.Request[gen.RefreshTokenRequest]) (*connect.Response[gen.RefreshTokenResponse], error) { - return unary(ctx, req, h.inner.RefreshToken) + refreshTokenFromCookie(req.Header(), req.Msg.GetRefreshToken(), func(token string) { + req.Msg.RefreshToken = token + }) + return unaryCookieingRefreshToken(ctx, req, h.inner.RefreshToken) } func (h *authConnectHandler) SwitchOrganization(ctx context.Context, req *connect.Request[gen.SwitchOrganizationRequest]) (*connect.Response[gen.SwitchOrganizationResponse], error) { return unary(ctx, req, h.inner.SwitchOrganization) } func (h *authConnectHandler) Logout(ctx context.Context, req *connect.Request[gen.LogoutRequest]) (*connect.Response[emptypb.Empty], error) { + refreshTokenFromCookie(req.Header(), req.Msg.GetRefreshToken(), func(token string) { + req.Msg.RefreshToken = token + }) return unary(ctx, req, h.inner.Logout) } func (h *authConnectHandler) ValidateClientAuthorization(ctx context.Context, req *connect.Request[gen.ValidateClientAuthorizationRequest]) (*connect.Response[gen.ValidateClientAuthorizationResponse], error) { diff --git a/module/services/accounts/code/pkg/adapters/credential_issuance_factor_test.go b/module/services/accounts/code/pkg/adapters/credential_issuance_factor_test.go new file mode 100644 index 000000000..1f2aab52b --- /dev/null +++ b/module/services/accounts/code/pkg/adapters/credential_issuance_factor_test.go @@ -0,0 +1,60 @@ +package adapters + +import ( + "os" + "regexp" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// Issuing a credential is a privilege-changing operation, so it sits behind the +// same second factor as every other one (SP-IDENT-10). +// +// CreateAPIKey was the hole: it ran requireInteractiveSession, requireScope, +// requireScopesWithinCaller and requireOrgAdmin, and then minted. An +// organization owner with a verified session and NO enrolled factor could ask +// for `*:*` and get a credential that authenticates as them for as long as it +// lives, without the session that asked for it and across a password change — +// which is the thing a factor on the privileged mutations is protecting. +// +// Asserted from the source, in the shape that catches the two ways this breaks: +// deleting the call, and calling it with its error discarded. +func TestCredentialIssuanceRequiresAFactor(t *testing.T) { + text := executableSource(t, "rpcs.go") + + body := regexp.MustCompile( + `func \(s \*APIKeyServer\) CreateAPIKey\((?s:.*?)\n\}\n`).FindString(text) + require.NotEmpty(t, body, "CreateAPIKey must still be here to be gated") + + require.Regexp(t, guardReturnsItsRefusal(`requireMFA\(ctx, actorID\)`), body, + "credential issuance must require a recent factor AND return that refusal") + + // Ordering: after the authorization guards, before the mutation. A factor + // check the write has already passed is not a check. + gate := regexp.MustCompile(`requireMFA\(ctx, actorID\)`).FindStringIndex(body) + mint := strings.Index(body, "service.CreateAPIKey(") + admin := strings.Index(body, "requireOrgAdmin(") + require.NotEqual(t, -1, mint) + require.NotEqual(t, -1, admin) + require.Less(t, admin, gate[0], "the factor runs after the authorization guards") + require.Less(t, gate[0], mint, "and before the key is minted") +} + +// The declaration and the enforcement must agree. The gate above holds the +// handler; this holds the POLICY, which is what the authorization-coverage gate +// and the generated gateway catalog read. CreateAPIKey declared +// MFA_REQUIREMENT_NONE while asserting a factor in code would have left the two +// describing different hosts. +func TestCredentialIssuanceDeclaresTheFactorItEnforces(t *testing.T) { + proto, err := os.ReadFile("../../../proto/saas/accounts/v1/api_keys.proto") + require.NoError(t, err) + + rpc := regexp.MustCompile( + `rpc CreateAPIKey\((?s:.*?)\n \}\n`).FindString(string(proto)) + require.NotEmpty(t, rpc) + require.Contains(t, rpc, "mfa: MFA_REQUIREMENT_IF_ENROLLED_RECENT_STEP_UP", + "the declared policy must require the factor the handler enforces") + require.NotContains(t, rpc, "mfa: MFA_REQUIREMENT_NONE") +} diff --git a/module/services/accounts/code/pkg/adapters/grpc_auth_interceptor.go b/module/services/accounts/code/pkg/adapters/grpc_auth_interceptor.go index f5ac5a804..7006fb27a 100644 --- a/module/services/accounts/code/pkg/adapters/grpc_auth_interceptor.go +++ b/module/services/accounts/code/pkg/adapters/grpc_auth_interceptor.go @@ -442,6 +442,8 @@ func stampForwardedGRPCIdentity(ctx context.Context, md metadata.MD) (context.Co if scopedRoles := firstMetadataValue(md, "x-scoped-roles"); scopedRoles != "" { ctx = withScopedRoles(ctx, parseScopedRoles(scopedRoles)) } + ctx = auth.WithVerifiedResourceConfinement(ctx, firstMetadataValue(md, strings.ToLower(resourceConfinementHeader))) + ctx = auth.WithVerifiedInstallation(ctx, firstMetadataValue(md, strings.ToLower(installationHeader))) return withScopedRolesTruncated(ctx, firstMetadataValue(md, "x-scoped-roles-truncated") == "true"), nil } diff --git a/module/services/accounts/code/pkg/adapters/impersonation_stop_test.go b/module/services/accounts/code/pkg/adapters/impersonation_stop_test.go index 082cb170e..26db1a490 100644 --- a/module/services/accounts/code/pkg/adapters/impersonation_stop_test.go +++ b/module/services/accounts/code/pkg/adapters/impersonation_stop_test.go @@ -272,8 +272,15 @@ func newImpersonationRig(t *testing.T) *impersonationRig { func (r *impersonationRig) start(t *testing.T, subjectID string) (string, string, context.Context) { t.Helper() + // Genuine AAL2 evidence, not merely a timestamp: a platform administrator + // reaches impersonation only behind a recent second factor, and + // Assurance.HasRecentMFA requires the level and the method as well as the + // time. The fixture carried the time alone and passed only because an + // a privileged actor's factor evidence is required, not merely consulted. adminCtx := stampVerifiedIdentity(context.Background(), supportActorID, "", auth.Assurance{ - MFAVerifiedAt: time.Now(), + Level: auth.AssuranceLevelAAL2, + AuthenticationMethods: []string{auth.AuthenticationMethodWebAuthn}, + MFAVerifiedAt: time.Now(), }) resp, err := (&PlatformAdminServer{}).ImpersonateUser(adminCtx, &gen.ImpersonateUserRequest{UserId: subjectID, Reason: "investigating a billing discrepancy"}) diff --git a/module/services/accounts/code/pkg/adapters/logout_retry_state_test.go b/module/services/accounts/code/pkg/adapters/logout_retry_state_test.go new file mode 100644 index 000000000..c77236dfb --- /dev/null +++ b/module/services/accounts/code/pkg/adapters/logout_retry_state_test.go @@ -0,0 +1,74 @@ +package adapters + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// downstreamStatus answers with a chosen status, for the incomplete-sign-out path. +func downstreamStatus(status int, resp string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.ReadAll(r.Body) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = io.WriteString(w, resp) + }) +} + +// An INCOMPLETE sign-out keeps the refresh cookie. +// +// Logout answers 503 "logout incomplete, retry" when it revoked the refresh +// family but could not record an access-revocation marker. That answer exists so +// the caller can retry — and the retry needs the refresh token, which lives only +// in this cookie. The middleware cleared it on EVERY logout response, so the one +// answer that asks for a retry was also the one that destroyed the credential +// the retry needs, while an unmarked access token stayed valid. +// +// The family revocation runs last in Logout and its error is returned directly, +// so a 5xx here means the durable half did not land either: the session really is +// still live, and keeping the credential is the lesser harm of the two. +func TestIncompleteLogoutKeepsTheRetryCredential(t *testing.T) { + h := refreshTokenCookie(downstreamStatus( + http.StatusServiceUnavailable, `{"error":"logout incomplete, retry"}`)) + + req := httptest.NewRequest(http.MethodPost, "/v1/auth/logout", strings.NewReader(`{}`)) + req.AddCookie(&http.Cookie{Name: refreshTokenCookieName, Value: "cookie-rt"}) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("expected the downstream status to pass through, got %d", rec.Code) + } + for _, c := range rec.Result().Cookies() { + if c.Name == refreshTokenCookieName && c.MaxAge < 0 { + t.Fatalf("an incomplete sign-out must not clear the refresh cookie: "+ + "it is the only credential a retry has, got %+v", c) + } + } +} + +// The control, beside the existing TestRefreshCookie_Logout_ClearsCookie: a +// COMPLETED sign-out still clears it. Without this the test above would pass on +// a middleware that never cleared the cookie at all, which would leave a signed +// -out browser holding a live refresh credential. +func TestCompletedLogoutStillClearsTheCookie(t *testing.T) { + h := refreshTokenCookie(downstreamStatus(http.StatusOK, `{}`)) + + req := httptest.NewRequest(http.MethodPost, "/v1/auth/logout", strings.NewReader(`{}`)) + req.AddCookie(&http.Cookie{Name: refreshTokenCookieName, Value: "cookie-rt"}) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + var cleared bool + for _, c := range rec.Result().Cookies() { + if c.Name == refreshTokenCookieName && c.MaxAge < 0 { + cleared = true + } + } + if !cleared { + t.Fatal("a completed sign-out must clear the refresh cookie") + } +} diff --git a/module/services/accounts/code/pkg/adapters/mfa_privileged_test.go b/module/services/accounts/code/pkg/adapters/mfa_privileged_test.go new file mode 100644 index 000000000..277c3b785 --- /dev/null +++ b/module/services/accounts/code/pkg/adapters/mfa_privileged_test.go @@ -0,0 +1,108 @@ +package adapters + +import ( + "context" + "testing" + + "accounts/pkg/auth" + "accounts/pkg/business" + gen "accounts/pkg/gen/saas/accounts/v1" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// The actor of these tests holds no verified MFA device. That is the whole +// point: the gate used to look for a device, find none, and admit — so the one +// principal whose compromise is the whole platform or the whole tenant was the +// one it could not touch. +type mfaPolicyStore struct { + business.Store + platformRole string + orgRole gen.OrgRole + member bool +} + +func (s *mfaPolicyStore) GetPlatformRole(context.Context, string) (string, error) { + return s.platformRole, nil +} + +func (s *mfaPolicyStore) GetOrgMembership(context.Context, string, string) (*gen.OrgMembership, error) { + if !s.member { + return nil, nil + } + return &gen.OrgMembership{Role: s.orgRole}, nil +} + +func (s *mfaPolicyStore) WithUserTx(ctx context.Context, _ string, fn func(context.Context) error) error { + return fn(ctx) +} + +func (s *mfaPolicyStore) HasVerifiedMFA(context.Context, string) (bool, error) { + return false, nil +} + +const ( + mfaActorID = "00000000-0000-4000-8000-0000000000a1" + mfaOrgID = "00000000-0000-4000-8000-0000000000b1" +) + +// The parameter is the Store interface rather than this file's fake, so a test that +// needs another store method — a principal lookup, say — supplies its own fake instead +// of growing this one past what its own cases need. +func installMFAPolicyStore(t *testing.T, store business.Store) { + t.Helper() + previous := service + t.Cleanup(func() { service = previous }) + svc, err := business.NewService(store) + require.NoError(t, err) + WithService(svc) +} + +// The caller context every case shares: a verified identity for this actor in +// this organization, and no recent second factor. +func mfaRequestContext() context.Context { + return auth.WithVerifiedDatabaseIdentity(context.Background(), mfaActorID, mfaOrgID) +} + +func TestRequireMFARefusesUnenrolledPrivileged(t *testing.T) { + for _, tc := range []struct { + name string + store *mfaPolicyStore + }{ + {"a platform support role", &mfaPolicyStore{platformRole: "support"}}, + {"a platform billing role", &mfaPolicyStore{platformRole: "billing"}}, + {"a platform super admin", &mfaPolicyStore{platformRole: "super_admin"}}, + {"an organization owner", &mfaPolicyStore{member: true, orgRole: gen.OrgRole_ORG_ROLE_OWNER}}, + } { + t.Run(tc.name, func(t *testing.T) { + installMFAPolicyStore(t, tc.store) + + err := requireMFA(mfaRequestContext(), mfaActorID) + + require.Equal(t, codes.FailedPrecondition, status.Code(err)) + require.Equal(t, "mfa_required", status.Convert(err).Message()) + }) + } +} + +// The controls. An ordinary member with nothing enrolled still passes, so the +// refusal above is the privilege and not a gate that refuses everybody; and an +// org admin is deliberately outside the rule, because admin is a role an owner +// grants and withdraws rather than the tenant's own lifecycle. +func TestRequireMFAStaysOptInForAnUnprivilegedActor(t *testing.T) { + for _, tc := range []struct { + name string + store *mfaPolicyStore + }{ + {"a plain member", &mfaPolicyStore{member: true, orgRole: gen.OrgRole_ORG_ROLE_MEMBER}}, + {"an organization admin", &mfaPolicyStore{member: true, orgRole: gen.OrgRole_ORG_ROLE_ADMIN}}, + {"no membership at all", &mfaPolicyStore{}}, + } { + t.Run(tc.name, func(t *testing.T) { + installMFAPolicyStore(t, tc.store) + require.NoError(t, requireMFA(mfaRequestContext(), mfaActorID)) + }) + } +} diff --git a/module/services/accounts/code/pkg/adapters/principal_factor_test.go b/module/services/accounts/code/pkg/adapters/principal_factor_test.go new file mode 100644 index 000000000..876daa96f --- /dev/null +++ b/module/services/accounts/code/pkg/adapters/principal_factor_test.go @@ -0,0 +1,246 @@ +package adapters + +import ( + "context" + "testing" + "time" + + "accounts/pkg/auth" + "accounts/pkg/business" + gen "accounts/pkg/gen/saas/accounts/v1" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// R1019-N02, the half the first response missed: a principal is a credential-bearing +// actor, so minting one and revoking one are privilege-granting mutations, and both +// reached their write with no factor evidence — for a platform super-admin and for an +// organization owner alike. +// +// Both caller grades are exercised, because they pass through different authorization +// guards: a super-admin short-circuits requireOrgAdmin on the platform role, while an +// owner is admitted on membership. A gate placed after only one of those is a gate for +// only one of these callers. +type principalFactorStore struct { + business.Store + platformRole string + orgRole gen.OrgRole + member bool + enrolled bool + principal *business.Principal + + // The writes record instead of panicking, so "the mutation was reached" is an + // assertion rather than a crash. Reaching the write with no factor evidence IS the + // finding, so the test has to be able to say that happened. + created []string + revoked []string +} + +func (s *principalFactorStore) GetPlatformRole(context.Context, string) (string, error) { + return s.platformRole, nil +} + +func (s *principalFactorStore) GetOrgMembership(context.Context, string, string) (*gen.OrgMembership, error) { + if !s.member { + return nil, nil + } + return &gen.OrgMembership{Role: s.orgRole}, nil +} + +func (s *principalFactorStore) WithUserTx(ctx context.Context, _ string, fn func(context.Context) error) error { + return fn(ctx) +} + +func (s *principalFactorStore) HasVerifiedMFA(context.Context, string) (bool, error) { + return s.enrolled, nil +} + +func (s *principalFactorStore) GetPrincipal(context.Context, string) (*business.Principal, error) { + return s.principal, nil +} + +// RevokePrincipal resolves the principal first, to decide which authorization applies, +// and that read runs in a scope. Only the scope's Within is needed: the gate under test +// sits between that read and the write, so nothing beyond it has to work for the +// refusal to be the real one. +type principalFactorScope struct{ business.Scoped } + +func (s *principalFactorScope) Within(ctx context.Context, fn func(context.Context) error) error { + return fn(ctx) +} + +func (s *principalFactorStore) As(business.Identity) business.Scoped { + return &principalFactorScope{} +} + +func (s *principalFactorStore) CreateAgentPrincipal(_ context.Context, p *business.Principal) error { + s.created = append(s.created, p.AgentIdentifier) + return nil +} + +func (s *principalFactorStore) RevokePrincipal(_ context.Context, id, _ string) error { + s.revoked = append(s.revoked, id) + return nil +} + +func (s *principalFactorStore) GetAgentPrincipal(context.Context, string, string) (*business.Principal, error) { + return nil, nil +} + +func (s *principalFactorStore) DisableAgentPrincipal(context.Context, string, string) (bool, error) { + return false, nil +} + +func (s *principalFactorStore) EnableAgentPrincipal(context.Context, string) (bool, error) { + return false, nil +} + +func (s *principalFactorStore) ListPrincipals(context.Context, string, string, int32, string) ([]*business.Principal, string, error) { + return nil, "", nil +} + +func principalFactorCalls() []factorCall { + principals := &PrincipalServer{} + return []factorCall{ + {"CreateAgentPrincipal", func(ctx context.Context) error { + _, err := principals.CreateAgentPrincipal(ctx, &gen.CreateAgentPrincipalRequest{ + OrgId: mfaOrgID, AgentIdentifier: "a-delegated-agent", DisplayName: "A Delegated Agent", + }) + return err + }}, + {"RevokePrincipal", func(ctx context.Context) error { + _, err := principals.RevokePrincipal(ctx, &gen.RevokePrincipalRequest{ + Id: factorUUID, Reason: "a recorded reason", + }) + return err + }}, + } +} + +func TestR1019PrincipalMutationsRequireAFactor(t *testing.T) { + for _, caller := range []struct { + name string + store *principalFactorStore + }{ + {"platform super-admin", &principalFactorStore{ + platformRole: "super_admin", + principal: &business.Principal{ID: factorUUID, OrgID: mfaOrgID}, + }}, + {"organization owner", &principalFactorStore{ + orgRole: gen.OrgRole_ORG_ROLE_OWNER, member: true, + principal: &business.Principal{ID: factorUUID, OrgID: mfaOrgID}, + }}, + } { + t.Run(caller.name, func(t *testing.T) { + installMFAPolicyStore(t, caller.store) + ctx := privilegedActorContext() + + for _, call := range principalFactorCalls() { + err := call.invoke(ctx) + require.Error(t, err, "%s must not mutate without a recent factor", call.name) + require.Equal(t, codes.FailedPrecondition, status.Code(err), + "%s: expected the factor gate to answer, not an earlier guard", call.name) + require.Equal(t, "mfa_required", status.Convert(err).Message(), call.name) + } + + // The refusal is only the gate if the write did not happen anyway. + require.Empty(t, caller.store.created, "no principal may be created without a factor") + require.Empty(t, caller.store.revoked, "no principal may be revoked without a factor") + }) + } +} + +// The control: the same caller with recent AAL2 evidence gets PAST the factor gate. +// Without it, a handler that refused everything would satisfy the test above and the +// refusals would prove nothing about the factor. +// +// Past the gate the handler runs into store surface this package does not wire, which +// is where the test's interest ends: a panic from there is proof the gate admitted, so +// it is recovered and reported as such rather than left to fail the run. What must +// never happen is an mfa_required. +func TestR1019PrincipalMutationsProceedPastTheFactorGate(t *testing.T) { + store := &principalFactorStore{ + platformRole: "super_admin", + principal: &business.Principal{ID: factorUUID, OrgID: mfaOrgID}, + } + installMFAPolicyStore(t, store) + ctx := stampVerifiedIdentity(context.Background(), mfaActorID, "", auth.Assurance{ + Level: auth.AssuranceLevelAAL2, + AuthenticationMethods: []string{auth.AuthenticationMethodWebAuthn}, + MFAVerifiedAt: time.Now(), + }) + ctx = auth.WithVerifiedDatabaseIdentity(ctx, mfaActorID, mfaOrgID) + + for _, call := range principalFactorCalls() { + t.Run(call.name, func(t *testing.T) { + err, admitted := invokePastTheGate(ctx, call) + if admitted { + return // it reached store surface beyond the gate, which is the point + } + if err == nil { + return + } + require.NotEqual(t, "mfa_required", status.Convert(err).Message(), + "recent AAL2 evidence must satisfy the gate this mutation carries") + }) + } +} + +// invokePastTheGate reports the handler's error, and whether it got far enough to +// panic in unwired store surface — which only happens after the factor gate admitted. +func invokePastTheGate(ctx context.Context, call factorCall) (err error, admitted bool) { + defer func() { + if recovered := recover(); recovered != nil { + admitted = true + } + }() + return call.invoke(ctx), false +} + +// The population this gate reaches is wider than "privileged actor", and that is worth +// asserting rather than discovering: CreateAgentPrincipal admits an organization ADMIN, +// who is not privileged, so requireMFA applies its opt-in rule — an ENROLLED admin +// without recent evidence is refused, an unenrolled one is not. +// +// Both halves are asserted because only the pair pins the behaviour. Without the +// unenrolled case this would also pass if the gate were fail-closed for everyone, which +// would force every operator's whole roster to enrol; without the enrolled case it would +// pass if the gate did nothing for a non-privileged actor. module/FEATURES.md states +// this, and an operator reading only "ordinary tenant mutations are not gated" would +// not predict it. +func TestR1019OrganizationAdminMeetsTheOptInFactorRule(t *testing.T) { + admin := func(enrolled bool) *principalFactorStore { + return &principalFactorStore{ + orgRole: gen.OrgRole_ORG_ROLE_ADMIN, member: true, enrolled: enrolled, + principal: &business.Principal{ID: factorUUID, OrgID: mfaOrgID}, + } + } + create := principalFactorCalls()[0] + require.Equal(t, "CreateAgentPrincipal", create.name) + + t.Run("enrolled without recent evidence is refused", func(t *testing.T) { + store := admin(true) + installMFAPolicyStore(t, store) + + err := create.invoke(privilegedActorContext()) + require.Error(t, err) + require.Equal(t, codes.FailedPrecondition, status.Code(err)) + require.Equal(t, "mfa_required", status.Convert(err).Message()) + require.Empty(t, store.created, "the write must not happen behind the refusal") + }) + + t.Run("not enrolled is still admitted", func(t *testing.T) { + installMFAPolicyStore(t, admin(false)) + + err, admitted := invokePastTheGate(privilegedActorContext(), create) + if admitted { + return // past the gate, into store surface this package does not wire + } + if err != nil { + require.NotEqual(t, "mfa_required", status.Convert(err).Message(), + "an unenrolled organization admin must not be forced to enrol by this gate") + } + }) +} diff --git a/module/services/accounts/code/pkg/adapters/principal_rpcs.go b/module/services/accounts/code/pkg/adapters/principal_rpcs.go index 81511f11c..c1a5d7116 100644 --- a/module/services/accounts/code/pkg/adapters/principal_rpcs.go +++ b/module/services/accounts/code/pkg/adapters/principal_rpcs.go @@ -70,6 +70,13 @@ func (s *PrincipalServer) CreateAgentPrincipal(ctx context.Context, req *gen.Cre if err := requireOrgAdmin(ctx, actorID, req.GetOrgId()); err != nil { return nil, err } + // A principal is a credential-bearing actor, so minting one is a + // privilege-granting mutation (SP-IDENT-10). The factor check runs AFTER the + // authorization guard and BEFORE the mutation: a factor check the write has + // already passed is not a check. + if err := requireMFA(ctx, actorID); err != nil { + return nil, err + } p, err := service.CreateAgentPrincipal(ctx, business.CreateAgentRequest{ OrgID: req.GetOrgId(), AgentIdentifier: req.GetAgentIdentifier(), @@ -134,23 +141,29 @@ func (s *PrincipalServer) RevokePrincipal(ctx context.Context, req *gen.RevokePr } // Humans (org_id NULL) only revocable by platform admin; agents // + services revocable by their org's admins. + // + // actorID is resolved once, before the branch, so the factor check below cannot + // be reached on a path that did not establish an actor: it was declared inside + // each branch, which is how a gate comes to be added to one branch and not the + // other. + actorID, err := requireAuth(ctx) + if err != nil { + return nil, err + } if p.OrgID == "" { - actorID, authErr := requireAuth(ctx) - if authErr != nil { - return nil, authErr - } if paErr := requirePlatformAdmin(ctx, actorID); paErr != nil { return nil, paErr } } else { - actorID, authErr := requireAuth(ctx) - if authErr != nil { - return nil, authErr - } - if err := requireOrgAdmin(ctx, actorID, p.OrgID); err != nil { - return nil, err + if orgErr := requireOrgAdmin(ctx, actorID, p.OrgID); orgErr != nil { + return nil, orgErr } } + // Revoking a principal is a platform-security mutation, and it is irreversible + // where Disable is not. Same placement rule: after the guard, before the write. + if mfaErr := requireMFA(ctx, actorID); mfaErr != nil { + return nil, mfaErr + } if err := service.RevokePrincipal(ctx, req.GetId(), req.GetReason()); err != nil { return nil, mapPrincipalError(err) } diff --git a/module/services/accounts/code/pkg/adapters/privileged_mutation_factor_test.go b/module/services/accounts/code/pkg/adapters/privileged_mutation_factor_test.go new file mode 100644 index 000000000..ceb8845de --- /dev/null +++ b/module/services/accounts/code/pkg/adapters/privileged_mutation_factor_test.go @@ -0,0 +1,200 @@ +package adapters + +import ( + "context" + "regexp" + "strings" + "testing" + "time" + + "accounts/pkg/auth" + gen "accounts/pkg/gen/saas/accounts/v1" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// SP-IDENT-10: a platform administrator or an organization owner carries out a +// sensitive operation only behind a recent second factor. +// +// requireMFA was made fail-closed for a privileged actor, but the privilege-granting +// and platform-security mutations did not reach it: each ran its authorization guard +// and then its mutation. A helper nothing calls enforces nothing. +func TestR1019PrivilegedMutationRequiresFactor(t *testing.T) { + // A super-admin with nothing enrolled and no recent factor. requireRoleScope + // falls to the platform role when no organization is named, so both servers' + // authorization guards pass and the factor gate is what answers. + for _, grade := range []string{"support", "billing", "super_admin"} { + t.Run(grade, func(t *testing.T) { + installMFAPolicyStore(t, &mfaPolicyStore{platformRole: grade}) + ctx := privilegedActorContext() + + // Counted, not assumed: a grade below every method's role floor would be + // refused by the authorization guard on every call and prove nothing + // about the factor gate. At least one call per grade has to reach it. + reachedTheGate := 0 + for _, call := range privilegedMutations() { + err := call.invoke(ctx) + require.Error(t, err, "%s must not mutate without a recent factor", call.name) + if status.Code(err) == codes.PermissionDenied { + // This grade is below the method's role floor, so the + // authorization guard answered first — a stronger refusal, and + // not what this test is about. + continue + } + require.Equal(t, codes.FailedPrecondition, status.Code(err), + "%s: expected the factor gate to answer", call.name) + require.Equal(t, "mfa_required", status.Convert(err).Message(), call.name) + reachedTheGate++ + } + require.NotZero(t, reachedTheGate, + "every call was refused before the factor gate, so this grade proved nothing") + }) + } +} + +// The control: with recent AAL2 evidence the same privileged actor passes the gate, +// so the refusals above are the missing factor and not a handler that refuses +// everything. Asserted on the gate itself rather than through a handler, because a +// handler that passes the gate proceeds into the service and this package's fixtures +// wire no store for the mutations themselves. +func TestR1019PrivilegedMutationAdmittedWithRecentFactor(t *testing.T) { + installMFAPolicyStore(t, &mfaPolicyStore{platformRole: "super_admin"}) + ctx := stampVerifiedIdentity(context.Background(), mfaActorID, "", auth.Assurance{ + Level: auth.AssuranceLevelAAL2, + AuthenticationMethods: []string{auth.AuthenticationMethodWebAuthn}, + MFAVerifiedAt: time.Now(), + }) + ctx = auth.WithVerifiedDatabaseIdentity(ctx, mfaActorID, mfaOrgID) + + require.NoError(t, requireMFA(ctx, mfaActorID), + "recent AAL2 evidence must satisfy the gate these mutations now carry") +} + +// Every method listed here must carry the gate, and carry it BEFORE its mutation: a +// factor check the write has already passed is not a check. Read from the source, so +// a method added to the privileged set cannot be left ungated, and so the ordering +// cannot regress to the shape this finding started as. +// +// Two things this check learned the hard way (R1019-N15). It must prove the gate's +// error is RETURNED, not merely that requireMFA appears — ignoring the result leaves +// the call in place and enforces nothing. And it must read source with comments +// STRIPPED, because a comment containing the expected text otherwise satisfies a +// substring search while the executable guard is gone. Both of those mutations +// survived the first version of this test. +func TestR1019PrivilegedMutationsGateBeforeTheMutation(t *testing.T) { + // The gate in its only acceptable form: the error is bound, tested, and returned. + // RE2 has no backreferences, so the three names are captured and compared below — + // which is what forbids binding one variable and returning another. + gateReturns := regexp.MustCompile( + `if (\w+) := requireMFA\(ctx, actorID\); (\w+) != nil \{\s*return nil, (\w+)\s*\}`) + + for _, m := range privilegedMutationMethods { + t.Run(m.server+"."+m.method, func(t *testing.T) { + text := executableSource(t, m.file) + pattern := regexp.MustCompile(`func \(s \*` + m.server + `\) ` + m.method + + `\(ctx context\.Context(?s:.*?)\n\}\n`) + body := pattern.FindString(text) + require.NotEmpty(t, body, "%s.%s not found in %s", m.server, m.method, m.file) + + gate := gateReturns.FindStringSubmatchIndex(body) + require.NotNil(t, gate, + "%s.%s must gate a second factor AND return its refusal; a requireMFA whose "+ + "error is discarded enforces nothing", m.server, m.method) + bound := body[gate[2]:gate[3]] + tested := body[gate[4]:gate[5]] + returned := body[gate[6]:gate[7]] + require.Equal(t, bound, tested, + "%s.%s tests a different variable than the one requireMFA bound", m.server, m.method) + require.Equal(t, bound, returned, + "%s.%s returns a different variable than the one requireMFA bound", m.server, m.method) + // The MUTATING call specifically, not the first service call in the body: + // RevokePrincipal reads the principal first to decide which authorization + // applies, and "before the first service call" would have demanded the gate + // run before that read — or, read the other way, been satisfied by a gate + // that merely preceded it. + call := "service." + m.method + "(" + mutation := strings.Index(body, call) + require.NotEqual(t, -1, mutation, + "%s.%s does not call %s; name its mutating call in privilegedMutationMethods", + m.server, m.method, call) + require.Less(t, gate[0], mutation, + "%s.%s gates the factor AFTER its mutation, which enforces nothing", + m.server, m.method) + }) + } +} + +type privilegedMutation struct { + server, method, file string +} + +// The privilege-granting and platform-security mutations. Roles, scopes and +// principals grant authority; the platform-admin entries change another identity's +// state or a tenant's ceiling. +// +// UpsertFeatureFlag is deliberately absent: it mutates nothing, returning +// FailedPrecondition for a retired inventory. DisableAgentPrincipal and +// EnableAgentPrincipal are absent for a different reason — they admit on an internal +// service credential, where there is no human actor and so no factor to require. +var privilegedMutationMethods = []privilegedMutation{ + {"PermServer", "CreateRole", "rpcs.go"}, + {"PermServer", "UpdateRole", "rpcs.go"}, + {"PermServer", "DeleteRole", "rpcs.go"}, + {"PermServer", "AssignRole", "rpcs.go"}, + {"PermServer", "RevokeRole", "rpcs.go"}, + {"PermServer", "GrantScope", "rpcs.go"}, + {"PermServer", "RevokeScope", "rpcs.go"}, + {"PlatformAdminServer", "SuspendUser", "rpcs.go"}, + {"PlatformAdminServer", "UnsuspendUser", "rpcs.go"}, + {"PlatformAdminServer", "RevokeSession", "rpcs.go"}, + {"PlatformAdminServer", "OverrideEntitlement", "rpcs.go"}, + {"PrincipalServer", "CreateAgentPrincipal", "principal_rpcs.go"}, + {"PrincipalServer", "RevokePrincipal", "principal_rpcs.go"}, +} + +type factorCall struct { + name string + invoke func(context.Context) error +} + +func privilegedMutations() []factorCall { + perm := &PermServer{} + admin := &PlatformAdminServer{} + return []factorCall{ + {"AssignRole", func(ctx context.Context) error { + _, err := perm.AssignRole(ctx, &gen.AssignRoleRequest{ + RoleId: factorUUID, SubjectId: factorUUID, + SubjectKind: gen.SubjectKind_SUBJECT_KIND_USER, + }) + return err + }}, + {"RevokeRole", func(ctx context.Context) error { + _, err := perm.RevokeRole(ctx, &gen.RevokeRoleRequest{ + RoleId: factorUUID, SubjectId: factorUUID, + }) + return err + }}, + {"SuspendUser", func(ctx context.Context) error { + _, err := admin.SuspendUser(ctx, &gen.SuspendUserRequest{ + UserId: factorUUID, Reason: "a recorded reason", + }) + return err + }}, + {"OverrideEntitlement", func(ctx context.Context) error { + _, err := admin.OverrideEntitlement(ctx, &gen.OverrideEntitlementRequest{ + OrgId: factorUUID, Feature: "seats", LimitValue: 10, + Reason: "a recorded reason", + }) + return err + }}, + } +} + +func privilegedActorContext() context.Context { + ctx := stampVerifiedIdentity(context.Background(), mfaActorID, "", auth.Assurance{}) + return auth.WithVerifiedDatabaseIdentity(ctx, mfaActorID, mfaOrgID) +} + +const factorUUID = "00000000-0000-4000-8000-0000000000d1" diff --git a/module/services/accounts/code/pkg/adapters/refresh_credential_carrier_test.go b/module/services/accounts/code/pkg/adapters/refresh_credential_carrier_test.go new file mode 100644 index 000000000..0e5303f82 --- /dev/null +++ b/module/services/accounts/code/pkg/adapters/refresh_credential_carrier_test.go @@ -0,0 +1,305 @@ +package adapters + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "regexp" + "strings" + "testing" + + gen "accounts/pkg/gen/saas/accounts/v1" + + "connectrpc.com/connect" + "github.com/stretchr/testify/require" + "google.golang.org/genproto/googleapis/api/annotations" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/reflect/protoregistry" +) + +// The browser's logout posts an empty body and the HttpOnly cookie. The cookie +// was moved into the body for refresh and not for logout, so field validation +// rejected the request before Logout ran: the session family stayed active and +// the access-token id was never revoked, while the browser's own cookie was +// cleared — a sign-out that looked complete and ended nothing. +func TestL1LogoutForwardsCookieRefreshToken(t *testing.T) { + handler := refreshTokenCookie(downstream(`{}`)) + + request := httptest.NewRequest(http.MethodPost, "/v1/auth/logout", strings.NewReader(`{}`)) + request.AddCookie(&http.Cookie{Name: refreshTokenCookieName, Value: "cookie-rt"}) + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + + require.Contains(t, recorder.Header().Get("X-Echo-Body"), "cookie-rt", + "logout must reach the handler carrying the cookie's refresh token") + + cleared := false + for _, cookie := range recorder.Result().Cookies() { + if cookie.Name == refreshTokenCookieName && cookie.MaxAge < 0 { + cleared = true + } + } + require.True(t, cleared, "logout must still clear the browser's cookie") +} + +// A logout that already carries a token in its body keeps it: the cookie is a +// fallback for the browser, not an override of an explicit credential. +func TestLogoutKeepsAnExplicitBodyRefreshToken(t *testing.T) { + handler := refreshTokenCookie(downstream(`{}`)) + + request := httptest.NewRequest(http.MethodPost, "/v1/auth/logout", + strings.NewReader(`{"refresh_token":"body-rt"}`)) + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + + require.Contains(t, recorder.Header().Get("X-Echo-Body"), "body-rt") +} + +// Every REST route that completes an authentication hands the refresh credential +// to the browser as a cookie and strips it from the body. The set is derived +// from the service descriptor rather than restated, so a route added to the +// contract cannot be left out of the middleware: the WebAuthn completion was +// exactly that, sharing its response message with the TOTP completion while only +// the TOTP path was listed. +func TestEveryAuthCompletionCookiesTheRefreshToken(t *testing.T) { + declared := restRoutesReturningARefreshToken(t) + require.NotEmpty(t, declared, "the descriptor scan found no route to check") + + require.Contains(t, declared, oauthTokenEndpointRESTPath, + "the exemption names a route the contract does not have") + + for path := range declared { + if path == oauthTokenEndpointRESTPath { + continue + } + t.Run(path, func(t *testing.T) { + require.True(t, authenticationCompletingRESTPaths[path], + "this route's response carries a refresh token and must be cookie-lifted") + + handler := refreshTokenCookie(downstream(`{"accessToken":"at","refreshToken":"rt-secret"}`)) + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{}`))) + + var body map[string]any + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &body)) + require.NotContains(t, body, "refreshToken") + require.NotContains(t, body, "refresh_token") + require.NotContains(t, recorder.Body.String(), "rt-secret") + + cookie := refreshCookieOf(t, recorder) + require.Equal(t, "rt-secret", cookie.Value) + require.True(t, cookie.HttpOnly) + require.True(t, cookie.Secure) + require.Equal(t, http.SameSiteStrictMode, cookie.SameSite) + require.Equal(t, "/v1/auth", cookie.Path) + }) + } + + // And nothing is listed that the contract does not actually return a refresh + // token on, so the map cannot be kept green by growing it. + for path := range authenticationCompletingRESTPaths { + require.Contains(t, declared, path, + "this path is cookie-lifted but no descriptor route returns a refresh token there") + } +} + +// restRoutesReturningARefreshToken reads the accounts service descriptors and +// returns the POST paths whose response message carries a refresh_token field. +func restRoutesReturningARefreshToken(t *testing.T) map[string]bool { + t.Helper() + paths := map[string]bool{} + services := gen.File_saas_accounts_v1_authentication_proto.Services() + for i := range services.Len() { + methods := services.Get(i).Methods() + for j := range methods.Len() { + method := methods.Get(j) + if method.Output().Fields().ByName(protoreflect.Name("refresh_token")) == nil { + continue + } + rule, ok := proto.GetExtension( + method.Options(), annotations.E_Http).(*annotations.HttpRule) + if !ok || rule == nil { + continue + } + if post := rule.GetPost(); post != "" { + paths[post] = true + } + } + } + return paths +} + +// The Connect surface of the same methods: it serves the same browser, over the +// same origin, and returned the credential as readable content. A Connect caller +// that needs the token in a body is a registered OAuth client and uses the token +// endpoint, which is a different contract. +func TestConnectAuthCompletionsCookieTheRefreshToken(t *testing.T) { + for _, tc := range []struct { + name string + carry func() (http.Header, string) + }{ + {"Authenticate", func() (http.Header, string) { + response := connect.NewResponse(&gen.AuthenticateResponse{AccessToken: "at", RefreshToken: "rt-secret"}) + cookieTheRefreshToken(response.Header(), response.Msg) + return response.Header(), response.Msg.GetRefreshToken() + }}, + {"CompleteMFAChallenge", func() (http.Header, string) { + response := connect.NewResponse(&gen.CompleteMFAChallengeResponse{AccessToken: "at", RefreshToken: "rt-secret"}) + cookieTheRefreshToken(response.Header(), response.Msg) + return response.Header(), response.Msg.GetRefreshToken() + }}, + {"RefreshToken", func() (http.Header, string) { + response := connect.NewResponse(&gen.RefreshTokenResponse{AccessToken: "at", RefreshToken: "rt-secret"}) + cookieTheRefreshToken(response.Header(), response.Msg) + return response.Header(), response.Msg.GetRefreshToken() + }}, + } { + t.Run(tc.name, func(t *testing.T) { + header, remaining := tc.carry() + require.Empty(t, remaining, "the response body must not carry the refresh token") + + setCookie := header.Get("Set-Cookie") + require.Contains(t, setCookie, refreshTokenCookieName+"=rt-secret") + require.Contains(t, setCookie, "HttpOnly") + require.Contains(t, setCookie, "Secure") + require.Contains(t, setCookie, "SameSite=Strict") + require.Contains(t, setCookie, "Path=/v1/auth") + }) + } +} + +// A response with no refresh token is left exactly as it was, so the lift cannot +// be satisfied by clearing a field that was never set or by always emitting a +// cookie. +func TestCookieTheRefreshTokenLeavesAnUnrelatedResponseAlone(t *testing.T) { + response := connect.NewResponse(&gen.AuthenticateResponse{AccessToken: "at"}) + cookieTheRefreshToken(response.Header(), response.Msg) + + require.Equal(t, "at", response.Msg.GetAccessToken()) + require.Empty(t, response.Header().Get("Set-Cookie")) +} + +// The handlers have to actually route through the lift. Read the source and +// require it of every authConnectHandler method whose response message carries a +// refresh token, so a handler added later cannot quietly use the plain helper. +func TestEveryConnectAuthCompletionRoutesThroughTheLift(t *testing.T) { + text := executableSource(t, "connect_handlers.go") + + methods := regexp.MustCompile( + `(?m)^func \(h \*authConnectHandler\) ([A-Za-z]+)\(ctx context\.Context, req \*connect\.Request\[gen\.([A-Za-z]+)\]\) \(\*connect\.Response\[gen\.([A-Za-z]+)\][^\n]*\n((?:\t[^\n]*\n)*)`). + FindAllStringSubmatch(text, -1) + require.NotEmpty(t, methods) + + checked := 0 + for _, match := range methods { + name, responseType, body := match[1], match[3], match[4] + message, err := protoregistry.GlobalTypes.FindMessageByName( + protoreflect.FullName("saas.accounts.v1." + responseType)) + require.NoError(t, err, "%s: unknown response message %s", name, responseType) + if message.Descriptor().Fields().ByName("refresh_token") == nil || + connectHandlersExemptFromTheRefreshCookie[name] { + continue + } + checked++ + require.Contains(t, body, "unaryCookieingRefreshToken", + "%s completes an authentication and must lift the refresh token out of the body", name) + } + require.NotZero(t, checked, "the scan matched no authentication-completing handler") + + // The exemption set is exactly the OAuth token endpoint, so an entry cannot be + // added to it without this assertion being read and changed. + require.Equal(t, map[string]bool{"ExchangeClientToken": true}, + connectHandlersExemptFromTheRefreshCookie) +} + +// The response half removed the credential from the Connect body; without a request +// half a Connect client had nowhere to put it back, so refresh and logout failed +// field validation before reaching the verifier. A carrier that works in one +// direction is not a carrier. +func TestR1019ConnectRefreshConsumesCookie(t *testing.T) { + refresh := connect.NewRequest(&gen.RefreshTokenRequest{}) + refresh.Header().Set("Cookie", refreshTokenCookieName+"=cookie-rt; codefly_session=1") + refreshTokenFromCookie(refresh.Header(), refresh.Msg.GetRefreshToken(), func(token string) { + refresh.Msg.RefreshToken = token + }) + require.Equal(t, "cookie-rt", refresh.Msg.GetRefreshToken(), + "the Connect refresh must take the credential from the cookie") + + logout := connect.NewRequest(&gen.LogoutRequest{}) + logout.Header().Set("Cookie", refreshTokenCookieName+"=cookie-rt") + refreshTokenFromCookie(logout.Header(), logout.Msg.GetRefreshToken(), func(token string) { + logout.Msg.RefreshToken = token + }) + require.Equal(t, "cookie-rt", logout.Msg.GetRefreshToken()) +} + +// An explicit body credential wins: the cookie is the browser's convenience, not an +// override — the same precedence the REST middleware uses. +func TestR1019ConnectBodyCredentialWinsOverTheCookie(t *testing.T) { + request := connect.NewRequest(&gen.RefreshTokenRequest{RefreshToken: "body-rt"}) + request.Header().Set("Cookie", refreshTokenCookieName+"=cookie-rt") + refreshTokenFromCookie(request.Header(), request.Msg.GetRefreshToken(), func(token string) { + request.Msg.RefreshToken = token + }) + require.Equal(t, "body-rt", request.Msg.GetRefreshToken()) +} + +// No cookie and no body leaves the message untouched, so the absence is reported by +// field validation rather than by an empty credential reaching the verifier. +func TestR1019ConnectWithNoCarrierIsUnchanged(t *testing.T) { + request := connect.NewRequest(&gen.RefreshTokenRequest{}) + request.Header().Set("Cookie", "codefly_session=1") + refreshTokenFromCookie(request.Header(), request.Msg.GetRefreshToken(), func(token string) { + request.Msg.RefreshToken = token + }) + require.Empty(t, request.Msg.GetRefreshToken()) +} + +// The lift has to run BEFORE the message is validated, or the empty field is +// rejected before the cookie is ever consulted. The handler is the only place that +// ordering holds, so it is asserted from the source. +func TestR1019ConnectRefreshLiftsBeforeValidation(t *testing.T) { + // Comments stripped: replacing the call with a comment that contains its text + // satisfied a substring search while nothing consumed the cookie (R1019-N15). + text := executableSource(t, "connect_handlers.go") + + for _, method := range []string{"RefreshToken", "Logout"} { + pattern := regexp.MustCompile(`func \(h \*authConnectHandler\) ` + method + + `\(ctx context\.Context(?s:.*?)\n\}\n`) + body := pattern.FindString(text) + require.NotEmpty(t, body, method) + lift := strings.Index(body, "refreshTokenFromCookie(") + dispatch := strings.Index(body, "h.inner.") + require.NotEqual(t, -1, lift, "%s must consume the cookie carrier", method) + require.Less(t, lift, dispatch, + "%s must lift the cookie before dispatching, or validation refuses the empty field first", + method) + } +} + +// R1019-N15: every assertion above either calls cookieTheRefreshToken directly or +// reads the source for the wrapper's NAME, so removing the lift from inside the +// wrapper survived the whole suite — the helper still worked when called by a test, +// and the handlers still named the wrapper that no longer called it. +// +// This drives the wrapper itself. It is the only test that fails if the call inside +// it goes away. +func TestR1019TheLiftRunsInsideTheWrapper(t *testing.T) { + request := connect.NewRequest(&gen.RefreshTokenRequest{RefreshToken: "rt-in"}) + + response, err := unaryCookieingRefreshToken(t.Context(), request, + func(context.Context, *gen.RefreshTokenRequest) (*gen.RefreshTokenResponse, error) { + return &gen.RefreshTokenResponse{AccessToken: "at", RefreshToken: "rt-secret"}, nil + }) + require.NoError(t, err) + + require.Empty(t, response.Msg.GetRefreshToken(), + "the wrapper must lift the credential out of the body it returns") + require.Equal(t, "at", response.Msg.GetAccessToken(), "the rest of the response is untouched") + setCookie := response.Header().Get("Set-Cookie") + require.Contains(t, setCookie, refreshTokenCookieName+"=rt-secret", + "the wrapper must put the credential in the cookie it lifted it into") + require.Contains(t, setCookie, "HttpOnly") +} diff --git a/module/services/accounts/code/pkg/adapters/registered_client_origin_test.go b/module/services/accounts/code/pkg/adapters/registered_client_origin_test.go new file mode 100644 index 000000000..1e9cd3879 --- /dev/null +++ b/module/services/accounts/code/pkg/adapters/registered_client_origin_test.go @@ -0,0 +1,113 @@ +//go:build pure + +package adapters + +import ( + "context" + "net/http" + "testing" + + "accounts/pkg/auth" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc/metadata" +) + +// The gateway→accounts origin contract, held from the accounts side. +// +// The invariant: the public origin this service treats as verified is fixed by +// operator configuration, and a forwarded value is honoured only where it equals +// the configured one. That guard is right and stays. What this test pins is the +// OTHER half of the same contract — that a registered client calling from its own +// permitted origin is not refused by it. +// +// The two halves met in one header. The gateway stamped a registered client's +// CORS origin as `X-Codefly-Public-Origin`, this service required that value to +// equal the configured host origin, and so configuring the host origin — which +// deployed boot requires — refused every authenticated request from every +// registered browser client before its RPC ran. Neither guard was wrong; the +// header meant two things about two different parties. +// +// It is asserted by VALUE rather than by driving the gateway, because the gateway +// is a separate Go module with no `go.work` and an accounts test cannot import it. +// The gateway's own half (`TestCORS_ARegisteredClientDoesNotSpeakForTheHostOrigin`) +// asserts that it stamps no public origin for a registered client; this asserts +// that what it does stamp is admitted. Together they are the contract; apart, +// either could pass while the pair stayed broken, which is exactly what happened. +func TestRegisteredClientReachesAccountsUnderAPinnedHostOrigin(t *testing.T) { + const ( + configuredHostOrigin = "https://host.example" + registeredClient = "example-addin" + clientOrigin = "https://addin.example" + ) + + previousToken := gatewayToken + SetGatewayToken("test-gateway-token") + t.Cleanup(func() { SetGatewayToken(previousToken) }) + + previousOrigin, _ := auth.ConfiguredPublicOrigin() + require.NoError(t, auth.SetConfiguredPublicOrigin(configuredHostOrigin)) + t.Cleanup(func() { _ = auth.SetConfiguredPublicOrigin(previousOrigin) }) + + // What the gateway forwards for an authenticated, registered browser client: + // the identity it verified, its own trust credential, and the client's id — + // and, since the separation, NO public origin, because the client is not a + // witness to this host's. + forwarded := func() http.Header { + h := http.Header{} + h.Set("X-Codefly-Gateway-Token", "test-gateway-token") + h.Set("X-User-Id", "01a12b95-153e-7c35-977b-7e1f6cffea65") + h.Set("X-Org-Id", "01a12b95-153e-7c38-b042-e09562cfd4f3") + h.Set("X-Org-Role", "admin") + h.Set("X-Session-Id", "01a12b95-153e-7c3c-86d2-80e898a68c54") + h.Set("X-Credential-Kind", "session") + // Present and empty, exactly as the gateway sends it. The forwarded + // credential is incomplete without this header at all, which is a + // different refusal from the one under test. + h.Set("X-Scopes", "") + h.Set("X-Client-Id", registeredClient) + h.Set("Origin", clientOrigin) + return h + } + + authorize := func(t *testing.T, transport string, h http.Header) error { + t.Helper() + if transport == "connect" { + _, err := (&connectPolicyInterceptor{}).authorize( + context.Background(), "/saas.accounts.v1.UserService/GetSelf", h) + return err + } + md := metadata.MD{} + for name, values := range h { + md.Set(name, values...) + } + _, err := (&grpcPolicyAuthorizer{exposure: rpcExposureTenant}).authorize( + metadata.NewIncomingContext(context.Background(), md), + "/saas.accounts.v1.UserService/GetSelf") + return err + } + + for _, transport := range []string{"connect", "grpc"} { + t.Run(transport, func(t *testing.T) { + // The host's own frontend path: it proves the configured origin and is + // admitted. This is the control — it passed before and must still. + host := forwarded() + host.Set(publicOriginHeader, configuredHostOrigin) + require.NoError(t, authorize(t, transport, host), + "the host's own origin must stay admitted") + + // The registered client, on its own origin, carrying no claim about + // this host's. Admitted, which is the defect this closes. + require.NoError(t, authorize(t, transport, forwarded()), + "a registered client on its permitted origin must reach accounts") + + // And the guard still bites: a forwarded origin that is NOT the + // configured one is refused, whoever sent it. Were this to pass, the + // separation above would have been bought by dropping the protection. + foreign := forwarded() + foreign.Set(publicOriginHeader, clientOrigin) + require.Error(t, authorize(t, transport, foreign), + "an origin other than the configured one must stay refused") + }) + } +} diff --git a/module/services/accounts/code/pkg/adapters/rest_extras.go b/module/services/accounts/code/pkg/adapters/rest_extras.go index cf52af89a..23c528636 100644 --- a/module/services/accounts/code/pkg/adapters/rest_extras.go +++ b/module/services/accounts/code/pkg/adapters/rest_extras.go @@ -16,7 +16,6 @@ import ( "bytes" "encoding/json" "io" - "net" "net/http" "strings" "sync" @@ -31,32 +30,35 @@ const refreshTokenCookieName = "codefly_rt" // refreshTokenCookie is an HTTP middleware that moves the refresh token between // the auth JSON endpoints and an httpOnly, SameSite=Strict cookie: // -// - POST /v1/auth/refresh: if the request body carries no refresh token, the -// one from the cookie is injected, so the browser never has to hold it. -// - 2xx responses of /v1/auth/authenticate, /v1/auth/mfa/complete, and -// /v1/auth/refresh: the refresh token is lifted out of the JSON body into -// the cookie and STRIPPED from the body, so JS never sees it. +// - POST /v1/auth/refresh and POST /v1/auth/logout: when the request body +// carries no refresh token, the one from the cookie is injected, so the +// browser never has to hold it. +// - 2xx responses of every authentication-completing route: the refresh token +// is lifted out of the JSON body into the cookie and STRIPPED from the body, +// so JS never sees it. // - POST /v1/auth/logout: the cookie is cleared. // // SameSite=Strict + the existing credentialed-CORS allowlist is the CSRF -// defense for the (same-origin) refresh call. Secure is set automatically on -// TLS so local http dev still works. +// defense for the (same-origin) refresh call. func refreshTokenCookie(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { path := r.URL.Path - isAuth := path == "/v1/auth/authenticate" - isMFAComplete := path == "/v1/auth/mfa/complete" - isRefresh := path == "/v1/auth/refresh" - isLogout := path == "/v1/auth/logout" + completesAuthentication := authenticationCompletingRESTPaths[path] + consumesCookieRefreshToken := cookieRefreshTokenRESTPaths[path] + isLogout := path == logoutRESTPath - if !isAuth && !isMFAComplete && !isRefresh && !isLogout { + if !completesAuthentication && !consumesCookieRefreshToken { next.ServeHTTP(w, r) return } - // Request side: feed the cookie's refresh token into the refresh call - // when the body doesn't already carry one. - if isRefresh { + // Request side: feed the cookie's refresh token into the call when the + // body doesn't already carry one. Logout needs this as much as refresh + // does — the browser's logout posts an empty body and the HttpOnly + // cookie, so without the lift the request failed field validation before + // Logout ran, leaving the session family and the access token live while + // the browser's own cookie was cleared. + if consumesCookieRefreshToken { if c, err := r.Cookie(refreshTokenCookieName); err == nil && c.Value != "" { r = injectRefreshToken(r, c.Value) } @@ -66,13 +68,27 @@ func refreshTokenCookie(next http.Handler) http.Handler { rec := &bodyCapture{ResponseWriter: w, status: http.StatusOK} next.ServeHTTP(rec, r) - if isLogout { - clearRefreshCookie(w, r) + // The cookie is cleared only when the sign-out COMPLETED. + // + // It was cleared on every logout response, including the 503 that says + // "logout incomplete, retry". That answer exists so the caller can retry, + // and the retry needs the refresh token the cookie holds — clearing it + // destroyed the only credential that could finish the job, while an + // unmarked access token stayed valid. The browser then had no way back to + // a complete sign-out and no way to tell that it had not had one. + // + // A failed sign-out leaving the credential in place is the lesser harm of + // the two: the family revocation runs LAST in Logout and its error is + // returned directly, so a 5xx here means the durable half did not land + // either and the session is genuinely still live. Pretending otherwise is + // what this avoids. + if isLogout && rec.status >= 200 && rec.status < 300 { + clearRefreshCookie(w) } body := rec.buf.Bytes() - if (isAuth || isMFAComplete || isRefresh) && rec.status >= 200 && rec.status < 300 { + if completesAuthentication && rec.status >= 200 && rec.status < 300 { if token, stripped, ok := extractAndStripRefreshToken(body); ok { - setRefreshCookie(w, r, token) + setRefreshCookie(w, token) body = stripped } } @@ -82,6 +98,61 @@ func refreshTokenCookie(next http.Handler) http.Handler { }) } +const logoutRESTPath = "/v1/auth/logout" + +// authenticationCompletingRESTPaths are the REST routes whose response carries a +// freshly minted refresh token. Every one of them must hand it to the browser as +// a cookie and strip it from the body; a route missing here returns the +// credential as readable content. +// +// TestEveryAuthCompletionCookiesTheRefreshToken derives the same set from the +// service descriptor and fails when the two disagree, so a new route cannot be +// added to the contract and left out here. The WebAuthn completion was exactly +// that: it shares its response message with the TOTP completion, and only the +// TOTP path was listed. +var authenticationCompletingRESTPaths = map[string]bool{ + "/v1/auth/authenticate": true, + "/v1/auth/mfa/complete": true, + "/v1/auth/mfa/webauthn/complete": true, + "/v1/auth/refresh": true, +} + +// The refresh cookie is scoped to `/v1/auth`, which is the REST auth surface and +// NOT the Connect procedure prefix (`/saas.accounts.v1.AuthService/…`). A browser +// therefore does not attach it to a Connect call, and that is deliberate: the +// alternative is a cookie scoped to `/`, which would attach the refresh credential +// to every request this origin serves, including the ones the frontend proxies +// onward. +// +// So the journey for a Connect client is defined rather than accidental: +// authenticate over Connect (the response sets the cookie and carries no readable +// token), then REFRESH AND SIGN OUT over REST `/v1/auth/refresh` and +// `/v1/auth/logout`, where the cookie is in scope. The Connect refresh and logout +// still work for a caller that supplies the credential itself — in its body, or as +// an explicit Cookie header — which refreshTokenFromCookie consumes. + +// oauthTokenEndpointRESTPath is the one authentication-completing route whose +// response carries the refresh credential in the body, and must. +// +// It is the OAuth 2.1 token endpoint. Its caller is a registered client +// exchanging an authorization code or a refresh token over a back-channel POST, +// not a browser completing a session: RFC 6749 §5.1 defines the response as a +// JSON body with `refresh_token` in it, and a Set-Cookie there reaches no client +// that could use it. The credential it issues is bound to that client's own +// session kind and registry, which is the separate contract the browser's +// session is deliberately not part of. +// +// Named as an exemption rather than simply absent, so the descriptor-derived +// coverage test can require it to be the ONLY one. +const oauthTokenEndpointRESTPath = "/v1/auth/token" + +// cookieRefreshTokenRESTPaths are the routes whose refresh-token field is served +// from the cookie when the body does not carry one. +var cookieRefreshTokenRESTPaths = map[string]bool{ + "/v1/auth/refresh": true, + logoutRESTPath: true, +} + // bodyCapture buffers the downstream handler's response so the middleware can // rewrite the body and headers before they reach the client. type bodyCapture struct { @@ -152,45 +223,51 @@ func extractAndStripRefreshToken(body []byte) (token string, stripped []byte, ok return token, out, true } -func secureRefreshCookie(r *http.Request) bool { - // Public hosts stay fail-closed behind TLS-terminating proxies. Loopback - // HTTP is the only exception because browsers otherwise discard the cookie. - host := r.Host - if parsedHost, _, err := net.SplitHostPort(host); err == nil { - host = parsedHost - } - if strings.EqualFold(host, "localhost") || strings.HasSuffix(strings.ToLower(host), ".localhost") { - return false - } - if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() { - return false - } - return true +// allowInsecureRefreshCookie drops Secure from the refresh cookie. It exists for +// one situation: a browser discards a Secure cookie on a plaintext loopback +// origin, so local development would have no session at all. +// +// It is a property of the DEPLOYMENT, and the zero value — every deployed +// environment, since only work.go sets it — keeps Secure. The flag replaces a +// derivation from the request's Host header, which a caller supplies: a request +// arriving at a deployed cell with `Host: localhost` was answered with a cookie +// the browser would then send over plaintext. +var allowInsecureRefreshCookie bool + +// SetAllowInsecureRefreshCookie wires the local-development exemption. Call it +// from codefly.IsLocal() only, like SetExposeAuthErrorDetail: it must never be +// true in a deployed environment. +func SetAllowInsecureRefreshCookie(v bool) { allowInsecureRefreshCookie = v } + +// secureRefreshCookie reports whether the refresh cookie carries Secure. +func secureRefreshCookie() bool { return !allowInsecureRefreshCookie } + +// refreshCookieMaxAgeSeconds matches the refresh-token TTL. +const refreshCookieMaxAgeSeconds = 7 * 24 * 60 * 60 + +func setRefreshCookie(w http.ResponseWriter, token string) { + http.SetCookie(w, refreshCookie(token, refreshCookieMaxAgeSeconds)) } -func setRefreshCookie(w http.ResponseWriter, r *http.Request, token string) { - http.SetCookie(w, &http.Cookie{ - Name: refreshTokenCookieName, - Value: token, - Path: "/v1/auth", - HttpOnly: true, - Secure: secureRefreshCookie(r), - SameSite: http.SameSiteStrictMode, - MaxAge: 7 * 24 * 60 * 60, // 7 days, matches refresh-token TTL - }) +func clearRefreshCookie(w http.ResponseWriter) { + cleared := refreshCookie("", -1) + cleared.Expires = time.Unix(0, 0) + http.SetCookie(w, cleared) } -func clearRefreshCookie(w http.ResponseWriter, r *http.Request) { - http.SetCookie(w, &http.Cookie{ +// refreshCookie is the one shape the refresh credential is ever handed to a +// browser in: scoped to the auth routes, not script-readable, same-site, and +// transport-protected outside local development. +func refreshCookie(token string, maxAge int) *http.Cookie { + return &http.Cookie{ Name: refreshTokenCookieName, - Value: "", + Value: token, Path: "/v1/auth", HttpOnly: true, - Secure: secureRefreshCookie(r), + Secure: secureRefreshCookie(), SameSite: http.SameSiteStrictMode, - MaxAge: -1, - Expires: time.Unix(0, 0), - }) + MaxAge: maxAge, + } } var ( diff --git a/module/services/accounts/code/pkg/adapters/rest_extras_test.go b/module/services/accounts/code/pkg/adapters/rest_extras_test.go index c154fceba..2ade0305a 100644 --- a/module/services/accounts/code/pkg/adapters/rest_extras_test.go +++ b/module/services/accounts/code/pkg/adapters/rest_extras_test.go @@ -97,21 +97,46 @@ func TestRefreshCookie_DoesNotTrustForwardedProto(t *testing.T) { } } -func TestRefreshCookie_LoopbackHTTPOriginCanStoreCookie(t *testing.T) { - h := refreshTokenCookie(downstream(`{"accessToken":"at","refreshToken":"rt-secret"}`)) - req := httptest.NewRequest(http.MethodPost, "http://localhost:21931/v1/auth/authenticate", strings.NewReader(`{}`)) +// The local-development exemption is the RUNTIME's, not the request's: a browser +// discards a Secure cookie on a plaintext loopback origin, so local development +// needs one, and nothing a caller sends may claim it. Asserted in both +// directions, because the defect was that a deployed cell answered `Host: +// localhost` with a cookie it would then send over plaintext. +func TestRefreshCookieSecureFlagComesFromTheRuntimeNotTheRequest(t *testing.T) { + loopbackRequest := func() *http.Request { + return httptest.NewRequest(http.MethodPost, + "http://localhost:21931/v1/auth/authenticate", strings.NewReader(`{}`)) + } + + // A deployed runtime (the zero value of the flag) keeps Secure even for a + // request that looks entirely like local development. rec := httptest.NewRecorder() - h.ServeHTTP(rec, req) + refreshTokenCookie(downstream(`{"accessToken":"at","refreshToken":"rt-secret"}`)). + ServeHTTP(rec, loopbackRequest()) + if cookie := refreshCookieOf(t, rec); !cookie.Secure { + t.Fatal("a deployed runtime must keep Secure whatever the request's host says") + } + // A local runtime drops it, so a developer still has a session. + SetAllowInsecureRefreshCookie(true) + t.Cleanup(func() { SetAllowInsecureRefreshCookie(false) }) + rec = httptest.NewRecorder() + refreshTokenCookie(downstream(`{"accessToken":"at","refreshToken":"rt-secret"}`)). + ServeHTTP(rec, loopbackRequest()) + if cookie := refreshCookieOf(t, rec); cookie.Secure { + t.Fatal("a local runtime must drop Secure so the browser stores the cookie") + } +} + +func refreshCookieOf(t *testing.T, rec *httptest.ResponseRecorder) *http.Cookie { + t.Helper() for _, cookie := range rec.Result().Cookies() { if cookie.Name == refreshTokenCookieName { - if cookie.Secure { - t.Fatal("loopback HTTP refresh cookie must be accepted by the browser") - } - return + return cookie } } t.Fatal("refresh-token cookie was not set") + return nil } func TestRefreshCookie_RefreshRequest_InjectsCookieIntoBody(t *testing.T) { diff --git a/module/services/accounts/code/pkg/adapters/rpcs.go b/module/services/accounts/code/pkg/adapters/rpcs.go index 22891eb60..c85892daf 100644 --- a/module/services/accounts/code/pkg/adapters/rpcs.go +++ b/module/services/accounts/code/pkg/adapters/rpcs.go @@ -579,6 +579,14 @@ func (s *PermServer) CreateRole(ctx context.Context, req *gen.CreateRoleRequest) if err := requireRoleScope(ctx, actorID, req.OrgId); err != nil { return nil, err } + // A privilege-granting or platform-security mutation sits behind a recent second + // factor (SP-IDENT-10). It runs AFTER the authorization guards and BEFORE the + // mutation: a factor check the write has already passed is not a check. + // requireMFA is fail-closed for a privileged actor, so an unenrolled platform + // role or organization owner is refused rather than admitted. + if err := requireMFA(ctx, actorID); err != nil { + return nil, err + } return service.CreateRole(ctx, actorID, req) } @@ -608,6 +616,14 @@ func (s *PermServer) UpdateRole(ctx context.Context, req *gen.UpdateRoleRequest) if err := requireRoleScope(ctx, actorID, req.OrgId); err != nil { return nil, err } + // A privilege-granting or platform-security mutation sits behind a recent second + // factor (SP-IDENT-10). It runs AFTER the authorization guards and BEFORE the + // mutation: a factor check the write has already passed is not a check. + // requireMFA is fail-closed for a privileged actor, so an unenrolled platform + // role or organization owner is refused rather than admitted. + if err := requireMFA(ctx, actorID); err != nil { + return nil, err + } return service.UpdateRole(ctx, actorID, req) } @@ -627,6 +643,14 @@ func (s *PermServer) DeleteRole(ctx context.Context, req *gen.DeleteRoleRequest) if err := requirePlatformRole(ctx, actorID, "super_admin"); err != nil { return nil, err } + // A privilege-granting or platform-security mutation sits behind a recent second + // factor (SP-IDENT-10). It runs AFTER the authorization guards and BEFORE the + // mutation: a factor check the write has already passed is not a check. + // requireMFA is fail-closed for a privileged actor, so an unenrolled platform + // role or organization owner is refused rather than admitted. + if err := requireMFA(ctx, actorID); err != nil { + return nil, err + } if err := service.DeleteRole(ctx, actorID, req); err != nil { return nil, err } @@ -644,6 +668,14 @@ func (s *PermServer) AssignRole(ctx context.Context, req *gen.AssignRoleRequest) if err := requireRoleScope(ctx, actorID, req.OrgId); err != nil { return nil, err } + // A privilege-granting or platform-security mutation sits behind a recent second + // factor (SP-IDENT-10). It runs AFTER the authorization guards and BEFORE the + // mutation: a factor check the write has already passed is not a check. + // requireMFA is fail-closed for a privileged actor, so an unenrolled platform + // role or organization owner is refused rather than admitted. + if err := requireMFA(ctx, actorID); err != nil { + return nil, err + } return service.AssignRole(ctx, req) } @@ -658,6 +690,14 @@ func (s *PermServer) RevokeRole(ctx context.Context, req *gen.RevokeRoleRequest) if err := requireRoleScope(ctx, actorID, req.OrgId); err != nil { return nil, err } + // A privilege-granting or platform-security mutation sits behind a recent second + // factor (SP-IDENT-10). It runs AFTER the authorization guards and BEFORE the + // mutation: a factor check the write has already passed is not a check. + // requireMFA is fail-closed for a privileged actor, so an unenrolled platform + // role or organization owner is refused rather than admitted. + if err := requireMFA(ctx, actorID); err != nil { + return nil, err + } if err := service.RevokeRole(ctx, actorID, req); err != nil { return nil, err } @@ -766,6 +806,15 @@ func (s *PermServer) GrantScope(ctx context.Context, req *gen.GrantScopeRequest) // The subject's tenancy is settled inside the grant's own transaction, under // the membership lock a removal takes (Service.grantScopeTx), not here: a // check committed before that transaction could be stale by the write. + // + // A privilege-granting or platform-security mutation sits behind a recent second + // factor (SP-IDENT-10). It runs AFTER the authorization guards and BEFORE the + // mutation: a factor check the write has already passed is not a check. + // requireMFA is fail-closed for a privileged actor, so an unenrolled platform + // role or organization owner is refused rather than admitted. + if err := requireMFA(ctx, actorID); err != nil { + return nil, err + } return service.GrantScope(ctx, actorID, req) } @@ -780,6 +829,14 @@ func (s *PermServer) RevokeScope(ctx context.Context, req *gen.RevokeScopeReques if err := requireRoleScope(ctx, actorID, req.OrgId); err != nil { return nil, err } + // A privilege-granting or platform-security mutation sits behind a recent second + // factor (SP-IDENT-10). It runs AFTER the authorization guards and BEFORE the + // mutation: a factor check the write has already passed is not a check. + // requireMFA is fail-closed for a privileged actor, so an unenrolled platform + // role or organization owner is refused rather than admitted. + if err := requireMFA(ctx, actorID); err != nil { + return nil, err + } if err := service.RevokeScope(ctx, actorID, req); err != nil { return nil, err } @@ -871,6 +928,19 @@ func (s *APIKeyServer) CreateAPIKey(ctx context.Context, req *gen.CreateAPIKeyRe if err := requireOrgAdmin(ctx, actorID, req.OrganizationId); err != nil { return nil, err } + // Issuing a credential is a privilege-changing mutation, so it sits behind a + // recent second factor like the others (SP-IDENT-10). The key this returns + // authenticates as its creator for as long as it lives and without the + // session that asked for it, which is the thing a factor is protecting: an + // owner whose session was taken could mint a credential that outlives the + // theft and survives a password change. + // + // AFTER the authorization guards and BEFORE the mutation. requireMFA is + // fail-closed for a privileged actor, so an unenrolled owner or platform + // role is refused rather than admitted. + if err := requireMFA(ctx, actorID); err != nil { + return nil, err + } response, err := service.CreateAPIKey(ctx, actorID, req) return response, quotaStatusError(err) } @@ -1078,7 +1148,83 @@ func (s *AuthServer) RefreshToken(ctx context.Context, req *gen.RefreshTokenRequ if err := Validate(req); err != nil { return nil, err } - return service.RefreshToken(ctx, req) + resp, err := service.RefreshToken(ctx, req) + if err != nil { + return nil, refreshStatusError(ctx, err) + } + return resp, nil +} + +// refreshStatusError maps a refusal to refresh onto the one answer a caller is +// allowed to see: unauthenticated, with a fixed reason. +// +// Every rejection the session store can reach — an unknown token, a revoked +// family, an expired or idled-out session, a reused token — is reported as +// ErrRefreshRevoked or ErrRefreshReuse, deliberately indistinguishable so the +// store is not an oracle. Returning the wrapped chain undid that twice over: it +// named the internal call path, and a refusal counted as a server error, so an +// expired cookie — the single most ordinary thing a browser can present — +// arrived as HTTP 500 and was counted against availability. +// +// The cause is logged, where it is diagnosable without being disclosed. +// Anything that is NOT a refusal (a store that cannot be reached, an invalid +// session policy) still passes through unchanged: it is a genuine server-side +// failure, and reporting it as a credential problem would send an operator +// looking at the wrong half. +// innerStatus finds a gRPC status an error carries beneath any wrapping, or nil. +// status.Convert cannot be used for this: given a wrapped error it builds a status +// from the whole chain's text, which is the internal detail the caller must not see. +func innerStatus(err error) *status.Status { + var carrier interface{ GRPCStatus() *status.Status } + if errors.As(err, &carrier) { + return carrier.GRPCStatus() + } + return nil +} + +// callerFacingRefusal reports whether a status is a DECISION ABOUT THE CALLER, whose +// message was therefore written for the caller to read. +// +// Being a status does not make a message public-safe (R1019-N10). A code in this set +// answers "you may not" or "that input is wrong", and the reason is the answer. The +// codes outside it — Internal, Unavailable, Unknown, DataLoss, Aborted, DeadlineExceeded +// — answer "this host failed", and their messages carry whatever the failing layer +// happened to say: a DSN, a host name, a driver's text. Those are logged and replaced. +// +// OK is outside the set deliberately. (*status.Status).Err() returns nil for an OK +// status, so passing one through would turn a failure into a nil error beside a nil +// response, and the caller would read a success that never happened. +func callerFacingRefusal(st *status.Status) bool { + switch st.Code() { + case codes.Unauthenticated, codes.PermissionDenied, codes.FailedPrecondition, + codes.InvalidArgument, codes.NotFound, codes.AlreadyExists, codes.OutOfRange: + return true + default: + return false + } +} + +func refreshStatusError(ctx context.Context, err error) error { + inner := innerStatus(err) + switch { + case errors.Is(err, auth.ErrRefreshReuse), errors.Is(err, auth.ErrRefreshRevoked): + wool.Get(ctx).In("RefreshToken").Warn("refresh refused", wool.ErrField(err)) + return status.Error(codes.Unauthenticated, "invalid refresh token") + case inner != nil && callerFacingRefusal(inner): + // A deliberate refusal from further in — a session that is no longer active, a + // policy refusal. Its reason was chosen for the caller, so it is returned + // WITHOUT the wrapper: the wrapping is what names the internal call chain, + // and returning err unchanged would carry it out with the good reason. + return inner.Err() + default: + // Anything else is this host failing, and the chain — status-carrying or not — + // names the internal call path and the store's address. SP-GW-13: a refused or + // failed request carries a stable public reason and the cause is logged, not + // returned. Unavailable rather than Internal because a refresh is worth + // retrying. + wool.Get(ctx).In("RefreshToken").Error("refresh failed", wool.ErrField(err)) + return status.Error(codes.Unavailable, "refresh temporarily unavailable") + } } func (s *AuthServer) SwitchOrganization(ctx context.Context, req *gen.SwitchOrganizationRequest) (*gen.SwitchOrganizationResponse, error) { @@ -1113,7 +1259,15 @@ func (s *AuthServer) BeginOAuth(ctx context.Context, req *gen.BeginOAuthRequest) } state, err := service.BeginOAuth(ctx, req.Provider, req.RedirectUri) if err != nil { - return nil, status.Errorf(codes.FailedPrecondition, "cannot mint oauth state: %v", err) + // A refused provider or redirect is the caller's request being wrong, + // not this host failing; and the reason is a wrapped internal chain, + // which named the call path to an unauthenticated caller. Log it, and + // answer the one thing the caller may know. + wool.Get(ctx).In("BeginOAuth").Warn("oauth begin refused", wool.ErrField(err)) + if errors.Is(err, auth.ErrInvalidOAuthRequest) { + return nil, status.Error(codes.InvalidArgument, "provider or redirect_uri not allowed") + } + return nil, status.Error(codes.FailedPrecondition, "oauth state unavailable") } return &gen.BeginOAuthResponse{State: state}, nil } @@ -1127,7 +1281,21 @@ func (s *AuthServer) Logout(ctx context.Context, req *gen.LogoutRequest) (*empty // family. Empty when called from a non-authenticated context (e.g. // the FE only forwards the refresh, never the access). accessToken := bearerFromContext(ctx) - if err := service.Logout(ctx, req, accessToken); err != nil { + // The verified session, from the credential the caller actually presented + // rather than from the message: the session's access half is then revoked on + // the strength of who is calling, not of what the body happened to carry. + var verifiedSession string + if sessionID, ok := auth.VerifiedSessionID(ctx); ok { + verifiedSession = sessionID.String() + } + if err := service.Logout(ctx, req, accessToken, verifiedSession); err != nil { + // An incomplete sign-out is reported as retryable rather than as success: + // the family is revoked, so nothing refreshes, but an already-issued access + // token stays usable until it expires and the caller may want to retry. + // The reason is fixed; the cause is logged in the business layer. + if errors.Is(err, business.ErrLogoutIncomplete) { + return nil, status.Error(codes.Unavailable, "logout incomplete, retry") + } return nil, err } return &emptypb.Empty{}, nil @@ -1570,6 +1738,14 @@ func (s *PlatformAdminServer) SuspendUser(ctx context.Context, req *gen.SuspendU if err := requirePlatformRole(ctx, actorID, "super_admin"); err != nil { return nil, err } + // A privilege-granting or platform-security mutation sits behind a recent second + // factor (SP-IDENT-10). It runs AFTER the authorization guards and BEFORE the + // mutation: a factor check the write has already passed is not a check. + // requireMFA is fail-closed for a privileged actor, so an unenrolled platform + // role or organization owner is refused rather than admitted. + if err := requireMFA(ctx, actorID); err != nil { + return nil, err + } if err := service.SuspendUser(ctx, actorID, req); err != nil { return nil, err } @@ -1587,6 +1763,14 @@ func (s *PlatformAdminServer) UnsuspendUser(ctx context.Context, req *gen.Unsusp if err := requirePlatformRole(ctx, actorID, "super_admin"); err != nil { return nil, err } + // A privilege-granting or platform-security mutation sits behind a recent second + // factor (SP-IDENT-10). It runs AFTER the authorization guards and BEFORE the + // mutation: a factor check the write has already passed is not a check. + // requireMFA is fail-closed for a privileged actor, so an unenrolled platform + // role or organization owner is refused rather than admitted. + if err := requireMFA(ctx, actorID); err != nil { + return nil, err + } if err := service.UnsuspendUser(ctx, actorID, req); err != nil { return nil, err } @@ -1655,6 +1839,14 @@ func (s *PlatformAdminServer) RevokeSession(ctx context.Context, req *gen.Revoke if err := requirePlatformRole(ctx, actorID, "support"); err != nil { return nil, err } + // A privilege-granting or platform-security mutation sits behind a recent second + // factor (SP-IDENT-10). It runs AFTER the authorization guards and BEFORE the + // mutation: a factor check the write has already passed is not a check. + // requireMFA is fail-closed for a privileged actor, so an unenrolled platform + // role or organization owner is refused rather than admitted. + if err := requireMFA(ctx, actorID); err != nil { + return nil, err + } if err := service.RevokeSession(ctx, actorID, req); err != nil { return nil, err } @@ -1709,6 +1901,14 @@ func (s *PlatformAdminServer) OverrideEntitlement(ctx context.Context, req *gen. if err := requirePlatformAdmin(ctx, actorID); err != nil { return nil, err } + // A privilege-granting or platform-security mutation sits behind a recent second + // factor (SP-IDENT-10). It runs AFTER the authorization guards and BEFORE the + // mutation: a factor check the write has already passed is not a check. + // requireMFA is fail-closed for a privileged actor, so an unenrolled platform + // role or organization owner is refused rather than admitted. + if err := requireMFA(ctx, actorID); err != nil { + return nil, err + } id, err := service.OverrideEntitlement(ctx, actorID, req) if err != nil { return nil, status.Error(codes.Internal, err.Error()) diff --git a/module/services/accounts/code/pkg/adapters/source_assertions_test.go b/module/services/accounts/code/pkg/adapters/source_assertions_test.go new file mode 100644 index 000000000..1dcb833f2 --- /dev/null +++ b/module/services/accounts/code/pkg/adapters/source_assertions_test.go @@ -0,0 +1,73 @@ +package adapters + +import ( + "go/parser" + "go/token" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// Shared machinery for the tests in this package that assert on SOURCE rather than on +// behaviour — the factor gates, the reserved-audience guards, the Connect cookie lift. +// +// It lives in its own file because three test files use it. It was defined in the one +// named for a single finding, which is how a helper becomes invisible to the next +// person who needs it (R1019-N12/f7). + +// executableSource is the file with every comment removed, so prose can neither +// satisfy nor break a source assertion. A comment is not code; a check that cannot +// tell them apart is satisfied by writing the answer in a comment. +func executableSource(t *testing.T, name string) string { + t.Helper() + source, err := os.ReadFile(name) + require.NoError(t, err) + fileSet := token.NewFileSet() + // ParseComments is required: without it File.Comments is EMPTY and this function + // silently strips nothing, which is a check that looks like it works. It was that + // way until a mutation test proved a commented-out guard still satisfied a caller. + parsed, err := parser.ParseFile(fileSet, name, source, + parser.ParseComments|parser.SkipObjectResolution) + require.NoError(t, err) + + stripped := append([]byte(nil), source...) + for _, group := range parsed.Comments { + // Position().Offset is the canonical byte offset. Deriving one from Pos() and + // the file's Base() by hand is where this silently became a no-op. + start := fileSet.Position(group.Pos()).Offset + end := fileSet.Position(group.End()).Offset + for i := start; i < end && i < len(stripped); i++ { + if stripped[i] != '\n' { + stripped[i] = ' ' + } + } + } + return string(stripped) +} + +// The guard against that failure mode: this function must actually remove a comment, +// and must leave code alone. Asserted against a file written for the purpose — asserting +// against this file would compare its own assertion literals, which are code, and a +// needle that appears in both can never be shown absent. +func TestExecutableSourceRemovesComments(t *testing.T) { + dir := t.TempDir() + name := filepath.Join(dir, "sample.go") + require.NoError(t, os.WriteFile(name, []byte(`package sample + +// a line comment naming guardedCall() +func f() { + /* a block comment naming guardedCall() */ + guardedCall() +} +`), 0o600)) + + text := executableSource(t, name) + require.Contains(t, text, "\tguardedCall()", "the executable call must survive") + require.NotContains(t, text, "a line comment", "a line comment must not survive") + require.NotContains(t, text, "a block comment", "a block comment must not survive") + require.Equal(t, 1, strings.Count(text, "guardedCall()"), + "only the executable occurrence may remain; the two in comments must be gone") +} diff --git a/module/services/accounts/code/pkg/adapters/work_context_confined_mint_test.go b/module/services/accounts/code/pkg/adapters/work_context_confined_mint_test.go new file mode 100644 index 000000000..75fef3d17 --- /dev/null +++ b/module/services/accounts/code/pkg/adapters/work_context_confined_mint_test.go @@ -0,0 +1,383 @@ +package adapters + +// A MINT MADE WITH A RESOURCE-CONFINED CREDENTIAL is held to the consumer that +// credential was delivered to (register finding SA-F-MCPAUD, round 3; +// holdConfinedMintToItsConsumer carries the rule). +// +// These run the WHOLE procedure, not the helper, because every refusal has to +// land before anything is signed and because the rule composes with the mint's +// other gates — the audience vocabulary, the boundary collision check, the +// viewer's own authority. A test of the helper alone could not tell a refusal +// from a refusal that arrives too late. +// +// The counterpart is the perimeter's: it is the gateway that resolves which +// installation a resource's consumer is installed as, and the gateway's own +// tests prove the two headers below are stamped from a verified credential and +// stripped from caller input. + +import ( + "context" + "net/http" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + accountsauth "accounts/pkg/auth" + "accounts/pkg/business" + gen "accounts/pkg/gen/saas/accounts/v1" +) + +const ( + confinedInstallation = "019f6bf7-7777-7777-8777-777777777771" + confinedConsumerID = "019f6bf7-8888-7888-8888-888888888881" + confinedResource = "https://host.example.com/api/solutions/example/proxy/mcp" +) + +// confinedConsumer is the installed consumer's ceiling: it may address the +// `documents` module and act on evidence, and nothing else. +func confinedConsumer() *business.InstallationAuthorityFacts { + return &business.InstallationAuthorityFacts{ + OwnerPrincipalID: renewActorID, + Actor: &business.Principal{ + ID: confinedConsumerID, + Kind: "agent", + AgentIdentifier: "acme/example:1.0.0", + AllowedAudiences: []string{"documents"}, + AllowedScopes: []string{"evidence"}, + }, + OrganizationRevision: 12, + } +} + +// confinedCallerContext is the request the perimeter admitted: the viewer's +// identity, the resource their credential is confined to, and the installation +// that admitted it. +// confinedMintRequest is boundaryMintRequest with a Task named, which an +// ordinary (non-solution-scoped) mint requires: the host assigns a boundary only +// to a solution's own credential, and a viewer-owned Task names its own. +func confinedMintRequest(audience string) *gen.StartTaskWorkContextRequest { + request := boundaryMintRequest(audience) + request.TaskId = uuid.Must(uuid.NewV7()).String() + return request +} + +func confinedCallerContext(resource, installation string) context.Context { + ctx := accountsauth.WithVerifiedResourceConfinement(boundaryCaller(), resource) + return accountsauth.WithVerifiedInstallation(ctx, installation) +} + +// withConfiguredOrigin pins the origin the resource indicator must name, the way +// a deployment pins it. Without it a confined mint refuses for a reason that has +// nothing to do with what these tests are about, which is itself asserted below. +func withConfiguredOrigin(t *testing.T) { + t.Helper() + previous, _ := accountsauth.ConfiguredPublicOrigin() + require.NoError(t, accountsauth.SetConfiguredPublicOrigin("https://host.example.com")) + t.Cleanup(func() { _ = accountsauth.SetConfiguredPublicOrigin(previous) }) +} + +func newConfinedMintServer(t *testing.T) *WorkContextAuthorityServer { + t.Helper() + server, authority := newSolutionBoundaryServer(t) + authority.installationFcts = confinedConsumer() + return server +} + +// The consumer's own work: the audience its installation declares, a scope its +// agent is granted, in its own organization. Admitted. +func TestConfinedMintIsIssuedInsideItsConsumersCeiling(t *testing.T) { + solutionBoundaryService(t) + withConfiguredOrigin(t) + server := newConfinedMintServer(t) + + issued, err := server.StartTask( + confinedCallerContext(confinedResource, confinedInstallation), + confinedMintRequest("documents")) + + require.NoError(t, err) + require.NotEmpty(t, issued.GetToken()) +} + +// The three refusals, each on its own, with everything else held equal. +func TestConfinedMintIsRefusedOutsideItsConsumersCeiling(t *testing.T) { + for name, probe := range map[string]struct { + request func() *gen.StartTaskWorkContextRequest + ctx func() context.Context + expectedCode codes.Code + expectedText string + }{ + // 1. ANOTHER ORGANIZATION. The viewer may well be a member of it — + // requireOrgMember would pass — but the installation the perimeter + // checked is in the organization the credential names. + "another organization": { + request: func() *gen.StartTaskWorkContextRequest { + req := confinedMintRequest("documents") + req.OrgId = uuid.Must(uuid.NewV7()).String() + return req + }, + expectedCode: codes.PermissionDenied, + expectedText: "that credential's own organization", + }, + // 2. AN AUDIENCE THE CONSUMER DOES NOT DECLARE. `example` is in the + // host's vocabulary — a declared module prefix — so the vocabulary gate + // admits it and only the installation's ceiling refuses it. + "an audience the consumer does not declare": { + request: func() *gen.StartTaskWorkContextRequest { return confinedMintRequest("example") }, + expectedCode: codes.PermissionDenied, + expectedText: "allowed audiences", + }, + // 3. A SCOPE OUTSIDE THE INSTALLATION'S GRANT. + "a scope the installation does not grant": { + request: func() *gen.StartTaskWorkContextRequest { + req := confinedMintRequest("documents") + req.AuthorityScopes = []*gen.WorkContextScope{{ + ResourceKind: "billing", Actions: []string{"write"}, + }} + return req + }, + expectedCode: codes.PermissionDenied, + expectedText: "allowed scopes", + }, + // A confinement the perimeter admitted without naming an installation. + // Nothing produces it, and a mint is not issued on a ceiling nobody can + // be held to. + "no installation to be held to": { + request: func() *gen.StartTaskWorkContextRequest { return confinedMintRequest("documents") }, + ctx: func() context.Context { + return accountsauth.WithVerifiedResourceConfinement(boundaryCaller(), confinedResource) + }, + expectedCode: codes.FailedPrecondition, + expectedText: "no consented ceiling", + }, + // A resource this host does not issue. The confinement is unaccountable, + // so it refuses rather than passing through unread. + "a resource this host does not issue": { + request: func() *gen.StartTaskWorkContextRequest { return confinedMintRequest("documents") }, + ctx: func() context.Context { + return confinedCallerContext("https://elsewhere.example/api/solutions/example/proxy/mcp", confinedInstallation) + }, + expectedCode: codes.PermissionDenied, + expectedText: "does not issue", + }, + } { + t.Run(name, func(t *testing.T) { + solutionBoundaryService(t) + withConfiguredOrigin(t) + server := newConfinedMintServer(t) + ctx := confinedCallerContext(confinedResource, confinedInstallation) + if probe.ctx != nil { + ctx = probe.ctx() + } + + issued, err := server.StartTask(ctx, probe.request()) + + require.Error(t, err, "nothing may be signed for %s", name) + require.Nil(t, issued) + require.Equal(t, probe.expectedCode, status.Code(err)) + require.Contains(t, err.Error(), probe.expectedText) + }) + } +} + +// THE CONTROLS. Without them every refusal above would also be produced by a +// mint that refused everything. +// +// An UNCONFINED request — every other request this service serves — reaches the +// viewer's own authority and is bounded by it alone: the same audience and the +// same scope the consumer's ceiling refuses above are issued here, so the +// refusals are about the confinement and not about the mint. +func TestAnUnconfinedMintIsNotHeldToAnyConsumer(t *testing.T) { + solutionBoundaryService(t) + withConfiguredOrigin(t) + server := newConfinedMintServer(t) + + request := confinedMintRequest("example") + request.AuthorityScopes = []*gen.WorkContextScope{{ + ResourceKind: "billing", Actions: []string{"write"}, + }} + issued, err := server.StartTask(boundaryCaller(), request) + + require.NoError(t, err) + require.NotEmpty(t, issued.GetToken()) +} + +// A deployment with no configured origin cannot say what a resource identifier +// IS, so it cannot account for one. It refuses the confined mint, and says which +// of the two refusals it is — an operator fixes a configuration, a client +// re-authorizes. +func TestConfinedMintRefusesWithNoConfiguredOrigin(t *testing.T) { + solutionBoundaryService(t) + previous, _ := accountsauth.ConfiguredPublicOrigin() + require.NoError(t, accountsauth.SetConfiguredPublicOrigin("")) + t.Cleanup(func() { _ = accountsauth.SetConfiguredPublicOrigin(previous) }) + server := newConfinedMintServer(t) + + issued, err := server.StartTask( + confinedCallerContext(confinedResource, confinedInstallation), + confinedMintRequest("documents")) + + require.Error(t, err) + require.Nil(t, issued) + require.Equal(t, codes.FailedPrecondition, status.Code(err)) + require.Contains(t, err.Error(), "no configured public address") +} + +// THE DELIVERY SHAPE, not a constructed context: the headers the gateway +// forwards, through the REAL Connect interceptor, into the REAL StartTask. +// +// Every test above installs the confinement with the context carriers directly, +// which proves the rule and not the route to it. This proves the route: that the +// perimeter's two headers survive the interceptor's strip, reach the mint as +// facts, and are refused when a caller supplies them without the gateway's own +// credential or supplies either one twice. +func forwardedByTheGateway(confined bool) http.Header { + headers := http.Header{} + headers.Set("X-Codefly-Gateway-Token", "test-gateway-token") + headers.Set("X-User-Id", renewActorID) + headers.Set("X-Org-Id", renewOrgID) + headers.Set("X-Org-Role", "admin") + headers.Set("X-Session-Id", renewSession) + headers.Set("X-Credential-Kind", credentialKindSession) + headers.Set("X-Scopes", "") + if confined { + headers.Set(resourceConfinementHeader, confinedResource) + headers.Set(installationHeader, confinedInstallation) + } + return headers +} + +func authorizeAsTheGateway(t *testing.T, headers http.Header) (context.Context, error) { + t.Helper() + previous := gatewayToken + SetGatewayToken("test-gateway-token") + t.Cleanup(func() { SetGatewayToken(previous) }) + // The procedure name is the policy lookup key, so it is the real one: an + // unclassified procedure is refused before any header is read. + return (&connectPolicyInterceptor{}).authorize( + context.Background(), "/saas.accounts.v1.WorkContextService/StartTask", headers) +} + +func TestConfinedMintThroughTheRealInterceptor(t *testing.T) { + for name, probe := range map[string]struct { + request func() *gen.StartTaskWorkContextRequest + want codes.Code + }{ + "control: inside the ceiling": { + request: func() *gen.StartTaskWorkContextRequest { return confinedMintRequest("documents") }, + want: codes.OK, + }, + "another organization": { + request: func() *gen.StartTaskWorkContextRequest { + request := confinedMintRequest("documents") + request.OrgId = uuid.Must(uuid.NewV7()).String() + return request + }, + want: codes.PermissionDenied, + }, + "an audience the consumer does not declare": { + request: func() *gen.StartTaskWorkContextRequest { return confinedMintRequest("example") }, + want: codes.PermissionDenied, + }, + } { + t.Run(name, func(t *testing.T) { + solutionBoundaryService(t) + withConfiguredOrigin(t) + server := newConfinedMintServer(t) + ctx, err := authorizeAsTheGateway(t, forwardedByTheGateway(true)) + require.NoError(t, err) + + issued, err := server.StartTask(ctx, probe.request()) + + require.Equal(t, probe.want, status.Code(err), "err=%v", err) + if probe.want == codes.OK { + require.NotEmpty(t, issued.GetToken()) + } + }) + } +} + +// AN EMPTY CEILING IS NOTHING FOR A CONFINED MINT, where it is unrestricted +// everywhere else. +// +// This is the ordinary state, not an edge case: both fields are optional, the +// proto documents empty as unrestricted, and the install dialog leaves them +// empty. Read that way here, a credential delivered to one third-party client +// mints for any audience this host serves and then pairs at every module the +// organization installed — which is the escape the ceiling exists to close. +func TestConfinedMintUnderAnUndeclaredCeilingIsRefused(t *testing.T) { + for name, strip := range map[string]func(*business.Principal){ + "no declared audiences": func(p *business.Principal) { p.AllowedAudiences = nil }, + "no declared scopes": func(p *business.Principal) { p.AllowedScopes = nil }, + } { + t.Run(name, func(t *testing.T) { + solutionBoundaryService(t) + withConfiguredOrigin(t) + server, authority := newSolutionBoundaryServer(t) + consumer := confinedConsumer() + strip(consumer.Actor) + authority.installationFcts = consumer + ctx, err := authorizeAsTheGateway(t, forwardedByTheGateway(true)) + require.NoError(t, err) + + // `documents` is what this consumer's full ceiling DOES declare, so + // the only thing that can refuse it here is the empty list itself. + issued, err := server.StartTask(ctx, confinedMintRequest("documents")) + + require.Error(t, err, "an installation with %s put no bound on a credential delivered to a third party", name) + require.Nil(t, issued) + require.Equal(t, codes.FailedPrecondition, status.Code(err)) + require.Contains(t, err.Error(), "declares no allowed") + }) + } +} + +// THE CONTROL for the two above: the same empty ceiling puts no bound on an +// UNCONFINED mint, which is what the field means everywhere else and what an +// installation's own headless work relies on. +func TestAnUndeclaredCeilingStillLeavesAnUnconfinedMintUnbounded(t *testing.T) { + solutionBoundaryService(t) + withConfiguredOrigin(t) + server, authority := newSolutionBoundaryServer(t) + consumer := confinedConsumer() + consumer.Actor.AllowedAudiences = nil + consumer.Actor.AllowedScopes = nil + authority.installationFcts = consumer + + issued, err := server.StartTask(boundaryCaller(), confinedMintRequest("example")) + + require.NoError(t, err) + require.NotEmpty(t, issued.GetToken()) +} + +// The two ceiling headers are read ONLY behind the gateway's own credential, and +// only once each. Without the credential they are deleted — which also leaves the +// request unauthenticated, so there is nothing to mint for either. +func TestCeilingHeadersCannotBeForged(t *testing.T) { + solutionBoundaryService(t) + withConfiguredOrigin(t) + + t.Run("no gateway credential", func(t *testing.T) { + headers := forwardedByTheGateway(true) + headers.Del("X-Codefly-Gateway-Token") + ctx, err := authorizeAsTheGateway(t, headers) + if err == nil { + _, err = newConfinedMintServer(t).StartTask(ctx, confinedMintRequest("documents")) + } + require.Error(t, err) + }) + + for name, header := range map[string]string{ + "a second installation": installationHeader, + "a second resource": resourceConfinementHeader, + } { + t.Run(name+" is refused", func(t *testing.T) { + headers := forwardedByTheGateway(true) + headers.Add(header, "a-value-the-perimeter-did-not-stamp") + _, err := authorizeAsTheGateway(t, headers) + require.Error(t, err, "a second %s would let the path that added it choose the ceiling", header) + }) + } +} diff --git a/module/services/accounts/code/pkg/adapters/work_context_reserved_audience_test.go b/module/services/accounts/code/pkg/adapters/work_context_reserved_audience_test.go new file mode 100644 index 000000000..cf4b4b112 --- /dev/null +++ b/module/services/accounts/code/pkg/adapters/work_context_reserved_audience_test.go @@ -0,0 +1,214 @@ +package adapters + +import ( + "context" + "regexp" + "strings" + "testing" + + gen "accounts/pkg/gen/saas/accounts/v1" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// Every peer that reads the reserved module audience treats a context carrying +// it as a composed module acting with no person present. A person-driven mint +// that yields that spelling contradicts the premise those peers rely on, even +// where nothing downstream grants it anything — the register's reproducer is an +// audience exchange that succeeds and a module surface that verifies the result. +// +// The refusal is asserted on an UNAUTHENTICATED caller on purpose: it must come +// before the caller's authority is resolved, so the reserved audience is +// unreachable rather than merely unreachable-for-this-person. +// +// These are the four mints a signed-in person reaches with an audience of their +// choosing. The other two entry points that read an audience are refused by the +// same gate but cannot be reached this way, and each for a reason that is the +// point rather than a gap: +// +// - StartInstallationTask opens with requireInternalCredential, so it is not a +// person-driven mint at all — an unauthenticated caller is turned away +// before any audience is looked at. +// - RenewWorkContext gates the RESOLVED audience, after authenticating: a +// renewal naming none inherits the parent's, and it is that value which is +// checked and stamped. Gating the request field instead would let a renewal +// carry the reserved spelling forward from a parent while naming nothing. +// +// TestEveryRequestSuppliedAudienceRefusesTheReservedOne is what holds those two +// to the gate, from the source, so dropping it from either is still caught. +func TestPersonMintsRefuseTheModuleAudience(t *testing.T) { + server := &WorkContextAuthorityServer{} + ctx := context.Background() + + calls := []struct { + name string + call func() error + }{ + {"StartTask", func() error { + _, err := server.StartTask(ctx, &gen.StartTaskWorkContextRequest{ + OrgId: uuidForReservedAudienceTest, TaskId: uuidForReservedAudienceTest, + SessionId: uuidForReservedAudienceTest, + ActorPrincipalId: uuidForReservedAudienceTest, + AuthorityScopes: scopesForReservedAudienceTest(), + Audience: ModuleWorkContextAudience, + }) + return err + }}, + {"StartRootSession", func() error { + _, err := server.StartRootSession(ctx, &gen.StartRootSessionWorkContextRequest{ + OrgId: uuidForReservedAudienceTest, ParentWorkContextToken: "parent.token", + SessionId: uuidForReservedAudienceTest, + Audience: ModuleWorkContextAudience, + }) + return err + }}, + {"ExchangeAudience", func() error { + _, err := server.ExchangeAudience(ctx, &gen.ExchangeWorkContextAudienceRequest{ + OrgId: uuidForReservedAudienceTest, ParentWorkContextToken: "parent.token", + AttenuatedScopes: scopesForReservedAudienceTest(), + Audience: ModuleWorkContextAudience, + }) + return err + }}, + {"StartChildSession", func() error { + _, err := server.StartChildSession(ctx, &gen.StartChildSessionWorkContextRequest{ + OrgId: uuidForReservedAudienceTest, ParentWorkContextToken: "parent.token", + SessionId: uuidForReservedAudienceTest, ActorPrincipalId: uuidForReservedAudienceTest, + GrantedScopes: scopesForReservedAudienceTest(), + Audience: ModuleWorkContextAudience, + }) + return err + }}, + } + + for _, tc := range calls { + t.Run(tc.name, func(t *testing.T) { + err := tc.call() + require.Error(t, err) + require.Equal(t, codes.PermissionDenied, status.Code(err), + "the reserved audience must be refused before the caller's authority is resolved") + require.Contains(t, status.Convert(err).Message(), "never caller-supplied") + }) + } +} + +// The counterpart: an ordinary audience is NOT refused for BEING the reserved +// one, so the test above cannot pass on a server that refuses every audience. +// Such a mint is still refused — by the vocabulary half, which this server has +// no registry to answer from — and that is a different refusal naming a +// different thing, which is the whole distinction being asserted. +func TestAnOrdinaryAudiencePassesTheReservedAudienceGuard(t *testing.T) { + _, err := (&WorkContextAuthorityServer{}).ExchangeAudience(context.Background(), + &gen.ExchangeWorkContextAudienceRequest{ + OrgId: uuidForReservedAudienceTest, ParentWorkContextToken: "parent.token", + AttenuatedScopes: scopesForReservedAudienceTest(), + Audience: "modelservice", + }) + require.Error(t, err) + require.NotContains(t, status.Convert(err).Message(), "never caller-supplied") +} + +// A mint added later must not be able to skip the guard silently. Every +// EXPORTED method of this server that takes its audience FROM THE REQUEST has to +// run the audience gate; the module mints are exempt because they pass the +// audience themselves rather than reading it off a caller's message. +// +// Exported only, because only an exported method is an RPC: the generated +// service interface is what a caller can reach, and it has no unexported +// members. An unexported helper that reads an audience is reached through one of +// these methods, which has already gated, or through host code that builds the +// request itself (exchangeVerifiedParent has one such caller in +// delegated_read_audience.go, where the audience is a derived binding's, not a +// caller's). Matching those too would make this test demand a second gate on a +// path with no caller-supplied value to gate. +func TestEveryRequestSuppliedAudienceRefusesTheReservedOne(t *testing.T) { + // Comments stripped: a source check that cannot tell code from prose is satisfied + // by writing the answer in a comment (R1019-N15). + text := executableSource(t, "work_context_rpcs.go") + + methods := regexp.MustCompile(`(?m)^func \(s \*WorkContextAuthorityServer\) ([A-Z][A-Za-z]*)\(`). + FindAllStringSubmatchIndex(text, -1) + require.NotEmpty(t, methods) + for index, match := range methods { + name := text[match[2]:match[3]] + end := len(text) + if index+1 < len(methods) { + end = methods[index+1][0] + } + body := text[match[0]:end] + if !strings.Contains(body, "req.GetAudience()") { + continue + } + // Two spellings, both of which gate the request's audience: the field + // directly, or the local that a renewal resolves it into. Anything else + // passing through this gate is a value this test cannot vouch for, so it + // is not accepted as one. + // + // Each is matched as a guard whose error LEAVES the handler. Matching the + // call alone is what let the discard mutation survive (R1019-N15): a call + // whose result is dropped reads as a check and enforces nothing. + gatesTheField := regexp.MustCompile( + guardReturnsItsRefusal(`requireVocabularyAudience\(ctx, req\.GetAudience\(\)\)`)). + MatchString(body) + gatesTheResolved := regexp.MustCompile( + guardReturnsItsRefusal(`requireVocabularyAudience\(ctx, audience\)`)). + MatchString(body) && strings.Contains(body, "audience := req.GetAudience()") + require.True(t, gatesTheField || gatesTheResolved, + "%s takes its audience from the request and must refuse the reserved module "+ + "audience AND return that refusal; a guard whose error is discarded enforces nothing", name) + } +} + +// Every one of these requests is validated before it reaches the guard, so each +// field it declares has to be well-formed: the subject here is the audience, and +// a message rejected for a malformed id would prove nothing about it. +const uuidForReservedAudienceTest = "00000000-0000-4000-8000-000000000001" + +func scopesForReservedAudienceTest() []*gen.WorkContextScope { + return []*gen.WorkContextScope{{ResourceKind: "records", Actions: []string{"read"}}} +} + +// Renewal's guard at the top of the method sees only what the REQUEST named, so an +// omitted audience passed it and then inherited the parent's — the one path by which +// a person-driven renewal could still carry the reserved module audience. The +// EFFECTIVE value is what gets signed, so the effective value is what is refused. +func TestR1019RenewInheritedAudience(t *testing.T) { + text := executableSource(t, "work_context_rpcs.go") + + body := regexp.MustCompile( + `func \(s \*WorkContextAuthorityServer\) RenewWorkContext\((?s:.*?)\n\}\n`). + FindString(text) + require.NotEmpty(t, body) + + inherit := strings.Index(body, "audience = parent.GetAudience()") + require.NotEqual(t, -1, inherit, "renewal still inherits the parent audience") + + // A refusal of the EFFECTIVE audience, after the inheritance and before signing — + // and one whose error is RETURNED. Discarding it left the call in place and + // enforced nothing, which is how this check passed while the guard did not run + // (R1019-N15). + returns := regexp.MustCompile(guardReturnsItsRefusal(`requireVocabularyAudience\(ctx, audience\)`)) + located := returns.FindStringIndex(body) + require.NotNil(t, located, + "renewal must refuse the audience it will actually sign, not only the one "+ + "requested, and must return that refusal") + effective := located[0] + require.Less(t, inherit, effective, "the check must follow the inheritance") + + sign := strings.Index(body, "s.signer.") + require.NotEqual(t, -1, sign) + require.Less(t, effective, sign, "the check must precede signing") +} + +// guardReturnsItsRefusal is the pattern for "this guard is called AND its error leaves +// the handler". A call whose result is discarded is not a guard, and matching the call +// alone is what let that mutation survive. +// +// The error variable's name is not pinned, because these handlers bind several +// different ones; what is pinned is that something is bound from the call, tested +// against nil, and returned from inside that branch. +func guardReturnsItsRefusal(call string) string { + return `if \w+ := ` + call + `; \w+ != nil \{\s*return nil, \w+\s*\}` +} diff --git a/module/services/accounts/code/pkg/adapters/work_context_rpcs.go b/module/services/accounts/code/pkg/adapters/work_context_rpcs.go index b01ced35a..35c5342d1 100644 --- a/module/services/accounts/code/pkg/adapters/work_context_rpcs.go +++ b/module/services/accounts/code/pkg/adapters/work_context_rpcs.go @@ -453,6 +453,25 @@ func (s *WorkContextAuthorityServer) StartTask( if err := requireVocabularyAudience(ctx, req.GetAudience()); err != nil { return nil, err } + permissions, scopes, err := workContextScopes(req.GetAuthorityScopes(), mintContentReads(req.GetAudience())) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + // A CONFINED CREDENTIAL IS HELD TO ITS CONSUMER'S CEILING, before the + // viewer's own authority is resolved at all. + // + // The order is the point, not a convenience. This is a ceiling, so a mint + // outside it must be refused whatever the person could otherwise have + // minted, and the refusal must say which rule turned it away. Running it + // after the authority resolution would let an incidental refusal arrive + // first and read as the reason — the organization binding in particular is + // also enforced, for its own purposes, by the membership lookup's verified + // database scope, and that refusal names a database scope rather than a + // consumer. Runs for a resource-confined request only; every other mint + // reaches the viewer's authority below exactly as before. + if err := s.holdConfinedMintToItsConsumer(ctx, req, permissions, scopes); err != nil { + return nil, err + } ownerID, err := s.authorizeOwner(ctx, req.GetOrgId()) if err != nil { return nil, err @@ -462,10 +481,6 @@ func (s *WorkContextAuthorityServer) StartTask( return nil, err } sessionID := callerSessionID(ctx, req.GetSessionId()) - permissions, scopes, err := workContextScopes(req.GetAuthorityScopes(), mintContentReads(req.GetAudience())) - if err != nil { - return nil, status.Error(codes.InvalidArgument, err.Error()) - } facts, err := s.resolveAuthority( ctx, req.GetOrgId(), ownerID, req.GetActorPrincipalId(), permissions, ) @@ -511,6 +526,157 @@ func (s *WorkContextAuthorityServer) StartTask( return issuedWorkContext(token, signed), nil } +// holdConfinedMintToItsConsumer bounds a mint made with a RESOURCE-CONFINED +// credential by the consumer that credential was delivered to +// (register finding SA-F-MCPAUD, round 3). +// +// Until this, nothing in this service read the confinement at all. The perimeter +// admitted a resource-bound credential at this one procedure and stamped which +// resource it named; the mint then issued for any audience in the host's +// vocabulary, any scope within the VIEWER's own authority, and any organization +// the viewer is a member of. So a credential delivered to one third-party client +// for one solution could mint a viewer-owned capability in another organization, +// addressed to a consumer that solution has nothing to do with — and the +// perimeter's own entitlement check, which asks about the credential's +// organization, could not see any of it, because the body is what names the +// organization and the audience. +// +// Three refusals, and each is a different question: +// +// 1. THE ORGANIZATION. The capability is issued for the organization the +// credential itself names, and no other. The perimeter checked this +// consumer's installation against THAT organization; a body naming a second +// one would be a capability for an organization whose consent nobody asked +// about. Membership is not consent: a person belongs to several +// organizations and a client was delivered to one. +// +// This binding was ALREADY enforced, incidentally: authorizeOwner's +// membership lookup opens with RequireVerifiedDatabaseScope, which compares +// the requested organization against the verified identity the perimeter +// projected. So a cross-organization mint was refused before this — the +// round-3 review's reading that it was not came from a harness whose +// accounts was a double, and the real procedure refuses it. It is restated +// here because the ceiling is where it belongs and because the incidental +// refusal is about a database scope: it would survive a change to the +// membership path that this rule must not survive, and it names something +// other than the consumer for an operator reading the refusal. +// +// 2. THE AUDIENCE, and 3. THE SCOPES, both against the INSTALLATION the +// perimeter admitted under, which is an administrator's recorded consent to +// this consumer: its declared `allowed_audiences`, narrowed to what this host +// still serves, and its agent principal's standing scope grants. The viewer's +// own authority continues to bound the mint below; this is the second, +// narrower bound, and a capability must be inside both. +// +// What this does NOT re-derive is which installation the resource names. The +// perimeter resolved resource → declared binding → target → the entitlement that +// admitted this viewer, and it states the installation from that one answer. +// Asking again here would be a second resolution that could disagree with the +// one admission was decided on, and the header is the perimeter's own, stripped +// from caller input like every identity header beside it. What this DOES check is +// that the resource is one this host issues at its own origin, so a confinement +// this service cannot account for refuses rather than passing through unread. +// +// It fails CLOSED in every undecidable case: a confinement with no installation, +// an installation that cannot be resolved, a host with no configured origin. A +// capability issued on an unanswered question is the failure this whole finding +// is about. +func (s *WorkContextAuthorityServer) holdConfinedMintToItsConsumer( + ctx context.Context, + req *gen.StartTaskWorkContextRequest, + permissions []business.WorkContextPermission, + scopes []*basev0.WorkScopeV1, +) error { + resource, confined := accountsauth.VerifiedResourceConfinement(ctx) + if !confined { + return nil + } + origin, configured := accountsauth.ConfiguredPublicOrigin() + if !configured { + // This deployment cannot say what any resource identifier IS, so it + // cannot judge one. The perimeter refuses a confined credential in the + // same state, so the two sides agree rather than one admitting what the + // other cannot account for. + return status.Error(codes.FailedPrecondition, + "this host has no configured public address, so it cannot account for the resource this credential is confined to") + } + if _, err := accountsauth.RequireResourceAtOrigin(resource, origin); err != nil { + return status.Error(codes.PermissionDenied, + "the credential is confined to a resource this host does not issue") + } + caller, authenticated := accountsauth.VerifiedRequestIdentity(ctx) + if !authenticated || caller.OrgID == uuid.Nil { + return status.Error(codes.PermissionDenied, + "a confined credential names no organization, so there is none to hold this mint to") + } + if caller.OrgID.String() != req.GetOrgId() { + // Neither organization is named. One is the caller's own and the other is + // theirs to read off the request they sent; printing them would make this + // refusal an oracle for which organizations a person belongs to. + return status.Error(codes.PermissionDenied, + "a capability minted with a credential delivered to one consumer is issued only for that credential's own organization") + } + installation, admitted := accountsauth.VerifiedInstallation(ctx) + if !admitted { + return status.Error(codes.FailedPrecondition, + "a confined credential reached this mint without the installation it was admitted under, so there is no consented ceiling to hold it to") + } + consumer, err := s.resolveInstallationAuthority(ctx, req.GetOrgId(), installation, permissions) + if err != nil { + return err + } + if consumer.Actor == nil { + // A healthy resolve always names the installation's agent. Fail closed + // rather than treat a missing ceiling as an absent one: enforceActorCeiling + // reads a nil actor as unrestricted, which is the most permissive reading + // of the state that says least. + return status.Error(codes.Internal, + "the consumer's installation resolved without an agent principal, so its ceiling could not be read") + } + // AN EMPTY CEILING IS NOTHING HERE, where everywhere else it is everything. + // + // `allowed_audiences` and `allowed_scopes` are optional, and an empty list + // means UNRESTRICTED on every other mint — an installation that names no + // audience puts no audience limit on its agent's own headless work, which is + // the documented and intended reading for a consumer an administrator + // installed and trusts. The install form leaves both empty by default, so + // that is the ordinary state, not an edge case. + // + // For a CONFINED mint it inverts. The credential is held by a third party + // this organization never installed — an MCP client the person authorized for + // ONE solution — and the installation is the only statement of what that + // solution may act for. Read as unrestricted, it says the person's whole + // authority is available to that client at every module the organization + // installed, which is the escape the ceiling exists to close and is what the + // round-4 review found shipped. + // + // So it refuses, by name and actionably. A silent narrowing to "nothing" would + // have been the same decision with the operator left to discover it from a + // failing tool call. + // + // WHAT THIS IS NOT: the ceiling a consumer's own DECLARATION would give. The + // delivered presence document carries module PINS (module, package, version); + // the audience a capability names is the consumed module's ROUTE ALIAS; and no + // record on this host joins the two. Deriving one would be a guess at exactly + // the question this check answers, so the authority stays the installation an + // administrator consented to, and the gap is recorded rather than papered + // over (see the PR body and module/SOLUTION_REGISTRATION.md). + if len(consumer.Actor.AllowedAudiences) == 0 { + return status.Errorf(codes.FailedPrecondition, + "the installation of the consumer this credential was delivered to declares no allowed audiences, so there is nothing to hold its mint to. "+ + "An empty ceiling is unrestricted for an installation's own work and is NOT accepted for a credential delivered to a third party: "+ + "name the audiences agent %s may be minted for on that installation.", + consumer.Actor.AgentIdentifier) + } + if len(consumer.Actor.AllowedScopes) == 0 { + return status.Errorf(codes.FailedPrecondition, + "the installation of the consumer this credential was delivered to declares no allowed scopes, so there is nothing to bound its mint to. "+ + "Name the resource kinds agent %s may be minted for on that installation.", + consumer.Actor.AgentIdentifier) + } + return enforceActorCeiling(ctx, consumer.Actor, req.GetAudience(), scopes) +} + // StartInstallationTask is the headless mint. It opens with a service credential // instead of a bearer and draws its authority from an installation: the owner of // record becomes the context owner, the installation's agent principal the sole diff --git a/module/services/accounts/code/pkg/auth/ed25519/logout_family_test.go b/module/services/accounts/code/pkg/auth/ed25519/logout_family_test.go new file mode 100644 index 000000000..2dc7c5705 --- /dev/null +++ b/module/services/accounts/code/pkg/auth/ed25519/logout_family_test.go @@ -0,0 +1,136 @@ +package ed25519minter_test + +import ( + "context" + "encoding/json" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// keyedRevoker answers what was actually written, which the shared fakeRevoker +// cannot: it returns one fixed verdict for every id, so it can show THAT a +// marker was written but never that the right id was matched — and the whole +// defect here is a marker written under an id nothing carries. +type keyedRevoker struct { + mu sync.Mutex + sessions map[string]time.Duration + jtis map[string]time.Duration +} + +func newKeyedRevoker() *keyedRevoker { + return &keyedRevoker{ + sessions: map[string]time.Duration{}, + jtis: map[string]time.Duration{}, + } +} + +func (k *keyedRevoker) Revoke(_ context.Context, jti string, ttl time.Duration) error { + k.mu.Lock() + defer k.mu.Unlock() + k.jtis[jti] = ttl + return nil +} + +func (k *keyedRevoker) IsRevoked(_ context.Context, jti string) (bool, error) { + k.mu.Lock() + defer k.mu.Unlock() + _, ok := k.jtis[jti] + return ok, nil +} + +func (k *keyedRevoker) RevokeSession(_ context.Context, id string, ttl time.Duration) error { + k.mu.Lock() + defer k.mu.Unlock() + k.sessions[id] = ttl + return nil +} + +func (k *keyedRevoker) IsSessionRevoked(_ context.Context, id string) (bool, error) { + k.mu.Lock() + defer k.mu.Unlock() + _, ok := k.sessions[id] + return ok, nil +} + +// Signing out kills every access token in the refresh family, not only the +// rotation the caller happens to be holding. +// +// The sequence is the one that was broken: mint, refresh, then log out with the +// refreshed pair. Every refresh mints a NEW session id and keeps the family, and +// the sign-out wrote its access-revocation marker under the CURRENT session id — +// so an access token minted before that refresh carried a different `sid`, +// matched no marker, and stayed valid until its natural expiry. A token stolen +// before a refresh therefore survived the sign-out that was supposed to end it. +// +// The family id is the identifier that does not rotate, so it is what the token +// now carries and what the sign-out revokes. +func TestSigningOutRevokesTheWholeRefreshFamily(t *testing.T) { + ctx := context.Background() + m, _ := newMinter(t) + m.SetRevoker(newKeyedRevoker()) + + first, err := m.Mint(ctx, newIdentity()) + require.NoError(t, err) + + // Both tokens verify before anything is revoked, so the assertions below + // cannot pass on a minter that rejects everything. + _, err = m.VerifyAccess(first.AccessToken) + require.NoError(t, err) + + second, err := m.VerifyRefresh(ctx, first.RefreshToken) + require.NoError(t, err) + _, err = m.VerifyAccess(second.AccessToken) + require.NoError(t, err) + + // The rotation really did change the session id — otherwise this test would + // pass for the wrong reason, on a build where `sid` happened to be stable. + require.NotEqual(t, + sessionIDOf(t, first.AccessToken), sessionIDOf(t, second.AccessToken), + "a refresh must rotate the session id, or this finding cannot occur") + + require.NoError(t, m.Revoke(ctx, second.RefreshToken)) + + _, err = m.VerifyAccess(second.AccessToken) + require.Error(t, err, "the access token presented at sign-out must be refused") + + _, err = m.VerifyAccess(first.AccessToken) + require.Error(t, err, + "an access token minted BEFORE the last refresh must be refused too: it is "+ + "the same family, and the sign-out ended the family") +} + +// The counterpart: a sign-out in one family must not revoke another's. Without +// it, stamping a shared or zero family id would pass the test above while +// signing every other person out. +func TestSigningOutLeavesAnotherFamilyAlone(t *testing.T) { + ctx := context.Background() + m, _ := newMinter(t) + m.SetRevoker(newKeyedRevoker()) + + mine, err := m.Mint(ctx, newIdentity()) + require.NoError(t, err) + theirs, err := m.Mint(ctx, newIdentity()) + require.NoError(t, err) + + require.NoError(t, m.Revoke(ctx, mine.RefreshToken)) + + _, err = m.VerifyAccess(mine.AccessToken) + require.Error(t, err) + _, err = m.VerifyAccess(theirs.AccessToken) + require.NoError(t, err, "another family's session must survive this sign-out") +} + +// sessionIDOf reads the `sid` claim off an access token's payload, so the test +// can prove the rotation it depends on actually happened. +func sessionIDOf(t *testing.T, accessToken string) string { + t.Helper() + var claims struct { + SessionID string `json:"sid"` + } + require.NoError(t, json.Unmarshal([]byte(decodeJWTPayload(t, accessToken)), &claims)) + require.NotEmpty(t, claims.SessionID) + return claims.SessionID +} diff --git a/module/services/accounts/code/pkg/auth/ed25519/minter.go b/module/services/accounts/code/pkg/auth/ed25519/minter.go index a14797ab3..987901089 100644 --- a/module/services/accounts/code/pkg/auth/ed25519/minter.go +++ b/module/services/accounts/code/pkg/auth/ed25519/minter.go @@ -124,6 +124,13 @@ type accessClaims struct { ScopedRoles map[string][]string `json:"sr,omitempty"` ScopedRolesTruncated bool `json:"srt,omitempty"` SessionID string `json:"sid"` + // FamilyID is the refresh family this access token was minted under, and it + // is STABLE across rotation where `sid` is not: every refresh mints a new + // session id and keeps the family. A sign-out revokes the family, so without + // this claim the revocation had nothing to match an access token minted + // before the last refresh — one stolen then stayed usable until it expired, + // through a completed logout. + FamilyID string `json:"fid,omitempty"` // AuthorizedParty is the registered client the token was issued to (OIDC // `azp`). Absent on the host's own web session, so a consumer reads "no azp" // as a first-party web session rather than as a missing value. @@ -315,7 +322,7 @@ func (m *Minter) prepareMint(identity *auth.Identity, familyID uuid.UUID) (*auth } // Access token - access, accessExpiresAt, err := m.signAccess(identity, sessionID, now) + access, accessExpiresAt, err := m.signAccess(identity, sessionID, familyID, now) if err != nil { return nil, nil, fmt.Errorf("ed25519minter: sign access: %w", err) } @@ -516,7 +523,7 @@ func (m *Minter) SwitchOrganization( if err != nil { return err } - accessToken, accessExpiresAt, err = m.signAccess(identity, current.ID, now) + accessToken, accessExpiresAt, err = m.signAccess(identity, current.ID, current.FamilyID, now) return err }) if err != nil { @@ -592,6 +599,18 @@ func isMFAMethod(method string) bool { method == auth.AuthenticationMethodWebAuthn } +// familyAccessClaim renders the family id for the access token, and renders +// NOTHING for the zero value. An access-only window (impersonation) has no +// refresh family, and stamping the zero UUID would put one shared id on every +// such token — a single marker would then revoke all of them at once, across +// tenants. +func familyAccessClaim(familyID uuid.UUID) string { + if familyID == uuid.Nil { + return "" + } + return familyID.String() +} + // Revoke implements auth.JWTMinter.Revoke by revoking the family backing the // given refresh token. Safe to call with an already-revoked token. func (m *Minter) Revoke(ctx context.Context, refreshToken string) error { @@ -603,7 +622,23 @@ func (m *Minter) Revoke(ctx context.Context, refreshToken string) error { } return fmt.Errorf("ed25519minter: revoke lookup: %w", err) } - return m.store.RevokeFamily(ctx, rec.FamilyID, "logout") + // The durable half: nothing in this family can be refreshed again. + if err := m.store.RevokeFamily(ctx, rec.FamilyID, "logout"); err != nil { + return err + } + // The access half, for the WHOLE family rather than one session of it. + // + // RevokeSessionAccess on the presented `sid` covers only the rotation the + // caller is holding. Every refresh mints a new session id, so a token minted + // before the last refresh carried a different one and matched no marker — a + // token stolen before a refresh survived the sign-out that followed it, until + // its natural expiry. The family id is on every access token in the chain + // (accessClaims.FamilyID), so one marker reaches all of them. + // + // Best-effort, exactly like the other access-marker writes: the durable + // revocation above is the authority, and a store outage bounds exposure to + // the access TTL. The caller is told, because Logout collects that. + return m.RevokeSessionAccess(ctx, rec.FamilyID.String()) } // VerifyAccess parses and validates an access token minted by this Minter. @@ -709,11 +744,23 @@ func (m *Minter) VerifyAccess(tokenString string) (*auth.Identity, error) { } } - // Session-scoped revocation (admin session-kill). A single marker keyed by - // the `sid` claim invalidates every access token in the session at once, - // covering the path where the killer never held the victim's token. - if claims.SessionID != "" { - revoked, err := m.revoker.IsSessionRevoked(context.Background(), claims.SessionID) + // Session- and family-scoped revocation. A single marker invalidates every + // access token it covers at once: the path where the killer never held the + // victim's token (admin session-kill), and the path where the token predates + // the last refresh (sign-out). + // + // Both keys are checked, because they answer different questions and the + // session one alone left a hole: `sid` changes on every refresh, so a + // sign-out that revoked the CURRENT session left an access token minted + // before it unmatched. The family does not change, so one marker covers the + // whole chain. They share the session-marker namespace — both are ids this + // host mints, and neither can collide with the other — so this needs no + // second store and no second TTL. + for _, key := range []string{claims.SessionID, claims.FamilyID} { + if key == "" { + continue + } + revoked, err := m.revoker.IsSessionRevoked(context.Background(), key) switch { case err != nil && !m.cfg.RevocationFailOpen: return nil, fmt.Errorf("%w: %v", auth.ErrRevocationUnavailable, err) @@ -843,6 +890,7 @@ func (m *Minter) accessTTL(identity *auth.Identity) time.Duration { func (m *Minter) signAccess( identity *auth.Identity, sessionID uuid.UUID, + familyID uuid.UUID, now time.Time, ) (string, time.Time, error) { jti, err := randHex(16) @@ -875,6 +923,7 @@ func (m *Minter) signAccess( ID: jti, }, SessionID: sessionID.String(), + FamilyID: familyAccessClaim(familyID), AuthorizedParty: identity.ClientID, Email: identity.Email, Name: identity.DisplayName, diff --git a/module/services/accounts/code/pkg/auth/public_origin.go b/module/services/accounts/code/pkg/auth/public_origin.go index 868e20b5a..5b1d6f297 100644 --- a/module/services/accounts/code/pkg/auth/public_origin.go +++ b/module/services/accounts/code/pkg/auth/public_origin.go @@ -2,18 +2,88 @@ package auth import ( "context" + "errors" "fmt" "net" "net/url" + "strconv" "strings" + + "golang.org/x/net/idna" +) + +// publicOriginHostProfile renders a host the way the browser's URL parser does. +// +// idna.Lookup — the obvious choice, and what this used first — additionally enforces +// DNS hostname policy, which refuses an UNDERSCORE in a label. Browsers accept one, +// and internal and staging hostnames routinely carry one, so Lookup refused origins a +// deployment legitimately serves while the frontend accepted them (R1019-N12/f1). +// StrictDomainName(false) drops that policy and keeps the mapping. +// +// idna.Punycode is NOT the alternative, though it accepts more: it does raw conversion +// with no case folding, so "BÜCHER.example" encodes to "xn--BCHER-2pa.example" while +// "bücher.example" encodes to "xn--bcher-kva.example" — two spellings of one origin +// that would never compare equal, and lowercasing afterwards cannot merge them because +// the punycode payloads already differ. MapForLookup case-folds BEFORE encoding, which +// is why it is the mapping used here and why no ToLower is needed after it. +// +// VerifyDNSLength(false) because a length limit is the resolver's to enforce, not this +// comparison's: refusing an over-long host here would reject it at sign-in rather than +// where it actually fails. +var publicOriginHostProfile = idna.New( + idna.MapForLookup(), + idna.Transitional(false), + idna.StrictDomainName(false), + idna.VerifyDNSLength(false), ) type verifiedPublicOriginKey struct{} -// CanonicalPublicOrigin validates an exact browser origin. Frontend derives the -// origin from the actual request and authenticates it to the trusted gateway; -// business code never derives security-sensitive URLs from caller-controlled -// forwarding headers. +// configuredPublicOrigin is the origin an operator pinned for this deployment, +// installed once at startup. The empty string means none is pinned, which only a +// local runtime reaches: boot requires one outside local development. +var configuredPublicOrigin string + +// SetConfiguredPublicOrigin installs the operator-configured public origin. Call +// it once from startup, before anything serves. +// +// The value is stored CANONICALIZED, through the same function the forwarded origin +// goes through, so the two sides of the comparison cannot spell one origin two ways. +// Storing it raw is what made `https://app.example:443` refuse the equivalent origin +// the frontend forwards. A value that does not canonicalize is stored trimmed, so the +// comparison refuses it rather than silently matching something. +func SetConfiguredPublicOrigin(origin string) error { + trimmed := strings.TrimSuffix(strings.TrimSpace(origin), "/") + if trimmed == "" { + configuredPublicOrigin = "" + return nil + } + canonical, err := CanonicalPublicOrigin(trimmed) + if err != nil { + // Storing the raw value on failure looked like the conservative branch and was + // the opposite: a value that cannot canonicalize can never equal a canonicalized + // candidate, so every origin comparison failed for the life of the process while + // boot reported success (R1019-N12/f2). The caller refuses instead. + return fmt.Errorf("configured public origin is unusable: %w", err) + } + configuredPublicOrigin = canonical + return nil +} + +// ConfiguredPublicOrigin returns the pinned origin, if there is one. +func ConfiguredPublicOrigin() (string, bool) { + return configuredPublicOrigin, configuredPublicOrigin != "" +} + +// ErrPublicOriginNotConfigured is returned when a forwarded origin does not match +// the one this deployment pinned. +var ErrPublicOriginNotConfigured = errors.New( + "public origin does not match the configured application base URL") + +// CanonicalPublicOrigin validates an exact browser origin: absolute, HTTP(S), +// HTTPS unless loopback, and nothing but scheme and authority. It says a value is +// WELL FORMED, never that it is trusted — WithVerifiedPublicOrigin is what decides +// that. func CanonicalPublicOrigin(candidate string) (string, error) { candidate = strings.TrimSpace(candidate) parsed, err := url.Parse(candidate) @@ -25,25 +95,149 @@ func CanonicalPublicOrigin(candidate string) (string, error) { parsed.ForceQuery || parsed.Fragment != "" { return "", fmt.Errorf("public origin must not contain credentials, path, query, or fragment") } - switch strings.ToLower(parsed.Scheme) { + scheme := strings.ToLower(parsed.Scheme) + host, err := canonicalPublicOriginHost(parsed.Hostname()) + if err != nil { + return "", err + } + switch scheme { case "https": case "http": - if !isPublicOriginLoopback(parsed.Hostname()) { + if !isPublicOriginLoopback(host) { return "", fmt.Errorf("non-loopback public origin must use HTTPS") } default: return "", fmt.Errorf("public origin must use HTTP(S)") } - return strings.TrimSuffix(candidate, "/"), nil + port, err := canonicalPublicOriginPort(parsed.Port(), scheme) + if err != nil { + return "", err + } + if strings.Contains(host, ":") { + host = "[" + host + "]" // an IPv6 literal keeps its brackets + } + return scheme + "://" + host + port, nil +} + +// canonicalPublicOriginHost renders a host the one way the browser renders it, so +// two spellings of one origin cannot compare unequal (SP-GW-08, R1019-N12). +// +// The frontend half of this comparison is `new URL(...).origin`, the WHATWG parser. +// Go's net/url is laxer than that parser in four ways that each produced a +// browser-equivalent spelling this function used to refuse: it preserves host case, +// leaves an IPv6 literal in whatever form it was written, leaves a Unicode hostname +// un-encoded, and treats a host-less authority as a host. Each is settled here — +// lowercase, the compressed IPv6 form, IDNA/punycode, and a refusal — rather than at +// the comparison, because a comparison can only reject what reaches it in one shape. +func canonicalPublicOriginHost(hostname string) (string, error) { + if hostname == "" { + // `https://:443` parses in Go with an empty hostname. A browser rejects it, + // so there is no origin it could be equivalent to. + return "", fmt.Errorf("public origin must name a host") + } + if ip := net.ParseIP(hostname); ip != nil { + // The compressed form: an expanded literal and its compression are one address, + // and net.IP.String() picks the same representation the browser does. + return ip.String(), nil + } + if numericHostSpelling(hostname) { + // A host the WHATWG parser reads as an IPv4 ADDRESS but spells differently + // from this side. `https://2130706433`, `https://0x7f000001` and + // `https://127.1` are all 127.0.0.1 to a browser and to the frontend's + // `new URL(...)`, which rewrites them to the dotted quad; Go's net/url + // preserves whatever was written. Both sides would then be configured with + // one value and compare two, so every same-origin check fails and sign-in + // refuses — a configuration that cannot work, accepted at boot. + // + // Refused rather than rewritten. Rewriting would mean reimplementing that + // parser's integer-host rules in Go and keeping them in step forever, for a + // spelling no deployment needs: a dotted quad says the same thing and both + // sides already agree on it. + return "", fmt.Errorf( + "public origin host %q is a numeric spelling of an address, which this "+ + "host and the browser's URL parser render differently; write it as a "+ + "dotted quad (or a hostname)", hostname) + } + ascii, err := publicOriginHostProfile.ToASCII(hostname) + if err != nil { + return "", fmt.Errorf("public origin host is not a usable hostname") + } + return ascii, nil +} + +// numericHostSpelling reports whether a hostname that is NOT already a canonical +// IP literal would be read as an IPv4 address by the WHATWG URL parser. +// +// That parser's trigger is the last label: if it parses as a number — decimal, +// hex `0x…`, or octal with a leading zero — the whole host is an address and is +// re-rendered as a dotted quad. net.ParseIP has already accepted every spelling +// the two sides agree on before this runs, so anything reaching here with a +// numeric final label is a spelling only one side would keep. +func numericHostSpelling(hostname string) bool { + labels := strings.Split(hostname, ".") + last := labels[len(labels)-1] + if last == "" { + // A trailing dot: the label before it decides. + if len(labels) < 2 { + return false + } + last = labels[len(labels)-2] + } + if last == "" { + return false + } + if lower := strings.ToLower(last); strings.HasPrefix(lower, "0x") { + _, err := strconv.ParseUint(lower[2:], 16, 64) + return err == nil && len(lower) > 2 + } + _, err := strconv.ParseUint(last, 10, 64) + return err == nil +} + +// canonicalPublicOriginPort renders the port as the ":NNN" suffix of a canonical +// origin, or "" when the origin carries the scheme's default. +// +// An explicit DEFAULT port is the same origin as none, and the two sides spell it +// differently: the browser and the frontend both drop it, while a configured value may +// carry it. A leading-zero port, and an empty explicit port, are likewise the same +// origin the browser renders without them. Anything outside the one-to-65535 range is +// not a port at all, and the browser refuses the whole URL rather than ignoring it. +func canonicalPublicOriginPort(port, scheme string) (string, error) { + if port == "" { + return "", nil // including `https://app.example:`, which Go reports as no port + } + number, err := strconv.Atoi(port) + if err != nil || number < 1 || number > 65535 { + return "", fmt.Errorf("public origin port must be between 1 and 65535") + } + if (scheme == "https" && number == 443) || (scheme == "http" && number == 80) { + return "", nil + } + return ":" + strconv.Itoa(number), nil // decimal, so a leading zero cannot survive } // WithVerifiedPublicOrigin records the origin only after the gateway credential -// has been authenticated by an adapter. +// has been authenticated by an adapter — AND only when it is the origin this +// deployment pinned. +// +// The invariant: the origin this service treats as its verified public origin — the +// one that binds a sign-in redirect, an authenticator's relying party and an +// emailed link — is fixed by operator configuration (SP-GW-08). A forwarded value +// is honoured only where it equals the configured one, so the configuration is the +// authority and a forwarding hop cannot substitute for it. This check is +// independent of any hop's own correctness, which is why it lives here as well. +// +// Where nothing is pinned the candidate stands on the credential alone, which only +// a local runtime reaches: boot requires a pinned origin outside local development +// (requireApplicationBaseURL). func WithVerifiedPublicOrigin(ctx context.Context, candidate string) (context.Context, error) { origin, err := CanonicalPublicOrigin(candidate) if err != nil { return ctx, err } + if configured, ok := ConfiguredPublicOrigin(); ok && !strings.EqualFold(origin, configured) { + return ctx, ErrPublicOriginNotConfigured + } return context.WithValue(ctx, verifiedPublicOriginKey{}, origin), nil } diff --git a/module/services/accounts/code/pkg/auth/public_origin_configured_test.go b/module/services/accounts/code/pkg/auth/public_origin_configured_test.go new file mode 100644 index 000000000..ea1240555 --- /dev/null +++ b/module/services/accounts/code/pkg/auth/public_origin_configured_test.go @@ -0,0 +1,145 @@ +package auth_test + +import ( + "context" + "testing" + + "accounts/pkg/auth" + + "github.com/stretchr/testify/require" +) + +// The gateway credential alone was not enough, and that is the whole of what this +// closes. The frontend held the internal token legitimately and derived the origin +// it stamped from the caller's own X-Forwarded-Host whenever its rendered endpoint +// was a loopback placeholder — which is what a render produces. So an authenticated +// hop forwarded a caller's choice and it was recorded as VERIFIED, which bound an +// OAuth redirect, the authenticator relying-party origin and every emailed link to +// a host the caller named. +func TestForwardedOriginIsRefusedUnlessItIsTheConfiguredOne(t *testing.T) { + pin(t, "https://app.cell.example") + + _, err := auth.WithVerifiedPublicOrigin(context.Background(), "https://evil.example") + require.ErrorIs(t, err, auth.ErrPublicOriginNotConfigured) + + ctx, err := auth.WithVerifiedPublicOrigin(context.Background(), "https://app.cell.example") + require.NoError(t, err) + origin, ok := auth.VerifiedPublicOrigin(ctx) + require.True(t, ok) + require.Equal(t, "https://app.cell.example", origin) +} + +// A trailing slash and a differently-cased host are the same origin, so a +// deployment is not refused for spelling its own value two ways. +func TestConfiguredOriginComparisonIsOnTheOrigin(t *testing.T) { + pin(t, "https://app.cell.example/") + + for _, candidate := range []string{ + "https://app.cell.example", + "https://app.cell.example/", + "https://APP.CELL.example", + } { + _, err := auth.WithVerifiedPublicOrigin(context.Background(), candidate) + require.NoError(t, err, candidate) + } +} + +// A near-miss is a miss: a subdomain, a sibling host, a different scheme and a +// different port are each a different origin. +func TestConfiguredOriginRefusesNearMisses(t *testing.T) { + pin(t, "https://app.cell.example") + + for _, candidate := range []string{ + "https://app.cell.example.evil.example", + "https://evil.app.cell.example", + "https://app.cell.example:8443", + "http://localhost:3000", + } { + _, err := auth.WithVerifiedPublicOrigin(context.Background(), candidate) + require.ErrorIs(t, err, auth.ErrPublicOriginNotConfigured, candidate) + } +} + +// With nothing pinned the candidate stands on the credential alone, which only a +// local runtime reaches: boot requires a pinned origin outside local development. +// Asserted so the comparison above is the pin and not a blanket refusal. +func TestUnpinnedDeploymentStillAcceptsAWellFormedForwardedOrigin(t *testing.T) { + pin(t, "") + + ctx, err := auth.WithVerifiedPublicOrigin(context.Background(), "http://localhost:3000") + require.NoError(t, err) + origin, ok := auth.VerifiedPublicOrigin(ctx) + require.True(t, ok) + require.Equal(t, "http://localhost:3000", origin) +} + +// Well-formedness is still checked first, so a pinned deployment does not accept a +// malformed value by matching it against anything. +func TestAMalformedOriginIsRefusedWhateverIsPinned(t *testing.T) { + pin(t, "https://app.cell.example") + + for _, candidate := range []string{ + "https://app.cell.example/path", + "https://user:secret@app.cell.example", + "app.cell.example", + "", + } { + _, err := auth.WithVerifiedPublicOrigin(context.Background(), candidate) + require.Error(t, err, candidate) + } +} + +func pin(t *testing.T, origin string) { + t.Helper() + previous, _ := auth.ConfiguredPublicOrigin() + t.Cleanup(func() { auth.SetConfiguredPublicOrigin(previous) }) + auth.SetConfiguredPublicOrigin(origin) +} + +// An explicit DEFAULT port is the same origin as none, and the two sides spell it +// differently: a browser and the frontend both drop it, while a configured value may +// carry it. Retaining it made an operator's `https://app.example:443` refuse the +// equivalent origin the frontend forwards. +func TestR1019ConfiguredOriginDefaultPorts(t *testing.T) { + for _, tc := range []struct{ configured, forwarded string }{ + {"https://app.cell.example:443", "https://app.cell.example"}, + {"https://app.cell.example", "https://app.cell.example:443"}, + {"http://localhost:80", "http://localhost"}, + {"http://localhost", "http://localhost:80"}, + } { + t.Run(tc.configured+" vs "+tc.forwarded, func(t *testing.T) { + pin(t, tc.configured) + _, err := auth.WithVerifiedPublicOrigin(context.Background(), tc.forwarded) + require.NoError(t, err, "equivalent origins must not disagree over a default port") + }) + } +} + +// A NON-default port is part of the origin and still distinguishes it, so the +// canonicalization above does not collapse genuinely different origins. +func TestR1019NonDefaultPortStillDistinguishesAnOrigin(t *testing.T) { + pin(t, "https://app.cell.example") + _, err := auth.WithVerifiedPublicOrigin(context.Background(), "https://app.cell.example:8443") + require.ErrorIs(t, err, auth.ErrPublicOriginNotConfigured) + + pin(t, "https://app.cell.example:8443") + _, err = auth.WithVerifiedPublicOrigin(context.Background(), "https://app.cell.example") + require.ErrorIs(t, err, auth.ErrPublicOriginNotConfigured) +} + +// CanonicalPublicOrigin returns the canonical spelling, so a caller that stores the +// result stores one form. +func TestR1019CanonicalOriginDropsOnlyTheDefaultPort(t *testing.T) { + for candidate, want := range map[string]string{ + "https://app.example:443": "https://app.example", + "https://app.example": "https://app.example", + "https://app.example:8443": "https://app.example:8443", + "http://127.0.0.1:80": "http://127.0.0.1", + "http://127.0.0.1:3000": "http://127.0.0.1:3000", + "https://app.example/": "https://app.example", + } { + got, err := auth.CanonicalPublicOrigin(candidate) + require.NoError(t, err, candidate) + require.Equal(t, want, got, candidate) + } +} diff --git a/module/services/accounts/code/pkg/auth/public_origin_vectors_test.go b/module/services/accounts/code/pkg/auth/public_origin_vectors_test.go new file mode 100644 index 000000000..30ab4b7b7 --- /dev/null +++ b/module/services/accounts/code/pkg/auth/public_origin_vectors_test.go @@ -0,0 +1,155 @@ +package auth + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// R1019-N12: one origin must have one spelling on BOTH sides of the comparison. +// +// The frontend canonicalizes with `new URL(...).origin`, the WHATWG parser; this +// service canonicalizes with net/url, which is laxer. Where they disagreed, a +// configured origin and the equivalent origin the frontend resolved compared unequal, +// and the refusal looked like a misconfiguration. +// +// These vectors are the agreement itself. The same table is asserted in +// src/test/public-origin-canonicalization.test.ts against the real URL parser, and +// module/tools/public_origin_vectors_lockstep_test.go holds the two copies identical +// — so a case fixed on one side cannot be left failing on the other. Each side +// asserts BEHAVIOUR on the vectors; the lockstep gate only proves both assert the +// same set. +// +// Expected values are the browser's own answers, taken from the parser rather than +// reasoned about, with the deliberate exceptions noted in refusedPublicOriginVectors. +// +// THIS TABLE IS THE FULL EXTENT OF THE AGREEMENT. The lockstep gate proves the two +// sides assert the same cases; it cannot prove anything about a case absent from both, +// and a gap here reads as agreement when there is none. An underscore host and a +// hyphen-edged label were both missing, and both were spellings the two sides answered +// differently (R1019-N12/f3). A new spelling belongs here first. +var canonicalPublicOriginVectors = []struct{ Input, Canonical string }{ + {"https://app.example", "https://app.example"}, + {"https://APP.example", "https://app.example"}, + {"https://app.example:443", "https://app.example"}, + {"https://app.example:0443", "https://app.example"}, + {"https://app.example:", "https://app.example"}, + {"https://app.example:8443", "https://app.example:8443"}, + {"https://app.example:08443", "https://app.example:8443"}, + {"https://app.example/", "https://app.example"}, + {"https://[0:0:0:0:0:0:0:1]", "https://[::1]"}, + {"https://[::1]:443", "https://[::1]"}, + {"https://bücher.example", "https://xn--bcher-kva.example"}, + {"http://localhost:80", "http://localhost"}, + {"http://127.0.0.1:3000", "http://127.0.0.1:3000"}, + {"http://[::1]:80", "http://[::1]"}, + {"https://my_app.example", "https://my_app.example"}, + {"https://a_b-c.example", "https://a_b-c.example"}, + {"https://BÜCHER.example", "https://xn--bcher-kva.example"}, +} + +// Refused on both sides. Most entries are refused by the browser parser too. +// +// Two groups are deliberately stricter than that parser, because a rule both sides +// apply is worth more than matching it on a value no deployment needs: +// +// - PORT 0, which `new URL` accepts and keeps. No origin can be served on it, +// and the frontend's canonicalOrigin applies the same range check. +// - A NUMERIC SPELLING of an address, which that parser accepts and REWRITES: +// `2130706433`, `0x7f000001`, `127.1` and `0177.0.0.1` all come back as +// `127.0.0.1`, while Go's net/url preserves what was written. Configuring +// either side with one of these gives the two services different values from +// the same configuration, so every same-origin comparison fails and sign-in +// refuses. A dotted quad says the same thing and both sides keep it. +var refusedPublicOriginVectors = []string{ + "https://:443", + "https://app.example:99999", + "https://app.example:0", + "https://-app.example", + "https://app-.example", + "https://app.example:-1", + "https://app.example/path", + "https://app.example?q=1", + "https://app.example#f", + "https://user:secret@app.example", + "https://2130706433", + "https://0x7f000001", + "https://127.1", + "https://0177.0.0.1", + "app.example", + "", +} + +func TestR1019CanonicalPublicOriginMatchesTheBrowserSpelling(t *testing.T) { + for _, vector := range canonicalPublicOriginVectors { + t.Run(vector.Input, func(t *testing.T) { + got, err := CanonicalPublicOrigin(vector.Input) + require.NoError(t, err, "%q is a usable origin", vector.Input) + require.Equal(t, vector.Canonical, got, + "%q must canonicalize the way the browser spells it", vector.Input) + }) + } +} + +func TestR1019CanonicalPublicOriginRefusesWhatTheBrowserWillNotParse(t *testing.T) { + for _, input := range refusedPublicOriginVectors { + t.Run(input, func(t *testing.T) { + _, err := CanonicalPublicOrigin(input) + require.Error(t, err, "%q is not an origin and must be refused", input) + }) + } +} + +// Canonicalization is only worth anything if the COMPARISON uses it. A configured +// origin and a differently spelled equivalent must reach the same verified origin. +func TestR1019ConfiguredOriginAcceptsEveryEquivalentSpelling(t *testing.T) { + t.Cleanup(func() { _ = SetConfiguredPublicOrigin("") }) + + for _, spelling := range []string{ + "https://app.example", "https://APP.example", "https://app.example:443", + "https://app.example:0443", "https://app.example:", "https://app.example/", + } { + require.NoError(t, SetConfiguredPublicOrigin(spelling)) + configured, ok := ConfiguredPublicOrigin() + require.True(t, ok) + require.Equal(t, "https://app.example", configured, + "%q pins the same origin as every other spelling of it", spelling) + + for _, candidate := range []string{ + "https://app.example", "https://APP.example", "https://app.example:443", + } { + ctx, err := WithVerifiedPublicOrigin(t.Context(), candidate) + require.NoError(t, err, "%q is the pinned origin spelled %q", candidate, spelling) + verified, ok := VerifiedPublicOrigin(ctx) + require.True(t, ok) + require.Equal(t, "https://app.example", verified) + } + + _, err := WithVerifiedPublicOrigin(t.Context(), "https://other.example") + require.ErrorIs(t, err, ErrPublicOriginNotConfigured, + "a different origin is still refused when %q is pinned", spelling) + } +} + +// R1019-N12/f2: the setter must refuse a value it cannot canonicalize rather than +// store it. Storing the raw string kept the process running with a configured origin +// that could never equal any canonicalized candidate, so every comparison failed for +// the life of the process and nothing said why. +func TestR1019ConfiguringAnUnusableOriginIsRefused(t *testing.T) { + t.Cleanup(func() { _ = SetConfiguredPublicOrigin("") }) + require.NoError(t, SetConfiguredPublicOrigin("https://app.example")) + + for _, unusable := range refusedPublicOriginVectors { + if unusable == "" { + continue // the empty value means "none pinned", which is a local runtime + } + require.Error(t, SetConfiguredPublicOrigin(unusable), + "%q cannot be canonicalized, so pinning it would make every comparison fail", unusable) + } + + // And the pin is unchanged by a refused call, so a failed reconfiguration cannot + // leave the process comparing against something it never accepted. + configured, ok := ConfiguredPublicOrigin() + require.True(t, ok) + require.Equal(t, "https://app.example", configured) +} diff --git a/module/services/accounts/code/pkg/auth/resource_confinement.go b/module/services/accounts/code/pkg/auth/resource_confinement.go new file mode 100644 index 000000000..a1b1d2853 --- /dev/null +++ b/module/services/accounts/code/pkg/auth/resource_confinement.go @@ -0,0 +1,76 @@ +package auth + +// THE CONFINEMENT A CREDENTIAL CARRIES, as this service reads it +// (register finding SA-F-MCPAUD, round 3). +// +// A credential issued for one consumer — an RFC 8707 resource indicator naming a +// solution's MCP endpoint — is held by a third-party client and, once forwarded, +// by the solution runtime it dialled. The perimeter admits such a credential at +// the Work Context mint, because the consumer's own work needs a capability +// minted on the person's behalf. What the mint may then ISSUE has to be bounded +// by the consumer, or a credential delivered to one solution mints the person's +// whole authority, for any audience, in any organization they belong to. +// +// The perimeter cannot bound it: the ceiling is an installation's, and the +// perimeter is not the authority on installations. So the perimeter states two +// facts and this service decides from them — which resource the credential names, +// and which installation admitted the request. Both arrive as headers the +// perimeter owns, and both are read ONLY behind a verified gateway credential, on +// the same footing and through the same strip as the identity headers beside +// them (forwardedIdentityHeaders): a caller that could set either would choose +// its own ceiling. +// +// Recorded as two separate facts rather than one, because they fail differently. +// A confinement with no installation is a perimeter that admitted a confined +// credential without resolving its consent, which must refuse; an installation +// with no confinement is an ordinary session's request, which is every other +// request this service serves. + +import ( + "context" + "strings" +) + +type ( + verifiedResourceConfinementKey struct{} + verifiedInstallationKey struct{} +) + +// WithVerifiedResourceConfinement records the resource a credential is confined +// to. The value is the indicator as issued, compared by equality and never +// re-rendered, so it is stored exactly as the perimeter stated it. +// +// An empty candidate records NOTHING, and that is the whole point of a presence +// test rather than a string comparison: the perimeter stamps the header on every +// allow decision and leaves it empty for an unbound session, so "" is "this +// credential is not confined" and must not be mistaken for a confinement to the +// empty resource. +func WithVerifiedResourceConfinement(ctx context.Context, candidate string) context.Context { + if strings.TrimSpace(candidate) == "" { + return ctx + } + return context.WithValue(ctx, verifiedResourceConfinementKey{}, candidate) +} + +// VerifiedResourceConfinement reports the resource this request's credential is +// confined to, and whether it is confined at all. +func VerifiedResourceConfinement(ctx context.Context) (string, bool) { + resource, ok := ctx.Value(verifiedResourceConfinementKey{}).(string) + return resource, ok && resource != "" +} + +// WithVerifiedInstallation records which installation the perimeter's authority +// admitted this request under. +func WithVerifiedInstallation(ctx context.Context, candidate string) context.Context { + if strings.TrimSpace(candidate) == "" { + return ctx + } + return context.WithValue(ctx, verifiedInstallationKey{}, candidate) +} + +// VerifiedInstallation reports the installation the perimeter admitted this +// request under, and whether it named one. +func VerifiedInstallation(ctx context.Context) (string, bool) { + installation, ok := ctx.Value(verifiedInstallationKey{}).(string) + return installation, ok && installation != "" +} diff --git a/module/services/accounts/code/pkg/business/auth.go b/module/services/accounts/code/pkg/business/auth.go index dffc99d7b..01b66757d 100644 --- a/module/services/accounts/code/pkg/business/auth.go +++ b/module/services/accounts/code/pkg/business/auth.go @@ -95,13 +95,17 @@ func (s *Service) Authenticate(ctx context.Context, req *gen.AuthenticateRequest } case *gen.AuthenticateRequest_HeaderJwt: authenticationMethod = auth.AuthenticationMethodHeaderJWT - if s.validator == nil { + // The header-jwt validator, never the code-exchange one. A deployment in + // any other provider mode leaves this nil, so the credential is refused + // rather than handed to a validator that asks no nonce — see + // SetHeaderJWTTokenValidator. + if s.headerJWTValidator == nil { return nil, w.Wrapf(auth.ErrInvalidOAuthRequest, "header-jwt authentication is not enabled") } if credentials.HeaderJwt == nil || credentials.HeaderJwt.Token == "" { return nil, w.Wrapf(auth.ErrMissingClaims, "header-jwt token missing") } - claims, err = s.validator.Validate(ctx, credentials.HeaderJwt.Token) + claims, err = s.headerJWTValidator.Validate(ctx, credentials.HeaderJwt.Token) if err != nil { return nil, w.Wrapf(err, "header-jwt authentication") } @@ -467,27 +471,66 @@ func (s *Service) BeginOAuth(ctx context.Context, provider, redirectURI string) return s.oauthState.Mint(provider, redirectURI) } -// Logout revokes the session family associated with the given refresh -// token AND adds the caller's access token jti to the revocation list -// (when accessToken is non-empty). Idempotent: revoking an -// already-revoked token is a no-op. +// Logout ends the presented session: it revokes the session family the refresh +// token belongs to, adds the caller's access token jti to the revocation list, +// and marks the presented session so every access token minted for it dies now +// rather than at its natural TTL. Idempotent: revoking an already-revoked +// session is a no-op. +// +// sessionID is the verified session the caller presented, not a request field. +// It is what makes the access half of the sign-out independent of the body: a +// logout that reaches here revokes the presented session's access tokens whether +// or not a refresh credential came with it. +// +// EVERY revocation is required, and a sign-out is acknowledged only when all of +// them held. SP-IDENT-05 is that after signing out the presented access token no +// longer authenticates — so reporting success while the access half failed tells +// the caller their session ended when it has not, which is the one answer a +// sign-out must never give. A failure that leaves a token live has to reach the +// caller, who can retry, rather than a log nobody reads. // -// The access token half is best-effort — failure here never fails the -// logout, since the token will expire naturally within AccessTokenTTL. -func (s *Service) Logout(ctx context.Context, req *gen.LogoutRequest, accessToken string) error { +// The order is deliberate: the family revocation runs LAST and its result is +// returned directly, so a failed access-marker write cannot discard the durable +// half. Work that succeeded is kept — each step is idempotent, so a retry +// completes the remainder rather than starting over. +func (s *Service) Logout(ctx context.Context, req *gen.LogoutRequest, accessToken, sessionID string) error { w := wool.Get(ctx).In("Logout") if s.minter == nil { return w.NewError("auth path not wired: minter missing") } + var incomplete []string if accessToken != "" { if err := s.minter.RevokeAccess(ctx, accessToken); err != nil { - w.Warn("RevokeAccess failed (best-effort)", wool.ErrField(err)) + w.Warn("RevokeAccess failed", wool.ErrField(err)) + incomplete = append(incomplete, "the presented access token") + } + } + if sessionID != "" { + if err := s.minter.RevokeSessionAccess(ctx, sessionID); err != nil { + w.Warn("RevokeSessionAccess failed", wool.ErrField(err)) + incomplete = append(incomplete, "the presented session's access tokens") } } - return s.minter.Revoke(ctx, req.RefreshToken) + if err := s.minter.Revoke(ctx, req.RefreshToken); err != nil { + return err + } + if len(incomplete) > 0 { + // The family is revoked, so nothing can be refreshed; what is still live is + // the access half, until its natural expiry. The caller is told, because the + // difference between "signed out" and "signed out except for a few minutes" + // is theirs to act on. + return fmt.Errorf("%w: %s", ErrLogoutIncomplete, strings.Join(incomplete, " and ")) + } + return nil } +// ErrLogoutIncomplete reports a sign-out whose refresh family was revoked while an +// access-token revocation could not be recorded. The session cannot be refreshed, +// but an already-issued access token stays usable until it expires, so this is not +// the complete sign-out SP-IDENT-05 describes. +var ErrLogoutIncomplete = errors.New("logout incomplete: revocation could not be fully recorded") + // GetJWKS returns the sidecar-facing JSON Web Key Set. // Non-authoritative: the sidecar loads its key from Vault directly. // This endpoint exists for external tooling. diff --git a/module/services/accounts/code/pkg/business/auth_header_jwt_mode_test.go b/module/services/accounts/code/pkg/business/auth_header_jwt_mode_test.go new file mode 100644 index 000000000..a9212ffe4 --- /dev/null +++ b/module/services/accounts/code/pkg/business/auth_header_jwt_mode_test.go @@ -0,0 +1,92 @@ +package business + +import ( + "context" + "errors" + "testing" + + "accounts/pkg/auth" + gen "accounts/pkg/gen/saas/accounts/v1" + + "github.com/stretchr/testify/require" +) + +// recordingValidator stands in for the provider stack a deployment builds. The +// assertion that matters is whether it is CONSULTED: an id_token presented in the +// request body must never reach the code-exchange validator, which checks a +// signature, an issuer, an audience and an expiry — and no nonce, because the +// nonce is the code exchange's binding to a single authorize request and lives in +// the state that exchange holds. +type recordingValidator struct{ consulted bool } + +func (v *recordingValidator) Validate(context.Context, string) (*auth.Claims, error) { + v.consulted = true + return &auth.Claims{Subject: "subject", Email: "person@example.com", Provider: "oidc"}, nil +} + +type refusingResolver struct{} + +func (refusingResolver) Resolve(context.Context, *auth.Claims, auth.Intent) (*auth.Identity, error) { + return nil, errors.New("the resolver must not be reached") +} + +type headerJWTModeStore struct{ Store } + +// The only store read either test reaches. Both end before any identity exists: +// what each asserts is whether the VALIDATOR was consulted. +func (*headerJWTModeStore) ResolveIdentity(context.Context, string, string) (*ResolvedIdentity, error) { + return nil, errors.New("no identity in this fixture") +} + +// In OIDC mode the header-jwt login path is inactive, and a body carrying an +// identity assertion is refused rather than validated. The two modes verify +// different things, and only the configured mode's verifier may decide: an +// assertion that completed none of this mode's handoff is not an identity here, +// whatever another mode's validator would make of it. +func TestL1VHeaderJWTBodyRefusedInOIDCMode(t *testing.T) { + validator := &recordingValidator{} + service, err := NewService(&headerJWTModeStore{}) + require.NoError(t, err) + service.SetIdentityResolver(refusingResolver{}) + service.SetJWTMinter(stubMinterForHeaderJWTMode{}) + // Exactly what work.go's OIDC branch wires: the code-exchange validator, and + // no header-jwt validator. + service.SetTokenValidator(validator) + + resp, err := service.Authenticate(context.Background(), &gen.AuthenticateRequest{ + Provider: "oidc", + Authentication: &gen.AuthenticateRequest_HeaderJwt{ + HeaderJwt: &gen.HeaderJWTAuthentication{Token: "an.unexpired.idtoken"}, + }, + }) + + require.Nil(t, resp) + require.ErrorIs(t, err, auth.ErrInvalidOAuthRequest) + require.False(t, validator.consulted, + "the body credential must be refused before any validator sees it") +} + +// The counterpart: with the header-jwt validator wired — the one thing work.go's +// header-jwt branch does and no other branch does — the credential is accepted +// and reaches that validator, so the refusal above is the mode and not a path +// that refuses everything. +func TestHeaderJWTBodyIsValidatedInHeaderJWTMode(t *testing.T) { + headerJWT := &recordingValidator{} + service, err := NewService(&headerJWTModeStore{}) + require.NoError(t, err) + service.SetIdentityResolver(refusingResolver{}) + service.SetJWTMinter(stubMinterForHeaderJWTMode{}) + service.SetHeaderJWTTokenValidator(headerJWT) + + _, err = service.Authenticate(context.Background(), &gen.AuthenticateRequest{ + Provider: "header-jwt", + Authentication: &gen.AuthenticateRequest_HeaderJwt{ + HeaderJwt: &gen.HeaderJWTAuthentication{Token: "an.unexpired.idtoken"}, + }, + }) + + require.True(t, headerJWT.consulted, "the header-jwt validator must see the credential") + require.Error(t, err, "the refusing resolver is what ends this call") +} + +type stubMinterForHeaderJWTMode struct{ auth.JWTMinter } diff --git a/module/services/accounts/code/pkg/business/auth_header_jwt_test.go b/module/services/accounts/code/pkg/business/auth_header_jwt_test.go index dce1f7b39..eba442ab0 100644 --- a/module/services/accounts/code/pkg/business/auth_header_jwt_test.go +++ b/module/services/accounts/code/pkg/business/auth_header_jwt_test.go @@ -67,8 +67,9 @@ func wireHeaderJWTOnTestService(t *testing.T, fi *headerJWTIssuer, cfg headerjwt cfg.Audience = "codefly-app" v, err := headerjwt.New(cfg) require.NoError(t, err) - testService.SetTokenValidator(v) - t.Cleanup(func() { testService.SetTokenValidator(nil) }) + // The header-jwt validator, which is the only one this login path reads. + testService.SetHeaderJWTTokenValidator(v) + t.Cleanup(func() { testService.SetHeaderJWTTokenValidator(nil) }) } func headerJWTRequest(token string) *gen.AuthenticateRequest { diff --git a/module/services/accounts/code/pkg/business/datasource_content_ticket_test.go b/module/services/accounts/code/pkg/business/datasource_content_ticket_test.go index f802b2f41..03b2d47e1 100644 --- a/module/services/accounts/code/pkg/business/datasource_content_ticket_test.go +++ b/module/services/accounts/code/pkg/business/datasource_content_ticket_test.go @@ -58,17 +58,84 @@ func TestContentTicketRejectsTamperAndForeignKey(t *testing.T) { } } -// TestAttack_UnsetTicketKeyDisablesTickets: an unset seed used to produce the -// key sha256("datasource-content-ticket\x00") — a constant anyone can compute, -// so anyone could mint a ticket for any tenant's source. An unset key must -// leave content tickets disabled, which minting and redemption already treat -// as unavailable. +// TestAttack_UnsetTicketKeyDisablesTickets: an unset key must leave content tickets +// disabled, which minting and redemption already treat as unavailable, and must NOT +// fall back to any other value — SP-SEC-07 requires each purpose's key to be its +// own, derived from no credential shared beyond its owner. func TestAttack_UnsetTicketKeyDisablesTickets(t *testing.T) { - for _, seed := range [][]byte{nil, {}} { + for _, key := range [][]byte{nil, {}} { s := &Service{} - s.SetDatasourceTicketKey(seed) + // A link key is present, so an absent ticket key cannot be satisfied by the + // other purpose's value. + s.SetDatasourceKeys(key, []byte("a-delivered-link-key")) if s.datasourceTicketSigner != nil { - t.Fatalf("SetDatasourceTicketKey(%q) installed a signer keyed by a public constant", seed) + t.Fatalf("SetDatasourceKeys(%q, ...) installed a signer keyed by a public constant", key) } } } + +// And the other way round: an absent account-link key leaves the link state +// unsignable rather than borrowing the ticket key. +func TestAttack_UnsetAccountLinkKeyDisablesLinkState(t *testing.T) { + for _, key := range [][]byte{nil, {}} { + s := &Service{} + s.SetDatasourceKeys([]byte("a-delivered-ticket-key"), key) + if s.datasourceLinkKey != nil { + t.Fatalf("SetDatasourceKeys(..., %q) installed a link key from somewhere else", key) + } + } +} + +// The two purposes do not share a key even when an operator provisions one value +// for both: the link key is domain-separated, so a content ticket and a link state +// can never pass for each other. +func TestDatasourceKeysStayDistinctUnderOneProvisionedValue(t *testing.T) { + s := &Service{} + one := []byte("one-value-provisioned-for-both-purposes") + s.SetDatasourceKeys(one, one) + if s.datasourceTicketSigner == nil || s.datasourceLinkKey == nil { + t.Fatal("both purposes must be available") + } + if string(s.datasourceLinkKey) == string(s.datasourceTicketSigner.key) { + t.Fatal("the link key and the ticket key must differ even from one provisioned value") + } +} + +// Each purpose's key must derive from ITS OWN delivered input. Asserting only that the +// two keys differ is not enough: a link key derived from the TICKET input through a +// domain label still differs from the ticket key, so that assertion passes while the +// two purposes once again share one secret — which is the whole of SA-F-KEYREUSE. +// +// So this varies one input at a time and requires the other purpose not to move. +func TestR1019DatasourceKeysDeriveFromTheirOwnInput(t *testing.T) { + keys := func(ticket, link string) (string, string) { + s := &Service{} + s.SetDatasourceKeys([]byte(ticket), []byte(link)) + if s.datasourceTicketSigner == nil || s.datasourceLinkKey == nil { + t.Fatal("both purposes must be available") + } + return string(s.datasourceTicketSigner.key), string(s.datasourceLinkKey) + } + + baseTicket, baseLink := keys("ticket-key-one", "link-key-one") + + // Changing the LINK input must move the link key and leave the ticket key alone. + movedTicket, movedLink := keys("ticket-key-one", "link-key-two") + if movedLink == baseLink { + t.Fatal("the link key does not depend on the account-link input") + } + if movedTicket != baseTicket { + t.Fatal("the ticket key must not depend on the account-link input") + } + + // And changing the TICKET input must move the ticket key and leave the link key + // alone. This is the direction that catches a link key derived from the ticket + // input. + movedTicket, movedLink = keys("ticket-key-two", "link-key-one") + if movedTicket == baseTicket { + t.Fatal("the ticket key does not depend on the content-ticket input") + } + if movedLink != baseLink { + t.Fatal("the link key must not depend on the content-ticket input") + } +} diff --git a/module/services/accounts/code/pkg/business/datasource_deliveries_test.go b/module/services/accounts/code/pkg/business/datasource_deliveries_test.go index 498f9a52e..65f674524 100644 --- a/module/services/accounts/code/pkg/business/datasource_deliveries_test.go +++ b/module/services/accounts/code/pkg/business/datasource_deliveries_test.go @@ -449,7 +449,7 @@ func TestCompileDelivery_LargeBlobCarriesContentTicket(t *testing.T) { } store := newDatasourceFakeStore() svc, _ := newDatasourceService(store, producer, gh) - svc.SetDatasourceTicketKey([]byte("test-key")) + svc.SetDatasourceKeys([]byte("test-ticket-key"), []byte("test-link-key")) source := githubSource(t, svc, "main", []string{"docs"}, cA) if _, err := svc.CompileGitHubDelivery(context.Background(), source, diff --git a/module/services/accounts/code/pkg/business/datasource_directory_test.go b/module/services/accounts/code/pkg/business/datasource_directory_test.go index 633deea16..a2374b7a8 100644 --- a/module/services/accounts/code/pkg/business/datasource_directory_test.go +++ b/module/services/accounts/code/pkg/business/datasource_directory_test.go @@ -348,7 +348,7 @@ func newDirectoryService(t *testing.T) (*business.Service, *directoryStore, *rec store.roles[userJoe] = gen.OrgRole_ORG_ROLE_MEMBER store.roles[userAdm] = gen.OrgRole_ORG_ROLE_ADMIN svc, audit := newDatasourceService(store, &recordingProducer{}, nil) - svc.SetDatasourceTicketKey([]byte("internal-test-key")) + svc.SetDatasourceKeys([]byte("test-ticket-key"), []byte("test-link-key")) svc.SetDatasourceAccountLinkers(map[string]business.DatasourceAccountLinker{ "github": fakeLinker{accounts: map[string][2]string{"code-jane": {"1001", "jane"}, "code-joe": {"1002", "joe"}}}, }) diff --git a/module/services/accounts/code/pkg/business/datasource_oauth_integration_test.go b/module/services/accounts/code/pkg/business/datasource_oauth_integration_test.go index 58299c6ae..10f7d31ec 100644 --- a/module/services/accounts/code/pkg/business/datasource_oauth_integration_test.go +++ b/module/services/accounts/code/pkg/business/datasource_oauth_integration_test.go @@ -38,10 +38,16 @@ func realOAuthService(t *testing.T) (*business.Service, business.SecretCipher) { svc, err := business.NewService(testStore) require.NoError(t, err) svc.SetDatasourceConnector(keys.Cipher, nil, "") - seed := make([]byte, 32) - _, err = rand.Read(seed) + // Each message-authentication purpose holds its own delivered key, so this + // helper delivers two independent ones rather than a single seed both + // purposes are derived from. + ticketKey := make([]byte, 32) + _, err = rand.Read(ticketKey) require.NoError(t, err) - svc.SetDatasourceTicketKey(seed) + accountLinkKey := make([]byte, 32) + _, err = rand.Read(accountLinkKey) + require.NoError(t, err) + svc.SetDatasourceKeys(ticketKey, accountLinkKey) emitter, err := business.NewDurableAuditEmitter(testStore, testStore) require.NoError(t, err) t.Cleanup(emitter.Close) diff --git a/module/services/accounts/code/pkg/business/datasources.go b/module/services/accounts/code/pkg/business/datasources.go index 4acefc6e3..ef35213c3 100644 --- a/module/services/accounts/code/pkg/business/datasources.go +++ b/module/services/accounts/code/pkg/business/datasources.go @@ -576,23 +576,33 @@ func (s *Service) SetDatasourceGitHubClientFactory(factory func(token string) Gi s.newGitHubClient = factory } -// SetDatasourceTicketKey seeds the signer that mints and verifies the opaque -// content tickets a change-set job carries in place of an oversized blob. The -// seed is the deployment's internal key; the same seed must be present wherever -// ResolveContentTicket runs so a ticket minted by the compiler verifies at -// redemption. An empty seed disables tickets: deriving a key from it would sign -// with a constant anyone can compute. -func (s *Service) SetDatasourceTicketKey(seed []byte) { - if len(seed) == 0 { +// SetDatasourceKeys installs the message-authentication keys the datasource paths +// sign with: one for the opaque content tickets a change-set job carries in place +// of an oversized blob, one for the account-link state that carries a person +// through a provider's sign-in and back. +// +// Each is DELIVERED, one per purpose, rather than derived from a shared +// credential: SP-SEC-07 requires every signing or message-authentication purpose to +// have its own key, derived from no credential shared beyond its owner. +// +// An absent key leaves its purpose unavailable rather than falling back to a +// derivation, because a fallback would make the property conditional on nobody +// forgetting. +func (s *Service) SetDatasourceKeys(ticketKey, accountLinkKey []byte) { + if len(ticketKey) == 0 { s.datasourceTicketSigner = nil + } else { + s.datasourceTicketSigner = newDatasourceTicketSigner(ticketKey) + } + if len(accountLinkKey) == 0 { s.datasourceLinkKey = nil return } - // The account-link state is signed under its own domain-separated key, so - // neither kind of token can pass for the other. - linkKey := sha256.Sum256(append([]byte("datasource-account-link-state\x00"), seed...)) + // Domain-separated from whatever else the delivered value is used for, so a + // link state and a content ticket can never pass for each other even if an + // operator provisions one value for both keys. + linkKey := sha256.Sum256(append([]byte("datasource-account-link-state\x00"), accountLinkKey...)) s.datasourceLinkKey = linkKey[:] - s.datasourceTicketSigner = newDatasourceTicketSigner(seed) } // SetDatasourceAPIClientFactory overrides how per-Source API clients are built. diff --git a/module/services/accounts/code/pkg/business/grant_scope_subject_tenancy_integration_test.go b/module/services/accounts/code/pkg/business/grant_scope_subject_tenancy_integration_test.go index f027c8986..6af7043d3 100644 --- a/module/services/accounts/code/pkg/business/grant_scope_subject_tenancy_integration_test.go +++ b/module/services/accounts/code/pkg/business/grant_scope_subject_tenancy_integration_test.go @@ -6,8 +6,10 @@ import ( "context" "errors" "testing" + "time" "accounts/pkg/adapters" + "accounts/pkg/auth" "accounts/pkg/business" gen "accounts/pkg/gen/saas/accounts/v1" @@ -49,11 +51,34 @@ func (w *grantScopeWorld) grantTo(subject string, kind gen.SubjectKind) *gen.Gra return &gen.GrantScopeRequest{OrgId: w.org, SubjectId: subject, SubjectKind: kind, ScopePath: w.path, RoleId: w.role} } +// callerWithRecentFactor is storyCallerContext plus recent second-factor +// evidence, which every privilege-granting mutation on this host requires before +// it runs. Without it a test of such a mutation exercises the factor gate instead +// of the rule it names. +func callerWithRecentFactor(ctx context.Context, userID, orgID string) context.Context { + return auth.WithAssurance( + storyCallerContext(ctx, userID, orgID), + auth.Assurance{ + AuthenticationMethods: []string{auth.AuthenticationMethodOTP}, + AuthenticatedAt: time.Now(), + Level: auth.AssuranceLevelAAL2, + MFAVerifiedAt: time.Now(), + }, + ) +} + // A scope grant names a subject of the organization it is written in. A subject // of another organization is refused. +// +// The caller carries a SATISFIED second factor, because a scope grant is a +// privilege-granting mutation and sits behind one (SP-IDENT-10, requireMFA in +// PermServer.GrantScope). That gate runs before the mutation, so an actor without +// a recent factor is refused with `mfa_required` and never reaches the subject at +// all — correct, and it would make this test assert the factor rather than the +// tenancy. Satisfying it is what lets the subject check be the thing under test. func TestGrantScopeRefusesASubjectOfAnotherOrganization(t *testing.T) { w := newGrantScopeWorld(t) - caller := storyCallerContext(testCtx, w.admin, w.org) + caller := callerWithRecentFactor(testCtx, w.admin, w.org) _, err := (&adapters.PermServer{}).GrantScope(caller, w.grantTo(w.otherTeam, gen.SubjectKind_SUBJECT_KIND_TEAM)) require.Equal(t, codes.PermissionDenied, status.Code(err), "a team of another organization is refused: %v", err) diff --git a/module/services/accounts/code/pkg/business/service.go b/module/services/accounts/code/pkg/business/service.go index a4aa84c00..7ea3a613c 100644 --- a/module/services/accounts/code/pkg/business/service.go +++ b/module/services/accounts/code/pkg/business/service.go @@ -33,6 +33,7 @@ type Service struct { store Store hasher KeyHasher validator auth.TokenValidator // production: validates provider tokens after OAuth code exchange + headerJWTValidator auth.TokenValidator // header-jwt mode only: validates the gateway-injected identity assertion exchanger CodeExchanger // production: exchanges OAuth codes for provider tokens devValidator auth.TokenValidator // development only: allowlists explicit fixture identities resolver auth.IdentityResolver @@ -79,7 +80,7 @@ type Service struct { datasourceJobs jobs.Producer // privileged inbox producer for datasource ingest deliveries datasourceSyncOperations DatasourceSyncOperationStore datasourceConnectors *connector.Registry // the descriptor-driven connector registry; nil until the connector is configured - datasourceLinkKey []byte // signs account-link states; derived from the deployment's internal key + datasourceLinkKey []byte // signs account-link states; delivered as datasource-keys/DATASOURCE_ACCOUNT_LINK_KEY, not derived from any other credential datasourceLinkers map[string]DatasourceAccountLinker datasourceOAuth map[string]DatasourceOAuthProvider datasourceTXTResolver TXTResolver @@ -585,6 +586,25 @@ func (s *Service) SetTokenValidator(v auth.TokenValidator) { s.validator = v } +// SetHeaderJWTTokenValidator enables the header-jwt login path, where the +// identity assertion arrives as a token the trusted gateway injected rather than +// through an authorization-code exchange. +// +// It is a SEPARATE field from the code-exchange validator on purpose. Both are +// built from the same configured provider's key set, so sharing one field made +// the two paths indistinguishable: a body carrying `header_jwt` reached the +// code-exchange validator, which checks a signature, an issuer, an audience and +// an expiry — and no nonce, because a nonce is the code exchange's binding to a +// single authorize request and lives in the state the exchange holds. The result +// was an id_token admitted with no state, no proof-of-possession verifier and no +// nonce: exactly the injection and replay the nonce exists to stop. +// +// Only work.go's header-jwt branch calls this, so in every other provider mode +// the field is nil and the body credential is refused by name. +func (s *Service) SetHeaderJWTTokenValidator(v auth.TokenValidator) { + s.headerJWTValidator = v +} + // SetDevelopmentTokenValidator explicitly enables fixture authentication. // The validator must resolve an opaque fixture token to allowlisted claims; // Authenticate never trusts provider identity fields supplied by the caller. @@ -619,12 +639,15 @@ func (s *Service) SetEmailOutbox(outbox *email.Outbox, appBaseURL string) { // publicBaseURL is the origin baked into interactive links — magic-link, // invitation, and waitlist emails, and Stripe redirect targets. Those links are // delivered out of band and never re-validated downstream, so the origin must be -// operator-trusted. A configured APP_BASE_URL wins; the frontend-supplied -// verified public origin is a per-request, caller-influenced value (the frontend -// derives it from the browser request when its own endpoint is a placeholder), -// so it is only a fallback for deployments that have not pinned a canonical -// origin. Without either, callers fail closed rather than mint a link on an -// unverified host. +// operator-trusted. +// +// A configured APP_BASE_URL wins, and outside local development boot requires one +// (requireApplicationBaseURL). The verified public origin behind it is no longer a +// caller-influenced value: a forwarded origin is recorded as verified only when it +// equals the configured one (auth.WithVerifiedPublicOrigin), so where both exist +// they are the same string and where only the second does, nothing is pinned and +// the runtime is local. Without either, callers fail closed rather than mint a link +// on an unverified host. func (s *Service) publicBaseURL(ctx context.Context) string { if base := strings.TrimSuffix(strings.TrimSpace(s.appBaseURL), "/"); base != "" { return base diff --git a/module/services/accounts/code/pkg/business/service_test.go b/module/services/accounts/code/pkg/business/service_test.go index df3ab99a4..708bbe212 100644 --- a/module/services/accounts/code/pkg/business/service_test.go +++ b/module/services/accounts/code/pkg/business/service_test.go @@ -543,7 +543,9 @@ func TestLogout(t *testing.T) { }) require.NoError(t, err) - err = testService.Logout(testCtx, &gen.LogoutRequest{RefreshToken: authResp.RefreshToken}, "") + // No access token and no verified session: the body's refresh credential alone + // must still end the family, which is what a non-browser caller presents. + err = testService.Logout(testCtx, &gen.LogoutRequest{RefreshToken: authResp.RefreshToken}, "", "") require.NoError(t, err) _, err = testService.RefreshToken(testCtx, &gen.RefreshTokenRequest{ diff --git a/module/services/accounts/code/pkg/business/subject_tenancy_integration_test.go b/module/services/accounts/code/pkg/business/subject_tenancy_integration_test.go index 4d8d44401..e578f822a 100644 --- a/module/services/accounts/code/pkg/business/subject_tenancy_integration_test.go +++ b/module/services/accounts/code/pkg/business/subject_tenancy_integration_test.go @@ -97,10 +97,15 @@ func awaitBlockedTransaction(t *testing.T, msg string) { }, 10*time.Second, 20*time.Millisecond, msg) } +// The caller carries a satisfied second factor: a role assignment is a +// privilege-granting mutation and sits behind one (requireMFA in +// PermServer.AssignRole), and that gate runs before the mutation — so without it +// this asserts the factor rather than the subject's tenancy. ShareRecord below +// needs no such caller; it is not one of the gated mutations. func TestAssignRoleRequiresTheSubjectToBelongToTheOrganization(t *testing.T) { w := newTenancyWorld(t) server := &adapters.PermServer{} - caller := storyCallerContext(testCtx, w.admin, w.org) + caller := callerWithRecentFactor(testCtx, w.admin, w.org) // A scoped assignment is the one whose write also touches the subject's // sessions; it is refused before it is written. diff --git a/module/services/accounts/code/pkg/gen/saas/accounts/v1/api_keys.pb.go b/module/services/accounts/code/pkg/gen/saas/accounts/v1/api_keys.pb.go index cc6e334c0..fb92d263e 100644 --- a/module/services/accounts/code/pkg/gen/saas/accounts/v1/api_keys.pb.go +++ b/module/services/accounts/code/pkg/gen/saas/accounts/v1/api_keys.pb.go @@ -718,7 +718,7 @@ const file_saas_accounts_v1_api_keys_proto_rawDesc = "" + "\x18API_KEY_ENVIRONMENT_TEST\x10\x022\xf1\x05\n" + "\rAPIKeyService\x12\xcd\x01\n" + "\fCreateAPIKey\x12%.saas.accounts.v1.CreateAPIKeyRequest\x1a&.saas.accounts.v1.CreateAPIKeyResponse\"n\xc2\xf3\x18S\b\x02\x10\x04\x1a\x0eapi_keys:write*\x15\n" + - "\x0forganization_id\x10\x02\x18\x010\x01:\x18\n" + + "\x0forganization_id\x10\x02\x18\x010\x04:\x18\n" + "\x14saas.api_key.created\x10\x02@\x01H\x04P\x03X\x04`\x01x\x02\x82\xd3\xe4\x93\x02\x11:\x01*\"\f/v1/api-keys\x12\xbd\x01\n" + "\vListAPIKeys\x12$.saas.accounts.v1.ListAPIKeysRequest\x1a%.saas.accounts.v1.ListAPIKeysResponse\"a\xc2\xf3\x18I\b\x02\x10\x03\x1a\rapi_keys:read\"\rapi_keys:read*\x15\n" + "\x0forganization_id\x10\x02\x18\x010\x01:\x02\x10\x01@\x01H\x03P\x03X\x03`\x01\x82\xd3\xe4\x93\x02\x0e\x12\f/v1/api-keys\x12\xd0\x01\n" + diff --git a/module/services/accounts/code/pkg/gen/saas/accounts/v1/installations.pb.go b/module/services/accounts/code/pkg/gen/saas/accounts/v1/installations.pb.go index 201d081f5..0794d9af7 100644 --- a/module/services/accounts/code/pkg/gen/saas/accounts/v1/installations.pb.go +++ b/module/services/accounts/code/pkg/gen/saas/accounts/v1/installations.pb.go @@ -310,7 +310,16 @@ type InstallSolutionRequest struct { CoOwnerPrincipalIds []string `protobuf:"bytes,9,rep,name=co_owner_principal_ids,json=coOwnerPrincipalIds,proto3" json:"co_owner_principal_ids,omitempty"` // The agent's delegated-execution ceiling (migration 108): the Work Context // audiences it may be minted for and the resource kinds it may ever touch. - // Empty = unrestricted. + // + // Empty = unrestricted FOR THIS INSTALLATION'S OWN WORK, which is what an + // administrator consented to when they installed it. It is NOT unrestricted + // for a mint made with a credential delivered to a THIRD PARTY on a person's + // behalf — an MCP client the person authorized for this one solution. There + // the installation is the only statement of what the solution may act for, so + // an empty list is read as nothing and such a mint is REFUSED by name + // (accounts' holdConfinedMintToItsConsumer). The two readings are opposite on + // purpose: one bounds a consumer an admin trusts, the other bounds a + // credential they never saw. AllowedAudiences []string `protobuf:"bytes,10,rep,name=allowed_audiences,json=allowedAudiences,proto3" json:"allowed_audiences,omitempty"` AllowedScopes []string `protobuf:"bytes,11,rep,name=allowed_scopes,json=allowedScopes,proto3" json:"allowed_scopes,omitempty"` // The solution target being installed, from ListAvailableSolutions. Required. diff --git a/module/services/accounts/code/shipped_placeholder.go b/module/services/accounts/code/shipped_placeholder.go new file mode 100644 index 000000000..b1f921cbf --- /dev/null +++ b/module/services/accounts/code/shipped_placeholder.go @@ -0,0 +1,133 @@ +package main + +import ( + "fmt" + "strings" +) + +// Shipped-placeholder refusal. +// +// The module package ships working local defaults for its secret groups, and the +// repository they live in is public. The configuration-defaults gate requires +// every one of those defaults to carry a placeholder marker, precisely so a real +// credential can never be committed as one. That makes the same marker list the +// answer to the opposite question: a value carrying one is a value everybody +// knows. +// +// Nothing rejected one outside local development, so an unprovisioned secret +// group was not a denied group — it was a live credential with a published value, +// and the perimeter rested on mesh authorization alone. +// +// What is refused here, by name, at boot: the credentials that decide whether a +// caller is inside the perimeter. An optional feature's unprovisioned credential +// is a different question with a different answer — the feature is off — and +// conflating the two would wedge a cell that simply does not use one. + +// shippedPlaceholderMarkers are the phrases a shipped default carries. It is the +// same list the configuration-defaults gate requires of every secret default; +// TestShippedPlaceholderMarkersMatchTheShippingGate in module/tools holds the two +// copies in lockstep, because a marker added on one side and not the other means +// either a default that cannot ship or a published value a cell would accept. +// +// Each is a distinctive placeholder phrase. Common English words — "example", +// "dummy" — are deliberately absent: a production-shaped value would borrow one +// as a loose substring and be refused for no reason. +var shippedPlaceholderMarkers = []string{ + "replace-me", "replaceme", "change-me", "changeme", + "placeholder", "local-dev", "dev-only", +} + +// minimumPerimeterCredentialLength is the floor for a credential that decides +// perimeter membership. 32 characters is the shortest value that can carry 128 +// bits of entropy in any encoding an operator is likely to generate, and the +// shipped placeholders are shorter — so the length check is not a second spelling +// of the marker check: it also refuses a short hand-typed value that happens to +// carry no marker. +const minimumPerimeterCredentialLength = 32 + +// looksLikeShippedPlaceholder reports whether value carries a marker. +func looksLikeShippedPlaceholder(value string) bool { + lower := strings.ToLower(value) + for _, marker := range shippedPlaceholderMarkers { + if strings.Contains(lower, marker) { + return true + } + } + return false +} + +// refusePerimeterCredential reports why a perimeter credential is unfit for a +// deployed runtime, or nil. name is the group-qualified key, so the refusal says +// what an operator has to provision rather than that something was wrong. +// +// It never includes the value. +func refusePerimeterCredential(name, value string, current bool) error { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + if current { + return fmt.Errorf( + "%s is absent; a deployed cell must provision it before this service can "+ + "decide that a caller is inside the perimeter", name) + } + return nil + } + if looksLikeShippedPlaceholder(trimmed) { + return fmt.Errorf( + "%s carries a placeholder this module ships in its public local defaults; "+ + "provision a real value for this cell", name) + } + if len(trimmed) < minimumPerimeterCredentialLength { + return fmt.Errorf( + "%s is %d characters; a credential that decides perimeter membership must be at least %d", + name, len(trimmed), minimumPerimeterCredentialLength) + } + return nil +} + +// perimeterCredential is one value this service trusts to decide that a caller is +// inside the perimeter. Current distinguishes the two cases a single list was +// conflating: a CURRENT credential the deployment must carry, and the PREVIOUS +// half of a rotation, which is absent on every cell that is not mid-rotation. +// Refusing both alike would make the ordinary state an outage; refusing neither +// let a cell start with no perimeter credential at all, which is what R1019-N17 +// found. The flag is the distinction, so each answer is given once. +type perimeterCredential struct { + Name string + Value string + Current bool +} + +// perimeterCredentials are the values this service trusts to decide that a caller +// is inside the perimeter: the cluster-internal token both halves of a rotation, +// and the gateway-provenance token this service believes forwarded identity on. +func perimeterCredentials() []perimeterCredential { + return []perimeterCredential{ + {"internal-auth/CODEFLY_INTERNAL_TOKEN", workspaceEnv("internal-auth", "CODEFLY_INTERNAL_TOKEN"), true}, + {"internal-auth/CODEFLY_INTERNAL_TOKEN_PREVIOUS", workspaceEnv("internal-auth", "CODEFLY_INTERNAL_TOKEN_PREVIOUS"), false}, + {"gateway-trust/CODEFLY_GATEWAY_TOKEN", workspaceEnv("gateway-trust", "CODEFLY_GATEWAY_TOKEN"), true}, + {"gateway-trust/CODEFLY_GATEWAY_TOKEN_PREVIOUS", workspaceEnv("gateway-trust", "CODEFLY_GATEWAY_TOKEN_PREVIOUS"), false}, + } +} + +// requirePerimeterCredentials refuses to start a deployed runtime holding a +// shipped placeholder, or a perimeter credential too short to be one. +// +// Every refusal is collected rather than the first returned: an operator fixing +// these is editing one secret group, and finding out about the second key on the +// next deploy is a second outage for no reason. +func requirePerimeterCredentials(isLocal bool) error { + if isLocal { + return nil + } + var problems []string + for _, credential := range perimeterCredentials() { + if err := refusePerimeterCredential(credential.Name, credential.Value, credential.Current); err != nil { + problems = append(problems, err.Error()) + } + } + if len(problems) == 0 { + return nil + } + return fmt.Errorf("perimeter credentials are unfit for a deployed runtime: %s", + strings.Join(problems, "; ")) +} diff --git a/module/services/accounts/code/shipped_placeholder_test.go b/module/services/accounts/code/shipped_placeholder_test.go new file mode 100644 index 000000000..063eec767 --- /dev/null +++ b/module/services/accounts/code/shipped_placeholder_test.go @@ -0,0 +1,193 @@ +package main + +import ( + "strings" + "testing" + + "accounts/pkg/keyservice" + + "github.com/stretchr/testify/require" +) + +// A shipped default is a published value: these files ride verbatim in the +// immutable module package, out of a public repository. Outside local development +// a service holding one is a service whose perimeter credential everybody knows, +// and nothing said so. +func TestDeployedRefusesShippedPlaceholders(t *testing.T) { + for _, value := range []string{ + "local-dev-only-replace-me", + "local-dev-gateway-only-replace-me", + "LOCAL-DEV-ONLY-REPLACE-ME", + "prefixed-changeme-suffixed-and-long-enough-to-pass-the-length-floor", + "a-placeholder-value-long-enough-to-pass-the-length-floor", + } { + err := refusePerimeterCredential("internal-auth/CODEFLY_INTERNAL_TOKEN", value, true) + require.Error(t, err, "%q carries a shipped marker and must be refused", value) + require.Contains(t, err.Error(), "internal-auth/CODEFLY_INTERNAL_TOKEN", + "the refusal must name the key an operator has to provision") + require.NotContains(t, err.Error(), value, "a refusal never prints the value") + } +} + +// The length floor refuses a short value that carries no marker at all, so the +// two checks are not one check spelled twice. +func TestDeployedRefusesAShortPerimeterCredential(t *testing.T) { + err := refusePerimeterCredential("gateway-trust/CODEFLY_GATEWAY_TOKEN", "7Kq2Xp9Vb4Nf", true) + require.Error(t, err) + require.Contains(t, err.Error(), "gateway-trust/CODEFLY_GATEWAY_TOKEN") + require.Contains(t, err.Error(), "at least 32") + require.NotContains(t, err.Error(), "7Kq2Xp9Vb4Nf") +} + +// The controls. A provisioned credential passes, and an ABSENT one is not refused +// here: whether a credential is required is each loader's own question, and +// a rotation's PREVIOUS half is absent on every cell that is not mid-rotation, so +// refusing it would make the ordinary state an outage. A CURRENT credential's +// absence is the opposite answer, and R1019-N17 is what giving both the same one +// cost: a deployed cell started with no perimeter credential at all. +func TestPerimeterCredentialChecksAdmitWhatTheyShould(t *testing.T) { + require.NoError(t, refusePerimeterCredential( + "internal-auth/CODEFLY_INTERNAL_TOKEN", strings.Repeat("7Kq2Xp9Vb4Nf", 4), true)) + require.NoError(t, refusePerimeterCredential( + "internal-auth/CODEFLY_INTERNAL_TOKEN_PREVIOUS", "", false)) + require.NoError(t, refusePerimeterCredential( + "internal-auth/CODEFLY_INTERNAL_TOKEN_PREVIOUS", " ", false)) +} + +// Local development runs on the shipped defaults, so the gate is the deployed +// runtime's and must not touch it. +func TestLocalRuntimeKeepsTheShippedDefaults(t *testing.T) { + require.NoError(t, requirePerimeterCredentials(true)) +} + +// Every perimeter credential this service reads is covered. A fifth key added to +// the trust surface and not listed here would be unchecked and look checked. +func TestPerimeterCredentialsCoverEveryTrustDecidingKey(t *testing.T) { + names := map[string]bool{} + for _, credential := range perimeterCredentials() { + names[credential.Name] = true + } + require.Equal(t, map[string]bool{ + "internal-auth/CODEFLY_INTERNAL_TOKEN": true, + "internal-auth/CODEFLY_INTERNAL_TOKEN_PREVIOUS": true, + "gateway-trust/CODEFLY_GATEWAY_TOKEN": true, + "gateway-trust/CODEFLY_GATEWAY_TOKEN_PREVIOUS": true, + }, names) +} + +// A cell without a pinned public origin is a cell whose verified origin is a +// request parameter: unset, an OAuth redirect, the authenticator relying-party +// origin and every emailed link were bound to whatever origin the request's trusted +// hop forwarded — which that hop derived from the caller's own forwarding header. +func TestDeployedRequiresTheApplicationBaseURL(t *testing.T) { + blankWorkspaceKey(t, "application", "APP_BASE_URL") + + err := requireApplicationBaseURL(false) + require.Error(t, err, "a deployed runtime must refuse an unset APP_BASE_URL") + require.Contains(t, err.Error(), "APP_BASE_URL", + "the refusal must name the key an operator has to set") + + require.NoError(t, requireApplicationBaseURL(true), + "local development pins no runtime port") +} + +// Every perimeter credential and the application base URL are refused when a +// deployed runtime holds a placeholder, which is also what the shipped local +// defaults hold — so a test that drives these reads has to blank BOTH carriers. +// `codefly ci run` injects this module's own local groups as +// CODEFLY__WORKSPACE_CONFIGURATION____ (and the secret form), while a +// bare `go test` injects none and only the plain fallback can carry a stray value. +// Blanking one passes locally and fails in CI, or the reverse. +func blankWorkspaceKey(t *testing.T, group, key string) { + t.Helper() + upper := strings.ToUpper(group) + upper = strings.ReplaceAll(upper, "-", "_") + t.Setenv(key, "") + t.Setenv("CODEFLY__WORKSPACE_CONFIGURATION__"+upper+"__"+key, "") + t.Setenv("CODEFLY__WORKSPACE_SECRET_CONFIGURATION__"+upper+"__"+key, "") +} + +// The deployed perimeter check over the REAL carriers, not only over the pure +// comparison: with the shipped local default in the group a deployed runtime +// refuses, and with a provisioned value it starts. +func TestDeployedPerimeterCheckReadsTheConfigurationGroup(t *testing.T) { + for _, key := range []string{"CODEFLY_INTERNAL_TOKEN", "CODEFLY_INTERNAL_TOKEN_PREVIOUS"} { + blankWorkspaceKey(t, "internal-auth", key) + } + for _, key := range []string{"CODEFLY_GATEWAY_TOKEN", "CODEFLY_GATEWAY_TOKEN_PREVIOUS"} { + blankWorkspaceKey(t, "gateway-trust", key) + } + + // Nothing provisioned. This asserted NoError until R1019-N17: deferring the + // required-or-optional question to "each loader" meant no loader asked it, and a + // deployed cell started holding no perimeter credential at all. The CURRENT keys + // are refused by name here; the rotation halves stay optional below. + err := requirePerimeterCredentials(false) + require.Error(t, err, "a deployed cell with no perimeter credential must refuse to start") + require.Contains(t, err.Error(), "internal-auth/CODEFLY_INTERNAL_TOKEN") + require.Contains(t, err.Error(), "gateway-trust/CODEFLY_GATEWAY_TOKEN") + require.NotContains(t, err.Error(), "CODEFLY_INTERNAL_TOKEN_PREVIOUS", + "a rotation half is absent on every cell that is not mid-rotation") + + // The value this module ships in its public local defaults. + t.Setenv("CODEFLY__WORKSPACE_SECRET_CONFIGURATION__INTERNAL_AUTH__CODEFLY_INTERNAL_TOKEN", + "local-dev-only-replace-me") + err = requirePerimeterCredentials(false) + require.Error(t, err) + require.Contains(t, err.Error(), "internal-auth/CODEFLY_INTERNAL_TOKEN") + + // Provisioned values for both CURRENT keys, and still no rotation halves. + t.Setenv("CODEFLY__WORKSPACE_SECRET_CONFIGURATION__INTERNAL_AUTH__CODEFLY_INTERNAL_TOKEN", + strings.Repeat("7Kq2Xp9Vb4Nf", 4)) + t.Setenv("CODEFLY__WORKSPACE_SECRET_CONFIGURATION__GATEWAY_TRUST__CODEFLY_GATEWAY_TOKEN", + strings.Repeat("9Zr4Lm7Td2Wc", 4)) + require.NoError(t, requirePerimeterCredentials(false)) +} + +// R1019-N17, through the whole startup path rather than the perimeter check alone: +// with key custody and the public origin provisioned, an absent CURRENT perimeter +// credential still has to stop the service. requireStartupConfiguration is what +// main calls, so a refusal that exists only in the helper is not a refusal. +func TestR1019StartupRefusesAnAbsentCurrentPerimeterCredential(t *testing.T) { + provision := func(t *testing.T, absentKey string) { + t.Helper() + // Everything a hosted startup requires BEFORE the perimeter credentials, + // so the refusal under test is the one that answers. requireStartupConfiguration + // resolves the key service first and then its custody, and each refuses a + // hosted boot by name — so without these the subject is never reached and + // every case below would pass on somebody else's refusal. + keyServiceBackend(t, string(keyservice.BackendVault)) + t.Setenv("CODEFLY__WORKSPACE_CONFIGURATION__VAULT__VAULT_KEY_CUSTODY", "make seed-signing-key") + t.Setenv("CODEFLY__WORKSPACE_CONFIGURATION__APPLICATION__APP_BASE_URL", "https://app.example") + for _, credential := range perimeterCredentials() { + group, key, _ := strings.Cut(credential.Name, "/") + upper := strings.ReplaceAll(strings.ToUpper(group), "-", "_") + value := strings.Repeat("7Kq2Xp9Vb4Nf", 4) + if key == absentKey || strings.HasSuffix(key, "_PREVIOUS") { + value = "" + } + t.Setenv(key, value) + t.Setenv("CODEFLY__WORKSPACE_CONFIGURATION__"+upper+"__"+key, value) + t.Setenv("CODEFLY__WORKSPACE_SECRET_CONFIGURATION__"+upper+"__"+key, value) + } + } + + for _, absent := range []struct{ group, key string }{ + {"internal-auth", "CODEFLY_INTERNAL_TOKEN"}, + {"gateway-trust", "CODEFLY_GATEWAY_TOKEN"}, + } { + t.Run(absent.key, func(t *testing.T) { + provision(t, absent.key) + err := requireStartupConfiguration(t.Context(), false) + require.Error(t, err, "%s is absent and startup must refuse", absent.key) + require.Contains(t, err.Error(), absent.group+"/"+absent.key) + }) + } + + // The positive control: the same provisioning with nothing absent starts. Without + // it, a startup path that refused unconditionally would pass every case above. + t.Run("provisioned startup proceeds", func(t *testing.T) { + provision(t, "") + require.NoError(t, requireStartupConfiguration(t.Context(), false)) + }) +} diff --git a/module/services/accounts/code/work.go b/module/services/accounts/code/work.go index 8840d0fdc..7d662bf65 100644 --- a/module/services/accounts/code/work.go +++ b/module/services/accounts/code/work.go @@ -35,6 +35,7 @@ import ( "os" "strconv" "strings" + "sync" "time" "github.com/codefly-dev/core/wool" @@ -644,7 +645,7 @@ func doWork(ctx context.Context) (Clean, error) { if !hasConfiguredValue(headerName) { return nil, fmt.Errorf("identity provider header-jwt requires IDENTITY_HEADER_NAME") } - service.SetTokenValidator(v) + service.SetHeaderJWTTokenValidator(v) adapters.SetHeaderJWTLoginHeader(headerName) default: // user_identities.provider is a foreign key into the identity_providers @@ -663,7 +664,7 @@ func doWork(ctx context.Context) (Clean, error) { // SSO administration is a WorkOS-specific optional adapter. Other // identity providers cannot accidentally activate it by exposing a // similarly named management credential. - service.SetSSOManagementAPIKey(identityEnv("IDENTITY_MANAGEMENT_API_KEY")) + service.SetSSOManagementAPIKey(identityProviderSecret("IDENTITY_MANAGEMENT_API_KEY")) } oauthPolicy, err := buildOAuthRequestPolicy(authProvider) if err != nil { @@ -842,6 +843,10 @@ func doWork(ctx context.Context) (Clean, error) { // debugging; every deployed environment returns generic auth errors so the // identity/enumeration oracle stays closed (#208). adapters.SetExposeAuthErrorDetail(codefly.IsLocal()) + // A browser discards a Secure cookie on a plaintext loopback origin, so local + // development needs the exemption; nothing else does, and the default keeps + // Secure. + adapters.SetAllowInsecureRefreshCookie(codefly.IsLocal()) // Separate shared-secret guards for internal RPC admission and forwarded // gateway identity. Empty values fail closed; production deploys provide @@ -862,10 +867,14 @@ func doWork(ctx context.Context) (Clean, error) { adapters.SetGatewayToken(gatewayCredentials.current) adapters.SetPreviousGatewayToken(gatewayCredentials.previous) adapters.SetPreviousGatewayTokenExpiresAt(gatewayCredentials.previousExpiresAt) - // The datasource content-ticket signer is keyed from the same internal secret, - // domain-separated, so a change-set job's opaque content ticket verifies at - // redemption without a second key to provision. - service.SetDatasourceTicketKey([]byte(workspaceEnv("internal-auth", "CODEFLY_INTERNAL_TOKEN"))) + // One delivered key per purpose, never derived from the perimeter credential: + // see business.SetDatasourceKeys. A key a deployed runtime may not use is + // passed as ABSENT, which leaves its purpose unavailable — the same answer as + // an unprovisioned key, and the reason the loader does not need a second one. + service.SetDatasourceKeys( + usableDatasourceKey("DATASOURCE_CONTENT_TICKET_KEY", codefly.IsLocal()), + usableDatasourceKey("DATASOURCE_ACCOUNT_LINK_KEY", codefly.IsLocal()), + ) centralEnforcement, err := configuredCentralEnforcement() if err != nil { @@ -918,6 +927,13 @@ func doWork(ctx context.Context) (Clean, error) { if err != nil { return nil, err } + // The one origin this deployment treats as its own: what binds an OAuth + // redirect, an authenticator's relying party and every emailed link. From here + // on a forwarded origin is honoured only when it equals this one, so no hop can + // substitute a caller's choice (pkg/auth.WithVerifiedPublicOrigin). + if err := auth.SetConfiguredPublicOrigin(appBase); err != nil { + return nil, fmt.Errorf("application: APP_BASE_URL: %w", err) + } var workerEmailOutbox *email.Outbox var emailWorker *jobs.Worker if emailConfig.sender == nil { @@ -1709,6 +1725,22 @@ func configuredApplicationBaseURL() (string, error) { if parsed.Scheme != "https" && parsed.Hostname() != "localhost" && parsed.Hostname() != "127.0.0.1" { return "", fmt.Errorf("application: APP_BASE_URL must use https outside local development") } + // The value must also survive the function that will CANONICALIZE it at request + // time. The checks above are url.Parse plus field rules; pkg/auth uses IDNA. Two + // validators for one value meant an origin boot accepted and every request then + // refused — the cell started green and no authentication worked, because + // WithVerifiedPublicOrigin could not canonicalize what was configured + // (R1019-N12/f1). One validator decides. + if _, err := auth.CanonicalPublicOrigin(strings.TrimSuffix(raw, "/")); err != nil { + return "", fmt.Errorf( + "application: APP_BASE_URL is not an origin this service can use (%w): it is the "+ + "value every OAuth redirect, relying-party origin, emailed link and same-origin "+ + "comparison is bound to, so it has to be one this host can compare", err) + } + // The RAW value is returned, not the canonical one. It is also the token issuer and + // the published authorization-server metadata, where the spelling is part of the + // contract a verifier compares exactly — canonicalizing here would silently change + // `iss` for any deployment whose configured origin carries, say, an explicit :443. return strings.TrimSuffix(raw, "/"), nil } @@ -2018,6 +2050,68 @@ func identityEnv(key string) string { return value } +// identityProviderSecret reads a credential that authenticates THIS HOST to the +// identity provider, from the `identity-provider` group only accounts declares. +// +// They used to live in `identity`, which the frontend declares too — it renders +// the login page from that group's public facts, the issuer and the client id — +// so a deployment putting the provider's credentials there delivered them to the +// frontend as well, which reads neither. A public-facing process holding the +// provider's administration credential widens what a frontend compromise is worth, +// for nothing. +// +// The read falls back to `identity` while a deployment still holds them there, and +// says so once per key: the group a value is delivered under is the composition's +// to change, and a hard cutover here would take sign-in down on every cell between +// this landing and that edit. The fallback goes when the cells have moved — until +// then, the warning is how an operator knows one has not. +func identityProviderSecret(key string) string { + destination, _ := codefly.For(codefly.Context()).WorkspaceValue("identity-provider", key) + legacy := identityEnv(key) + + // A PROVISIONED destination value always wins. + // + // "Provisioned" has to exclude the placeholder this module ships in the new + // group's local defaults, and that is the whole of what this gets right: + // hasConfiguredValue rejects only the literal REPLACE_ME, so the shipped + // default read as configured and overrode a real value still delivered under + // `identity` — defeating the migration fallback precisely on the cells that + // have not migrated yet, which are the only ones that need it. + if isProvisionedCredential(destination) { + return destination + } + if isProvisionedCredential(legacy) { + reportIdentityProviderSecretStillInIdentityGroup(key) + return legacy + } + // Neither group holds a provisioned value: local development, where the + // shipped placeholder IS the intended value. Prefer the destination group so a + // developer edits the group this service now declares. + if strings.TrimSpace(destination) != "" { + return destination + } + return legacy +} + +// isProvisionedCredential reports whether a value is one an operator supplied, as +// opposed to a placeholder this module ships or an unset key. It is the selection +// rule for a credential that is moving between configuration groups: a shipped +// default must not count as the destination being ready. +func isProvisionedCredential(value string) bool { + return hasConfiguredValue(value) && !looksLikeShippedPlaceholder(value) +} + +var identityProviderSecretWarned sync.Map + +func reportIdentityProviderSecretStillInIdentityGroup(key string) { + if _, loaded := identityProviderSecretWarned.LoadOrStore(key, true); loaded { + return + } + wool.Get(context.Background()).In("identityProviderSecret").Warn( + "an identity-provider credential is still delivered under the `identity` group, which the frontend also declares; move it to `identity-provider`, which only this service declares", + wool.Field("key", key)) +} + // applicationEnv is also Codefly-only. Local product origins and bootstrap // identity must come from the selected workspace configuration, never from an // ambient shell file that can disagree with the browser runtime. @@ -2136,7 +2230,7 @@ func buildGenericOIDCStack(provider string) (auth.TokenValidator, business.CodeE // two can never disagree at login. func discoverOIDCValidator(provider string, defaultAudienceToClientID bool) (validator auth.TokenValidator, tokenURL, clientID, clientSecret string, err error) { clientID = identityEnv("IDENTITY_CLIENT_ID") - clientSecret = identityEnv("IDENTITY_CLIENT_SECRET") + clientSecret = identityProviderSecret("IDENTITY_CLIENT_SECRET") issuer := identityEnv("IDENTITY_ISSUER") if !hasConfiguredValue(clientID) || !hasConfiguredValue(clientSecret) { return nil, "", "", "", fmt.Errorf( @@ -2240,7 +2334,7 @@ func buildProviderStack(provider, selectedFixture string) (auth.TokenValidator, domain := identityEnv("IDENTITY_DOMAIN") audience := identityEnv("IDENTITY_AUDIENCE") clientID := identityEnv("IDENTITY_CLIENT_ID") - clientSecret := identityEnv("IDENTITY_CLIENT_SECRET") + clientSecret := identityProviderSecret("IDENTITY_CLIENT_SECRET") if !hasConfiguredValue(domain) || !hasConfiguredValue(clientID) || !hasConfiguredValue(clientSecret) || !hasConfiguredValue(audience) { return nil, nil, fmt.Errorf("identity provider auth0 requires IDENTITY_DOMAIN, IDENTITY_AUDIENCE, IDENTITY_CLIENT_ID, and IDENTITY_CLIENT_SECRET") } @@ -2260,7 +2354,7 @@ func buildProviderStack(provider, selectedFixture string) (auth.TokenValidator, case "google": clientID := identityEnv("IDENTITY_CLIENT_ID") - clientSecret := identityEnv("IDENTITY_CLIENT_SECRET") + clientSecret := identityProviderSecret("IDENTITY_CLIENT_SECRET") if !hasConfiguredValue(clientID) || !hasConfiguredValue(clientSecret) { return nil, nil, fmt.Errorf("identity provider google requires IDENTITY_CLIENT_ID and IDENTITY_CLIENT_SECRET") } @@ -2561,7 +2655,77 @@ func requireStartupConfiguration(ctx context.Context, isLocal bool) error { if err != nil { return err } - return requireKeyCustody(selection.SigningBackend, isLocal) + if err := requireKeyCustody(selection.SigningBackend, isLocal); err != nil { + return err + } + if err := requirePerimeterCredentials(isLocal); err != nil { + return err + } + return requireApplicationBaseURL(isLocal) +} + +// requireApplicationBaseURL refuses to start a deployed runtime without the +// origin it treats as its own. +// +// It is the value that binds an OAuth redirect, an authenticator's relying party +// and every emailed link. Unset, each of those was bound to whatever origin the +// request's trusted hop forwarded — which that hop derived from the caller's own +// forwarding header — so a caller chose the host a sign-in returned to and the host +// an emailed link pointed at. A cell without it is a cell whose verified origin is +// a request parameter, so it refuses here, where a deployment can still be fixed, +// rather than serving a sign-in bound to somewhere else. +// usableDatasourceKey resolves one datasource message-authentication key, or nil +// when a deployed runtime may not use what is configured. +// +// The shipped local defaults for this group carry placeholder markers, exactly as +// every other secret group's do, and this module's repository is public. A deployed +// runtime holding one would be signing with a published value, so it is treated as +// ABSENT rather than refused at boot: unlike a perimeter credential, a datasource +// key is not required for the service to serve, and the purpose it signs for +// already has a defined unavailable state. Refusing to start would turn an +// unprovisioned optional purpose into an outage. +// +// Local development uses the shipped default, which is its intended value. +func usableDatasourceKey(key string, isLocal bool) []byte { + value := strings.TrimSpace(workspaceEnv("datasource-keys", key)) + if value == "" { + return nil + } + if isLocal { + return []byte(value) + } + if looksLikeShippedPlaceholder(value) { + wool.Get(context.Background()).In("usableDatasourceKey").Warn( + "a datasource key carries a placeholder this module ships in its public local defaults; "+ + "the purpose it signs for is unavailable until the key is provisioned for this cell", + wool.Field("key", "datasource-keys/"+key)) + return nil + } + if len(value) < minimumPerimeterCredentialLength { + wool.Get(context.Background()).In("usableDatasourceKey").Warn( + "a datasource key is shorter than the minimum for a signing key; "+ + "the purpose it signs for is unavailable until it is replaced", + wool.Field("key", "datasource-keys/"+key), + wool.Field("minimum", minimumPerimeterCredentialLength)) + return nil + } + return []byte(value) +} + +func requireApplicationBaseURL(isLocal bool) error { + if isLocal { + return nil + } + configured, err := configuredApplicationBaseURL() + if err != nil { + return err + } + if configured == "" { + return fmt.Errorf("application: APP_BASE_URL is required outside the local environment: " + + "set it in the `application` configuration group to this cell's public origin, which is " + + "what binds an OAuth redirect, the authenticator relying-party origin and every emailed link") + } + return nil } // requireKeyCustody refuses to start outside the local environment without the diff --git a/module/services/accounts/code/work_test.go b/module/services/accounts/code/work_test.go new file mode 100644 index 000000000..feacddd91 --- /dev/null +++ b/module/services/accounts/code/work_test.go @@ -0,0 +1,61 @@ +package main + +import ( + "testing" + + "accounts/pkg/auth" + + "github.com/stretchr/testify/require" +) + +// R1019-N12/f1: APP_BASE_URL was validated at boot by url.Parse plus field rules and +// at request time by IDNA canonicalization. An origin the first accepted and the +// second could not canonicalize made a cell boot green and then refuse every +// authentication — sign-in, the OAuth redirect, the relying-party origin, emailed +// links and the solution proxy's same-origin check — with nothing at startup saying so. +// +// The assertion is the EQUIVALENCE, not a list of bad hosts: whatever +// CanonicalPublicOrigin refuses, boot must refuse. A second validator cannot drift +// back in without failing this. +func TestR1019StartupRefusesAnyOriginRequestTimeCannotCanonicalize(t *testing.T) { + for _, candidate := range []string{ + "https://app.example", // usable + "https://app.example:443", // usable, explicit default port + "https://my_app.example", // usable: an underscore is a legal host for a browser + "https://-app.example", // NOT usable: a label may not begin with a hyphen + "https://app-.example", // NOT usable: nor end with one + "https://app.example:0", // NOT usable: port 0 serves nothing + "https://:443", // NOT usable: no host + } { + t.Run(candidate, func(t *testing.T) { + t.Setenv("CODEFLY__WORKSPACE_CONFIGURATION__APPLICATION__APP_BASE_URL", candidate) + t.Setenv("APP_BASE_URL", candidate) + + _, requestTime := auth.CanonicalPublicOrigin(candidate) + _, boot := configuredApplicationBaseURL() + + if requestTime != nil { + require.Error(t, boot, + "request time cannot canonicalize %q, so boot must refuse it rather than "+ + "start a cell whose every origin comparison fails", candidate) + return + } + require.NoError(t, boot, + "request time canonicalizes %q, so boot must not refuse a usable origin", candidate) + }) + } +} + +// The raw spelling is what boot returns, because it is also the token issuer and the +// published authorization-server metadata, where a verifier compares it exactly. +// Canonicalizing it here would silently change `iss` for a deployment whose configured +// origin carries an explicit default port. +func TestR1019StartupKeepsTheConfiguredSpellingForTheIssuer(t *testing.T) { + t.Setenv("CODEFLY__WORKSPACE_CONFIGURATION__APPLICATION__APP_BASE_URL", "https://app.example:443") + t.Setenv("APP_BASE_URL", "https://app.example:443") + + base, err := configuredApplicationBaseURL() + require.NoError(t, err) + require.Equal(t, "https://app.example:443", base, + "the issuer keeps the operator's spelling; only the comparison canonicalizes") +} diff --git a/module/services/accounts/generated/authz-methods.json b/module/services/accounts/generated/authz-methods.json index dbbd2708b..3882447f2 100644 --- a/module/services/accounts/generated/authz-methods.json +++ b/module/services/accounts/generated/authz-methods.json @@ -27,7 +27,7 @@ "lookup": "RESOURCE_LOOKUP_DIRECT_ID" } ], - "mfa": "MFA_REQUIREMENT_NONE", + "mfa": "MFA_REQUIREMENT_IF_ENROLLED_RECENT_STEP_UP", "audit": { "events": [ "saas.api_key.created" @@ -43,13 +43,13 @@ "conditions": [], "impersonation": "IMPERSONATION_REQUIREMENT_FORBIDDEN" }, - "policy_tier": "org_admin", + "policy_tier": "mfa", "owner": { "module": "saas-starter", "service": "accounts" }, "source_proto": "saas/accounts/v1/api_keys.proto", - "policy_sha256": "43bdf1f9a139cc858b9deb4b2d4a5b2eb5b9ad7093363d1a13719d06f7e7239f", + "policy_sha256": "b3df2a4866dce11045d5802b2ffb2777b5c3d1de86184c06381c8445232faa88", "rate_limit_backend_failure_mode": "RATE_LIMIT_BACKEND_FAILURE_MODE_FAIL_OPEN" }, { diff --git a/module/services/accounts/generated/openapi-raw/api.swagger.json b/module/services/accounts/generated/openapi-raw/api.swagger.json index 3f01a78e0..524422ac1 100644 --- a/module/services/accounts/generated/openapi-raw/api.swagger.json +++ b/module/services/accounts/generated/openapi-raw/api.swagger.json @@ -8827,7 +8827,7 @@ "items": { "type": "string" }, - "description": "The agent's delegated-execution ceiling (migration 108): the Work Context\naudiences it may be minted for and the resource kinds it may ever touch.\nEmpty = unrestricted." + "description": "The agent's delegated-execution ceiling (migration 108): the Work Context\naudiences it may be minted for and the resource kinds it may ever touch.\n\nEmpty = unrestricted FOR THIS INSTALLATION'S OWN WORK, which is what an\nadministrator consented to when they installed it. It is NOT unrestricted\nfor a mint made with a credential delivered to a THIRD PARTY on a person's\nbehalf — an MCP client the person authorized for this one solution. There\nthe installation is the only statement of what the solution may act for, so\nan empty list is read as nothing and such a mint is REFUSED by name\n(accounts' holdConfinedMintToItsConsumer). The two readings are opposite on\npurpose: one bounds a consumer an admin trusts, the other bounds a\ncredential they never saw." }, "allowedScopes": { "type": "array", diff --git a/module/services/accounts/generated/service-catalog.json b/module/services/accounts/generated/service-catalog.json index 18539f094..35ebe97a5 100644 --- a/module/services/accounts/generated/service-catalog.json +++ b/module/services/accounts/generated/service-catalog.json @@ -525,7 +525,7 @@ "lookup": "RESOURCE_LOOKUP_DIRECT_ID" } ], - "mfa": "MFA_REQUIREMENT_NONE", + "mfa": "MFA_REQUIREMENT_IF_ENROLLED_RECENT_STEP_UP", "audit": { "events": [ "saas.api_key.created" @@ -541,7 +541,7 @@ "conditions": [], "impersonation": "IMPERSONATION_REQUIREMENT_FORBIDDEN" }, - "policy_tier": "org_admin", + "policy_tier": "mfa", "description": "Mint an API key for an administered organization.", "owner": { "module": "saas-starter", diff --git a/module/services/accounts/openapi/api.swagger.json b/module/services/accounts/openapi/api.swagger.json index 3e4dc5b45..a2c559bac 100644 --- a/module/services/accounts/openapi/api.swagger.json +++ b/module/services/accounts/openapi/api.swagger.json @@ -2350,7 +2350,7 @@ "type": "string" }, "allowedAudiences": { - "description": "The agent's delegated-execution ceiling (migration 108): the Work Context\naudiences it may be minted for and the resource kinds it may ever touch.\nEmpty = unrestricted.", + "description": "The agent's delegated-execution ceiling (migration 108): the Work Context\naudiences it may be minted for and the resource kinds it may ever touch.\n\nEmpty = unrestricted FOR THIS INSTALLATION'S OWN WORK, which is what an\nadministrator consented to when they installed it. It is NOT unrestricted\nfor a mint made with a credential delivered to a THIRD PARTY on a person's\nbehalf — an MCP client the person authorized for this one solution. There\nthe installation is the only statement of what the solution may act for, so\nan empty list is read as nothing and such a mint is REFUSED by name\n(accounts' holdConfinedMintToItsConsumer). The two readings are opposite on\npurpose: one bounds a consumer an admin trusts, the other bounds a\ncredential they never saw.", "items": { "type": "string" }, diff --git a/module/services/accounts/proto/saas/accounts/v1/api_keys.proto b/module/services/accounts/proto/saas/accounts/v1/api_keys.proto index 2b953c59f..8890e1ef9 100644 --- a/module/services/accounts/proto/saas/accounts/v1/api_keys.proto +++ b/module/services/accounts/proto/saas/accounts/v1/api_keys.proto @@ -112,7 +112,12 @@ service APIKeyService { target: RESOURCE_TARGET_ORGANIZATION lookup: RESOURCE_LOOKUP_DIRECT_ID } - mfa: MFA_REQUIREMENT_NONE + // Issuing a credential is a privilege-changing operation: the key it + // returns authenticates as its creator, for as long as it lives, without + // the session that asked for it. It declared MFA_REQUIREMENT_NONE while + // every other privilege-changing mutation on this host declared a factor, + // so an organization owner with no enrolled factor could mint one. + mfa: MFA_REQUIREMENT_IF_ENROLLED_RECENT_STEP_UP audit: { events: "saas.api_key.created" emission: AUDIT_EMISSION_SUCCESS diff --git a/module/services/accounts/proto/saas/accounts/v1/installations.proto b/module/services/accounts/proto/saas/accounts/v1/installations.proto index 9b70895db..bc9c0e277 100644 --- a/module/services/accounts/proto/saas/accounts/v1/installations.proto +++ b/module/services/accounts/proto/saas/accounts/v1/installations.proto @@ -131,7 +131,16 @@ message InstallSolutionRequest { ]; // The agent's delegated-execution ceiling (migration 108): the Work Context // audiences it may be minted for and the resource kinds it may ever touch. - // Empty = unrestricted. + // + // Empty = unrestricted FOR THIS INSTALLATION'S OWN WORK, which is what an + // administrator consented to when they installed it. It is NOT unrestricted + // for a mint made with a credential delivered to a THIRD PARTY on a person's + // behalf — an MCP client the person authorized for this one solution. There + // the installation is the only statement of what the solution may act for, so + // an empty list is read as nothing and such a mint is REFUSED by name + // (accounts' holdConfinedMintToItsConsumer). The two readings are opposite on + // purpose: one bounds a consumer an admin trusts, the other bounds a + // credential they never saw. repeated string allowed_audiences = 10 [ (buf.validate.field).repeated.max_items = 64, (buf.validate.field).repeated.items.string.max_len = 512 diff --git a/module/services/accounts/service.codefly.yaml b/module/services/accounts/service.codefly.yaml index 51d973f43..7416b9926 100644 --- a/module/services/accounts/service.codefly.yaml +++ b/module/services/accounts/service.codefly.yaml @@ -31,12 +31,14 @@ workspace-configuration-dependencies: - abuse-protection - application - billing + - datasource-keys - datasource-oauth - email - federation - gateway-trust - github-app - identity + - identity-provider - internal-auth - internal-transport - key-service diff --git a/module/services/auth-gateway/AGENTS.md b/module/services/auth-gateway/AGENTS.md index 90150ae93..89b3b7ee3 100644 --- a/module/services/auth-gateway/AGENTS.md +++ b/module/services/auth-gateway/AGENTS.md @@ -10,6 +10,27 @@ for the trust model behind declared presence. Nothing here may name a specific solution or composed module: every seam is generic, and a registered target is data, never a branch. +## What a deployed gateway refuses to start without + +Three of this service's controls are only controls when the thing they depend on +is there, and each used to degrade into something that read as working: + +- **A trusted-proxy range** (`gateway/TRUSTED_PROXY_CIDRS`). SP-GW-09 requires the + anonymous and authentication-factor budgets to key on the originating client. + Empty means no forwarding header is trusted, so `clientIP` answers the peer — + behind the frontend, the frontend's own pod — and every caller shares one bucket. + It read as working: the limiter was enabled and the buckets were enforced. +- **A shared rate-limit store.** Per-replica counters enforce the configured budget + times the replica count, and which replica a caller lands on decides their share. +- **A revocation store.** A revoker with no store answers "not revoked" to every + question, so a signed-out session, a killed device and an ended impersonation + window all keep authenticating until their tokens expire — and no request can + tell that from an empty revocation set. + +Each refuses at boot outside local development, naming what to provision. Local +development keeps all three fallbacks: one replica, no ingress hop, no cache +service, and the alternative is a gateway that cannot start. + ## Reading declared solution upstreams `GET /solutions/_registry` returns this replica's read projection: identity, @@ -50,6 +71,136 @@ constitutes organization consent. Module identity exchanges are described below; accounts owns their authority. +## What a runtime-registered upstream receives, and what it does not + +A runtime-registered upstream — a federated module prefix or a solution — receives +the person's `Authorization` bearer and does NOT receive their `Cookie` or +`Proxy-Authorization`. A catalog route is untouched: accounts is the host's own API, +where the session IS the credential. + +That split is a decision, not a halfway implementation, and it replaces an earlier +rule here that said all three were removed from every such request. Each half has +its own reason: + +**The cookie never travels, whatever the bearer does.** Nothing upstream can +authenticate with it — this perimeter refuses a cookie AS a credential by name (see +`ExtAuthz.Check`) — and it carries the longest-lived value a browser holds, the +refresh credential. Forwarding it delivers that value to a third party for no +purpose it could serve. The two removals used to be one decision, and the broader +one silently carried the narrower: a request on the MCP tool path skipped the whole +removal and forwarded `Cookie` with it. + +**The bearer does travel, to a DECLARED upstream, when the request is +authenticated.** A declared upstream authenticates the request by that header; the +runtime's HTTP wrapper and its passthrough handler both answer 401 without it. The +capability that should replace it — a host-minted context bound to that upstream's +own audience — is minted by nothing here and verified by nothing on the consumer +side, and the consumer half lives in another repository. Removing the header first +would break every composed solution while shipping a credential no consumer reads. + +**It does not travel on the anonymous module-federation surface**, which runs no +ext_authz at all: a bearer arriving there was never authenticated for that upstream, +and the loader fetches those bytes without a person. + +An upstream also receives the identity `ext_authz` stamped: the subject, the tenant, +the session, the credential kind and the scope ceiling. That names the person +independently of the bearer beside it. + +**SP-GW-07's second half is open, recorded as `SA-F-BEARER`.** The invariant is that +an upstream receives a host-minted context bound to THAT upstream's own audience — +not merely that it stops receiving the person's session. The host access token +carries one host-wide audience and the person's full authority, so a declared +upstream holding it holds more than it needs, and that exposure is real while this +stays open. What closes it: a host-minted context bound to the upstream's own +audience, plus consumer verification of it, plus one composed browser-to-consumer +request exercised end to end. + +What is missing is specific, so that the next person does not have to rediscover it. +`WorkContextService` already exposes `ExchangeAudience`, which re-binds an existing +Work Context to a different audience — and that is not the operation this needs. The +caller here holds a browser SESSION, which is not a Work Context, so the edge would +need a mint that takes a verified session and returns a context bound to one +upstream's audience, with that person's authority narrowed to what the upstream may +do on their behalf. No RPC does that today, and adding one is a descriptor change +(contract digests, published clients, a verified regeneration). + +Two things to settle with it, because they are the reason it is not a small change: +the mint is per request on the proxy path, so its latency and its failure mode become +the failure mode of all federated traffic; and the authority to narrow TO is the +installation's ceiling. That second one is no longer unreachable — the entitlement +authority answers it on the path that resolves consent, and this gateway now states +it as `x-codefly-installation-id` (see below) — but it is answered for one request +at a time, not as a lookup from a solution id (see +`module/SOLUTION_REGISTRATION.md`). + +**A consumer written against the old behaviour does NOT have to change for the +bearer.** Two things do change at the edge: a declared upstream no longer receives +the caller's cookie, and a resource-bound token is confined — it reaches the three +places below and nowhere else. Anything replaying such a token elsewhere stops +working, deliberately. + +## Three places for a resource-confined credential + +A token issued for a solution's MCP endpoint (RFC 8707 resource indicator) is held +by a third-party client and, once forwarded, by the runtime it dialled. It reaches: + +- that solution's own routes, where the resource and the path agree; +- `/saas.accounts.v1.WorkContextService/StartTask`, the delegation mint; and +- `/v1//*` for a DECLARED MODULE, and only in the company of a verified + capability for that module minted on this person's behalf. + +The second and third exist because the consumer's own work needs them, and they are +one flow, not two. The runtime's MCP viewer path derives a module gateway by minting +a viewer-owned Work Context — carrying the person's bearer, because its own service +credential does not stand in for the person — and then calls the module with that +capability AND the same bearer, since a declared upstream still authenticates by the +bearer (`SA-F-BEARER`, above). Admitting the mint alone moved the failure one request +later: the tool minted and was then refused at the module. + +**Admitting the bearer at module routes on its own would have been the confinement +deleted, not relocated** — a one-solution credential with the person's whole +authority at every declared module. So the pairing is the admission: +`gateway_module_capability.go` requires a capability for THAT module, owned by the +bearer's own subject, in its own organization, minted in its own session. The session +binding is what makes "minted under the consumer's ceiling" a fact rather than an +inference: a capability for the same module could otherwise have come from the same +person's ordinary browser session, where no consumer ceiling applies. + +**The ceiling itself is accounts', not this gateway's.** `ext_authz` stamps +`x-codefly-resource`; the gateway resolves the solution that resource names, requires +the organization to have installed it and the viewer to hold a grant reaching it — +the same question, through the same authority, as every proxied request to that +solution — and states WHICH installation admitted the request as +`x-codefly-installation-id`. Accounts then refuses a mint for another organization, +for an audience that installation does not declare, or for scopes outside its +agent's standing grants. An authority that cannot be asked answers 503; a capability +is not issued on an unanswered question. + +**An installation that declares NO audiences is refused here, and is unrestricted +everywhere else.** `allowed_audiences` and `allowed_scopes` are optional and empty +means unrestricted — the right reading for the installation's own headless work, +which an administrator consented to — and the install form leaves both empty, so +that is the ordinary state. For a confined mint the same emptiness would say the +person's whole authority is available, at every module the organization installed, +to a third party nobody installed. So accounts refuses and names the field. This is +the installation's ceiling and NOT the consumer's own declaration: the delivered +presence document carries module pins (module, package, version), a capability's +audience is the consumed module's route alias, and no record on this host joins the +two — deriving one would be a guess at the question the check exists to answer. + +Both stamps are on `untrustedAuthHeaders`, so a caller can assert neither and cannot +pick its own ceiling. The consumer check and the installation stamp run on the HTTP +gateway path, where the registry and the entitlement authority live; a deployment +driving `ext_authz` from Envoy gets the route confinement and the carried resource +but neither the consumer check nor the module pairing — the same asymmetry the +gateway credential already has, and in the same direction: Envoy mode refuses the +module route rather than admitting it unpaired. + +`x-codefly-installation-id` is also what a solution's runtime reads to name an +installation on every mint it makes. It will not take one out of a capability a +caller carried — inbound, that carrier is caller-controlled — so the hop that +resolved the consent is the only party that can supply it. + ## A registered client calls without a proxy The host's own frontend reaches this gateway through a server-side proxy, so it @@ -79,9 +230,30 @@ answer the way `gateway_solution_registry.go` caches the solution registry, and upstream. A token issued to one client is therefore useless from another's page, on catalog, solution, and declared-module routes alike. - `X-Codefly-Public-Origin` — the WebAuthn relying party, the origin an OAuth - start is validated against — is derived from that registration. The frontend's - internal-token path still establishes it too; the registration is the stronger - claim, since it names the client rather than only the process that forwarded. + start is validated against — is NOT derived from that registration, and was. + A registered client's own origin was stamped as the verified public origin, + which made the host's relying party and its sign-in redirect target a value a + client chose by registering it; accounts' readers all compare the forwarded + origin against the configured one, so the two authorities disagreed and the + weaker one was the one that named the client. The configured origin is the + authority, and the frontend's internal-token path is what establishes it. + A registered client therefore does NOT get a verified origin of its own: a + WebAuthn ceremony and a `BeginOAuth` redirect for such a client resolve against + the configured origin or the static allowlist, which is also the only reading + compatible with one relying-party id per deployment. Cross-origin access for + the client is unaffected — that is the CORS decision above, from the same + registration. + +**The grant on a forwarded response is this gateway's, and only this gateway's.** +Every proxied response has its `access-control-*` headers stripped before the +gateway stamps whatever it granted — including when it granted nothing. An +upstream may answer CORS itself, and accounts does: its generated handler hands an +empty allowlist to a library that reads empty as allow-all, so it answers a +wildcard origin with credentials true. Forwarded verbatim, that made the public +surface grant a cross-origin read the gateway had granted nobody, and made an +empty allowlist grant everything. The generated handler is the `go-grpc` agent's +to fix; what the gateway owns is that nothing leaves it claiming a grant it did not +make. Two deliberate limits. **Credentials are never allowed**: a registered client authenticates with its bearer, and echoing `access-control-allow-credentials` diff --git a/module/services/auth-gateway/code/authz_catalog_gen.go b/module/services/auth-gateway/code/authz_catalog_gen.go index e73778d32..9bda12402 100644 --- a/module/services/auth-gateway/code/authz_catalog_gen.go +++ b/module/services/auth-gateway/code/authz_catalog_gen.go @@ -11,7 +11,7 @@ type generatedAuthorizationMetadata struct { } var generatedAuthorizationByProcedure = map[string]generatedAuthorizationMetadata{ - "/saas.accounts.v1.APIKeyService/CreateAPIKey": {exposure: edgeExposureAuthenticated, rateLimitClass: edgeRateLimitClassStandardWrite, rateLimitBackendFailClosed: false, authenticationFactorAttempt: false, policySHA256: "43bdf1f9a139cc858b9deb4b2d4a5b2eb5b9ad7093363d1a13719d06f7e7239f"}, + "/saas.accounts.v1.APIKeyService/CreateAPIKey": {exposure: edgeExposureAuthenticated, rateLimitClass: edgeRateLimitClassStandardWrite, rateLimitBackendFailClosed: false, authenticationFactorAttempt: false, policySHA256: "b3df2a4866dce11045d5802b2ffb2777b5c3d1de86184c06381c8445232faa88"}, "/saas.accounts.v1.APIKeyService/ListAPIKeys": {exposure: edgeExposureAuthenticated, rateLimitClass: edgeRateLimitClassStandardRead, rateLimitBackendFailClosed: false, authenticationFactorAttempt: false, policySHA256: "c6c87c1ff7ffb6ff5f0733913bb6bead4d9d0a66c9fb8664f4c92a97ea988e95"}, "/saas.accounts.v1.APIKeyService/RevokeAPIKey": {exposure: edgeExposureAuthenticated, rateLimitClass: edgeRateLimitClassStandardWrite, rateLimitBackendFailClosed: false, authenticationFactorAttempt: false, policySHA256: "557d498b8e708671a8f5bce8e1665007af85265e1155fd6e695c0cd838fad4cc"}, "/saas.accounts.v1.APIKeyService/ValidateAPIKey": {exposure: edgeExposureInternal, rateLimitClass: edgeRateLimitClassInternal, rateLimitBackendFailClosed: false, authenticationFactorAttempt: false, policySHA256: "cb57ee7f4e96ab9548ead04103dfd3e9a96dce973c06a8bd62a9ac991eab1e16"}, diff --git a/module/services/auth-gateway/code/deployed_failclosed_test.go b/module/services/auth-gateway/code/deployed_failclosed_test.go new file mode 100644 index 000000000..4388594a5 --- /dev/null +++ b/module/services/auth-gateway/code/deployed_failclosed_test.go @@ -0,0 +1,91 @@ +package main + +import ( + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" +) + +// SP-GW-09: anonymous and authentication-factor budgets key on the originating +// client, and a cell with no trusted-proxy range refuses to start. A deployed +// gateway sits behind an ingress and a frontend hop, so with no trusted range the +// client address is the frontend's own pod and every caller shares one bucket. +func TestDeployedRefusesEmptyProxyTrust(t *testing.T) { + for _, raw := range []string{"", " ", ",", " , "} { + err := requireProxyTrust(raw, false) + require.Error(t, err, "an empty range %q must refuse a deployed boot", raw) + require.Contains(t, err.Error(), "TRUSTED_PROXY_CIDRS", + "the refusal must name the key an operator has to set") + } +} + +// Local development has no ingress hop and the peer IS the client. +func TestLocalRuntimeAcceptsAnEmptyProxyTrust(t *testing.T) { + require.NoError(t, requireProxyTrust("", true)) +} + +// A configured range still has to parse, and a configured range admits the boot. +func TestProxyTrustBootChecksAdmitAndRefuseTheRightThings(t *testing.T) { + require.NoError(t, requireProxyTrust("10.0.0.0/8,192.0.2.10", false)) + require.Error(t, requireProxyTrust("10.0.0.0/8,not-an-address", false)) + require.Error(t, requireProxyTrust("not-an-address", true), + "a malformed range is a configuration error in local development too") +} + +// The collapse itself, as a property rather than as a boot check: with a trusted +// hop configured, two clients arriving through the same frontend pod key on +// themselves. The auditor's reproducer showed both keying on the pod. +func TestTrustedProxyRangeKeysDistinctClientsDistinctly(t *testing.T) { + trust := newProxyTrust("10.0.0.0/8") + + first := httptest.NewRequest("POST", "/v1/auth/mfa/complete", nil) + first.RemoteAddr = "10.4.0.7:44001" // the frontend pod + first.Header.Set("X-Forwarded-For", "198.51.100.44") + + second := httptest.NewRequest("POST", "/v1/auth/mfa/complete", nil) + second.RemoteAddr = "10.4.0.7:44002" // the same frontend pod + second.Header.Set("X-Forwarded-For", "203.0.113.10") + + require.Equal(t, "198.51.100.44", trust.clientIP(first)) + require.Equal(t, "203.0.113.10", trust.clientIP(second)) + require.NotEqual(t, trust.clientIP(first), trust.clientIP(second), + "two clients behind one hop must not share one budget") + + // And with no trusted hop they do collapse, which is what the boot refusal + // above exists to prevent — asserted so the refusal's reason is not merely + // claimed. + untrusting := newProxyTrust("") + require.Equal(t, untrusting.clientIP(first), untrusting.clientIP(second)) + require.Equal(t, "10.4.0.7", untrusting.clientIP(first)) +} + +// A revoker with no store answers "not revoked" to every question, so a signed-out +// session, a killed device and an ended impersonation window all keep +// authenticating until their tokens expire — and nothing on the request path can +// tell that from an empty revocation set, so the gateway reports healthy. +func TestDeployedRefusesAMissingRevocationStore(t *testing.T) { + _, err := newRevoker("", defaultRevocationCacheTTL, false) + require.Error(t, err) + require.Contains(t, err.Error(), "revocation") + + revoker, err := newRevoker("", defaultRevocationCacheTTL, true) + require.NoError(t, err, "local development has no cache service") + require.IsType(t, noopRevoker{}, revoker) +} + +// A per-replica bucket is not the budget it reports: with N replicas the effective +// limit is N times the configured one, and which replica a caller lands on decides +// their share. For an authentication-factor budget that is the difference between +// a bounded number of attempts and a bounded number per replica. +func TestDeployedRefusesAMissingRateLimitStore(t *testing.T) { + t.Setenv("REDIS_URL", "") + + _, err := NewRateLimiter(1000) + require.Error(t, err) + require.Contains(t, err.Error(), "shared store") + + limiter, err := NewRateLimiter(1000, WithInProcessBackendAllowed(true)) + require.NoError(t, err, "local development has no cache service") + t.Cleanup(limiter.Stop) +} diff --git a/module/services/auth-gateway/code/ext_authz.go b/module/services/auth-gateway/code/ext_authz.go index 71dc901d3..c1e0a5a71 100644 --- a/module/services/auth-gateway/code/ext_authz.go +++ b/module/services/auth-gateway/code/ext_authz.go @@ -29,19 +29,23 @@ import ( // Field names use short JSON keys matching the backend minter. type accessClaims struct { jwt.RegisteredClaims - OrgID string `json:"org,omitempty"` - OrgRole string `json:"or,omitempty"` - PlatformRole string `json:"pr,omitempty"` - ScopedRoles map[string][]string `json:"sr,omitempty"` - ScopedRolesTruncated bool `json:"srt,omitempty"` - SessionID string `json:"sid"` - ActingAsUserID string `json:"acting,omitempty"` - MFASatisfied bool `json:"mfa,omitempty"` - AuthenticationMethods []string `json:"amr,omitempty"` - AuthenticationTime *jwt.NumericDate `json:"auth_time,omitempty"` - AssuranceLevel string `json:"acr,omitempty"` - MFAVerifiedAt *jwt.NumericDate `json:"mfa_at,omitempty"` - Act *actorClaim `json:"act,omitempty"` + OrgID string `json:"org,omitempty"` + OrgRole string `json:"or,omitempty"` + PlatformRole string `json:"pr,omitempty"` + ScopedRoles map[string][]string `json:"sr,omitempty"` + ScopedRolesTruncated bool `json:"srt,omitempty"` + SessionID string `json:"sid"` + // FamilyID is the refresh family, stable across the rotation that changes + // `sid`. A sign-out revokes it, so it is what matches an access token minted + // before the last refresh. + FamilyID string `json:"fid"` + ActingAsUserID string `json:"acting,omitempty"` + MFASatisfied bool `json:"mfa,omitempty"` + AuthenticationMethods []string `json:"amr,omitempty"` + AuthenticationTime *jwt.NumericDate `json:"auth_time,omitempty"` + AssuranceLevel string `json:"acr,omitempty"` + MFAVerifiedAt *jwt.NumericDate `json:"mfa_at,omitempty"` + Act *actorClaim `json:"act,omitempty"` // AuthorizedParty is the registered client the token was issued to (OIDC // `azp`). Absent on the host's own web sessions, which belong to no client. AuthorizedParty string `json:"azp,omitempty"` @@ -184,6 +188,22 @@ func (s *ExtAuthz) Check(ctx context.Context, req *authv3.CheckRequest) (*authv3 token := strings.TrimPrefix(auth, "Bearer ") if token != auth { if strings.HasPrefix(token, "cfly_sk_") { + // A tool endpoint is an OAuth protected resource, and an API key + // cannot be bound to one: it carries no audience at all, so there + // is no resource for it to name and nothing for the confinement + // below to judge. Refused HERE, because this branch RETURNS — the + // resource rules live in checkJWT, so a key reached a tool path + // by never being asked the question. + // + // Answered like every other refusal at this endpoint: 401 with + // the challenge, so a client re-authorizes for the resource + // rather than reading "forbidden" and stopping. + if isSolutionToolRequestPath(httpReq.GetPath()) { + recordJWTRejection(ctx, jwtRejectionGenericAudience) + return deny(401, "this endpoint requires a token issued for its own "+ + "resource; an API key names no resource. Authorize for this "+ + "resource and retry"), nil + } return s.checkAPIKey(ctx, token) } return s.checkJWT(ctx, token, httpReq.GetPath()) @@ -313,12 +333,21 @@ func (s *ExtAuthz) checkJWT(ctx context.Context, tokenString, path string) (*aut } } - // Session-scoped revocation (admin session-kill). A single marker keyed by - // the `sid` claim invalidates every access token in the session, covering - // the path where the admin never held the victim's token. Same fail-closed - // stance as the jti check. - if claims.SessionID != "" { - revoked, err := s.revoker.RevokedSession(ctx, claims.SessionID) + // Session- and family-scoped revocation. One marker invalidates every access + // token it covers, which is what the paths needing it have in common: the + // admin never held the victim's token (session-kill), and the token predates + // the last refresh (sign-out). Same fail-closed stance as the jti check. + // + // BOTH keys, because `sid` changes on every refresh and the family does not. + // Checking the session alone let an access token minted before the last + // refresh outlive the sign-out that revoked its family. accounts stamps the + // family on every access token in the chain and writes the same marker on + // logout; the two sides are held together by TestAccessClaimsMatchTheGateway. + for _, revocationKey := range []string{claims.SessionID, claims.FamilyID} { + if revocationKey == "" { + continue + } + revoked, err := s.revoker.RevokedSession(ctx, revocationKey) switch { case err != nil && !s.revocationFailOpen: log.Printf("session revocation check failed, denying (fail-closed): %v", err) @@ -353,7 +382,75 @@ func (s *ExtAuthz) checkJWT(ctx context.Context, tokenString, path string) (*aut recordJWTRejection(ctx, jwtRejectionInvalidAudience) return deny(401, "the token's audience does not name a single valid resource"), nil } - if solutionID, isSolutionPath := solutionIDFromPath(path); isSolutionPath { + // CONFINEMENT, decided on every path and not only a solution's. + // + // A resource-bound token names two audiences: the resource, and this host. + // The host audience is there so the token VERIFIES — the minter's own + // VerifyAccess and this parser both check it, and a token lacking it would be + // refused by its issuer (see the minter's two-audience comment). It is a + // parser requirement, and it was being read as an authorization one: the + // confinement below ran only under a solution path, so a resource token was + // admitted, as a full session, at this host's own API, at another declared + // module's routes, and at the Work Context surface that mints further + // capabilities. + // + // That is the escape this is here to close. The party holding such a token is + // a third-party MCP client and, once forwarded, the solution runtime it + // dialled; a credential delivered to one resource must not act as the person + // anywhere else. The runtime's own calls to this host are made with the + // module credential it was provisioned (`/modules/_work-context` takes the + // internal token and the module secret, never a person's bearer), so nothing + // legitimate needs a resource token off its own solution's paths. + if binding == resourceBound && s.publicBase == "" { + // No configured public address, so this process cannot say what ANY + // resource identifier is and cannot compare one. It refuses the token + // either way — a bound token is not admitted on a guess — but it says + // which of the two it is, because an operator fixes the configuration and + // a client re-authorizes, and those are different actions. The tool + // path's own arm below words this for the endpoint that publishes + // metadata; this is the same refusal for every other path. + recordJWTRejection(ctx, jwtRejectionWrongResource) + return deny(401, "this host has no configured public address, so it cannot "+ + "state the resource a token must name"), nil + } + resourceConfined := "" + if binding == resourceBound { + solutionID, isSolutionPath := solutionIDFromPath(path) + // The verified capability a declared module route established, if this + // request is on one. Read before the switch so the arm below does not + // resolve it twice, and nil on every other path and in Envoy mode. + pairing := presentedModuleCapability(ctx) + switch { + case isSolutionPath && resourceAudienceAdmits(resource, solutionID, s.publicBase): + // The solution its own resource names. Admitted, as before. + case pairing != nil: + // A DECLARED MODULE ROUTE, together with a verified capability for + // that module minted on this person's behalf in this session + // (gateway_module_capability.go carries the whole rule). This is the + // consumer's own work — the module its solution declared it consumes — + // and it is admitted only in that company. + if err := pairing.admitsConfinedCredential(claims); err != nil { + recordJWTRejection(ctx, jwtRejectionWrongResource) + return deny(401, err.Error()), nil + } + resourceConfined = resource + case isWorkContextMintPath(path): + // THE ONE HOST ROUTE (isWorkContextMintPath carries the reasoning). + // + // Admitted, and carried forward as a confinement rather than dropped: + // the resource travels to the mint in x-codefly-resource below, so what + // accounts issues is held to the consumer this credential was delivered + // to. Admitting the route without saying which resource asked would hand + // the mint a credential it could not tell from an unbound session, which + // is the confinement removed rather than relocated. + resourceConfined = resource + default: + recordJWTRejection(ctx, jwtRejectionWrongResource) + return deny(401, "the token presented was issued for a different "+ + "resource than this endpoint"), nil + } + } + if _, isSolutionPath := solutionIDFromPath(path); isSolutionPath { base := s.publicBase switch { case isSolutionToolRequestPath(path): @@ -384,28 +481,14 @@ func (s *ExtAuthz) checkJWT(ctx context.Context, tokenString, path string) (*aut "resource; the token presented names only the host. Authorize for "+ "this resource and retry"), nil } - if !resourceAudienceAdmits(resource, solutionID, base) { - recordJWTRejection(ctx, jwtRejectionWrongResource) - return deny(401, "the token presented was issued for a different "+ - "resource than this endpoint"), nil - } - case binding == resourceBound: - // A solution's other routes are the product's own API surface, - // reached with an ordinary session. A resource-bound token is still - // confined to the solution it names, so one minted for solution A - // cannot read solution B's data either. - if !resourceAudienceAdmits(resource, solutionID, base) { - recordJWTRejection(ctx, jwtRejectionWrongResource) - return deny(401, "the token presented was issued for a different "+ - "resource than this endpoint"), nil - } + // Which resource it must be is already settled: the confinement + // above admitted this token only at the solution its own resource + // names, so reaching here means the two agree. What is left for this + // arm is the rule confinement cannot express — that an UNBOUND + // credential is refused at a tool endpoint even though it is + // perfectly good everywhere else on this host. } } - // A resource-bound token still reaches the host's own API, which is not a - // solution path: the host is where a solution's runtime resolves the - // caller's authority, and the token names the host audience too for exactly - // that reason (see the minter's two-audience comment). - hdrs := []*corev3.HeaderValueOption{ hdr("x-user-id", claims.Subject), hdr("x-org-id", claims.OrgID), @@ -418,6 +501,27 @@ func (s *ExtAuthz) checkJWT(ctx context.Context, tokenString, path string) (*aut // carrying none would otherwise be indistinguishable from a session. hdr("x-credential-kind", credentialKindSession), } + if resourceConfined != "" { + // The resource this credential was issued for, stamped on the paths where + // a resource-bound credential is admitted AS a confined one: the + // delegation mint, and a declared module route reached in the company of + // this person's capability for that module. A solution's own paths do NOT + // stamp it — there the resource and the path already agree, and the + // upstream is the consumer the resource names. + // + // At the mint, accounts reads it as the ceiling: it resolves the installed + // consumer the resource names and refuses an organization other than this + // credential's, an audience that consumer does not declare, and scopes + // outside its installation's standing grants (StartTask). Empty is an + // ordinary unbound session, and allow() stamps the empty value rather + // than omitting the header, so the two are distinguishable without either + // being a missing-header inference. + // + // Gateway-owned, like every header beside it: the name is on + // untrustedAuthHeaders AND on canonicalUpstreamAuthHeaders, so a caller + // presenting it has its value replaced rather than forwarded. + hdrs = append(hdrs, hdr(resourceConfinementHeader, resourceConfined)) + } if claims.MFASatisfied { hdrs = append(hdrs, hdr("x-mfa-satisfied", "true")) } @@ -644,6 +748,18 @@ var canonicalUpstreamAuthHeaders = []string{ "x-acting-as-user-id", "x-act", "x-scopes", "x-credential-kind", "x-mfa-satisfied", "x-authentication-methods", "x-auth-time", "x-assurance-level", "x-mfa-verified-at", clientIDHeader, + // The resource a confined credential was issued for. ON THIS LIST, not only + // appended to the stamped set: allow() rebuilds the response from this list + // alone, so a header missing from it is silently dropped however carefully it + // was appended — which would have admitted the mint route with the + // confinement lost in transit, the one outcome worse than refusing it. + // + // Being here also means it is stamped on EVERY allow, empty when the + // credential names no resource. That is stronger than sending it only when + // confined: Envoy replaces a caller-supplied value even where the authenticated + // token has none, so a caller cannot assert a resource by arriving with one on + // a request that would otherwise carry no such header at all. + resourceConfinementHeader, } // clientIDHeader carries the registered client a request was made through, as diff --git a/module/services/auth-gateway/code/ext_authz_integration_test.go b/module/services/auth-gateway/code/ext_authz_integration_test.go index fcde35db1..42f41f331 100644 --- a/module/services/auth-gateway/code/ext_authz_integration_test.go +++ b/module/services/auth-gateway/code/ext_authz_integration_test.go @@ -119,7 +119,7 @@ func runExtAuthzIntegrationTests(m *testing.M) int { fmt.Fprintf(os.Stderr, "cache Redis connection not available\n") return 1 } - revoker, err := newRevoker(redisURL, defaultRevocationCacheTTL) + revoker, err := newRevoker(redisURL, defaultRevocationCacheTTL, false) if err != nil { fmt.Fprintf(os.Stderr, "cannot build revoker: %v\n", err) return 1 diff --git a/module/services/auth-gateway/code/gateway.go b/module/services/auth-gateway/code/gateway.go index e5e79fa1b..971b9adc1 100644 --- a/module/services/auth-gateway/code/gateway.go +++ b/module/services/auth-gateway/code/gateway.go @@ -25,6 +25,7 @@ import ( "strings" "time" + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" "github.com/codefly-dev/sdk-go/workcontext" corev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" authv3 "github.com/envoyproxy/go-control-plane/envoy/service/auth/v3" @@ -363,6 +364,14 @@ func (g *Gateway) ServeHTTP(w http.ResponseWriter, r *http.Request) { } injectHeaders(r, checkResp.GetOkResponse().GetHeaders()) + // A resource-confined credential reaches exactly one host route, and only + // for the consumer it was delivered to. Decided AFTER the projection, + // because the org and viewer it asks about are what that projection just + // established from the verified token — and refused before the limiter, so + // a refusal costs the organization nothing. + if !g.confineResourceMintToItsConsumer(w, r) { + return + } // Stamped after the identity projection, so it joins the headers g.rateLimitThenProxy(w, r, upstream, entry) @@ -471,6 +480,27 @@ func (g *Gateway) proxyTo(w http.ResponseWriter, r *http.Request, upstream *url. r.Header.Set("X-Codefly-Public-Origin", publicOrigin) } } + if isRuntimeRegisteredRoute(entry) { + // The cookie NEVER travels to a declared upstream, whatever the bearer + // does. Two independent decisions, because they were one and the broader + // one silently carried the narrower: a request on the tool path skipped + // the whole removal and forwarded `Cookie` with it. + // + // Nothing upstream can have a use for it. This perimeter refuses a cookie + // AS a credential by name (see ExtAuthz.Check), so a cookie reaching an + // upstream is a host session credential arriving somewhere that cannot + // present it back here — carrying the refresh credential's value to a + // third party for no purpose it could serve. + removeCallerCookie(r.Header) + // The bearer is a separate question, and SA-F-BEARER is recorded OPEN + // rather than closed here: see removePersonsSessionCredential and the PR + // body. What this route does enforce is confinement: ExtAuthz admits a + // resource-bound token at the solution its resource names, at the + // delegation mint, and at a declared module route only in the company of + // this person's verified capability for that module — three named places, + // so a credential an upstream receives cannot be replayed across the rest + // of this host. + } if entry != nil && entry.UpstreamPath != "" { r.URL.Path = entry.UpstreamPath r.URL.RawPath = "" @@ -489,6 +519,87 @@ func (g *Gateway) proxyTo(w http.ResponseWriter, r *http.Request, upstream *url. proxy.ServeHTTP(w, r) } +// removePersonsSessionCredential strips the person's own session from a request +// before it reaches a runtime-registered upstream — a composed module's federated +// prefix, or a solution. +// +// NOTHING CALLS THIS ON THE PROXY PATH, and that is the recorded state of +// SA-F-BEARER rather than an oversight. It is kept, with its tests, because the +// finding is open and this is the shape of its fix. +// +// Why it is not enabled here: a declared upstream authenticates the request BY +// the forwarded bearer today, so withholding it makes every composed solution +// answer 401 — the runtime's HTTP wrapper and its passthrough handler both +// require `Authorization`. The replacement is a host-minted capability bound to +// that upstream's own audience, and it does not exist on either side: this host +// does not mint one for a session, and no consumer verifies one. Landing the +// withholding first would break a working wire contract to close a finding, and +// the thing that would make it safe lives in another repository. +// +// What this PR does instead, and what it does not: +// - The cookie is withheld unconditionally (removeCallerCookie). No upstream +// can authenticate with it — this perimeter refuses a cookie as a credential +// by name — so there is no contract to break. +// - A resource-bound token is CONFINED to the solution its resource names, on +// every path (ExtAuthz.Check). That removes the replay the forwarding makes +// possible: an upstream holding a credential the host delivered to it cannot +// act with it at this host's API, at another module's routes, or at the Work +// Context surface. +// - The person's host-wide session still reaches a declared upstream. That +// exposure is unchanged and is recorded OPEN in the PR body, with the +// cross-repo work it waits on. +// +// The invariant (SP-GW-07): a module or solution upstream never receives the +// person's session credential or cookie. The host access token carries a host-wide +// audience and the person's full authority, so it is not a credential any single +// upstream should hold; the Cookie goes with it because the gateway has already +// resolved identity from it by this point, so an upstream reading it learns nothing +// it is not told. +// +// An upstream receives the identity headers ext_authz stamped — the subject, the +// tenant, the session, the credential kind, the scope ceiling — which name the +// person without carrying their authority. +// +// The ONE exception, and it is the invariant rather than a hole in it: a solution's +// MCP tool endpoint. ext_authz refuses a host-wide token there outright +// (`resourceUnbound` is a 401), so a request that reaches this point on a tool path +// carries a token whose audience names THAT SOLUTION'S resource and nothing else — +// which is exactly the audience-bound credential SP-GW-07's positive half asks for, +// already minted and already confined by `resourceAudienceAdmits`. Stripping it would +// not withhold the person's authority; it would break the one surface that is doing +// the right thing, and the merged resource-indicator work depends on forwarding it so +// the runtime can mint the viewer's Work Context from it. +// +// The binding is read off the PATH, not re-derived from the token: admission has +// already proved it, and a second JWT parse here would be a second verifier to keep in +// agreement with the first. +// +// For every other upstream path the host-wide session is still removed, and an upstream +// that needs to act on the person's behalf there still needs a host-minted capability +// bound to its own audience. **That remains unbuilt: see the audience-bound context +// note in services/auth-gateway/AGENTS.md.** +// +// Catalog routes are untouched: accounts is the host's own API, where the session +// is the credential. +func removePersonsSessionCredential(h http.Header) { + h.Del("Authorization") + h.Del("Cookie") + h.Del("Proxy-Authorization") +} + +// removeCallerCookie strips the caller's cookies alone, which is the part of the +// removal above that has no consumer contract to break. +// +// A declared upstream is reached through this gateway, and this gateway does not +// accept a cookie as a credential — it answers 401 and says so. So a cookie +// forwarded there can authenticate nothing; what it can do is hand a third-party +// pod the refresh credential's value, which is the longest-lived thing a browser +// holds. The gateway has also already resolved identity from it by this point, so +// an upstream reading it learns nothing it is not told in a header. +func removeCallerCookie(h http.Header) { + h.Del("Cookie") +} + // removeCallerConnectionOptions applies the caller's Connection header to the // caller's own headers, so it can never remove one this gateway stamped. // @@ -558,27 +669,33 @@ func (g *Gateway) withTrustedFrontendOrigin(r *http.Request) *http.Request { return r.WithContext(ctx) } -// publicOriginFor is the browser origin accounts may treat as verified: the -// WebAuthn relying party a credential is bound to, and the origin an OAuth -// redirect is validated against. +// publicOriginFor is THIS HOST's public origin, and only ever that: the WebAuthn +// relying party a credential is bound to, the base of an emailed link, and the +// origin the host's own /auth/callback is validated against. // -// Two callers can establish one. The frontend proves it by presenting the -// cluster-internal token alongside the origin it resolved server-side. A -// registered client proves it by arriving from an origin its own registration -// declares, which authorizeCrossOrigin has already checked — a stronger claim, -// since it names the client rather than only the process that forwarded it. +// One caller can establish it — the host's own frontend, which proves it by +// presenting the cluster-internal token alongside the origin it resolved +// server-side, and whose value accounts then honours only where it equals the +// configured one. +// +// A REGISTERED CLIENT'S OWN ORIGIN IS NOT THIS VALUE, and used to be returned +// here. A client's CORS origin and the host's public origin are different facts +// about different parties: an add-in served from https://addin.example is +// entitled to call this host from there, which authorizeCrossOrigin decides, and +// that entitlement says nothing about which origin the host mints links at or +// binds an authenticator to. Collapsing the two sent the client's origin to +// accounts AS the host's, where pinning the host origin then refused the request +// outright — so the two meanings were not merely conflated, they were in +// contradiction the moment an operator configured the host origin at all. +// +// Nothing is lost by separating them. A registered client's redirect target is +// authorized against the URIs its own registration declares +// (RegisteredClient.AllowsRedirect), never against a public origin, and that +// type's own comment is explicit that its redirects and its origins are +// independent "and deriving one from the other would silently widen whichever +// was not declared". This is that rule applied one hop further out. func publicOriginFor(r *http.Request) (string, bool) { - if origin, ok := trustedFrontendOrigin(r.Context()); ok { - return origin, true - } - if r.Header.Get(clientIDHeader) == "" { - return "", false - } - origin, err := canonicalPublicOrigin(r.Header.Get("Origin")) - if err != nil { - return "", false - } - return origin, true + return trustedFrontendOrigin(r.Context()) } func trustedFrontendOrigin(ctx context.Context) (string, bool) { @@ -654,17 +771,35 @@ func injectHeaders(r *http.Request, headers []*corev3.HeaderValueOption) { // // A-for-B therefore refuses with the audience error rather than with a 404: the // route resolves, and the capability presented to it names another consumer. -func (g *Gateway) rejectInvalidWorkContext(w http.ResponseWriter, r *http.Request, expected string) bool { +func (g *Gateway) rejectInvalidWorkContext(w http.ResponseWriter, r *http.Request, expected ...string) bool { + _, rejected := g.verifiedWorkContext(w, r, expected...) + return rejected +} + +// verifiedWorkContext is rejectInvalidWorkContext with the verified claims +// handed back, for the one caller that holds the capability to more than its +// audience. +// +// The claims are nil in three different situations, which is why the second +// return value exists rather than a nil check standing in for it: this edge +// verifies no capability at all (`g.workContext == nil`), the request presents +// none, or one was presented and refused. The first two are "carry on", the +// third is terminal — and a caller that read "no claims" as the first would +// admit a request whose capability had just been refused. +func (g *Gateway) verifiedWorkContext( + w http.ResponseWriter, r *http.Request, expected ...string, +) (*basev0.WorkContextV1, bool) { if g.workContext == nil { - return false + return nil, false } raw := r.Header.Get(workcontext.WorkContextHeaderName) if raw == "" { - return false + return nil, false } token, err := workcontext.ParseWorkContextToken(raw) + var claims *basev0.WorkContextV1 if err == nil { - err = g.workContext.Verify(r.Context(), token, expected) + claims, err = g.workContext.VerifyAny(r.Context(), token, expected...) } if err != nil { // Log the cause (not the token) so a wall of 401s can be told apart: @@ -672,9 +807,9 @@ func (g *Gateway) rejectInvalidWorkContext(w http.ResponseWriter, r *http.Reques // cannot reach, which the static 401 body deliberately does not reveal. log.Printf("WARN: blocked request: method=%s path=%s reason=invalid_work_context err=%v", r.Method, r.URL.Path, err) httpError(w, http.StatusUnauthorized, "invalid work context") - return true + return nil, true } - return false + return claims, false } // stripAllIdentityHeaders removes every identity header from the request, in @@ -727,8 +862,46 @@ var untrustedAuthHeaders = []string{ solutionIdentityHeader, solutionPublisherHeader, clientIDHeader, + // The resource a confined credential was issued for. Stamped by ext_authz on + // the paths that admit such a credential, and read by accounts as a + // ceiling on what the mint may issue — so a caller able to set it could lift + // its own confinement, or impose one on somebody else's request. + resourceConfinementHeader, + // WHICH INSTALLATION the authority admitted this request under. It selects + // the consent a confined mint is held to, and it is what a solution's + // runtime names on every mint it makes, so a caller able to set it would + // choose its own ceiling — or spend another organization's installation. + installationConfinementHeader, } +// resourceConfinementHeader carries the RFC 8707 resource a credential is bound +// to, from the ext_authz check to the Work Context mint. It is stamped on every +// allow decision — empty for an ordinary session, the resource identifier for a +// confined credential — so accounts reads a VALUE rather than inferring anything +// from a header's absence, and a caller cannot assert one by supplying it. +const resourceConfinementHeader = "x-codefly-resource" + +// installationConfinementHeader carries WHICH installation the entitlement +// authority admitted a request under, from the hop that resolved the consent to +// the services that need it. +// +// Two readers, one fact: +// +// - accounts, at a resource-confined Work Context mint, where it is the +// ceiling: the mint may issue only for an audience this installation +// declares and within its agent's standing grants. +// - a solution's runtime, on a proxied request, where it is what the runtime +// must name on every mint of its own. It will not take one from a carried +// capability — inbound that carrier is caller-controlled — so this hop is +// the only party that can supply it. +// +// Stamped only where an admission actually named one, which is only where the +// authority admitted the request. It is NOT stamped on an allow decision the way +// resourceConfinementHeader is, because ext_authz does not consult the +// entitlement authority and an empty value here would be a claim this process +// cannot make. +const installationConfinementHeader = "x-codefly-installation-id" + // solutionIdentityHeader and solutionPublisherHeader are the headers accounts // reads to decide which registered solution a Work Context mint is for. This // gateway stamps NEITHER — the credential it proved them from is deleted — and diff --git a/module/services/auth-gateway/code/gateway_ceiling_headers_test.go b/module/services/auth-gateway/code/gateway_ceiling_headers_test.go new file mode 100644 index 000000000..52842761a --- /dev/null +++ b/module/services/auth-gateway/code/gateway_ceiling_headers_test.go @@ -0,0 +1,175 @@ +package main + +// THE TWO HEADERS THE CEILING IS RESOLVED FROM, and the two hops they travel on +// (register finding SA-F-MCPAUD, round 4). +// +// `x-codefly-resource` and `x-codefly-installation-id` are the whole of what +// accounts has to decide a confined mint with: which consumer the credential was +// delivered to, and which installation of it this organization consented to. A +// caller that could set either picks its own ceiling, and a hop that failed to +// stamp the second leaves accounts with no ceiling at all. +// +// The round-3 test covered one route — the declared module route — and that route +// stamps the installation itself, after the strip. So the caller's value is +// overwritten there whether or not the header is on the strip list at all, and a +// suite with only that test stayed green when the name was removed from +// `untrustedAuthHeaders` (which also drops it from Envoy's `HeadersToRemove`) and +// when the mint stopped stamping it. These cover the three places that test +// cannot reach: the mint route, an ordinary session, and the Envoy answer. + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +// mintRequestOf is the StartTask request the consumer's runtime sends: the +// viewer's bearer, the org and session the gateway projected for it, and the +// audience of the module its solution declared it consumes. +func mintRequestOf(caller confinedCaller, forged http.Header) *http.Request { + body, _ := json.Marshal(map[string]any{ + "orgId": caller.org, + "sessionId": caller.session, + "taskId": uuid.Must(uuid.NewV7()).String(), + "audience": "documents", + "authorityScopes": []map[string]any{ + {"resourceKind": "documents", "actions": []string{"read"}}, + }, + }) + req := httptest.NewRequest(http.MethodPost, workContextMintProcedure, bytes.NewReader(body)) + req.Header.Set("authorization", "Bearer "+caller.bearer) + req.Header.Set("content-type", "application/json") + for name, values := range forged { + for _, value := range values { + req.Header.Add(name, value) + } + } + return req +} + +// BOTH HOPS, through the real handler, with the capability signed from what the +// gateway actually projected on the first one. +// +// This is the consumer flow end to end as far as this repository reaches: the +// mint is told which consumer and which installation, accounts (a double here, +// signing with the key the edge verifies) issues a capability for the viewer the +// projection named, and the module read is admitted on the pairing. +func TestCeilingHeaders_TheConsumersTwoHopsThroughTheRealGateway(t *testing.T) { + gw, module, accounts, caller, kid, wcPriv, _ := confinedModuleHarness(t) + + mint := httptest.NewRecorder() + gw.ServeHTTP(mint, mintRequestOf(caller, nil)) + + require.Equal(t, http.StatusOK, mint.Code, mint.Body.String()) + require.Equal(t, solutionMCPResource(testPublicBase, "example"), + accounts.lastHeaders.Get(resourceConfinementHeader), + "the mint is told which consumer this credential was delivered to") + require.Equal(t, "install-"+fakeSolutionTarget("example"), + accounts.lastHeaders.Get(installationConfinementHeader), + "and WHICH INSTALLATION of it admitted the request, which is the ceiling accounts resolves") + + // What accounts signs from that projection, and nothing of its own. + capability := mintViewerCapability(t, kid, wcPriv, "documents", + accounts.lastHeaders.Get("X-User-Id"), accounts.lastHeaders.Get("X-Org-Id"), accounts.lastHeaders.Get("X-Session-Id")) + + read := httptest.NewRecorder() + gw.ServeHTTP(read, moduleRequest(caller, capability.Encoded())) + + require.Equal(t, http.StatusOK, read.Code, read.Body.String()) + require.NotNil(t, module.lastHeaders) +} + +// A caller cannot pick the ceiling AT THE MINT — the route whose stamp accounts +// resolves the installation from. Both values are replaced, and the +// grpc-metadata spelling of either reaches nothing: accounts transcodes REST to +// gRPC, so a prefixed name is a second way to assert the same field. +func TestCeilingHeaders_ACallerCannotPickTheMintsCeiling(t *testing.T) { + gw, _, accounts, caller, _, _, _ := confinedModuleHarness(t) + + forged := http.Header{} + forged.Set(resourceConfinementHeader, solutionMCPResource(testPublicBase, "a-broader-consumer")) + forged.Set(installationConfinementHeader, "install-of-a-broader-consumer") + forged.Set("Grpc-Metadata-"+installationConfinementHeader, "install-of-a-broader-consumer") + w := httptest.NewRecorder() + gw.ServeHTTP(w, mintRequestOf(caller, forged)) + + require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, []string{solutionMCPResource(testPublicBase, "example")}, + accounts.lastHeaders.Values(resourceConfinementHeader), + "one value, and the one this credential names") + require.Equal(t, []string{"install-" + fakeSolutionTarget("example")}, + accounts.lastHeaders.Values(installationConfinementHeader), + "one value, and the one the authority admitted") + require.Empty(t, accounts.lastHeaders.Get("Grpc-Metadata-"+installationConfinementHeader)) +} + +// AN ORDINARY SESSION at the mint carries NO installation, forged or otherwise. +// +// Nothing resolves one for an unconfined request — no consumer was named, so no +// consent was looked up — and a value arriving anyway would hand accounts a +// ceiling for an installation this person never acted under. The resource header +// is restamped EMPTY rather than dropped, so accounts reads a value either way. +func TestCeilingHeaders_AnOrdinarySessionCannotForgeAnInstallationAtTheMint(t *testing.T) { + gw, _, accounts, _, _, _, access := confinedModuleHarness(t) + + forged := http.Header{} + forged.Set(installationConfinementHeader, "install-of-somebody-else") + ordinary := confinedCaller{ + bearer: signValidToken(t, access), + org: uuid.Must(uuid.NewV7()).String(), + session: uuid.Must(uuid.NewV7()).String(), + } + w := httptest.NewRecorder() + gw.ServeHTTP(w, mintRequestOf(ordinary, forged)) + + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + require.Empty(t, accounts.lastHeaders.Values(installationConfinementHeader), + "an unconfined mint names no consumer, so no installation is resolved and none may be asserted") + require.Empty(t, accounts.lastHeaders.Get(resourceConfinementHeader), + "and the resource is stamped empty, not left to a caller") +} + +// THE ENVOY ANSWER, which the HTTP path cannot speak for. +// +// A deployment driving ext_authz from Envoy acts on this response alone, so the +// two headers have to be in it: the resource restamped (replaced, including with +// the empty value) and the installation in the remove set, because ext_authz +// consults no entitlement authority and therefore never stamps one. A name +// missing from `untrustedAuthHeaders` disappears from BOTH the in-process strip +// and this set at once, which is why it is asserted here by value. +func TestCeilingHeaders_TheEnvoyAnswerReplacesOrRemovesThem(t *testing.T) { + withPublicBase(t, testPublicBase) + s, priv := newTestExtAuthz(t) + s.publicBase = testPublicBase + + for _, path := range []string{workContextMintProcedure, "/v1/users"} { + resp, err := s.Check(context.Background(), checkReq(path, map[string]string{ + "authorization": "Bearer " + signValidToken(t, priv), + resourceConfinementHeader: solutionMCPResource(testPublicBase, "example"), + installationConfinementHeader: "install-of-a-broader-consumer", + })) + require.NoError(t, err) + require.NotNil(t, resp.GetOkResponse(), path) + + stamped := headerMap(resp) + value, restamped := stamped[resourceConfinementHeader] + require.True(t, restamped, "the resource header must be REPLACED on %s, not left as the caller sent it", path) + require.Empty(t, value, "an unbound session is confined to nothing, by value") + require.Contains(t, resp.GetOkResponse().GetHeadersToRemove(), installationConfinementHeader, + "ext_authz stamps no installation, so a caller's must be removed on %s", path) + } + + // And the pairing is unreachable in Envoy mode, so a confined bearer at a + // module route is refused there rather than admitted unpaired. + resp, err := s.Check(context.Background(), checkReq("/v1/documents/collection", map[string]string{ + "authorization": "Bearer " + signResourceToken(t, priv, solutionMCPResource(testPublicBase, "example")), + })) + require.NoError(t, err) + require.NotNil(t, resp.GetDeniedResponse()) +} diff --git a/module/services/auth-gateway/code/gateway_cors.go b/module/services/auth-gateway/code/gateway_cors.go index 3daebafb6..555c1add6 100644 --- a/module/services/auth-gateway/code/gateway_cors.go +++ b/module/services/auth-gateway/code/gateway_cors.go @@ -136,9 +136,22 @@ func requestsAuthorizationHeader(requested string) bool { // matched it, so a client cannot reach the solution passthrough, a federated // module prefix, or a catalog route from an origin it did not register. func (g *Gateway) authorizeCrossOrigin(w http.ResponseWriter, r *http.Request) (http.ResponseWriter, bool) { + // Every forwarded response is wrapped, granted or not. An upstream may answer + // CORS itself, and accounts does: its generated handler hands an empty + // allowlist to a library that reads empty as allow-all, so it answered a + // wildcard origin with credentials true. Those headers used to reach the + // browser unchanged on every path where this gateway granted nothing, which + // means the public surface granted a cross-origin read the gateway had + // refused to grant — and an empty allowlist, which should grant nothing, + // granted everything. + // + // What this gateway granted is the only answer that leaves here. The wrapper + // with no origin strips and stamps nothing; with one it replaces. + stripping := &corsResponseWriter{ResponseWriter: w} + origin := r.Header.Get("Origin") if origin == "" { - return w, true + return stripping, true } if clientID := r.Header.Get(clientIDHeader); clientID != "" { @@ -170,10 +183,10 @@ func (g *Gateway) authorizeCrossOrigin(w http.ResponseWriter, r *http.Request) ( // to ever read its token. The origin is the only thing such a request can // be judged on, and it is the same thing the preflight was judged on. if credentialPresented(r) { - return w, true + return stripping, true } if !g.clients.originRegistered(r.Context(), origin) { - return w, true + return stripping, true } return &corsResponseWriter{ResponseWriter: w, origin: origin}, true } @@ -187,20 +200,35 @@ func credentialPresented(r *http.Request) bool { return r.Header.Get("Authorization") != "" || r.Header.Get("Cookie") != "" } -// corsResponseWriter stamps the grant on the way out. It has to run at -// WriteHeader rather than up front because the reverse proxy copies the -// upstream's own response headers into this map first, and an upstream that -// answers CORS itself — a solution serving its origin permissively — would -// otherwise leave two access-control-allow-origin values, which every browser -// rejects. The gateway is the authority for what it granted, so it replaces -// whatever the upstream said. +// corsResponseWriter makes this gateway the only author of the cross-origin grant +// on a forwarded response. It has to run at WriteHeader rather than up front +// because the reverse proxy copies the upstream's own response headers into this +// map first. +// +// With an origin it replaces whatever the upstream said — two +// access-control-allow-origin values is a response every browser rejects. With NO +// origin it still strips, which is the load-bearing half: an upstream that answers +// CORS permissively (a solution serving its own origin, or accounts' generated +// handler reading an empty allowlist as allow-all) would otherwise have its grant +// forwarded verbatim as though this gateway had made it. type corsResponseWriter struct { http.ResponseWriter origin string stamped bool } +// WriteHeader stamps the final response, and ONLY the final response. +// +// A 1xx is informational: net/http sends it and keeps the same header map open for +// the real response that follows, which the reverse proxy then fills from the +// upstream. Treating it as the response marked sanitization complete, so the final +// upstream headers — including an upstream's own permissive grant — went out +// untouched. An early hint is not an answer. func (w *corsResponseWriter) WriteHeader(status int) { + if status >= 100 && status < 200 { + w.ResponseWriter.WriteHeader(status) + return + } w.stamp() w.ResponseWriter.WriteHeader(status) } @@ -221,6 +249,12 @@ func (w *corsResponseWriter) stamp() { h.Del(key) } } + if w.origin == "" { + // Nothing was granted, so nothing is claimed. Vary is not added: the + // response does not differ by origin, and saying it does would needlessly + // fragment every cache entry on the public surface. + return + } h.Set("Access-Control-Allow-Origin", w.origin) h.Set("Access-Control-Expose-Headers", corsExposeHeaders) addVary(h, "Origin") diff --git a/module/services/auth-gateway/code/gateway_cors_test.go b/module/services/auth-gateway/code/gateway_cors_test.go index ed6c40f65..a24efd24c 100644 --- a/module/services/auth-gateway/code/gateway_cors_test.go +++ b/module/services/auth-gateway/code/gateway_cors_test.go @@ -237,11 +237,22 @@ func TestCORS_UpstreamGrantIsReplacedNotDuplicated(t *testing.T) { require.Empty(t, w.Header().Get("Access-Control-Allow-Credentials")) } -// The public origin an accounts route is told about decides which relying party -// a WebAuthn credential binds to and which redirect an OAuth start may use. A -// registered client establishes it from its own registration, which names the -// client — the frontend's shared-secret path only ever named the process. -func TestCORS_PublicOriginComesFromTheRegistration(t *testing.T) { +// A registered client's CORS origin is NOT this host's public origin, and this +// test is the inversion of one that asserted it was. +// +// The replaced test read: "A registered client establishes it from its own +// registration, which names the client — the frontend's shared-secret path only +// ever named the process." Naming the client is true and is not the point. The +// value names a property OF THIS HOST — which relying party an authenticator +// binds to, which origin an emailed link is rooted at, which /auth/callback the +// host's own sign-in may return to — and no client, however precisely +// identified, is a witness to it. +// +// Held as two facts about two parties, both of which must be true at once: +// the client reaches accounts on its own registered origin (it is entitled to, +// which is the CORS decision), and accounts is told nothing about a public +// origin by it (it is not entitled to say, which is this decision). +func TestCORS_ARegisteredClientDoesNotSpeakForTheHostOrigin(t *testing.T) { gw, api, _, priv := newGatewayHarness(t) twoRegisteredClients(t, gw) @@ -251,9 +262,48 @@ func TestCORS_PublicOriginComesFromTheRegistration(t *testing.T) { w := httptest.NewRecorder() gw.ServeHTTP(w, req) - require.Equal(t, http.StatusOK, w.Code) - require.Equal(t, addinOrigin, api.lastHeaders.Get("X-Codefly-Public-Origin")) + require.Equal(t, http.StatusOK, w.Code, "a registered client's request must still reach accounts") + require.Equal(t, []string{addinOrigin}, w.Header().Values("Access-Control-Allow-Origin"), + "and must still receive the CORS grant its registration earns") require.Equal(t, "test-gateway-token", api.lastHeaders.Get("X-Codefly-Gateway-Token")) + require.Empty(t, api.lastHeaders.Get("X-Codefly-Public-Origin"), + "the client's origin must NOT arrive at accounts as this host's public origin") +} + +// The whole of finding 1, as one request: with a host origin configured — which +// deployed boot requires — a registered client on its own origin must reach an +// accounts route rather than be refused by the pinning guard. +// +// It is a gateway test that asserts an ACCOUNTS outcome, because the defect +// lived in neither service alone. Accounts' guard was right (a forwarded origin +// is honoured only where it equals the configured one) and the gateway's CORS +// decision was right (this client may call from this origin); what was wrong was +// that one header carried both meanings, so configuring the host origin — the +// supported, required deployment state — turned every registered-client request +// into PermissionDenied. The stand-in below is what accounts does with the +// value: nothing may arrive that is not the configured origin. +func TestCORS_AConfiguredHostOriginDoesNotRefuseARegisteredClient(t *testing.T) { + gw, api, _, priv := newGatewayHarness(t) + twoRegisteredClients(t, gw) + + const configuredHostOrigin = "https://host.example" + + req := httptest.NewRequest(http.MethodGet, "/v1/users", nil) + req.Header.Set("Authorization", "Bearer "+signClientToken(t, priv, addinClient)) + req.Header.Set("Origin", addinOrigin) + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + + // Accounts' rule, applied here to exactly what the gateway stamped: a + // forwarded public origin is admitted only when it equals the configured one. + // An absent one carries no claim and is admitted; the client's own origin + // would be refused, which is the failure this closes. + forwarded := api.lastHeaders.Get("X-Codefly-Public-Origin") + require.NotEqual(t, addinOrigin, forwarded) + if forwarded != "" { + require.Equal(t, configuredHostOrigin, forwarded) + } } // The frontend still has its door: presenting the internal token alongside the @@ -536,7 +586,7 @@ func TestCORS_PreflightRefusesWhatThePublicSolutionSurfaceCannotGrant(t *testing func TestCORS_ThrottledResponseIsReadableAndRefusalsCostNoBudget(t *testing.T) { gw, _, _, priv := newGatewayHarness(t) twoRegisteredClients(t, gw) - gw.rateLimiter = NewRateLimiter(1) + gw.rateLimiter = newInProcessRateLimiter(1) token := signClientToken(t, priv, addinClient) call := func(origin string) *httptest.ResponseRecorder { diff --git a/module/services/auth-gateway/code/gateway_mcp_resource.go b/module/services/auth-gateway/code/gateway_mcp_resource.go index 739b6502d..bc000a95d 100644 --- a/module/services/auth-gateway/code/gateway_mcp_resource.go +++ b/module/services/auth-gateway/code/gateway_mcp_resource.go @@ -54,7 +54,15 @@ const ( // isSolutionMCPPath reports whether a solution sub-path (the suffix after // `/solutions//`, with no leading slash) is that solution's MCP endpoint. func isSolutionMCPPath(subPath string) bool { - return subPath == solutionMCPSegment + // The whole `mcp` SUBTREE, not the exact segment. An exact match closed + // `/solutions//mcp` and left `/solutions//mcp/` — a spelling every HTTP + // client and router treats as the same endpoint — admitting a token that names only + // the host, which is the invariant's whole subject (SP-SOL-07). MCP's own transports + // also address sub-routes under it (`mcp/messages` for the SSE channel), and those + // are the same protected resource: a resource identified by a prefix has to be + // judged by that prefix, or the narrowest spelling becomes the way around it. + return subPath == solutionMCPSegment || + strings.HasPrefix(subPath, solutionMCPSegment+"/") } // isSolutionToolRequestPath reports whether a FULL request path addresses a @@ -91,6 +99,51 @@ func publicBaseURL() string { return strings.TrimSuffix(strings.TrimSpace(workspaceEnv("application", "APP_BASE_URL")), "/") } +// workContextMintProcedure is the one of this host's OWN procedures that a +// resource-bound token reaches: the delegation mint. It is the full gRPC/Connect +// procedure name, which is also the request path on both transports. +// +// Named as a constant and compared whole. A prefix match would admit any +// procedure beginning with it, and this is the one place where the difference +// between "this exact mint" and "anything that looks like it" is the whole +// property. +const workContextMintProcedure = "/saas.accounts.v1.WorkContextService/StartTask" + +// isWorkContextMintPath reports whether a FULL request path addresses the +// delegation mint. +// +// Why the mint is admitted at all: +// +// A resource-bound token is held by a third-party MCP client and, once +// forwarded, by the solution runtime it dialled. Confinement exists so that +// credential cannot act as the person anywhere but the resource it names. The +// claim that nothing legitimate needed a host route was wrong: the runtime's MCP +// viewer path derives a module gateway by minting a viewer-owned Work Context +// here, carrying the viewer's bearer beside its own workload attestation. Its +// module credential does not stand in for the viewer's — the mint is what turns +// "this person asked" into a capability the destination module can check — so +// refusing this route refuses the viewer's own tool call, which is a product +// regression and not a security property. +// +// So the mint is admitted and no other procedure of this host's is. Everything +// else a resource token could otherwise have reached — this host's own API, the +// rest of the Work Context surface — stays refused, which is the escape the +// confinement was added to close. +// +// What the mint then ISSUES is bounded on the accounts side, from the stamped +// resource and the stamped installation: the viewer's own authority, the host +// audience vocabulary, and the installed consumer's declared audiences and +// standing grants all have to admit it (see accounts' StartTask). +// +// A DECLARED MODULE's routes are the one other place such a credential is +// admitted, and not by this function: they are not a host procedure, and the +// admission is a pairing with a verified capability for that module rather than +// a path (gateway_module_capability.go). +func isWorkContextMintPath(path string) bool { + pathOnly, _, _ := strings.Cut(path, "?") + return pathOnly == workContextMintProcedure +} + // resourceAudienceAdmits reports whether a token's resource audience permits a // request to solutionID. The audience must be a URL naming that solution's MCP // endpoint, at this host's configured origin when one is configured. @@ -259,3 +312,32 @@ func gatewayTokenIssuer() string { func gatewayAcceptedIssuers() []string { return []string{legacyTokenIssuer} } + +// solutionIDFromResource is the inverse of solutionMCPResource: the solution a +// resource identifier names, or ok=false when the value is not an identifier +// this host would have minted. +// +// Built by RECONSTRUCTION, not by parsing. It takes the candidate id out of the +// expected position and then requires solutionMCPResource to rebuild the exact +// string that arrived, so anything the two spellings disagree about — a +// different base, an extra segment, a trailing slash, a different case — fails +// rather than resolving to a solution it half-matches. The confinement is only +// as good as the identity of the thing it names, and a lenient reverse parse is +// how a token for one solution comes to name another. +func solutionIDFromResource(resource, publicBase string) (string, bool) { + if publicBase == "" { + return "", false + } + rest, ok := strings.CutPrefix(resource, publicBase+solutionProxyBase) + if !ok { + return "", false + } + id, _, found := strings.Cut(rest, "/") + if !found || id == "" || !solutionIDPattern.MatchString(id) { + return "", false + } + if solutionMCPResource(publicBase, id) != resource { + return "", false + } + return id, true +} diff --git a/module/services/auth-gateway/code/gateway_mcp_resource_test.go b/module/services/auth-gateway/code/gateway_mcp_resource_test.go index 1b64caee9..36e48e721 100644 --- a/module/services/auth-gateway/code/gateway_mcp_resource_test.go +++ b/module/services/auth-gateway/code/gateway_mcp_resource_test.go @@ -1,7 +1,9 @@ package main import ( + "context" "crypto/ed25519" + "errors" "net/http" "net/http/httptest" "os" @@ -154,12 +156,35 @@ func TestGateway_MCPToken_ReachesItsOwnSolutionsOtherRoutes(t *testing.T) { require.Equal(t, "/resolve", example.lastPath) } -// A resource-bound token still reaches the host's own API. That is not a hole: -// the host is where a solution's runtime resolves the caller's authority, and -// the token names the host audience for precisely that reason. Refusing it here -// would break the runtime without closing anything — nothing a solution can do -// with the host's API is narrowed by which solution asked. -func TestGateway_MCPToken_StillReachesTheHostAPI(t *testing.T) { +// A resource-bound token does NOT reach the host's own API. +// +// This asserted the opposite, on the reasoning that "nothing a solution can do +// with the host's API is narrowed by which solution asked". Two things refute +// that, and both are checkable here rather than arguable: +// +// 1. The holder is not the solution. It is whoever presents the token — the +// third-party MCP client it was issued to, and the runtime it was forwarded +// to. A bearer admitted at this host's API is that party acting AS the +// person, with the person's whole authority, for the token's lifetime. The +// Work Context surface is part of that API, so the reach included minting +// further capabilities for other audiences: authority amplification, not a +// fixed set of things "a solution" could already do. +// 2. The reach was the whole API, and only one route of it is needed. This +// reason previously read "the runtime does not need it", which was FALSE and +// is corrected here rather than quietly dropped: the runtime's MCP viewer +// path mints a viewer-owned Work Context at this host, carrying the person's +// bearer beside its own workload attestation, and its module credential does +// not stand in for that. Refusing every host route refused the viewer's own +// tool call. So exactly one route is admitted — the delegation mint — and it +// is admitted CONFINED, carrying the resource forward so what is issued is +// held to the consumer the credential was delivered to +// (TestGateway_MCPToken_ReachesTheMintAndNothingElseOnTheHost). +// +// The host audience such a token also carries is a PARSER requirement: the +// minter stamps it so its own VerifyAccess and this gateway's claims parser +// accept the token at all. Reading it as an authorization grant is what opened +// the escape. +func TestGateway_MCPToken_DoesNotReachTheHostAPI(t *testing.T) { withPublicBase(t, testPublicBase) gw, apiFake, _, priv := newGatewayHarness(t) @@ -169,8 +194,8 @@ func TestGateway_MCPToken_StillReachesTheHostAPI(t *testing.T) { w := httptest.NewRecorder() gw.ServeHTTP(w, req) - require.Equal(t, 200, w.Code) - require.Equal(t, "/v1/users", apiFake.lastPath) + require.Equal(t, 401, w.Code) + require.Nil(t, apiFake.lastHeaders, "accounts must not be reached at all") } // A token carrying no resource is the ordinary session credential the host has @@ -769,3 +794,183 @@ func TestThePublicAddressIsResolvedOnceAtConstruction(t *testing.T) { "a resource-bound token must not be refused because the environment moved under the process") require.Equal(t, "/mcp", example.lastPath) } + +// SA-F-MCPAUD, the spelling the merged resource-indicator work left open: an exact +// `mcp` segment match closed `/solutions//mcp` and admitted +// `/solutions//mcp/`, which routers, proxies and clients treat as the same +// endpoint. A token naming only the host reached the tool surface through it. +// +// Asserted on the predicate the admission switch keys on, with the non-MCP control, so +// widening it cannot have swept the product's own API surface in. +func TestR1019EverySpellingOfTheToolEndpointIsProtected(t *testing.T) { + for _, protected := range []string{ + "/solutions/abc/mcp", + "/solutions/abc/mcp/", + "/solutions/abc/mcp?session=1", + "/solutions/abc/mcp/messages", + "/solutions/abc/mcp/messages?session=1", + } { + require.True(t, isSolutionToolRequestPath(protected), + "%s addresses the tool surface and must demand a resource-bound token", protected) + } + + // The control: a solution's ordinary API surface is reached with a session, and + // requiring a resource-bound token there would refuse the product. + for _, ordinary := range []string{ + "/solutions/abc/other", + "/solutions/abc/mcpx", + "/solutions/abc/mcp-tools", + "/solutions/abc", + "/modules/abc/mcp", + } { + require.False(t, isSolutionToolRequestPath(ordinary), + "%s is not the tool surface and must stay reachable with a session", ordinary) + } +} + +// The one host route, and the confinement it carries. +// +// Three facts in one test, because the property is the conjunction and any two +// of them without the third is either a regression or an escape: +// +// 1. The delegation mint ADMITS a resource-bound token. Without this the +// runtime's MCP viewer cannot derive a module gateway for the person, which +// is the consumer regression the second review of this PR demonstrated with +// a real runtime tool call. +// 2. It arrives CONFINED. The resource travels to accounts as +// x-codefly-resource, so the mint can hold what it issues to the consumer +// the credential was delivered to. An admitted route that dropped the +// resource would be the confinement deleted, not relocated — accounts would +// see a credential indistinguishable from an unbound session. +// 3. Nothing ELSE on the host is admitted. The mint is one procedure, compared +// whole; a sibling procedure on the same service is refused, which is what +// stops "the mint is allowed" from becoming "the Work Context surface is +// allowed". +func TestGateway_MCPToken_ReachesTheMintAndNothingElseOnTheHost(t *testing.T) { + withPublicBase(t, testPublicBase) + s, key := newTestExtAuthz(t) + s.publicBase = testPublicBase + token := signResourceToken(t, key, solutionMCPResource(testPublicBase, "example")) + + admitted := s.check(t, workContextMintProcedure, token) + require.Equal(t, solutionMCPResource(testPublicBase, "example"), admitted.Get(resourceConfinementHeader), + "the mint must be told which resource the credential is confined to") + + for _, elsewhere := range []string{ + "/saas.accounts.v1.WorkContextService/StartInstallationTask", + "/saas.accounts.v1.WorkContextService/Exchange", + "/saas.accounts.v1.UserService/GetSelf", + "/v1/users", + } { + t.Run(elsewhere, func(t *testing.T) { + response, err := s.Check(context.Background(), checkReq(elsewhere, map[string]string{ + "authorization": "Bearer " + token, + })) + require.NoError(t, err) + require.NotNil(t, response.GetDeniedResponse(), + "every one of this host's own procedures but the mint stays refused") + require.Equal(t, int32(401), int32(response.GetDeniedResponse().GetStatus().GetCode())) + }) + } +} + +// An ordinary session credential is NOT confined, and must not arrive looking as +// though it were. A NON-EMPTY value is what tells accounts the mint is confined — +// it then resolves the installed consumer and holds the mint to that +// installation's declared audiences and standing grants — so a value stamped for +// a token that names no resource would impose a stranger's ceiling on the +// browser's own session, refusing the product rather than a replay. +func TestGateway_SessionTokenCarriesNoResourceConfinement(t *testing.T) { + withPublicBase(t, testPublicBase) + s, key := newTestExtAuthz(t) + s.publicBase = testPublicBase + + stamped := s.check(t, workContextMintProcedure, signValidToken(t, key)) + require.Empty(t, stamped.Get(resourceConfinementHeader), + "an unbound session must reach the mint with no confinement asserted") +} + +// check runs one ext_authz decision and returns the headers it stamped, +// failing the test if the request was refused. +func (s *ExtAuthz) check(t *testing.T, path, token string) http.Header { + t.Helper() + response, err := s.Check(context.Background(), checkReq(path, map[string]string{ + "authorization": "Bearer " + token, + })) + require.NoError(t, err) + require.Nil(t, response.GetDeniedResponse(), "expected %s to be admitted", path) + stamped := http.Header{} + for _, h := range response.GetOkResponse().GetHeaders() { + stamped.Set(h.GetHeader().GetKey(), h.GetHeader().GetValue()) + } + return stamped +} + +// The consumer half of the confinement: admitted at the mint only for a solution +// this organization installed and this viewer was granted. +// +// The route confinement alone ("a resource token reaches the mint and nothing +// else") bounds where the credential acts. It does NOT bound what it acts for: a +// token issued for one consumer would still have minted a capability while its +// consumer was uninstalled, revoked, or never granted to this person. These +// three cases are the difference, and they are asked through the same authority +// that already decides every proxied request to that solution — not a second, +// looser copy of the question. +func TestGateway_MCPToken_AtTheMintIsConfinedToItsInstalledConsumer(t *testing.T) { + withPublicBase(t, testPublicBase) + + mintRequest := func(t *testing.T, gw *Gateway, priv ed25519.PrivateKey, solution string) *httptest.ResponseRecorder { + t.Helper() + token := signResourceToken(t, priv, solutionMCPResource(testPublicBase, solution)) + req := httptest.NewRequest(http.MethodPost, workContextMintProcedure, nil) + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + return w + } + + t.Run("an installed, granted consumer mints", func(t *testing.T) { + gw, api, _, priv := newGatewayHarness(t) + registerSolutionUpstream(t, gw, "example") + gw.solutionEntitlements = &entitledTo{ids: []string{fakeSolutionTarget("example")}} + + require.Equal(t, http.StatusOK, mintRequest(t, gw, priv, "example").Code) + require.Equal(t, solutionMCPResource(testPublicBase, "example"), + api.lastHeaders.Get(resourceConfinementHeader), + "and the mint is told which consumer it is confined to") + }) + + t.Run("a consumer the organization did not install does not", func(t *testing.T) { + gw, api, _, priv := newGatewayHarness(t) + registerSolutionUpstream(t, gw, "example") + registerSolutionUpstream(t, gw, "other") + // Installed something else entirely. + gw.solutionEntitlements = &entitledTo{ids: []string{fakeSolutionTarget("other")}} + + require.Equal(t, http.StatusForbidden, mintRequest(t, gw, priv, "example").Code) + require.Nil(t, api.lastHeaders, "accounts must not be asked to mint at all") + }) + + t.Run("an authority that cannot be asked does not", func(t *testing.T) { + gw, api, _, priv := newGatewayHarness(t) + registerSolutionUpstream(t, gw, "example") + gw.solutionEntitlements = &fakeSolutionEntitlements{err: errors.New("accounts down")} + + require.Equal(t, http.StatusServiceUnavailable, mintRequest(t, gw, priv, "example").Code, + "a capability is never issued on an unanswered question") + require.Nil(t, api.lastHeaders) + }) + + t.Run("an ordinary session is not subject to any of it", func(t *testing.T) { + gw, api, _, priv := newGatewayHarness(t) + // No entitlement authority and no registered solution: an unconfined + // credential must not acquire a consumer check it never had. + req := httptest.NewRequest(http.MethodPost, workContextMintProcedure, nil) + req.Header.Set("Authorization", "Bearer "+signValidToken(t, priv)) + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + require.Empty(t, api.lastHeaders.Get(resourceConfinementHeader)) + }) +} diff --git a/module/services/auth-gateway/code/gateway_module_capability.go b/module/services/auth-gateway/code/gateway_module_capability.go new file mode 100644 index 000000000..d7187b2f0 --- /dev/null +++ b/module/services/auth-gateway/code/gateway_module_capability.go @@ -0,0 +1,132 @@ +package main + +// Admitting a RESOURCE-CONFINED credential at a declared module route +// (register finding SA-F-MCPAUD, round 3). +// +// A credential delivered to one consumer is confined to it: ext_authz admits a +// resource-bound token at the solution its resource names, at the delegation +// mint, and nowhere else. That left the consumer unable to do the one thing it +// was delivered for. The flow is three requests, not one: +// +// 1. a client authorizes for a solution's tool endpoint and calls it; +// 2. the solution's runtime mints a Work Context for the composed module it +// declared it consumes, forwarding the viewer's bearer, so the capability is +// owned and actored by the person rather than by the runtime; +// 3. the runtime calls `/v1//...` with that capability AND the same +// bearer, because a declared upstream authenticates the request by the +// bearer (SA-F-BEARER, open). +// +// Step 3 was refused, so the tool failed one request later than before. The +// naive repair — admit the bearer at module routes — would have handed a +// one-solution credential the person's whole authority at every declared module, +// which is the confinement deleted rather than relocated. +// +// THE RULE: a resource-confined bearer is admitted at a declared module route +// only TOGETHER WITH a verified capability for that module, owned by the +// bearer's own subject, in the bearer's own organization, minted in the +// bearer's own session. The capability is what carries the ceiling — accounts +// holds a confined mint to the installed consumer's declared audiences and its +// installation's standing grants (see confineResourceMintToItsConsumer and +// accounts' StartTask) — and the three bindings below are what stop a +// capability minted by anybody else, for anybody else, standing in for it. +// +// The session binding is the one that makes "minted under that ceiling" a fact +// rather than an inference. A capability for this module could otherwise have +// been minted by an UNCONFINED credential of the same person — their browser +// session — where no consumer ceiling applies; pairing on the session the +// bearer itself names leaves only capabilities minted by this very credential, +// and every one of those passed the confined mint. + +import ( + "context" + "fmt" + + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" +) + +// moduleCapabilityPairingKey carries the verified capability from the declared +// module route to ext_authz. +// +// It travels in the request CONTEXT and not in a header, deliberately. A header +// would be a value this process asserts to itself through a channel a caller can +// also write to; it would have to be stripped, listed, and trusted on the +// strength of that stripping. The context value is set by this process, after +// the signature check, on the one route that resolves to a declared module, and +// is unreachable from the request. +type moduleCapabilityPairingKey struct{} + +// moduleCapabilityPairing is the verified capability a declared module route +// presented, reduced to the facts a confined credential is paired against. +type moduleCapabilityPairing struct { + // audience is the capability's own audience, as signed. + audience string + // viewerAudience is the audience a VIEWER's delegated capability for this + // route carries — the module's own, which accounts derives from the declared + // module principal and the runtime names as its consumption target. + viewerAudience string + owner string + tenant string + session string +} + +// withPresentedModuleCapability records a verified capability for the route +// about to be judged. A nil claims set records nothing, so "no capability was +// presented" and "one was presented and verified" stay distinguishable. +func withPresentedModuleCapability( + ctx context.Context, claims *basev0.WorkContextV1, viewerAudience string, +) context.Context { + if claims == nil { + return ctx + } + return context.WithValue(ctx, moduleCapabilityPairingKey{}, &moduleCapabilityPairing{ + audience: claims.GetAudience(), + viewerAudience: viewerAudience, + owner: claims.GetOwnerPrincipalId(), + tenant: claims.GetTenantId(), + session: claims.GetSessionId(), + }) +} + +func presentedModuleCapability(ctx context.Context) *moduleCapabilityPairing { + pairing, _ := ctx.Value(moduleCapabilityPairingKey{}).(*moduleCapabilityPairing) + return pairing +} + +// admitsConfinedCredential reports whether this capability may stand in for the +// confinement a resource-bound credential carries. +// +// Every comparison refuses an EMPTY value on either side before comparing. Two +// absent subjects, tenants or sessions are equal, and an equality that holds +// because neither side said anything is the whole failure mode: a capability +// signed without a session would otherwise pair with a credential that names +// none, and the binding would be satisfied by the absence of the thing it binds. +func (p *moduleCapabilityPairing) admitsConfinedCredential(claims *accessClaims) error { + if p == nil { + return fmt.Errorf("no capability was presented") + } + // A module's OWN identity capability (the module capability audience, minted + // by StartModuleTask from the module's service credential) is verified at this + // route and is not this: it says nothing about the person holding the bearer, + // so pairing with it would admit a confined credential on the strength of a + // capability another party minted for itself. + if p.viewerAudience == "" || p.audience != p.viewerAudience { + return fmt.Errorf("the capability presented is not one minted for this module on this person's behalf") + } + for _, binding := range []struct { + name string + capability, jwt string + }{ + {"subject", p.owner, claims.Subject}, + {"organization", p.tenant, claims.OrgID}, + {"session", p.session, claims.SessionID}, + } { + if binding.capability == "" || binding.jwt == "" || binding.capability != binding.jwt { + // The refusal names WHICH binding failed, and no value from either + // side. Which of the three disagreed is what a runtime author debugs + // against; the values are a person's identifiers and a live session's. + return fmt.Errorf( + "the capability presented was not minted for the %s this credential names", binding.name) + } + } + return nil +} diff --git a/module/services/auth-gateway/code/gateway_module_confinement_test.go b/module/services/auth-gateway/code/gateway_module_confinement_test.go new file mode 100644 index 000000000..f130bf5c3 --- /dev/null +++ b/module/services/auth-gateway/code/gateway_module_confinement_test.go @@ -0,0 +1,260 @@ +package main + +// A RESOURCE-CONFINED CREDENTIAL AT A DECLARED MODULE ROUTE +// (register finding SA-F-MCPAUD, round 3; gateway_module_capability.go states +// the rule). +// +// The property: such a credential is admitted at `/v1//*` if and only if +// it arrives with a verified capability for THAT module, minted on the subject, +// in the organization, and in the session the credential itself names. Each +// refusal below is one binding broken on its own, with everything else held +// equal, so a test that passed because two things were wrong at once would not +// pass here. + +import ( + "crypto/ed25519" + "net/http" + "net/http/httptest" + "testing" + "time" + + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + "github.com/codefly-dev/sdk-go/workcontext" + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +// confinedCaller is one consumer's credential and the facts a capability has to +// agree with. Returned together, because the whole rule is an agreement between +// the two and a helper that generated them independently could only test that +// nothing agrees. +type confinedCaller struct { + bearer string + subject string + org string + session string +} + +func signConfinedCaller(t *testing.T, priv ed25519.PrivateKey, resource string) confinedCaller { + t.Helper() + caller := confinedCaller{ + subject: uuid.Must(uuid.NewV7()).String(), + org: uuid.Must(uuid.NewV7()).String(), + session: uuid.Must(uuid.NewV7()).String(), + } + claims := accessClaims{ + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: "saas-starter", + Subject: caller.subject, + Audience: jwt.ClaimStrings{"saas-starter", resource}, + IssuedAt: jwt.NewNumericDate(time.Now()), + NotBefore: jwt.NewNumericDate(time.Now().Add(-time.Second)), + ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)), + ID: "jti-" + uuid.Must(uuid.NewV7()).String(), + }, + OrgID: caller.org, + OrgRole: "admin", + SessionID: caller.session, + } + token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims) + token.Header["kid"] = accessKeyID(priv.Public().(ed25519.PublicKey)) + signed, err := token.SignedString(priv) + require.NoError(t, err) + caller.bearer = signed + return caller +} + +// mintViewerCapability signs the capability a consumer's runtime mints for a +// composed module on a person's behalf: the module's own audience, the person as +// owner, their organization as tenant, their session as the session. +func mintViewerCapability( + t *testing.T, kid string, priv ed25519.PrivateKey, audience, owner, tenant, session string, +) workcontext.WorkContextToken { + t.Helper() + signer, err := workcontext.NewWorkContextSigner(workcontext.WorkContextSignerOptions{ + Issuer: "saas-starter", KeyID: kid, PrivateKey: priv, + }) + require.NoError(t, err) + token, _, err := signer.StartTask(workcontext.StartTaskInput{ + Audience: audience, + TenantID: tenant, + OwnerPrincipalID: owner, + TaskID: uuid.Must(uuid.NewV7()).String(), + SessionID: session, + AuthorityScopes: []*basev0.WorkScopeV1{{ResourceKind: "documents", Actions: []string{"read"}}}, + }) + require.NoError(t, err) + return token +} + +// confinedModuleHarness wires a gateway with the edge verifier, one declared +// solution whose resource the credential names, one declared module the consumer +// reads, and an authority that has installed and granted both. +// +// `accounts` and `access` are returned because the mint hop needs them: the +// first is the upstream that receives the ceiling headers, the second signs an +// ordinary session to contrast with a confined one. +func confinedModuleHarness(t *testing.T) ( + gw *Gateway, module, accounts *fakeUpstream, caller confinedCaller, + kid string, wcPriv, access ed25519.PrivateKey, +) { + t.Helper() + withPublicBase(t, testPublicBase) + gw, accounts, _, access = newGatewayHarness(t) + pub, wcPriv := mustEd25519(t) + kid = "key-1" + jwks := jwksServer(t, jwksDocument(map[string]ed25519.PublicKey{kid: pub}), nil) + gw.workContext = newWorkContextVerifier(jwks.URL) + registerSolutionUpstream(t, gw, "example") + module = declareModule(t, gw, "documents") + gw.solutionEntitlements = &entitledTo{ids: []string{ + fakeSolutionTarget("example"), fakeSolutionTarget("documents"), + }} + caller = signConfinedCaller(t, access, solutionMCPResource(testPublicBase, "example")) + return gw, module, accounts, caller, kid, wcPriv, access +} + +func moduleRequest(caller confinedCaller, capability string) *http.Request { + req := httptest.NewRequest(http.MethodGet, "/v1/documents/collection", nil) + req.Header.Set("authorization", "Bearer "+caller.bearer) + if capability != "" { + req.Header.Set(workcontext.WorkContextHeaderName, capability) + } + return req +} + +// The consumer's own work goes through, and the upstream is told what the +// credential is confined to and which installation admitted it. +func TestModuleRoute_AConfinedCredentialIsAdmittedWithThePersonsCapability(t *testing.T) { + gw, module, _, caller, kid, wcPriv, _ := confinedModuleHarness(t) + capability := mintViewerCapability(t, kid, wcPriv, "documents", caller.subject, caller.org, caller.session) + + w := httptest.NewRecorder() + gw.ServeHTTP(w, moduleRequest(caller, capability.Encoded())) + + require.Equal(t, http.StatusOK, w.Code, + "the consumer this credential was delivered to must be able to read the module its solution declared") + require.NotNil(t, module.lastHeaders) + require.Equal(t, solutionMCPResource(testPublicBase, "example"), + module.lastHeaders.Get(resourceConfinementHeader), + "the upstream is told which consumer this credential was delivered to") + require.Equal(t, "install-"+fakeSolutionTarget("documents"), + module.lastHeaders.Get(installationConfinementHeader), + "and which installation admitted the request") + require.Equal(t, caller.subject, module.lastHeaders.Get("X-User-Id")) +} + +// Each binding, broken alone. +func TestModuleRoute_AConfinedCredentialIsRefusedWithoutAMatchingCapability(t *testing.T) { + for name, capabilityFor := range map[string]func(c confinedCaller) (audience, owner, tenant, session string){ + "no capability at all": nil, + "a capability owned by somebody else": func(c confinedCaller) (string, string, string, string) { + return "documents", uuid.Must(uuid.NewV7()).String(), c.org, c.session + }, + "a capability for another organization": func(c confinedCaller) (string, string, string, string) { + return "documents", c.subject, uuid.Must(uuid.NewV7()).String(), c.session + }, + "a capability from another session": func(c confinedCaller) (string, string, string, string) { + return "documents", c.subject, c.org, uuid.Must(uuid.NewV7()).String() + }, + // The module's OWN identity capability verifies at this route and says + // nothing about the person holding the credential, so it is not a + // capability this credential may be paired with. + "the module's own identity capability": func(c confinedCaller) (string, string, string, string) { + return moduleCapabilitiesAudience, c.subject, c.org, c.session + }, + } { + t.Run(name, func(t *testing.T) { + gw, module, _, caller, kid, wcPriv, _ := confinedModuleHarness(t) + encoded := "" + if capabilityFor != nil { + audience, owner, tenant, session := capabilityFor(caller) + encoded = mintViewerCapability(t, kid, wcPriv, audience, owner, tenant, session).Encoded() + } + + w := httptest.NewRecorder() + gw.ServeHTTP(w, moduleRequest(caller, encoded)) + + require.Equal(t, http.StatusUnauthorized, w.Code, + "a credential confined to one consumer must not reach a module on %s", name) + require.Nil(t, module.lastHeaders, "and nothing may reach the upstream") + }) + } +} + +// A capability for ANOTHER declared module is refused at this one's route, which +// is the A-for-B rule the edge already enforces — asserted here too, because +// listing two permitted audiences at a module route is the change that could +// have weakened it. +func TestModuleRoute_ACapabilityForAnotherModuleIsRefused(t *testing.T) { + gw, module, _, caller, kid, wcPriv, _ := confinedModuleHarness(t) + other := declareModule(t, gw, "ledgers") + capability := mintViewerCapability(t, kid, wcPriv, "ledgers", caller.subject, caller.org, caller.session) + + w := httptest.NewRecorder() + gw.ServeHTTP(w, moduleRequest(caller, capability.Encoded())) + + require.Equal(t, http.StatusUnauthorized, w.Code) + require.Nil(t, module.lastHeaders) + require.Nil(t, other.lastHeaders) +} + +// An ORDINARY session is unchanged: it needs no capability at a module route, and +// it is stamped no confinement. +func TestModuleRoute_AnUnconfinedSessionNeedsNoCapability(t *testing.T) { + withPublicBase(t, testPublicBase) + gw, _, _, priv := newGatewayHarness(t) + module := declareModule(t, gw, "documents") + gw.solutionEntitlements = &entitledTo{ids: []string{fakeSolutionTarget("documents")}} + + req := httptest.NewRequest(http.MethodGet, "/v1/documents/collection", nil) + req.Header.Set("authorization", "Bearer "+signValidToken(t, priv)) + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + require.Empty(t, module.lastHeaders.Get(resourceConfinementHeader), + "an unbound session is confined to nothing, and the header says so by value") +} + +// A deployment with NO edge verifier admits no confined credential here. The +// pairing is the only thing that admits one, and it cannot be established +// without a verifier, so the refusal is the same one round 2 shipped. +func TestModuleRoute_WithoutAnEdgeVerifierAConfinedCredentialStaysRefused(t *testing.T) { + gw, module, _, caller, kid, wcPriv, _ := confinedModuleHarness(t) + capability := mintViewerCapability(t, kid, wcPriv, "documents", caller.subject, caller.org, caller.session) + gw.workContext = nil + + w := httptest.NewRecorder() + gw.ServeHTTP(w, moduleRequest(caller, capability.Encoded())) + + require.Equal(t, http.StatusUnauthorized, w.Code) + require.Nil(t, module.lastHeaders) +} + +// A CALLER CANNOT PICK ITS OWN CEILING. Both stamps are gateway-owned: whatever a +// caller sends under either name is replaced by what this hop resolved. +// +// Asserted here as well as in the whole-list strip test, because this is the one +// route that STAMPS them: a strip that ran before the stamp and a stamp that +// honoured a caller's value are indistinguishable from the list test's point of +// view, which only ever sees a route that stamps neither. +func TestModuleRoute_ACallerCannotAssertItsOwnConfinementOrInstallation(t *testing.T) { + gw, module, _, caller, kid, wcPriv, _ := confinedModuleHarness(t) + capability := mintViewerCapability(t, kid, wcPriv, "documents", caller.subject, caller.org, caller.session) + + req := moduleRequest(caller, capability.Encoded()) + req.Header.Set(resourceConfinementHeader, solutionMCPResource(testPublicBase, "something-else")) + req.Header.Set(installationConfinementHeader, "install-of-somebody-elses-organization") + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, solutionMCPResource(testPublicBase, "example"), + module.lastHeaders.Get(resourceConfinementHeader), + "the resource is the one this credential names, not the one it asked for") + require.Equal(t, "install-"+fakeSolutionTarget("documents"), + module.lastHeaders.Get(installationConfinementHeader), + "the installation is the one the authority admitted, not the one it asked for") +} diff --git a/module/services/auth-gateway/code/gateway_module_routes.go b/module/services/auth-gateway/code/gateway_module_routes.go index 323da751b..b2cb4f8b9 100644 --- a/module/services/auth-gateway/code/gateway_module_routes.go +++ b/module/services/auth-gateway/code/gateway_module_routes.go @@ -128,11 +128,25 @@ func (g *Gateway) handleDeclaredModule(w http.ResponseWriter, r *http.Request) b return true } - // THE ROUTE IS RESOLVED, SO THE AUDIENCE IS KNOWN. A module route's target is - // the module capability surface, so that is what a capability presented here - // must name — the same derivation as a solution route, with the audience the - // host's vocabulary gives this kind. - if g.rejectInvalidWorkContext(w, r, moduleCapabilitiesAudience) { + // THE ROUTE IS RESOLVED, SO THE AUDIENCE IS KNOWN — and this route serves TWO + // kinds of consumer, so two audiences are permitted and no more. + // + // - moduleCapabilitiesAudience: a declared module's OWN identity, minted by + // accounts' StartModuleTask from the module's service credential. + // - the alias: a VIEWER's delegated capability for this module. Accounts + // refuses to mint the capability audience from any caller-supplied mint + // (requireMintableAudience — a context addressed to it is read as a module + // identity), so the audience a person's capability for this module can + // carry is the module's own name, which is the name this route was + // resolved on and the one a consuming runtime declares and asks for. + // + // Expecting ONLY the capability audience here was therefore an expectation no + // viewer capability could satisfy: with the edge verifier wired, every + // delegated capability presented at a declared module route was refused. + // Listing both is not a widening — a solution's audience is still refused + // here, which is the A-for-B rule this derivation exists for. + capability, rejected := g.verifiedWorkContext(w, r, moduleCapabilitiesAudience, alias) + if rejected { return true } @@ -140,8 +154,16 @@ func (g *Gateway) handleDeclaredModule(w http.ResponseWriter, r *http.Request) b // identity, run ext_authz, require a valid credential. A bearer-less call is // denied here, so a declared module adds a proxy target and never widens the // authenticated surface. + // + // The verified capability is carried INTO the check, because it is what lets a + // resource-confined credential be admitted here at all: ext_authz pairs the + // two and refuses the credential when no capability of this person's accompanies + // it (gateway_module_capability.go carries the whole rule). A deployment with + // no edge verifier records nothing, so such a credential stays refused — the + // same direction as every other asymmetry between this path and Envoy's. stripAllIdentityHeaders(r) - checkResp, err := g.authz.Check(r.Context(), buildCheckRequest(r)) + ctx := withPresentedModuleCapability(r.Context(), capability, alias) + checkResp, err := g.authz.Check(ctx, buildCheckRequest(r)) if err != nil { httpError(w, http.StatusInternalServerError, "auth check failed") return true @@ -161,9 +183,13 @@ func (g *Gateway) handleDeclaredModule(w http.ResponseWriter, r *http.Request) b injectHeaders(r, checkResp.GetOkResponse().GetHeaders()) // Forward the full /v1//... path unchanged: the module owns and serves - // its own /v1/ surface. The caller's bearer is preserved so the module - // can call back through the gateway on the user's behalf, exactly as solution - // passthrough does. + // its own /v1/ surface. The caller's bearer travels with it, because a + // declared upstream authenticates the request by it and the audience-bound + // capability that should replace it exists on neither side yet (SA-F-BEARER, + // open — see removePersonsSessionCredential in gateway.go). The caller's + // COOKIE does not travel, and a resource-bound token cannot be replayed off + // its own solution, so what an upstream holds is the person's host session + // and nothing wider. // // A module data endpoint and its admission read must consume the // same per-org/per-IP budget an equivalent catalog route does, or /v1//* @@ -176,7 +202,8 @@ func (g *Gateway) handleDeclaredModule(w http.ResponseWriter, r *http.Request) b return true } g.rateLimitThenServe(w, r, entry, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch g.admitViewerSolution(r.Context(), org, viewer, routing) { + admission, installation := g.admitViewerSolutionInstallation(r.Context(), org, viewer, routing) + switch admission { case viewerSolutionNotEntitled: httpError(w, http.StatusForbidden, "module is not installed for this organization") return @@ -184,6 +211,13 @@ func (g *Gateway) handleDeclaredModule(w http.ResponseWriter, r *http.Request) b httpError(w, http.StatusServiceUnavailable, "module installation authority unavailable") return } + // Which installation admitted this viewer, forwarded for the same reason + // it is forwarded to a solution: the consent is a fact about the request, + // and the upstream that serves it is the one that needs it. Gateway-owned + // and on untrustedAuthHeaders, so a caller's own value is already gone. + if installation != "" { + r.Header.Set(installationConfinementHeader, installation) + } g.proxyTo(w, r, routing.Upstream, entry) })) return true diff --git a/module/services/auth-gateway/code/gateway_module_routes_test.go b/module/services/auth-gateway/code/gateway_module_routes_test.go index 51029528a..31aa70026 100644 --- a/module/services/auth-gateway/code/gateway_module_routes_test.go +++ b/module/services/auth-gateway/code/gateway_module_routes_test.go @@ -82,6 +82,13 @@ func TestGateway_UndeclaredModulePrefixIsNotServed(t *testing.T) { // upstream, with the ordinary authenticated pipeline in front of it — identity // projected from the validated token, caller-supplied identity stripped, the // caller's bearer preserved, and the gateway's own accounts credential withheld. +// +// The bearer is asserted PRESERVED, and that is the recorded state of SA-F-BEARER +// rather than agreement with it. An earlier revision of this branch inverted this +// assertion; the withholding is not enabled, because a declared upstream +// authenticates by this header and the audience-bound capability that should +// replace it exists on neither side. See removePersonsSessionCredential in +// gateway.go. What IS asserted below is the cookie, which never travels. func TestGateway_DeclaredModuleIsRoutedThroughTheAuthenticatedPipeline(t *testing.T) { gw, _, _, priv := newGatewayHarness(t) fake := declareModule(t, gw, "example-module") diff --git a/module/services/auth-gateway/code/gateway_modules.go b/module/services/auth-gateway/code/gateway_modules.go index a3c5fa544..ac65babbd 100644 --- a/module/services/auth-gateway/code/gateway_modules.go +++ b/module/services/auth-gateway/code/gateway_modules.go @@ -25,10 +25,16 @@ import ( // named, rather than the static catalog. // // Both kinds qualify, for one reason: the address is read out of a delivered -// document and user bearers are forwarded to it, so the host must still refuse a -// name that resolves somewhere it will not send credentials. A module route added -// without this line would have been the one declared upstream dialled on the -// plain transport, and nothing about the route's shape would have said so. +// document and the person's identity is stamped on what is sent there, so the +// host must still refuse a name that resolves somewhere it will not send a +// request made on someone's behalf. A module route added without this line would +// have been the one declared upstream dialled on the plain transport, and nothing +// about the route's shape would have said so. +// +// The predicate has a second reader now: proxyTo withholds the caller's COOKIE +// for exactly these routes (removeCallerCookie). The bearer still travels — +// SA-F-BEARER is recorded open, for the reason given at +// removePersonsSessionCredential in gateway.go. func isRuntimeRegisteredRoute(entry *RouteEntry) bool { return entry != nil && (strings.HasPrefix(entry.Service, solutionServicePrefix) || diff --git a/module/services/auth-gateway/code/gateway_solution_admission_test.go b/module/services/auth-gateway/code/gateway_solution_admission_test.go index e2c23cf5d..86b67c026 100644 --- a/module/services/auth-gateway/code/gateway_solution_admission_test.go +++ b/module/services/auth-gateway/code/gateway_solution_admission_test.go @@ -427,7 +427,7 @@ func TestGatewaySolutionProxy_RefusedRequestsAreMeteredBeforeTheAuthorityIsAsked authority := &entitledTo{ids: nil} gw.solutionEntitlements = authority // effective budget = limit(1) + burst(max(1/5,1)=1) = 2 requests / org / min. - gw.rateLimiter = NewRateLimiter(1) + gw.rateLimiter = newInProcessRateLimiter(1) token := signValidToken(t, priv) throttled := false diff --git a/module/services/auth-gateway/code/gateway_solution_entitlements.go b/module/services/auth-gateway/code/gateway_solution_entitlements.go index a00892f71..761c4d733 100644 --- a/module/services/auth-gateway/code/gateway_solution_entitlements.go +++ b/module/services/auth-gateway/code/gateway_solution_entitlements.go @@ -367,23 +367,41 @@ const ( // answer changes with no write at all — a grant's expires_at passes, a member // leaves a team, a role loses a permission, an owner is demoted — so a cache // keyed on any revision a write advances would route on an expired grant. +// admitViewerSolution answers the admission alone, for the two call sites that +// need nothing else. func (g *Gateway) admitViewerSolution( ctx context.Context, org, viewer string, routing *solutionRouting, ) viewerSolutionAdmission { + admission, _ := g.admitViewerSolutionInstallation(ctx, org, viewer, routing) + return admission +} + +// admitViewerSolutionInstallation also returns WHICH installation admitted the +// viewer, empty on any answer other than admitted. +// +// It comes from the entitlement that matched, on the same answer that decided +// admission — never from a second read. The installation is the consent an +// organization gave this one target: it is what a confined mint is held to +// (confineResourceMintToItsConsumer stamps it, accounts resolves its declared +// audiences and standing grants), and asking for it again could resolve a +// different installation from the one that admitted the request. +func (g *Gateway) admitViewerSolutionInstallation( + ctx context.Context, org, viewer string, routing *solutionRouting, +) (viewerSolutionAdmission, string) { targetID := routing.GetTargetID() if targetID == "" { // Declared by nothing: a self-registration no delivery opened a target // for. There is no identity to be entitled to, so this is a verdict // rather than an outage — it is not a presence an administrator could // have consented to, and no later read will make it one. - return viewerSolutionNotEntitled + return viewerSolutionNotEntitled, "" } if g.solutionEntitlements == nil { // No authority client wired. Undecidable, never admitted: a deployment // that forgot to wire this must fail closed rather than serve every // solution to every organization, which is the behaviour this check // replaces. - return viewerSolutionUndecidable + return viewerSolutionUndecidable, "" } token := "" for page := 0; page < solutionEntitlementPageLimit; page++ { @@ -394,7 +412,7 @@ func (g *Gateway) admitViewerSolution( PageToken: token, }) if err != nil { - return viewerSolutionUndecidable + return viewerSolutionUndecidable, "" } for _, entitlement := range resp.GetEntitlements() { // Membership of the entitled set is the whole test. Health is @@ -405,13 +423,13 @@ func (g *Gateway) admitViewerSolution( // and make "nobody granted you this" indistinguishable from "it is // restarting". if entitlement.GetTargetId() == targetID { - return viewerSolutionAdmitted + return viewerSolutionAdmitted, entitlement.GetInstallationId() } } token = resp.GetNextPageToken() if token == "" { - return viewerSolutionNotEntitled + return viewerSolutionNotEntitled, "" } } - return viewerSolutionUndecidable + return viewerSolutionUndecidable, "" } diff --git a/module/services/auth-gateway/code/gateway_solution_entitlements_test.go b/module/services/auth-gateway/code/gateway_solution_entitlements_test.go index 88c228a4f..ddce94292 100644 --- a/module/services/auth-gateway/code/gateway_solution_entitlements_test.go +++ b/module/services/auth-gateway/code/gateway_solution_entitlements_test.go @@ -302,7 +302,7 @@ func TestGatewaySolutionEntitlements_IgnoresCallerSuppliedActingHeader(t *testin func TestGatewaySolutionEntitlements_SpendsThePerOrgBudget(t *testing.T) { gw, authority, priv := entitlementGateway(t) // effective budget = limit(1) + burst(max(1/5,1)=1) = 2 requests / org / min. - gw.rateLimiter = NewRateLimiter(1) + gw.rateLimiter = newInProcessRateLimiter(1) // One token, so every request keys on the same stamped X-Org-Id. token := signValidToken(t, priv) diff --git a/module/services/auth-gateway/code/gateway_solutions.go b/module/services/auth-gateway/code/gateway_solutions.go index 4ff6d36d9..45a4681a4 100644 --- a/module/services/auth-gateway/code/gateway_solutions.go +++ b/module/services/auth-gateway/code/gateway_solutions.go @@ -178,6 +178,17 @@ func (g *Gateway) handleSolutionRequest(w http.ResponseWriter, r *http.Request) return true } stripAllIdentityHeaders(r) + // The person's whole session credential is withheld HERE even though the + // authenticated path below still forwards it (SA-F-BEARER, open). + // + // This path is the exception that has no contract to break. It runs no + // ext_authz at all — it is the anonymous module-federation surface, which + // the browser's loader fetches with no credential — so a bearer that + // arrives on it is one the upstream was never authenticated to receive + // and has no use for. A page that happened to attach one would otherwise + // hand it to a third-party pod on an unauthenticated fetch, which is a + // strictly worse exposure than the authenticated path's. + removePersonsSessionCredential(r.Header) entry := &RouteEntry{ Service: "solution:" + id, UpstreamPath: publicPath, @@ -265,8 +276,11 @@ func (g *Gateway) handleSolutionRequest(w http.ResponseWriter, r *http.Request) httpError(w, http.StatusForbidden, "no organization in this session") return true } - // Proxy to the solution. The caller's bearer is preserved so the solution - // can call accounts through the gateway on the user's behalf. + // Proxy to the solution. The caller's COOKIE does not travel with it — proxyTo + // removes it for every declared upstream (removeCallerCookie) — and the + // solution receives the stamped identity headers beside the bearer. The + // bearer itself still travels: SA-F-BEARER is recorded open, for the reason + // given at removePersonsSessionCredential in gateway.go. // // Route through rateLimitThenProxy, not proxyTo directly: an authenticated // solution data endpoint must consume the same per-org budget as an equivalent @@ -304,7 +318,8 @@ func (g *Gateway) handleSolutionRequest(w http.ResponseWriter, r *http.Request) // free. The entitlement LISTING already wrapped its work this way; the proxy // did not. g.rateLimitThenServe(w, r, entry, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch g.admitViewerSolution(r.Context(), org, viewer, routing) { + admission, installation := g.admitViewerSolutionInstallation(r.Context(), org, viewer, routing) + switch admission { case viewerSolutionNotEntitled: // A verdict on this organization's admission, named so a client can // tell it from an ext_authz denial on the credential: one is fixed by @@ -320,6 +335,23 @@ func (g *Gateway) handleSolutionRequest(w http.ResponseWriter, r *http.Request) httpError(w, http.StatusServiceUnavailable, "solution entitlement authority unavailable") return } + // WHICH INSTALLATION admitted this viewer, forwarded to the runtime. + // + // A solution's runtime cannot mint anything for a composed module without + // it: the host requires every mint to name an installation, an + // organization is not an installation — one org may hold several — and the + // runtime deliberately will NOT read one out of a capability a caller + // carried, because inbound that carrier is caller-controlled. So the only + // party that can supply it is the hop that just resolved the consent, and + // until this it was supplied by nobody: a runtime asking for a module + // refused before it reached the mint. + // + // Gateway-owned like the identity projection beside it, and on + // untrustedAuthHeaders, so a caller's own value is replaced rather than + // forwarded. + if installation != "" { + r.Header.Set(installationConfinementHeader, installation) + } // The destination is the one this request's single resolution named, and // the one whose identity the authority just admitted. g.proxyTo(w, r, routing.Upstream, entry) @@ -451,3 +483,96 @@ func isForbiddenUpstreamHost(host string) bool { } return false } + +// confineResourceMintToItsConsumer is the second half of the resource +// confinement, and the half that makes it a confinement to a CONSUMER rather +// than only to a route. +// +// ext_authz admits a resource-bound credential at exactly one host route — the +// delegation mint — and stamps the resource it is bound to. That alone bounds +// WHERE the credential may act. This bounds WHAT it may act for: the solution +// the resource names must be one this organization installed and this viewer was +// granted, which is the same question, asked through the same authority, as the +// one every proxied request to that solution already passes. +// +// Three refusals, deliberately distinguished: +// +// - A resource this host would not have minted resolves to no solution. 401: +// the credential is not one of ours, and a client answers by re-authorizing. +// - A solution whose registration resolves to nothing, or to a target the +// viewer holds no grant for, is 403. It is a verdict about this person and +// this consumer, and no re-authorization changes it. +// - An authority that could not be asked is 503, never an admission. A mint is +// a capability; issuing one on "I could not check" is the failure mode this +// whole finding is about. +// +// It runs on the HTTP gateway path, after the identity projection, because that +// is where the registry and the entitlement authority live. A deployment driving +// ext_authz from Envoy gets the route confinement and the carried resource but +// not this check, which is stated here rather than assumed away: the asymmetry +// is the same one the gateway credential already has (see ExtAuthz.allow), and +// closing it means giving the ext_authz server its own registry client. +func (g *Gateway) confineResourceMintToItsConsumer(w http.ResponseWriter, r *http.Request) bool { + resource := r.Header.Get(resourceConfinementHeader) + if resource == "" || !isWorkContextMintPath(r.URL.Path) { + return true + } + solutionID, ok := solutionIDFromResource(resource, g.authz.publicBase) + if !ok { + httpError(w, http.StatusUnauthorized, + "the token presented names no resource this host issues") + return false + } + routing, resolution := g.solutions.resolveRouting(r.Context(), solutionID) + switch resolution { + case solutionRoutable: + // Resolved to one declared binding. Admission below. + case solutionRegistryUnavailable: + // Undecidable, and a mint is never issued on an unanswered question. + httpError(w, http.StatusServiceUnavailable, + "the solution registry could not be read, so this token's consumer "+ + "could not be identified") + return false + default: + // Unregistered, withdrawn, or the wrong surface: a verdict. + httpError(w, http.StatusForbidden, + "the consumer this token was issued for is not available here") + return false + } + org := r.Header.Get("X-Org-Id") + viewer := r.Header.Get("X-User-Id") + admission, installation := g.admitViewerSolutionInstallation(r.Context(), org, viewer, routing) + switch admission { + case viewerSolutionAdmitted: + if installation == "" { + // Admitted with no installation named. Nothing produces this — the + // admission comes FROM a matched entitlement, which carries one — so + // it is an authority that broke its contract, and a mint is not + // issued on a ceiling nobody can be held to. The alternative is a + // confined mint accounts would judge against no consumer at all, + // which is the unconfined mint this whole half closes. + httpError(w, http.StatusServiceUnavailable, + "the authority that decides this consumer's installation named none") + return false + } + // The consent this mint is held to. Accounts resolves this installation's + // declared audiences and its agent's standing grants and refuses anything + // outside them (StartTask), so the ceiling is the one an administrator + // actually consented to rather than the viewer's whole authority. + // + // Set AFTER stripAllIdentityHeaders and the identity projection, like the + // projection itself: the name is on untrustedAuthHeaders, so whatever a + // caller sent under it is already gone. + r.Header.Set(installationConfinementHeader, installation) + return true + case viewerSolutionNotEntitled: + httpError(w, http.StatusForbidden, + "this organization has not installed the consumer this token was issued for, "+ + "or you hold no grant that reaches it") + return false + default: + httpError(w, http.StatusServiceUnavailable, + "the authority that decides this consumer's installation could not be reached") + return false + } +} diff --git a/module/services/auth-gateway/code/gateway_solutions_test.go b/module/services/auth-gateway/code/gateway_solutions_test.go index a8e0e5143..37de3fa9b 100644 --- a/module/services/auth-gateway/code/gateway_solutions_test.go +++ b/module/services/auth-gateway/code/gateway_solutions_test.go @@ -211,7 +211,11 @@ func TestGateway_Solution_ValidJWT_ForwardsWithIdentity(t *testing.T) { require.NotEmpty(t, fake.lastHeaders.Get("x-user-id")) require.NotEmpty(t, fake.lastHeaders.Get("x-org-id")) require.Equal(t, "admin", fake.lastHeaders.Get("x-org-role")) - // The caller's bearer is preserved for the solution's own downstream calls. + // The caller's bearer DOES travel to the solution, which is the recorded + // state of SA-F-BEARER and not agreement with it: the solution authenticates + // by it and nothing mints the audience-bound capability that should replace + // it. See removePersonsSessionCredential in gateway.go. The cookie does not + // travel, and TestSolutionUpstreamReceivesNoCookie asserts that. require.Equal(t, "Bearer "+token, fake.lastHeaders.Get("authorization")) // The gateway token is an accounts-only capability and must not leak to a // solution upstream. @@ -272,7 +276,7 @@ func TestGateway_Solution_MissingID_NotFound(t *testing.T) { func TestGateway_Solution_RateLimited(t *testing.T) { gw, _, _, priv := newGatewayHarness(t) // effective budget = limit(1) + burst(max(1/5,1)=1) = 2 requests / org / min. - gw.rateLimiter = NewRateLimiter(1) + gw.rateLimiter = newInProcessRateLimiter(1) fake := registerSolutionUpstream(t, gw, "audit") // Reuse ONE token so every request keys on the same injected x-org-id. @@ -301,7 +305,7 @@ func TestGateway_Solution_RateLimited(t *testing.T) { func TestGateway_Solution_PublicSurface_RateLimited(t *testing.T) { gw, _, _, _ := newGatewayHarness(t) // effective budget = limit(1) + burst(max(1/5,1)=1) = 2 requests / key / min. - gw.rateLimiter = NewRateLimiter(1) + gw.rateLimiter = newInProcessRateLimiter(1) fake := registerSolutionUpstream(t, gw, "audit") got429 := false diff --git a/module/services/auth-gateway/code/gateway_test.go b/module/services/auth-gateway/code/gateway_test.go index f20772db1..80cd57136 100644 --- a/module/services/auth-gateway/code/gateway_test.go +++ b/module/services/auth-gateway/code/gateway_test.go @@ -25,6 +25,9 @@ type fakeUpstream struct { lastBody string statusCode int body string + // responseHeaders let a test give the upstream its own answer about + // cross-origin access, which is what accounts' generated CORS handler does. + responseHeaders http.Header } func (f *fakeUpstream) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -34,6 +37,11 @@ func (f *fakeUpstream) ServeHTTP(w http.ResponseWriter, r *http.Request) { if body, err := io.ReadAll(r.Body); err == nil { f.lastBody = string(body) } + for key, values := range f.responseHeaders { + for _, value := range values { + w.Header().Add(key, value) + } + } code := f.statusCode if code == 0 { code = 200 @@ -255,7 +263,7 @@ func TestGateway_GitHubWebhooks_ForwardedUnauthenticatedWithSignature(t *testing func TestGateway_GitHubWebhook_BudgetIsolatedFromAnonymousTraffic(t *testing.T) { gw, apiFake, _, _ := newGatewayHarness(t) // effective anonymous budget = limit(1) + burst(max(1/5,1)=1) = 2 per minute. - gw.rateLimiter = NewRateLimiter(1) + gw.rateLimiter = newInProcessRateLimiter(1) // Spend the anonymous per-IP budget on an unrelated public route. spent := false diff --git a/module/services/auth-gateway/code/gateway_upstream_cors_test.go b/module/services/auth-gateway/code/gateway_upstream_cors_test.go new file mode 100644 index 000000000..483e47d4d --- /dev/null +++ b/module/services/auth-gateway/code/gateway_upstream_cors_test.go @@ -0,0 +1,129 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +// accounts' generated CORS handler hands an empty allowlist to a library that +// reads empty as allow-all, so it answers a wildcard origin with credentials +// true. A grant on a response this gateway serves is this gateway's grant, so an +// upstream's own grant never reaches the public surface, and an empty allowlist +// grants nothing. +// +// The generated handler is the go-grpc agent's to fix. What this gateway owns is +// that the grant on its own responses is the one it made. +func TestUpstreamCrossOriginGrantNeverReachesTheBrowser(t *testing.T) { + gw, api, _, _ := newGatewayHarness(t) + api.responseHeaders = http.Header{ + "Access-Control-Allow-Origin": []string{"*"}, + "Access-Control-Allow-Credentials": []string{"true"}, + "Access-Control-Expose-Headers": []string{"x-everything"}, + } + + for _, tc := range []struct { + name string + origin string + }{ + {"an unregistered origin", "https://evil.example"}, + {"no origin at all", ""}, + } { + t.Run(tc.name, func(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, "/v1/status", nil) + if tc.origin != "" { + request.Header.Set("Origin", tc.origin) + } + recorder := httptest.NewRecorder() + gw.ServeHTTP(recorder, request) + + require.Equal(t, http.StatusOK, recorder.Code, "the request itself is unaffected") + for name := range recorder.Header() { + require.NotContains(t, lowered(name), "access-control-", + "the gateway granted nothing, so no access-control header may leave it: %s=%q", + name, recorder.Header().Get(name)) + } + }) + } +} + +// And a registered origin's grant is the gateway's own, replacing rather than +// joining the upstream's — two allow-origin values is a response every browser +// rejects. +func TestARegisteredOriginsGrantReplacesTheUpstreams(t *testing.T) { + gw, api, _, _ := newGatewayHarness(t) + twoRegisteredClients(t, gw) + api.responseHeaders = http.Header{ + "Access-Control-Allow-Origin": []string{"*"}, + "Access-Control-Allow-Credentials": []string{"true"}, + } + + request := httptest.NewRequest(http.MethodGet, "/v1/status", nil) + request.Header.Set("Origin", addinOrigin) + recorder := httptest.NewRecorder() + gw.ServeHTTP(recorder, request) + + require.Equal(t, []string{addinOrigin}, recorder.Result().Header.Values("Access-Control-Allow-Origin")) + require.Empty(t, recorder.Header().Get("Access-Control-Allow-Credentials"), + "credentials are never granted cross-origin, whatever the upstream said") +} + +func lowered(value string) string { + out := make([]byte, 0, len(value)) + for i := 0; i < len(value); i++ { + c := value[i] + if c >= 'A' && c <= 'Z' { + c += 'a' - 'A' + } + out = append(out, c) + } + return string(out) +} + +// A 1xx is informational: net/http sends it and keeps the same header map open for +// the real response the reverse proxy then fills from the upstream. Treating it as +// the response marked sanitization complete, so the final upstream headers — an +// upstream's own permissive grant among them — went out untouched. +// +// Driven through real local HTTP servers, because the behaviour is net/http's +// handling of an early hint and a recorder does not reproduce it. +func TestR1019CORSAfterInformationalResponse(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + // An early hint, exactly as a real upstream may send before its answer. + w.Header().Set("Link", "; rel=preload") + w.WriteHeader(http.StatusEarlyHints) + w.Header().Del("Link") + + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Credentials", "true") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("upstream-body")) + })) + t.Cleanup(upstream.Close) + + gw, _, _, _ := newGatewayHarness(t) + upstreamURL, parseErr := url.Parse(upstream.URL) + require.NoError(t, parseErr) + gw.upstreams["accounts"] = upstreamURL + + front := httptest.NewServer(gw) + t.Cleanup(front.Close) + + request, err := http.NewRequest(http.MethodGet, front.URL+"/v1/status", nil) + require.NoError(t, err) + request.Header.Set("Origin", "https://evil.example") + + response, err := http.DefaultClient.Do(request) + require.NoError(t, err) + t.Cleanup(func() { _ = response.Body.Close() }) + + require.Equal(t, http.StatusOK, response.StatusCode) + for name := range response.Header { + require.NotContains(t, lowered(name), "access-control-", + "the final response must carry no grant this gateway did not make: %s=%q", + name, response.Header.Get(name)) + } +} diff --git a/module/services/auth-gateway/code/main.go b/module/services/auth-gateway/code/main.go index 7a6c8c39b..70d004e50 100644 --- a/module/services/auth-gateway/code/main.go +++ b/module/services/auth-gateway/code/main.go @@ -33,6 +33,13 @@ func main() { panic(fmt.Sprintf("codefly init failed: %v", err)) } + // Refuse before anything is served: a deployed gateway holding a shipped + // placeholder, or a perimeter credential too short to be one, decides + // perimeter membership on a published value. + if err := requirePerimeterCredentials(codefly.IsLocal()); err != nil { + panic(err.Error()) + } + grpcNetwork, err := codefly.For(ctx).API(standards.GRPC).ResolveNetworkInstance() if err != nil || grpcNetwork == nil || grpcNetwork.Port == 0 { panic(fmt.Sprintf("Codefly gRPC endpoint is unavailable: %v", err)) @@ -130,7 +137,7 @@ func main() { if err != nil { panic(err) } - rev, err := newRevoker(redisURL, revocationTTL) + rev, err := newRevoker(redisURL, revocationTTL, codefly.IsLocal()) if err != nil { panic(fmt.Sprintf("configure access-token revocation: %v", err)) } @@ -216,15 +223,21 @@ func main() { if err != nil { panic(err) } - // Malformed TRUSTED_PROXY_CIDRS fails boot rather than silently trusting - // a narrower/empty set — parity with accounts' ParseTrustedProxyCIDRs. - if _, err := parseProxyTrust(workspaceEnv("gateway", "TRUSTED_PROXY_CIDRS")); err != nil { - panic(err) + // A malformed OR empty TRUSTED_PROXY_CIDRS fails boot on a deployed + // runtime rather than silently collapsing every bucket onto the frontend's + // pod address. + if err := requireProxyTrust(workspaceEnv("gateway", "TRUSTED_PROXY_CIDRS"), codefly.IsLocal()); err != nil { + panic(err.Error()) } - rateLimiter = NewRateLimiter(1000, + // 1000 req/min per org/IP; stricter MFA budget is configured separately. + rateLimiter, err = NewRateLimiter(1000, WithRedisURL(redisURL), WithAuthenticationAttemptLimit(authenticationAttemptLimit), - ) // 1000 req/min per org/IP; stricter MFA budget is configured separately. + WithInProcessBackendAllowed(codefly.IsLocal()), + ) + if err != nil { + panic(fmt.Sprintf("configure rate limiting: %v", err)) + } // The durable solution registry lives in accounts and is reached over the // same internal connection as the other brokered internal calls. The // reconcile loop rebuilds this replica's routing cache from it — once at diff --git a/module/services/auth-gateway/code/perimeter_workos_free_test.go b/module/services/auth-gateway/code/perimeter_workos_free_test.go index 9432fe2b8..fffe2a43b 100644 --- a/module/services/auth-gateway/code/perimeter_workos_free_test.go +++ b/module/services/auth-gateway/code/perimeter_workos_free_test.go @@ -265,6 +265,18 @@ var wantEmittedHeaders = []string{ "x-user-name", "x-session-id", "x-acting-as-user-id", "x-act", "x-scopes", "x-credential-kind", "x-mfa-satisfied", "x-authentication-methods", "x-auth-time", "x-assurance-level", "x-mfa-verified-at", "x-client-id", + // Added deliberately, which is what this list is for: the resource a + // confined credential was issued for, carried from the ext_authz decision to + // the Work Context mint so the mint can hold what it issues to the consumer + // the credential was delivered to. + // + // Eyeballed as this list demands. It leaks nothing: the value is an audience + // from the token this host itself minted — a public resource URL the client + // already dialled and the solution already publishes — never a provider + // identifier, and never anything derived from the caller's own headers. It is + // emitted empty for every credential that names no resource, like every + // canonical header beside it. + resourceConfinementHeader, } // TestPerimeter_SuccessPathEmitsCanonicalHeadersOnly asserts the ext_authz diff --git a/module/services/auth-gateway/code/ratelimit.go b/module/services/auth-gateway/code/ratelimit.go index b28fd2872..1d2dd6d41 100644 --- a/module/services/auth-gateway/code/ratelimit.go +++ b/module/services/auth-gateway/code/ratelimit.go @@ -158,6 +158,7 @@ type RateLimiter struct { type rateLimiterOptions struct { redisURL string authenticationAttemptLimit int + allowInProcessBackend bool } type RateLimiterOption func(*rateLimiterOptions) @@ -170,11 +171,25 @@ func WithAuthenticationAttemptLimit(limit int) RateLimiterOption { return func(options *rateLimiterOptions) { options.authenticationAttemptLimit = limit } } +// WithInProcessBackendAllowed permits the per-replica in-memory counters. Wire it +// from codefly.IsLocal() only. +// +// A per-replica bucket is not the budget it reports: with N replicas the effective +// limit is N times the configured one, and which replica a caller lands on decides +// how much of it they get. For an authentication-factor budget that is the +// difference between a bounded number of attempts and a bounded number per +// replica. So a deployed gateway refuses to start without the shared store rather +// than enforcing a budget nobody declared, while local development — one replica, +// no cache service — keeps the counters. +func WithInProcessBackendAllowed(allowed bool) RateLimiterOption { + return func(options *rateLimiterOptions) { options.allowInProcessBackend = allowed } +} + // NewRateLimiter creates a rate limiter allowing requestsPerMinute requests // per key per minute, with a 20% burst allowance. If REDIS_URL is set it // connects to Redis; otherwise it uses in-memory counters with a background // cleanup goroutine. -func NewRateLimiter(requestsPerMinute int, optionFns ...RateLimiterOption) *RateLimiter { +func NewRateLimiter(requestsPerMinute int, optionFns ...RateLimiterOption) (*RateLimiter, error) { options := rateLimiterOptions{ redisURL: os.Getenv("REDIS_URL"), authenticationAttemptLimit: authenticationAttemptLimitPerMinute, @@ -197,19 +212,29 @@ func NewRateLimiter(requestsPerMinute int, optionFns ...RateLimiterOption) *Rate if redisURL := options.redisURL; redisURL != "" { rb, err := newRedisBackend(redisURL) if err != nil { + if !options.allowInProcessBackend { + return nil, fmt.Errorf("rate limiter cannot reach its shared store: %w", err) + } log.Printf("WARNING: cannot connect to configured Redis: %v — falling back to in-memory rate limiter", err) } else { log.Printf("rate limiter using pooled Redis backend") rl.backend = rb - return rl + return rl, nil } + } else if !options.allowInProcessBackend { + return nil, fmt.Errorf( + "rate limiter has no shared store: a deployed gateway runs more than one replica, " + + "so per-replica counters enforce the configured budget times the replica count " + + "and which replica a caller lands on decides their share — resolve the cache " + + "service's redis/connection secret, or set REDIS_URL") } - // In-memory fallback. + // Per-replica counters: local development only (see + // WithInProcessBackendAllowed). mem := &memoryBackend{} rl.backend = mem go mem.cleanup(rl.stop) - return rl + return rl, nil } // Allow checks whether the given key is under the rate limit for the current @@ -357,11 +382,41 @@ func newProxyTrust(raw string) proxyTrust { return trust } +// requireProxyTrust is the boot check for a DEPLOYED runtime: the trusted-proxy +// range must parse AND name at least one hop. +// +// The invariant (SP-GW-09): anonymous and authentication-factor budgets are keyed +// on the ORIGINATING client, never on an intermediate hop, and a cell with no +// trusted-proxy range refuses to start. +// +// An empty range is not a narrow configuration, it is no configuration: with no +// trusted hop clientIP answers the peer, which behind the frontend is the +// frontend's own pod, so every caller shares one bucket. It read as working — the +// limiter was enabled and the buckets were enforced — which is why this refuses at +// boot rather than warning. +// +// Local development has no ingress hop and the peer IS the client, so an empty +// range is correct there and only there. +func requireProxyTrust(raw string, isLocal bool) error { + trust, err := parseProxyTrust(raw) + if err != nil { + return err + } + if isLocal || len(trust.prefixes) > 0 { + return nil + } + return fmt.Errorf( + "gateway: TRUSTED_PROXY_CIDRS is empty: a deployed gateway sits behind an ingress and " + + "a frontend hop, so with no trusted range every rate-limit bucket keys on the " + + "frontend's pod address and collapses into one — set it to the ranges this cell's " + + "ingress and frontend reach the gateway from") +} + // parseProxyTrust parses a comma-separated list of bare IPs and CIDRs into a // trust set, returning an error on any malformed entry. Boot validates through -// this (parity with accounts' ParseTrustedProxyCIDRs) so a typo'd -// TRUSTED_PROXY_CIDRS fails startup rather than silently narrowing the trust -// set and collapsing every forwarded caller into one spoofable IP bucket. +// requireProxyTrust so a typo'd TRUSTED_PROXY_CIDRS fails startup rather than +// silently narrowing the trust set and collapsing every forwarded caller into one +// spoofable IP bucket. func parseProxyTrust(raw string) (proxyTrust, error) { var trust proxyTrust for _, candidate := range strings.Split(raw, ",") { @@ -446,3 +501,14 @@ func parsePeerAddress(remoteAddr string) (netip.Addr, bool) { } return addr.Unmap(), true } + +// newInProcessRateLimiter is the per-replica limiter, for tests and for local +// development's single-replica graph. It is a distinct constructor rather than an +// option on the main one so a deployed wiring cannot reach it by passing a flag. +func newInProcessRateLimiter(requestsPerMinute int) *RateLimiter { + limiter, err := NewRateLimiter(requestsPerMinute, WithInProcessBackendAllowed(true)) + if err != nil { + panic(fmt.Sprintf("in-process rate limiter: %v", err)) + } + return limiter +} diff --git a/module/services/auth-gateway/code/resource_token_confinement_test.go b/module/services/auth-gateway/code/resource_token_confinement_test.go new file mode 100644 index 000000000..287fe3310 --- /dev/null +++ b/module/services/auth-gateway/code/resource_token_confinement_test.go @@ -0,0 +1,190 @@ +package main + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/require" +) + +// A resource-bound token is confined to the solution its resource names, on EVERY +// path (SA-F-MCPAUD, the escape half). +// +// The confinement used to run only under `/solutions//`, so a token the host +// delivered to one solution's runtime was admitted — as a full session, with the +// person's whole authority — at this host's own API, at another declared module's +// routes, and at the Work Context surface that mints further capabilities. A +// compromised runtime could therefore act as every viewer who had used it, +// anywhere, which is the opposite of what a resource indicator is for. +// +// The second audience such a token carries is this host's, and it is there so the +// token VERIFIES at all: the minter stamps it and its own VerifiedAccess checks +// it. Reading that parser requirement as an authorization grant is what made the +// escape. +// +// TWO host surfaces are admitted, each in its own company, and neither is +// exercised here: the Work Context mint (see +// TestResourceTokenAtTheWorkContextMintIsAdmittedConfined), and a declared module +// route reached together with this person's verified capability for that module +// (see gateway_module_confinement_test.go). Every request below presents NO +// capability, which is why the module path refuses here as well — what this test +// pins is that nothing about the token alone admits it anywhere. +func TestResourceTokenIsConfinedToItsOwnSolution(t *testing.T) { + for _, path := range []string{ + "/v1/users", // this host's own API + "/v1/documents/entries", // another declared module's routes + "/solutions/other/v1/things", // another solution's data + "/solutions/other/mcp", // another solution's tool endpoint + } { + t.Run(path, func(t *testing.T) { + withPublicBase(t, testPublicBase) + gw, api, _, priv := newGatewayHarness(t) + registerSolutionUpstream(t, gw, "example") + registerSolutionUpstream(t, gw, "other") + declareModule(t, gw, "documents") + + // Issued for `example`, presented elsewhere. + token := signResourceToken(t, priv, testPublicBase+"/api/solutions/example/proxy/mcp") + request := httptest.NewRequest(http.MethodGet, path, nil) + request.Header.Set("Authorization", "Bearer "+token) + recorder := httptest.NewRecorder() + gw.ServeHTTP(recorder, request) + + require.Equal(t, http.StatusUnauthorized, recorder.Code, + "a token issued for one resource must not be admitted at %s", path) + require.Nil(t, api.lastHeaders, + "and the upstream must not be reached at all") + }) + } +} + +// The control: the same token at the resource it DOES name is admitted. Without +// it the test above would pass on a gateway that refused every resource token. +func TestResourceTokenIsAdmittedAtItsOwnSolution(t *testing.T) { + withPublicBase(t, testPublicBase) + gw, _, _, priv := newGatewayHarness(t) + example := registerSolutionUpstream(t, gw, "example") + + token := signResourceToken(t, priv, testPublicBase+"/api/solutions/example/proxy/mcp") + request := httptest.NewRequest(http.MethodPost, "/solutions/example/mcp", nil) + request.Header.Set("Authorization", "Bearer "+token) + recorder := httptest.NewRecorder() + gw.ServeHTTP(recorder, request) + + require.Equal(t, http.StatusOK, recorder.Code) + require.NotNil(t, example.lastHeaders) +} + +// An ordinary host session is NOT confined: it names no resource, and it is the +// credential every surface except a tool endpoint accepts. This is the other +// control — confinement must narrow resource tokens, not every token. +func TestAnUnboundSessionIsNotConfined(t *testing.T) { + withPublicBase(t, testPublicBase) + gw, api, _, priv := newGatewayHarness(t) + + request := httptest.NewRequest(http.MethodGet, "/v1/users", nil) + request.Header.Set("Authorization", "Bearer "+signValidToken(t, priv)) + recorder := httptest.NewRecorder() + gw.ServeHTTP(recorder, request) + + require.Equal(t, http.StatusOK, recorder.Code) + require.NotNil(t, api.lastHeaders) +} + +// An API key names no resource and cannot be bound to one, so it is refused at a +// tool endpoint — which it reached by never being asked: the API-key branch +// returns before the resource rules, which live in the JWT path. +func TestAnAPIKeyIsRefusedAtAToolEndpoint(t *testing.T) { + withPublicBase(t, testPublicBase) + gw, _, _, _ := newGatewayHarness(t) + example := registerSolutionUpstream(t, gw, "example") + + request := httptest.NewRequest(http.MethodPost, "/solutions/example/mcp", nil) + request.Header.Set("Authorization", "Bearer cfly_sk_anything") + recorder := httptest.NewRecorder() + gw.ServeHTTP(recorder, request) + + require.Equal(t, http.StatusUnauthorized, recorder.Code) + require.Contains(t, recorder.Body.String(), "names no resource") + require.Nil(t, example.lastHeaders, "and the runtime is not reached") +} + +// THE MINT, asserted at the decision point rather than through ServeHTTP. +// +// It is admitted, and admitted CONFINED — the resource travels with it so +// accounts can hold what it issues to the consumer the credential was delivered +// to. The runtime's MCP viewer path needs exactly this: it mints a viewer-owned +// capability for the module its solution declared it consumes, forwarding the +// person's bearer, because its own service credential does not stand in for the +// person. +// +// This test used to assert the OPPOSITE — that the mint refused a resource token +// — and it kept passing after the admission shipped, because `newTestExtAuthz` +// leaves `publicBase` empty and a confined credential is refused in that state +// for an unrelated reason: this process cannot say what any resource identifier +// IS, so it cannot compare one. A test that passes through the path it means to +// exercise being unreachable asserts nothing about it. The public base is +// therefore set here, to the value the production constructor resolves, and both +// states are asserted separately. +// +// The Connect route is not in the test harness's catalog (ServeHTTP answers 404 +// before any credential is read), so this runs against ExtAuthz.Check, which is +// where the decision is made and where the real deployment reaches it. +func TestResourceTokenAtTheWorkContextMintIsAdmittedConfined(t *testing.T) { + withPublicBase(t, testPublicBase) + s, priv := newTestExtAuthz(t) + s.publicBase = publicBaseURL() + require.Equal(t, testPublicBase, s.publicBase, + "the admission under test is unreachable without a configured public address") + + resource := solutionMCPResource(testPublicBase, "example") + bound := validClaims(time.Now()) + bound.Audience = jwt.ClaimStrings{"saas-starter", resource} + + resp, err := s.Check(context.Background(), checkReq( + workContextMintProcedure, map[string]string{ + "authorization": "Bearer " + signClaims(t, priv, bound), + })) + require.NoError(t, err) + require.Nil(t, resp.GetDeniedResponse(), + "the consumer's own mint is admitted; what it may ISSUE is bounded by accounts") + require.Equal(t, resource, headerMap(resp)[resourceConfinementHeader], + "and the resource travels with it, or the mint cannot tell it from an unbound session") + + // The control: an unbound session still mints, and carries an EMPTY + // confinement rather than none, so accounts reads a value either way. + ok, err := s.Check(context.Background(), checkReq( + workContextMintProcedure, map[string]string{ + "authorization": "Bearer " + signClaims(t, priv, validClaims(time.Now())), + })) + require.NoError(t, err) + require.NotNil(t, ok.GetOkResponse(), "an ordinary session still reaches the mint") + require.Empty(t, headerMap(ok)[resourceConfinementHeader]) +} + +// Without a configured public address the mint refuses a confined credential, +// and says which of the two refusals it is. An operator fixes a configuration and +// a client re-authorizes; those are different actions, so they are different +// messages. +func TestResourceTokenAtTheMintIsRefusedWithNoConfiguredPublicAddress(t *testing.T) { + s, priv := newTestExtAuthz(t) + require.Empty(t, s.publicBase) + + bound := validClaims(time.Now()) + bound.Audience = jwt.ClaimStrings{ + "saas-starter", solutionMCPResource(testPublicBase, "example"), + } + + resp, err := s.Check(context.Background(), checkReq( + workContextMintProcedure, map[string]string{ + "authorization": "Bearer " + signClaims(t, priv, bound), + })) + require.NoError(t, err) + require.NotNil(t, resp.GetDeniedResponse()) + require.Equal(t, int32(401), int32(resp.GetDeniedResponse().Status.Code)) + require.Contains(t, resp.GetDeniedResponse().GetBody(), "no configured public address") +} diff --git a/module/services/auth-gateway/code/revocation.go b/module/services/auth-gateway/code/revocation.go index 9634652ca..1bbf143a6 100644 --- a/module/services/auth-gateway/code/revocation.go +++ b/module/services/auth-gateway/code/revocation.go @@ -247,13 +247,32 @@ func (c *cachedRevoker) evictOldest(n int) { } } -// newRevoker builds the revoker wired into the ext_authz check. No Redis configured -// falls back to noopRevoker (dev parity, loudly logged); a Redis URL that -// doesn't parse is a config error, not a reason to silently run without -// revocation — the caller must treat it as fatal. -func newRevoker(redisURL string, ttl time.Duration) (revoker, error) { +// newRevoker builds the revoker wired into the ext_authz check. +// +// A Redis URL that does not parse is a config error, not a reason to silently run +// without revocation — the caller must treat it as fatal. +// +// An ABSENT store is refused on a deployed runtime, by name. It used to fall back +// to noopRevoker with a log line, which is the worst available answer: the +// revoker answers "not revoked" to every question, so a signed-out session, a +// killed device and an ended impersonation window all keep authenticating until +// their tokens expire naturally — and the gateway reports healthy throughout, +// because nothing on the request path can tell a no-op revoker from an empty +// revocation set. The fail-closed behaviour on a store ERROR (revocationFailsOpen) +// was already right; this is the same rule for a store that was never there. +// +// Local development keeps the no-op: there is no cache service in a bare `go run` +// graph, and the alternative is a gateway that cannot start. +func newRevoker(redisURL string, ttl time.Duration, isLocal bool) (revoker, error) { if strings.TrimSpace(redisURL) == "" { - log.Printf("auth-gateway: no Redis configured — access-token revocation disabled (dev parity with accounts NoopTokenRevoker)") + if !isLocal { + return nil, fmt.Errorf( + "access-token revocation has no store: a deployed gateway must reach the cache " + + "service's Redis, because a revoker with no store answers \"not revoked\" to " + + "every question — resolve the cache service's redis/connection secret, or set " + + "REDIS_URL") + } + log.Printf("auth-gateway: no Redis configured — access-token revocation disabled (local development only)") return noopRevoker{}, nil } store, err := newRedisRevocationStore(redisURL) diff --git a/module/services/auth-gateway/code/shipped_placeholder.go b/module/services/auth-gateway/code/shipped_placeholder.go new file mode 100644 index 000000000..63fd9ca10 --- /dev/null +++ b/module/services/auth-gateway/code/shipped_placeholder.go @@ -0,0 +1,116 @@ +package main + +import ( + "fmt" + "strings" +) + +// Shipped-placeholder refusal — the gateway's half. +// +// The module package ships working local defaults for its secret groups, out of a +// public repository, and the configuration-defaults gate requires each of them to +// carry a placeholder marker so a real credential can never be committed as one. +// That makes the same marker list the answer to the opposite question: a value +// carrying one is a value everybody knows. +// +// This gateway holds two credentials that decide perimeter membership — the +// cluster-internal token it accepts on its own internal endpoints, and the +// gateway-provenance token accounts believes forwarded identity on — and nothing +// rejected a shipped placeholder in either outside local development. +// +// The list and the floor are duplicated from accounts deliberately: the two are +// separate Go modules with no shared local package, and +// module/tools/shipped_placeholder_lockstep_test.go holds the copies identical, so +// a marker added on one side cannot leave the other admitting a published value. + +var shippedPlaceholderMarkers = []string{ + "replace-me", "replaceme", "change-me", "changeme", + "placeholder", "local-dev", "dev-only", +} + +// minimumPerimeterCredentialLength is the floor for a credential that decides +// perimeter membership: the shortest value that can carry 128 bits of entropy in +// any encoding an operator is likely to generate. The shipped placeholders are +// shorter, so this is not a second spelling of the marker check — it also refuses +// a short hand-typed value carrying no marker. +const minimumPerimeterCredentialLength = 32 + +func looksLikeShippedPlaceholder(value string) bool { + lower := strings.ToLower(value) + for _, marker := range shippedPlaceholderMarkers { + if strings.Contains(lower, marker) { + return true + } + } + return false +} + +// refusePerimeterCredential reports why a perimeter credential is unfit for a +// deployed runtime, or nil. It names the group-qualified key and never the value. +// It never includes the value. +func refusePerimeterCredential(name, value string, current bool) error { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + if current { + return fmt.Errorf( + "%s is absent; a deployed cell must provision it before this service can "+ + "decide that a caller is inside the perimeter", name) + } + return nil + } + if looksLikeShippedPlaceholder(trimmed) { + return fmt.Errorf( + "%s carries a placeholder this module ships in its public local defaults; "+ + "provision a real value for this cell", name) + } + if len(trimmed) < minimumPerimeterCredentialLength { + return fmt.Errorf( + "%s is %d characters; a credential that decides perimeter membership must be at least %d", + name, len(trimmed), minimumPerimeterCredentialLength) + } + return nil +} + +// perimeterCredential is one value this service trusts to decide that a caller is +// inside the perimeter. Current distinguishes the two cases a single list was +// conflating: a CURRENT credential the deployment must carry, and the PREVIOUS +// half of a rotation, which is absent on every cell that is not mid-rotation. +// Refusing both alike would make the ordinary state an outage; refusing neither +// let a cell start with no perimeter credential at all, which is what R1019-N17 +// found. The flag is the distinction, so each answer is given once. +type perimeterCredential struct { + Name string + Value string + Current bool +} + +// perimeterCredentials are the values this gateway trusts to decide that a caller +// is inside the perimeter. +func perimeterCredentials() []perimeterCredential { + return []perimeterCredential{ + {"internal-auth/CODEFLY_INTERNAL_TOKEN", workspaceEnv("internal-auth", "CODEFLY_INTERNAL_TOKEN"), true}, + {"internal-auth/CODEFLY_INTERNAL_TOKEN_PREVIOUS", workspaceEnv("internal-auth", "CODEFLY_INTERNAL_TOKEN_PREVIOUS"), false}, + {"gateway-trust/CODEFLY_GATEWAY_TOKEN", workspaceEnv("gateway-trust", "CODEFLY_GATEWAY_TOKEN"), true}, + } +} + +// requirePerimeterCredentials refuses to start a deployed runtime holding a +// shipped placeholder, or a perimeter credential too short to be one. Every +// refusal is collected: an operator fixing these edits one secret group, and +// learning about the second key on the next deploy is a second outage for nothing. +func requirePerimeterCredentials(isLocal bool) error { + if isLocal { + return nil + } + var problems []string + for _, credential := range perimeterCredentials() { + if err := refusePerimeterCredential(credential.Name, credential.Value, credential.Current); err != nil { + problems = append(problems, err.Error()) + } + } + if len(problems) == 0 { + return nil + } + return fmt.Errorf("perimeter credentials are unfit for a deployed runtime: %s", + strings.Join(problems, "; ")) +} diff --git a/module/services/auth-gateway/code/shipped_placeholder_test.go b/module/services/auth-gateway/code/shipped_placeholder_test.go new file mode 100644 index 000000000..be97bd27d --- /dev/null +++ b/module/services/auth-gateway/code/shipped_placeholder_test.go @@ -0,0 +1,164 @@ +package main + +import ( + "os" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// The gateway decides perimeter membership on these two credentials and accepted +// a value published in this repository's own local defaults. +func TestDeployedRefusesShippedPlaceholders(t *testing.T) { + for _, value := range []string{ + "local-dev-only-replace-me", + "local-dev-gateway-only-replace-me", + "LOCAL-DEV-GATEWAY-ONLY-REPLACE-ME", + "prefixed-changeme-suffixed-and-long-enough-to-pass-the-length-floor", + } { + err := refusePerimeterCredential("gateway-trust/CODEFLY_GATEWAY_TOKEN", value, true) + require.Error(t, err, "%q carries a shipped marker and must be refused", value) + require.Contains(t, err.Error(), "gateway-trust/CODEFLY_GATEWAY_TOKEN") + require.NotContains(t, err.Error(), value, "a refusal never prints the value") + } +} + +func TestDeployedRefusesAShortPerimeterCredential(t *testing.T) { + err := refusePerimeterCredential("internal-auth/CODEFLY_INTERNAL_TOKEN", "7Kq2Xp9Vb4Nf", true) + require.Error(t, err) + require.Contains(t, err.Error(), "at least 32") + require.NotContains(t, err.Error(), "7Kq2Xp9Vb4Nf") +} + +func TestPerimeterCredentialChecksAdmitWhatTheyShould(t *testing.T) { + require.NoError(t, refusePerimeterCredential( + "gateway-trust/CODEFLY_GATEWAY_TOKEN", strings.Repeat("7Kq2Xp9Vb4Nf", 4), true)) + require.NoError(t, refusePerimeterCredential( + "internal-auth/CODEFLY_INTERNAL_TOKEN_PREVIOUS", "", false)) +} + +func TestLocalRuntimeKeepsTheShippedDefaults(t *testing.T) { + require.NoError(t, requirePerimeterCredentials(true)) +} + +// Every credential the ext_authz trust surface reads is covered, so a third key +// added there cannot be unchecked while looking checked. +func TestPerimeterCredentialsCoverEveryTrustDecidingKey(t *testing.T) { + names := map[string]bool{} + for _, credential := range perimeterCredentials() { + names[credential.Name] = true + } + require.Equal(t, map[string]bool{ + "internal-auth/CODEFLY_INTERNAL_TOKEN": true, + "internal-auth/CODEFLY_INTERNAL_TOKEN_PREVIOUS": true, + "gateway-trust/CODEFLY_GATEWAY_TOKEN": true, + }, names) +} + +// The boot refusals are only refusals if main WIRES them. Removing a call site leaves +// every unit test above green while a deployed gateway starts on a published +// credential, with no shared store for revocation or rate limits, and with no +// trusted-proxy range — so the wiring is asserted from the source. +// +// A fourth refusal stood here, for a durable module prefix-claim store. It is gone +// because its subject is: a module route is no longer claimed at runtime but read +// per request from the delivered registry (gateway_module_routes.go), which is +// shared and durable by construction and fails closed when no snapshot has loaded. +// +// Each is matched with codefly.IsLocal() specifically: wiring one of these to a +// constant, or to a request-derived value, is the same defect as not wiring it. +func TestR1019DeployedRefusalsAreWiredAtBoot(t *testing.T) { + source, err := os.ReadFile("main.go") + require.NoError(t, err) + text := string(source) + + for _, wiring := range []struct{ what, call string }{ + {"the shipped-placeholder refusal", "requirePerimeterCredentials(codefly.IsLocal())"}, + {"the trusted-proxy range refusal", "requireProxyTrust(workspaceEnv(\"gateway\", \"TRUSTED_PROXY_CIDRS\"), codefly.IsLocal())"}, + {"the revocation-store refusal", "newRevoker(redisURL, revocationTTL, codefly.IsLocal())"}, + {"the rate-limit store refusal", "WithInProcessBackendAllowed(codefly.IsLocal())"}, + } { + require.Contains(t, text, wiring.call, + "%s must be wired at boot from the runtime, not from a constant", wiring.what) + } + + // And the refusal has to stop the process. A call whose error is ignored is not a + // refusal; every one of these is fatal at boot. + require.NotContains(t, text, "_ = requirePerimeterCredentials", + "the placeholder refusal's error must not be discarded") +} + +// The per-replica limiter is reachable only through a distinct constructor, so a +// deployed wiring cannot opt into it by passing a flag. This is the claim the review +// found overstated: the option EXISTS and any caller could pass it, so what holds is +// that the only production call site passes codefly.IsLocal() — which is what the +// source assertion above actually establishes. +func TestR1019InProcessLimiterIsNotTheProductionWiring(t *testing.T) { + source, err := os.ReadFile("main.go") + require.NoError(t, err) + require.NotContains(t, string(source), "WithInProcessBackendAllowed(true)", + "production must not hard-enable the per-replica limiter") + require.NotContains(t, string(source), "newInProcessRateLimiter(", + "the per-replica constructor is for tests and local development only") +} + +// R1019-N17: a CURRENT perimeter credential that is absent is refused by name, and +// over the real carriers rather than only through the pure comparison. The gateway +// decides perimeter membership on these, so starting without one admits whoever +// reaches the internal endpoints. +// +// The PREVIOUS half is asserted in the same test rather than a separate one, +// because the property is the DISTINCTION: a check that refuses both is as wrong +// as a check that refuses neither, and only one assertion catches each direction. +func TestR1019AbsentCurrentPerimeterCredentialRefusesDeployedStartup(t *testing.T) { + for _, absent := range []struct{ group, key string }{ + {"internal-auth", "CODEFLY_INTERNAL_TOKEN"}, + {"gateway-trust", "CODEFLY_GATEWAY_TOKEN"}, + } { + t.Run(absent.key, func(t *testing.T) { + for _, credential := range perimeterCredentials() { + group, key, _ := strings.Cut(credential.Name, "/") + if key == absent.key { + blankWorkspaceKey(t, group, key) + continue + } + provisionWorkspaceKey(t, group, key, strings.Repeat("7Kq2Xp9Vb4Nf", 4)) + } + err := requirePerimeterCredentials(false) + require.Error(t, err, "%s is absent and a deployed gateway must refuse to start", absent.key) + require.Contains(t, err.Error(), absent.group+"/"+absent.key, + "the refusal must name the key an operator has to provision") + }) + } + + t.Run("the rotation half stays optional", func(t *testing.T) { + for _, credential := range perimeterCredentials() { + group, key, _ := strings.Cut(credential.Name, "/") + if strings.HasSuffix(key, "_PREVIOUS") { + blankWorkspaceKey(t, group, key) + continue + } + provisionWorkspaceKey(t, group, key, strings.Repeat("7Kq2Xp9Vb4Nf", 4)) + } + require.NoError(t, requirePerimeterCredentials(false), + "a cell that is not mid-rotation carries no PREVIOUS value and must still start") + }) +} + +// blankWorkspaceKey and provisionWorkspaceKey write the carriers workspaceEnv +// actually reads. Both have to be set: codefly CI injects the configuration group, +// a bare `go test` does not, and a shipped .secret.env default reaching either +// carrier would otherwise decide the result instead of the test. +func blankWorkspaceKey(t *testing.T, group, key string) { + t.Helper() + provisionWorkspaceKey(t, group, key, "") +} + +func provisionWorkspaceKey(t *testing.T, group, key, value string) { + t.Helper() + upper := strings.ReplaceAll(strings.ToUpper(group), "-", "_") + t.Setenv(key, value) + t.Setenv("CODEFLY__WORKSPACE_CONFIGURATION__"+upper+"__"+key, value) + t.Setenv("CODEFLY__WORKSPACE_SECRET_CONFIGURATION__"+upper+"__"+key, value) +} diff --git a/module/services/auth-gateway/code/upstream_session_credential_test.go b/module/services/auth-gateway/code/upstream_session_credential_test.go new file mode 100644 index 000000000..a87236ac6 --- /dev/null +++ b/module/services/auth-gateway/code/upstream_session_credential_test.go @@ -0,0 +1,105 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" +) + +// The cookie never reaches a declared upstream, whatever the bearer does. +// +// These were one decision and the broader one carried the narrower silently: the +// tool path skipped the whole removal, so a resource token presented with a +// session cookie forwarded the cookie too. They are two decisions now, and this +// is the one with no consumer contract to break — this perimeter refuses a cookie +// AS a credential by name, so an upstream can authenticate nothing with it, while +// the cookie carries the longest-lived value a browser holds. +func TestDeclaredUpstreamNeverReceivesTheCallerCookie(t *testing.T) { + for _, tc := range []struct{ name, path string }{ + {"a declared module route", "/v1/documents/entries"}, + {"a solution data route", "/solutions/audit/v1/audit/logs"}, + } { + t.Run(tc.name, func(t *testing.T) { + gw, _, _, priv := newGatewayHarness(t) + var fake *fakeUpstream + if tc.path == "/v1/documents/entries" { + fake = declareModule(t, gw, "documents") + } else { + fake = registerSolutionUpstream(t, gw, "audit") + } + + request := httptest.NewRequest(http.MethodGet, tc.path, nil) + request.Header.Set("Authorization", "Bearer "+signValidToken(t, priv)) + request.Header.Set("Cookie", "codefly_rt=a-refresh-token; codefly_session=1") + recorder := httptest.NewRecorder() + gw.ServeHTTP(recorder, request) + + require.Equal(t, http.StatusOK, recorder.Code) + require.Empty(t, fake.lastHeaders.Get("Cookie"), + "the cookie the gateway has already resolved identity from must not travel") + require.NotEmpty(t, fake.lastHeaders.Get("x-user-id"), + "the upstream still learns who the person is") + }) + } +} + +// The MCP tool path is the one the cookie leaked through, so it is asserted in +// its own right: a resource-bound token is forwarded there, and the cookie beside +// it is not. +func TestToolPathForwardsTheResourceTokenWithoutTheCookie(t *testing.T) { + withPublicBase(t, testPublicBase) + gw, _, _, priv := newGatewayHarness(t) + example := registerSolutionUpstream(t, gw, "example") + + token := signResourceToken(t, priv, testPublicBase+"/api/solutions/example/proxy/mcp") + request := httptest.NewRequest(http.MethodPost, "/solutions/example/mcp", nil) + request.Header.Set("Authorization", "Bearer "+token) + request.Header.Set("Cookie", "codefly_rt=a-refresh-token") + recorder := httptest.NewRecorder() + gw.ServeHTTP(recorder, request) + + require.Equal(t, http.StatusOK, recorder.Code) + require.Equal(t, "Bearer "+token, example.lastHeaders.Get("Authorization"), + "a resource-bound token is what the runtime authenticates the caller by") + require.Empty(t, example.lastHeaders.Get("Cookie"), + "the cookie is withheld on this path too, which it was not") +} + +// The anonymous module-federation surface is the one place the whole session +// credential is withheld, because it is the one place no ext_authz runs: a bearer +// arriving here was never authenticated for this upstream and the loader fetches +// these bytes with no credential at all. +func TestPublicSolutionPathForwardsNoCredential(t *testing.T) { + gw, _, _, _ := newGatewayHarness(t) + fake := registerSolutionUpstream(t, gw, "audit") + + request := httptest.NewRequest(http.MethodGet, "/solutions/audit/assets/remoteEntry.js", nil) + request.Header.Set("Authorization", "Bearer a-host-session-token") + request.Header.Set("Cookie", "codefly_rt=a-refresh-token") + recorder := httptest.NewRecorder() + gw.ServeHTTP(recorder, request) + + require.Equal(t, http.StatusOK, recorder.Code) + require.Empty(t, fake.lastHeaders.Get("Authorization")) + require.Empty(t, fake.lastHeaders.Get("Cookie")) +} + +// The host's own API is the one upstream where the session IS the credential, so +// neither removal may reach a catalog route. Without this control the assertions +// above would pass on a gateway that forwarded nothing to anybody. +func TestCatalogRouteStillCarriesTheSessionToAccounts(t *testing.T) { + gw, api, _, priv := newGatewayHarness(t) + + token := signValidToken(t, priv) + request := httptest.NewRequest(http.MethodGet, "/v1/users", nil) + request.Header.Set("Authorization", "Bearer "+token) + request.Header.Set("Cookie", "codefly_session=1") + recorder := httptest.NewRecorder() + gw.ServeHTTP(recorder, request) + + require.Equal(t, http.StatusOK, recorder.Code) + require.Equal(t, "Bearer "+token, api.lastHeaders.Get("Authorization")) + require.Equal(t, "codefly_session=1", api.lastHeaders.Get("Cookie")) +} diff --git a/module/services/auth-gateway/code/work_context.go b/module/services/auth-gateway/code/work_context.go index a49ca56bc..cdabbc7c7 100644 --- a/module/services/auth-gateway/code/work_context.go +++ b/module/services/auth-gateway/code/work_context.go @@ -9,6 +9,7 @@ import ( "strings" "time" + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" "github.com/codefly-dev/sdk-go/workcontext" ) @@ -89,26 +90,67 @@ const gatewayAudience = "auth-gateway" func (v *workContextVerifier) Verify( ctx context.Context, token workcontext.WorkContextToken, expected string, ) error { + _, err := v.VerifyAny(ctx, token, expected) + return err +} + +// VerifyAny is Verify for a route that accepts MORE THAN ONE audience, and it +// returns the verified claims so a caller can hold the capability to facts +// beyond its audience. +// +// `permitted` is the set of audiences the resolved route accepts. One entry is +// the ordinary case and is what Verify passes. A route with several is a route +// whose destination legitimately consumes capabilities of two different kinds — +// a declared module's own identity, and a viewer's delegated capability for it — +// and listing both is what keeps a single-valued expectation from refusing the +// one the caller actually holds (see verifiedModuleCapability). +// +// The audience comparison is made HERE rather than by passing a value to sdk-go, +// because sdk-go compares one string. An EMPTY `permitted` — no entry at all, or +// one empty entry, which is what the catalog routes pass — means "do not +// compare", and that is sdk-go's own sentinel (`check.want != ""` in +// workcontext/work_context.go) restated locally so the one reliance on it stays +// in one place and stays explicit. +func (v *workContextVerifier) VerifyAny( + ctx context.Context, token workcontext.WorkContextToken, permitted ...string, +) (*basev0.WorkContextV1, error) { keyID, err := workContextTokenKeyID(token) if err != nil { - return err + return nil, err } verifier, err := v.cache.resolve(ctx, keyID) if err != nil { - return err + return nil, err } - claims, err := verifier.Verify(token, workcontext.WorkContextExpectations{Audience: expected}) + claims, err := verifier.Verify(token, workcontext.WorkContextExpectations{}) if err != nil { - return invalidWorkContext(err) + return nil, invalidWorkContext(err) } // Refused AFTER the signature check, deliberately: an unsigned token claiming // the hop's audience is an invalid token, not a routing question, and answering // the audience refusal first would tell an unauthenticated caller which // audience the hop answers to. if claims.GetAudience() == gatewayAudience { - return fmt.Errorf("%w: a capability addressed to this forwarding hop is never consumed by it", workcontext.ErrWorkContextInvalid) + return nil, fmt.Errorf("%w: a capability addressed to this forwarding hop is never consumed by it", workcontext.ErrWorkContextInvalid) + } + wanted := make([]string, 0, len(permitted)) + for _, audience := range permitted { + if audience != "" { + wanted = append(wanted, audience) + } + } + if len(wanted) == 0 { + return claims, nil + } + for _, audience := range wanted { + if claims.GetAudience() == audience { + return claims, nil + } } - return nil + // The refusal names neither the presented audience nor the permitted set: the + // caller learns that the capability is not valid at this route, which is the + // whole verdict, and not which consumers this route serves. + return nil, fmt.Errorf("%w: the capability names a consumer this route does not serve", workcontext.ErrWorkContextInvalid) } func workContextTokenKeyID(token workcontext.WorkContextToken) (string, error) { diff --git a/module/services/frontend/AGENTS.md b/module/services/frontend/AGENTS.md index 0a257e52b..dc11c2293 100644 --- a/module/services/frontend/AGENTS.md +++ b/module/services/frontend/AGENTS.md @@ -179,6 +179,28 @@ A solution remote executes in the host origin with the viewer's credentials; the trust model and the registration/installation/entitlement boundary are in [../../SOLUTION_REGISTRATION.md](../../SOLUTION_REGISTRATION.md). +**Open, and recorded here so the next reader does not have to rediscover it.** +Nothing pins what a cross-origin manifest serves. An absolute URL on any origin is +accepted, admitting it adds that origin to the script source, and there is no +integrity digest — so a holder of a solution's registration credential can point a +registered remote at code of its choosing and have it execute in the host origin +with the viewer's session. The audit raised this as a minor finding because the +live policy lists no foreign origin and every deployed remote is same-origin today, +which bounds it to the credential-to-code path. + +Closing it means one of two things, and both are decisions about the solution +contract rather than a defect to repair: + +- **Accept only root-relative manifests.** This removes the cross-origin-remote + shape described above, and with it the runtime origin derivation in + `src/proxy.ts` — the listing fetch, its cache, its outage reporting and the + policy-size guard exist only to carry foreign origins. +- **Pin each remote by a digest recorded when it was published.** This keeps the + shape and needs a publication-time provenance record the registration contract + does not have. + +Either is its own change, with the owner choosing which shape the contract keeps. + ## The published client kit `code/packages/saas-sdk` is `@codefly-dev/saas-sdk`, published to GitHub Packages diff --git a/module/services/frontend/code/package-lock.json b/module/services/frontend/code/package-lock.json index 1cc6a8c4d..8e38d4a5f 100644 --- a/module/services/frontend/code/package-lock.json +++ b/module/services/frontend/code/package-lock.json @@ -15,7 +15,7 @@ "@bufbuild/protobuf": "^2.11.0", "@codefly-dev/saas-plugin-contract": "2.4.2", "@codefly-dev/saas-plugin-react": "0.4.3", - "@codefly-dev/saas-sdk": "0.3.23", + "@codefly-dev/saas-sdk": "0.3.25", "@codefly-dev/saas-ui": "0.12.7", "@codefly-dev/ui": "0.12.7", "@codefly/saas-plugin-manifest": "0.1.1", @@ -15347,7 +15347,7 @@ }, "packages/saas-sdk": { "name": "@codefly-dev/saas-sdk", - "version": "0.3.23", + "version": "0.3.25", "license": "Apache-2.0", "dependencies": { "@bufbuild/protobuf": "^2.11.0", diff --git a/module/services/frontend/code/package.json b/module/services/frontend/code/package.json index ca15dbe96..79d24462a 100644 --- a/module/services/frontend/code/package.json +++ b/module/services/frontend/code/package.json @@ -38,7 +38,7 @@ "@bufbuild/protobuf": "^2.11.0", "@codefly-dev/saas-plugin-contract": "2.4.2", "@codefly-dev/saas-plugin-react": "0.4.3", - "@codefly-dev/saas-sdk": "0.3.23", + "@codefly-dev/saas-sdk": "0.3.25", "@codefly-dev/saas-ui": "0.12.7", "@codefly-dev/ui": "0.12.7", "@codefly/saas-plugin-manifest": "0.1.1", diff --git a/module/services/frontend/code/packages/saas-sdk/generated/contract/catalog.codefly.json b/module/services/frontend/code/packages/saas-sdk/generated/contract/catalog.codefly.json index 459cb34de..43cbdf026 100644 --- a/module/services/frontend/code/packages/saas-sdk/generated/contract/catalog.codefly.json +++ b/module/services/frontend/code/packages/saas-sdk/generated/contract/catalog.codefly.json @@ -10,7 +10,7 @@ "kind": "protobuf", "package": "saas.accounts.v1", "path": "contract.binpb", - "digest": "sha256:e4fbe4fd6bbd4d025ec8b3978fca2afbe0a5597c9f01eacd2ee095305a1b61f4", + "digest": "sha256:045c67283ce0dbce7dc8ab3b21a9bb23b3e9d461fb51972853b6217128417869", "services": [ { "name": "APIKeyService", diff --git a/module/services/frontend/code/packages/saas-sdk/generated/contract/contract.binpb b/module/services/frontend/code/packages/saas-sdk/generated/contract/contract.binpb index ad7e5fb9e..28bc29cbd 100644 Binary files a/module/services/frontend/code/packages/saas-sdk/generated/contract/contract.binpb and b/module/services/frontend/code/packages/saas-sdk/generated/contract/contract.binpb differ diff --git a/module/services/frontend/code/packages/saas-sdk/generated/library.codefly.yaml b/module/services/frontend/code/packages/saas-sdk/generated/library.codefly.yaml index 6cc59629d..df0c01473 100644 --- a/module/services/frontend/code/packages/saas-sdk/generated/library.codefly.yaml +++ b/module/services/frontend/code/packages/saas-sdk/generated/library.codefly.yaml @@ -12,7 +12,7 @@ sources: module: saas-starter service: accounts endpoint: connect - contract-digest: sha256:e4fbe4fd6bbd4d025ec8b3978fca2afbe0a5597c9f01eacd2ee095305a1b61f4 + contract-digest: sha256:045c67283ce0dbce7dc8ab3b21a9bb23b3e9d461fb51972853b6217128417869 services: - APIKeyService - AccessibleScopeService diff --git a/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/api_keys_pb.ts b/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/api_keys_pb.ts index 90cbfd60d..94635b453 100644 --- a/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/api_keys_pb.ts +++ b/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/api_keys_pb.ts @@ -17,7 +17,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file saas/accounts/v1/api_keys.proto. */ export const file_saas_accounts_v1_api_keys: GenFile = /*@__PURE__*/ - fileDesc("Ch9zYWFzL2FjY291bnRzL3YxL2FwaV9rZXlzLnByb3RvEhBzYWFzLmFjY291bnRzLnYxIoYDCgZBUElLZXkSCgoCaWQYASABKAkSFwoPb3JnYW5pemF0aW9uX2lkGAIgASgJEg8KB3VzZXJfaWQYAyABKAkSDAoEbmFtZRgEIAEoCRIOCgZwcmVmaXgYBSABKAkSLAoGc2NvcGVzGAYgAygLMhwuc2Fhcy5hY2NvdW50cy52MS5QZXJtaXNzaW9uEjgKC2Vudmlyb25tZW50GAcgASgOMiMuc2Fhcy5hY2NvdW50cy52MS5BUElLZXlFbnZpcm9ubWVudBIuCgpjcmVhdGVkX2F0GAggASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIuCgpleHBpcmVzX2F0GAkgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIwCgxsYXN0X3VzZWRfYXQYCiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEi4KCnJldm9rZWRfYXQYCyABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wIvQBChNDcmVhdGVBUElLZXlSZXF1ZXN0EiEKD29yZ2FuaXphdGlvbl9pZBgBIAEoCUIIukgFcgOwAQESGAoEbmFtZRgCIAEoCUIKukgHcgUQARj/ARIsCgZzY29wZXMYAyADKAsyHC5zYWFzLmFjY291bnRzLnYxLlBlcm1pc3Npb24SOAoLZW52aXJvbm1lbnQYBCABKA4yIy5zYWFzLmFjY291bnRzLnYxLkFQSUtleUVudmlyb25tZW50EjgKCmV4cGlyZXNfYXQYBSABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wQgi6SAWyAQJAASJUChRDcmVhdGVBUElLZXlSZXNwb25zZRIlCgNrZXkYASABKAsyGC5zYWFzLmFjY291bnRzLnYxLkFQSUtleRIVCg1wbGFpbnRleHRfa2V5GAIgASgJImkKEkxpc3RBUElLZXlzUmVxdWVzdBIhCg9vcmdhbml6YXRpb25faWQYASABKAlCCLpIBXIDsAEBEhwKCXBhZ2Vfc2l6ZRgCIAEoBUIJukgGGgQYZCAAEhIKCnBhZ2VfdG9rZW4YAyABKAkiVgoTTGlzdEFQSUtleXNSZXNwb25zZRImCgRrZXlzGAEgAygLMhguc2Fhcy5hY2NvdW50cy52MS5BUElLZXkSFwoPbmV4dF9wYWdlX3Rva2VuGAIgASgJIk4KE1Jldm9rZUFQSUtleVJlcXVlc3QSFAoCaWQYASABKAlCCLpIBXIDsAEBEiEKD29yZ2FuaXphdGlvbl9pZBgCIAEoCUIIukgFcgOwAQEiLQoVVmFsaWRhdGVBUElLZXlSZXF1ZXN0EhQKA2tleRgBIAEoCUIHukgEcgIQASKdAgoWVmFsaWRhdGVBUElLZXlSZXNwb25zZRINCgV2YWxpZBgBIAEoCBIPCgd1c2VyX2lkGAIgASgJEhcKD29yZ2FuaXphdGlvbl9pZBgDIAEoCRIOCgZzY29wZXMYBCADKAkSEgoKd29ya3NwYWNlcxgFIAMoCRINCgVyb2xlcxgGIAMoCRJMCgphdHRyaWJ1dGVzGAcgAygLMjguc2Fhcy5hY2NvdW50cy52MS5WYWxpZGF0ZUFQSUtleVJlc3BvbnNlLkF0dHJpYnV0ZXNFbnRyeRIWCg5wcmluY2lwYWxfa2luZBgIIAEoCRoxCg9BdHRyaWJ1dGVzRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4ASp0ChFBUElLZXlFbnZpcm9ubWVudBIjCh9BUElfS0VZX0VOVklST05NRU5UX1VOU1BFQ0lGSUVEEAASHAoYQVBJX0tFWV9FTlZJUk9OTUVOVF9MSVZFEAESHAoYQVBJX0tFWV9FTlZJUk9OTUVOVF9URVNUEAIy8QUKDUFQSUtleVNlcnZpY2USzQEKDENyZWF0ZUFQSUtleRIlLnNhYXMuYWNjb3VudHMudjEuQ3JlYXRlQVBJS2V5UmVxdWVzdBomLnNhYXMuYWNjb3VudHMudjEuQ3JlYXRlQVBJS2V5UmVzcG9uc2UibsLzGFMIAhAEGg5hcGlfa2V5czp3cml0ZSoVCg9vcmdhbml6YXRpb25faWQQAhgBMAE6GAoUc2Fhcy5hcGlfa2V5LmNyZWF0ZWQQAkABSARQA1gEYAF4AoLT5JMCEToBKiIML3YxL2FwaS1rZXlzEr0BCgtMaXN0QVBJS2V5cxIkLnNhYXMuYWNjb3VudHMudjEuTGlzdEFQSUtleXNSZXF1ZXN0GiUuc2Fhcy5hY2NvdW50cy52MS5MaXN0QVBJS2V5c1Jlc3BvbnNlImHC8xhJCAIQAxoNYXBpX2tleXM6cmVhZCINYXBpX2tleXM6cmVhZCoVCg9vcmdhbml6YXRpb25faWQQAhgBMAE6AhABQAFIA1ADWANgAYLT5JMCDhIML3YxL2FwaS1rZXlzEtABCgxSZXZva2VBUElLZXkSJS5zYWFzLmFjY291bnRzLnYxLlJldm9rZUFQSUtleVJlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHkigAHC8xhjCAIQBBoOYXBpX2tleXM6d3JpdGUiDmFwaV9rZXlzOndyaXRlKhUKD29yZ2FuaXphdGlvbl9pZBACGAEwAToYChRzYWFzLmFwaV9rZXkucmV2b2tlZBACQAFIBFADWANgAXgCgtPkkwITKhEvdjEvYXBpLWtleXMve2lkfRJ9Cg5WYWxpZGF0ZUFQSUtleRInLnNhYXMuYWNjb3VudHMudjEuVmFsaWRhdGVBUElLZXlSZXF1ZXN0Giguc2Fhcy5hY2NvdW50cy52MS5WYWxpZGF0ZUFQSUtleVJlc3BvbnNlIhjC8xgUCAMQATABOgIQAUABSAdQBFgDYAFiBnByb3RvMw", [file_buf_validate_validate, file_google_api_annotations, file_google_protobuf_empty, file_google_protobuf_timestamp, file_saas_accounts_v1_common, file_saas_policy_v1_options]); + fileDesc("Ch9zYWFzL2FjY291bnRzL3YxL2FwaV9rZXlzLnByb3RvEhBzYWFzLmFjY291bnRzLnYxIoYDCgZBUElLZXkSCgoCaWQYASABKAkSFwoPb3JnYW5pemF0aW9uX2lkGAIgASgJEg8KB3VzZXJfaWQYAyABKAkSDAoEbmFtZRgEIAEoCRIOCgZwcmVmaXgYBSABKAkSLAoGc2NvcGVzGAYgAygLMhwuc2Fhcy5hY2NvdW50cy52MS5QZXJtaXNzaW9uEjgKC2Vudmlyb25tZW50GAcgASgOMiMuc2Fhcy5hY2NvdW50cy52MS5BUElLZXlFbnZpcm9ubWVudBIuCgpjcmVhdGVkX2F0GAggASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIuCgpleHBpcmVzX2F0GAkgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIwCgxsYXN0X3VzZWRfYXQYCiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEi4KCnJldm9rZWRfYXQYCyABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wIvQBChNDcmVhdGVBUElLZXlSZXF1ZXN0EiEKD29yZ2FuaXphdGlvbl9pZBgBIAEoCUIIukgFcgOwAQESGAoEbmFtZRgCIAEoCUIKukgHcgUQARj/ARIsCgZzY29wZXMYAyADKAsyHC5zYWFzLmFjY291bnRzLnYxLlBlcm1pc3Npb24SOAoLZW52aXJvbm1lbnQYBCABKA4yIy5zYWFzLmFjY291bnRzLnYxLkFQSUtleUVudmlyb25tZW50EjgKCmV4cGlyZXNfYXQYBSABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wQgi6SAWyAQJAASJUChRDcmVhdGVBUElLZXlSZXNwb25zZRIlCgNrZXkYASABKAsyGC5zYWFzLmFjY291bnRzLnYxLkFQSUtleRIVCg1wbGFpbnRleHRfa2V5GAIgASgJImkKEkxpc3RBUElLZXlzUmVxdWVzdBIhCg9vcmdhbml6YXRpb25faWQYASABKAlCCLpIBXIDsAEBEhwKCXBhZ2Vfc2l6ZRgCIAEoBUIJukgGGgQYZCAAEhIKCnBhZ2VfdG9rZW4YAyABKAkiVgoTTGlzdEFQSUtleXNSZXNwb25zZRImCgRrZXlzGAEgAygLMhguc2Fhcy5hY2NvdW50cy52MS5BUElLZXkSFwoPbmV4dF9wYWdlX3Rva2VuGAIgASgJIk4KE1Jldm9rZUFQSUtleVJlcXVlc3QSFAoCaWQYASABKAlCCLpIBXIDsAEBEiEKD29yZ2FuaXphdGlvbl9pZBgCIAEoCUIIukgFcgOwAQEiLQoVVmFsaWRhdGVBUElLZXlSZXF1ZXN0EhQKA2tleRgBIAEoCUIHukgEcgIQASKdAgoWVmFsaWRhdGVBUElLZXlSZXNwb25zZRINCgV2YWxpZBgBIAEoCBIPCgd1c2VyX2lkGAIgASgJEhcKD29yZ2FuaXphdGlvbl9pZBgDIAEoCRIOCgZzY29wZXMYBCADKAkSEgoKd29ya3NwYWNlcxgFIAMoCRINCgVyb2xlcxgGIAMoCRJMCgphdHRyaWJ1dGVzGAcgAygLMjguc2Fhcy5hY2NvdW50cy52MS5WYWxpZGF0ZUFQSUtleVJlc3BvbnNlLkF0dHJpYnV0ZXNFbnRyeRIWCg5wcmluY2lwYWxfa2luZBgIIAEoCRoxCg9BdHRyaWJ1dGVzRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4ASp0ChFBUElLZXlFbnZpcm9ubWVudBIjCh9BUElfS0VZX0VOVklST05NRU5UX1VOU1BFQ0lGSUVEEAASHAoYQVBJX0tFWV9FTlZJUk9OTUVOVF9MSVZFEAESHAoYQVBJX0tFWV9FTlZJUk9OTUVOVF9URVNUEAIy8QUKDUFQSUtleVNlcnZpY2USzQEKDENyZWF0ZUFQSUtleRIlLnNhYXMuYWNjb3VudHMudjEuQ3JlYXRlQVBJS2V5UmVxdWVzdBomLnNhYXMuYWNjb3VudHMudjEuQ3JlYXRlQVBJS2V5UmVzcG9uc2UibsLzGFMIAhAEGg5hcGlfa2V5czp3cml0ZSoVCg9vcmdhbml6YXRpb25faWQQAhgBMAQ6GAoUc2Fhcy5hcGlfa2V5LmNyZWF0ZWQQAkABSARQA1gEYAF4AoLT5JMCEToBKiIML3YxL2FwaS1rZXlzEr0BCgtMaXN0QVBJS2V5cxIkLnNhYXMuYWNjb3VudHMudjEuTGlzdEFQSUtleXNSZXF1ZXN0GiUuc2Fhcy5hY2NvdW50cy52MS5MaXN0QVBJS2V5c1Jlc3BvbnNlImHC8xhJCAIQAxoNYXBpX2tleXM6cmVhZCINYXBpX2tleXM6cmVhZCoVCg9vcmdhbml6YXRpb25faWQQAhgBMAE6AhABQAFIA1ADWANgAYLT5JMCDhIML3YxL2FwaS1rZXlzEtABCgxSZXZva2VBUElLZXkSJS5zYWFzLmFjY291bnRzLnYxLlJldm9rZUFQSUtleVJlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHkigAHC8xhjCAIQBBoOYXBpX2tleXM6d3JpdGUiDmFwaV9rZXlzOndyaXRlKhUKD29yZ2FuaXphdGlvbl9pZBACGAEwAToYChRzYWFzLmFwaV9rZXkucmV2b2tlZBACQAFIBFADWANgAXgCgtPkkwITKhEvdjEvYXBpLWtleXMve2lkfRJ9Cg5WYWxpZGF0ZUFQSUtleRInLnNhYXMuYWNjb3VudHMudjEuVmFsaWRhdGVBUElLZXlSZXF1ZXN0Giguc2Fhcy5hY2NvdW50cy52MS5WYWxpZGF0ZUFQSUtleVJlc3BvbnNlIhjC8xgUCAMQATABOgIQAUABSAdQBFgDYAFiBnByb3RvMw", [file_buf_validate_validate, file_google_api_annotations, file_google_protobuf_empty, file_google_protobuf_timestamp, file_saas_accounts_v1_common, file_saas_policy_v1_options]); /** * @generated from message saas.accounts.v1.APIKey diff --git a/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/installations_pb.ts b/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/installations_pb.ts index cba907611..da9ec3898 100644 --- a/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/installations_pb.ts +++ b/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/installations_pb.ts @@ -169,7 +169,16 @@ export type InstallSolutionRequest = Message<"saas.accounts.v1.InstallSolutionRe /** * The agent's delegated-execution ceiling (migration 108): the Work Context * audiences it may be minted for and the resource kinds it may ever touch. - * Empty = unrestricted. + * + * Empty = unrestricted FOR THIS INSTALLATION'S OWN WORK, which is what an + * administrator consented to when they installed it. It is NOT unrestricted + * for a mint made with a credential delivered to a THIRD PARTY on a person's + * behalf — an MCP client the person authorized for this one solution. There + * the installation is the only statement of what the solution may act for, so + * an empty list is read as nothing and such a mint is REFUSED by name + * (accounts' holdConfinedMintToItsConsumer). The two readings are opposite on + * purpose: one bounds a consumer an admin trusts, the other bounds a + * credential they never saw. * * @generated from field: repeated string allowed_audiences = 10; */ diff --git a/module/services/frontend/code/packages/saas-sdk/package.json b/module/services/frontend/code/packages/saas-sdk/package.json index cc661917b..fbc4f3c3d 100644 --- a/module/services/frontend/code/packages/saas-sdk/package.json +++ b/module/services/frontend/code/packages/saas-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@codefly-dev/saas-sdk", - "version": "0.3.23", + "version": "0.3.25", "description": "Typed Connect client, gateway-bound facades, and audit data-graph tooling for the saas-starter public API.", "repository": { "type": "git", diff --git a/module/services/frontend/code/server/security-headers.mjs b/module/services/frontend/code/server/security-headers.mjs index 04c42867d..7b8825d36 100644 --- a/module/services/frontend/code/server/security-headers.mjs +++ b/module/services/frontend/code/server/security-headers.mjs @@ -267,6 +267,22 @@ export function contentSecurityPolicy( export function baselineSecurityHeaders() { return [ { key: "Cross-Origin-Opener-Policy", value: "same-origin" }, + // Pin the browser to HTTPS for this origin. The edge redirects plaintext to + // TLS, which protects the redirect and nothing before it: the FIRST request a + // browser makes to a host it has never pinned goes out in the clear, with + // whatever the address bar or a link gave it, and an attacker on the path + // answers it. One year is the conventional floor for a policy a browser is + // asked to remember. + // + // Deliberately WITHOUT includeSubDomains or preload. Both commit hosts this + // service does not own — a sibling subdomain served over plaintext breaks + // under the first, and the second is effectively irreversible — so they are + // the ingress operator's to add once they know the host tree. Browsers ignore + // the header on a plaintext origin, so local development is unaffected. + { + key: "Strict-Transport-Security", + value: "max-age=31536000", + }, { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()", diff --git a/module/services/frontend/code/src/app/(auth)/oauth2/authorize/route.ts b/module/services/frontend/code/src/app/(auth)/oauth2/authorize/route.ts index dc856e87c..3aacb45f8 100644 --- a/module/services/frontend/code/src/app/(auth)/oauth2/authorize/route.ts +++ b/module/services/frontend/code/src/app/(auth)/oauth2/authorize/route.ts @@ -10,7 +10,6 @@ import { } from "@/features/auth/model/oauth-authorization"; import { resolveCodeflyGatewayContext } from "@/lib/codefly-gateway-context"; import { INTERNAL_TOKEN_HEADER } from "@/lib/internal-token"; -import { requestPublicOrigin } from "@/lib/public-origin"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; @@ -66,13 +65,14 @@ export async function GET(request: Request): Promise { } const headers = new Headers({ "content-type": "application/json" }); - // The authorization server needs to know which origin it is being reached - // at, because every URL it publishes names it. That claim is only trusted - // beside the cluster-internal token, which this process holds and a browser - // does not — the same pair src/proxy.ts stamps on product API traffic. - const gatewayContext = resolveCodeflyGatewayContext( - requestPublicOrigin(request), - ); + // The authorization server needs to know which origin it is being reached at, + // because every URL it publishes names it — the issuer in its metadata, the + // redirect it binds a code to, the link it emails. That origin is OPERATOR + // CONFIGURATION and is resolved from it here, never read off this request: the + // internal token stamped beside it proves which PROCESS is asking, which is + // not the same as the origin being this host's, so a caller-supplied host + // travelling under that token would have been published as the issuer's. + const gatewayContext = resolveCodeflyGatewayContext(); if (gatewayContext) { headers.set(INTERNAL_TOKEN_HEADER, gatewayContext.internalToken); headers.set(PUBLIC_ORIGIN_HEADER, gatewayContext.publicOrigin); diff --git a/module/services/frontend/code/src/app/api/solutions/[id]/proxy/[...path]/__tests__/route.test.ts b/module/services/frontend/code/src/app/api/solutions/[id]/proxy/[...path]/__tests__/route.test.ts index d3c7be853..0e5ecb70e 100644 --- a/module/services/frontend/code/src/app/api/solutions/[id]/proxy/[...path]/__tests__/route.test.ts +++ b/module/services/frontend/code/src/app/api/solutions/[id]/proxy/[...path]/__tests__/route.test.ts @@ -3,26 +3,30 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("server-only", () => ({})); // The route resolves the gateway base (getEndpoints) and the first-party trust -// context (getCurrentModule/getCurrentService/getWorkspaceSecret, via -// resolveCodeflyGatewayContext). vi.mock is hoisted above module init, so the -// stubs are created with vi.hoisted. +// context (getCurrentModule/getCurrentService/getWorkspaceSecret/ +// getWorkspaceConfiguration, via resolveCodeflyGatewayContext). vi.mock is +// hoisted above module init, so the stubs are created with vi.hoisted. const { getEndpoints, getCurrentModule, getCurrentService, getWorkspaceSecret, + getWorkspaceConfiguration, } = vi.hoisted(() => ({ getEndpoints: vi.fn<() => Array>>(), getCurrentModule: vi.fn<() => string>(() => ""), getCurrentService: vi.fn<() => string>(() => ""), getWorkspaceSecret: vi.fn<(name: string, key: string) => string | undefined>(), + getWorkspaceConfiguration: + vi.fn<(name: string, key: string) => string | undefined>(), })); vi.mock("codefly", () => ({ getEndpoints, getCurrentModule, getCurrentService, getWorkspaceSecret, + getWorkspaceConfiguration, })); // The registry is a durable, gateway-brokered record rather than a map in this @@ -48,10 +52,10 @@ function withGateway() { ]); } -// Resolve the first-party trust context the way a real runtime does: no -// discoverable own endpoint, so resolveCodeflyGatewayContext falls back to the -// request origin for the public origin and reads the internal token from config. -function withTrustContext() { +// Resolve the first-party trust context the way a real runtime does: the public +// origin comes from the deployment's own configuration and from nothing else, so a +// test that wants a different one configures it rather than sending a header. +function withTrustContext(publicOrigin = "http://frontend") { getCurrentModule.mockReturnValue(""); getCurrentService.mockReturnValue(""); getWorkspaceSecret.mockImplementation((name, key) => @@ -59,6 +63,9 @@ function withTrustContext() { ? INTERNAL_TOKEN : undefined, ); + getWorkspaceConfiguration.mockImplementation((name, key) => + name === "application" && key === "APP_BASE_URL" ? publicOrigin : undefined, + ); } function registerAudit(serviceAlias = "audit-backend") { @@ -113,6 +120,14 @@ beforeEach(() => { getCurrentModule.mockReturnValue(""); getCurrentService.mockReturnValue(""); getWorkspaceSecret.mockReturnValue(undefined); + // The public origin is operator configuration, and the same-origin check is made + // against it, so every fixture pins the one these requests arrive on. A test + // about the origin itself overrides it (withTrustContext). + getWorkspaceConfiguration.mockImplementation((name, key) => + name === "application" && key === "APP_BASE_URL" + ? "http://frontend" + : undefined, + ); findSolution.mockResolvedValue(null); fetchMock = vi.fn( async () => @@ -128,6 +143,7 @@ afterEach(() => { findSolution.mockReset(); getEndpoints.mockReset(); getCurrentModule.mockReset(); + getWorkspaceConfiguration.mockReset(); getCurrentService.mockReset(); getWorkspaceSecret.mockReset(); vi.unstubAllGlobals(); @@ -354,10 +370,12 @@ describe("solution proxy passthrough", () => { // Behind a TLS-terminating ingress the pod sees plaintext http and its own // host, while the browser's Origin is the public https origin. A same-origin - // write (every Connect call and chat turn is a POST) must still pass. + // write (every Connect call and chat turn is a POST) must still pass — on the + // strength of the CONFIGURED origin, which is the only thing that knows what + // the browser sees. it("allows a same-origin POST behind a TLS-terminating ingress", async () => { withGateway(); - withTrustContext(); + withTrustContext("https://app.example.com"); registerAudit(); const res = await POST( @@ -365,8 +383,6 @@ describe("solution proxy passthrough", () => { authorization: "Bearer caller-token", origin: "https://app.example.com", "sec-fetch-site": "same-origin", - "x-forwarded-proto": "https", - "x-forwarded-host": "app.example.com", }), context("audit", ["records"]), ); @@ -377,14 +393,53 @@ describe("solution proxy passthrough", () => { it("rejects a cross-site Origin behind a TLS-terminating ingress", async () => { withGateway(); - withTrustContext(); + withTrustContext("https://app.example.com"); + registerAudit(); + + const res = await POST( + rawRequest("http://frontend/api/solutions/audit/proxy/records", "POST", { + origin: "https://evil.example", + }), + context("audit", ["records"]), + ); + + expect(res.status).toBe(403); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + // A comparison is only a check when one side is not the caller's to choose: the + // origin compared against is the configured one, so supplying forwarding headers + // alongside an Origin changes nothing about the answer. + it("rejects a forged cross-site request that supplies its own forwarded host", async () => { + withGateway(); + withTrustContext("https://app.example.com"); registerAudit(); const res = await POST( rawRequest("http://frontend/api/solutions/audit/proxy/records", "POST", { origin: "https://evil.example", "x-forwarded-proto": "https", - "x-forwarded-host": "app.example.com", + "x-forwarded-host": "evil.example", + }), + context("audit", ["records"]), + ); + + expect(res.status).toBe(403); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + // And with no configured origin there is nothing to compare against, so the + // route refuses rather than admitting on an absent comparison. + it("refuses every request when the deployment has no configured public origin", async () => { + withGateway(); + withTrustContext(); + getWorkspaceConfiguration.mockReturnValue(undefined); + registerAudit(); + + const res = await POST( + rawRequest("http://frontend/api/solutions/audit/proxy/records", "POST", { + authorization: "Bearer caller-token", + "sec-fetch-site": "same-origin", }), context("audit", ["records"]), ); @@ -411,8 +466,8 @@ describe("solution proxy passthrough", () => { const forwarded = fetchMock.mock.calls[0][1].headers as Headers; expect(forwarded.has("x-secret-smuggle")).toBe(false); - // x-forwarded-host is consumed to derive the public origin, never relayed - // to the upstream as a raw header. + // x-forwarded-host decides nothing here any more, and is never relayed to the + // upstream as a raw header either. expect(forwarded.has("x-forwarded-host")).toBe(false); }); @@ -437,15 +492,17 @@ describe("solution proxy passthrough", () => { it("attaches the first-party trust headers resolved from config", async () => { withGateway(); - withTrustContext(); + withTrustContext("https://app.example"); registerAudit(); await GET( proxyRequest("http://frontend/api/solutions/audit/proxy/records", { headers: { authorization: "Bearer caller-token", + // Offered by the caller and ignored: the stamped origin is the + // configured one, whatever the request says. "x-forwarded-proto": "https", - "x-forwarded-host": "app.example", + "x-forwarded-host": "evil.example", }, }), context("audit", ["records"]), diff --git a/module/services/frontend/code/src/app/api/solutions/[id]/proxy/[...path]/__tests__/stream.test.ts b/module/services/frontend/code/src/app/api/solutions/[id]/proxy/[...path]/__tests__/stream.test.ts index a109704f0..699161afe 100644 --- a/module/services/frontend/code/src/app/api/solutions/[id]/proxy/[...path]/__tests__/stream.test.ts +++ b/module/services/frontend/code/src/app/api/solutions/[id]/proxy/[...path]/__tests__/stream.test.ts @@ -14,6 +14,12 @@ vi.mock("codefly", () => ({ getCurrentModule: () => "", getCurrentService: () => "", getWorkspaceSecret: () => "fixture-internal", + // The public origin is operator configuration, and the same-origin check is + // made against it, so a fixture deployment has to pin one. + getWorkspaceConfiguration: (group: string, key: string) => + group === "application" && key === "APP_BASE_URL" + ? "http://localhost" + : undefined, })); vi.mock("@/solutions/registry", () => ({ findSolution })); diff --git a/module/services/frontend/code/src/app/api/solutions/[id]/proxy/[...path]/route.ts b/module/services/frontend/code/src/app/api/solutions/[id]/proxy/[...path]/route.ts index a2b6228b8..750f8b069 100644 --- a/module/services/frontend/code/src/app/api/solutions/[id]/proxy/[...path]/route.ts +++ b/module/services/frontend/code/src/app/api/solutions/[id]/proxy/[...path]/route.ts @@ -1,8 +1,11 @@ import { getEndpoints } from "codefly"; -import { resolveCodeflyGatewayContext } from "@/lib/codefly-gateway-context"; +import { + resolveCodeflyGatewayContext, + resolveVerifiedPublicOrigin, +} from "@/lib/codefly-gateway-context"; import { INTERNAL_TOKEN_HEADER } from "@/lib/internal-token"; -import { requestPublicOrigin } from "@/lib/public-origin"; + import { findSolution } from "@/solutions/registry"; export const dynamic = "force-dynamic"; @@ -19,14 +22,21 @@ interface RouteContext { // internal token, so it must reject cross-site requests itself — cookies ride // along automatically on a forged cross-origin call, and the gateway cannot tell // a CSRF-driven request from a legitimate one once the internal token is -// attached. The browser's Origin is compared with the request's PUBLIC origin: -// behind a TLS-terminating ingress the pod-local request URL is plaintext http, -// so comparing with it rejected every same-origin write a deployed cell served. -function sameOrigin(request: Request): boolean { +// attached. +// +// The browser's Origin is compared with this deployment's CONFIGURED public +// origin, which comes from operator configuration. The comparison is only a check +// when one side is not the caller's to choose, so the side it compares against is +// never taken from the request. +// +// A deployment with no verified public origin refuses: there is nothing to compare +// against, and admitting on that basis is the same defect by omission. +function sameOrigin(request: Request, hostOrigin: string | undefined): boolean { + if (!hostOrigin) return false; const origin = request.headers.get("origin"); if (origin) { try { - if (new URL(origin).origin !== requestPublicOrigin(request)) return false; + if (new URL(origin).origin !== hostOrigin) return false; } catch { return false; } @@ -74,7 +84,11 @@ async function handler( ): Promise { const { id, path } = await context.params; - if (!sameOrigin(request)) { + // The origin is resolved on its own, not off the trust context: a deployment + // missing the internal token is a different condition from one with no verified + // origin, and refusing a same-origin request for the first would be answering a + // question nobody asked. + if (!sameOrigin(request, resolveVerifiedPublicOrigin())) { return new Response("cross-origin request rejected", { status: 403 }); } @@ -119,9 +133,7 @@ async function handler( // First-party trust headers, resolved server-side from Codefly config — the // gateway rejects solution traffic that lacks them even with a valid user // identity. Set from a fresh Headers so a caller can never spoof them. - const gatewayContext = resolveCodeflyGatewayContext( - requestPublicOrigin(request), - ); + const gatewayContext = resolveCodeflyGatewayContext(); if (gatewayContext) { headers.set(INTERNAL_TOKEN_HEADER, gatewayContext.internalToken); headers.set(PUBLIC_ORIGIN_HEADER, gatewayContext.publicOrigin); diff --git a/module/services/frontend/code/src/app/api/solutions/surfaces/__tests__/route.test.ts b/module/services/frontend/code/src/app/api/solutions/surfaces/__tests__/route.test.ts index ac116a278..2c2a443c8 100644 --- a/module/services/frontend/code/src/app/api/solutions/surfaces/__tests__/route.test.ts +++ b/module/services/frontend/code/src/app/api/solutions/surfaces/__tests__/route.test.ts @@ -5,12 +5,21 @@ vi.mock("server-only", () => ({})); // The registry reaches the gateway through service discovery and reads the // cluster-internal secret through the Codefly SDK; vi.mock is hoisted above // module init, so the stubs must be created with vi.hoisted. -const { getWorkspaceSecret, getEndpoints } = vi.hoisted(() => ({ - getWorkspaceSecret: - vi.fn<(name: string, key: string) => string | undefined>(), - getEndpoints: vi.fn<() => Array>>(() => []), +const { getWorkspaceSecret, getEndpoints, getWorkspaceConfiguration } = + vi.hoisted(() => ({ + getWorkspaceSecret: + vi.fn<(name: string, key: string) => string | undefined>(), + getEndpoints: vi.fn<() => Array>>(() => []), + getWorkspaceConfiguration: + vi.fn<(name: string, key: string) => string | undefined>(), + })); +vi.mock("codefly", () => ({ + getWorkspaceSecret, + getEndpoints, + getWorkspaceConfiguration, + getCurrentModule: () => "", + getCurrentService: () => "", })); -vi.mock("codefly", () => ({ getWorkspaceSecret, getEndpoints })); import { GET } from "@/app/api/solutions/surfaces/route"; diff --git a/module/services/frontend/code/src/app/api/solutions/surfaces/route.ts b/module/services/frontend/code/src/app/api/solutions/surfaces/route.ts index 0d9ad6231..31337bcd1 100644 --- a/module/services/frontend/code/src/app/api/solutions/surfaces/route.ts +++ b/module/services/frontend/code/src/app/api/solutions/surfaces/route.ts @@ -1,4 +1,4 @@ -import { requestPublicOrigin } from "@/lib/public-origin"; +import { configuredPublicOrigin } from "@/lib/public-origin"; import { entitlementFailureResponse, isEntitlementFailure, @@ -72,12 +72,14 @@ export async function GET(request: Request): Promise { if (registered === "unavailable") { return Response.json({ error: "registry_unavailable" }, { status: 503 }); } - // A solution served through this host resolves its modules against this - // host's own public origin (see surfacesProjection). - const hostOrigin = requestPublicOrigin(request); - // The origin is part of the projection, so it is part of what may be reused: - // two callers reaching this host through different public origins must not - // share a cached answer that resolves a module against one of them. + // A solution served through this host resolves its modules against this host's + // own public origin (see surfacesProjection) — the configured one, not one + // taken from the request: a client told to fetch a remote's modules from a + // caller-chosen host would load code from it. + const hostOrigin = configuredPublicOrigin(); + // The origin stays in the cache key. It no longer varies per caller, so this + // is not what separates two callers any more; it is what stops a reconfigured + // origin being served from an entry computed against the previous one. const solutions = cachedProjection( entitlements, `${client}\u0000${hostOrigin ?? ""}`, diff --git a/module/services/frontend/code/src/components/__tests__/account-menu-sign-out.test.tsx b/module/services/frontend/code/src/components/__tests__/account-menu-sign-out.test.tsx new file mode 100644 index 000000000..1d9702894 --- /dev/null +++ b/module/services/frontend/code/src/components/__tests__/account-menu-sign-out.test.tsx @@ -0,0 +1,104 @@ +// THE ACCOUNT MENU's "Sign out" — the primary sign-out control, on every +// dashboard and admin page — takes the shared sign-out journey. +// +// It is the control most people actually use, and it was the one left behind: +// `onClick={logout}` handed the promise to a menu item, which discards it, so an +// incomplete sign-out looked finished and the route shell then sent the person +// to the sign-in page as though the session had ended. Local state is cleared +// either way, which is what made the wrong answer invisible — the app looks +// signed out while an already-issued access token stays live until it expires. +// +// Asserted behaviourally, on where the person is SENT, because that is the whole +// difference between the two outcomes. The structural sweep beside this +// (sign-out-controls-contract.test.ts) is what finds the next control; this is +// what proves the journey runs for this one. +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, expect, it, vi } from "vitest"; +import { AppearanceProvider } from "@/lib/appearance-provider"; +import { FrontendConfigProvider } from "@/lib/providers"; + +const routerReplace = vi.hoisted(() => vi.fn()); +vi.mock("next/navigation", () => ({ + usePathname: () => "/", + useRouter: () => ({ push: vi.fn(), replace: routerReplace }), + notFound: () => { + throw new Error("not found"); + }, +})); +const logout = vi.hoisted(() => vi.fn(async () => ({ complete: false }))); +vi.mock("@/lib/auth", () => ({ + useAuth: () => ({ + user: { id: "user-1", email: "jane@example.com", name: "Jane Doe" }, + isAuthenticated: true, + isLoading: false, + platformRole: undefined, + orgRole: "admin", + logout, + getToken: () => "token", + }), +})); +vi.mock("@/features/notifications/ui/notification-bell", () => ({ NotificationBell: () => null })); +vi.mock("@/features/notifications/ui/notification-banner", () => ({ NotificationBanner: () => null })); +vi.mock("@/features/organizations/ui/org-switcher", () => ({ OrgSwitcher: () => null })); +vi.mock("@/components/theme-toggle", () => ({ ThemeToggle: () => null })); +vi.mock("@/components/rate-limit-banner", () => ({ RateLimitBanner: () => null })); + +import { AdminLayout } from "@/components/admin-layout"; +import applicationFrontendConfig from "../../../frontend.config"; + +function Wrap({ children }: { children: ReactNode }) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return ( + + + {children} + + + ); +} + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); + vi.unstubAllGlobals(); +}); + +it("an incomplete sign-out from the account menu goes to the retry page", async () => { + const replace = vi.fn(); + vi.stubGlobal("location", { ...window.location, replace }); + render( + + content + , + ); + const triggers = screen.getAllByText(/Jane Doe|jane@example.com/); + fireEvent.click(triggers[triggers.length - 1]); + const item = await screen.findByText("Sign out"); + fireEvent.click(item); + await waitFor(() => expect(logout).toHaveBeenCalled()); + await waitFor(() => expect(replace).toHaveBeenCalledWith("/auth/logout")); + // And NOT to the page a finished sign-out lands on, which is where the route + // shell sends an unauthenticated viewer: the two are indistinguishable to + // the person, and only one of them is true. + expect(routerReplace).not.toHaveBeenCalledWith("/auth/login"); +}); + +// THE CONTROL. Without it the assertion above would also pass on a menu that +// sent every sign-out to the retry page, which presents a finished sign-out as +// unfinished — the same defect in the other direction. +it("a complete sign-out from the account menu goes to the signed-out page", async () => { + logout.mockResolvedValueOnce({ complete: true }); + const replace = vi.fn(); + vi.stubGlobal("location", { ...window.location, replace }); + render( + + content + , + ); + const triggers = screen.getAllByText(/Jane Doe|jane@example.com/); + fireEvent.click(triggers[triggers.length - 1]); + fireEvent.click(await screen.findByText("Sign out")); + await waitFor(() => expect(replace).toHaveBeenCalledWith("/auth/login")); +}); diff --git a/module/services/frontend/code/src/components/__tests__/sign-out-controls-contract.test.ts b/module/services/frontend/code/src/components/__tests__/sign-out-controls-contract.test.ts new file mode 100644 index 000000000..4c8d12d8d --- /dev/null +++ b/module/services/frontend/code/src/components/__tests__/sign-out-controls-contract.test.ts @@ -0,0 +1,210 @@ +import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +// Where the sweep's own probes are written. Outside the tree, so a probe can +// never be mistaken for a control by the very sweep under test. +const sweepProbeDirectory = mkdtempSync(join(tmpdir(), "sign-out-sweep-")); + +// Every user-facing sign-out control goes through the shared journey. +// +// This is a STRUCTURAL assertion, and says so: it reads source rather than +// behaviour, which proves only that no control calls `logout()` for itself — +// not that the journey is right. The journey's own behaviour is asserted in +// src/lib/__tests__/sign-out-journey.test.ts, and real controls are driven in +// consent-banner.test.tsx and account-menu-sign-out.test.tsx. This exists for +// the failure those cannot catch: the NEXT sign-out control, added months from +// now, quietly calling `logout()` and redirecting wherever it likes. +// +// It used to carry a FIXED LIST of five files, and that is how the account menu +// — the control most people actually use — went unfixed through two review +// rounds. The list named every control somebody had thought of; the one it did +// not name was invisible, and the regex it paired with the list matched only +// `await logout()` / `void logout()`, so the menu's `onClick={logout}` would not +// have been caught even if the file had been listed. A sweep whose membership is +// a judgement call tests the judgement, not the codebase. +// +// So the subject is DERIVED: every file that takes `logout` out of `useAuth()` +// is a sign-out control, found by reading the tree. Nothing has to remember to +// add one. + +/** Every source file under src/, excluding tests and generated clients. */ +function sourceFiles(): string[] { + return readdirSync("src", { recursive: true, encoding: "utf8" }) + .map((entry) => `src/${entry}`) + .filter((path) => path.endsWith(".ts") || path.endsWith(".tsx")) + .filter((path) => !path.includes("__tests__")) + .filter((path) => !path.endsWith(".test.ts") && !path.endsWith(".test.tsx")) + .filter((path) => !path.startsWith("src/gen/")) + .sort(); +} + +/** + * The three files that are not controls and may call `logout` for themselves: + * the auth provider, which DEFINES it; the journey, which IS the shared route; + * and the logout page, which is where the journey SENDS an incomplete sign-out + * and therefore owns the retry. The provider is held to its own rule below + * rather than exempted outright. + */ +const NOT_A_CONTROL = [ + "src/lib/auth.tsx", + "src/lib/sign-out-journey.ts", + "src/features/auth/ui/logout-page.tsx", +]; + +/** + * Source with comments and string literals removed. + * + * A structural sweep that reads raw text matches things that are not code. Two + * false readings, both found by running it: the comment beside the account-menu + * fix quotes the `onClick={logout}` it replaced, and the command palette's + * search keywords are the literal string "sign out logout". Neither is a use of + * anything. Nothing here should depend on how a defect is described or on what a + * control calls itself. + */ +function code(path: string): string { + return readFileSync(path, "utf8") + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/^[^\n]*?\/\/.*$/gm, (line) => (line.includes("://") ? line : "")) + .replace(/"(?:[^"\\\n]|\\.)*"/g, '""') + .replace(/'(?:[^'\\\n]|\\.)*'/g, "''") + .replace(/`(?:[^`\\]|\\.)*`/g, "``"); +} + +/** + * The local name(s) a file binds the auth context's sign-out to. + * + * There are three spellings, and the first version of this sweep saw one: + * + * const { logout } = useAuth() -> "logout" + * const { logout: signOut } = useAuth() -> "signOut" + * const auth = useAuth() -> "auth.logout" + * + * Deriving the NAME rather than matching the word is what makes the rule below + * independent of spelling. A sweep keyed on the literal `logout` could not see + * the renamed binding's uses at all, and could not see a member access either — + * the round-4 review's probes are exactly those two. + */ +function signOutBindings(source: string): string[] { + const names: string[] = []; + for (const match of source.matchAll(/const\s*\{([^}]*)\}\s*=\s*useAuth\(\)/g)) { + for (const field of match[1].split(",")) { + const [key, alias] = field.split(":").map((part) => part.trim()); + if (key === "logout") names.push(alias || key); + } + } + for (const match of source.matchAll(/const\s+([A-Za-z_$][\w$]*)\s*=\s*useAuth\(\)/g)) { + // Taking the WHOLE context is not binding the sign-out. Plenty of files + // read other fields off it and have no business with this rule; counting + // them as controls would demand they mention the journey. The binding + // exists only where the member is actually reached for. + const member = `${match[1]}.logout`; + if (source.includes(member)) names.push(member); + } + return names; +} + +/** Any file that binds the context's sign-out, however it spells it. */ +function controls(): string[] { + return sourceFiles().filter( + (path) => !NOT_A_CONTROL.includes(path) && signOutBindings(code(path)).length > 0, + ); +} + +function escapeForRegExp(literal: string): string { + return literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** + * Every use of a bound sign-out that is NOT the argument to `signOutAndLeave`. + * + * The rule is stated as a WHITELIST of one, which is the only form that holds: + * the previous version listed the spellings it refused, so `onClick={() => + * logout()}` beside a legitimate `signOutAndLeave(logout)` in the same file + * passed both of its checks. A control has exactly one legitimate thing to do + * with the sign-out — hand it to the journey — so anything else is a finding, + * and nobody has to predict the next spelling. + */ +function straySignOutUses(path: string): string[] { + const source = code(path); + const stray: string[] = []; + for (const name of signOutBindings(source)) { + const remaining = source + // the binding is not a use of itself + .replace(/const\s*\{[^}]*\}\s*=\s*useAuth\(\)/g, "") + .replace(/const\s+[A-Za-z_$][\w$]*\s*=\s*useAuth\(\)/g, "") + // the one permitted use + .split(`signOutAndLeave(${name}`) + .join("signOutAndLeave("); + const uses = remaining.match( + new RegExp(`(^|[^\\w$.])${escapeForRegExp(name)}\\b`, "g"), + ); + for (const use of uses ?? []) stray.push(`${name}: ${use.trim()}`); + } + return stray; +} + +describe("sign-out controls", () => { + // The population itself is asserted, because a derived sweep that found + // NOTHING would pass every test below having checked no file at all — the + // failure mode a fixed list at least could not have. + it("finds the controls by reading the tree", () => { + const found = controls(); + expect(found.length).toBeGreaterThanOrEqual(6); + expect(found).toContain("src/components/admin-layout.tsx"); + expect(found).toContain("src/components/command-palette.tsx"); + expect(found).toContain("src/components/consent-banner.tsx"); + }); + + it.each(controls())("%s routes through the shared journey", (path: string) => { + expect(code(path)).toContain("signOutAndLeave"); + }); + + it.each(controls())("%s does nothing else with the sign-out", (path: string) => { + expect(straySignOutUses(path)).toEqual([]); + }); + + // The sweep is held to the spellings it was blind to, as source rather than + // as a claim: each of these is a control that would have passed the previous + // version, and each must be reported now. + it.each([ + [ + "an arrow calling it beside a legitimate journey call", + `const { logout } = useAuth(); + onClick={() => void signOutAndLeave(logout)} + onClick={() => logout()}`, + ], + [ + "a member access off the whole context", + `const auth = useAuth(); + onClick={() => { void auth.logout(); window.location.replace("/auth/login"); }}`, + ], + [ + "a renamed binding", + `const { logout: signOut } = useAuth(); + onClick={async () => { await signOut(); }}`, + ], + ])("reports %s", (_name: string, source: string) => { + const probe = `${sweepProbeDirectory}/probe.tsx`; + writeFileSync(probe, source); + try { + expect(straySignOutUses(probe)).not.toEqual([]); + } finally { + rmSync(probe, { force: true }); + } + }); + + // The provider DEFINES the sign-out, so it is the one file that calls it for + // itself — and its own callers must still read the outcome. A discarded + // result there is the same defect one layer down: exitImpersonation tore the + // credentials down and told the operator to sign in again without ever + // looking at whether the sign-out completed. + it("the auth provider reads every sign-out outcome it produces", () => { + // Anchored to the start of a STATEMENT, so `const x = await logout();` + // — the outcome being read — is not mistaken for the outcome being + // thrown away. + const discarded = code("src/lib/auth.tsx").match(/^\s*(?:await|void|return)\s+logout\(\)/gm); + expect(discarded ?? []).toEqual([]); + }); +}); diff --git a/module/services/frontend/code/src/components/admin-layout.tsx b/module/services/frontend/code/src/components/admin-layout.tsx index 500efcbef..f4452d0f4 100644 --- a/module/services/frontend/code/src/components/admin-layout.tsx +++ b/module/services/frontend/code/src/components/admin-layout.tsx @@ -47,6 +47,7 @@ import { NotificationBell } from "@/features/notifications/ui/notification-bell" import { OrgSwitcher } from "@/features/organizations/ui/org-switcher"; import { useAppearance } from "@/lib/appearance-provider"; import { useAuth } from "@/lib/auth"; +import { signOutAndLeave } from "@/lib/sign-out-journey"; import { sessionDisplayLabel } from "@/lib/auth-session"; import { getNavigationIcon } from "@/lib/navigation-icons"; import { canPresent, selectNavigation } from "@/lib/plugins/presentation"; @@ -320,7 +321,17 @@ export function AdminLayout({ children }: { children: React.ReactNode }) { ); })} - + { + // THE PRIMARY SIGN-OUT CONTROL: the account menu on + // every dashboard and admin page. `onClick={logout}` + // handed the promise to the menu, which discards it, + // so an incomplete sign-out looked finished and the + // route shell then sent the person to the sign-in + // page as if the session had ended. + void signOutAndLeave(logout); + }} + > Sign out diff --git a/module/services/frontend/code/src/components/command-palette.tsx b/module/services/frontend/code/src/components/command-palette.tsx index a429d05be..7a9a44966 100644 --- a/module/services/frontend/code/src/components/command-palette.tsx +++ b/module/services/frontend/code/src/components/command-palette.tsx @@ -38,6 +38,7 @@ import { isSuperAdmin } from "@/lib/permissions"; import { selectNavigation } from "@/lib/plugins/presentation"; import { useFrontendConfig } from "@/lib/providers"; import { usePublicRuntimeConfig } from "@/lib/public-runtime-config-provider"; +import { signOutAndLeave } from "@/lib/sign-out-journey"; export function CommandPalette() { const [open, setOpen] = useState(false); @@ -150,9 +151,12 @@ export function CommandPalette() { { + onSelect={() => { setOpen(false); - await logout(); + // Through the shared journey. Discarding the outcome here + // left the palette — one of the two controls reachable when + // the account menu is not — silently reporting success. + void signOutAndLeave(logout); }} > diff --git a/module/services/frontend/code/src/components/consent-banner.test.tsx b/module/services/frontend/code/src/components/consent-banner.test.tsx index 97dfcd108..12e381d6a 100644 --- a/module/services/frontend/code/src/components/consent-banner.test.tsx +++ b/module/services/frontend/code/src/components/consent-banner.test.tsx @@ -28,7 +28,7 @@ const clients = vi.hoisted(() => ({ }, })); -const logout = vi.hoisted(() => vi.fn(async () => undefined)); +const logout = vi.hoisted(() => vi.fn(async () => ({ complete: true }))); vi.mock("@/lib/auth", () => ({ useAuth: () => ({ @@ -94,6 +94,35 @@ describe("ConsentBanner", () => { vi.unstubAllGlobals(); }); + // The same control, when the server could NOT finish revoking. + // + // This banner is one of the two sign-out controls a person reaches when the + // account menu is unavailable, and it redirected to the login page whatever + // `logout()` reported — so the one case where a sign-out must not claim to + // have finished was the case it claimed loudest. Local state is cleared either + // way, which is what made it invisible: the app looks signed out while an + // unmarked access token stays valid until it expires. + it("sends an INCOMPLETE sign-out to the retry journey, not to login", async () => { + authState.isAuthenticated = true; + clients.consent.getStatus.mockResolvedValue({ + currentTermsVersion: "terms-v1", + termsAcceptedVersion: "", + policyVersion: "policy-v2", + purposes: [], + }); + logout.mockResolvedValueOnce({ complete: false }); + const replace = vi.fn(); + vi.stubGlobal("location", { ...window.location, replace }); + + render(); + + fireEvent.click(await screen.findByRole("button", { name: "Sign out" })); + await waitFor(() => expect(replace).toHaveBeenCalled()); + expect(replace).toHaveBeenCalledWith("/auth/logout"); + expect(replace).not.toHaveBeenCalledWith("/auth/login"); + vi.unstubAllGlobals(); + }); + it("keeps Sign out enabled, first and reachable when Terms cannot be accepted", async () => { authState.isAuthenticated = true; clients.consent.getStatus.mockResolvedValue({ diff --git a/module/services/frontend/code/src/components/consent-banner.tsx b/module/services/frontend/code/src/components/consent-banner.tsx index 7e90b2ed1..acc3c8aaa 100644 --- a/module/services/frontend/code/src/components/consent-banner.tsx +++ b/module/services/frontend/code/src/components/consent-banner.tsx @@ -12,6 +12,7 @@ import { import { WaitlistService } from "@/gen/saas/accounts/v1/waitlist_pb"; import { useAuth } from "@/lib/auth"; import { apiTransport } from "@/lib/connect/transport"; +import { signOutAndLeave } from "@/lib/sign-out-journey"; import { Button, Notice, Switch } from "@/shared/ui"; const STORAGE_KEY = "saas-starter:consent-preferences"; @@ -155,10 +156,10 @@ export function ConsentBanner({ // leave the session. The kit renders it always, never disabled. escape={{ label: "Sign out", - onSelect: async () => { - await logout(); - window.location.replace("/auth/login"); - }, + // The shared journey, not a redirect of its own: an incomplete + // sign-out must not land on the login page, which would present a + // session the server could not revoke as ended. + onSelect: () => signOutAndLeave(logout), }} actions={ + ); } diff --git a/module/services/frontend/code/src/features/invitations/ui/invitation-acceptance.tsx b/module/services/frontend/code/src/features/invitations/ui/invitation-acceptance.tsx index 8b24d64db..537534580 100644 --- a/module/services/frontend/code/src/features/invitations/ui/invitation-acceptance.tsx +++ b/module/services/frontend/code/src/features/invitations/ui/invitation-acceptance.tsx @@ -11,6 +11,7 @@ import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; import { useEffect, useState } from "react"; import { useAuth } from "@/lib/auth"; +import { signOutAndLeave } from "@/lib/sign-out-journey"; import { Button, Card, @@ -323,12 +324,17 @@ export function InvitationAcceptance() { diff --git a/module/services/frontend/code/src/features/organizations/service/lifecycle.ts b/module/services/frontend/code/src/features/organizations/service/lifecycle.ts index f391a6944..3103ae63b 100644 --- a/module/services/frontend/code/src/features/organizations/service/lifecycle.ts +++ b/module/services/frontend/code/src/features/organizations/service/lifecycle.ts @@ -2,6 +2,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useAuth } from "@/lib/auth"; +import { signOutAndLeave } from "@/lib/sign-out-journey"; import { fallbackOrganization } from "../model/leave-flow"; import { orgMutations } from "./mutations"; import { orgQueries } from "./queries"; @@ -39,7 +40,10 @@ function useDeparture() { } await action(); invalidateOrganization(queryClient, orgId); - if (stranded) await logout(); + // Stranded: the person left their last organization, so there is nothing + // left to be signed in to. Through the shared journey, so an incomplete + // sign-out is not left looking like a finished one. + if (stranded) await signOutAndLeave(logout); }; } diff --git a/module/services/frontend/code/src/features/solution-installations/ui/install-solution-dialog.tsx b/module/services/frontend/code/src/features/solution-installations/ui/install-solution-dialog.tsx index c6ebd2963..a94471a7c 100644 --- a/module/services/frontend/code/src/features/solution-installations/ui/install-solution-dialog.tsx +++ b/module/services/frontend/code/src/features/solution-installations/ui/install-solution-dialog.tsx @@ -200,7 +200,7 @@ export function InstallSolutionDialog({
{(control) => ( @@ -214,7 +214,7 @@ export function InstallSolutionDialog({ {(control) => ( diff --git a/module/services/frontend/code/src/gen/saas/accounts/v1/api_keys_pb.ts b/module/services/frontend/code/src/gen/saas/accounts/v1/api_keys_pb.ts index c4346edf7..e3994cac1 100644 --- a/module/services/frontend/code/src/gen/saas/accounts/v1/api_keys_pb.ts +++ b/module/services/frontend/code/src/gen/saas/accounts/v1/api_keys_pb.ts @@ -17,7 +17,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file saas/accounts/v1/api_keys.proto. */ export const file_saas_accounts_v1_api_keys: GenFile = /*@__PURE__*/ - fileDesc("Ch9zYWFzL2FjY291bnRzL3YxL2FwaV9rZXlzLnByb3RvEhBzYWFzLmFjY291bnRzLnYxIoYDCgZBUElLZXkSCgoCaWQYASABKAkSFwoPb3JnYW5pemF0aW9uX2lkGAIgASgJEg8KB3VzZXJfaWQYAyABKAkSDAoEbmFtZRgEIAEoCRIOCgZwcmVmaXgYBSABKAkSLAoGc2NvcGVzGAYgAygLMhwuc2Fhcy5hY2NvdW50cy52MS5QZXJtaXNzaW9uEjgKC2Vudmlyb25tZW50GAcgASgOMiMuc2Fhcy5hY2NvdW50cy52MS5BUElLZXlFbnZpcm9ubWVudBIuCgpjcmVhdGVkX2F0GAggASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIuCgpleHBpcmVzX2F0GAkgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIwCgxsYXN0X3VzZWRfYXQYCiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEi4KCnJldm9rZWRfYXQYCyABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wIvQBChNDcmVhdGVBUElLZXlSZXF1ZXN0EiEKD29yZ2FuaXphdGlvbl9pZBgBIAEoCUIIukgFcgOwAQESGAoEbmFtZRgCIAEoCUIKukgHcgUQARj/ARIsCgZzY29wZXMYAyADKAsyHC5zYWFzLmFjY291bnRzLnYxLlBlcm1pc3Npb24SOAoLZW52aXJvbm1lbnQYBCABKA4yIy5zYWFzLmFjY291bnRzLnYxLkFQSUtleUVudmlyb25tZW50EjgKCmV4cGlyZXNfYXQYBSABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wQgi6SAWyAQJAASJUChRDcmVhdGVBUElLZXlSZXNwb25zZRIlCgNrZXkYASABKAsyGC5zYWFzLmFjY291bnRzLnYxLkFQSUtleRIVCg1wbGFpbnRleHRfa2V5GAIgASgJImkKEkxpc3RBUElLZXlzUmVxdWVzdBIhCg9vcmdhbml6YXRpb25faWQYASABKAlCCLpIBXIDsAEBEhwKCXBhZ2Vfc2l6ZRgCIAEoBUIJukgGGgQYZCAAEhIKCnBhZ2VfdG9rZW4YAyABKAkiVgoTTGlzdEFQSUtleXNSZXNwb25zZRImCgRrZXlzGAEgAygLMhguc2Fhcy5hY2NvdW50cy52MS5BUElLZXkSFwoPbmV4dF9wYWdlX3Rva2VuGAIgASgJIk4KE1Jldm9rZUFQSUtleVJlcXVlc3QSFAoCaWQYASABKAlCCLpIBXIDsAEBEiEKD29yZ2FuaXphdGlvbl9pZBgCIAEoCUIIukgFcgOwAQEiLQoVVmFsaWRhdGVBUElLZXlSZXF1ZXN0EhQKA2tleRgBIAEoCUIHukgEcgIQASKdAgoWVmFsaWRhdGVBUElLZXlSZXNwb25zZRINCgV2YWxpZBgBIAEoCBIPCgd1c2VyX2lkGAIgASgJEhcKD29yZ2FuaXphdGlvbl9pZBgDIAEoCRIOCgZzY29wZXMYBCADKAkSEgoKd29ya3NwYWNlcxgFIAMoCRINCgVyb2xlcxgGIAMoCRJMCgphdHRyaWJ1dGVzGAcgAygLMjguc2Fhcy5hY2NvdW50cy52MS5WYWxpZGF0ZUFQSUtleVJlc3BvbnNlLkF0dHJpYnV0ZXNFbnRyeRIWCg5wcmluY2lwYWxfa2luZBgIIAEoCRoxCg9BdHRyaWJ1dGVzRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4ASp0ChFBUElLZXlFbnZpcm9ubWVudBIjCh9BUElfS0VZX0VOVklST05NRU5UX1VOU1BFQ0lGSUVEEAASHAoYQVBJX0tFWV9FTlZJUk9OTUVOVF9MSVZFEAESHAoYQVBJX0tFWV9FTlZJUk9OTUVOVF9URVNUEAIy8QUKDUFQSUtleVNlcnZpY2USzQEKDENyZWF0ZUFQSUtleRIlLnNhYXMuYWNjb3VudHMudjEuQ3JlYXRlQVBJS2V5UmVxdWVzdBomLnNhYXMuYWNjb3VudHMudjEuQ3JlYXRlQVBJS2V5UmVzcG9uc2UibsLzGFMIAhAEGg5hcGlfa2V5czp3cml0ZSoVCg9vcmdhbml6YXRpb25faWQQAhgBMAE6GAoUc2Fhcy5hcGlfa2V5LmNyZWF0ZWQQAkABSARQA1gEYAF4AoLT5JMCEToBKiIML3YxL2FwaS1rZXlzEr0BCgtMaXN0QVBJS2V5cxIkLnNhYXMuYWNjb3VudHMudjEuTGlzdEFQSUtleXNSZXF1ZXN0GiUuc2Fhcy5hY2NvdW50cy52MS5MaXN0QVBJS2V5c1Jlc3BvbnNlImHC8xhJCAIQAxoNYXBpX2tleXM6cmVhZCINYXBpX2tleXM6cmVhZCoVCg9vcmdhbml6YXRpb25faWQQAhgBMAE6AhABQAFIA1ADWANgAYLT5JMCDhIML3YxL2FwaS1rZXlzEtABCgxSZXZva2VBUElLZXkSJS5zYWFzLmFjY291bnRzLnYxLlJldm9rZUFQSUtleVJlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHkigAHC8xhjCAIQBBoOYXBpX2tleXM6d3JpdGUiDmFwaV9rZXlzOndyaXRlKhUKD29yZ2FuaXphdGlvbl9pZBACGAEwAToYChRzYWFzLmFwaV9rZXkucmV2b2tlZBACQAFIBFADWANgAXgCgtPkkwITKhEvdjEvYXBpLWtleXMve2lkfRJ9Cg5WYWxpZGF0ZUFQSUtleRInLnNhYXMuYWNjb3VudHMudjEuVmFsaWRhdGVBUElLZXlSZXF1ZXN0Giguc2Fhcy5hY2NvdW50cy52MS5WYWxpZGF0ZUFQSUtleVJlc3BvbnNlIhjC8xgUCAMQATABOgIQAUABSAdQBFgDYAFCtAEKFGNvbS5zYWFzLmFjY291bnRzLnYxQgxBcGlLZXlzUHJvdG9QAVosYWNjb3VudHMvcGtnL2dlbi9zYWFzL2FjY291bnRzL3YxO2FjY291bnRzdjGiAgNTQViqAhBTYWFzLkFjY291bnRzLlYxygIQU2Fhc1xBY2NvdW50c1xWMeICHFNhYXNcQWNjb3VudHNcVjFcR1BCTWV0YWRhdGHqAhJTYWFzOjpBY2NvdW50czo6VjFiBnByb3RvMw", [file_buf_validate_validate, file_google_api_annotations, file_google_protobuf_empty, file_google_protobuf_timestamp, file_saas_accounts_v1_common, file_saas_policy_v1_options]); + fileDesc("Ch9zYWFzL2FjY291bnRzL3YxL2FwaV9rZXlzLnByb3RvEhBzYWFzLmFjY291bnRzLnYxIoYDCgZBUElLZXkSCgoCaWQYASABKAkSFwoPb3JnYW5pemF0aW9uX2lkGAIgASgJEg8KB3VzZXJfaWQYAyABKAkSDAoEbmFtZRgEIAEoCRIOCgZwcmVmaXgYBSABKAkSLAoGc2NvcGVzGAYgAygLMhwuc2Fhcy5hY2NvdW50cy52MS5QZXJtaXNzaW9uEjgKC2Vudmlyb25tZW50GAcgASgOMiMuc2Fhcy5hY2NvdW50cy52MS5BUElLZXlFbnZpcm9ubWVudBIuCgpjcmVhdGVkX2F0GAggASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIuCgpleHBpcmVzX2F0GAkgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIwCgxsYXN0X3VzZWRfYXQYCiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEi4KCnJldm9rZWRfYXQYCyABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wIvQBChNDcmVhdGVBUElLZXlSZXF1ZXN0EiEKD29yZ2FuaXphdGlvbl9pZBgBIAEoCUIIukgFcgOwAQESGAoEbmFtZRgCIAEoCUIKukgHcgUQARj/ARIsCgZzY29wZXMYAyADKAsyHC5zYWFzLmFjY291bnRzLnYxLlBlcm1pc3Npb24SOAoLZW52aXJvbm1lbnQYBCABKA4yIy5zYWFzLmFjY291bnRzLnYxLkFQSUtleUVudmlyb25tZW50EjgKCmV4cGlyZXNfYXQYBSABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wQgi6SAWyAQJAASJUChRDcmVhdGVBUElLZXlSZXNwb25zZRIlCgNrZXkYASABKAsyGC5zYWFzLmFjY291bnRzLnYxLkFQSUtleRIVCg1wbGFpbnRleHRfa2V5GAIgASgJImkKEkxpc3RBUElLZXlzUmVxdWVzdBIhCg9vcmdhbml6YXRpb25faWQYASABKAlCCLpIBXIDsAEBEhwKCXBhZ2Vfc2l6ZRgCIAEoBUIJukgGGgQYZCAAEhIKCnBhZ2VfdG9rZW4YAyABKAkiVgoTTGlzdEFQSUtleXNSZXNwb25zZRImCgRrZXlzGAEgAygLMhguc2Fhcy5hY2NvdW50cy52MS5BUElLZXkSFwoPbmV4dF9wYWdlX3Rva2VuGAIgASgJIk4KE1Jldm9rZUFQSUtleVJlcXVlc3QSFAoCaWQYASABKAlCCLpIBXIDsAEBEiEKD29yZ2FuaXphdGlvbl9pZBgCIAEoCUIIukgFcgOwAQEiLQoVVmFsaWRhdGVBUElLZXlSZXF1ZXN0EhQKA2tleRgBIAEoCUIHukgEcgIQASKdAgoWVmFsaWRhdGVBUElLZXlSZXNwb25zZRINCgV2YWxpZBgBIAEoCBIPCgd1c2VyX2lkGAIgASgJEhcKD29yZ2FuaXphdGlvbl9pZBgDIAEoCRIOCgZzY29wZXMYBCADKAkSEgoKd29ya3NwYWNlcxgFIAMoCRINCgVyb2xlcxgGIAMoCRJMCgphdHRyaWJ1dGVzGAcgAygLMjguc2Fhcy5hY2NvdW50cy52MS5WYWxpZGF0ZUFQSUtleVJlc3BvbnNlLkF0dHJpYnV0ZXNFbnRyeRIWCg5wcmluY2lwYWxfa2luZBgIIAEoCRoxCg9BdHRyaWJ1dGVzRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4ASp0ChFBUElLZXlFbnZpcm9ubWVudBIjCh9BUElfS0VZX0VOVklST05NRU5UX1VOU1BFQ0lGSUVEEAASHAoYQVBJX0tFWV9FTlZJUk9OTUVOVF9MSVZFEAESHAoYQVBJX0tFWV9FTlZJUk9OTUVOVF9URVNUEAIy8QUKDUFQSUtleVNlcnZpY2USzQEKDENyZWF0ZUFQSUtleRIlLnNhYXMuYWNjb3VudHMudjEuQ3JlYXRlQVBJS2V5UmVxdWVzdBomLnNhYXMuYWNjb3VudHMudjEuQ3JlYXRlQVBJS2V5UmVzcG9uc2UibsLzGFMIAhAEGg5hcGlfa2V5czp3cml0ZSoVCg9vcmdhbml6YXRpb25faWQQAhgBMAQ6GAoUc2Fhcy5hcGlfa2V5LmNyZWF0ZWQQAkABSARQA1gEYAF4AoLT5JMCEToBKiIML3YxL2FwaS1rZXlzEr0BCgtMaXN0QVBJS2V5cxIkLnNhYXMuYWNjb3VudHMudjEuTGlzdEFQSUtleXNSZXF1ZXN0GiUuc2Fhcy5hY2NvdW50cy52MS5MaXN0QVBJS2V5c1Jlc3BvbnNlImHC8xhJCAIQAxoNYXBpX2tleXM6cmVhZCINYXBpX2tleXM6cmVhZCoVCg9vcmdhbml6YXRpb25faWQQAhgBMAE6AhABQAFIA1ADWANgAYLT5JMCDhIML3YxL2FwaS1rZXlzEtABCgxSZXZva2VBUElLZXkSJS5zYWFzLmFjY291bnRzLnYxLlJldm9rZUFQSUtleVJlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHkigAHC8xhjCAIQBBoOYXBpX2tleXM6d3JpdGUiDmFwaV9rZXlzOndyaXRlKhUKD29yZ2FuaXphdGlvbl9pZBACGAEwAToYChRzYWFzLmFwaV9rZXkucmV2b2tlZBACQAFIBFADWANgAXgCgtPkkwITKhEvdjEvYXBpLWtleXMve2lkfRJ9Cg5WYWxpZGF0ZUFQSUtleRInLnNhYXMuYWNjb3VudHMudjEuVmFsaWRhdGVBUElLZXlSZXF1ZXN0Giguc2Fhcy5hY2NvdW50cy52MS5WYWxpZGF0ZUFQSUtleVJlc3BvbnNlIhjC8xgUCAMQATABOgIQAUABSAdQBFgDYAFCtAEKFGNvbS5zYWFzLmFjY291bnRzLnYxQgxBcGlLZXlzUHJvdG9QAVosYWNjb3VudHMvcGtnL2dlbi9zYWFzL2FjY291bnRzL3YxO2FjY291bnRzdjGiAgNTQViqAhBTYWFzLkFjY291bnRzLlYxygIQU2Fhc1xBY2NvdW50c1xWMeICHFNhYXNcQWNjb3VudHNcVjFcR1BCTWV0YWRhdGHqAhJTYWFzOjpBY2NvdW50czo6VjFiBnByb3RvMw", [file_buf_validate_validate, file_google_api_annotations, file_google_protobuf_empty, file_google_protobuf_timestamp, file_saas_accounts_v1_common, file_saas_policy_v1_options]); /** * @generated from message saas.accounts.v1.APIKey diff --git a/module/services/frontend/code/src/gen/saas/accounts/v1/installations_pb.ts b/module/services/frontend/code/src/gen/saas/accounts/v1/installations_pb.ts index 5b93b221f..2f1632ad2 100644 --- a/module/services/frontend/code/src/gen/saas/accounts/v1/installations_pb.ts +++ b/module/services/frontend/code/src/gen/saas/accounts/v1/installations_pb.ts @@ -169,7 +169,16 @@ export type InstallSolutionRequest = Message<"saas.accounts.v1.InstallSolutionRe /** * The agent's delegated-execution ceiling (migration 108): the Work Context * audiences it may be minted for and the resource kinds it may ever touch. - * Empty = unrestricted. + * + * Empty = unrestricted FOR THIS INSTALLATION'S OWN WORK, which is what an + * administrator consented to when they installed it. It is NOT unrestricted + * for a mint made with a credential delivered to a THIRD PARTY on a person's + * behalf — an MCP client the person authorized for this one solution. There + * the installation is the only statement of what the solution may act for, so + * an empty list is read as nothing and such a mint is REFUSED by name + * (accounts' holdConfinedMintToItsConsumer). The two readings are opposite on + * purpose: one bounds a consumer an admin trusts, the other bounds a + * credential they never saw. * * @generated from field: repeated string allowed_audiences = 10; */ diff --git a/module/services/frontend/code/src/lib/__tests__/codefly-gateway-context.test.ts b/module/services/frontend/code/src/lib/__tests__/codefly-gateway-context.test.ts index 05a525eeb..9e9605849 100644 --- a/module/services/frontend/code/src/lib/__tests__/codefly-gateway-context.test.ts +++ b/module/services/frontend/code/src/lib/__tests__/codefly-gateway-context.test.ts @@ -1,56 +1,76 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { type CodeflyRuntimeReader, resolveCodeflyGatewayContext, } from "@/lib/codefly-gateway-context"; +const loopbackEndpoint = { + module: "saas-starter", + service: "frontend", + name: "http", + protocol: "HTTP" as const, + address: "http://localhost:42152", + routes: [], +}; + +/** A deployed runtime whose own endpoint is the render's loopback placeholder. */ function runtime( overrides: Partial = {}, ): CodeflyRuntimeReader { return { currentModule: () => "saas-starter", currentService: () => "frontend", - endpoints: () => [ - { - module: "saas-starter", - service: "frontend", - name: "http", - protocol: "HTTP", - address: "http://localhost:42152", - routes: [], - }, - ], + endpoints: () => [loopbackEndpoint], workspaceSecret: () => "internal-test-token", + workspaceConfiguration: () => undefined, + isDeployedBuild: () => true, ...overrides, }; } +function configured(value: string | undefined): Partial { + return { + workspaceConfiguration: (group, key) => + group === "application" && key === "APP_BASE_URL" ? value : undefined, + }; +} + describe("Codefly frontend gateway context", () => { - it("takes the secret from the SDK and, for a loopback placeholder endpoint, the public origin from the request", () => { - // The render bakes the own HTTP endpoint as a loopback placeholder, so the - // real public origin must come from the browser request. + it("takes the public origin from operator configuration", () => { expect( - resolveCodeflyGatewayContext("https://app.example", runtime()), + resolveCodeflyGatewayContext( + runtime(configured("https://app.cell.example")), + ), ).toEqual({ internalToken: "internal-test-token", - publicOrigin: "https://app.example", + publicOrigin: "https://app.cell.example", }); }); - it("prefers a real (non-loopback) SDK endpoint over the request origin", () => { + // The defect: the render bakes the own endpoint as a loopback placeholder, so + // this fell back to the caller's X-Forwarded-Host and stamped it, under the + // internal token, as the VERIFIED public origin. accounts then bound an OAuth + // redirect, the authenticator relying-party origin and emailed links to a host + // the caller chose. There is no request to fall back to any more — the function + // does not take one. + it("refuses, by name, when a deployed build has no configured origin and a loopback placeholder endpoint", () => { + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + expect(resolveCodeflyGatewayContext(runtime())).toBeUndefined(); + expect(error).toHaveBeenCalledWith( + expect.stringContaining("application/APP_BASE_URL"), + ); + } finally { + error.mockRestore(); + } + }); + + it("uses a render that carries a real ingress host when nothing is pinned", () => { expect( resolveCodeflyGatewayContext( - "https://caller-controlled.example", runtime({ endpoints: () => [ - { - module: "saas-starter", - service: "frontend", - name: "http", - protocol: "HTTP", - address: "https://app.cell.example", - routes: [], - }, + { ...loopbackEndpoint, address: "https://app.cell.example" }, ], }), ), @@ -60,57 +80,114 @@ describe("Codefly frontend gateway context", () => { }); }); - it("fails closed when a Codefly runtime has no own HTTP endpoint", () => { + it("keeps the loopback endpoint in local development, where the browser reaches it", () => { expect( - resolveCodeflyGatewayContext( - "https://caller-controlled.example", - runtime({ endpoints: () => [] }), - ), - ).toBeUndefined(); + resolveCodeflyGatewayContext(runtime({ isDeployedBuild: () => false })), + ).toEqual({ + internalToken: "internal-test-token", + publicOrigin: "http://localhost:42152", + }); }); - it("fails closed on ambiguous or malformed own endpoints", () => { - const endpoint = runtime().endpoints()[0]; - expect( - resolveCodeflyGatewayContext( - "https://caller-controlled.example", - runtime({ endpoints: () => [endpoint, endpoint] }), - ), - ).toBeUndefined(); + it("prefers the configured origin over a render that carries a real host", () => { expect( resolveCodeflyGatewayContext( - "https://caller-controlled.example", runtime({ + ...configured("https://pinned.example"), endpoints: () => [ - { ...endpoint, address: "https://user:secret@app.example" }, + { ...loopbackEndpoint, address: "https://rendered.example" }, ], }), - ), - ).toBeUndefined(); + )?.publicOrigin, + ).toBe("https://pinned.example"); + }); + + it("refuses a configured value that is not an exact origin", () => { + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + for (const value of [ + "https://app.example/path", + "https://user:secret@app.example", + "app.example", + "ftp://app.example", + "https://app.example?q=1", + ]) { + expect( + resolveCodeflyGatewayContext(runtime(configured(value))), + ).toBeUndefined(); + } + expect(error).toHaveBeenCalled(); + } finally { + error.mockRestore(); + } + }); + + it("fails closed when a Codefly runtime has no own HTTP endpoint and nothing is configured", () => { + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + expect( + resolveCodeflyGatewayContext(runtime({ endpoints: () => [] })), + ).toBeUndefined(); + } finally { + error.mockRestore(); + } + }); + + it("fails closed on ambiguous or malformed own endpoints", () => { + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + expect( + resolveCodeflyGatewayContext( + runtime({ + isDeployedBuild: () => false, + endpoints: () => [loopbackEndpoint, loopbackEndpoint], + }), + ), + ).toBeUndefined(); + expect( + resolveCodeflyGatewayContext( + runtime({ + isDeployedBuild: () => false, + endpoints: () => [ + { + ...loopbackEndpoint, + address: "https://user:secret@app.example", + }, + ], + }), + ), + ).toBeUndefined(); + } finally { + error.mockRestore(); + } }); it("fails closed when the workspace secret is unavailable", () => { expect( resolveCodeflyGatewayContext( - "https://app.example", - runtime({ workspaceSecret: () => undefined }), + runtime({ + ...configured("https://app.cell.example"), + workspaceSecret: () => undefined, + }), ), ).toBeUndefined(); }); - it("allows request-origin fallback only outside a Codefly runtime", () => { + // Outside a Codefly runtime (an isolated component test) there is no own + // endpoint and no configuration, so there is no origin — and, with no request + // to borrow one from, nothing to guess with. + it("fails closed outside a Codefly runtime unless an origin is configured", () => { + const outside = { + currentModule: () => "", + currentService: () => "", + endpoints: () => [], + isDeployedBuild: () => false, + }; + expect(resolveCodeflyGatewayContext(runtime(outside))).toBeUndefined(); expect( resolveCodeflyGatewayContext( - "https://isolated.example", - runtime({ - currentModule: () => "", - currentService: () => "", - endpoints: () => [], - }), - ), - ).toEqual({ - internalToken: "internal-test-token", - publicOrigin: "https://isolated.example", - }); + runtime({ ...outside, ...configured("https://isolated.example") }), + )?.publicOrigin, + ).toBe("https://isolated.example"); }); }); diff --git a/module/services/frontend/code/src/lib/__tests__/impersonation-session.test.tsx b/module/services/frontend/code/src/lib/__tests__/impersonation-session.test.tsx index df8cb7740..073d8e041 100644 --- a/module/services/frontend/code/src/lib/__tests__/impersonation-session.test.tsx +++ b/module/services/frontend/code/src/lib/__tests__/impersonation-session.test.tsx @@ -62,12 +62,14 @@ let refreshQueue: Array<() => Response>; let logoutCalls: number; let stopCalls: Array; let stopResponse: () => Response; +let logoutResponse: () => Response; async function renderAsAdmin() { refreshQueue = []; logoutCalls = 0; stopCalls = []; stopResponse = () => HttpResponse.json({ durationSeconds: "42" }); + logoutResponse = () => HttpResponse.json({}); server.use( http.post("/v1/auth/refresh", () => { const next = refreshQueue.shift(); @@ -75,7 +77,7 @@ async function renderAsAdmin() { }), http.post("/v1/auth/logout", () => { logoutCalls += 1; - return HttpResponse.json({}); + return logoutResponse(); }), // The bearer is recorded, not just the call: the stop has to be made by // the impersonation session itself for the server to derive which window @@ -299,6 +301,53 @@ describe("Impersonation session journey", () => { expect(logoutCalls).toBe(1); }); + // THE SIGN-OUT'S OWN OUTCOME, read rather than discarded. + // + // Exiting tears the credentials down and tells the operator to sign in again. + // If the sign-out itself was not confirmed — the server answers 503 "logout + // incomplete, retry" — an already-issued access token stays valid until it + // expires, and local state is cleared either way, so saying nothing presents + // an unfinished sign-out as a finished one. That was the shape of the defect + // in every user-facing sign-out control; this is the same defect one layer + // down, and it is asserted on the message the operator is actually given. + it("reports an unconfirmed sign-out when leaving an impersonated session", async () => { + await renderAsAdmin(); + act(() => enter(impersonationToken)); + await waitFor(() => + expect(screen.getByTestId("state").textContent).toBe(`authed/${TARGET}`), + ); + + // The teardown path is the one that signs out: a restore that SUCCEEDS + // keeps the admin's own session and never calls it. So the restore fails + // here, as in the test below, and the only difference between the two is + // whether the sign-out itself was confirmed. + logoutResponse = () => new HttpResponse(null, { status: 503 }); + refreshQueue.push(() => new HttpResponse(null, { status: 503 })); + await act(async () => { + await expect(exit()).rejects.toThrow(/not confirmed/i); + }); + + expect(logoutCalls).toBe(1); + expect(getToken()).toBeNull(); + }); + + // THE CONTROL: the same teardown with a CONFIRMED sign-out reports the + // restore failure instead, which is the message that path always gave. So + // the one above is about the sign-out's outcome and not about tearing down. + it("reports only the restore failure when the sign-out is confirmed", async () => { + await renderAsAdmin(); + act(() => enter(impersonationToken)); + await waitFor(() => + expect(screen.getByTestId("state").textContent).toBe(`authed/${TARGET}`), + ); + + refreshQueue.push(() => new HttpResponse(null, { status: 503 })); + await act(async () => { + await expect(exit()).rejects.toThrow(/sign in again/i); + }); + expect(logoutCalls).toBe(1); + }); + // An expired or already-revoked window is refused by the server, and there // was nothing left to close. Reporting a failed stop there would cry wolf on // the one message that has to be trusted. diff --git a/module/services/frontend/code/src/lib/__tests__/proxy-public-origin.test.ts b/module/services/frontend/code/src/lib/__tests__/proxy-public-origin.test.ts deleted file mode 100644 index db865c2a4..000000000 --- a/module/services/frontend/code/src/lib/__tests__/proxy-public-origin.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { NextRequest } from "next/server"; -import { describe, expect, it } from "vitest"; - -import { publicRequestOrigin } from "@/proxy"; - -describe("publicRequestOrigin", () => { - it("uses the ingress-forwarded proto and host over the pod's plaintext origin", () => { - // TLS terminates at the ingress, so the pod receives http and nextUrl.origin - // would report the wrong scheme; the browser is on https://app.example, and - // Accounts rejects a non-loopback http public origin. - const req = new NextRequest( - "http://frontend.pod.internal/saas.accounts.v1.AuthService/BeginOAuth", - { - headers: { - "x-forwarded-proto": "https", - "x-forwarded-host": "app.example", - }, - }, - ); - expect(publicRequestOrigin(req)).toBe("https://app.example"); - }); - - it("takes the outermost hop when forwarded headers are comma-lists", () => { - const req = new NextRequest("http://frontend.pod.internal/", { - headers: { - "x-forwarded-proto": "https, http", - "x-forwarded-host": "app.example, internal.pod", - }, - }); - expect(publicRequestOrigin(req)).toBe("https://app.example"); - }); - - it("overrides only the scheme when x-forwarded-host is absent", () => { - // TLS terminated at the ingress: nextUrl carries the right host but the - // wrong (plaintext) scheme, so only the proto needs correcting. - const req = new NextRequest("http://app.example/", { - headers: { "x-forwarded-proto": "https" }, - }); - expect(publicRequestOrigin(req)).toBe("https://app.example"); - }); - - it("falls back to nextUrl entirely without forwarded headers (local dev, direct hits)", () => { - const req = new NextRequest( - "http://localhost:3000/saas.accounts.v1.AuthService/BeginOAuth", - ); - expect(publicRequestOrigin(req)).toBe("http://localhost:3000"); - }); -}); diff --git a/module/services/frontend/code/src/lib/__tests__/sign-out-journey.test.ts b/module/services/frontend/code/src/lib/__tests__/sign-out-journey.test.ts new file mode 100644 index 000000000..25ef9c4da --- /dev/null +++ b/module/services/frontend/code/src/lib/__tests__/sign-out-journey.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it, vi } from "vitest"; +import { + SIGN_OUT_COMPLETE_DESTINATION, + SIGN_OUT_RETRY_DESTINATION, + signOutAndLeave, + signOutDestination, +} from "@/lib/sign-out-journey"; + +// Every user-facing sign-out control shares this decision, so it is asserted +// once here and the controls are asserted to USE it rather than each re-testing +// the whole matrix. +describe("the shared sign-out journey", () => { + it("sends a completed sign-out to the login page", () => { + expect(signOutDestination({ complete: true })).toBe( + SIGN_OUT_COMPLETE_DESTINATION, + ); + }); + + // The three ways a sign-out fails to finish are ONE outcome, because they are + // one fact about the session: nothing confirmed it ended. An incomplete + // answer, a rejected call, and a caller that ignored the result entirely all + // leave an unmarked access token valid until it expires. + it.each([ + ["the server reported incompleteness", async () => ({ complete: false })], + [ + "the call rejected", + async () => { + throw new Error("network"); + }, + ], + ["nothing was reported at all", async () => undefined], + ])("sends %s to the retry journey", async (_name, logout) => { + const navigate = vi.fn(); + await signOutAndLeave( + logout as unknown as () => Promise<{ complete: boolean }>, + { navigate }, + ); + expect(navigate).toHaveBeenCalledWith(SIGN_OUT_RETRY_DESTINATION); + }); + + // A control may choose where a FINISHED sign-out goes — the invitation flow + // returns to the invitation it was signed into the wrong account for — and + // may not choose what an unfinished one looks like. + it("honours a control's own completed destination, never its own retry", async () => { + const navigate = vi.fn(); + const completeDestination = "/auth/login?next=%2Finvite%2Fabc"; + + await signOutAndLeave(async () => ({ complete: true }), { + completeDestination, + navigate, + }); + expect(navigate).toHaveBeenCalledWith(completeDestination); + + navigate.mockClear(); + await signOutAndLeave(async () => ({ complete: false }), { + completeDestination, + navigate, + }); + expect(navigate).toHaveBeenCalledWith(SIGN_OUT_RETRY_DESTINATION); + }); + + // The retry journey is a real page, not a string nobody serves: /auth/logout + // is what reports the failure and offers the retry, and the refresh cookie + // the server kept is the credential that retry uses. + it("names the page that owns the incomplete case", () => { + expect(SIGN_OUT_RETRY_DESTINATION).toBe("/auth/logout"); + }); +}); diff --git a/module/services/frontend/code/src/lib/auth.tsx b/module/services/frontend/code/src/lib/auth.tsx index b82750b17..4dfb8f28d 100644 --- a/module/services/frontend/code/src/lib/auth.tsx +++ b/module/services/frontend/code/src/lib/auth.tsx @@ -310,6 +310,19 @@ export async function deriveOAuthNonce(state: string): Promise { // provider's token endpoint as `code_verifier`. The provider re-hashes // it and compares — this binds the code redemption to the original // authorize request and blocks a stolen-code replay. +/** + * What a sign-out actually achieved. + * + * `complete: false` means the server could not finish revoking — it answers 503 + * "logout incomplete, retry" when an access-revocation marker cannot be written + * — or that nothing answered at all. The refresh family is only revoked when the + * server returns success, and the refresh cookie is kept in the incomplete case + * so a retry still has the credential it needs. + */ +export interface SignOutResult { + complete: boolean; +} + export async function newPkce(): Promise<{ verifier: string; challenge: string; @@ -393,7 +406,12 @@ interface AuthContextType extends AuthState { userId?: string, mfaToken?: string, ) => boolean; - logout: () => Promise; + // Reports whether the sign-out COMPLETED. An incomplete one (the server + // answers 503 "logout incomplete, retry") leaves an unmarked access token + // valid until it expires, so a caller that presents sign-out to a person must + // not present it as finished. Callers free to ignore it still get the local + // state cleared; what they lose is only the ability to say so. + logout: () => Promise; switchOrganization: (organizationId: string) => Promise; // Installs an impersonation access token as the active session. The admin's // own refresh cookie is untouched, so it remains the way back out. @@ -822,21 +840,38 @@ export function AuthProvider({ children }: { children: ReactNode }) { window.location.replace("/auth/login"); }, []); - const logout = useCallback(async () => { + const logout = useCallback(async (): Promise => { // Always hit logout so the server clears the httpOnly refresh cookie, even // if we have no access token in memory. The refresh token is read from the // cookie server-side (no body token needed). - await fetch("/v1/auth/logout", { - method: "POST", - credentials: "include", - headers: { - "Content-Type": "application/json", - ...(state.accessToken - ? { Authorization: `Bearer ${state.accessToken}` } - : {}), - }, - body: JSON.stringify({}), - }).catch(() => {}); + // + // The OUTCOME is observed. This swallowed every failure, so a 503 saying + // "logout incomplete, retry" cleared the local state and rendered a + // finished sign-out while an unmarked access token stayed valid — the + // product asserting something the server had just refused to assert. A + // network failure counts as incomplete for the same reason: nothing + // confirmed the session ended. + let complete = false; + try { + const response = await fetch("/v1/auth/logout", { + method: "POST", + credentials: "include", + headers: { + "Content-Type": "application/json", + ...(state.accessToken + ? { Authorization: `Bearer ${state.accessToken}` } + : {}), + }, + body: JSON.stringify({}), + }); + complete = response.ok; + } catch { + complete = false; + } + // The local state is cleared either way: the person asked to sign out, and + // leaving them signed in HERE would neither end the session nor tell them + // anything. The server keeps the refresh cookie on an incomplete sign-out, + // so the retry credential survives this. clearRefreshToken(); sessionStorage.removeItem("codefly_mfa_login_token"); setConnectToken(null); @@ -850,6 +885,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { isLoading: false, isAuthenticated: false, }); + return { complete }; }, [state.accessToken]); const enterImpersonation = useCallback( @@ -908,7 +944,25 @@ export function AuthProvider({ children }: { children: ReactNode }) { if (outcome.status === "unavailable" && !stopped) { throw new Error("Could not restore your session — please try again."); } - await logout(); + // The sign-out's OWN outcome, read rather than discarded. `logout()` + // reports incompleteness — the server answered 503 "logout incomplete, + // retry", or nothing answered — and this told the operator to sign in + // again either way, which is the same defect the user-facing sign-out + // controls had: local state is cleared regardless, so an unfinished + // sign-out is invisible while an already-issued token stays live. + // + // It does not take signOutAndLeave's navigation. The outcome here is + // owned by the banner that called this and reports a thrown message as a + // toast; a redirect would race that, and the operator's next step is the + // same in both branches. What the shared journey decides — WHERE an + // incomplete sign-out goes — has no second destination to choose from on + // this path, so what is shared is the fact, not the route. + const signedOut = await logout(); + if (!signedOut.complete) { + throw new Error( + "Your impersonation session was ended, but signing you out of this browser was not confirmed — close this tab and sign in again.", + ); + } if (outcome.status === "unavailable") { throw new Error( "Your impersonation session was ended, but your admin session could not be restored — please sign in again.", diff --git a/module/services/frontend/code/src/lib/codefly-gateway-context.ts b/module/services/frontend/code/src/lib/codefly-gateway-context.ts index 84b9afd38..4dec7a2c6 100644 --- a/module/services/frontend/code/src/lib/codefly-gateway-context.ts +++ b/module/services/frontend/code/src/lib/codefly-gateway-context.ts @@ -2,6 +2,7 @@ import { getCurrentModule, getCurrentService, getEndpoints, + getWorkspaceConfiguration, getWorkspaceSecret, type ServiceEndpoint, } from "codefly"; @@ -16,6 +17,15 @@ export interface CodeflyRuntimeReader { currentService(): string; endpoints(): ServiceEndpoint[]; workspaceSecret(name: string, key: string): string | undefined; + workspaceConfiguration(name: string, key: string): string | undefined; + /** + * Whether this is a deployed build. Next sets NODE_ENV at build time — + * "development" under `next dev`, "production" under `next build` — and the + * service's execution profiles map every local profile to a development build + * and the deployed one to a production build. Nothing a caller sends can + * change it. + */ + isDeployedBuild(): boolean; } const runtimeSDK: CodeflyRuntimeReader = { @@ -23,8 +33,37 @@ const runtimeSDK: CodeflyRuntimeReader = { currentService: getCurrentService, endpoints: getEndpoints, workspaceSecret: getWorkspaceSecret, + workspaceConfiguration: getWorkspaceConfiguration, + isDeployedBuild: () => process.env.NODE_ENV === "production", }; +/** + * The one spelling of an origin, shared with the accounts service. + * + * `URL.origin` is the agreement: host case, a default or leading-zero port, an + * expanded IPv6 literal and a Unicode hostname all settle here, and the Go half + * reproduces exactly these answers (R1019-N12). The vectors are asserted on both + * sides and held identical by module/tools/public_origin_vectors_lockstep_test.go. + * + * Port 0 is the one place this is deliberately stricter than the parser, which + * accepts and keeps it: no origin can be served on port 0, and the same range check + * on both sides is worth more than matching the parser on a value that cannot occur. + */ +// writtenHostname is the host as the operator SPELLED it, lowercased, with no +// parsing beyond finding the authority. It exists so canonicalOrigin can tell a +// dotted quad that was WRITTEN as one from a dotted quad the parser produced out +// of an integer spelling, which is the only question the rule needs answered. +function writtenHostname(candidate: string): string { + const afterScheme = candidate.slice(candidate.indexOf("//") + 2); + const authority = afterScheme.split(/[/?#]/, 1)[0] ?? ""; + const hostAndPort = authority.slice(authority.lastIndexOf("@") + 1); + if (hostAndPort.startsWith("[")) { + return hostAndPort.slice(0, hostAndPort.indexOf("]") + 1).toLowerCase(); + } + const colon = hostAndPort.indexOf(":"); + return (colon === -1 ? hostAndPort : hostAndPort.slice(0, colon)).toLowerCase(); +} + function canonicalOrigin(candidate: string): string | undefined { try { const parsed = new URL(candidate); @@ -38,6 +77,44 @@ function canonicalOrigin(candidate: string): string | undefined { ) { return undefined; } + if (parsed.port !== "") { + const port = Number(parsed.port); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + return undefined; + } + } + // A hostname label may not begin or end with a hyphen (RFC 1123). The parser + // accepts one and Go's IDNA mapping refuses it, which is a spelling the two + // sides would answer differently (R1019-N12/f3) — so this side refuses it too + // rather than leaving the disagreement to the comparison. An IPv6 literal is + // bracketed and has no labels, so it is left alone. + if (!parsed.hostname.startsWith("[")) { + for (const label of parsed.hostname.split(".")) { + if (label.startsWith("-") || label.endsWith("-")) { + return undefined; + } + } + } + // A NUMERIC spelling of an IPv4 address is refused, even though this + // parser accepts it, because it does not keep what was written: + // `2130706433`, `0x7f000001`, `127.1` and `0177.0.0.1` all come back as + // `127.0.0.1`, while Go's net/url preserves the original. Both services + // would be configured with one value and compare two, so every + // same-origin check fails and sign-in refuses — a configuration that + // cannot work, accepted at boot. + // + // Narrowed to exactly that rewrite: the parsed host is a dotted quad AND + // it is not what was written. The two rewrites the two sides DO agree on + // are untouched — an IPv6 literal compresses on both (`[0:0:…:1]` → + // `[::1]`) and a Unicode hostname encodes on both (`bücher.example` → + // `xn--bcher-kva.example`) — so this cannot be the broader "the parser + // changed something" test, which refused both of those. + if ( + /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(parsed.hostname) && + writtenHostname(candidate) !== parsed.hostname + ) { + return undefined; + } return parsed.origin; } catch { return undefined; @@ -51,13 +128,31 @@ function isLoopbackOrigin(origin: string): boolean { return octets.length === 4 && octets[0] === "127"; } +/** + * The key an operator sets to pin this deployment's public origin. The same key + * accounts reads for the links it mints, so the origin that binds a sign-in + * redirect, an authenticator's relying party and an emailed link is one value in + * one place. + */ +const PUBLIC_ORIGIN_CONFIGURATION = "application/APP_BASE_URL"; + /** * Resolve the server-side credentials used between the public frontend and the * private auth gateway. Codefly's injected representation remains entirely * behind sdk-js; this library only consumes typed SDK values. + * + * The public origin is OPERATOR CONFIGURATION and is never derived from a request. + * It is what binds an OAuth redirect, the authenticator relying-party origin, every + * emailed link and the solution proxy's same-origin comparison, and none of those is + * safe to bind to a value a caller can name — so this resolves the origin from + * configuration or refuses, and never from the request. + * + * The order is: the configured origin; then the SDK-discovered own endpoint when + * the render carries a real host; and on a deployed build, nothing else — a + * loopback placeholder with no configured origin refuses, by name, rather than + * asking the caller. */ export function resolveCodeflyGatewayContext( - requestOrigin: string, runtime: CodeflyRuntimeReader = runtimeSDK, ): CodeflyGatewayContext | undefined { const internalToken = runtime @@ -65,16 +160,67 @@ export function resolveCodeflyGatewayContext( ?.trim(); if (!internalToken) return undefined; + const publicOrigin = resolveVerifiedPublicOrigin(runtime); + if (!publicOrigin) return undefined; + return { internalToken, publicOrigin }; +} + +/** + * The origin alone, for a caller that needs to judge a request against it rather + * than to stamp trust headers. Exported separately so "this deployment has no + * verified origin" and "this deployment has no internal token" stay two different + * answers: a route that conflated them would refuse a same-origin request for a + * missing credential it was not asking about. + */ +export function resolveVerifiedPublicOrigin( + runtime: CodeflyRuntimeReader = runtimeSDK, +): string | undefined { + return resolvePublicOrigin(runtime); +} + +function resolvePublicOrigin( + runtime: CodeflyRuntimeReader, +): string | undefined { + const [group, key] = PUBLIC_ORIGIN_CONFIGURATION.split("/"); + const configured = runtime.workspaceConfiguration(group, key)?.trim(); + if (configured) { + const origin = canonicalOrigin(configured); + if (!origin) { + console.error( + `${PUBLIC_ORIGIN_CONFIGURATION} is not an exact http(s) origin, so this deployment has no verified public origin; sign-in and the solution proxy are refused until it is corrected`, + ); + return undefined; + } + return origin; + } + + const endpointOrigin = ownEndpointOrigin(runtime); + + // A render that carries a real ingress host is operator configuration too, so + // it answers when nothing is pinned. + if (endpointOrigin && !isLoopbackOrigin(endpointOrigin)) { + return endpointOrigin; + } + + // A loopback own endpoint is correct in local development — the browser + // reaches that same host — and is the render's placeholder everywhere else. + // Only the build tells those apart, and nothing a caller sends does. + if (!runtime.isDeployedBuild()) { + return endpointOrigin; + } + + console.error( + `${PUBLIC_ORIGIN_CONFIGURATION} is not set and this deployment's own endpoint renders as a loopback placeholder, so there is no verified public origin; set it to this cell's public origin. Sign-in and the solution proxy are refused until then, rather than binding them to a host taken from the request.`, + ); + return undefined; +} + +function ownEndpointOrigin(runtime: CodeflyRuntimeReader): string | undefined { const currentModule = runtime.currentModule().trim(); const currentService = runtime.currentService().trim(); - // Outside a Codefly runtime (isolated component tests) there is no own - // endpoint to discover, so the request origin is the only public origin. - if (!currentModule && !currentService) { - const publicOrigin = canonicalOrigin(requestOrigin); - if (!publicOrigin) return undefined; - return { internalToken, publicOrigin }; - } + // endpoint to discover. + if (!currentModule && !currentService) return undefined; const matches = runtime .endpoints() @@ -88,20 +234,5 @@ export function resolveCodeflyGatewayContext( // A Codefly runtime identity must have exactly one SDK-discovered own HTTP // endpoint. if (matches.length !== 1) return undefined; - const endpointOrigin = canonicalOrigin(matches[0].address); - if (!endpointOrigin) return undefined; - - // The render bakes the own HTTP endpoint as a loopback placeholder - // (localhost:8080) that carries no real ingress host: the true public origin - // is a per-cell/per-ingress fact the SDK cannot know. Fall back to the actual - // browser request in that case. A genuine local-dev endpoint is loopback too, - // and its request origin is the same host, so this stays correct in dev. Once - // the render carries a real non-loopback ingress host, that host keeps - // priority over the caller-influenced request origin. - const publicOrigin = isLoopbackOrigin(endpointOrigin) - ? canonicalOrigin(requestOrigin) - : endpointOrigin; - if (!publicOrigin) return undefined; - - return { internalToken, publicOrigin }; + return canonicalOrigin(matches[0].address); } diff --git a/module/services/frontend/code/src/lib/public-origin.ts b/module/services/frontend/code/src/lib/public-origin.ts index 8592f9456..f83a988ad 100644 --- a/module/services/frontend/code/src/lib/public-origin.ts +++ b/module/services/frontend/code/src/lib/public-origin.ts @@ -1,20 +1,18 @@ +import "server-only"; + +import { resolveVerifiedPublicOrigin } from "@/lib/codefly-gateway-context"; + /** - * The public browser origin of a request, preferring the ingress-set forwarded - * pair over the pod-local request URL — the same resolution src/proxy.ts uses, - * because behind a TLS-terminating ingress the pod sees plaintext `http` and its - * own host. + * The origin this deployment treats as its own, from operator configuration. + * + * It comes from `application/APP_BASE_URL`, or from a render that carries a real + * ingress host, and from nothing else — never from a request, because an origin a + * caller can name is not this deployment's origin. See `resolveVerifiedPublicOrigin`, + * which refuses rather than guessing. + * + * Returns undefined when this deployment has no verified public origin, which + * every caller must treat as a refusal. */ -export function requestPublicOrigin(request: Request): string { - const url = new URL(request.url); - const forwardedProto = request.headers - .get("x-forwarded-proto") - ?.split(",")[0] - ?.trim(); - const forwardedHost = request.headers - .get("x-forwarded-host") - ?.split(",")[0] - ?.trim(); - const protocol = forwardedProto ? `${forwardedProto}:` : url.protocol; - const host = forwardedHost || url.host; - return `${protocol}//${host}`; +export function configuredPublicOrigin(): string | undefined { + return resolveVerifiedPublicOrigin(); } diff --git a/module/services/frontend/code/src/lib/sign-out-journey.ts b/module/services/frontend/code/src/lib/sign-out-journey.ts new file mode 100644 index 000000000..c1b8ac197 --- /dev/null +++ b/module/services/frontend/code/src/lib/sign-out-journey.ts @@ -0,0 +1,74 @@ +import type { SignOutResult } from "@/lib/auth"; + +/** + * Where a sign-out control sends the person once `logout()` has answered. + * + * Every user-facing sign-out control shares ONE destination rule, because the + * two outcomes are not interchangeable and deciding per control is how they + * drifted apart: + * + * - complete — the server revoked the session. `/auth/login`, which is what a + * finished sign-out looks like. + * - incomplete — the server answered 503 "logout incomplete, retry", or nothing + * answered at all, so an already-issued access token stays valid until it + * expires. `/auth/logout`, the page that OWNS this case: it says the session + * may still be usable and offers the retry, which the kept refresh cookie + * still has the credential for. + * + * The defect this replaces: `logout()` reported incompleteness and only + * `/auth/logout` read it. The Terms banner redirected to `/auth/login` + * regardless, and the command palette discarded the result entirely — so the two + * controls a person reaches when the account menu is unavailable were precisely + * the two that presented an unfinished sign-out as finished. Local state is + * cleared either way, which is what makes the wrong answer invisible: the app + * looks signed out while the token is live. + * + * It is a plain function, not a hook: a control calls it inside the handler it + * already has, and nothing about it depends on React. + */ +export const SIGN_OUT_COMPLETE_DESTINATION = "/auth/login"; +export const SIGN_OUT_RETRY_DESTINATION = "/auth/logout"; + +export function signOutDestination( + result: SignOutResult | undefined, + completeDestination: string = SIGN_OUT_COMPLETE_DESTINATION, +): string { + // Only the COMPLETE destination is a control's to choose. A control with its + // own next step — returning to the invitation it was signed into the wrong + // account for — still has one, and the incomplete case stays the shared + // journey's, because that is the outcome a control must not be able to + // present its own way. + return result?.complete ? completeDestination : SIGN_OUT_RETRY_DESTINATION; +} + +/** + * Runs a sign-out and leaves for the destination its outcome names. + * + * A rejection is the same fact as an explicit incompleteness — nothing + * confirmed the session ended — so it takes the retry journey rather than + * propagating, which in a click handler would surface as an unhandled rejection + * and leave the person on a page that still looks signed in. + * + * `navigate` exists so a test can observe the decision instead of the browser + * performing it; production callers omit it. + */ +export async function signOutAndLeave( + logout: () => Promise, + options: { + completeDestination?: string; + navigate?: (destination: string) => void; + } = {}, +): Promise { + const navigate = + options.navigate ?? + ((destination: string) => { + window.location.replace(destination); + }); + let result: SignOutResult | undefined; + try { + result = await logout(); + } catch { + result = { complete: false }; + } + navigate(signOutDestination(result, options.completeDestination)); +} diff --git a/module/services/frontend/code/src/proxy.ts b/module/services/frontend/code/src/proxy.ts index 96b7eeb4f..baa9b173c 100644 --- a/module/services/frontend/code/src/proxy.ts +++ b/module/services/frontend/code/src/proxy.ts @@ -99,26 +99,6 @@ function isProductAPI(pathname: string): boolean { return oauthSurfaceUpstreamPath(pathname) !== undefined; } -// Next derives `nextUrl`'s protocol and host from the internal request URL and -// ignores `x-forwarded-*`, so behind a TLS-terminating ingress `nextUrl.protocol` -// is the pod's plaintext `http:` rather than the browser's `https:` — and -// Accounts rejects a non-loopback `http` public origin, which would leave OAuth -// broken. The ingress sets the forwarded pair from the real client connection; -// prefer each, falling back to `nextUrl` (local dev, direct-to-pod requests). -export function publicRequestOrigin(req: NextRequest): string { - const forwardedProto = req.headers - .get("x-forwarded-proto") - ?.split(",")[0] - ?.trim(); - const forwardedHost = req.headers - .get("x-forwarded-host") - ?.split(",")[0] - ?.trim(); - const protocol = forwardedProto ? `${forwardedProto}:` : req.nextUrl.protocol; - const host = forwardedHost || req.nextUrl.host; - return `${protocol}//${host}`; -} - export function trustedGatewayRequestHeaders( req: NextRequest, context: CodeflyGatewayContext | undefined, @@ -659,7 +639,7 @@ export async function proxy(req: NextRequest) { const { pathname, search } = req.nextUrl; const gatewayHeaders = trustedGatewayRequestHeaders( req, - resolveCodeflyGatewayContext(publicRequestOrigin(req)), + resolveCodeflyGatewayContext(), ); // The CSP lookup needs the cluster-internal secret and nothing else, so it // reads that secret rather than the gateway context, whose resolution also diff --git a/module/services/frontend/code/src/test/pipeline-gateway.test.ts b/module/services/frontend/code/src/test/pipeline-gateway.test.ts index 4dbdbeeac..537375501 100644 --- a/module/services/frontend/code/src/test/pipeline-gateway.test.ts +++ b/module/services/frontend/code/src/test/pipeline-gateway.test.ts @@ -1,6 +1,7 @@ import type { ServiceEndpoint } from "codefly"; import { describe, expect, it } from "vitest"; +import type { CodeflyRuntimeReader } from "@/lib/codefly-gateway-context"; import { codeflyInjectedRuntime, type PipelineRuntimeReader, @@ -61,6 +62,24 @@ function selfStartedRuntime( }); } +// The gateway-context reader a `runtimeWithResolvedOrigin` case passes through. It +// carries the two members the public origin now depends on — the configured value +// and whether this is a deployed build — because the origin is operator +// configuration and no longer derivable from a request. +function gatewayRuntime( + overrides: Partial = {}, +): CodeflyRuntimeReader { + return { + currentModule: () => "saas-starter", + currentService: () => "frontend", + endpoints: () => [authGatewayREST()], + workspaceSecret: () => "internal-test-token", + workspaceConfiguration: () => undefined, + isDeployedBuild: () => false, + ...overrides, + }; +} + describe("codeflyInjectedRuntime", () => { it("recognizes a Codefly-owned test before endpoints are injected", () => { expect(codeflyInjectedRuntime(runtime({ endpoints: () => [] }))).toBe(true); @@ -77,24 +96,19 @@ describe("codeflyInjectedRuntime", () => { describe("runtimeWithResolvedOrigin", () => { it("supplies the SDK-resolved own origin when Codefly runs only dependencies", () => { - const adapted = runtimeWithResolvedOrigin(ORIGIN, { - currentModule: () => "saas-starter", - currentService: () => "frontend", - endpoints: () => [authGatewayREST()], - workspaceSecret: () => "internal-test-token", - }); + const adapted = runtimeWithResolvedOrigin(ORIGIN, gatewayRuntime()); expect(adapted.endpoints()).toContainEqual(frontendHTTP()); }); it("does not replace an injected own endpoint", () => { const injected = frontendHTTP({ address: "https://app.cell.example" }); - const adapted = runtimeWithResolvedOrigin(ORIGIN, { - currentModule: () => "saas-starter", - currentService: () => "frontend", - endpoints: () => [injected], - workspaceSecret: () => "internal-test-token", - }); + const adapted = runtimeWithResolvedOrigin( + ORIGIN, + gatewayRuntime({ + endpoints: () => [injected], + }), + ); expect(adapted.endpoints()).toEqual([injected]); }); diff --git a/module/services/frontend/code/src/test/pipeline-gateway.ts b/module/services/frontend/code/src/test/pipeline-gateway.ts index 0a041cac6..ee1b29ed5 100644 --- a/module/services/frontend/code/src/test/pipeline-gateway.ts +++ b/module/services/frontend/code/src/test/pipeline-gateway.ts @@ -1,3 +1,10 @@ +import { + getCurrentModule, + getCurrentService, + getEndpoints, + getWorkspaceConfiguration, + getWorkspaceSecret, +} from "codefly"; import { NextRequest } from "next/server"; import { type CodeflyRuntimeReader, @@ -6,12 +13,6 @@ import { import { INTERNAL_TOKEN_HEADER } from "@/lib/internal-token"; import { trustedGatewayRequestHeaders } from "@/proxy"; import { productOrigin } from "@/test/codefly-endpoints"; -import { - getCurrentModule, - getCurrentService, - getEndpoints, - getWorkspaceSecret, -} from "codefly"; // Endpoint resolution lives in one place, shared with the Playwright config and // the e2e global setup: see ./codefly-endpoints. @@ -30,6 +31,10 @@ const gatewayRuntimeSDK: CodeflyRuntimeReader = { currentService: getCurrentService, endpoints: getEndpoints, workspaceSecret: getWorkspaceSecret, + workspaceConfiguration: getWorkspaceConfiguration, + // A pipeline test runs against a local graph, whose own endpoint is the real + // loopback address rather than a render's placeholder. + isDeployedBuild: () => false, }; export function runtimeWithResolvedOrigin( @@ -78,7 +83,7 @@ export function runtimeWithResolvedOrigin( export function stampedGatewayHeaders(): Record { const origin = productOrigin(); const runtime = runtimeWithResolvedOrigin(origin); - const context = resolveCodeflyGatewayContext(origin, runtime); + const context = resolveCodeflyGatewayContext(runtime); if (!context) { throw new Error( "Codefly did not provide the frontend gateway context (internal-auth secret and frontend/http endpoint).", diff --git a/module/services/frontend/code/src/test/public-origin-canonicalization.test.ts b/module/services/frontend/code/src/test/public-origin-canonicalization.test.ts new file mode 100644 index 000000000..d9d41601b --- /dev/null +++ b/module/services/frontend/code/src/test/public-origin-canonicalization.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; + +import { + type CodeflyRuntimeReader, + resolveVerifiedPublicOrigin, +} from "@/lib/codefly-gateway-context"; + +// R1019-N12: one origin must have one spelling on BOTH sides of the comparison. +// +// This side canonicalizes with `new URL(...).origin`; the accounts service reproduces +// the same answers in Go. Where the two disagreed, a configured origin and the +// equivalent origin resolved here compared unequal, and the refusal read as a +// misconfiguration. +// +// The same table is asserted in pkg/auth/public_origin_vectors_test.go, and +// module/tools/public_origin_vectors_lockstep_test.go holds the two copies identical, +// so a case fixed on one side cannot be left failing on the other. +// +// THIS TABLE IS THE FULL EXTENT OF THE AGREEMENT: the gate cannot prove anything about +// a spelling absent from both tables, and a gap here reads as agreement when there is +// none (R1019-N12/f3). A new spelling belongs here first. +const canonicalPublicOriginVectors: [string, string][] = [ + ["https://app.example", "https://app.example"], + ["https://APP.example", "https://app.example"], + ["https://app.example:443", "https://app.example"], + ["https://app.example:0443", "https://app.example"], + ["https://app.example:", "https://app.example"], + ["https://app.example:8443", "https://app.example:8443"], + ["https://app.example:08443", "https://app.example:8443"], + ["https://app.example/", "https://app.example"], + ["https://[0:0:0:0:0:0:0:1]", "https://[::1]"], + ["https://[::1]:443", "https://[::1]"], + ["https://bücher.example", "https://xn--bcher-kva.example"], + ["http://localhost:80", "http://localhost"], + ["http://127.0.0.1:3000", "http://127.0.0.1:3000"], + ["http://[::1]:80", "http://[::1]"], + ["https://my_app.example", "https://my_app.example"], + ["https://a_b-c.example", "https://a_b-c.example"], + ["https://BÜCHER.example", "https://xn--bcher-kva.example"], +]; + +// Refused on both sides. Port 0 is the one case the parser itself accepts; see the +// note on canonicalOrigin for why both sides refuse it anyway. +const refusedPublicOriginVectors: string[] = [ + "https://:443", + "https://app.example:99999", + "https://app.example:0", + "https://-app.example", + "https://app-.example", + "https://app.example:-1", + "https://app.example/path", + "https://app.example?q=1", + "https://app.example#f", + "https://user:secret@app.example", + "https://2130706433", + "https://0x7f000001", + "https://127.1", + "https://0177.0.0.1", + "app.example", + "", +]; + +// A deployed build with the origin in `application/APP_BASE_URL` and nothing else to +// fall back on: the configured value is the only input, so the result is the +// canonicalization and nothing about the resolution order. +function configuredWith(appBaseUrl: string): CodeflyRuntimeReader { + return { + currentModule: () => "saas-starter", + currentService: () => "frontend", + endpoints: () => [], + workspaceSecret: () => "internal-test-token", + workspaceConfiguration: (group: string, key: string) => + group === "application" && key === "APP_BASE_URL" ? appBaseUrl : undefined, + isDeployedBuild: () => true, + }; +} + +describe("the verified public origin has one spelling", () => { + it.each(canonicalPublicOriginVectors)("%s canonicalizes to %s", (input, expected) => { + expect(resolveVerifiedPublicOrigin(configuredWith(input))).toBe(expected); + }); + + it.each(refusedPublicOriginVectors.map((input) => [input]))( + "refuses %s rather than guessing an origin", + (input) => { + expect(resolveVerifiedPublicOrigin(configuredWith(input))).toBeUndefined(); + }, + ); +}); diff --git a/module/services/frontend/code/src/test/security-headers.test.ts b/module/services/frontend/code/src/test/security-headers.test.ts index c150fad4b..ab7499adb 100644 --- a/module/services/frontend/code/src/test/security-headers.test.ts +++ b/module/services/frontend/code/src/test/security-headers.test.ts @@ -31,6 +31,30 @@ describe("securityHeaders", () => { "frame-ancestors 'none'", ); }); + + // The edge redirects plaintext to TLS, which protects the redirect and nothing + // before it: a browser's first request to a host it has never pinned goes out in + // the clear and an attacker on the path answers it. + it("pins the browser to HTTPS for a year", () => { + const byKey = Object.fromEntries( + securityHeaders({}).map((h) => [h.key, h.value]), + ); + expect(byKey["Strict-Transport-Security"]).toBe("max-age=31536000"); + }); + + // includeSubDomains and preload commit hosts this service does not own: a + // sibling subdomain on plaintext breaks under the first, and the second is + // effectively irreversible. Both are the ingress operator's to add, so their + // absence here is a decision rather than an omission. + it("commits no host but its own", () => { + const byKey = Object.fromEntries( + securityHeaders({}).map((h) => [h.key, h.value]), + ); + expect(byKey["Strict-Transport-Security"]).not.toContain( + "includeSubDomains", + ); + expect(byKey["Strict-Transport-Security"]).not.toContain("preload"); + }); }); describe("baselineSecurityHeaders", () => { @@ -41,6 +65,7 @@ describe("baselineSecurityHeaders", () => { expect(byKey["X-Frame-Options"]).toBe("DENY"); expect(byKey["X-Content-Type-Options"]).toBe("nosniff"); expect(byKey["Cross-Origin-Opener-Policy"]).toBe("same-origin"); + expect(byKey["Strict-Transport-Security"]).toBe("max-age=31536000"); expect(byKey["Content-Security-Policy"]).toBeUndefined(); }); }); diff --git a/module/services/frontend/code/tests/e2e/security-headers.spec.ts b/module/services/frontend/code/tests/e2e/security-headers.spec.ts index 963c051bd..9c2127b9b 100644 --- a/module/services/frontend/code/tests/e2e/security-headers.spec.ts +++ b/module/services/frontend/code/tests/e2e/security-headers.spec.ts @@ -11,6 +11,9 @@ function cspOf(response: Awaited>): string { const headers = response?.headers() ?? {}; expect(headers["x-frame-options"]).toBe("DENY"); expect(headers["x-content-type-options"]).toBe("nosniff"); + // The edge's plaintext redirect protects the redirect and nothing before it, so + // the origin pins the browser itself. + expect(headers["strict-transport-security"]).toContain("max-age="); const csp = headers["content-security-policy"]; expect(csp, "response is missing a CSP").toBeDefined(); return csp as string; diff --git a/module/services/marketing/code/next.config.mjs b/module/services/marketing/code/next.config.mjs index 8939a9f74..86f75e1b7 100644 --- a/module/services/marketing/code/next.config.mjs +++ b/module/services/marketing/code/next.config.mjs @@ -26,6 +26,12 @@ const nextConfig = { headers: [ { key: "Content-Security-Policy", value: contentSecurityPolicy }, { key: "Cross-Origin-Opener-Policy", value: "same-origin" }, + // Pin the browser to HTTPS. The edge redirects plaintext to TLS, which + // protects the redirect and nothing before it: a browser's first request + // to a host it has never pinned goes out in the clear. Without + // includeSubDomains or preload, which commit hosts this site does not + // own; browsers ignore the header on a plaintext origin. + { key: "Strict-Transport-Security", value: "max-age=31536000" }, { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" }, { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, { key: "X-Content-Type-Options", value: "nosniff" }, diff --git a/module/services/marketing/code/tests/next-config.d.ts b/module/services/marketing/code/tests/next-config.d.ts new file mode 100644 index 000000000..6cccd7e37 --- /dev/null +++ b/module/services/marketing/code/tests/next-config.d.ts @@ -0,0 +1,12 @@ +// next.config.mjs is JavaScript, and this project compiles with allowJs disabled, so +// the configuration carries no types of its own. The tests read the real file rather +// than a copy of its values — a test that restates the headers it is checking proves +// only that two literals agree — so the shape it exposes is declared here. +declare module "@/../next.config.mjs" { + export type MarketingHeader = { key: string; value: string }; + export type MarketingHeaderGroup = { source: string; headers: MarketingHeader[] }; + const config: { + headers?: () => Promise; + }; + export default config; +} diff --git a/module/services/marketing/code/tests/security-headers.test.ts b/module/services/marketing/code/tests/security-headers.test.ts new file mode 100644 index 000000000..a53454ae6 --- /dev/null +++ b/module/services/marketing/code/tests/security-headers.test.ts @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import nextConfig from "@/../next.config.mjs"; + +async function headersFor(path: string): Promise> { + const groups = await nextConfig.headers!(); + const matched = groups.filter((group) => group.source.startsWith("/:path")); + assert.ok(matched.length > 0, `no header group matches ${path}`); + return new Map(matched.flatMap((group) => group.headers).map((h) => [h.key, h.value])); +} + +// A header value is a list of directives, so a directive is only present when it stands +// as its own list member. Searching the whole string for "max-age=" accepts +// "not-max-age=31536000", which pins nothing: the assertions below read the parsed list. +function directives(value: string): Map { + return new Map( + value.split(";").map((part) => { + const [name, ...rest] = part.trim().split("="); + return [name.toLowerCase(), rest.join("=")]; + }), + ); +} + +// The edge redirects plaintext to TLS, which protects the redirect and nothing before +// it: a browser's first request to a host it has never pinned goes out in the clear. +// The origin has to ask the browser to remember. +test("the marketing origin pins HTTPS", async () => { + const sts = (await headersFor("/")).get("Strict-Transport-Security"); + assert.ok(sts, "Strict-Transport-Security is absent"); + const maxAge = directives(sts).get("max-age"); + assert.ok(maxAge !== undefined, `max-age is not a directive of ${sts}`); + assert.match(maxAge, /^\d+$/); + assert.ok(Number(maxAge) >= 31536000, "max-age is under a year"); +}); + +// includeSubDomains and preload commit hosts this site does not own: a sibling +// subdomain on plaintext breaks under the first, and the second is effectively +// irreversible. Their absence is a decision, so it is asserted. +test("the marketing origin commits no host but its own", async () => { + const sts = (await headersFor("/")).get("Strict-Transport-Security")!; + const names = [...directives(sts).keys()]; + assert.ok(!names.includes("includesubdomains"), `includeSubDomains is set in ${sts}`); + assert.ok(!names.includes("preload"), `preload is set in ${sts}`); +}); + +// The framing and sniffing defenses beside it, so removing any one of the three is +// detected rather than only the newest. +test("the marketing origin refuses framing and sniffing", async () => { + const headers = await headersFor("/"); + assert.equal(headers.get("X-Frame-Options"), "DENY"); + assert.equal(headers.get("X-Content-Type-Options"), "nosniff"); + assert.match(headers.get("Content-Security-Policy")!, /frame-ancestors 'none'/); +}); diff --git a/module/tools/access_claim_lockstep_test.go b/module/tools/access_claim_lockstep_test.go new file mode 100644 index 000000000..d14d8da68 --- /dev/null +++ b/module/tools/access_claim_lockstep_test.go @@ -0,0 +1,54 @@ +package tools + +import ( + "path/filepath" + "regexp" + "testing" +) + +// The access token's revocation claims are read by two independent Go modules +// with no shared type between them: accounts mints them, the gateway parses them. +// A claim one side renames is a claim the other silently stops finding — and for +// a REVOCATION claim, silently stopping means admitting a token that was revoked. +// +// `fid` is the one this gate exists for. It carries the refresh family, which is +// stable across the rotation that changes `sid`, and a sign-out revokes it. If +// accounts stamps `fid` and the gateway looks for anything else, every access +// token minted before the last refresh survives the sign-out at the edge while +// accounts' own verifier refuses it — two components disagreeing about whether a +// person is signed out, with no test failing on either side. +// +// This gate proves the two sides name the same claims. It cannot prove either +// uses them correctly; that is what each side's behavioural tests are for. +func TestAccessClaimsMatchTheGateway(t *testing.T) { + moduleDir := findModuleDir(t) + minter := readFileOrFail(t, filepath.Join(moduleDir, "services", "accounts", "code", + "pkg", "auth", "ed25519", "minter.go")) + gateway := readFileOrFail(t, filepath.Join(moduleDir, "services", "auth-gateway", "code", + "ext_authz.go")) + + for _, claim := range []struct{ field, tag string }{ + {"SessionID", "sid"}, + {"FamilyID", "fid"}, + } { + t.Run(claim.tag, func(t *testing.T) { + // The tag may carry options (`,omitempty`); the NAME is what travels. + pattern := regexp.MustCompile( + claim.field + `\s+string\s+` + "`" + `json:"` + claim.tag + `(,[a-z]+)?"` + "`") + if !pattern.MatchString(minter) { + t.Fatalf("accounts does not stamp %s as json:%q", claim.field, claim.tag) + } + if !pattern.MatchString(gateway) { + t.Fatalf("the gateway does not parse %s as json:%q", claim.field, claim.tag) + } + }) + } + + // And both must actually CONSULT the family, not merely carry it. A claim + // parsed and never checked is the same hole with an extra field in it. + for name, source := range map[string]string{"accounts": minter, "gateway": gateway} { + if !regexp.MustCompile(`claims\.SessionID, claims\.FamilyID`).MatchString(source) { + t.Errorf("%s must check the family alongside the session when deciding revocation", name) + } + } +} diff --git a/module/tools/configuration_key_uniqueness_test.go b/module/tools/configuration_key_uniqueness_test.go new file mode 100644 index 000000000..4aa3ed157 --- /dev/null +++ b/module/tools/configuration_key_uniqueness_test.go @@ -0,0 +1,66 @@ +package tools + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// A configuration key is declared ONCE per file. +// +// The CLI's configuration loader refuses a profile that declares one key under +// two spellings — "configuration definitions conflict" — and it refuses the whole +// profile, not the one key. So a duplicate does not degrade anything: it takes +// down every database-backed suite, the local graph and the deployed boot, all at +// once, and it surfaces as a service that cannot initialize rather than as +// anything naming the file. +// +// This happened here. A merge kept two `APP_BASE_URL=` declarations in +// `application.env`, one from each side, and nothing in this repository noticed +// until five suites failed to boot at the same time. A duplicate is exactly the +// shape a merge produces — both sides appending a documented block for the same +// key — so it is worth a gate that names the file and the key. +func TestConfigurationKeysAreDeclaredOnce(t *testing.T) { + moduleDir := findModuleDir(t) + root := filepath.Join(moduleDir, "configurations") + + var checked int + err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() || !strings.HasSuffix(path, ".env") { + return err + } + checked++ + seen := map[string]int{} + for _, line := range strings.Split(readFileOrFail(t, path), "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + key, _, found := strings.Cut(trimmed, "=") + if !found { + continue + } + seen[strings.TrimSpace(key)]++ + } + for key, count := range seen { + if count > 1 { + rel, relErr := filepath.Rel(moduleDir, path) + if relErr != nil { + rel = path + } + t.Errorf("%s declares %s %d times. The configuration loader refuses a "+ + "profile with one key under two spellings, and it refuses the WHOLE "+ + "profile — so this takes down every database-backed suite and the "+ + "local run. Declare it once and merge the prose.", rel, key, count) + } + } + return nil + }) + if err != nil { + t.Fatalf("walking %s: %v", root, err) + } + if checked == 0 { + t.Fatalf("no .env files found under %s, so this gate asserted nothing", root) + } +} diff --git a/module/tools/credential_group_scope_test.go b/module/tools/credential_group_scope_test.go new file mode 100644 index 000000000..2b5fa5f5a --- /dev/null +++ b/module/tools/credential_group_scope_test.go @@ -0,0 +1,146 @@ +package tools + +import ( + "os" + "path/filepath" + "sort" + "strings" + "testing" +) + +// A service is delivered every group it declares, whether it reads it or not. +// So a credential's blast radius is the set of services that declare the group +// it travels in — which is a fact about the MANIFESTS, not about who calls what. +// +// The frontend is a public-facing process. It declares `identity` because it +// renders the login page from that group's public facts: the issuer, the client +// id, the display name, the scope. A deployment that puts the identity provider's +// own credentials in that same group therefore hands them to the frontend too, +// which reads neither — and a public process holding the provider's administration +// credential widens what a frontend compromise is worth, for nothing. +// +// The answer is which GROUP a credential lives in, so that is what this holds. +// Every group listed here carries credentials, and this says which services may +// declare it. A group added to a service's manifest and not here passes; a +// credential group reaching a service not listed fails, named. +var credentialGroupHolders = map[string][]string{ + // The provider credentials that authenticate this host TO the identity + // provider. accounts performs the authorization-code exchange and runs the + // optional administration adapter; nothing else reads either. + "identity-provider": {"accounts"}, + // The credential accounts believes forwarded identity on. Only the identity + // plane — the service that stamps it and the service that trusts it. + "gateway-trust": {"accounts", "auth-gateway"}, + // The GitHub App's signing key and webhook secret: deployment custody, read + // only by accounts. + "github-app": {"accounts"}, + // The cell's Vault credentials. + "vault": {"accounts"}, +} + +func TestACredentialGroupIsDeclaredOnlyByServicesThatReadIt(t *testing.T) { + declaredBy := map[string][]string{} + for _, service := range servicesWithManifests(t) { + for group := range declaredWorkspaceGroups(t, service) { + declaredBy[group] = append(declaredBy[group], service) + } + } + + for group, allowed := range credentialGroupHolders { + holders := declaredBy[group] + sort.Strings(holders) + permitted := map[string]bool{} + for _, service := range allowed { + permitted[service] = true + } + for _, service := range holders { + if !permitted[service] { + t.Errorf("service %q declares the credential group %q, which carries credentials it does not read; "+ + "a declared group is delivered whether it is read or not, so this widens what a compromise of %s is worth. "+ + "Permitted holders: %s", + service, group, service, strings.Join(allowed, ", ")) + } + } + if len(holders) == 0 { + t.Errorf("no service declares the credential group %q; either it is dead and should be deleted, "+ + "or a service reads it undeclared and gets nothing in every deployed cell", group) + } + } +} + +// servicesWithManifests lists every service of this module, so the gate above +// cannot be satisfied by a service it forgot to look at. +func servicesWithManifests(t *testing.T) []string { + t.Helper() + base := filepath.Join(findModuleDir(t), "services") + entries, err := os.ReadDir(base) + if err != nil { + t.Fatalf("read %s: %v", base, err) + } + var services []string + for _, entry := range entries { + if !entry.IsDir() { + continue + } + if _, statErr := os.Stat(filepath.Join(base, entry.Name(), "service.codefly.yaml")); statErr != nil { + continue + } + services = append(services, entry.Name()) + } + if len(services) == 0 { + t.Fatal("no service manifests found; this gate now proves nothing") + } + sort.Strings(services) + return services +} + +// The gateway-provenance credential makes accounts believe a forwarded identity, +// so a holder of it can assert any user to accounts. The gate above keeps the +// GROUP to the identity plane; this keeps the KEY to that group. +// +// The defect it closes is the other way a credential spreads: not a service +// declaring a group it does not read, but a key appearing in a second group that +// many services do read. A `CODEFLY_GATEWAY_TOKEN` in `internal-auth` reaches +// every service that declares the internal token, which is nearly all of them, and +// the name alone makes it look like the same credential the gateway stamps — so a +// composition can be built where it is, and accounts then believes forwarded +// identity from anything that holds it. +func TestTheGatewayCredentialKeyAppearsInNoOtherGroup(t *testing.T) { + moduleDir := findModuleDir(t) + base := filepath.Join(moduleDir, "configurations", "local") + entries, err := os.ReadDir(base) + if err != nil { + t.Fatalf("read %s: %v", base, err) + } + + found := 0 + for _, entry := range entries { + if entry.IsDir() { + continue + } + group := strings.TrimSuffix(strings.TrimSuffix(entry.Name(), ".env"), ".secret") + data, readErr := os.ReadFile(filepath.Join(base, entry.Name())) + if readErr != nil { + t.Fatalf("read %s: %v", entry.Name(), readErr) + } + for _, line := range strings.Split(string(data), "\n") { + key, _, assigned := strings.Cut(strings.TrimSpace(line), "=") + if !assigned || strings.HasPrefix(key, "#") { + continue + } + if !strings.Contains(key, "GATEWAY_TOKEN") { + continue + } + found++ + if group != "gateway-trust" { + t.Errorf("the group %q declares %q: the gateway-provenance credential belongs to `gateway-trust`, "+ + "which only the identity plane declares. In any other group it reaches every service that "+ + "declares that group, and accounts then believes forwarded identity from each of them", + group, key) + } + } + } + if found == 0 { + t.Fatal("no GATEWAY_TOKEN key found in any shipped default; the key was renamed and this gate now proves nothing") + } +} diff --git a/module/tools/public_origin_vectors_lockstep_test.go b/module/tools/public_origin_vectors_lockstep_test.go new file mode 100644 index 000000000..b51234aad --- /dev/null +++ b/module/tools/public_origin_vectors_lockstep_test.go @@ -0,0 +1,91 @@ +package tools + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// R1019-N12. The frontend and accounts each canonicalize a public origin, in different +// languages, and the comparison between them only holds if both settle every spelling +// the same way. Each side asserts BEHAVIOUR on a table of vectors; this holds the two +// tables identical. +// +// Without it the failure is silent in the worst direction: a case fixed on one side +// and left failing on the other reads as two green suites, and the disagreement +// surfaces as a deployed origin refusing itself. +// +// This gate proves only that the two sides assert the same INPUTS and EXPECTATIONS. It +// cannot prove either side's implementation is right — that is what the behavioural +// tests on each side are for, and neither is redundant. +func TestPublicOriginVectorsAgreeAcrossServices(t *testing.T) { + moduleDir := findModuleDir(t) + goSource := filepath.Join(moduleDir, "services", "accounts", "code", + "pkg", "auth", "public_origin_vectors_test.go") + tsSource := filepath.Join(moduleDir, "services", "frontend", "code", + "src", "test", "public-origin-canonicalization.test.ts") + + goCanonical := stringLiteralsInBlock(t, goSource, `canonicalPublicOriginVectors = \[\]struct\{ Input, Canonical string \}\{`, "}") + tsCanonical := stringLiteralsInBlock(t, tsSource, `canonicalPublicOriginVectors: \[string, string\]\[\] = \[`, "]") + if strings.Join(goCanonical, "|") != strings.Join(tsCanonical, "|") { + t.Errorf("the canonicalization vectors differ between the two services.\n"+ + "accounts (%s):\n %v\nfrontend (%s):\n %v\n"+ + "Both sides must assert the same input/expected pairs, in the same order, or a case "+ + "fixed on one side can be left failing on the other with both suites green.", + goSource, goCanonical, tsSource, tsCanonical) + } + if len(goCanonical) < 20 || len(goCanonical)%2 != 0 { + t.Fatalf("expected an even, non-trivial number of canonicalization literals, got %d", len(goCanonical)) + } + + goRefused := stringLiteralsInBlock(t, goSource, `refusedPublicOriginVectors = \[\]string\{`, "}") + tsRefused := stringLiteralsInBlock(t, tsSource, `refusedPublicOriginVectors: string\[\] = \[`, "]") + if strings.Join(goRefused, "|") != strings.Join(tsRefused, "|") { + t.Errorf("the refused-origin vectors differ between the two services.\n"+ + "accounts: %v\nfrontend: %v", goRefused, tsRefused) + } + if len(goRefused) < 8 { + t.Fatalf("expected a non-trivial refusal list, got %d entries", len(goRefused)) + } +} + +// stringLiteralsInBlock returns every double-quoted literal between the line matching +// opening and the first line whose trimmed text is exactly closing. Both languages +// spell a string literal the same way, which is what makes one extractor enough. +func stringLiteralsInBlock(t *testing.T, path, opening, closing string) []string { + t.Helper() + source, err := os.ReadFile(path) + if err != nil { + t.Fatalf("reading %s: %v", path, err) + } + open := regexp.MustCompile(opening) + literal := regexp.MustCompile(`"((?:[^"\\]|\\.)*)"`) + + var found []string + inBlock := false + for _, line := range strings.Split(string(source), "\n") { + if !inBlock { + if open.MatchString(line) { + inBlock = true + } + continue + } + if strings.TrimSuffix(strings.TrimSpace(line), ";") == closing { + return found + } + if strings.HasPrefix(strings.TrimSpace(line), "//") { + continue + } + for _, match := range literal.FindAllStringSubmatch(line, -1) { + found = append(found, match[1]) + } + } + if !inBlock { + t.Fatalf("%s declares no block matching %q — the vectors moved or were renamed, "+ + "which silently retires this gate", path, opening) + } + t.Fatalf("%s has no %q closing the block matching %q", path, closing, opening) + return nil +} diff --git a/module/tools/shipped_placeholder_lockstep_test.go b/module/tools/shipped_placeholder_lockstep_test.go new file mode 100644 index 000000000..646a2dea7 --- /dev/null +++ b/module/tools/shipped_placeholder_lockstep_test.go @@ -0,0 +1,103 @@ +package tools + +import ( + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "testing" +) + +// The marker list exists in three places, and they must agree. +// +// `configuration_defaults_test.go` at the repository root REQUIRES every shipped +// secret default to carry one, so a real credential can never be committed as a +// default. accounts and auth-gateway REFUSE a value carrying one outside local +// development, because a value carrying one is a value everybody knows: these +// files ride verbatim in the immutable module package, out of a public repository. +// +// The three copies are separate Go modules with no shared local package, so the +// agreement is checked at the text level. A marker added only to the shipping gate +// leaves a published value a cell would accept; one added only to a service +// refuses a default that side still ships, which is a boot failure in local +// development. Neither is visible in the diff that causes it. +// +// Same for the length floor: the two services must not disagree about how strong a +// perimeter credential has to be, or the gateway and accounts admit different +// populations of value. +func TestShippedPlaceholderMarkersMatchTheShippingGate(t *testing.T) { + moduleDir := findModuleDir(t) + repoRoot := filepath.Dir(moduleDir) + + sources := map[string]string{ + "the shipping gate (configuration_defaults_test.go)": filepath.Join(repoRoot, "configuration_defaults_test.go"), + "accounts": filepath.Join(moduleDir, "services", "accounts", "code", "shipped_placeholder.go"), + "auth-gateway": filepath.Join(moduleDir, "services", "auth-gateway", "code", "shipped_placeholder.go"), + } + + var reference []string + var referenceName string + for name, path := range sources { + markers := placeholderMarkersIn(t, name, path) + if reference == nil { + reference, referenceName = markers, name + continue + } + if strings.Join(markers, ",") != strings.Join(reference, ",") { + t.Errorf("placeholder markers differ: %s has %v, %s has %v — "+ + "a marker on only one side either leaves a published value a cell accepts, "+ + "or refuses a default that side still ships", + referenceName, reference, name, markers) + } + } + if len(reference) == 0 { + t.Fatal("no placeholder markers were found in any source") + } +} + +func TestPerimeterCredentialFloorsAgree(t *testing.T) { + moduleDir := findModuleDir(t) + floors := map[string]string{} + for _, service := range []string{"accounts", "auth-gateway"} { + path := filepath.Join(moduleDir, "services", service, "code", "shipped_placeholder.go") + source, err := os.ReadFile(path) + if err != nil { + t.Fatalf("%s: %v", path, err) + } + match := regexp.MustCompile(`minimumPerimeterCredentialLength = (\d+)`).FindSubmatch(source) + if match == nil { + t.Fatalf("%s declares no minimumPerimeterCredentialLength", path) + } + floors[service] = string(match[1]) + } + if floors["accounts"] != floors["auth-gateway"] { + t.Errorf("perimeter credential floors differ: accounts %s, auth-gateway %s — "+ + "the two would admit different populations of value", + floors["accounts"], floors["auth-gateway"]) + } +} + +// placeholderMarkersIn reads the sorted marker literals out of a +// `...Markers = []string{...}` declaration, whatever the variable is called on +// each side. +func placeholderMarkersIn(t *testing.T, name, path string) []string { + t.Helper() + source, err := os.ReadFile(path) + if err != nil { + t.Fatalf("%s (%s): %v", name, path, err) + } + block := regexp.MustCompile(`(?s)laceholderMarkers = \[\]string\{(.*?)\}`).FindSubmatch(source) + if block == nil { + t.Fatalf("%s (%s): no placeholder marker declaration found", name, path) + } + var markers []string + for _, literal := range regexp.MustCompile(`"([^"]+)"`).FindAllSubmatch(block[1], -1) { + markers = append(markers, string(literal[1])) + } + if len(markers) == 0 { + t.Fatalf("%s (%s): the marker declaration is empty", name, path) + } + sort.Strings(markers) + return markers +}