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 ({ first: Program, rest: Vec<Program> }); deduplicated; stable Program::all() order. ["chip","medicaid"] collapses to one Medicaid.

Construction

WorkerProgramScope::from_claim(&[S]) (public — the cross-crate admissibility guard and the login surface both need it), TryFrom<&canopy_auth::Claims>, and the test-only for_test / every_program_for_test.

Error

ScopeAdmissionError::{MissingOrEmpty, Malformed(String)} (thiserror). MissingOrEmpty, not AbsentClaims.primary_programs is #[serde(default)], so absent and [] are indistinguishable on the wire; both are tested.

Three representations

authorization membership — canonical Program, chip ≡ medicaid, via contains / contains_any / contains_all / contains_slug; storage/query expansionstorage_slugs() expands Medicaid["medicaid","chip"], because application and appeal rows store the exact slug and a CHIP-only row would otherwise vanish; presentationiter(), first(), len(), Display for tab order, default selection and fan-out.

Derives

Debug, Clone, PartialEq, Eq, Serialize, Deserialize (hand-written Serialize so the wire form stays a slug array).

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_programsSessionData.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

auth::callback

WorkerProgramScope::try_from(&claims) via the shared helper; MissingOrEmptymissing_primary_programs, Malformed ⇒ the existing malformed_primary_programs.

session::rederive_authz

Returns Result<(WorkerRole, WorkerProgramScope), AdmissionRejection>not Option, which erases the reason A6 needs. Login and refresh call the same helper, so they cannot drift.

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)

  1. SessionData::in_program_scope survives MR-1 as a thin shim over WorkerProgramScope::contains_slug (57 sites) as does fact_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.

  2. ScopeAdmissionError does not reuse Claims::parsed_primary_programs() — that helper discards which slug was bad, and Malformed(String) names it in the log line.

  3. from_claim is pub, not construction-by-TryFrom-only: the cross-crate admissibility guard and the login surface both construct from a slug slice.

  4. 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.

  5. WorkerProgramScope gained contains_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.

  6. build_applications_query and hero_apps_query take storage_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.

  7. The my_queue sequential renewals fan-out is deferred to #1518 with an in-code comment naming the issue, not silently left.

  8. LoginQuery.error is a String narrowed by LoginError::from_code rather 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.

  9. 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.

  10. 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 /full fetch ensure_household_member already performs — one lookup, two guards

Application (approve, deny, run determination, intake page)

programs_requested on the fetched row

IEVS discrepancy

SNAP, authoritatively — these handlers mutate clients.snap regardless of what form.program claims

Document accept / reject / scan-override

the programs of the verification(s) the document resolves

ELE consent

