Plan: worker program scope, enforced (#742)
On this page
Umbrella issue: #742 · Epic: &78 (children #1515–#1520) · also satisfies epic &62 B2 · Approved: 2026-08-20 · Done: 2026-08-21 (all six MRs merged; Parts A–E reflect as-built — see A — deviations from the approved plan (as-built), B — deviations from the approved plan (as-built), C — deviations from the approved plan (as-built, MR-4), Part D deviations (plan → as-built)) · Decision: ADR-044
Context
Three defect classes share one root: canopy-web’s program scope is advisory.
1 — The fail-open. session::program_in_scope began primary.is_empty() || …, and
audit::event_visible_to_programs carried an independent copy. An IdP without the
claim mapper silently granted jurisdiction-wide read and write. Nothing distinguished
"authorized for all five programs" from "nobody told us anything about this worker".
2 — The gate checks a label, not a resource. Thirteen production mutations pass
caller-supplied form.program to the shared helper — income ×3, members ×3,
assets ×2, expenses ×2, ievs ×2, address ×1. A SNAP-only worker posts
program=snap and edits person facts on a TANF-only household, or resolves a SNAP IEVS
discrepancy for a household outside their scope. ensure_household_member (#996) binds
person→household but never household→scope. Three more mutations have no gate at all:
actions::{accept_document, reject_document, scan_override_document}.
3 — Reads are almost entirely unscoped. Case search and the command palette return
jurisdiction-wide names and DOBs; application index, appeals, notices, renewals and team
queue are unscoped; notice PDFs and document bytes are authenticated-only, so direct-ID
access works; dashboard panels are jurisdiction-wide; the audit page, CSV export and
citation-by-id are unfiltered; /sse broadcasts the full serialized envelope of
every *.determined / notice.generated / appeal.filed event to every connected
worker with no filter whatsoever. Reads are the disclosure surface; a write-only fix
would be a fig leaf.
The scope of #742 is all three classes.
The decision this plan records
Program scope is an attribute of the worker’s identity. canopy already refuses to admit
an identity whose role is absent (/login?error=no_role, #1024) or whose
primary_programs claim is malformed. The same rule applies to the same attribute:
a missing-or-empty primary_programs claim is a rejected login, with no canopy-side
override. A unscoped_worker_programs knob would be a second source of truth for
authorization scope, competing with the IdP that already owns role, identity and the
malformed-claim rule; the deployment’s override exists and lives with the rest of worker
authorization — grant all five programs in the claim mapper. ADR-041 is cited only as
the mechanism-vs-policy framing: an analogy, not authority.
Two limits ADR-044 states plainly rather than implies:
-
Role-agnostic. Supervisors, admins and auditors are scoped by their claim. A cross-program admin is granted all five in the mapper. No role-tier bypass.
-
This is a BFF control, not end-to-end enforcement. Upstream services still receive canopy-web’s service identity; the route audit cannot protect direct internal calls, a compromised BFF, or object-state races. The upstream actor-claim work (#424, ADR-019, ADR-023) and network isolation remain required and are not superseded.
Citation hygiene: the repo’s unversioned "PUB-1075 §9.3.1" references are stale. Least privilege is AC-6 (§4.1) in IRS Publication 1075 (Rev. 11-2021); Security pins the revision in force and MR-1 sweeps the stale citations repo-wide, leaving frozen records (CHANGELOG, archived plans, the roadmap’s delivery log) alone.
Design
Part A — the scope primitive and admission (MR-1, #1515)
A1. WorkerProgramScope (services/canopy-web/src/program_scope.rs)
A complete, non-empty, deduplicated set — a private Vec with two predicates cannot
serve the existing iteration, fan-out and default-selection call sites.
| Concern | Decision |
|---|---|
Invariants |
Structurally non-empty ( |
Construction |
|
Error |
|
Three representations |
authorization membership — canonical |
Derives |
|
Program itself moves to a new services/canopy-web/src/program.rs so program_scope
and session can depend on it without reaching into a UI page module;
api::case_detail re-exports it, and the UI-flavored methods stay there.
A2. Rolling-deploy-safe field rename
SessionData.primary_programs → SessionData.program_scope: WorkerProgramScope, with
[serde(rename = "primary_programs")] preserving the wire key and no
[serde(default)]. A renamed key would make an old or rolled-back replica see the old
field missing, default it to [], and re-grant allow-all. With the key preserved:
-
old replica + new session (always non-empty) ⇒ correct scoped behavior;
-
new replica + legacy
[]session ⇒ deserialization fails ⇒ no session ⇒/login(fail-closed); -
the only residual exposure is a legacy session on an old replica — today’s behavior — closed by the cutover’s session purge plus old-replica drain (A5).
Tests pin old→new and new→old serialization in both directions.
A3. Admission is the single derivation point
| Site | Change |
|---|---|
|
|
|
Returns |
Refresh failure path |
After a successful refresh-token rotation, an admission failure previously returned without persisting or clearing the session — the invalid-grant cascade the surrounding code warns about. The rotated tokens are persisted or the session is flushed before redirecting; never neither. |
Freshness |
Stored scope is authoritative until refresh. ADR-044 states the maximum revocation delay (= access-token lifetime) and the emergency path: purge sessions in the store. |
Downstream, program_scope is non-empty by construction, so both is_empty() ⇒ see-all
branches are deleted, not inverted.
A4. The login error page
LoginQuery had only return_to, and single-IdP mode restarts OAuth immediately, so
/login?error=… → IdP → callback → reject → loop. The pre-existing no_role and
malformed_primary_programs redirects were already broken this way.
LoginQuery gains an error field narrowed by LoginError::from_code; the sign-in
template gains an error-banner block; and automatic OAuth redirection is suppressed
whenever a recognized admission error is present — the sign-in page renders with a
banner naming the missing claim and telling the worker to ask their administrator. An
unrecognized ?error= value suppresses nothing and renders the ordinary login, so a
stale bookmark cannot lock anyone out, and the banner copy comes from the closed enum so
the raw parameter never reaches the page.
A5. Production cutover
Full procedure: the cutover runbook. Shape: IdP inventory + backfill → per-provider token preflight (the claim must ride the access token and survive rotation) → canary watched through the A6 counters → legacy-session purge → old-replica drain, with a rollback that is safe only with the purge already applied. Onboarding gains the claim as a provisioning step; break-glass is granting the claim in the IdP, not a canopy flag.
A6. Observability
canopy_web.auth.admission_rejected{idp,stage,reason} — low-cardinality, closed label
vocabulary, pinned by test; stage distinguishes login from refresh. Session-schema
deserialization failures get their own counter (canopy_web.session.decode_failed) —
previously collapsed into "no session" by unwrap_or(None), which would have made a
botched cutover look like ordinary logouts.
A7. Devstack fixtures
Keycloak imports a realm only when it is absent, so an edited canopy-realm.json is
invisible to dev refresh/dev reload. MR-1 exposes the existing force-recreate path as
cargo xtask dev reimport-realm (recreates only the stateless keycloak container and
clears tests/e2e/auth/*.json, whose cached tokens would otherwise carry pre-edit
claims); docker compose remains off-limits.
Every existing worker fixture gains an explicit primary_programs attribute, and the
limited-scope fixtures Parts C–E need are added rather than carved out of the
existing ones (see A — deviations from the approved plan (as-built)): jane.supervisor.snap, jane.admin.snap,
jane.auditor.snap, jane.chip-worker (chip↔medicaid canonicalization) and
jane.unscoped (the rejection path). fti.auditor, data.steward and applicant.test
hold no WorkerRole-recognized role, so they are not admissible web fixtures and giving
them programs would change nothing.
A — deviations from the approved plan (as-built)
-
SessionData::in_program_scopesurvives MR-1 as a thin shim overWorkerProgramScope::contains_slug(57 sites) as doesfact_editor::deny_unless_in_scope(26). Both are deleted by the MRs that replace their call sites (MR-2 for mutations, MR-4 for reads); deleting them in MR-1 would have pulled Parts B–D into one MR. -
ScopeAdmissionErrordoes not reuseClaims::parsed_primary_programs()— that helper discards which slug was bad, andMalformed(String)names it in the log line. -
from_claimispub, not construction-by-TryFrom-only: the cross-crate admissibility guard and the login surface both construct from a slug slice. -
The scope-denial structured signal moves to MR-2, where the first 403 that emits it exists. MR-1 ships the two admission counters only.
-
WorkerProgramScopegainedcontains_all(the all-of rule),is_all_programs,Display,every_program_for_test, and a cross-crate guard asserting canopy-auth and canopy-web agree on the admissible slug vocabulary. -
build_applications_queryandhero_apps_querytakestorage_slugs()in MR-1 rather than MR-4 — they were already program-filtering and would otherwise have had to keep a now-unrepresentable empty-scope branch. -
The
my_queuesequential renewals fan-out is deferred to #1518 with an in-code comment naming the issue, not silently left. -
LoginQuery.erroris aStringnarrowed byLoginError::from_coderather than a directly-deserialized closed enum: serde would reject an unknown value into a 422 before the handler could decide to render the ordinary login page. -
A7 adds new limited-scope fixtures instead of narrowing the existing privileged ones. Narrowing them in MR-1 would break unrelated E2E specs that MR-1 does not otherwise touch, because MR-1 does not scope reads — that is MR-4/MR-5.
-
A7 additionally ships
cargo xtask dev reimport-realm(above); the approved plan assumed an operator-run sequence.
Part B — mutations (MR-2, #1516)
B1. ProgramScope<P>: hoist the statically-known gates
A pure guard mirroring session::WritePermission — it yields nothing, so handler bodies
only lose their gate:
pub trait ProgramTag: sealed::Sealed { const PROGRAM: Program; }
pub struct ProgramScope<P: ProgramTag>(PhantomData<P>); // FromRequestParts
ProgramTag is sealed, and a unit test asserts every tag’s PROGRAM constant
exhaustively — matching the type name ProgramScope<Wic> does not prove
Wic::PROGRAM == Wic.
Corpus: every mutation whose program is a compile-time literal — ~30 handler signatures
across actions.rs, actions_snap*.rs (8 sites), actions_tanf.rs, actions_caps.rs,
actions_wic.rs, actions_medicaid.rs (4, including ingest_cmd_update_medicaid).
file_recert_nudge/dismiss_recert_nudge share a gate inside post_nudge_action — the
guard goes on both signatures and the helper check is deleted. The census is derived by
the audit tool, not by hand.
This is not behavior-neutral, and the delivery notes and CHANGELOG say so: today’s
static gates return Result<_, Html<String>>, i.e. HTTP 200 with an HTML body; the
extractor returns a real 403. It also drops the cosmetic id from the banner, moves
scope denial ahead of malformed-form rejection, and adds a second session resolution per
request.
Adding a guard makes resolve_discrepancy_tanf an eight-argument fn and trips
clippy::too_many_arguments under -D warnings; the three ubiquitous Extension`s
collapse into a `WriteDeps FromRequestParts bundle (the eligibility DetermineDeps
precedent).
B2. AuthorizedResource + ScopedClients
Classification cannot enforce anything — deleting a dynamic handler’s gate would leave the audit green. So the authorization result becomes a value the write path cannot proceed without:
/// Proof that `worker`'s scope covers the AUTHORITATIVE programs of a
/// specific resource. Obtainable only from an upstream-backed lookup.
pub struct AuthorizedResource { /* resource id + the authoritative program set */ }
impl ServiceClients {
/// The only accessor exposing `post`/`put`/`delete` to a handler.
pub fn authorized(&self, authz: &AuthorizedResource) -> ScopedClients;
}
Every handler already funnels through clients.with_service_identity(&svc_token).await
(78 call sites). It keeps returning a client bundle, but that bundle exposes only get;
post/put/patch/delete move to ScopedClients, reachable solely via
.authorized(&authz). Read handlers are unaffected; deleting a write handler’s check
stops compiling. Authoritative program sets, never form.program:
| Resource | Authority |
|---|---|
Household / person facts (income, assets, expenses, address, members) |
the household’s participating programs, from the same |
Application (approve, deny, run determination, intake page) |
|
IEVS discrepancy |
SNAP, authoritatively — these handlers mutate |
Document accept / reject / scan-override |
the programs of the verification(s) the document resolves |
ELE consent |
|
All-of vs any-of is decided explicitly: a mutation whose effect spans several programs requires all of them in scope (approving an application runs a determination for every requested program; accepting a document can resolve verifications across programs); a mutation on a household’s shared facts requires any in-scope participating program. Both rules are stated in ADR-044 and pinned by tests.
Empty / malformed target sets fail closed. Approve/deny currently discard malformed program entries and proceed on an empty set, and request-verification explicitly proceeds when the set is missing or not an array. The authoritative set is parsed into a typed non-empty collection; unparseable or empty ⇒ 422/403, never a permitted write.
B3. route-authz closes the loop
Extend xtask/src/cmd/route_authz.rs with a scope pass covering all 66 mutating
registrations (api, studio, composition), keyed on HandlerRef::Named { module, name }:
-
RequireExtractor("program_scope::ProgramScope<Wic>")— module names the program; -
RequireAuthorizedWrite— the handler must resolve anAuthorizedResource, verified by requiring the canonically-resolvedauthorized(call and that it dominates every write in the handler’s control flow, not merely appears; -
NotProgramScoped(reason)— genuinely cross-program, reason recorded.
Unlisted ⇒ hard failure, same remediation message as the existing Unclassified arm.
fn_extractors / top_type_resolved recursively resolve generic tag arguments, type
aliases and local shadows. No new CI step — route-authz is already a
cargo xtask validate gate. Deletion-canary tests: removing a gate from a fixture
handler must fail the audit.
B — deviations from the approved plan (as-built)
-
The household fact-write authority is the union of
programs_requestedacross the household’s applications (GET /v1/applications?household_id=, one extra upstream read per fact write) — NOT the persons/fullfetch, which carries no program data at all (a plan premise found false at implementation). The membership IDOR guard still rides the/fullfetch; the two lookups are sequential, authorization first. -
ProgramScope<P>yields the proof (.authorized()→AuthorizedResource) rather than nothing: once B2 locked the write verbs behind the proof type, the statically-gated handlers needed one too, and re-deriving it in the body would have duplicated the check the extractor already ran. -
AuthorizedClient(owned) joinsScopedClients(borrowed): the ADR-043 exchanged-token path builds per-targetInternalClientclones carrying the worker-context bearer, which cannot ride a roster borrow — blessed viaInternalClient::into_authorizedagainst the same proof type. -
WriteDepsis adopted only where the argument count demands it (resolve_discrepancy_tanf, the one 4-Extension handler that would exceedtoo_many_argumentswith the guard added); other handlers keep their explicit Extensions to bound the diff. -
NeutralWritehas a single variant (CitationRender): the composition and studio mutations write canopy-web’s own DB via sqlx, notInternalClient, so only the audit-citation render RPC needed the non-program accessor. -
Scope denials on formerly
Result<_, Html<String>>handlers (approve, deny, the document actions, ELE, file-application) widen the error type toResponse; their other error arms keep today’s HTTP-200 inline-fragment behavior — only scope/authority denials change status (403/422), as the plan’s not-behavior-neutral note promised. -
Write dominance is enforced by the compiler, not AST analysis: the write verbs are module-private, so every write is unreachable without a proof — strictly stronger than the planned dominance check. The audit’s
RequireAuthorizedWriteverifies classification completeness, authorization reach (a fixpoint over call edges), and class symmetry (noneutral_writerborrowing; no authorization onNotProgramScoped), with deletion canaries at both layers. -
The routed test surface split into three files for the B1 route-module budget:
route_test_harness.rs(shared mock + case driver),write_authz_route_tests.rs(the #1004 matrices),scope_authz_route_tests.rs(the #1516 tampering matrix). -
The
NotInScope/NotInEleScopeerror variants are deleted WITH the label-shaped gates they rendered for;ScopeDeniedowns the copy. -
IEVS uses the
ProgramScope<Snap>extractor (B1’s mechanism) rather than a resource lookup — the plan’s B2 table already named SNAP as the static authority; no lookup exists to make. -
The
AuthorizedResourceproof was not target-bound at MR-2: it proved a check ran before any write, and per-handler proof↔write agreement was carried by the scope pass + the routed tampering tests, not the type system — filed as #1524 (hardening, not a live defect). Resolved by #1524: the proof’s program set now rides both write paths. EveryScopedClientsfield is private — the ten cross-program neutrals expose infallible accessors (behavior unchanged), the five per-program accessors require proof membership (fail-closedScopeDenied, recorded) — and the per-target blessings (into_authorized/into_neutral) poison a program-service clone whose proof lacks that program: its verbs answer a 403-classedscope_mismatchwith zero upstream I/O. Aneutral_writer/into_neutralblessing carries the empty set, so enumerated non-program writes structurally cannot reach a program service. -
Review-discovered, pre-existing, out of scope here: the member editors (
edit_person/remove_member) never adopted the #996 membership IDOR guard the sibling fact editors run — filed as #1523 and stated honestly inapi/canopy-web.adoc.
Part C — upstream program filters (MR-3, #1517) and read surfaces (MR-4, #1518)
C1. Upstream (MR-3)
applications and appeals already accept a programs filter; renewals is
per-program by route (ADR-001). canopy-notices has none and gets one, with the same
invalid_programs 422 contract as applications. Any other list endpoint the C2 matrix
needs is added here, so scoping is query-time, never client-side post-filtering of an
already-limited page.
C2. The protected-GET and panel policy matrix (MR-4)
Every protected GET and every dashboard panel is classified Scoped or Neutral(reason),
and the matrix is enforced by `route-authz’s GET taint pass — an unclassified
protected GET is a build failure, exactly like an unclassified mutation.
| Surface | Fix |
|---|---|
Case search, command palette |
scope the upstream query; never return out-of-scope names/DOBs/households |
Direct case navigation |
verify the household participates in an in-scope program — today only the
query-string program is checked. Program tabs likewise derive from participation ∩
scope, not |
Application index/process, appeals, notices, SNAP renewals, team queue |
pass |
Notice PDFs, document bytes |
authorize the artifact by its owning case/application before streaming — authentication alone permits direct-ID access |
Panels: recent determinations, supervisor KPIs, cross-program alerts, hero counts |
scope each; the supervisor/analyst hero branch that deliberately omits |
|
an all-five scope means five sequential renewal calls where an empty scope meant
one. Deduplicate and route through the existing |
Because the set is never empty, the degenerate "send no programs= ⇒ upstream returns
everything" branch disappears everywhere.
C — deviations from the approved plan (as-built, MR-4)
-
Appeals did NOT already accept a
programsfilter on the endpoint canopy-web calls (a C1 premise found false at implementation):/v1/appealshad none — the filter existed only on/v1/appeals/queue, which is status-narrowed and capped. MR-4 addsListParams.programs+ theprogram = ANYpredicate to/v1/appeals(and to/v1/appeals/hearings/upcomingfor the supervisor panel), under C1’s "any other list endpoint the C2 matrix needs is added here" provision. -
Case search cannot be scoped by a pure upstream query: canopy-persons holds no program data at all (ADR-001 isolation), so the participation decision rides the per-row applications ancillary the handler already fanned out for its program label — zero additional upstream calls. A row whose participation ∩ scope is empty is silently absent; a row whose participation is UNKNOWN (ancillary failure) drops fail-closed — and case search degrades the fragment, so an outage can never read as "no cases found". The command palette adopts the same gate (one bounded lookup over its deduped household candidates) but omits silently on failure (a per-keystroke surface with no degraded-state UI, by its existing design), and a person with NO household participates in nothing and never renders (the #531 rule generalized; ADR-044 absence-is-not-authorization).
-
The chip rail is participation ∩ scope — the epic-&53 "one card per KNOWN program" design (unconfigured / outside-your-scope ghost cards) is superseded: a card’s PRESENCE now means participation, so out-of-scope participation must not mint a card at all.
fileable_programs(the File application modal) deliberately stays deployed ∩ scope — filing CREATES participation, so gating it on participation would be circular. -
An empty participation union 404s the case view (
NoAuthority→ 404 on the full page): a household id matching nothing and a household with zero applications are deliberately indistinguishable — no household-existence oracle for out-of-scope probing — and every current intake path creates the application before the case page is reachable. Any future "create household first, apply later" flow must revisit this gate. (Error exits on the full page render the shared error page with real statuses; since #1526 the fragment surfaces answer real statuses too — see the next item.) A malformed union (a real record with an unrecognized program) stays 422. -
Program-less notices drop fail-closed from scoped indexes (
program IS NULLnever matches theANYfilter). The schema permits them but no producer emits one today; the notice-PDF direct read uses the household-participation fallback instead, since the artifact is bound to a case that CAN authorize it. -
Denial shapes were NOT unified in MR-4 (the full-page participation denial was a bare 403,
get_taband the intake page answered 200-with- banner) — deliberately filed as #1526 rather than widening three more handler signatures there. Resolved by #1526: one read-denial contract —ScopeDenied::status()(403 out-of-scope / 422 unusable authority) on every scope/participation read denial, the shared fragment on htmx surfaces (get_tab, fact-history) with thex-canopy-denialmarker anhtmx:beforeSwaptolerance keys on (htmx 2 refuses to swap 4xx bodies by default), the full error page on page surfaces (case detail — already honest since the MR-4 tail — and intake); the?program=/path-program gates route through the sharedScopeDenied::view_gatepredicate and record on the newview_gatemetric surface, so every denial fires ADR-044 A6 telemetry identically. The intake unknown-program typo corrective deliberately stays a 200 content answer (a URL-shape response with its own copy, not an authz denial). -
my_queue’s "deduplicate" item was already a no-op(`canonical_slugs() is deduplicated and chip-free by construction); the real change is the renewal legs ridingbounded_join(input-order preserved — thefold_leg_failureslast-failure read and the stable due-date sort both depend on it). On a stamped roster the page-wide permit gate remains the binding concurrency bound; the join matters for the un-stamped command-palette path, whose deadline adoption stays with #1319. -
Per-program panels render an explicit "Outside your program scope" card (
scoped_out, outcomeEmpty) instead of fetching: overdue-cases, caseload-trend, IEVS alerts + the two supervisor-KPI side tiles (SNAP), sanctions roll-up (TANF), upcoming appointments (WIC). The overpayment roll-up renders only in-scopeby_programrows and recomputes its headline from them, so the jurisdiction-wide totals never reach the render path. Cross-program alerts carryprograms=on BOTH tiers (the supervisor/alltriage feed is claim-bounded too — role-agnostic), with the #596 assignment axis unchanged; the new conjunct sits outside the pinned partial-index predicate on both alert SQL consts, so the v2 index stays implied. -
Enforcement is two-layer where the plan said one:
route-authzgains the GET pass (READ_SCOPE_POLICY over the 30 protected GETs, count + stale-entry canaries, reach-verifiedScopedReadvia the #1516 fixpoint engine), and — because 45 panel/section surfaces hang off two route handlers, invisible to the route walker — the per-panel matrix is enforced by thePANEL_SCOPE_POLICYexhaustiveness test incanopy-web/src/dashboard/panels/mod.rsagainst the linkme registry. -
The routed read-authorization proof for
/cases/{household_id}is the live disclosure spec (program-scope-reads.spec.ts, run as the tanf-only worker — the seed’s one snap+tanf household bounds their whole legitimate universe) rather than an axum-mounted test:get_case_detailrequires the full CompositionState + DB harness, and the gate’s primitives (household_programs_union,any_of_slugs,authorize_application_read) carry the unit matrix. The four unwired limited-scope realm fixtures (jane.supervisor.snapetc.) stay unwired: the tanf-only caseworker exercises the read gates non-vacuously, and the supervisor/auditor surfaces they would discriminate are MR-5/MR-6 territory. -
SNAP data-leak fixes folded in where the tab renders them: the determination tab’s enrollment + open-adverse-actions fetches gain the same snap-scope gate the TSNAP cert already had, and the
?program=allsummary probes only participation ∩ scope (pre-MR it probed all five services and filtered client-side).
Part D — audit (MR-5, #1519) — as built
The audit page, CSV export and citation-by-id are unfiltered, and the shared predicate has three callers, not two — the case-detail Activity tab is the third (plus the dashboard audit panel).
More fundamentally, the BFF-side classifier treats every event from applications,
notices, appeals, renewals, enrollment, verification and eligibility as
Neutral — so an application.approved on a TANF-only household is shown to a SNAP-only
worker. Reusing that predicate cannot implement the policy, and post-filtering a limited
page yields short pages regardless.
The fix is upstream — authoritative program metadata on the audit row:
-
Column:
audit_events.programs TEXT[]on both twins (migration20261128000000, partial GIN), a trichotomy —NULL= no assertion (drops fail-closed for scoped readers),'{}'= asserted program-NEUTRAL (visible to all; protects the Pub-1075ssn.accessedtrail), non-empty = storage slugs, visible on scope overlap. OUTSIDE the frozen v1AuditChainInputshash (dedup_keyprecedent; header records the posture; chain-v2 treatment deferred to the #1279 cutover — the dormant v2 tables do NOT gain the column). -
Envelope: additive
EventEnvelope.programs: Option<Vec<String>>
.with_programs()/.program_neutral()builders; the HTTP ingest request carries the same field. -
Ingest derivation bridge (
derive_programs): validated publisher assertion (an unknown slug rejects the WHOLE assertion to NULL, no fall-through) → routing-key first/last dot-segment → curated neutral families (person./persons./auth./composition./applicant.session.,rules.evaluated,*.export.requested) → NULL. The bridge means the flip is safe before every publisher is stamped. -
Publisher stamping (the 110-site census): program services stamp their slug; eligibility stamps
determination.completedwith the approved∪denied union and run-cohort events withrun.programs; appeals/IPV stamp from the row’s program; notices stamp conditionally; applications stamp section events[program]andexpedited_identified["snap"]; genuinely cross-program or pre-assignment events are asserted neutral; the handful with no authoritative source stay unstamped WITH a comment (the derivation bridge or NULL covers them honestly). -
Query-time filter: repeated
programs=onGET /v1/security/eventsand the FOIA export union (both arms); unknown slug = 422; empty = unscoped (pre-#1519 service-caller contract preserved). -
Citation-by-id: direct ROW authorization in canopy-web — neutral admits, overlap required, a no-assertion row is a 404 BEFORE attestation.
-
Web callers: the audit page, CSV export, Activity tab, case-detail Audit section and dashboard audit panel all append the worker’s storage slugs query-time; the BFF classifier (
event_program/event_visible_to_programs) is DELETED with its tests — it does not survive as presentation (nothing needed it). -
Seed: canopy-seed stamps generated rows through the same trichotomy, so devstack reseed is the pre-#1519-history remediation (runbook:
security-operations.adoc› Audit Program-Scope Posture).
Part D deviations (plan → as-built)
-
Neutral got a first-class value — the plan’s binary (field present /
UnknownProgram) became a trichotomy:'{}'asserted-neutral is distinct fromNULLno-assertion, because cross-program compliance streams (e.g.ssn.accessed) must stay visible to scoped workers while unasserted history must not. -
A derivation bridge at ingest rather than publisher-stamping as the sole source — routing-key segments and a curated neutral-family list cover unstamped publishers honestly, so MR-5 does not need all 110 sites stamped to be correct.
-
event_programfully deleted, not "survives as presentation" — no presentation caller existed once authorization moved upstream. -
Export scoping added to
GET /v1/export/audit-events(both union arms) — the plan named list + citation; the export is the same disclosure surface.
Part E — /sse (MR-6, #1520) — as built
/sse is auth-gated but has no scope filter, and serializes the whole envelope. Two
changes:
-
Minimal invalidation messages — the hub narrows each envelope to
{"event_type", "household_id"?}(the id the browser re-fetches by; nothing else from the payload survives) BEFORE it crosses the broadcast channel. The re-fetch rides the Part C/D authorization. -
Fail-closed per-connection filtering on authoritative program metadata, derived once at the hub with the Part D precedence: envelope
programsassertion (a garbage assertion overlaps no scope — same drop, no re-derivation) → routing-key first/last dot-segment (tanf.determined,determination.completed.snap) → no metadata ⇒ delivered to NOBODY (assignment.createdstays bound for forward-compat but drops until its future producer stamps the envelope). Delivery per connection follows the Part D trichotomy: asserted-neutral reaches every worker; a program set requires storage-slug overlap (medicaid’s expansion covers chip).
When scope changes mid-connection the stream terminates and the client reconnects,
re-deriving scope from the session — checked by force-reloading the session record
(Session::load; the handle’s cache would never see another request’s refresh)
before EVERY delivery attempt, visible or not, with any read failure, missing
record, or undeserializable row also terminating fail-closed. Live-stream tests
pin: a snap-only connection never receives an out-of-scope or unstamped event id;
a scope change terminates before the next delivery; a deleted session terminates.
Tests
| Area | Legs |
|---|---|
proptest (mandatory — parser + deserializer) |
arbitrary claim vectors: a constructed scope is non-empty, deduplicated, chip-free and
stably ordered; |
Session wire |
legacy↔new both directions; missing key and |
Login |
followed-redirect tests, single- and multi-IdP, for |
Refresh |
routed tests for valid / changed / missing / malformed scope; persistence vs flush after rotation; concurrent refresh |
Mutations |
caller-tampering ( |
Audit tool |
all five tag mappings; alias, local-shadow, wrapper and |
Reads |
limited-scope supervisor, admin and auditor coverage for every read, export, direct-ID and SSE surface — all-five privileged fixtures would mask these defects |
Fixtures |
repository-wide migration of all 18 |
Removed |
every fail-open test deleted with the behavior it pins, stated in the commit message (testing-discipline) |
E2E |
deterministic security fixtures; no |
Delivery
Epic &78 "worker program scope, enforced", one child issue per MR, linked via epic_id;
#742 is the umbrella, `/relate`d to each child and closed by MR-6. MR-1 + MR-2 satisfy
epic &62 B2.
| MR | Branch / content | Status |
|---|---|---|
1 |
|
Done (2026-08-20) — MR !1173, merge 8e0c073e |
2 |
|
Done (2026-08-21) — MR !1174, merge 71b26326 |
3 |
|
Done (2026-08-21) — MR !1175, merge 9b1d92b8 |
4 |
|
Done (2026-08-21) — MR !1179, merge 7e1aae90 |
5 |
|
Done (2026-08-21) — MR !1180, merge 156abb1b |
6 |
|
Done (2026-08-21) — MR !1181, merge b7bd337b |
Docs: ADR-044 plus the architecture.adoc cheat-sheet row and nav entry;
security.adoc (the required claim, Pub 1075 AC-6 with the revision pinned, the sweep
carve-out for frozen records); authorization-inventory.adoc (the full mutation + GET
matrix); api/canopy-web.adoc and shared-crates.adoc; local-dev.adoc
(dev reimport-realm); the cutover runbook; the coding-conventions.adoc overlay (the
audit’s scope + GET passes); CHANGELOG.adoc per MR — MR-1 Changed (tokens without the
claim are not admitted; sessions invalidated at cutover), MR-2 Fixed (the form.program
bypass; the ungated document actions) and Changed (scope denials are now 403,
previously 200).
Verification
-
Full battery per MR (
cargo fmt --check --all, clippy-D warnings, nextest,cargo xtask validateincluding the extendedroute-authz). -
MR-2 exploit regression: as a SNAP-only worker, POST an income edit for a TANF-only household with
program=snap⇒ 403 (today: 200 and a committed write). Same for the IEVS accept path and each document action. -
MR-4/5/6 disclosure regression as a limited-scope supervisor: case search, command palette, application index, appeals, notices, team queue, every panel, the audit page, the CSV export, citation-by-id, a direct notice-PDF id, a direct document id, and a live
/sseconnection each return only in-scope data. -
MR-1 cutover rehearsal on devstack: canary replica, legacy-session purge, old-replica drain and a rollback, each observed through the A6 counters;
jane.unscopedlands on the sign-in page with the banner and does not loop.