Plan: Enforce write-role authorization on every case mutation (#1004)
On this page
Status
| MR | Description | Status |
|---|---|---|
A (prereq) |
Fail-closed role resolution + refresh re-derivation (#1024 — blocks #1004) |
Done (2026-07-10) — !803 |
1 |
Close the 4 mutation gaps + the two scope defects + a route-level test harness |
Done (2026-07-10) — !804 |
3 |
|
Done (2026-07-10) — !805 |
2 |
UI same-predicate sweep (the full write surface) |
Done (2026-07-10) — final MR of #1004 |
Issue: #1004 (priority::critical, type::security). Prerequisite: #1024 (blocks). Branches: one per MR — feature/{issue}-{slug}.
Context
Read-only roles (Auditor, Analyst, StudioAdmin — can_write() == false, session.rs:79) can hand-craft requests to canopy-web case mutations that skip the WritePermission gate. canopy-web calls upstream services with its service identity (ADR-019), so the BFF is the only place the worker-role decision can be enforced. Authorization today also depends on UI affordances (write buttons are shown to every authenticated worker, then 403’d), and there is no guard preventing the next handler from omitting the gate.
Verified inventory — 68 mutating HTTP registrations:
-
api/mod.rs— 57: 53 already gated byWritePermission; 4 gaps —run_determination(applications.rs:808),put_section_proxy(:1889),complete_data_collection_proxy(:1990),request_verification(case_detail.rs:3787). -
studio/wizard.rs— 2:StudioAdminOnly.composition.rs— 9: 6JurisdictionAdmin, 3/user/mestructurally self-scoped (handler hard-codessession.worker_id; no target-user param). -
main.rsouter router: 0 mutating POSTs, but 3 state-changing GETs (/logout,/auth/callback,/login) — GETs skip the CSRF middleware (csrf.rs:53).
This gate is only as strong as the role it checks, so it depends on a fail-closed role model (MR A).
Design
Reuse WritePermission (no new extractors)
The 4 gapped handlers gain _write: WritePermission — matching the 53 handlers that already do. This single-sources the 403 (session.rs:411-416), adds no new types, and moots any "consolidate handlers" churn. run_determination keeps AuthenticatedWorkerWithCsrf (it uses the token at :972) + adds _write; put_section_proxy/complete_data_collection_proxy switch AuthenticatedWorkerWithCsrf{worker, csrf_token: _} → AuthenticatedWorker(worker) + _write (they discard the token); request_verification binds worker + _write.
Same predicate, UI and backend
can_write() is used in zero templates. MR2 threads can_write (+ ele_in_scope, snap_in_scope) through every write-control template so a non-writer never sees a write affordance — the same predicate the backend enforces. Every edit form is <details><summary>trigger</summary><form>POST</form></details>; the whole <details> is gated (hiding only the summary leaves the POST in the DOM).
The guard is an AST audit, router-aware
MR3’s guard parses canopy-web with syn (already an xtask dep, full,visit; prior art in quality_budgets.rs B2/B7), enumerates every route registration (method-router chains + on()), resolves each handler to its fn, and requires a param whose top-level type is an accepted extractor for that router (rejecting Result<_>/Extension<_>/Option<_>/#[cfg]-gated/shadow idents). Unresolved handlers or undecodable registration forms fail (never skip). /user/me uses a new in-code SelfScoped marker (no fragile TOML allowlist).
Steps
Sequence: A → 1 → 3 → 2 (foundation, close the holes, lock them, then align the UI). Each MR is one branch/MR via the repo recipe; new .rs files carry the SPDX header.
MR A — Fail-closed role resolution + refresh re-derivation (#1024, blocks #1004).
Add a least-privilege WorkerRole::Unprivileged (all capability predicates false). from_keycloak_roles fallback → Unprivileged (never infer Caseworker from the absence of a recognized role; an explicit caseworker claim still → Caseworker); flip test role_from_empty_roles (session.rs:487). Login rejects an Unprivileged resolution (/login?error=no_role, mirroring the malformed-primary_programs reject at auth/mod.rs:307-316). The refresh arm (session.rs:314-327) validates the new access token, re-derives role + primary_programs, and fails closed (RefreshFailed) on invalid/collapsed-to-Unprivileged. Update the stale "UI permissions"/"QC" doc comments in session.rs.
MR 1 — Close the 4 gaps + the two scope defects (depends on A). Done (2026-07-10) — !804.
WritePermission is now a guard-only unit struct (the pub SessionData field was dead — every call site pairs it with AuthenticatedWorker(worker) — so it was removed along with the stale #[allow(dead_code)]; the extractor is the single source of the write-denial 403). All 4 handlers gain write: WritePermission: run_determination keeps AuthenticatedWorkerWithCsrf (it uses the CSRF token to re-render the tab) + adds _write; put_section_proxy/complete_data_collection_proxy switch AuthenticatedWorkerWithCsrf{csrf:} → AuthenticatedWorker(worker) + _write (they discarded the token); request_verification binds the worker + _write.
request_verification: adds a hidden application_id to the verification form (_top_bar_actions.html); re-fetches that application, fail-closes with an explicit 403 on an application/household mismatch (anti-tamper) and on program-scope (any requested program ∉ worker.in_program_scope); return type is now Result<Redirect, Response> so the 403 carries a real status. Because the scope derives from the fetched row, an out-of-scope writer makes ONE benign read before the denial — the enforced property there is zero upstream writes.
run_determination: validate_run_determination_request split into parse_required_program (present + valid slug) + program_in_request_set (needs the row); the parse + program-scope gate now run before the fetch, returning an explicit (StatusCode::FORBIDDEN, render_program_scope_denied(…)) (was Html → HTTP 200), so an out-of-scope worker triggers no fetch and no programs_requested leak.
put_section_proxy/complete_data_collection_proxy scope denials were verified to already return 403 (no change needed).
Tests (api/write_authz_route_tests.rs): an in-process axum::Router driven via tower::ServiceExt::oneshot through the real protected middleware (require_auth + CSRF) + a tower-sessions MemoryStore + a per-role seeded session cookie + a mock upstream (a bound axum::serve server per case) with total + write call counters; 7 roles × 4 routes, valid CSRF seeded + sent: read-only → 403 (exact status/body) + zero upstream calls; in-scope writer → an upstream write is reached; out-of-scope writer → program-scope 403 with zero upstream writes (zero total calls for the three URL-program-scoped routes).
MR 3 — syn route-authz guard (xtask/src/cmd/route_authz.rs). Done (2026-07-10) — !805.
Parses every .rs under services/canopy-web/src (incl. main.rs), skipping [cfg(test)] modules (inline + whole-file mod foo; → foo.rs, e.g. the MR1 harness). Pass 1 indexes every non-test fn by name → declaring file stem + its parameters' top-level type idents; pass 2 decomposes each .route(path, method_router) — the base builder (post(h)) + the chain (.put().patch().delete()) — into (verb, handler) pairs. For each mutating verb it resolves the handler (module::fn binds by file stem — disambiguating e.g. appeals::file_appeal vs actions_snap_appeal::file_appeal; a bare fn prefers the router’s own file) and requires a parameter whose top-level type is the router’s extractor (so a Result<_>/Extension<_>/Option<_> wrapper, whose outermost type differs, does NOT satisfy; a [cfg]-gated param is not counted). Router policy: api/mod.rs → WritePermission; studio/wizard.rs → StudioAdminOnly; api/composition.rs → JurisdictionAdmin, except /user/me → SelfScoped (a new marker in composition_session.rs, swapped onto the 3 /user/me mutating handlers). Fail-closed: the outer main.rs router may register NO mutating verb (all mutations must sit behind the CSRF + require-auth sub-router; the GET-mutation prohibition is enforced as this outer-router-forbids-mutations rule + the fact that the 3 auth-lifecycle routes are GETs); an unclassified router file with a mutation, a non-literal path, an unrecognized method-router builder (on(…)/any(…)), an inline closure, or an unresolved/ambiguous handler are all hard failures (no silent skip). A canary asserts the total mutating count == 68. Verified end-to-end: the audit is clean on the real tree (109 scanned, 68 mutating) and fails on a planted ungated handler. Wired: cmd/mod.rs + main.rs dispatch + validate.rs [9c/15] + a .gitlab-ci.yml route-authz-audit job added to docker-promote’s `needs:. 14 unit tests (decompose, wrapper-rejection, cfg-skip, per-router policy, /user/me→SelfScoped, outer-router mutation, unresolved, module disambiguation).
MR 2 — UI same-predicate sweep. Done (2026-07-10) — final MR of #1004.
Threads a can_write: bool field (+ ele_in_scope/snap_in_scope on the shells) through every write-control template + struct + construction site, gating the whole <details>/<form> (never just the submit button) so a read-only role sees no write affordance the backend WritePermission gate would 403. Covered: the 3 shell structs + top_bar_actions.html (Run Determination, ELE consent → ele_in_scope && can_write, Request Verification, File application, File Appeal → hidden for non-writers, Change Report / Interim Contact → snap_in_scope && can_write incl. their modals); TabDeterminationTemplate (the always-on Run Determination form); ApplicationIntakeTemplate (intake.html complete-data-collection + run-determination + the _intake_section_form.html section-save PUT) + ApplicationProcessTemplate (process.html approve + deny + deny modal); and the per-tab Tab{Income,Assets,Expenses,Persons,Address,Appeals,Authorization,Nutrition,WorkReq,Renewals}Template + DocumentsTemplate — can_write derived once in dispatch_fetch (ctx.session.role.can_write()) + each explicit get_tab arm, threaded through the section fetch()/render*_tab fns. actions_for (determination_view.rs) gained a can_write param that returns an empty action set for a non-writer (collapsing every per-program determination action form at once). Read-only copy fixed: render_notice_banner drops the "click Run Determination" imperative for non-writers; the determination empty-state drops "click Run Determination above". Render tests assert the writer sees the markup and the read-only role does not (determination_run_button_gated_by_can_write, income_tab_write_controls_gated_by_can_write, actions_for_empty_for_non_writer) — Askama’s compile-time field checking guarantees every construction site supplies can_write.
Follow-ups (file, /relate #1004)
-
#1025 —
docker-promote.needsomitstyped-id-path-audit(from #627) → security audits don’t gate promotion. -
#1026 — harden the MR3 route-authz audit coverage (mutations via
.nest/.merge/.route_service, state-changingget()/head()in a sub-router, and shadow-named extractors — the AST-scope blind spots surfaced by the MR3 pre-commit review).
Verification
-
cargo xtask route-authz audit→ 0 violations + complete-inventory assertion; fails on a planted ungated route / GET-mounted mutation / wrong-router gate. -
MR1 harness: 7 roles × 4 routes green (403 + zero-upstream for read-only + out-of-scope; handler reached for in-scope writers).
-
MR A: unit tests for the fail-closed decisions — role×capability incl.
Unprivileged,admit_role(login/refresh admission), andrederive_authz(refresh downgrade admitted read-only / no-role + malformed-programs fail closed). The end-to-end HTTP login-reject + refresh-downgrade wiring is exercised by the auth e2e suite; a role-less-user + IdP-downgrade e2e fixture is test-hardening tracked with #1004’s MR1 route-level harness. -
MR2: Askama compile-time (missing
can_writefield = build error) + a render test (Analyst → no write markup; Caseworker → present). -
Per MR:
cargo clippy -p canopy-web -p xtask --all-targets --profile test — -D warnings,cargo fmt --check --all,cargo xtask quality-budgets --fail-on-regression, fullcargo xtask validate.