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:
-
primary_programsis 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. -
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. -
There is no canopy-side override. No
unscoped_worker_programssetting, no environment escape hatch, no jurisdiction toggle. -
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_roleadmission rejection).