Plan: Enrollment Household RBAC (Issue #408)
On this page
Status
| Step | Description | Status |
|---|---|---|
1 |
canopy-applications schema. New forward-only migration (ADR-016) |
Done (2026-05-11) |
2 |
canopy-applications store. New module |
Done (2026-05-11) |
3 |
canopy-applications API. New module * Register on the router at |
Done (2026-05-11) |
4 |
canopy-enrollment outbound client. canopy-enrollment currently makes NO outbound HTTP calls — there is no |
Done (2026-05-11) |
5 |
canopy-enrollment bootstrap wiring. |
Done (2026-05-11) |
6 |
Inline RBAC gate inside [source,rust] ---- claims.require_service_caller()?; if let Some(actor) = claims.actor() { let supervisor = actor.has_role("supervisor") |
|
actor.has_role("admin"); if !supervisor { let worker_uuid: Uuid = actor.sub.parse().map_err( |
_ |
ApiError::Forbidden)?; let household_uuid: Uuid = household_id.into(); let assigned = clients .applications .is_worker_assigned_to_household(worker_uuid, household_uuid) .await .map_err( |
e |
ApiError::internal("canopy-applications assignment lookup", e))?; if !assigned { // Audit deny first, then 403 so the security subscriber sees it. events::publish_household_issuance_access_denied( &publisher, worker_uuid, household_uuid, actor.realm_access.roles.clone(), ).await; return Err(ApiError::Forbidden); } } } ---- Inject |
Done (2026-05-11) |
7 |
Audit-trail events. New entries in * canopy-security already subscribes to all events via wildcard |
Done (2026-05-11) |
8 |
Tests. * |
Done (2026-05-11) |
9 |
Docs. * |
Done (2026-05-11) |
Issue: #408
Branch: feat/enrollment-household-rbac
Labels: type::security, priority::medium, service::enrollment, service::applications, compliance::pub-1075, workflow::ready
As-built deviation (2026-05-11): Step 8 of the plan called for in-store unit tests against a per-test PgPool via canopy_test_lib::pg_test — that harness does not exist in this codebase; all existing canopy-applications tests are HTTP integration tests. Store coverage therefore ships as HTTP round-trip tests that transitively exercise the same code paths. Step 8’s actor-supplied test cases (supervisor pass, caseworker reject/accept) require an actor-token-minting test harness that doesn’t exist yet either — the BFF-side actor signing flow is described in ADR-019 but not yet implemented in code. The bare-service-token system-traffic path is exercised; actor-roundtrip tests will land alongside the BFF actor-mint code (tracked as the natural next step in the ADR-019 cutover).
Context
services/canopy-enrollment/src/api/mod.rs:312-336 (list_issuances_for_household) exposes GET /v1/households/{household_id}/issuances for SNAP benefit-issuance history. Post-ADR-019 cutover (MR !233 / !245), the handler is gated by claims.require_service_caller()? — any service-class caller can read any household’s issuance ledger. That is correct service-identity wiring but it leaves a Pub 1075 §9.3.1 least-privilege gap when the calling service forwards a worker actor: a caseworker who is NOT assigned to a household can still cause canopy-enrollment to disclose that household’s SNAP benefit amounts (FTI-adjacent under §9.3.1) just by routing the request through canopy-web or any other actor-carrying BFF.
The architectural fix locked 2026-05-05 is to make case assignment a first-class application-lifecycle concern with canopy-applications as the system of record, and to gate household-scoped reads on an active assignment row. canopy-enrollment becomes a read-only consumer of assignment state via HTTP. This preserves ADR-001 program-data isolation (no shared database) while letting any service that needs the same gate consult one source.
Shared-crate vs HTTP-query — why HTTP
The canopy-overpayments precedent (MR !237 / !245, see crates/canopy-overpayments/src/lib.rs) ships shared types + a migrations/canonical.sql byte-stamped into each program service. That pattern fits overpayments because each program owns its own claims/plans/recoupments — three independent ledgers, identical shapes, no cross-program reads.
Assignment data is the opposite shape: there is ONE assignment record per (worker, household), shared by every consumer (canopy-enrollment today; canopy-renewals, canopy-notices, canopy-reporting likely tomorrow). Stamping the same SQL into every consumer’s DB and replicating writes across services would defeat the "single source of truth" property the gate depends on. Canopy-applications is the natural home: it already tracks the application lifecycle that produces the assignment, it already exposes an internal-only API surface (post-cutover), and it has no FTI-adjacent payload that would force the data into a more-restricted enclave. HTTP query against canopy-applications matches both ADR-001 (program-data isolation: assignment is application-lifecycle metadata, not benefit data) and ADR-019 (service-class JWT for the inter-service call).
Code references
-
services/canopy-enrollment/src/api/mod.rs:312-336— thelist_issuances_for_householdhandler being gated. -
services/canopy-enrollment/src/api/mod.rs:73-85— the router (NOT:68-84as the prior plan claimed). -
services/canopy-applications/src/api/mod.rs:119-145— the canopy-applications router that gains the 4 assignment routes. -
services/canopy-applications/src/api/mod.rs:165—claims.require_service_caller()?pattern that every new handler follows post-ADR-019. -
crates/canopy-auth/src/claims.rs:132-249— realClaimsAPI (require_service_caller,has_role,actor()). NOTclaims.role.as_deref()as the prior plan sketched. -
crates/canopy-auth/src/claims.rs:74-76—Claims::actor: Option<Box<Claims>>injected by middleware after validatingX-Canopy-Actorper ADR-019. -
services/canopy-web/src/clients.rs:229-240— reference pattern forwith_service_identity(&ServiceTokenSource). -
services/canopy-reporting/src/clients/mod.rs:17-43— reference pattern for theServiceClients+ per-callscoped(token)shape. -
crates/canopy-api/src/bootstrap.rs:126-148— whereBootstrapResult::service_token_sourceis populated (canopy-enrollment will unwrap it). -
services/canopy-enrollment/src/main.rs:61-84— router build site where the new extensions layer in. -
services/canopy-applications/migrations/20260401000000_create_applications_tables.sql— model precedent for the new migration’s ID + timestamp shape (gen_random_uuid + TIMESTAMPTZ DEFAULT now()). -
ADR-001 — justifies putting
household_assignmentsin canopy-applications (the application-lifecycle service) rather than splitting per-program; assignment is metadata, not benefit data. -
ADR-016 — no DROPs, no down migrations; soft-delete via
unassigned_at. -
ADR-019 — canopy-enrollment → canopy-applications calls use service-class JWT + on-behalf-of actor header, not JWT pass-through.
Scope
In scope:
-
household_assignmentstable + 4 CRUD endpoints in canopy-applications (POST/DELETE/2× GET). -
ApplicationsClient::is_worker_assigned_to_householdin a newclients/module on canopy-enrollment. -
Inline RBAC gate on
list_issuances_for_householdhonoringclaims.actor()for the worker identity. -
Allow + deny audit events published to
canopy.events; persisted by canopy-security’s wildcard subscriber. -
Unit + devstack integration tests covering allow/deny paths.
Out of scope:
-
Cross-service RBAC for non-enrollment household-scoped endpoints (canopy-renewals, canopy-notices, canopy-reporting). Each service that needs the same gate adopts the same
ApplicationsClient::is_worker_assigned_to_householdcall in a separate plan; this plan focuses on canopy-enrollment because it owns the FTI-adjacent issuance ledger. -
Self-service assignment (caseworker assigning themselves). All assignments are supervisor-initiated; the API gate enforces this.
-
Time-bounded assignments or handoff workflows. The
unassigned_atcolumn supports them but no UI/automation is in scope. -
Bulk-import of historical assignments. Pre-1.0; supervisors will assign as cases flow.
-
Middleware abstraction. With only one household-scoped read on canopy-enrollment today, an inline gate is shorter than the equivalent
from_fnmiddleware that would still need to extract actor + path manually. Revisit when a second endpoint needs the gate.
Dependencies
-
ADR-019 cutover MRs !233 + !245 are merged;
claims.actor()is the on-behalf-of source. -
BootstrapResult::service_token_sourceexists percrates/canopy-api/src/bootstrap.rs:126-148— the canopy-enrollment bootstrap path was already updated for the cutover. -
No prerequisite plans; self-contained.
Design
Migration
-- services/canopy-applications/migrations/20260511000000_create_household_assignments.sql
-- SPDX-License-Identifier: AGPL-3.0-or-later
--
-- Per-worker case assignment. Sole source of truth across services that
-- need to gate household-scoped reads on assignment (canopy-enrollment
-- first; canopy-renewals / canopy-notices / canopy-reporting on adoption).
-- Pub 1075 §9.3.1 least-privilege baseline.
CREATE TABLE household_assignments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
worker_id UUID NOT NULL,
household_id UUID NOT NULL,
assigned_at TIMESTAMPTZ NOT NULL DEFAULT now(),
unassigned_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX household_assignments_active_uniq
ON household_assignments (worker_id, household_id)
WHERE unassigned_at IS NULL;
CREATE INDEX household_assignments_by_worker_active
ON household_assignments (worker_id)
WHERE unassigned_at IS NULL;
CREATE INDEX household_assignments_by_household_active
ON household_assignments (household_id)
WHERE unassigned_at IS NULL;
Wire shape (post-cutover, with this plan applied)
canopy-web → canopy-enrollment (per ADR-019):
GET /v1/households/{household_id}/issuances HTTP/1.1
Authorization: Bearer eyJ... (service token, azp=canopy-web, roles=[service:canopy-web])
X-Canopy-Actor: eyJ... (canopy-web-signed actor JWT, sub=<worker uuid>, roles=[caseworker])
canopy-enrollment handler (:312-336) — flow:
-
claims.require_service_caller()— passes (azp is a service principal). -
claims.actor()returns the worker actor. -
Actor role check:
supervisor/admin→ pass through to issuance query. -
Else: call canopy-applications
GET /v1/households/{household_id}/assignmentsusing canopy-enrollment’s own service token (NOT forwarded). Filter for an active row matchingactor.sub. -
Hit → publish
enrollment.household_issuance.read, return 200 with the issuance list. -
Miss → publish
enrollment.household_issuance.access_denied, return 403.
System callers (no actor — drainers, scheduled jobs) skip the assignment check; only worker-attributable calls trigger it. This matches claims.actor() being None for pure service-to-service traffic per ADR-019.
Claim/role helper API (real, NOT the prior plan’s invented API)
// crates/canopy-auth/src/claims.rs — already exists, used as-is:
claims.require_service_caller()?; // gates service-class only
claims.actor() // -> Option<&Claims>
actor.has_role("supervisor") // -> bool
actor.has_role("admin") // -> bool
// NOT: claims.role.as_deref() — that field does not exist
// NOT: canopy_auth::claims::extract(&request) — there is no such free fn
Files Touched
| File | Change |
|---|---|
|
New forward-only migration (ADR-016) — 1 table + 3 partial indexes |
|
Add |
|
|
|
New store module — 5 functions + 5 unit tests |
|
|
|
New API module — 4 handlers, all |
|
New devstack-gated integration test |
|
Add |
|
New module — |
|
Add |
|
Construct |
|
Inline RBAC gate inserted into |
|
Two new emitters — |
|
New devstack-gated integration test |
|
canopy-applications route table +4 endpoints; canopy-enrollment household-issuance row gets an RBAC note |
|
New row for |
|
|
Verification
-
cargo nextest run -p canopy-applications --lib— 5 new unit tests onstore::assignmentspass. -
cargo nextest run -p canopy-enrollment --lib— existing unit tests stay green; handler-signature changes compile. -
cargo xtask dev start && cargo nextest run -p canopy-applications --test assignments_test -p canopy-enrollment --test household_rbac_test— devstack-gated integration tests pass. -
Manual smoke against devstack: acquire a canopy-web service token + actor JWT for caseworker A (assigned to household X).
GET /v1/households/X/issuances→ 200. Same caseworker, household Y (not assigned) → 403. Acquire supervisor actor JWT, both → 200. Acquire bare service token, both → 200 (system traffic). -
Confirm
audit_events(in canopy-security DB) shows two rows from the smoke: oneenrollment.household_issuance.read, oneenrollment.household_issuance.access_denied. -
cargo xtask validate— full battery green at MR boundary. -
cargo xtask docs plan-lint— 0 violations (Status-vocabulary tokens canonical).
Documentation Updates
-
.claude/docs/services.md— canopy-applications route table (4 new endpoints) + canopy-enrollment household-issuance RBAC note -
docs/modules/ROOT/pages/rbac-matrix.adoc— new row for the gated read with Pub 1075 §9.3.1 citation -
CHANGELOG.adoc—== Unreleased/=== Securityentry citing Pub 1075 §9.3.1 and #408 -
Plan archive: move to
docs/modules/ROOT/pages/plans/archive/per ADR-013 post-merge