ADR-044: Worker Program Scope Is a Required IdP Claim

On this page

Status: Accepted (2026-08-20)
Issue: #742 (umbrella), #1515 (this decision)
Epic: &78 (worker program scope, enforced); satisfies epic &62 B2
Relates to: ADR-019 (service identity), ADR-023/ADR-043 (OIDC program), ADR-001 (service-per-DB), the authorization inventory, the implementing plan

Context

canopy-web is the worker portal’s backend-for-frontend. Every worker session carries a program scope — which of SNAP, TANF, Medicaid (incl. CHIP), CAPS and WIC that worker is authorized to see and act on — derived from the primary_programs claim on the IdP-issued access token.

Until #1515 that scope was advisory. Two independent predicates (session::program_in_scope and audit::event_visible_to_programs) began with an is_empty() test that treated an absent claim as see and do everything. An IdP without the claim mapper — a new provider, a mis-provisioned worker, a mapper that silently stopped emitting — therefore granted jurisdiction-wide read and write. The failure was silent by construction: nothing distinguished "this worker is authorized for all five programs" from "nobody told us anything about this worker".

That is the exact inversion IRS Publication 1075 least privilege (AC-6) forbids for a system holding FTI-adjacent data. The absence of an authorization statement is not an authorization.

Decision

Program scope is an attribute of the worker’s identity, and a token that does not carry a usable one is not admitted.

Concretely:

  1. primary_programs is a required claim. A token whose claim is absent, empty, or names a program canopy does not recognize fails admission. Both admission points — the OAuth callback and the slow-path refresh re-derivation — apply the identical rule through one shared helper (session::rederive_authz), so login and refresh cannot drift.

  2. The claim is parsed once, at admission, into a WorkerProgramScope: a non-empty, deduplicated, canonically ordered set. It is structurally impossible to construct an empty one, so no downstream consumer can reintroduce a see-all branch — there is nothing left to branch on.

  3. There is no canopy-side override. No unscoped_worker_programs setting, no environment escape hatch, no jurisdiction toggle.

  4. The rule is role-agnostic. Supervisors, jurisdiction admins, Studio admins, analysts and auditors are scoped by their claim exactly like a caseworker. There is no privileged tier that bypasses it.

Why no canopy-side override

canopy’s standing posture for a policy control is a fail-closed default plus an explicit, accountable deployment override (ADR-041 is the reference shape: canopy owns the mechanism, the jurisdiction owns the policy). That framing is cited here as an analogy, not as authority — ADR-041’s own subject is a logging policy that is fully jurisdiction-overridable, and it does not license an override here.

The override already exists, and it lives with the rest of worker authorization: grant the programs in the IdP claim mapper. A deployment that wants a cross-program worker maps all five. A canopy-side knob would be a second source of truth for authorization scope, competing with the IdP that already owns the worker’s identity, their role, and — since #1024 — the rule that a token with no recognized role is refused. Two sources of truth for one attribute is how a fail-open comes back.

The same logic already governs the sibling attributes: an unrecognized role is a rejected login with no canopy-side override, and a malformed primary_programs claim was already a rejected login before this ADR. #1515 closes the remaining gap — the absent claim — rather than opening a new exception for it.

Consequences

Admission and the rejection surface

Admission failure has three codes, shared verbatim between the rejection type and the login page: no_role, missing_primary_programs, malformed_primary_programs.

/auth/callback redirects a refused worker to /login?error=<code>. In a single-IdP deployment /login normally restarts OAuth immediately, which would spin IdP → callback → reject → IdP forever with nothing on screen — the pre-existing no_role path was already broken this way. /login therefore suppresses the automatic redirect whenever a recognized error code is present and renders the sign-in page with a banner naming the missing claim. An unrecognized ?error= value suppresses nothing: it renders the ordinary login, so a stale bookmark cannot lock anyone out, and the banner copy is chosen from a closed enum so the raw parameter never reaches the page.

Maximum revocation delay

