Plan: Enrollment Household RBAC (Issue #408)

On this page

Status

Step Description Status

1

canopy-applications schema. New forward-only migration (ADR-016) services/canopy-applications/migrations/20260511000000_create_household_assignments.sql adding household_assignments(id UUID PK DEFAULT gen_random_uuid(), worker_id UUID NOT NULL, household_id UUID NOT NULL, assigned_at TIMESTAMPTZ NOT NULL DEFAULT now(), unassigned_at TIMESTAMPTZ NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now()) plus three partial indexes: UNIQUE (worker_id, household_id) WHERE unassigned_at IS NULL, (worker_id) WHERE unassigned_at IS NULL, (household_id) WHERE unassigned_at IS NULL. Soft-delete via unassigned_at; no DELETE / no DROP per ADR-016.

Done (2026-05-11)

2

canopy-applications store. New module services/canopy-applications/src/store/assignments.rs with sqlx functions assign(pool, worker_id, household_id) → Result<HouseholdAssignment>, unassign(pool, id) → Result<bool>, list_by_worker(pool, worker_id) → Result<Vec<HouseholdAssignment>>, list_by_household(pool, household_id) → Result<Vec<HouseholdAssignment>>, is_assigned(pool, worker_id, household_id) → Result<bool> (SELECT 1 against the active-only partial unique index). Register the module in services/canopy-applications/src/store/mod.rs:3 alongside authorized_reps. Domain type HouseholdAssignment added to services/canopy-applications/src/domain.rs with FromRow, Serialize, Deserialize, ToSchema.

Done (2026-05-11)

3

canopy-applications API. New module services/canopy-applications/src/api/assignments.rs exposing four endpoints (all gated claims.require_service_caller()? per ADR-019 — canopy-applications is internal-only as of MR !233 / !245, see services/canopy-applications/src/api/mod.rs:165):

* POST /v1/workers/{worker_id}/assignments — body { household_id }. Caller must be either a service token whose actor (claims.actor()) has supervisor or admin role, or a service token with no actor (system seeding). Returns 201 Created with the new row. * DELETE /v1/assignments/{id} — soft-deletes by setting unassigned_at = now(). Same supervisor-actor gate. * GET /v1/workers/{worker_id}/assignments — list active assignments for a worker. Any service-class caller. * GET /v1/households/{household_id}/assignments — list active assignments for a household. Any service-class caller. This is the endpoint canopy-enrollment queries.

Register on the router at services/canopy-applications/src/api/mod.rs:119-145 (extend the existing routes() function — that is the real router location, NOT :68-84 as the prior plan misstated). Add the four handler symbols + their schemas to the existing ApiDoc #[openapi(paths(..), components(schemas(..)))] at :87-117.

Done (2026-05-11)

4

canopy-enrollment outbound client. canopy-enrollment currently makes NO outbound HTTP calls — there is no src/clients/ directory. Create one: services/canopy-enrollment/src/clients/mod.rs exposing ServiceClients { applications: ApplicationsClient }, modeled on services/canopy-reporting/src/clients/mod.rs:17-43 (per-call clone with bearer attached) BUT using ADR-019’s service-class JWT — not JWT pass-through. New method ApplicationsClient::is_worker_assigned_to_household(&self, worker_id: Uuid, household_id: Uuid) → anyhow::Result<bool> issues GET /v1/households/{household_id}/assignments and scans for an active row whose worker_id matches. Outbound auth uses ServiceTokenSource::current() from crates/canopy-auth/src/service_token.rs — see the canopy-web pattern at services/canopy-web/src/clients.rs:229-240 for the .with_service_identity(source) shape. services/canopy-enrollment/Cargo.toml gains reqwest = { workspace = true }.

Done (2026-05-11)

5

canopy-enrollment bootstrap wiring. services/canopy-enrollment/src/main.rs:61-84 constructs ServiceClients::from_config(&svc_config) and layers two new extensions onto the router: axum::Extension(Arc::new(service_clients)) and axum::Extension(boot.service_token_source.clone().expect("ADR-019 service token required for household RBAC")). The service_token_source is already populated on BootstrapResult per crates/canopy-api/src/bootstrap.rs:126-148 — this step just unwraps it (canopy-enrollment cannot start without an OIDC service-client per ADR-019). Add applications_url field to EnrollmentConfig in services/canopy-enrollment/src/config.rs, sourced from CANOPY_ENROLLMENT__APPLICATIONS_URL per ADR-012.

Done (2026-05-11)

6

Inline RBAC gate inside list_issuances_for_household. Edit services/canopy-enrollment/src/api/mod.rs:312-336 directly — the existing handler. Pre-gate logic before the existing claims.require_service_caller()? line at :318:

[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 Extension(clients): Extension<Arc<crate::clients::ServiceClients>> and Extension(svc_token): Extension<canopy_auth::ServiceTokenSource> into the handler signature. Scope the clients per-call with let clients = clients.scoped(svc_token.current().await?); before use. Reason for inline-gate vs middleware: the only household-scoped read on canopy-enrollment today is this one handler; a route_layer(from_fn(…​)) middleware would require manual claims/actor + path extraction that is identical to inline code. Re-evaluate when a second household-scoped endpoint lands.

Done (2026-05-11)

7

Audit-trail events. New entries in services/canopy-enrollment/src/events.rs:

* publish_household_issuance_read(publisher, worker_id: Uuid, household_id: Uuid, role_summary: String) emitting event_type enrollment.household_issuance.read with payload { worker_id, household_id, role_summary }. * publish_household_issuance_access_denied(publisher, worker_id: Uuid, household_id: Uuid, roles: Vec<String>) emitting enrollment.household_issuance.access_denied.

canopy-security already subscribes to all events via wildcard # (per .claude/docs/services.md canopy-security row — "wildcard subscriber with audit persistence"), so these land in audit_events without further wiring. Per ADR-004 events carry IDs only — no PII. The allow-path call is emitted just before the existing Ok(Json(issuances)) return.

Done (2026-05-11)

8

Tests.

* services/canopy-applications/src/store/assignments.rs#tests (in-module) — 5 unit tests: (a) assign happy path; (b) re-assigning the same worker+household while previous is active fails on the partial-unique index; (c) unassign flips unassigned_at; (d) re-assigning AFTER unassign succeeds (the unique index is partial on WHERE unassigned_at IS NULL); (e) is_assigned returns true/false correctly across active/inactive rows. All run against a per-test PgPool via the existing canopy_test_lib::pg_test harness. * services/canopy-enrollment/tests/household_rbac_test.rs — new devstack-gated integration test, mirrors the setup in services/canopy-enrollment/tests/household_issuances_test.rs:11-22. Four cases: (a) service token + supervisor actor → 200 for any household; (b) service token + caseworker actor + assigned household → 200; (c) service token + caseworker actor + unassigned household → 403; (d) bare service token, no actor → 200 (system traffic). Asserts the deny case publishes enrollment.household_issuance.access_denied. * services/canopy-applications/tests/assignments_test.rs — devstack-gated. POST /v1/workers/{id}/assignments by a service-token-with-supervisor-actor returns 201; by a service-token-with-caseworker-actor returns 403; GET endpoints return the seeded row.

Done (2026-05-11)

9

Docs.

* .claude/docs/services.md — extend canopy-applications route table (4 new endpoints under "domain") and add an RBAC note to canopy-enrollment’s GET /v1/households/{id}/issuances row. * docs/modules/ROOT/pages/rbac-matrix.adoc — add a row for enrollment.household_issuance_read mapping {worker_role × assignment} → allow/deny with the Pub 1075 §9.3.1 citation. * CHANGELOG.adoc == Unreleased / === Security — single entry citing Pub 1075 §9.3.1 and linking #408. * Plan moves to docs/modules/ROOT/pages/plans/archive/ per ADR-013 post-merge.

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 — the list_issuances_for_household handler being gated.

  • services/canopy-enrollment/src/api/mod.rs:73-85 — the router (NOT :68-84 as 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:165claims.require_service_caller()? pattern that every new handler follows post-ADR-019.

  • crates/canopy-auth/src/claims.rs:132-249 — real Claims API (require_service_caller, has_role, actor()). NOT claims.role.as_deref() as the prior plan sketched.

  • crates/canopy-auth/src/claims.rs:74-76Claims::actor: Option<Box<Claims>> injected by middleware after validating X-Canopy-Actor per ADR-019.

  • services/canopy-web/src/clients.rs:229-240 — reference pattern for with_service_identity(&ServiceTokenSource).

  • services/canopy-reporting/src/clients/mod.rs:17-43 — reference pattern for the ServiceClients + per-call scoped(token) shape.

  • crates/canopy-api/src/bootstrap.rs:126-148 — where BootstrapResult::service_token_source is 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_assignments in 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_assignments table + 4 CRUD endpoints in canopy-applications (POST/DELETE/2× GET).

  • ApplicationsClient::is_worker_assigned_to_household in a new clients/ module on canopy-enrollment.

  • Inline RBAC gate on list_issuances_for_household honoring claims.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_household call 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_at column 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_fn middleware 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_source exists per crates/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:

  1. claims.require_service_caller() — passes (azp is a service principal).

  2. claims.actor() returns the worker actor.

  3. Actor role check: supervisor / admin → pass through to issuance query.

  4. Else: call canopy-applications GET /v1/households/{household_id}/assignments using canopy-enrollment’s own service token (NOT forwarded). Filter for an active row matching actor.sub.

  5. Hit → publish enrollment.household_issuance.read, return 200 with the issuance list.

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

services/canopy-applications/migrations/20260511000000_create_household_assignments.sql

New forward-only migration (ADR-016) — 1 table + 3 partial indexes

services/canopy-applications/src/domain.rs

Add HouseholdAssignment struct

services/canopy-applications/src/store/mod.rs

pub mod assignments; at :3 (alongside authorized_reps)

services/canopy-applications/src/store/assignments.rs

New store module — 5 functions + 5 unit tests

services/canopy-applications/src/api/mod.rs

pub mod assignments; at :3; extend routes() at :119-145 with 4 new routes; extend ApiDoc paths() + components(schemas(..)) at :87-117

services/canopy-applications/src/api/assignments.rs

New API module — 4 handlers, all claims.require_service_caller()? gated; mutate handlers additionally check claims.actor() for supervisor/admin

services/canopy-applications/tests/assignments_test.rs

New devstack-gated integration test

services/canopy-enrollment/Cargo.toml

Add reqwest = { workspace = true }

services/canopy-enrollment/src/clients/mod.rs

New module — ServiceClients { applications: ApplicationsClient }, ApplicationsClient::is_worker_assigned_to_household, modeled on services/canopy-reporting/src/clients/mod.rs:17-43 but using ServiceTokenSource per ADR-019

services/canopy-enrollment/src/config.rs

Add applications_url: String (env CANOPY_ENROLLMENT__APPLICATIONS_URL, ADR-012)

services/canopy-enrollment/src/main.rs

Construct ServiceClients; layer Extension(Arc::new(clients)) + Extension(boot.service_token_source.unwrap()) onto the router at :61-84; declare mod clients; near :13

services/canopy-enrollment/src/api/mod.rs

Inline RBAC gate inserted into list_issuances_for_household at :312-336; handler signature gains Extension<Arc<clients::ServiceClients>> + Extension<ServiceTokenSource>; add use uuid::Uuid; if not present

services/canopy-enrollment/src/events.rs

Two new emitters — publish_household_issuance_read, publish_household_issuance_access_denied

services/canopy-enrollment/tests/household_rbac_test.rs

New devstack-gated integration test

.claude/docs/services.md

canopy-applications route table +4 endpoints; canopy-enrollment household-issuance row gets an RBAC note

docs/modules/ROOT/pages/rbac-matrix.adoc

New row for enrollment.household_issuance_read with Pub 1075 §9.3.1 citation

CHANGELOG.adoc

== Unreleased / === Security entry citing Pub 1075 §9.3.1 + #408

Verification

  1. cargo nextest run -p canopy-applications --lib — 5 new unit tests on store::assignments pass.

  2. cargo nextest run -p canopy-enrollment --lib — existing unit tests stay green; handler-signature changes compile.

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

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

  5. Confirm audit_events (in canopy-security DB) shows two rows from the smoke: one enrollment.household_issuance.read, one enrollment.household_issuance.access_denied.

  6. cargo xtask validate — full battery green at MR boundary.

  7. 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 / === Security entry citing Pub 1075 §9.3.1 and #408

  • Plan archive: move to docs/modules/ROOT/pages/plans/archive/ per ADR-013 post-merge

Edit this page · default