Plan: OIDC Validation at Service Boundaries + Citizen-Upload Isolation

On this page
NOTE

Ratified program plan. The 2026-08-10 maintainer ruling on #546 (note 3666918785) activated the full program and resolved all five architect-input flags; this rewrite replaced the original <TBD> stub on 2026-08-11 and the program was decomposed into 34 child issues (#1418–#1451) under epic &52. Dependencies are wired as GitLab blocks/is_blocked_by links — the issue DAG, not this page, is the authoritative "what can start now" view.

Status

Step Description Status

S0

ADR drafted (ADR-023)

Done (2026-05-23)

F1a

Fleet authorization-branch inventory manifest (#1418)

Done (2026-08-17) — MR !1145, inventory page

F1b

EffectiveUser resolution + guards + amending ADR-043 (#1419)

Done (2026-08-17) — MR !1148, ADR-043

F2

Validated-bearer extension + exact-aud/azp/role policy primitives (#1420)

Done (2026-08-17) — SubjectBearer + canopy_auth::policy (#1420)

F3

TokenExchanger broker + test-lib acquire_exchanged_token (#1421)

Done (2026-08-17) — canopy_auth::token_exchanger (#1421)

F4

Conformance harness skeleton (#1422)

Done (2026-08-17) — canopy_test_lib::conformance + matrix driver (#1422)

R1

Exchanger clients + realm wiring + hop-2 devstack proof (#1423)

Done (2026-08-17) — hop-2 PROVEN (chained exchange works in KC 26.5; off-ramp not triggered, C1 actor retirement unconditional) (#1423)

A1

auth.token_exchange audit sink + broker emit (#1424)

Done (2026-08-17) — chain accept (jti dedup + frozen purpose gate) + ChainAuditSink + boot-constructed brokers in web/eligibility + live e2e incl. the withheld-token AC; exchanger default scopes trimmed to minimal (the scope-subset rule refuses profile/email pollution) (#1424)

S-tanf

canopy-tanf receiver slice (#1425)

Done (2026-08-17) — first ReceiverContract adopter: determine + discrepancy resolve service-or-exchanged, 4 user-only routes enforced (ENFORCE_USER_ONLY_ROUTES=true in devstack), exchanged_gate on the full router, EffectiveUser attribution; senders live (web BFF tanf writes + orchestrator hop-2 behind EXCHANGE_TARGETS, fail-not-fallback); F4 matrix activated for canopy-tanf (288 rows, live-verified) with the devstack-only canopy-conformance-exchanger minting the adversarial shapes (#1425)

S-medicaid

canopy-medicaid receiver slice (#1426)

Done (2026-08-17) — second ReceiverContract adopter on the tanf template: determine service-or-exchanged (hop-2 target; CHIP deliberately stays service-class — aud=canopy-chip cannot pass the single-audience gate), 6 user-only routes enforced incl. the two ELE ops routes under [admin, quality_control], EffectiveUser attribution ×3 (converging the survey’s inconsistent-attribution flag); F4 matrix activated (486 rows, 247 ran live-green; driver generalized to per-service exchange kits + base_url tripwire) (#1426)

S-security

canopy-security receiver slice (#1427)

Done (2026-08-17) — third ReceiverContract adopter, user-only-enforcement ONLY (not a program service — no hop-2; ingest + signing-keys stay S2S): archive POST (admin) + bulk audit export (admin|quality_control) enforced with EffectiveUser attribution; 14 hand-rolled dual sites untouched behind exchanged_gate; F4 matrix activated with READ rows only (both user-only routes write-unsafe to probe — enforcement pinned by security_test incl. both service-class kills) (#1427)

S-persons

canopy-persons receiver slice (#1428)

Done (2026-08-17) — fourth ReceiverContract adopter, user-only-only on the hard service-only data plane: redact-fact/redact-ssn/compensate-finalize-orphan (data_steward) + bulk export (admin|quality_control) enforced with EffectiveUser attribution; azp allowlist deliberately web-exchanger-ONLY (least privilege — persons ∉ EXCHANGE_TARGETS); NEW export_test.rs closed a zero-coverage gap; F4 matrix activated (3 steward mutations 404-before-write + 2 batchGets; export excluded write-unsafe); operator-tool exchange gap discovered → runbook exchange mint + #1501 (#1428)

S-applications

canopy-applications receiver slice (#1429)

Done (2026-08-17) — fifth adopter, the fleet’s first ZERO-swap slice (no pure human-role gates exist — no guards change): exchanged_gate threaded through the shared app::build_router prod/test assembly + web-exchanger-only allowlist; wired ActorVerifier untouched (accept/reject/scan-override stay verified-actor — their exchange migration is C1’s scope); F4 matrix activated (3 service-only + 2 dual rows) + receiver_contract_test.rs pinning the dual exchanged-arm positive and both gate kills (#1429)

S-eligibility

canopy-eligibility receiver slice + hop-2 sender (#1430)

Done (2026-08-22) — sixth ReceiverContract adopter and the fleet’s ONE hop-2 receiver: the contract admits the exact {canopy-eligibility, canopy-eligibility-exchanger} audience pair (new ExchangeRequest.hop2_exchanger + with_hop2_exchanger, both-ends opt-in, live-verified two-audience-param mint on KC 26.5), so canopy-web’s two determine senders mint a DELEGABLE bearer the orchestrator re-exchanges per flipped fan-out target — hop-2 live end-to-end; the 6 bulk-run mutations enforced user-only (ENFORCE_USER_ONLY_ROUTES=true devstack) with EffectiveUser ledger attribution; azp allowlist web-exchanger-only; F4 matrix activated (4 probe-safe dual rows; write-arm mutations pinned by receiver_contract_test.rs incl. the pair-positive and non-pair multi-aud kill) (#1430)

S-snap

canopy-snap receiver slice (#1431)

Done (2026-08-22) — seventh ReceiverContract adopter, terminal exchange target: /v1/determine gains the hop-2 arm (EXCHANGE_TARGETS += canopy-snap; dry-run stays service-only), redact + QC export enforced user-only, EffectiveUser at the three attribution sites (incl. the service’s one genuine actor().map_or), the as_of/trigger orchestrator pins accept both orchestrator shapes (orchestrator_caller — the ET-evening 403 killer; date seam tracked as #1561), all five web worker writes ride the shared #1560 dispatch (which this MR also landed, closing #1560); F4 matrix activated (9 rows: 4 dual + 1 user-only + 3 service-only + the recompute service-or-exchanged pin; the seed determine row reclassified; floor 430 → 510) + receiver_contract_test.rs pins export enforcement, the direct-worker determine 403, and the cross-service pair-replay 401 (#1431)

S-caps

canopy-caps receiver slice (#1432)

Done (2026-08-22) — eighth ReceiverContract adopter, terminal target: determine gains the hop-2 arm (EXCHANGE_TARGETS += canopy-caps), redact enforced user-only with EffectiveUser, and the two worker-facing authorization PUTs move to require_service_or_exchanged with the BFF senders flipped (the snap-recompute precedent) — which surfaced #1564: both actions had been PUTting to nonexistent /v1/caps/* paths since #448 (fixed same MR); F4 matrix activated (7 rows incl. the matrix’s first PUT surface; floor 510 → 590) + receiver_contract_test.rs (5 live-green) (#1432)

S-wic

canopy-wic receiver slice (#1433)

Done (2026-08-23) — ninth ReceiverContract adopter, terminal target: determine gains the hop-2 arm (EXCHANGE_TARGETS += canopy-wic), redact enforced user-only with EffectiveUser (the caps twin), the appointment create widened to require_service_or_exchanged with BOTH wic BFF senders flipped (appointment + nutritional-risk; body-string attribution flags stay open); F4 matrix activated (6 rows; floor 590 → 650) + receiver_contract_test.rs (4 live-green) (#1433)

S-verification

canopy-verification receiver slice (#1434)

Done (2026-08-23) — tenth ReceiverContract adopter, terminal target with ZERO user-only routes (the applications precedent): the create moved to require_service_or_exchanged (the survey’s orchestrator-only note was stale — the BFF’s request-verification action posts it, sender now exchanged via #1560); the /internal/v1 api-key surface classified-not-migrated (no Claims exist there; retirement stays the flagged follow-on, N1 owns the stale security.adoc bullet); F4 matrix activated (seed pair live incl. the reclassified create probe + 2 new dual rows; floor 650 → 690) + receiver_contract_test.rs (3 live-green incl. the 422-zero-write exchanged create positive) (#1434)

S-enrollment

canopy-enrollment receiver slice (#1435)

Done (2026-08-23) — eleventh ReceiverContract adopter, terminal target with zero user-only routes: the #408 household gate migrated to EffectiveUser — its actor arms were UNREACHABLE (no verifier; every read passed) and the assignment check + audits are now live for exchanged workers (live-verified deny/bypass/pass ladder); the two #408-gated household reads + the two web-driven adverse-action writes widened to require_service_or_exchanged (the read widening makes the gate reachable), senders flipped (#1560); stay/reopen + enact-sweep + batchGet + CRUD stay service-only (FU-B/D4); F4 matrix activated (6 rows) + receiver_contract_test.rs (3 live-green) (#1435)

S-renewals

canopy-renewals receiver slice (#1436)

Done (2026-08-23) — twelfth ReceiverContract adopter, terminal target with zero user-only routes (uniform survey: 27 bare require_service_caller guards, zero actor consumption): the six web-driven worker writes (certification create, snap + program interim-contact/change-report, nudge action) widened to require_service_or_exchanged; their eleven BFF senders flipped via the shared renewals_write_client (#1560; one shared helper — same audience + surface for all); machine surfaces (universe snapshots, scheduler, rollup, periodic-report pipeline, redetermination — no web senders) + SSR reads stay service-only (FU-B/D4); body-field attribution unchanged (survey flags stand); F4 matrix activated (6 service-only GET rows) + receiver_contract_test.rs (3 live-green) (#1436)

S-notices

canopy-notices receiver slice (#1437)

Done (2026-08-23) — thirteenth ReceiverContract adopter, terminal target with zero user-only routes: ONE route widened — the citation render RPC (require_service_or_exchanged on a dedicated admin/studio_admin/auditor bar mirroring the web download gate; caseworkers excluded by design, role-deny live-pinned); its single sender flipped via the NEW InternalClient::into_neutral (the NeutralWrite counterpart of into_authorized, recognized by the #1004 route audit); machine surfaces (generate/queue/resend) + portal reads/mark-read + SSR reads stay service-only (FU-B/D4; the survey’s IDOR-delegation + resend flags stand); F4 matrix activated (4 service-only GETs + the render row on WorkerSubject::Admin) + receiver_contract_test.rs (3 live-green) (#1437)

S-reporting

canopy-reporting receiver slice (#1438)

Done (2026-08-23) — fourteenth ReceiverContract adopter and the FIRST dominant-UserOnly slice: all 21 supervisor report surfaces flipped to require_user_only(SUPERVISOR_OR_ABOVE) with devstack ENFORCING (broad-aud workers 403 aud_not_exact; service class 403; only the exchanged user-context arm passes — live-verified ladder); the three dual surfaces (overpayments summary + the two org-visible runs reads) deliberately unchanged — the runs reads' residual arms live-pinned (test + F4 Dual rows); NO web sender changes (the only BFF call is the dual summary, FU-A); the devstack suites migrated to exchanged bearers (steward-client precedent); F4 matrix activated (3 UserOnly 404-deterministic rows + 2 Dual runs rows) + receiver_contract_test.rs (4 live-green) (#1438)

S-appeals

canopy-appeals receiver slice (#1439)

Done (2026-08-23) — fifteenth and FINAL receiver of the chain, terminal target with zero user-only routes (uniform survey: 27 bare require_service_caller guards): the two web-driven worker writes (file, decision) widened to require_service_or_exchanged, senders flipped via the shared appeals_write_client (#1560); hearing/withdraw/IPV/internal surfaces stay service-only (FU-B/D4; the survey’s zero-attribution + body-actor flags stand as follow-ons); no portal or inbound service callers exist to strand; F4 matrix activated (6 service-only GET rows) + receiver_contract_test.rs (3 live-green: dual pre-write-neutral positives — the filing’s 422 enrollment binding + the decision’s 404 — service arm, rogue azp) (#1439)

P1

Portal target/scope-aware token sources + self-validation contract (#1440)

Done (2026-08-23) — PortalTokenSources: eight per-target ServiceTokenSource`s (the new `with_scopes builder mints with aud-canopy-<target>; each source self-validates against ITS OWN target audience — the exact contract replacing the process-wide canopy-internal-service pin); all 28 call sites across 10 portal modules split per target (cross-target token reuse in home/persona/notices/verifications/lookup eliminated — the audit spawn now fetches its own security token); realm CONTRACTED atomically in devstack (broad mapper removed, 8 aud-canopy-* optional scopes, 600s lifespan — the ADR-043 A1 short-exp dimension); identity render emits the narrow portal client (pin-tested) and identity verify --check-portal probes the narrow mint (exact target aud, broad aud ABSENT, service role, ≤600s); production rotation gates + rollback criteria recorded on #1440 (#1440)

P2

Portal azp allowlist + operation scopes at the 8 targets (#1441)

Done (2026-08-23) — citizen-class carve-out in canopy-auth (compiled recognition, both halves independent; require_service_caller kill
portal arms with per-route operation scopes); 12-scope portal:* vocabulary minted per target by PortalTokenSources + realm client scopes (include.in.token.scope=true) + identity render/--check-portal; 21 receiver routes flipped across the 8 targets (7 portal-only intake surfaces on applications; audit-ingest service-or-portal); F4 PortalLateralAccess live on all 47 target rows + PortalScopeMissing on the 9 classified rows (floor 945 → 1000); live pins in narrow_token_test (intra-target kill, scope kill, 8-target positive sweep incl. ack/respond/documents-list) + enrollment receiver tests (#1441)

P3

Origin-verifiable ownership binding — absorbs #665 (#1442)

Done (2026-08-23) — portal-signed 120s ES256 X-Canopy-Applicant claim (canopy-signing ApplicantClaimIssuer, distinct aud namespace, cross-family confusion refused both ways); middleware lift + typed ApplicantOwnership extension + require_owned_{application,household, person} guards (inert for non-citizen principals, fail-closed for the portal); portal mints per resource-keyed call (intake/authn surfaces exempt); six origins verify via the shared fail-loud boot helper and enforce per route incl. the upload subject-person binding and the uniform-404 post-load compares; F4 CrossOwnerAccess live on the 6 pre-load classified rows (floor 1000 → 1005); ownership negatives code-pinned live (ownership_claim_missing / ownership_mismatch / middleware 401s); closes #665 (#1442)

C1

Cutover cleanup — retire legacy guards + X-Canopy-Actor where migrated (#1443)

Done (2026-08-24) — retirement UNCONDITIONAL (hop-2 proven, off-ramp not triggered): the middleware 401s ANY request carrying the header; Claims::actor + EffectiveUser::ViaActor deleted (two shapes remain: Direct/System); canopy-signing’s actor issuer/verify/adapter deleted (module reshaped to claim_registry — the ES256 registry + shared internals survive for the #1442 applicant claims); the document review trio migrated to require_service_or_exchanged + in-handler human projection (reviewer = the exchanged bearer’s own sub; body-UUID impersonation probe retained); the four legacy no-actor patterns translated onto EffectiveUser (assignments supervisor-when-human, sections attribution, canopy-api admin-replay human-admin gate
idempotency principal); canopy-web’s #1560 dispatch DRAINED — no legacy service-token arm for user-context writes, unconfigured exchanger = hard exchanger_not_configured error (unit-pinned); web route harness gained an in-process RFC 8693 mock exchange so positive arms exercise the real broker; tanf attribution test rides a real exchanged bearer; F4: trio lands as UserOnly rows (floor 1005 → 1038); web-actor keypair dropped from compose + keygen rosters (#1443)

N1

Keycloak deployment-notes consolidation (#1444)

Done (2026-08-24) — idp-integration gains the stand-up-a-realm checklist (KC 26.2+ floor, per-client exchange toggle, mappers incl. the ADR-044 primary_programs claim, per-target + portal:* scopes, exchanger clients with the 300/240 lifespan split, ≤300s policy, retired-actor warning) + the honest no-exchange off-ramp (per-target service accounts generalize P1; the user-context path is inoperable without RFC 8693 post-C1); security.adoc’s stale X-Service-Api-Key bullet corrected to the ADR-019/ADR-043 posture with the api-key surface named as the classified residual (#1444)

S6

Fleet conformance matrix closure (#1445)

Done (2026-08-24) — the no-slice proofs land: canopy-rules gains its 2-row ServiceOnly tranche (still all-service, still rejecting user bearers; NEVER_FLIPS gives its unmintable exchange rows an honest permanent Pending — no aud-canopy-rules scope exists) and canopy-exchange is pinned vacuous by routes_is_an_empty_router (zero routes — epic &79 brings routes + contract together); MixedVersionState retired (fleet post-C1 uniform); floor 1038 → 1046; the raw-broad-audience kill is asserted on every user-only-bearing service (tanf/medicaid/persons/snap/caps/wic/reporting + the applications trio); remaining permanent pendings enumerated in testing.adoc (broker-side pair, IdP lane, portal post-load) (#1445)

T1

Program terminal — status reconciliation + epic closure (#1446)

Done (2026-08-24) — every program row terminal; plan archived (nav → Archive); roadmap phase note added; epic &52 closed with the shipping summary; the five deferral issues confirmed open + unblocked (workflow::ready; #1449 stays needs-spec) and referenced from the closing summary (#1446)

FU-A

Deferral: per-service audience for service-class tokens (#1447)

Deferred (post-program follow-up — tracked as #1447; the FU-A residual broad-aud=canopy service arms on service/dual routes are the documented v1 posture; #1571’s admin-replay hardening relates)

FU-B

Deferral: nested-hop exchange + attribution preservation (#1448)

Deferred (post-program follow-up — tracked as #1448; nested service hops under a user origin keep service identity today, attribution preserved at the origin write)

FU-C

Deferral: citizen-content process isolation (#1449)

Deferred (needs-spec — tracked as #1449; ADR-043 A4 narrowed ADR-023 Decision 3 to credential+data isolation, process/RCE isolation is explicitly post-v1)

FU-D

Deferral: identity-revocation guidance correction (#1450)

Deferred (trivial follow-up — tracked as #1450; ADR-043 A5 recorded the RFC 7009 token-value correction, the xtask guidance fix rides #1450)

non-KC

Deferral: non-Keycloak IdP deployment notes (#1451)

Deferred (documentation follow-up — tracked as #1451; the N1 checklist + off-ramp carry the Keycloak-shaped contract any IdP must replicate)

Epic: &52
Issues: #1418–#1451 (34 program issues; #546 was the decomposed placeholder, #1006 the already-shipped upload-quarantine child)
Branch: feature/546-oidc-program-plan (this plan MR only — each child issue gets its own feature/{iid}-… branch)

Context

ADR-023 (2026-05-23) mandates OIDC validation at every program service, token exchange for user-context requests, citizen-upload isolation, and service-class credential narrowing. Its original companion plan was a stub, and four of its premises are stale at HEAD:

  • The canopy-auth middleware is already fleet-mounted — canopy-api’s bootstrap installs the `AuthLayer centrally (crates/canopy-api/src/bootstrap.rs:156-168); "add the middleware to each service" is done and was never the hard part.

  • The citizen upload pipeline shipped behind quarantine (#1006, ADR-042).

  • The portal’s real downstream audience is 8 services (applications, security, verification, persons, notices, eligibility, snap, enrollment), not the 2 the stub guessed.

  • canopy-web already swapped JWT pass-through for service-token + X-Canopy-Actor (ADR-019), so the migration starts from actor-attribution, not from raw user tokens.

The gap that remains is authorization, not authentication. Every service accepts the same two audiences (canopy worker + canopy-internal-service) with any-match semantics, and any service:* role passes require_service_caller anywhere. One abused service credential — above all the portal’s, which parses citizen-supplied bytes — authorizes calls across the whole fleet. This program replaces that flat trust with per-target exchanged tokens for user-context requests, a narrowly-authorized portal credential, and receiver-side contracts that verify who is calling, for whom, and for what.

Frozen decisions (2026-08-10 ruling)

These are binding; deviations require a new ruling.

# Decision

R1

Full program activated now; auth foundations (the stub’s "Steps 1–2") first.

R2

The citizen-path mechanism is a dedicated narrow IdP service account, not RFC 8693 exchange — ADR-026’s opaque Redis sessions mean no citizen token exists to exchange. Recorded in amending ADR-043 (rides the first implementing MR, F1b; ADR-023 itself is immutable).

R3

Rollout is sequential, FTI-first.

R4

Exchanged-token cache: per-request.

R5

Keycloak-only v1; other IdPs additive later.

R6

X-Canopy-Actor retained until migration completes, then retired. EXECUTED by C1 (#1443, 2026-08-24): the header is rejected (401) fleet-wide.

R7

mTLS is a post-migration stretch goal.

Scope

In scope (what v1 actually closes):

  • Worker-context request chains carry the worker’s validated identity in a per-target exchanged token; user-only routes stop honoring service-class tokens.

  • The portal operates on a dedicated narrow credential, azp-allowlisted and ownership-bound at its 8 targets.

  • Every exchange is audited (success and failure) before the token is used.

Out of scope (this plan MR): product code; the amending ADR (F1b’s MR); realm/devstack changes; implementing #515/#518; hop-3 chaining; per-service service-class audiences; citizen-content process isolation; mTLS.

Deferred with owners — each a linked follow-up issue, not a footnote:

Residual Follow-up Milestone

Per-service audience for service-class tokens — a leaked service token still reaches service-only/dual routes in v1

FU-A (#1447, weight 5)

T5

Nested-hop exchange + attribution preservation (hop-3): snap→persons/enrollment, appeals→enrollment/snap, applications→persons, verification→persons, enrollment→applications stay service-class under user origins

FU-B (#1448, weight 5)

T5

Citizen-content process/RCE isolation — parsers still run in canopy-applications' privileged process; #1006 bounds the data/credential radius, not parser compromise. ADR-043 narrows ADR-023 D3 to credential+data isolation and names process isolation future defense-in-depth

FU-C (#1449, weight 8)

T5

identity revoke helper — ADR-023’s promise is itself flawed (RFC 7009 revokes token values, not JTIs); ADR-043 records the correction, FU-D ships the operational replacement

FU-D (#1450, weight 2)

T5

Non-Keycloak IdP portability notes (Authentik/Kanidm/Zitadel; joins the #512/#514/#515 set)

non-KC (#1451, weight 2)

T5

Design

A. EffectiveUser + fleet authorization-branch inventory (Phase-0 foundation)

Today "no actor claim" is read four incompatible ways across the fleet:

  1. no-actor-passesrequire_supervisor_actor (services/canopy-applications/src/api/assignments.rs:36);

  2. no-actor-passes-with-auditgate_household_actor_access (services/canopy-enrollment/src/api/mod.rs:123);

  3. no-actor-rejectsverified_reviewer (services/canopy-applications/src/api/documents_scan.rs:29);

  4. attribution-resolution (who to record, not allow/deny) — actor().map_or(claims.sub, |a| a.sub): medicaid handlers.rs:84-87 (FTI accessed_by), applications sections.rs:31-41 (422s on non-UUID sub), tanf discrepancy_handlers.rs:84, snap recompute_handler.rs:236, enrollment mod.rs:182.

An exchanged worker bearer has claims.actor() == None with the identity in the token itself — all four readings would misclassify it. So before any receiver flips, a typed EffectiveUser in canopy-auth resolves: direct user claims for a user bearer; verified actor for a legacy service bearer; None only for genuine system traffic. It exposes both an authorization verdict and a subject-to-attribute projection (pattern 4 is attribution, not authz — the type serves both).

F1a delivers the fleet manifest that seeds every slice: every actor() / is_service() / service_id() / role / ownership / audit branch, per service, with file:line. The require_* grep is only a starting index (plan-time counts: appeals 27 · applications 36+3+1 · caps 9+4 · eligibility 1+8+1 · enrollment 18 · medicaid 4+14 · notices 8 · persons 28 · renewals 25 · reporting 0+1 · security 3 · snap 7+21 · tanf 6+21 · verification 1+5 plus the api-key surface · wic 3+6).

B. Receiver-first, per-target rollout

Each target service migrates FTI-first through five gates: prepare receiver (accept exchanged tokens, keep accepting service+actor) → switch that target’s senders (canopy-web + orchestrator) → observe (conformance rows + audit events) → enforce (user-only routes reject service-class) → rollback criteria (config flip restores legacy acceptance without redeploying senders). A sender is never switched before its receiver is ready; C1 is small cleanup, not a big-bang cutover.

C. Receiver contract

Kills the raw-aud=canopy bypass:

  • User-context arm: exact target audience + azp in the authorized-exchanger allowlist + required worker role (preserves the caseworker-or-above bar). User-only routes stop accepting aud=canopy / aud=canopy-internal-service.

  • Service arm: dual routes keep the any-service:* arm in v1 — that is precisely the FU-A residual. Nested hops mint scope-less client_credentials tokens today, so requiring operation scopes on the service arm would break snap→persons. Operation-scope requirements apply only where classified: the portal’s citizen-reachable routes (P2) and any route a slice explicitly hardens.

  • Each slice’s route classification (user-only / service-only / dual / portal-only) gates which arm applies.

  • 401/403 frozen: 401 = no/invalid token; 403 = validated but unauthorized (wrong aud/azp/role). ADR-043 + middleware + tests must all encode this; S6 asserts the rejection of a raw aud=canopy token on a user-only route fleet-wide.

D. TokenExchanger broker

  • Runtime output validation before any use: signature/iss/typ; sub preserved; azp = this exchanger client; exactly the requested audience (never canopy/canopy-internal-service, never a service:* role); required worker role present in realm_access.roles — an exchange returning stripped roles fails loudly at the broker, not as a mystery 403 downstream; granted scope ⊆ requested; token_type Bearer; no refresh token; exp ≤ min(subject.exp, now+300s) (+5s clock-skew tolerance on the TTL arm, validation-time clock — #1565).

  • Cache key = the normalized exchange request (audience + canonical scope set
    purpose) — never "per audience" alone. Lifetime: per-request (ruling R4).

  • Audit-before-use, fail-closed: the broker records auth.token_exchange (including failed/denied exchanges) and does not release the token until the audit write commits. No ambient transaction on read requests — the broker owns its audit write path. Crash recovery + dedup key on the exchange jti. The sink (A1) is live before any enforced exchange.

  • Operational spec: exchange timeout, response body bounds, end-to-end secret/token redaction (the subject bearer rides a non-Debug, non-loggable request extension — F2), bounded retry + circuit breaker, cancellation safety, latency/error/cache metrics, and an explicit ban on silent fallback to a broad service token during IdP outage — the request fails instead.

  • Dedicated exchanger clients (canopy-web-exchanger, canopy-eligibility-exchanger) isolate exchange credentials, per-target scope policy, and the ≤300s exchanged-token lifetime from those services' ordinary tokens.

Keycloak GA semantics (26.2+ standard token exchange V2): per-requesting-client toggle; the subject token must carry the requester in aud (hence R1’s audience mappers on worker tokens); the exchanged aud derives from the requester’s client scopes (the audience param only down-filters); the requester must be confidential. RFC 8693 act-claim delegation is EXPERIMENTAL in Keycloak, so v1 uses the GA impersonation-style semantics: sub preserved, exchanging client visible as azp, no act claim. ADR-043 records this.

E. Portal isolation

  • P1 — token sources (LANDED, #1440): pre-slice one process-wide scope-less token served all 8 targets with self-validation pinned to canopy-internal-service. Acquisition is now per-target and scope-aware (PortalTokenSources, one narrow source per target minting with aud-canopy-<target>), each source self-validating against its own target audience. Devstack contracted atomically (code + realm deploy together); PRODUCTION narrowing runs as a rotation sequence (tokens live 1800s and are cached): expand → deploy → realm switch → drain/deny old broad tokens → contract, with rollback criteria per gate recorded on #1440.

  • P2 — receiver-side narrowing (LANDED, #1441): the portal credential is a compiled CITIZEN CLASS in canopy-auth (CITIZEN_CLASS_SERVICE_IDS — compiled, not config, because an empty env allowlist would fail OPEN by leaving the portal recognized as an ordinary service bearer; recognition checks the service:* role half and the azp half independently so a foreign role cannot mask the azp). Claims::require_service_caller kills it with 403 portal_on_non_portal_route, and every service-accepting contract arm delegates there — so every non-portal route in the 8 targets rejects the portal through one check. Classified routes re-admit it through the portal arm (azp allowlist + a per-route-family operation scope from the 12-scope portal:* vocabulary; 403 portal_scope_missing without it) via require_portal_only / require_service_or_portal / require_dual_or_portal / require_service_or_exchanged_or_portal. Portal-only surfaces — verify-credential, drafts, recovery initiate/kill, all on canopy-applications — serve ONLY the citizen arm (403 portal_only_route for everything else, services included); audit-ingest is service-or-portal (the exchange-audit sinks post there with ordinary service tokens). Negatives: F4’s PortalLateralAccess runs on every row of the 8 targets (+ PortalScopeMissing on the classified rows; floor 945 → 1000) with live pins in narrow_token_test + the per-service receiver tests.

  • P3 — ownership binding (LANDED, #1442; design adjudicated 2026-08-23: portal-signed claim on a dedicated header, recorded as an ADR-043 amendment): the portal mints a 120s ES256 X-Canopy-Applicant claim per resource-keyed call from its ADR-026 session — application id as sub, household/person bindings once resolved through the authenticated applications read — signed with a key that is deliberately NOT the OAuth2 client secret (a stolen narrow bearer cannot mint claims; resource routes fail closed 403 ownership_claim_missing). Deliberately NOT the (since-retired, C1) worker actor channel — the applicant claim rides its own header + aud namespace. Six origins verify (shared fail-loud boot helper; kid derived from the raw pem on both sides) and enforce per route: applications drafts/read/documents (+ the upload’s subject-person binding — the documents.rs:240 hole), persons' person read, notices list + reads (post-load compares are UNIFORM 404s — no existence oracle), verification list/respond (the citizen arm’s session binding runs before the legacy 403/422 arms, closing their oracle), eligibility determinations, enrollment annual-summary. Non-citizen principals pass every guard untouched. F4: CrossOwnerAccess live on the 6 pre-load classified rows (post-load rows honestly Pending — absence and foreign are deliberately indistinguishable); the exempt intake surfaces (create-draft, verify-credential, recovery) are the authentication itself. P3 is the real #665 and closes it.

F. Audit events

auth.token_exchange payload: {sub, azp, target aud, granted scope, purpose ∈ worker_request | orchestrator_fanout, exp, jti, parent/subject jti, correlation id} — IDs/enums only, populating canopy-security’s structured actor/resource fields; covers web and eligibility hop-2, success and failure. The stub’s citizen_upload/background_job purposes die — neither path exchanges (R2; ADR-023 D4).

G. Conformance harness

Route-manifest-driven, landed before the first receiver flip (F4), extended per slice, closed fleet-wide at S6. Matrix rows per service: exchanged-accepted · service-class-rejected-on-user-only · raw-aud=canopy-rejected · exchanged-token-missing-worker-role→403 · wrong-role · wrong-exchanger-azp · extra/absent audience · excessive scope/lifetime · actor-header
exchanged-token combination · portal lateral access inside an allowed target · cross-owner access · cache separation · IdP-failure behavior · mixed-version state (the mixed-version kind was retired at S6 — the fleet is post-C1 uniform, so no transition combination remains for a two-version stack to disagree about).

Phase map

34 issues, all epic_id-linked under epic &52. exchange and rules get no slice — empty router / all-require_service_caller; nothing to migrate.

Phase ID Issue Delivers Weight Depends

0

F1a

#1418

Fleet authorization-branch inventory manifest (docs page; seeds every slice)

3

— (sole DAG root)

0

F1b

#1419

EffectiveUser type + guards + amending ADR-043 (first implementing MR)

5

F1a

0

F2

#1420

Sensitive validated-bearer extension + exact-aud/azp/role policy primitives

3

F1b

0

F3

#1421

TokenExchanger broker (Design D, inert) + test-lib acquire_exchanged_token

5

F2

0

F4

#1422

Conformance harness skeleton (Design G, pre-flip)

3

F2

1

R1

#1423

Exchanger clients + realm wiring (toggles, audience mappers, per-target client scopes, realm-role mappers, ≤300s exp, client policies); identity verify exchange probe; render 13→18; hop-2 devstack proof

5

F3

1

A1

#1424

auth.token_exchange audit sink + broker emit live (before any flip)

3

R1

2

S-svc ×15

#1425–#1439

Per-target receiver slices (Design B), FTI-first: tanf → medicaid → security → persons → applications → eligibility → snap → caps → wic → verification → enrollment → renewals → notices → reporting → appeals

3 for tanf/applications/security/eligibility/persons, else 2

A1 + F4 + the prior slice (sequential, R3)

3

P1

#1440

Portal target/scope-aware token sources + self-validation contract + rotation sequence

5

F3

3

P2

#1441

azp allowlist + operation scopes at the 8 targets
negative tests

5

P1 + the 8 target slices

3

P3

#1442

Origin-verifiable ownership binding (absorbs and closes #665)

5

P2

4

C1

#1443

Cutover cleanup: retire legacy guards + X-Canopy-Actor where migrated (conditional on the R1 hop-2 outcome); drain broad tokens

3

All slices + P2

4

N1

#1444

Keycloak deploy-notes consolidation (idp-integration.adoc; fixes the stale X-Service-Api-Key bullet in security.adoc)

2

R1

4

S6

#1445

Fleet conformance matrix closure

3

C1

4

T1

#1446

Terminal: status reconciliation, plan → Done + nav Archive, epic closure, roadmap update

2

C1 + P3 + N1 + S6

FU-A/B/C/D, non-KC

#1447–#1451

The five deferrals (Scope table)

5/5/8/2/2

FU-A, FU-B ← C1; FU-D ← R1; non-KC ← N1; FU-C relates F1b only (needs-spec)

The full edge set is wired in GitLab (verified 2026-08-11): the foundation chain F1a→F1b→F2→{F3,F4}, F3→R1→A1, {A1,F4}→S-tanf, the 14 sequential slice edges, F3→P1→P2 (+ the 8 portal-target slices→P2), P2→{P3,C1}, S-appeals→C1, R1→N1, C1→S6, {C1,P3,N1,S6}→T1, C1→{FU-A,FU-B}, R1→FU-D, N1→non-KC.

Caller manifest

Seeds the slices; each slice verifies and completes its own rows from F1a.

Class Callers v1 treatment

User-context edge

canopy-web direct calls; eligibility orchestrator fan-out

Exchanged tokens (R1 wiring; eligibility slice for hop-2)

Nested hops under a user origin

snap recompute → persons (recompute_handler.rs:119,149,407) and enrollment (:120,150,455); appeals request-path → enrollment via AdverseActionsClient (main.rs:127-130 — the actor rides a string param, not the header, so C1’s header retirement does not touch its attribution) + snap (main.rs:203); applications finalize → persons; verification → persons (main.rs:64); eligibility → persons + verification; enrollment → applications

Stay service-class (FU-B); their target routes classify dual

Background

appeals workers/stay/reconcile (the EnrollmentClient at main.rs:50 is background-only — clients.rs:190-191 and :284 carry stale contrary doc comments, corrected in the appeals slice); applications reconciler; notices worker/recovery; renewals subscriber/PR; medicaid ELE; reporting workers; signing registration; key-history

Stay service-class (ADR-023 D4)

Issue matrix

Common to all 34: epic &52, testable ACs in-body, DAG links wired. Milestone T1 — Correctness except the five deferrals (T5 — New Features; new capability beyond the ratified program, T5-last per the tier order). Lifecycle: F1a opened workflow::ready (sole root); every other spine child workflow::blocked; FU-C workflow::needs-spec.

Issues Type / priority Extra labels

F1a #1418, N1 #1444, non-KC #1451

type::documentation; high (F1a) / medium

program::infrastructure

F1b #1419, F2 #1420, F3 #1421, F4 #1422

type::security / high

program::infrastructure, service::shared-crates

R1 #1423

type::security / high

program::infrastructure, service::devstack

A1 #1424

type::security / high

program::infrastructure, service::security, compliance::pub-1075, compliance::hipaa

Slices #1425–#1439

type::security / high

service::<svc>; program::tanf|medicaid|snap|caps|wic for program services, program::infrastructure for security, else program::cross-program; compliance::pub-1075 on tanf/medicaid/security/persons; compliance::hipaa on medicaid

P1 #1440, P2 #1441, P3 #1442

type::security / high

service::portal, program::cross-program, compliance::pub-1075

C1 #1443

type::security / high

program::infrastructure, compliance::pub-1075

S6 #1445

type::security / medium

program::infrastructure

T1 #1446

type::chore / medium

program::infrastructure

FU-A #1447, FU-B #1448

type::feature / medium

program::infrastructure, planning::needs-plan

FU-C #1449

type::feature / medium

program::cross-program, service::applications, workflow::needs-spec

FU-D #1450

type::chore / medium

program::infrastructure, service::xtask, planning::trivial

non-KC #1451

type::documentation / medium

program::infrastructure, planning::trivial

Relations swept 2026-08-11: #665 re-pointed (blocked by P3 #1442, which absorbs it); #1008 relates F1b; #985/#874/#731/#1356 relate C1; #515 relates F1b
non-KC; #518 relates R1 + N1; #512/#514 relate non-KC.

Off-ramps

  • Hop-2 rejected by Keycloak (chained exchange is not explicitly documented — R1 proves or disproves it in the devstack): the orchestrator keeps service-token + actor for its fan-out; FU-B owns the fix; C1’s actor retirement stays conditional on what actually migrated.

  • Keycloak-without-exchange deployments: dedicated per-target service accounts everywhere (the portal pattern generalized), documented in N1.

Verification

This plan MR is docs-only; its verification is structural:

  1. cargo xtask plan-lint — Status vocabulary clean.

  2. cargo xtask check-docs — sync/drift gate clean.

  3. Full pre-push battery (the sole functional gate; docs-only changes must not regress it).

  4. Epic &52 re-fetch: 36 children (34 open after #546 closes); DAG link types spot-checked; #665 re-pointed; roadmap tracker updated with history untouched.

Program-level verification lives in the child issues: F4/S6 conformance matrix, per-slice observation gates, and the R1 identity verify exchange probe.

Documentation Updates

  • This plan page (rewritten in this MR; per-slice updates ride the slices).

  • Roadmap — Phase E.9 tracker entry (this MR).

  • ADR-043 (F1b’s MR) + ADR index.

  • authorization-inventory.adoc (F1a’s MR, nav-linked).

  • Per-service api/canopy-*.adoc pages — each slice updates its own.

  • IdP integration (R1/N1) + the security.adoc X-Service-Api-Key correction (N1).

  • CHANGELOG.adoc — implementing MRs only (this MR ships no code or contract change).

References

  • ADR-023 (ratified by this plan, amended by ADR-043)

  • ADR-019 (service identity + X-Canopy-Actor, amended)

  • ADR-014 (audit chain the exchange events extend)

  • ADR-026 (opaque applicant sessions — why R2 replaced the citizen-exchange mechanism)

  • Maintainer ruling: #546 note 3666918785 (2026-08-10)

  • Keycloak 26.2+ standard token exchange (V2, GA); RFC 8693, RFC 6749, RFC 7009, RFC 8705

  • IRS Pub 1075 §9 / Pub 4812 §3.5; HIPAA 45 CFR §164.312(b)

Edit this page · default