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-Actorheader (C1, #1443: a stale sender fails loud). -
403 — a validated token that is unauthorized for the route: wrong
aud,azpnot 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’ssub-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-verifiedX-Canopy-Actorshape 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_jobpurpose codes die in the A1 audit schema rather than shipping as dead vocabulary.