contains_any([Snap, Tanf])

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 an AuthorizedResource, verified by requiring the canonically-resolved authorized( 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)

  1. The household fact-write authority is the union of programs_requested across the household’s applications (GET /v1/applications?household_id=, one extra upstream read per fact write) — NOT the persons /full fetch, which carries no program data at all (a plan premise found false at implementation). The membership IDOR guard still rides the /full fetch; the two lookups are sequential, authorization first.

  2. 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.

  3. AuthorizedClient (owned) joins ScopedClients (borrowed): the ADR-043 exchanged-token path builds per-target InternalClient clones carrying the worker-context bearer, which cannot ride a roster borrow — blessed via InternalClient::into_authorized against the same proof type.

  4. WriteDeps is adopted only where the argument count demands it (resolve_discrepancy_tanf, the one 4-Extension handler that would exceed too_many_arguments with the guard added); other handlers keep their explicit Extensions to bound the diff.

  5. NeutralWrite has a single variant (CitationRender): the composition and studio mutations write canopy-web’s own DB via sqlx, not InternalClient, so only the audit-citation render RPC needed the non-program accessor.

  6. Scope denials on formerly Result<_, Html<String>> handlers (approve, deny, the document actions, ELE, file-application) widen the error type to Response; 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.

  7. 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 RequireAuthorizedWrite verifies classification completeness, authorization reach (a fixpoint over call edges), and class symmetry (no neutral_writer borrowing; no authorization on NotProgramScoped), with deletion canaries at both layers.

  8. 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).

  9. The NotInScope / NotInEleScope error variants are deleted WITH the label-shaped gates they rendered for; ScopeDenied owns the copy.

  10. 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.

  11. The AuthorizedResource proof 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. Every ScopedClients field is private — the ten cross-program neutrals expose infallible accessors (behavior unchanged), the five per-program accessors require proof membership (fail-closed ScopeDenied, 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-classed scope_mismatch with zero upstream I/O. A neutral_writer / into_neutral blessing carries the empty set, so enumerated non-program writes structurally cannot reach a program service.

  12. 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 in api/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 Program::all() ∩ scope

Application index/process, appeals, notices, SNAP renewals, team queue

pass storage_slugs() to the C1 filters

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 programs= is scoped like the rest (role-agnostic)

my_queue fan-out

an all-five scope means five sequential renewal calls where an empty scope meant one. Deduplicate and route through the existing clients::bounded_join
UPSTREAM_FANOUT_LIMIT, already used by this same panel, with deadline/load coverage

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)

  1. Appeals did NOT already accept a programs filter on the endpoint canopy-web calls (a C1 premise found false at implementation): /v1/appeals had none — the filter existed only on /v1/appeals/queue, which is status-narrowed and capped. MR-4 adds ListParams.programs + the program = ANY predicate to /v1/appeals (and to /v1/appeals/hearings/upcoming for the supervisor panel), under C1’s "any other list endpoint the C2 matrix needs is added here" provision.

  2. 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).

  3. 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.

  4. 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.

  5. Program-less notices drop fail-closed from scoped indexes (program IS NULL never matches the ANY filter). 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.

  6. Denial shapes were NOT unified in MR-4 (the full-page participation denial was a bare 403, get_tab and 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 the x-canopy-denial marker an htmx:beforeSwap tolerance 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 shared ScopeDenied::view_gate predicate and record on the new view_gate metric 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).

  7. 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 riding bounded_join (input-order preserved — the fold_leg_failures last-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.

  8. Per-program panels render an explicit "Outside your program scope" card (scoped_out, outcome Empty) 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-scope by_program rows and recomputes its headline from them, so the jurisdiction-wide totals never reach the render path. Cross-program alerts carry programs= on BOTH tiers (the supervisor /all triage 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.

  9. Enforcement is two-layer where the plan said one: route-authz gains the GET pass (READ_SCOPE_POLICY over the 30 protected GETs, count + stale-entry canaries, reach-verified ScopedRead via 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 the PANEL_SCOPE_POLICY exhaustiveness test in canopy-web/src/dashboard/panels/mod.rs against the linkme registry.

  10. 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_detail requires 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.snap etc.) 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.

  11. 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=all summary 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 (migration 20261128000000, partial GIN), a trichotomyNULL = no assertion (drops fail-closed for scoped readers), '{}' = asserted program-NEUTRAL (visible to all; protects the Pub-1075 ssn.accessed trail), non-empty = storage slugs, visible on scope overlap. OUTSIDE the frozen v1 AuditChainInputs hash (dedup_key precedent; 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.completed with the approved∪denied union and run-cohort events with run.programs; appeals/IPV stamp from the row’s program; notices stamp conditionally; applications stamp section events [program] and expedited_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= on GET /v1/security/events and 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)

  1. Neutral got a first-class value — the plan’s binary (field present / UnknownProgram) became a trichotomy: '{}' asserted-neutral is distinct from NULL no-assertion, because cross-program compliance streams (e.g. ssn.accessed) must stay visible to scoped workers while unasserted history must not.

  2. 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.

  3. event_program fully deleted, not "survives as presentation" — no presentation caller existed once authorization moved upstream.

  4. 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:

  1. 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.

  2. Fail-closed per-connection filtering on authoritative program metadata, derived once at the hub with the Part D precedence: envelope programs assertion (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.created stays 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; contains(p) ⟺ membership modulo the chip collapse; storage_slugs() round-trips CHIP; serde round-trip equality; any unparseable slug always fails

Session wire

legacy↔new both directions; missing key and [] both fail; rolling-deploy and rollback simulations (old-format read by new code and vice-versa)

Login

followed-redirect tests, single- and multi-IdP, for no_role / missing_primary_programs / malformed_primary_programs: stable error rendering, no OAuth restart, correct session side effects

Refresh

routed tests for valid / changed / missing / malformed scope; persistence vs flush after rotation; concurrent refresh

Mutations

caller-tampering (form.program ≠ the resource’s programs ⇒ 403) on all 13 sites; missing / malformed / empty authoritative sets ⇒ fail closed; all-of vs any-of matrices; a real routed static-handler test asserting 403

Audit tool

all five tag mappings; alias, local-shadow, wrapper and cfg cases; gate-deletion canaries for both RequireExtractor and RequireAuthorizedWrite; write-dominance ordering

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 SessionData literals, including the integration test outside src, plus the JWT builders

Removed

every fail-open test deleted with the behavior it pins, stated in the commit message (testing-discipline)

E2E

deterministic security fixtures; no test.skip on missing seed data. A dedicated recognized-role user with no claim (jane.unscoped) exercises the rejection path — not removal of the client-wide mapper

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

feat/1515-program-scope-admission — Part A: the type, the wire-safe rename, admission, the login error page, the refresh fix, observability, devstack fixtures, the cutover runbook, fixture migration, ADR-044, the Pub 1075 citation sweep (Closes #1515, Relates to #742). Atomic: a partial version is unsafe

Done (2026-08-20) — MR !1173, merge 8e0c073e

2

feat/1516-mutation-authorization — Part B: extractor hoist, AuthorizedResource/ScopedClients, the 13 tampering sites, the 3 ungated document actions, IEVS→authoritative SNAP, empty/malformed target sets, the route-authz scope pass (Closes #1516)

Done (2026-08-21) — MR !1174, merge 71b26326

3

feat/1517-notices-programs-filter — Part C1: the canopy-notices programs filter (plus any other endpoint C2 needs), contracts, test-lib (Closes #1517)

Done (2026-08-21) — MR !1175, merge 9b1d92b8

4

feat/1518-scoped-read-surfaces — Part C2: the protected-GET/panel matrix, route-authz GET enforcement, the case-participation check, direct-ID artifact authorization, the my_queue fan-out (Closes #1518)

Done (2026-08-21) — MR !1179, merge 7e1aae90

5

feat/1519-audit-program-metadata — Part D: the authoritative program field in canopy-security, the query-time filter, citation authorization, the web audit page/CSV/Activity tab (Closes #1519)

Done (2026-08-21) — MR !1180, merge 156abb1b

6

feat/1520-sse-scoping — Part E: minimal invalidation messages, fail-closed filtering, scope-change reconnection (Closes #1520, Closes #742)

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 validate including the extended route-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 /sse connection 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.unscoped lands on the sign-in page with the banner and does not loop.

Edit this page · default