ADR-043: OIDC Program Amendments — Citizen-Path Credential, Exchange Semantics, and the Frozen Rejection Contract

On this page

Status: Accepted (2026-08-17)
Issue: #1419 (OIDC F1b; program activated by the 2026-08-10 maintainer ruling on #546, note 3666918785)
Amends: ADR-023 (immutable — its amendments ride here per the ruling)
Relates to: ADR-019 (service identity), ADR-026 (opaque portal sessions), ADR-014 (audit chain), epic &52, the F1a inventory

Context

ADR-023 mandated OIDC validation at every program service, RFC 8693 token exchange for user-context requests, citizen-upload isolation, and service-class credential narrowing. The 2026-08-10 ruling activated the full program and resolved the five architect flags; ADR-023 itself is immutable, so the resolutions that CHANGE its text are recorded here, on the program’s first implementing MR, alongside the one piece of shared machinery every later slice consumes (the EffectiveUser resolution, below).

Amendments to ADR-023

A1 — The citizen-path credential is a dedicated narrow IdP service account (ruling R2)

ADR-023 Decision 3 derived the citizen-upload credential "via RFC 8693 token exchange at the moment the citizen-content boundary is crossed". That mechanism is replaced: RFC 8693 requires a subject token, and under ADR-026 the applicant portal holds an opaque Redis session — no citizen token exists to exchange. Citizen-content processing instead authenticates with a dedicated narrow IdP service account (client_credentials), provisioned with exactly Decision 3’s four scoping dimensions (narrowed aud, operation scope`s, short `exp, optional cnf). Only the derivation mechanism changes; the scoping posture is unchanged.