The scope stored in the session is authoritative until the next token refresh. Changing a worker’s claim in the IdP therefore takes effect no later than one access-token lifetime — that is the maximum revocation delay this design accepts. The emergency path is not "wait for the token": purge the affected rows from the session store, which forces re-admission on the next request.

Rolling deploys

SessionData keeps the stored key primary_programs under [serde(rename)], with no [serde(default)]. The consequences are deliberate:

  • old replica reading a new session — the key and shape are unchanged, so it behaves correctly;

  • new replica reading a legacy session (missing key, or []) — deserialization fails, the session is discarded, and the worker is sent to /login. Fail-closed.

The residual exposure is a legacy session on an old replica, which is exactly today’s behavior; the cutover closes it with a session-store purge followed by an old-replica drain. A renamed key would have been strictly worse: the old replica would see its field missing, default it to [], and re-grant see-all.

All-of versus any-of

Scope checks are not uniformly "any". The rule is fixed by what the operation does; both halves land, pinned by tests, in the mutation MR (#1516):

  • all-of — a mutation whose effect spans several programs requires every one of them in scope. Approving an application runs a determination for each requested program; accepting a document can resolve verifications across programs. A worker who is not authorized for one of the affected programs may not trigger the whole effect.

  • any-of — a mutation on a household’s shared facts (income, assets, expenses, address, membership) requires any in-scope participating program, because the fact is not per-program.

An empty or unparseable authoritative set is never a permitted write: it fails closed (403/422), not "no programs to check, therefore allowed". (Also #1516 — today’s approve/deny handlers still proceed on an empty set; that defect is in its scope.)

CHIP

The authorization vocabulary has five programs; CHIP is administered under Medicaid and canonicalizes to it, so a chip claim satisfies a Medicaid check and vice versa. Storage is different: application and appeal rows persist the exact slug, so a Medicaid scope expands to ["medicaid", "chip"] when it is used as a query filter. Membership and storage expansion are separate accessors on WorkerProgramScope precisely so the two are not confused.

This is a BFF control, not end-to-end enforcement

canopy-web enforces scope at its own edge. Requests it makes upstream still carry canopy-web’s service identity, so this ADR does not:

  • protect a service called directly, bypassing canopy-web;

  • protect against a compromised BFF;

  • close object-state races between the authorization lookup and the write.

The upstream actor-claim work (#424, ADR-019, ADR-023) and network isolation remain required and are not superseded by this decision. What this ADR buys is that the worker-facing surface stops being fail-open. The follow-on mutation MR (#1516) makes the enforcement structural: the route audit (cargo xtask route-authz) gains a scope pass that fails the build for an unclassified mutating route, and the write clients become unreachable without an authorization value.

Operational

Admission rejections are counted by (idp, stage, reason) (canopy_web.auth.admission_rejected) so a canary replica shows a missing claim mapper as a rejection spike attributable to one provider rather than as a wave of support tickets. Session rows that no longer deserialize are counted separately (canopy_web.session.decode_failed) — before #1515 that was collapsed into "no session", which would have made a botched schema cutover look like an ordinary afternoon of logouts.

Worker provisioning gains the claim as a required step, and break-glass is granting the claim in the IdP — not a canopy flag.

Alternatives considered

Keep the fail-open, warn loudly. A log line nobody reads is not a control, and the deployments most likely to lack the mapper are the ones least likely to be watching canopy’s logs. Rejected.

Default an unscoped worker to a minimal scope (e.g. SNAP only). Still invents an authorization statement the IdP never made, and does it silently. A worker would appear to work normally while missing four programs' worth of their caseload — a harder failure to diagnose than a refused login. Rejected.

A canopy-side unscoped_worker_programs setting. The second-source-of-truth problem above. Rejected.

Enforce upstream instead of at the BFF. Correct in the long run and tracked as the actor-claim work, but it does not exist yet, and the disclosure surface is live now. This ADR is explicitly a BFF control that does not retire that work.

References

  • IRS Publication 1075, AC-6 (§4.1) — least privilege. The revision in force is pinned in Security.

  • #1024 — the role half of the same rule (no_role admission rejection).

  • Plan: program-scope enforcement.

Edit this page · default