Landed (#1440, OIDC P1): the narrow account is real — the portal holds one scope-aware client_credentials source per backend target (each minting with the aud-canopy-<target> optional client scope and self-validating against its own target audience), the realm client carries no broad canopy-internal-service mapper (a scope-less mint yields a token no receiver accepts), and access.token.lifespan = 600 covers the short-exp dimension (cnf remains optional/deferred).

Landed (#1442, OIDC P3 — ownership binding; design adjudicated 2026-08-23): the narrow credential’s REACH inside its classified surface is bound to the citizen’s own resources. The portal signs a 120-second ES256 ownership claim per resource-keyed call (X-Canopy-Applicant; canopy_signing::applicant_claim — the ADR-019 actor-token idiom in a DISTINCT aud namespace and on a DISTINCT header, because actor presence drives audit attribution and gates like enrollment’s #408): sub = the ADR-026 session’s application id, plus the household/person bindings the portal resolved through its own authenticated applications read. The claim key is a separate secret from the OAuth2 client secret — that separation IS the threat model: an attacker holding only the stolen narrow bearer cannot mint claims, and every resource-keyed route fails closed (403 ownership_claim_missing; a foreign binding is 403 ownership_mismatch on claim-local compares and a UNIFORM 404 on post-load compares, so denial never confirms a foreign resource exists). Verification: kid-registered public key at each origin (the web-actor #1009 distribution shape — env override or .keys/), one shared fail-loud boot helper. The intake surfaces (create-draft, verify-credential, recovery) are exempt: they ARE the authentication that creates the binding. Ownership of resources ACROSS services stays attested by the portal from its authenticated reads — the same trust root as the session itself.

Landed (#1441, OIDC P2): the operation-scope dimension is enforced receiver-side. The portal credential is a compiled CITIZEN CLASS (canopy_auth::policy::CITIZEN_CLASS_SERVICE_IDS — compiled rather than config so an empty allowlist cannot fail open; role half and azp half recognized independently): Claims::require_service_caller refuses it (403 portal_on_non_portal_route) and every service-accepting receiver arm delegates there, so the narrowed token reaches NOTHING in its 8 targets except routes explicitly classified portal-reachable, which re-admit it on azp allowlist + a per-route-family operation scope (the 12-scope portal:* vocabulary; 403 portal_scope_missing without it, 403 portal_only_route for non-citizen principals on the portal-only intake surfaces). This amends ADR-019’s "Per-service token audience … Single aud: canopy-internal-service is sufficient" non-goal FOR THE PORTAL ONLY — the rest of the fleet’s service tokens stay broad until the deferred FU-A (#1447).

Consequently the citizen_upload exchange-purpose code in ADR-023 Decision 6 is retired unused: no exchange occurs on the citizen path (nor on background paths — background_job is likewise unused). In v1 the auth.token_exchange audit stream (A1, #1424) carries the plan’s frozen purpose vocabulary: worker_request | orchestrator_fanout (the web edge and the eligibility hop-2 fan-out).

A2 — GA exchange semantics: sub preserved, azp names the exchanger, act unused

Keycloak’s generally-available token exchange emits the exchanged token with the subject’s sub preserved (the worker remains the subject) and the exchanging client visible as azp. The act (actor) claim is an experimental Keycloak surface and is not used — no canopy receiver may depend on act. Receivers authorize the user-context arm on exactly: target audience match + azp in the authorized-exchanger allowlist + required worker role in realm_access.roles (the plan’s receiver contract).

Hop-2 pair refinement (#1430). "Target audience match" means the token’s aud is exactly [target] — with ONE structured exception: a hop-2 receiver (a service whose own paired exchanger re-exchanges inbound user-context bearers for a fan-out; canopy-eligibility today) also accepts an aud of exactly the {target, target’s-paired-exchanger} pair, because Keycloak V2 requires the subject of a chained exchange to name the requesting exchanger in aud. The pair is opted into on BOTH ends — the sender’s ExchangeRequest.hop2_exchanger (the broker refuses a grant missing either entry or carrying an unrequested exchanger rider) and the receiver’s ReceiverContract::with_hop2_exchanger (every other service, and every other multi-audience shape, stays exactly-single). A pair token replayed at a single-exact service is 403 aud_not_exact there.

A3 — The frozen 401/403 contract

Fleet-wide, non-negotiable, encoded in middleware, receivers, and the S6 conformance matrix:

  • 401 — no token, structurally invalid token, or a token that fails cryptographic/issuer/typ validation — including ANY request carrying the retired X-Canopy-Actor header (C1, #1443: a stale sender fails loud).

  • 403 — a validated token that is unauthorized for the route: wrong aud, azp not allowlisted, missing role, or a service-class bearer on a user-only route.

A4 — Decision 3 narrowed to credential + data isolation

ADR-023 Decision 3’s isolation claim is narrowed to credential and data isolation: the narrow service account bounds what a compromised parse can reach. Process/RCE isolation (parsing in a separate process/container with its own kernel attack surface) is explicitly out of v1 scope and named as future defense-in-depth — FU-C, #1449.

A5 — Revocation guidance corrected (FU-D)

ADR-023 Decision 5’s cargo xtask identity revoke <jti> misstates RFC 7009: the revocation endpoint revokes token values (the token string presented), not JTIs. Operational guidance and any tooling must take the token value; correcting the ADR-023 prose downstream artifacts copied is #1450.

Decision (F1b machinery): EffectiveUser

The F1a inventory catalogued four incompatible readings of "no actor" across its 467 read-verified branches. An exchanged worker bearer has claims.actor() == None with the identity in the token itself — all four readings misclassify it. Before any receiver flips:

canopy_auth::EffectiveUser is the single resolution of "who is the human behind this request":

  • Direct(user) — the bearer is the human: any non-service token (worker, applicant, and every exchanged token — A2’s sub-preserved shape keeps the identity in the bearer).

  • System(service) — a service bearer with no user context: genuine background/system traffic.

  • ViaActor { bearer, actor } — RETIRED (C1, #1443, executing ruling R6): the transitional legacy-service-bearer + middleware-verified X-Canopy-Actor shape existed until every migrated surface flipped; post-C1 the middleware rejects the header outright and the resolution is total over the two shapes above.

It exposes both an authorization verdict (require_user → 403 per A3 — the no-actor-rejects pattern) and a subject-to-attribute projection (attribution_sub — the attribution-resolution pattern; System attributes the calling service itself, exactly what the legacy actor().map_or(claims.sub, |a| a.sub) shape did). is_system() serves the passes-with-audit pattern. F1b changes no call sites — the S-slices adopt per service, consulting the inventory.

Consequences

  • The F1a inventory’s blocking finding stood until the slices landed; the transition wired verifiers at applications/tanf and the middleware 401’d unverifiable headers. Post-C1 (#1443) the 401 is UNCONDITIONAL: the header is retired, the verifiers are gone, and worker identity rides exchanged bearers only.

  • Receivers gain one vocabulary for "who did this", so the S-slice diffs are mechanical substitutions with per-route classification from the inventory.

  • The unused citizen_upload/background_job purpose codes die in the A1 audit schema rather than shipping as dead vocabulary.

Edit this page · default