Fleet authorization-branch inventory (OIDC F1a)
On this page
Program: epic &52 — OIDC validation at service boundaries (#1418, F1a). Generated against: main merge 4ecbd749 (2026-08-16), read-verified file:line per entry (the plan-time require_* grep counts were the index, not the target — each section records its delta). This manifest seeds every later migration slice (F1b→C1): a receiver flip consults its service section here; each slice re-verifies and completes its own rows before enforcing.
Machine-readable projection (F4, #1422): canopy_test_lib::conformance::manifest() carries the executable subset of this inventory — route + method + classification + guard citation — and the conformance matrix (Testing › "boundary-auth conformance matrix") asserts each row’s auth classification against the live devstack on every battery. Slices extend the manifest with their service’s routes as they flip.
The four no-actor patterns
How today’s code reads a request whose Claims carry no X-Canopy-Actor:
-
no-actor-passes — a bare service bearer passes a user-facing decision (exemplar: canopy-web
assignments.rs:36). -
no-actor-passes-with-audit — passes, but the absence is recorded (exemplar: enrollment
gate_household_actor_access,api/mod.rs:123). -
no-actor-rejects — refused outright (exemplar: applications
documents_scan.rs:29verified_reviewer). -
attribution-resolution — decides WHO to record, never allow/deny (
actor().map_or(claims.sub, |a| a.sub)shapes).
An exchanged worker bearer carried claims.actor() == None with the identity in the token itself — all four readings misclassified it; F1b’s EffectiveUser became the single resolution.
Post-C1 status (#1443, 2026-08-24): the four patterns no longer exist outside EffectiveUser internals, and the actor channel is retired (the middleware 401s any request carrying X-Canopy-Actor; Claims::actor and EffectiveUser::ViaActor are deleted — the resolution is total over Direct/System). Where each pattern went:
-
no-actor-passes → retired outright: the applications assignment mutations are service-caller-only provisioning surfaces (post-C1 a service bearer never transports a human, so the delegated-supervisor bar could never fire again and was deleted).
-
no-actor-passes-with-audit → enrollment’s
gate_household_actor_accessalready rodeEffectiveUser(user_claims()with a system-passes arm, S-enrollment). -
no-actor-rejects → the document review trio’s
require_service_or_exchanged+ in-handler human projection (documents_scan::reviewer_uuid/releasing_supervisor— bare service 403s at the projection), and canopy-api’s admin replayrequire_admin_human— since #1571 (2026-08-24) a USER-ONLY route on the mounting service’s receiver contract (require_user_only(admin): theAdminRoutesfamily takes the contract at construction, so all six mounts compose the §C exchanged arm and the S6 broad-audience kill; conformance rows pin every mount). -
attribution-resolution →
EffectiveUser::attribution_sub()everywhere (sectionseditor_uuid, the canopy-api idempotency principal, the tanf/medicaid/snap/persons/security attribution sites).
The per-service tables below are the F1a point-in-time record (read-verified at 4ecbd749, 2026-08-16) the slices consumed; they are NOT re-verified against post-C1 HEAD.
Classification vocabulary used below: the four patterns, plus pure-role-gate (role check with no actor semantics), ownership-gate (household/person scope check), audit-only (records, never decides), other (see notes).
Per-service authorization branches
canopy-appeals (31 branches)
| Site | Construct | Classification | Route | Notes |
|---|---|---|---|---|
|
require_service_caller |
no-actor-passes |
POST /v1/appeals (file_appeal) |
Bare service bearer passes; claims.actor() never consulted. No worker attribution recorded for the filing act — only household-side requestor_person_id from the body; 'request_received' timeline event is anonymous. |
|
require_service_caller |
no-actor-passes |
GET /v1/appeals (list_appeals) |
Read-only list; no actor semantics. |
|
require_service_caller |
no-actor-passes |
GET /v1/appeals/queue (appeals_queue) |
Worker-queue read consumed by the web BFF; a no-actor service bearer sees the jurisdiction-wide queue. |
|
require_service_caller |
no-actor-passes |
GET /v1/appeals/hearings/upcoming (upcoming_hearings) |
Supervisor-dashboard read; no actor semantics. |
|
require_service_caller |
no-actor-passes |
GET /v1/appeals/{id} (get_appeal) |
Full appeal + timeline read; no household-ownership gate — any service bearer reads any appeal. |
|
require_service_caller |
no-actor-passes |
GET /v1/appeals/{id}/hearing-view (get_appeal_hearing_view) |
FTI-safe projection proxy to canopy-snap using appeals' OWN service identity (SnapHearingClient); the worker never reaches the program endpoint. No actor recorded for the access. |
|
require_service_caller |
no-actor-passes |
PUT /v1/appeals/{id}/schedule (schedule_hearing) |
MUTATION WITH ZERO ATTRIBUTION: ScheduleHearingRequest has no actor field; the 'hearing_scheduled' timeline event is anonymous. |
|
require_service_caller |
no-actor-passes |
PUT /v1/appeals/{id}/decision (record_decision) |
Attribution via caller-supplied body string req.actor persisted at :1010 (decision_actor) and echoed in the :1036 timeline note — unverified string, not claims. Also forwarded to enrollment via execute_decision_action_command :1123→send_action_command :1191. |
|
require_service_caller |
no-actor-passes |
PUT /v1/appeals/{id}/final-appeal (record_final_appeal) |
Body-string req.actor persisted at :1400 (final_appeal_actor) and :1419 timeline; also forwarded to enrollment restay at :1290 (regrant_stay). |
|
require_service_caller |
no-actor-passes |
PUT /v1/appeals/{id}/final-decision (record_final_appeal_decision) |
Body-string req.actor persisted at :1575 and :1592 timeline; forwarded to enrollment veto/release via send_action_command :1626. |
|
require_service_caller |
no-actor-passes |
PUT /v1/appeals/{id}/withdraw (withdraw_appeal) |
Body-string req.actor persisted at :1769 (withdrawal_actor) and in the :1776-1796 timeline notes. |
|
require_service_caller |
no-actor-passes |
PUT /v1/appeals/{id}/withdraw/confirm (confirm_withdrawal) |
Body-string req.actor persisted at :1848 and :1867 timeline. |
|
require_service_caller |
no-actor-passes |
PUT /v1/appeals/{id}/withdraw/reinstate (reinstate_appeal) |
Body-string req.actor persisted at :1916 and :1928 timeline. |
|
require_service_caller |
no-actor-passes |
PUT /v1/appeals/{id}/withdraw/finalize (finalize_withdrawal) |
Body-string req.actor persisted at :2036 and :2054-2059 timeline; forwarded to the post-commit enrollment stay release at :2125. This is the ONLY transition that releases the enrollment stay. |
|
require_service_caller |
no-actor-passes |
POST /v1/appeals/{id}/postponements (record_postponement) |
Body-string req.actor persisted at :2228 (store requested_by) and :2245 timeline. |
|
require_service_caller |
no-actor-passes |
POST /v1/internal/appeals/clock-check (trigger_clock_check) |
Internal ops trigger; no attribution of who triggered. |
|
require_service_caller |
no-actor-passes |
POST /v1/internal/appeals/reconcile (trigger_reconcile) |
Internal ops trigger; 503s when no OIDC service identity is configured (ADR-019). No attribution of who triggered. |
|
STAY_ACTOR const ("canopy-appeals", defined :35) passed to client.stay() |
audit-only |
background stay-retry worker (no inbound route) |
Background worker self-labels its stay commands with the hardcoded service name — correct for a worker with no inbound bearer; the ADR-019 service JWT is the authentication. |
|
AdverseActionsClient::command(actor: &str) — string-param actor forwarded cross-service |
audit-only |
outbound PUT enrollment /v1/adverse-actions/{id}/stays/{appeal_id} |
THE appeals string-param actor: caller-supplied &str serialized into StayCommandRequest{actor} (:493-496) as a label on stay/restay/release/veto commands. stay() :421-428 and restay() :462-469 delegate here. Enrollment records the authenticated service JWT sub alongside — 'caller-supplied identity is labeling, not authentication' (cb_stay.rs:32-34, #1093). API handlers feed it from unverified body strings (req.actor). |
|
require_service_caller |
no-actor-passes |
POST /v1/ipv/cases (create_referral) |
Attribution via typed body field referred_by: PersonId (caller-asserted, persisted :96 and in the :110 timeline) — same trust model as the string actor, just typed. |
|
require_service_caller |
no-actor-passes |
GET /v1/ipv/cases?person_id= (list_cases) |
Read; no person/household ownership gate — any service bearer queries any person’s IPV cases. |
|
require_service_caller |
no-actor-passes |
GET /v1/ipv/cases/{id} (get_case) |
Read; no ownership gate. |
|
require_service_caller |
no-actor-passes |
PUT /v1/ipv/cases/{id}/schedule-adh (schedule_adh) |
MUTATION WITH ZERO ATTRIBUTION: no actor field in ScheduleAdhRequest; anonymous timeline event. |
|
require_service_caller |
no-actor-passes |
PUT /v1/ipv/cases/{id}/send-notice (send_notice) |
MUTATION WITH ZERO ATTRIBUTION (no request body at all). |
|
require_service_caller |
no-actor-passes |
PUT /v1/ipv/cases/{id}/record-decision (ipv record_decision) |
MUTATION WITH ZERO ATTRIBUTION: RecordAdhDecisionRequest carries decision only; the adh_decision/cleared timeline events name no recorder. |
|
require_service_caller |
no-actor-passes |
PUT /v1/ipv/cases/{id}/waiver (record_waiver) |
MUTATION WITH ZERO ATTRIBUTION: body bound as _req (ignored); anonymous waiver_signed timeline event. |
|
require_service_caller |
no-actor-passes |
PUT /v1/ipv/cases/{id}/impose-disqualification (impose_disqualification) |
MUTATION WITH ZERO ATTRIBUTION — the highest-stakes IPV action (12/24-month or permanent disqualification) records no imposing actor anywhere. |
|
require_service_caller |
no-actor-passes |
PUT /v1/ipv/cases/{id}/withdraw (withdraw_case) |
MUTATION WITH ZERO ATTRIBUTION (no request body). |
|
require_service_caller |
no-actor-passes |
GET /v1/ipv/disqualifications/active?person_id= (check_active_disqualification) |
Read consumed by program services during eligibility; no ownership gate. |
|
referred_by: PersonId persisted on ipv_cases |
audit-only |
store layer (create_referral) |
Typed but caller-asserted referral attribution; bound at :47. No verification against claims. |
|
actor: &str attribution params persisted to appeal_requests columns |
audit-only |
store layer (all mutating handlers) |
Attribution-persistence family, all plain &str with no resolution branch: record_decision :284 (decision_actor field :273, bound :304), record_final_appeal :349/:353, record_final_appeal_decision :400/:405, request_withdrawal :430/:437, confirm_withdrawal :465/:470, reinstate_appeal :495/:498, finalize_withdrawal :523/:527, record_postponement :671 (requested_by, timeline-only — the UPDATE persists no actor column). No store-level role or household-ownership guard exists anywhere in store.rs or ipv/store.rs. |
canopy-applications (59 branches)
| Site | Construct | Classification | Route | Notes |
|---|---|---|---|---|
|
require_supervisor_actor: claims.actor() None arm ⇒ Ok(()) |
no-actor-passes |
POST /v1/workers/{worker_id}/assignments + DELETE /v1/assignments/{id} |
THE in-service no-actor-passes exemplar: a bare service bearer with no X-Canopy-Actor passes a MUTATING assignment decision. Module doc (lines 16-18) declares it intentional for 'pure system traffic — seeding, scheduled assignment workflows'. |
|
actor.has_role("supervisor") || actor.has_role("admin") |
pure-role-gate |
POST /v1/workers/{worker_id}/assignments + DELETE /v1/assignments/{id} |
Actor-present path: supervisor/admin passes; line 38 Some(_) ⇒ ApiError::Forbidden rejects any other actor. |
|
require_service_caller |
pure-role-gate |
POST /v1/workers/{worker_id}/assignments (create_assignment) |
|
|
require_supervisor_actor(&claims) call |
no-actor-passes |
POST /v1/workers/{worker_id}/assignments (create_assignment) |
Call site of the line-36 helper. |
|
require_service_caller |
pure-role-gate |
DELETE /v1/assignments/{id} (delete_assignment) |
|
|
require_supervisor_actor(&claims) call |
no-actor-passes |
DELETE /v1/assignments/{id} (delete_assignment) |
|
|
require_service_caller |
pure-role-gate |
GET /v1/workers/{worker_id}/assignments (list_assignments_by_worker) |
No actor gate on reads — no-actor service bearers read any worker’s caseload. |
|
require_service_caller |
pure-role-gate |
GET /v1/households/{household_id}/assignments (list_assignments_by_household) |
Hot path consumed by canopy-enrollment. |
|
require_service_caller |
pure-role-gate |
POST /v1/households/{household_id}/authorized-representatives (create_rep) |
|
|
require_service_caller |
pure-role-gate |
GET /v1/households/{household_id}/authorized-representatives (list_reps_by_household) |
|
|
require_service_caller |
pure-role-gate |
GET /v1/authorized-representatives/{id} (get_rep) |
|
|
require_service_caller |
pure-role-gate |
PUT /v1/authorized-representatives/{id} (update_rep) |
|
|
require_service_caller |
pure-role-gate |
DELETE /v1/authorized-representatives/{id} (delete_rep) |
|
|
require_service_caller |
pure-role-gate |
POST /v1/applications/{id}/documents (upload_document) |
Applicant uploads arrive via portal service token with no actor (no applicant actor JWT exists — module doc lines 6-16); uploaded_by_source is caller-supplied form data, not claims-derived. |
|
require_service_caller |
pure-role-gate |
GET /v1/applications/{id}/documents (list_documents) |
|
|
require_service_caller |
pure-role-gate |
GET /v1/applications/{id}/documents/{document_id}/content (get_document_content) |
Byte egress gated only on service-class + scan-viewability (line 376 quarantine gate is data-state, not principal); no actor, no ownership check at origin. |
|
require_service_caller |
pure-role-gate |
POST /v1/applications/{id}/documents/{document_id}/accept (accept_document) |
|
|
verified_reviewer(&claims) call |
no-actor-rejects |
POST /v1/applications/{id}/documents/{document_id}/accept |
accepted_by from the VERIFIED actor claim, never a request body (#1009). |
|
require_service_caller |
pure-role-gate |
POST /v1/applications/{id}/documents/{document_id}/reject (reject_document) |
|
|
verified_reviewer(&claims) call |
no-actor-rejects |
POST /v1/applications/{id}/documents/{document_id}/reject |
rejected_by from the verified actor claim. |
|
verified_reviewer: claims.actor().ok_or(ApiError::Forbidden) |
no-actor-rejects |
accept/reject document (called from documents.rs:434/468) |
The prompt’s no-actor-rejects exemplar (#1009): review without a verifiable human actor is refused; lines 30-35 return 422 when the verified actor sub is not a worker UUID. |
|
verified_supervisor: claims.actor().ok_or(ApiError::Forbidden) |
no-actor-rejects |
POST /v1/applications/{id}/documents/{document_id}/scan-override |
|
|
SCAN_OVERRIDE_ROLES.iter().any(|r| actor.has_role(r)) — supervisor|admin |
pure-role-gate |
POST /v1/applications/{id}/documents/{document_id}/scan-override |
Origin-side role enforcement on the VERIFIED actor JWT (const at line 41); the BFF affordance gate is explicitly not the boundary. |
|
Uuid::parse_str(&actor.sub) → 422 |
attribution-resolution |
POST /v1/applications/{id}/documents/{document_id}/scan-override |
Projects verified actor sub onto the overridden_by worker-UUID column. |
|
require_service_caller |
pure-role-gate |
POST /v1/applications/{id}/documents/{document_id}/scan-override (scan_override_document) |
|
|
verified_supervisor(&claims) call |
no-actor-rejects |
POST /v1/applications/{id}/documents/{document_id}/scan-override |
overridden_by lands on the row + the scan_overridden event (reason digest only, ADR-004). |
|
require_service_caller |
pure-role-gate |
POST /v1/applications/{id}/documents/{document_id}/rescan (rescan_document) |
Mutates scan state and revokes acceptance with NO actor identity required — contrast scan-override. Event trigger recorded as 'manual' only. |
|
require_service_caller |
pure-role-gate |
POST /v1/applications:batchGet (batch_get_applications) |
Deliberately service-tier-only per #1249 least-privilege posture (§B4 bulk read); interactive per-application GET stays broader. |
|
require_service_or_applicant_or_caseworker_or_above |
pure-role-gate |
POST /v1/applications/{id}/ele-consent (record_ele_consent) |
The only route in this service accepting a direct |
|
consent_recorded_by = Uuid::parse_str(&claims.sub).unwrap_or_else(|_| app.submitted_by.into()) |
attribution-resolution |
POST /v1/applications/{id}/ele-consent |
Attribution ONLY — but unlike sections.rs it never consults claims.actor(): a worker attesting through the BFF (service token + actor JWT) is recorded as the SUBMITTER, not the worker; non-UUID sub silently falls back instead of 422ing. Inconsistent with the sections/documents attribution patterns. |
|
require_service_caller |
pure-role-gate |
POST /v1/applicants/verify-credential (verify_credential) |
Applicant has no token at lookup time — this IS how they authenticate; real allow/deny is the store credential check (store/credentials.rs:26). |
|
require_service_caller |
pure-role-gate |
POST /v1/applicants/drafts (create_draft) |
|
|
require_service_caller |
pure-role-gate |
PATCH /v1/applicants/drafts/{id} (patch_draft) |
|
|
require_service_caller |
pure-role-gate |
GET /v1/applicants/drafts/{id} (get_draft) |
IDOR boundary is explicitly the portal BFF (doc lines 588-592): {id} must come from a freshly verified credential. |
|
require_service_caller |
pure-role-gate |
POST /v1/applicants/drafts/reap (reap_drafts) |
Operator tooling, not applicant-reachable. |
|
require_service_caller |
pure-role-gate |
POST /v1/applicants/drafts/{id}/finalize (finalize_draft) |
|
|
require_service_caller |
pure-role-gate |
POST /v1/applications (create_application) |
submitted_by_role is caller-supplied body data validated against a closed set (lines 956-969), NOT claims-derived — spoofable by any service-class caller. |
|
require_service_caller |
pure-role-gate |
GET /v1/applications (list_applications) |
|
|
require_service_caller |
pure-role-gate |
GET /v1/applications/caseload-trend (get_caseload_trend) |
|
|
require_service_caller |
pure-role-gate |
GET /v1/applications/{id} (get_application) |
|
|
require_service_caller |
pure-role-gate |
PUT /v1/applications/{id} (update_application) |
|
|
require_service_caller |
pure-role-gate |
DELETE /v1/applications/{id} (withdraw_application) |
|
|
require_service_caller |
pure-role-gate |
POST /v1/applications/{id}/interview/waive (waive_interview) |
|
|
require_service_caller |
pure-role-gate |
POST /v1/applications/{id}/interview/complete (complete_interview) |
|
|
require_service_caller |
pure-role-gate |
POST /v1/applications/{id}/programs/{program}/determination (record_determination) |
|
|
require_service_caller |
pure-role-gate |
POST /v1/applicants/recover/initiate (recover_initiate) |
|
|
target.is_recovery_blocked() confidential/kill-lock gate |
other |
POST /v1/applicants/recover/initiate |
Data-state protection gate (not claims-based): confidential cases and kill-switch-locked cases refuse self-serve recovery (impl store/recovery.rs:71-72). Allow/deny decision a migration must keep in scope even though it never touches Claims. |
|
require_service_caller |
pure-role-gate |
POST /v1/applicants/recover/kill/{token} (recover_kill) |
Real authz is possession of the 256-bit kill-switch token. |
|
require_service_caller |
pure-role-gate |
GET /v1/applicants/recover/{recovery_id} (recover_get) |
Internal read for the canopy-notices subscriber; exposes kill_switch_token + contact — never applicant-reachable. |
|
editor_uuid: claims.actor().map(|a| a.sub).unwrap_or(claims.sub) |
attribution-resolution |
PUT /v1/applications/{id}/sections/{program}/{section} |
Decides WHO to record as last_edited_by, not allow/deny. Lines 36-40: 422 when the resolved sub is not a UUID (the prompt’s sections.rs:31-41 attribution site). A service bearer with no actor records the service account’s own UUID sub — silent misattribution if a BFF omits the actor header. |
|
require_service_or_caseworker_or_above |
pure-role-gate |
PUT /v1/applications/{id}/sections/{program}/{section} (upsert_section) |
Dual gate: service-class OR caseworker/eligibility_specialist/supervisor/quality_control/admin human bearer. |
|
editor_uuid(&claims) call |
attribution-resolution |
PUT /v1/applications/{id}/sections/{program}/{section} |
Editor UUID feeds the row’s last_edited_by and the application_section.updated event. |
|
require_service_or_caseworker_or_above |
pure-role-gate |
GET /v1/applications/{id}/sections (list_sections) |
|
|
require_service_or_caseworker_or_above |
pure-role-gate |
POST /v1/applications/{id}/programs/{program}/complete-data-collection (complete_data_collection) |
No attribution capture on this state transition (event carries no editor). |
|
submitted_by_role: "applicant" hardcoded on portal finalize |
audit-only |
POST /v1/applicants/drafts/{id}/finalize |
Constant attribution, no branch: every finalized draft records role 'applicant' (vs create_application where the caller supplies the role). |
|
require_scanned_uploads boot guard |
other |
NOT request authz: boot-time fail-closed config guard (noop scanner refused outside development unless CANOPY_APPLICATIONS__ALLOW_INSECURE_SCANNER=true, ADR-041 override pattern). Called from main.rs:118. Plan-time require_* greps sweep it in. |
|
|
boot.auth.with_actor_verifier(ActorVerifyingKeyRegistry) wiring |
attribution-resolution |
Middleware-level X-Canopy-Actor verification (#1009/ADR-019): ONLY canopy-web’s web-actor public key is registered (lines 62-74); any other or reformatted key fails closed as UnknownKid, so claims.actor() is None unless canopy-web minted the JWT. |
|
|
verify_credential (HH code + Argon2id passcode check) |
other |
POST /v1/applicants/verify-credential |
The applicant’s actual authn/authz decision — credential possession, not Claims. Uniform None on all failure modes; timing-equalized dummy verify for unknown codes (lines 58-64). |
|
RecoveryTarget::is_recovery_blocked |
other |
POST /v1/applicants/recover/initiate |
confidentiality.disables_self_serve_recovery() || recovery_locked. |
canopy-caps (15 branches)
| Site | Construct | Classification | Route | Notes |
|---|---|---|---|---|
|
require_service_caller |
pure-role-gate |
POST /v1/determine |
ADR-019 hard cutover (#439): orchestrator-only, service-to-service, not a user-facing decision. |
|
require_service_or_caseworker_or_above |
no-actor-passes |
GET /v1/determinations/{id} |
Dual read; service bearer with no actor passes. |
|
require_data_steward |
pure-role-gate |
POST /v1/determinations/{id}/redact |
Dedicated-role gate (ADR-036 Decision M); admins do NOT auto-hold it. Principal-class-agnostic: a service token granted the data_steward realm role would also pass — the gate never checks is_service()/actor(). One of the two sites missing from the plan-time count. |
|
audit-attribution (Some(claims.sub) into determination.redacted event) |
attribution-resolution |
POST /v1/determinations/{id}/redact |
Records claims.sub directly — NOT actor().map_or(claims.sub, …). If a service bearer ever holds data_steward, the tamper-evident audit event attributes the service, not the human. events.rs:80-92 takes actor_sub: Option<&str> (string param). |
|
require_service_or_caseworker_or_above |
no-actor-passes |
GET /v1/authorizations/{id} |
Dual read; service bearer passes without actor. |
|
require_service_or_caseworker_or_above |
no-actor-passes |
GET /v1/determinations?household_id=X |
Household-scoped list; the household_id filter is a query param, not an ownership check — any passing caller can enumerate any household (LIMIT 100). |
|
require_service_caller |
pure-role-gate |
POST /v1/authorizations/active:batchGet |
Deliberately service-tier (#1249 least-privilege posture, §B4 bulk read): caseworkers excluded by design; 500-id cap. |
|
require_service_or_caseworker_or_above |
no-actor-passes |
GET /v1/determinations/{id}/authorizations |
Dual read; service bearer passes without actor. |
|
require_service_caller |
no-actor-passes |
PUT /v1/authorizations/{id} |
USER-FACING mutation behind a bare service gate: canopy-web actions_caps.rs:181 PUTs with with_service_identity() and NO X-Canopy-Actor. Worker identity travels as body field updated_by, which canopy-caps EXPLICITLY IGNORES (contracts authorizations.rs:63-65: 'audit lives on canopy-web’s tracing log'). |
|
require_service_caller |
no-actor-passes |
PUT /v1/authorizations/{id}/provider |
Same pattern as update_authorization: BFF-driven worker mutation, service bearer, no actor; switched_by body field ignored server-side (contracts authorizations.rs:85-88). |
|
require_service_caller |
pure-role-gate |
POST /v1/providers |
Provider registry create (#396). No in-repo caller found in canopy-web/portal src — likely seeding/ops; service-to-service in practice. |
|
require_service_caller |
no-actor-passes |
GET /v1/providers/{id} |
Worker-facing read: canopy-web determination_view.rs:874 fetches it to render provider detail, with service identity and no actor. |
|
require_service_caller |
pure-role-gate |
PUT /v1/providers/{id} |
No in-repo caller found; service-tier registry maintenance. |
|
require_service_caller |
pure-role-gate |
DELETE /v1/providers/{id} |
Soft-delete (status=inactive); no in-repo caller found. |
|
require_service_caller |
pure-role-gate |
GET /v1/providers |
List; no in-repo caller found (only the {id} GET is called by canopy-web). |
canopy-eligibility (38 branches)
| Site | Construct | Classification | Route | Notes |
|---|---|---|---|---|
|
claims.require_admin() |
pure-role-gate |
POST /v1/eligibility/bulk-runs |
Human admin role required; a service bearer (no admin role) fails — actor never consulted. |
|
created_by: &claims.sub, created_authz_basis: "role:admin" (store::create_cohort_run) |
attribution-resolution |
POST /v1/eligibility/bulk-runs |
Raw claims.sub recorded; no actor().map_or shape anywhere in this service. |
|
record_action("create", &claims, …) |
audit-only |
POST /v1/eligibility/bulk-runs |
H22 ledger row (actor=claims.sub, basis=basis_of). |
|
claims.require_admin() |
pure-role-gate |
POST /v1/eligibility/bulk-runs/{id}/enact |
Also settings.accept_downstream config-refusal 403 at 179-181 (deployment gate, not caller authz). |
|
store::enact_rearm(…, &claims.sub, "role:admin", …) |
attribution-resolution |
POST /v1/eligibility/bulk-runs/{id}/enact |
enacted_by / enact_authz_basis columns (store.rs:1394-1413). |
|
record_action("enact", &claims, …) |
audit-only |
POST /v1/eligibility/bulk-runs/{id}/enact |
Includes accept_preview_failures override detail — audited operator decision (B11). |
|
claims.require_supervisor_or_above() |
pure-role-gate |
POST /v1/eligibility/bulk-runs/{id}/pause |
Supervisor may pause (stays live when bulk core disabled, H18). |
|
record_action("pause", &claims, …) |
audit-only |
POST /v1/eligibility/bulk-runs/{id}/pause |
|
|
claims.require_supervisor_or_above() |
pure-role-gate |
POST /v1/eligibility/bulk-runs/{id}/resume |
|
|
record_action("resume", &claims, …) |
audit-only |
POST /v1/eligibility/bulk-runs/{id}/resume |
|
|
claims.require_admin() |
pure-role-gate |
POST /v1/eligibility/bulk-runs/{id}/cancel |
|
|
store::start_drain(…, Some(&claims.sub)) |
attribution-resolution |
POST /v1/eligibility/bulk-runs/{id}/cancel |
canceled_by column (store.rs:1157-1174). |
|
record_action("cancel", &claims, …) |
audit-only |
POST /v1/eligibility/bulk-runs/{id}/cancel |
|
|
claims.require_admin() |
pure-role-gate |
POST /v1/eligibility/bulk-runs/{id}/retry-failures |
|
|
record_action("retry_failures", &claims, …) |
audit-only |
POST /v1/eligibility/bulk-runs/{id}/retry-failures |
|
|
require_reader(&claims) |
no-actor-passes |
GET /v1/eligibility/bulk-runs/{id} |
|
|
require_reader(&claims) |
no-actor-passes |
GET /v1/eligibility/bulk-runs |
|
|
require_reader(&claims) |
no-actor-passes |
GET /v1/eligibility/bulk-runs/{id}/failures |
|
|
require_reader(&claims) |
no-actor-passes |
GET /v1/eligibility/bulk-runs/{id}/actions |
Audit-ledger READ surface — serves actor + authz_basis rows. |
|
require_reader: if claims.is_service() { Ok } else require_supervisor_or_above() |
no-actor-passes |
all four bulk-run GET routes |
ANY service bearer with no actor reads run status, failures pages (household_id + application_id rows), and the H22 audit ledger. Human arm is supervisor-or-above. |
|
basis_of: claims.has_role("admin") ? "role:admin" : "role:supervisor" |
attribution-resolution |
bulk-run mutation ledger rows |
Decides which basis string to ledger, never allow/deny. Two-valued: a future service-caller mutation would be mislabeled role:supervisor. |
|
record_action → store::append_action(&claims.sub, basis_of(claims), …) |
audit-only |
bulk-run mutations |
Best-effort AFTER the committed transition — a failed append is only an ERROR log (446-471); the mutation stands with a ledger gap. |
|
claims.require_service_or_caseworker_or_above() |
no-actor-passes |
POST /v1/eligibility/determine |
Service arm (is_service) passes with no actor; actor is never consulted (no actor verifier configured in this service, so Claims::actor is always None). |
|
claims.service_id() != Some("canopy-eligibility") → 403 |
other |
POST /v1/eligibility/determine (bulk arm) |
Exact-service-identity gate (#1213 D-5): only the service’s own bulk-consumer self-call may carry a bulk-marked body. service_id() falls back to azp when no service: role suffix exists (canopy-auth claims.rs:241-247) — subtle azp-fallback dependence. Rejection is warn-logged with caller id (handlers.rs:141-144). |
|
claims.require_service_or_caseworker_or_above() |
no-actor-passes |
POST /v1/eligibility/determine/dry-run |
Service bearer, no actor, allowed; dry-run is write-free. |
|
claims.require_service_or_caseworker_or_above() |
no-actor-passes |
GET /v1/eligibility/requests/{id} |
No tenancy/ownership scoping — any service bearer reads any request. |
|
claims.require_service_or_caseworker_or_above() |
no-actor-passes |
GET /v1/eligibility/requests/{id}/determinations |
Same service-open unscoped read. |
|
claims.require_service_or_caseworker_or_above() |
no-actor-passes |
GET /v1/eligibility/results/{application_id} |
Same service-open unscoped read. |
|
caseworker-tier: path worker must equal own UUID sub; supervisor/admin: any worker; service: service_id() == "canopy-web" only |
ownership-gate |
GET /v1/eligibility/workers/{worker_id}/cross-program-alerts |
#596 replaced the #590 identity gate: the result set is now FILTERED to the effective worker’s active household_assignments (live canopy-applications lookup, fail-closed 502 — never the unscoped list). Foreign services are 403 service_not_allowlisted (least privilege until #1430). |
|
require_supervisor_or_above() OR service_id() == "canopy-web" |
no-actor-passes |
GET /v1/eligibility/cross-program-alerts/all |
The unscoped jurisdiction view moved to this explicit supervisor-only path (#596); never consults assignments. The old /v1/eligibility/cross-program-alerts path is retired (404). |
|
claims.require_service_or_caseworker_or_above() (entry gate before resolve_effective_worker) |
no-actor-passes |
GET /v1/eligibility/workers/{worker_id}/cross-program-alerts |
Entry gate; each read publishes the aggregate eligibility.cross_program_alerts.accessed audit event. |
|
claims.require_service_or_caseworker_or_above() |
no-actor-passes |
GET /v1/eligibility/case-status |
Unscoped by household ownership; any service bearer can read any household’s status. |
|
claims.require_service_or_caseworker_or_above() |
no-actor-passes |
GET /v1/eligibility/determinations?household_id= |
PORTAL-TARGET route: canopy-portal home.rs:247 calls this with a bare service bearer (fetch_json bearer_auth only, no X-Canopy-Actor) — passes on the service arm with no actor. No household-ownership check server-side; the portal BFF scopes household_id itself. |
|
requested_by = format!("system:cola-redetermination:{run_id}") |
attribution-resolution |
bulk arm of POST /v1/eligibility/determine |
System-principal attribution for bulk-admitted eligibility_requests rows; consumer.rs:256 sets the same string on the frozen dispatch body. |
|
verify_dispatch_binding: outer_bound requires request.requested_by == system_principal (+ app/household/programs match, 180-198) |
other |
bulk arm of POST /v1/eligibility/determine |
Context-binding integrity gate on the self-call body vs the durable case row — not claims-based; 422 bulk_context_mismatch on drift. |
|
append_action(actor, authz_basis, reason, …) INSERT bulk_run_actions |
audit-only |
bulk-run mutations (store level) |
The H22 ledger table write; actor column is a plain string. |
|
enact_rearm sets enacted_by/enact_authz_basis/enacted_at |
attribution-resolution |
POST /v1/eligibility/bulk-runs/{id}/enact (store level) |
|
|
create_eligibility_request(requested_by: &str) → eligibility_requests.requested_by |
attribution-resolution |
POST /v1/eligibility/determine |
STRING-PARAM ACTOR: the value is the caller-supplied BODY field DetermineRequest.requested_by (crates/canopy-contracts-eligibility/src/determine.rs:34, 'for audit attribution'), threaded via orchestrator.rs:1296 — attribution is self-declared, never derived from claims/actor. |
canopy-enrollment (27 branches)
| Site | Construct | Classification | Route | Notes |
|---|---|---|---|---|
|
require_service_caller |
no-actor-passes |
POST /v1/adverse-actions (schedule_adverse_action) |
Bare service gate; see the companion attribution finding at line 151 — the action row’s actor is caller-supplied text with NO authenticated-principal binding. |
|
audit-attribution: params.actor = req.actor.clone() (also exemption.actor at lines 142-145) |
attribution-resolution |
POST /v1/adverse-actions (schedule_adverse_action) |
INCONSISTENT with the sibling commands: the scheduled action row persists caller-supplied req.actor (and exemption authority/actor) verbatim — no claims.sub prefix — while cancel/stay/reopen persist format!("sub={}; actor={}"). Caller-supplied identity is labeling (#1093 lesson cited at adverse_actions.rs:430-432) yet here it is the ONLY attribution on the row. |
|
require_service_caller |
no-actor-passes |
GET /v1/adverse-actions (list_adverse_actions) |
Bare service gate; cursor-paginated global/household list, unaudited. |
|
require_service_caller |
no-actor-passes |
GET /v1/adverse-actions/{id} (get_adverse_action) |
Bare service gate; the lookup canopy-appeals validates filings against. |
|
require_service_caller |
no-actor-passes |
POST /v1/adverse-actions/{id}/cancel (cancel_adverse_action) |
Bare service gate; attribution resolved at line 374. |
|
audit-attribution: format!("sub={}; actor={}", claims.sub, req.actor) |
attribution-resolution |
POST /v1/adverse-actions/{id}/cancel |
Records the SERVICE token’s sub (the BFF/caller service principal) + caller-supplied display text. NOT the actor().map_or(claims.sub, |a| a.sub) shape — claims.actor() is never consulted, so a propagated X-Canopy-Actor human identity is ignored in favor of the request-body string. |
|
require_service_caller |
no-actor-passes |
PUT /v1/adverse-actions/{id}/stays/{appeal_id} (stay_adverse_action) |
Bare service gate on the fenced stay/restay/release/veto commands; attribution resolved at line 433. |
|
audit-attribution: format!("sub={}; actor={}", claims.sub, req.actor) |
attribution-resolution |
PUT /v1/adverse-actions/{id}/stays/{appeal_id} |
Same shape as cancel: authenticated principal = service JWT sub, human actor = untrusted display text ('#1093 taught us caller-supplied identity is labeling', lines 430-432). claims.actor() not consulted. |
|
require_service_caller |
no-actor-passes |
GET /v1/adverse-actions/{id}/stays/{appeal_id} (get_appeal_stay) |
Bare service gate; read-only reconciliation ground truth for canopy-appeals, no signal appended. |
|
require_service_caller |
no-actor-passes |
POST /v1/adverse-actions/enact-sweep (trigger_enact_sweep) |
Bare service gate on the on-demand enact-sweep pass (operator/journey trigger); no actor, no attribution — sweep outcomes carry system attribution internally. |
|
claims.actor() (let-else in gate_household_actor_access) |
no-actor-passes |
GET /v1/households/{household_id}/issuances + GET /v1/households/{household_id}/annual-summary |
No actor → return Ok(()) unconditionally ('pure system traffic — scheduled jobs / the applicant-portal BFF'). READ-VERIFIED DISCREPANCY vs the plan’s 'no-actor-passes-with-audit' label: the no-actor arm emits NO audit row recording the absence — audit_household_read (mod.rs:176-204) fires ONLY when an actor is present, and the doc-comment at mod.rs:172-175 explicitly says an actor-less read 'is not audited here'. The audit instrumentation covers only the actor-present arms (allow → enrollment.household_issuance.read; deny → .access_denied). |
|
actor.has_role("supervisor") || actor.has_role("admin") |
pure-role-gate |
GET /v1/households/{household_id}/issuances + GET /v1/households/{household_id}/annual-summary |
Worker-actor role bypass of the household-assignment check inside gate_household_actor_access; supervisor/admin pass unaudited at this branch (allow-path audit still fires later via audit_household_read). |
|
actor.sub.parse::<Uuid>() → Forbidden on failure |
ownership-gate |
GET /v1/households/{household_id}/issuances + GET /v1/households/{household_id}/annual-summary |
Fail-closed prelude to the assignment lookup: an actor whose sub is not a UUID is denied (tracing::warn + 403) — string-form actor subs are unsupported here. No audit event on this deny arm (the access_denied audit fires only on the assignment-miss path below). |
|
is_worker_assigned_to_household (canopy-applications RBAC lookup) |
ownership-gate |
GET /v1/households/{household_id}/issuances + GET /v1/households/{household_id}/annual-summary |
#408 Pub 1075 AC-6 least-privilege: non-supervisor worker actor must hold an active assignment in canopy-applications (clients/mod.rs is_worker_assigned_to_household, called with a freshly minted service token — inbound bearer NOT forwarded). assigned → Ok; not assigned → falls to the audited deny at mod.rs:151-169. |
|
events::publish_household_issuance_access_denied + Err(Forbidden) at mod.rs:169 |
audit-only |
GET /v1/households/{household_id}/issuances + GET /v1/households/{household_id}/annual-summary |
Deny-path audit: best-effort one-shot tx staging enrollment.household_issuance.access_denied (worker_id, household_id, full roles Vec — events.rs:174-191); every failure is warn-and-continue, then the 403 returns regardless. The deny decision is the API contract; the audit never blocks it. |
|
claims.actor() + actor.sub.parse() guard in audit_household_read (audit-attribution) |
attribution-resolution |
GET /v1/households/{household_id}/issuances + GET /v1/households/{household_id}/annual-summary |
Allow-path #408 audit: fires ONLY when a worker actor with a UUID sub is present — records worker_id + comma-joined role_summary via enrollment.household_issuance.read (events.rs:150-167). Actor-less (portal BFF / system) reads are deliberately unaudited; an actor with a non-UUID sub silently skips the audit (cannot occur in practice — the gate already 403’d that shape at mod.rs:129). Best-effort: warn-and-continue on failure. |
|
require_service_caller |
no-actor-passes |
POST /v1/enrollments (create_enrollment) |
Bare service gate; actor never consulted. Service-tier write (auto-enroll/orchestrator surface). |
|
require_service_caller |
no-actor-passes |
GET /v1/enrollments?household_id= (list_enrollments) |
Bare service gate; actor never consulted; unaudited household-scoped read (contrast the #408-gated per-household issuance reads). |
|
require_service_caller |
no-actor-passes |
GET /v1/enrollments/{id} (get_enrollment) |
Bare service gate; actor never consulted. |
|
require_service_caller |
no-actor-passes |
POST /v1/enrollments/{id}/issue (issue_benefits) |
Bare service gate on a money-moving EBT issuance; no actor consulted, no attribution recorded on the issuance row. |
|
require_service_caller |
no-actor-passes |
GET /v1/enrollments/{id}/issuances (list_issuances) |
Bare service gate; deliberately unaudited service-tier read (per the mod.rs:769-779 doc on the batch endpoint’s posture). |
|
require_service_caller (ADR-019 bearer gate, ahead of the #408 gate) |
no-actor-passes |
GET /v1/households/{household_id}/issuances (list_issuances_for_household) |
First of two layers: service-class bearer required, THEN gate_household_actor_access (mod.rs:718-726) modulates by actor; allow path audited via audit_household_read at mod.rs:746. |
|
require_service_caller |
no-actor-passes |
POST /v1/households/issuances:batchGet (batch_get_household_issuances) |
§B4 bulk read, deliberately OUTSIDE the #408 gate/audit (doc mod.rs:769-779: emits ZERO #408 events by construction, pinned by test). Reporting-pipeline system read; a migration slice must preserve the actor-less path here. |
|
require_service_caller (ahead of the #408 gate at mod.rs:852-860) |
no-actor-passes |
GET /v1/households/{household_id}/annual-summary (get_household_annual_summary) |
Same two-layer shape as the issuance list: service bearer, then gate_household_actor_access; allow path audited at mod.rs:882. This is the ONE enrollment route the applicant portal calls (actor-less BFF read). |
|
require_service_caller |
no-actor-passes |
POST /v1/enrollments/{id}/terminate (terminate_enrollment) |
410 Gone tombstone (#1095 — direct termination removed) but still auth-gated: a non-service caller gets 403 before the 410. Keep the gate when migrating so the tombstone doesn’t become an unauthenticated probe surface. |
|
require_service_caller |
no-actor-passes |
POST /v1/adverse-actions/{id}/reopen (reopen_adverse_action) |
Bare service gate; canopy-renewals is the intended caller. Attribution resolved at line 78. |
|
audit-attribution: format!("sub={}; actor={}", claims.sub, req.actor) |
attribution-resolution |
POST /v1/adverse-actions/{id}/reopen |
Same sub+display-text shape as cancel/stay; persisted on the append-only reopen row (adverse_actions.rs:1704-1712) and the 'reopened' signal. claims.actor() not consulted. |
canopy-medicaid (40 branches)
| Site | Construct | Classification | Route | Notes |
|---|---|---|---|---|
|
require_service_caller |
no-actor-passes |
POST /v1/cmd/ingest |
Service-only (#448, worker-portal-initiated via canopy-web). Actor never consulted; attribution instead rides the request body (submitted_by, line 94). |
|
audit-attribution (body string req.submitted_by persisted) |
other |
POST /v1/cmd/ingest |
STRING-PARAM ACTOR: caller-supplied submitted_by is inserted into medicaid_cmd_events.submitted_by with no cross-check against Claims/actor — a migration slice should derive this from the verified actor. |
|
require_service_caller |
no-actor-passes |
POST /v1/determinations/{id}/requeue |
Service-only operator action (#392 quarantine resolution via canopy-web). Actor never consulted. |
|
audit-attribution (body string req.resolved_by, tracing only) |
audit-only |
POST /v1/determinations/{id}/requeue |
STRING-PARAM ACTOR: resolved_by comes from the request body and is only tracing::info-logged (no DB row); trusted from the caller. |
|
require_fti_auditor |
pure-role-gate |
GET /v1/fti-audit-log |
Dedicated fti_auditor role per Pub 1075 par9 (#383); admin explicitly excluded. Bearer realm roles only. |
|
require_fti_auditor |
pure-role-gate |
GET /v1/fti-audit-log/{id} |
Same dedicated-role separation. |
|
require_fti_auditor |
pure-role-gate |
GET /v1/fti-audit-log/summary |
Same dedicated-role separation. |
|
require_service_caller |
no-actor-passes |
POST /v1/determine |
ADR-019 hard cutover (#439): service-only. Service bearer with no actor passes; actor is optional and only feeds the accessed_by attribution at 84-87. No audit of actor absence. |
|
actor().map_or(claims.sub, |a| a.sub) → accessed_by |
attribution-resolution |
POST /v1/determine |
FTI accessed_by attribution (ADR-028 par52/ADR-014), lines 84-87 — the site named in the task. DEAD-ARM IN PRACTICE: medicaid never wires AuthLayer::with_actor_verifier (bootstrap default AuthLayer::new, crates/canopy-api/src/bootstrap.rs:180), so actor() is ALWAYS None here and accessed_by always records the calling service’s sub, never the on-behalf-of worker; a caller sending X-Canopy-Actor gets 401 from shared middleware (crates/canopy-auth/src/middleware.rs:139-145). |
|
require_service_or_caseworker_or_above |
no-actor-passes |
GET /v1/determinations/{id} |
Transitional ADR-019 dual gate: is_service() short-circuits with no actor check; user bearer needs caseworker+ realm role (has_role checks bearer roles only, claims.rs:148-150, 266-274). |
|
require_data_steward |
pure-role-gate |
POST /v1/determinations/{id}/redact |
Dedicated data_steward realm role (ADR-036 Decision M); admins do NOT auto-hold it. Checks bearer realm roles only — a generic service token is rejected; actor never consulted. |
|
audit-attribution (claims.sub into determination.redacted event) |
attribution-resolution |
POST /v1/determinations/{id}/redact |
Records Some(claims.sub) as the redacting steward on the tamper-evident event — NO actor() resolution (inconsistent with the 84-87 pattern). Safe today because the gate is a human role, but a service token granted data_steward would attribute as the service. |
|
require_service_caller (month-arm) |
no-actor-passes |
GET /v1/determinations?month= |
#1249 split gate: the |
|
require_service_or_caseworker_or_above (unscoped arm) |
no-actor-passes |
GET /v1/determinations |
Else-arm of the same 291-295 split: interactive unscoped list keeps the dual gate; service bearer passes with no actor. |
|
require_service_or_caseworker_or_above |
no-actor-passes |
GET /v1/applications/{id}/categories |
Dual gate; service arm actor-blind. |
|
require_service_or_caseworker_or_above |
no-actor-passes |
GET /v1/determinations/{id}/explanation |
Dual gate; service arm actor-blind. |
|
require_service_or_caseworker_or_above |
no-actor-passes |
GET /v1/tma |
Dual gate; household_id is a raw query param with NO household-ownership check — any caseworker or service token can read any household’s TMA rows. |
|
require_service_or_caseworker_or_above |
no-actor-passes |
GET /v1/ele/{person_id} |
Dual gate; no person/household ownership check. |
|
require_service_or_caseworker_or_above |
no-actor-passes |
GET /v1/ele/chain-status |
Dual gate on a chain-integrity probe (no PII in response). |
|
require_admin_or_quality_control |
pure-role-gate |
POST /v1/ele/{person_id}/revoke |
Manual worker action (Pub-1075-relevant); bearer realm roles only, actor never consulted; generic service tokens rejected. |
|
audit-attribution (Uuid::parse_str(claims.sub) → actor_id) |
attribution-resolution |
POST /v1/ele/{person_id}/revoke |
Lines 573-582: bearer sub parsed as UUID for the hash-chained revoke event’s actor_id; non-UUID sub → actor_id=None with a loud warn (attribution dropped, action proceeds). No actor() resolution. |
|
require_service_or_caseworker_or_above |
no-actor-passes |
GET /v1/ele/household/{household_id} |
Dual gate; no household-ownership check (worker-portal rollup). |
|
require_admin_or_quality_control |
pure-role-gate |
POST /v1/ele/renewals/run |
Admin/QC ops affordance triggering the scheduler tick; the resulting chain events carry actor_id=None (scheduler.rs:305) — the triggering admin is NOT attributed on the rows. |
|
require_service_or_caseworker_or_above |
no-actor-passes |
GET /v1/overpayments |
Dual gate; canopy-reporting drains this with a service token (no actor) per ADR-001 Amendment 1 par-B2/B3. |
|
require_service_or_caseworker_or_above |
no-actor-passes |
POST /v1/overpayments |
WRITE endpoint (files a claim) with the actor-blind service arm; no attribution of who filed is captured from Claims at all. |
|
require_service_or_caseworker_or_above |
no-actor-passes |
GET /v1/overpayments/{id} |
Dual gate. |
|
require_service_or_caseworker_or_above |
no-actor-passes |
POST /v1/overpayments/{id}/repayment-plans |
WRITE with actor-blind service arm; no Claims-derived attribution persisted. |
|
require_service_or_caseworker_or_above |
no-actor-passes |
POST /v1/overpayments/{id}/recoupments |
WRITE (ledger append) with actor-blind service arm; no Claims-derived attribution persisted. |
|
require_service_or_caseworker_or_above |
no-actor-passes |
GET /v1/overpayments/{id}/ledger |
Dual gate. |
|
CallerContext.accessed_by (attribution plumbing) |
audit-only |
POST /v1/determine |
Threads the handler-resolved accessed_by (actor-else-service sub) into persist_determinations (line 703) for the ADR-014 chain writes. Not a gate. |
|
audit-attribution (accessed_by into FtiAuditEntry) |
audit-only |
POST /v1/determine |
medicaid_snapshot_chain_entry (717-747) stamps accessed_by on each determination-snapshot FTI chain entry (FtiAction::Write, resource determination_snapshot). |
|
audit-attribution (chain entry append per snapshot) |
audit-only |
POST /v1/determine |
One chain entry per FTI-bearing snapshot appended in the same all-or-nothing tx (append_determination_chain_entries at 848; mode-split v1/v2 per #1207). |
|
audit-attribution (actor_id canonicalized into chain hash) |
audit-only |
Lines 119-135: actor_id (or the None sentinel) is hashed into the ADR-014 ELE event hash — attribution is tamper-evident once written. |
|
|
audit-attribution (actor_id: None on source-closed lapse event) |
audit-only |
Bus subscriber (snap/tanf case-closed) writes Lapsed chain events with actor_id=None — attribution to the originating event/service is only in the JSON payload. |
|
|
audit-attribution (actor_id: None on ELE grant event) |
audit-only |
SNAP/TANF-approval GRANT subscriber persists Granted events with actor_id=None; provenance carried as source_program/source_determination_id instead. |
|
|
audit-attribution (actor_id: None on renewal chain event) |
audit-only |
Daily/manual ELE renewal sweep writes system-initiated Renewed events with actor_id=None — no system sentinel, and a manual /ele/renewals/run trigger loses the admin’s identity. |
|
|
audit-attribution (actor_id: None on expiry-lapse chain event) |
audit-only |
Expiry-driven Lapsed events: system-initiated, actor_id=None. |
|
|
audit-attribution (actor_id bound into ele_grant_events insert) |
audit-only |
Store-level persistence of the hash-chained event’s actor_id (Option<Uuid>); NULL for all system/subscriber paths. |
|
|
fti_audited(pool, accessed_by, …) wrapper |
audit-only |
read_fti_tax_data: every FTI read audited with accessed_by per Pub 1075. Currently #[expect(dead_code)] — wiring tracked by #785; the identity a future caller threads here is a migration concern. |
|
|
fti_audited(pool, accessed_by, …) wrapper |
audit-only |
read_fti_for_magi: same audited-read wrapper, also dead_code pending #785. |
canopy-notices (9 branches)
| Site | Construct | Classification | Route | Notes |
|---|---|---|---|---|
|
require_service_caller |
no-actor-passes |
POST /v1/documents/render |
ADR-029 signed-document render (audit citation). Worker-triggered via canopy-web (api/audit_log.rs:624) with a bare service bearer; handler never reads actor(); the JWS signs inputs, not the requesting human. |
|
require_service_caller |
pure-role-gate |
POST /v1/notices |
generate_notice — machine surface (orchestrator/event-driven callers). No user decision flows through; no actor concept in the request. |
|
require_service_caller |
no-actor-passes |
GET /v1/notices |
list_notices — portal BFF lists an applicant household’s notices (portal notices.rs:228) and canopy-web lists for workers, both with bare service tokens. household_id is an UNVERIFIED query filter; no actor requirement, no audit of absence. |
|
require_service_caller |
no-actor-passes |
GET /v1/notices/{id} |
get_notice — any service bearer can read any notice+appeal-rights by id. Portal uses this as its OWN ownership pre-check (portal notices.rs:180); the service enforces nothing per-household. |
|
require_service_caller |
no-actor-passes |
GET /v1/notices/{id}/pdf |
get_notice_pdf — PII-bearing PDF streamed (or on-demand rendered) for any service bearer, no actor, no per-request audit row in this handler. |
|
require_service_caller |
no-actor-passes |
POST /v1/notices/{id}/mark-read |
mark_notice_read — an APPLICANT user action persisted with zero actor attribution. Doc comment (lines 381-384) explicitly delegates the IDOR boundary to the portal BFF; the service records read_at with no record of who. |
|
require_service_caller |
pure-role-gate |
POST /v1/notices/{id}/resend |
resend_notice — no in-tree BFF caller found (grep of canopy-web + canopy-portal); operational/test surface. Re-queues with delivery channel literal "test" (line 426). |
|
require_service_caller |
pure-role-gate |
GET /v1/notices/queue |
list_delivery_queue — machine delivery-worker surface; no user context possible. |
|
household-membership check (person.household_id != Some(household_id) → NotInHousehold error) |
ownership-gate |
NON-claims, worker-side guard on the notice-generation path: recipient person must belong to the target household or generation fails (notice-misdirection defense). Not an HTTP authz branch but a real ownership control a migration must not regress. |
canopy-persons (58 branches)
| Site | Construct | Classification | Route | Notes |
|---|---|---|---|---|
|
require_admin_or_quality_control |
pure-role-gate |
GET /v1/export/persons (export_persons) |
Bearer realm-role gate (admin|quality_control); actor never consulted. A service token carrying those realm roles would also pass — user-only by intent, not by is_service() exclusion. |
|
audit-attribution (persons.export.requested payload "actor": claims.sub) |
audit-only |
Export-of-the-export event; publish failure is warn-and-continue (export.rs:264-271) — best-effort, unlike the fail-closed ssn.accessed. |
|
|
require_service_caller |
no-actor-passes |
POST /v1/persons (create_person) |
Service-tier gate only; claims.actor() never consulted. Downstream Pub-1075 ssn.accessed audit (line 265-272) attributes claims.sub — the service sub when no actor. |
|
require_finalize_caller (conditional: step.is_some()) |
pure-role-gate |
POST /v1/persons (create_person) |
Service-identity gate: service_id() must be canopy-applications; runs only when X-Canopy-Finalize-* headers present. Actor never consulted. |
|
audit-attribution (audit_ssn_access, actor_sub = claims.sub at all 7 call sites) |
audit-only |
Pub-1075 ssn.accessed staged fail-closed AFTER projection. NO actor() resolution anywhere in the service — no actor().map_or(claims.sub,…) shape exists; an on-behalf-of actor is never recorded. Call sites: mod.rs:265, 500, 539, 584, 869, 954; export.rs:217. |
|
|
require_service_caller |
no-actor-passes |
GET /v1/persons (list_persons) |
SSN audit at 500-507 attributes claims.sub raw. |
|
require_service_caller |
no-actor-passes |
GET /v1/persons/{id} (get_person) |
PORTAL TARGET: canopy-portal calls this with a bare service bearer and no X-Canopy-Actor. SSN audit at 539-546 attributes claims.sub (portal’s service sub, not the applicant). |
|
require_service_caller |
no-actor-passes |
PUT /v1/persons/{id} (update_person) |
SSN audit at 584-591 attributes claims.sub raw. |
|
require_service_caller |
no-actor-passes |
DELETE /v1/persons/{id} (delete_person) |
Soft delete; no audit event at all — no attribution recorded for the delete. |
|
require_service_caller |
no-actor-passes |
POST /v1/households (create_household) |
|
|
require_finalize_caller (conditional) |
pure-role-gate |
POST /v1/households (create_household) |
|
|
require_service_caller |
no-actor-passes |
GET /v1/households/{id} (get_household) |
|
|
require_fact_ownership (definition) |
ownership-gate |
ADR-027 D11: supplied fact_id must belong to path person_id else 404 (no cross-person existence leak). Table name is a fixed handler literal. Data-tenancy within a person, NOT caller scoping. |
|
|
require_service_caller |
no-actor-passes |
GET /v1/households/{id}/full (get_household_full) |
SSN audit at 869-876 attributes claims.sub raw (BatchLookup purpose). |
|
require_service_caller |
no-actor-passes |
POST /v1/persons:batchGet (batch_get_persons) |
SSN audit at 953-962 fires only when projection.ssn requested; attributes claims.sub raw. |
|
require_service_caller |
no-actor-passes |
POST /v1/households:batchGet (batch_get_households) |
#1249 least-privilege posture; deliberately zero Pub-1075 events (no sealed value opened, comment 993-997). |
|
require_member_ownership (definition) |
ownership-gate |
Household-scoped twin; returns stored person_id (immutable subject). |
|
|
require_service_caller |
no-actor-passes |
POST /v1/households/{id}/members/claims (claim_household_member) |
Attribution comes from body-supplied req.author, never from Claims/actor. |
|
require_finalize_caller (conditional) |
pure-role-gate |
POST /v1/households/{id}/members/claims |
|
|
require_member_ownership |
ownership-gate |
POST /v1/households/{id}/members/claims |
Correction fact_id must belong to path household (404 on miss, D11); stored person_id authoritative; 422 at 1123-1127 blocks reassignment. |
|
require_service_caller |
no-actor-passes |
DELETE /v1/households/{id}/members/claims/{fact_id} (close_household_member_claim) |
Close event author is None (events.rs:214 'ADR-019 on-behalf-of limitation'). |
|
require_member_ownership |
ownership-gate |
DELETE /v1/households/{id}/members/claims/{fact_id} |
|
|
require_service_caller |
no-actor-passes |
POST /v1/persons/{id}/addresses/claims (claim_address) |
|
|
require_finalize_caller (conditional) |
pure-role-gate |
POST /v1/persons/{id}/addresses/claims |
|
|
require_fact_ownership |
ownership-gate |
POST /v1/persons/{id}/addresses/claims |
Only when correction fact_id supplied. |
|
require_service_caller |
no-actor-passes |
DELETE /v1/persons/{id}/addresses/claims/{fact_id} (close_address_claim) |
Close event author None (events.rs:339). |
|
require_fact_ownership |
ownership-gate |
DELETE /v1/persons/{id}/addresses/claims/{fact_id} |
|
|
require_service_caller |
no-actor-passes |
GET /v1/persons/{id}/income (list_income) |
|
|
require_service_caller |
no-actor-passes |
GET /v1/persons/{id}/assets (list_assets) |
|
|
require_service_caller |
no-actor-passes |
GET /v1/persons/{id}/expenses (list_expenses) |
|
|
reject_system_author (body-supplied Author integrity gate) |
other |
Blocks Author::System on fact claims (ADR-027 §1). req.author is CALLER-SUPPLIED and never cross-checked against Claims or actor — the service trusts the calling service’s stated author. Call sites: 1116, 1283, 1634, 1700, 1765. |
|
|
require_service_caller |
no-actor-passes |
POST /v1/persons/{id}/income/claims (claim_income) |
|
|
require_finalize_caller (conditional) |
pure-role-gate |
POST /v1/persons/{id}/income/claims |
|
|
require_fact_ownership |
ownership-gate |
POST /v1/persons/{id}/income/claims |
|
|
require_service_caller |
no-actor-passes |
POST /v1/persons/{id}/assets/claims (claim_asset) |
|
|
require_finalize_caller (conditional) |
pure-role-gate |
POST /v1/persons/{id}/assets/claims |
|
|
require_fact_ownership |
ownership-gate |
POST /v1/persons/{id}/assets/claims |
|
|
require_service_caller |
no-actor-passes |
POST /v1/persons/{id}/expenses/claims (claim_expense) |
|
|
require_finalize_caller (conditional) |
pure-role-gate |
POST /v1/persons/{id}/expenses/claims |
|
|
require_fact_ownership |
ownership-gate |
POST /v1/persons/{id}/expenses/claims |
|
|
require_service_caller |
no-actor-passes |
DELETE /v1/persons/{id}/income/claims/{fact_id} (close_income_claim) |
Close event author None (events.rs:276-278). |
|
require_fact_ownership |
ownership-gate |
DELETE /v1/persons/{id}/income/claims/{fact_id} |
|
|
require_service_caller |
no-actor-passes |
GET /v1/persons/{id}/addresses (list_addresses) |
|
|
require_data_steward |
pure-role-gate |
POST /v1/persons/{id}/facts/{kind}/{fact_id}/redact (post_redact_fact) |
Dedicated data_steward role (ADR-036 Decision M; admin does NOT auto-pass). Actor never consulted; redaction attributed to claims.sub at 1946-1955. |
|
require_fact_ownership |
ownership-gate |
POST /v1/persons/{id}/facts/{kind}/{fact_id}/redact |
kind pre-validated by fact_subject_kind (1890-1898) before SQL table interpolation. |
|
require_data_steward |
pure-role-gate |
POST /v1/persons/{id}/redact-ssn (post_redact_ssn) |
Redaction attributed to claims.sub at 2010-2016. |
|
require_service_caller |
no-actor-passes |
POST /v1/internal/finalize-operations/{op}/{gen}/register |
Paired with unconditional require_finalize_caller at 2080. |
|
require_finalize_caller (unconditional) |
pure-role-gate |
POST /v1/internal/finalize-operations/{op}/{gen}/register |
applications-only (ADR-038). |
|
require_service_caller |
no-actor-passes |
POST /v1/internal/finalize-operations/{op}/{gen}/release |
|
|
require_finalize_caller (unconditional) |
pure-role-gate |
POST /v1/internal/finalize-operations/{op}/{gen}/release |
|
|
require_service_caller |
no-actor-passes |
POST /v1/internal/finalize-operations/{op}/{gen}/cancel |
|
|
require_finalize_caller (unconditional) |
pure-role-gate |
POST /v1/internal/finalize-operations/{op}/{gen}/cancel |
|
|
require_service_caller |
no-actor-passes |
GET /v1/internal/finalize-operations/{op} (get_finalize_operation) |
|
|
require_finalize_caller (unconditional) |
pure-role-gate |
GET /v1/internal/finalize-operations/{op} |
|
|
require_data_steward |
pure-role-gate |
POST /v1/households/{household_id}/compensate-finalize-orphan (compensate_finalize_orphan) |
Human steward op (ADR-038 MR9). No audit-attribution event carries the steward’s sub in this handler. |
|
household_self_membership_is_finalize provenance gate |
other |
Refuses orphan compensation (409) without a finalize-origin self membership — protects non-finalize data from steward auto-shred (#1055). Companion saga-era gate graph_has_receipts at 2467. |
|
|
require_finalize_caller (definition: claims.service_id() == "canopy-applications") |
pure-role-gate |
service_id() (canopy-auth claims.rs:241-247) falls back to azp when no service: role. Safe today only because every call site runs require_service_caller first (doc comment finalize.rs:86-88); the invariant is per-call-site, not enforced in the helper. |
|
|
require_active_generation (definition; store-level FOR SHARE gate) |
other |
Saga-state gate, not identity authz: refuses a finalize-tagged write whose (operation_id, generation) is absent/cancelled (409). Sole call site: api/mod.rs:314 (resolve_step). |
canopy-renewals (34 branches)
| Site | Construct | Classification | Route | Notes |
|---|---|---|---|---|
|
require_service_caller |
no-actor-passes |
POST /v1/renewals/snap/certifications (create_certification) |
Worker-initiated via canopy-web BFF (actions_snap.rs:320, plain .post, no X-Canopy-Actor). Service bearer with no actor passes; no attribution of the deciding worker anywhere in the request. |
|
require_service_caller |
no-actor-passes |
GET /v1/renewals/snap/certifications?household_id= (get_active_certification) |
Household-scoped PII read; household_id is a trusted caller-supplied query param (HouseholdQuery, mod.rs:66-69); no ownership gate, actor never consulted. |
|
require_service_caller |
no-actor-passes |
GET /v1/renewals/snap/certifications/{id} (get_certification) |
Any service bearer can read any certification by id; no actor, no ownership check. |
|
require_service_caller |
pure-role-gate |
POST /v1/renewals/snap/universe-snapshots (create_universe_snapshot) |
#1470, machine-to-machine only — called by canopy-eligibility bulk_runs_support.rs:350. No human actor exists by design. Inline comment: 'service-class only, like every renewals federal-universe read'. |
|
require_service_caller |
pure-role-gate |
GET /v1/renewals/snap/universe-snapshots/{id}/rows (list_universe_snapshot_rows) |
#1470, machine-to-machine only — canopy-eligibility bulk/worker.rs:378 pages it. |
|
require_service_caller |
no-actor-passes |
GET /v1/renewals/snap/due (list_due) |
Worker MyQueue/dashboard feed (canopy-web renewals.rs:83, dashboard.rs); household case rows served to a no-actor service bearer. |
|
require_service_caller |
no-actor-passes |
GET /v1/renewals/{program}/due (list_program_due) |
Worker MyQueue per-program fan-out (canopy-web my_queue.rs:363). 422s unknown program slug after the gate; non-snap degrades to empty page. |
|
require_service_caller |
no-actor-passes |
GET /v1/renewals/overdue (list_overdue) |
Worker-dashboard Overdue-cases panel feed (#520, canopy-web overdue_cases.rs); household-level rows, no actor. |
|
require_service_caller |
pure-role-gate |
GET /v1/renewals/caseload-trend (get_caseload_trend) |
Aggregate depth series only (no per-household data); doc-comment says 'Service-caller only' (#702/#1218). Supervisor dashboard panel calls with pure service identity. |
|
require_service_caller |
no-actor-passes |
GET /v1/renewals/snap/interim-contacts/due (list_interim_contacts_due) |
Worker feed; ADR-033 as_of read seam; no actor. |
|
require_service_caller |
no-actor-passes |
POST /v1/renewals/snap/certifications/{id}/interim-contact (record_interim_contact) |
Worker decision write via BFF (canopy-web actions.rs:87, plain .post). Change-report row records contact_method/notes from body; NO worker identity recorded at all. |
|
require_service_caller |
no-actor-passes |
POST /v1/renewals/snap/certifications/{id}/change-report (create_change_report) |
Worker decision write via BFF (canopy-web actions.rs:261). No worker identity captured in the row. |
|
require_service_caller |
no-actor-passes |
POST /v1/renewals/{program}/certifications/{id}/interim-contact (record_program_interim_contact) |
#448 non-SNAP variant (BFF actions_tanf/medicaid/caps/wic). Gate then validate_program (mod.rs:1061) + snap-rejection branch (mod.rs:1064). cert_id is opaque/unverified; row keyed on body household_id. |
|
require_service_caller |
no-actor-passes |
POST /v1/renewals/{program}/certifications/{id}/change-report (create_program_change_report) |
#448 non-SNAP variant; same shape — validate_program at 1112, snap-rejection at 1113; body household_id trusted. |
|
require_service_caller |
no-actor-passes |
GET /v1/renewals/snap/nudges (list_recert_nudges) |
Worker case-detail feed; household_id caller-supplied (NudgeListQuery mod.rs:1139-1144). |
|
require_service_caller |
no-actor-passes |
POST /v1/renewals/snap/nudges/{id}/action (action_recert_nudge) |
Worker file/dismiss decision. Attribution comes from body req.action_by (see mod.rs:1207 entry) — the claims actor is never read. |
|
audit-attribution (body-supplied action_by UUID) |
attribution-resolution |
POST /v1/renewals/snap/nudges/{id}/action |
Worker identity recorded from request body (req.action_by), NEVER from claims.actor() — unverified attribution; a service bearer can stamp any UUID. Persisted at store.rs:1026-1036 (UPDATE … action_by = $3 WHERE action_taken IS NULL — idempotency guard preserves first attribution). |
|
require_service_caller |
no-actor-passes |
GET /v1/renewals/snap/periodic-reports (list_periodic_reports) |
Household-scoped cycle list, caller-supplied household_id. |
|
require_service_caller |
no-actor-passes |
GET /v1/renewals/snap/periodic-reports/{id} (get_periodic_report) |
Any service bearer reads any cycle by id. |
|
require_service_caller |
no-actor-passes |
POST /v1/renewals/snap/periodic-reports/{id}/form (record_periodic_report_form) |
Worker records 3730 Step-2 form receipt; no worker identity captured. |
|
require_service_caller |
no-actor-passes |
POST /v1/renewals/snap/periodic-reports/{id}/vcl (send_periodic_report_vcl) |
Worker sends VCL (3730 Step 4); no worker identity captured. |
|
require_service_caller |
no-actor-passes |
POST /v1/renewals/snap/periodic-reports/{id}/verified (record_periodic_report_verified) |
Worker verification stamp; no identity captured. |
|
require_service_caller |
no-actor-passes |
POST /v1/renewals/snap/periodic-reports/{id}/complete (complete_periodic_report) |
Worker processes cycle (locked tx + outbox event); no identity captured in the processed stamp or change-report rows. |
|
require_service_caller |
no-actor-passes |
POST /v1/renewals/snap/periodic-reports/{id}/reopen (reopen_periodic_report) |
Worker reopen of a terminated cycle. Actor attribution is body string req.actor forwarded to enrollment (mod.rs:1610 entry). Also a deployment gate at 1580-1588: refuses (500) when no ADR-019 service identity is configured for the enrollment call. |
|
audit-attribution (body-supplied actor string) |
attribution-resolution |
POST /v1/renewals/snap/periodic-reports/{id}/reopen |
req.actor (free-form string from request body) forwarded verbatim to canopy-enrollment’s ReopenAdverseActionRequest as the audited reopen actor — string-param actor, unverified. |
|
require_service_caller |
no-actor-passes |
GET /v1/renewals/snap/redeterminations (list_redeterminations) |
Worker veto/cancel queue; household_id caller-supplied. |
|
require_service_caller |
no-actor-passes |
POST /v1/renewals/snap/redeterminations/{id}/action (action_redetermination) |
Worker redetermined/dismissed decision; attribution from body req.action_by (mod.rs:1852 entry). |
|
audit-attribution (body-supplied action_by UUID) |
attribution-resolution |
POST /v1/renewals/snap/redeterminations/{id}/action |
Same shape as nudge action: body action_by persisted at pr_pipeline.rs:785-795 (UPDATE … action_by WHERE action_taken IS NULL). Unverified. |
|
require_service_caller |
pure-role-gate |
POST /v1/renewals/scheduler/run (run_scheduler_pass) |
#1109 operator/journey trigger; machine action under advisory lock, gated clock, no caller-supplied date. No human attribution expected. |
|
require_service_caller |
pure-role-gate |
POST /v1/renewals/caseload-rollup/refresh (run_caseload_rollup_refresh) |
#1218 R4 trigger; same machine-trigger shape as scheduler/run. |
|
ApiServer::router protected mount (shared canopy-api auth middleware) |
other |
all /v1/renewals/* routes |
All api::routes() mount as protected under /v1 via canopy_api::ApiServer::router — bearer validation + X-Canopy-Actor parsing live in the shared canopy-api/canopy-auth layer, not in this service. No service-local middleware or role construct besides the 27 require_service_caller calls; canopy-auth’s require_service_caller (crates/canopy-auth/src/claims.rs:253-259) checks is_service() only and never consults actor(). |
|
audit-attribution (PIPELINE_ACTOR constant) |
audit-only |
scheduler drain → enrollment schedule_action (nonfiler termination) |
Machine actor constant 'canopy-renewals periodic-report pipeline' (defined pr_pipeline.rs:63) stamped as ExemptionClaim.actor (line 271) and request actor (line 284) on scheduler-initiated adverse actions. Correct for machine actions, but enrollment cannot distinguish it from a spoofed body string. |
|
audit-attribution (PIPELINE_ACTOR constant) |
audit-only |
scheduler drain → enrollment schedule_action (VCL-failure termination) |
Same constant at lines 402 (ExemptionClaim.actor) and 413 (request actor) on the failure_to_provide_verification arm. |
|
guarded attribution UPDATE (WHERE action_taken IS NULL) |
audit-only |
store::action_recert_nudge |
Store-level guard is idempotency/first-decision-wins (doc at store.rs:1017-1020: 'retry can’t overwrite the first decision or its actor'), NOT authorization. No authz predicates exist anywhere in store.rs. |
canopy-reporting (28 branches)
| Site | Construct | Classification | Route | Notes |
|---|---|---|---|---|
|
claims.require_supervisor_or_above() |
pure-role-gate |
GET /v1/reporting/overpayments |
Service bearers fail (no supervisor role); actor never consulted. Same for every gate below. |
|
claims.require_supervisor_or_above() (+ &claims.sub as requested_by at 377) |
pure-role-gate |
POST /v1/reporting/snap/fns-388 |
Attribution: enqueue_run persists claims.sub as report_runs.requested_by. |
|
claims.require_supervisor_or_above() |
pure-role-gate |
GET /v1/reporting/snap/fns-388 |
|
|
claims.require_supervisor_or_above() |
pure-role-gate |
GET /v1/reporting/snap/fns-388/{month} |
|
|
claims.require_supervisor_or_above() (+ &claims.sub at 461) |
pure-role-gate |
POST /v1/reporting/snap/qc-universe |
|
|
claims.require_supervisor_or_above() |
pure-role-gate |
GET /v1/reporting/snap/qc-universe/{date} |
|
|
claims.require_supervisor_or_above() |
pure-role-gate |
GET /v1/reporting/snap/qc-universe/{date}/csv |
|
|
claims.require_supervisor_or_above() (+ &claims.sub at 673) |
pure-role-gate |
POST /v1/reporting/tanf/acf-199 |
|
|
claims.require_supervisor_or_above() |
pure-role-gate |
GET /v1/reporting/tanf/acf-199 |
|
|
claims.require_supervisor_or_above() |
pure-role-gate |
GET /v1/reporting/tanf/acf-196 |
|
|
claims.require_supervisor_or_above() |
pure-role-gate |
GET /v1/reporting/tanf/wpr |
|
|
claims.require_supervisor_or_above() |
pure-role-gate |
GET /v1/reporting/tanf/acf-199/csv |
|
|
claims.require_supervisor_or_above() |
pure-role-gate |
POST /v1/reporting/tanf/acf-196 |
|
|
claims.require_supervisor_or_above() |
pure-role-gate |
POST /v1/reporting/tanf/wpr |
|
|
claims.require_supervisor_or_above() (+ &claims.sub at 945) |
pure-role-gate |
POST /v1/reporting/medicaid/tmsis |
|
|
claims.require_supervisor_or_above() |
pure-role-gate |
GET /v1/reporting/medicaid/tmsis |
Decrypts sealed T-MSIS attributes under the KEK for the response — PHI read gate. |
|
claims.require_supervisor_or_above() |
pure-role-gate |
GET /v1/reporting/medicaid/cms-64 |
|
|
claims.require_supervisor_or_above() |
pure-role-gate |
GET /v1/reporting/medicaid/tmsis/csv |
Streams the full decrypted federal file — highest-sensitivity read behind this gate. |
|
claims.require_supervisor_or_above() |
pure-role-gate |
POST /v1/reporting/medicaid/cms-64 |
|
|
claims.require_supervisor_or_above() (+ &claims.sub at 1147) |
pure-role-gate |
POST /v1/reporting/medicaid/cms-416 |
|
|
claims.require_supervisor_or_above() |
pure-role-gate |
GET /v1/reporting/medicaid/cms-416 |
|
|
claims.require_service_or_caseworker_or_above() |
no-actor-passes |
GET /v1/reporting/overpayments/summary |
Deliberately service-open for BFF panels (comment 1269-1273: canopy-web hits it with a service-class token, no actor); direct caseworker traffic also passes. |
|
authorize_runs_read: if claims.is_service() { Ok } else require_supervisor_or_above() |
no-actor-passes |
GET /v1/reporting/runs + GET /v1/reporting/runs/{id} |
D7 explicit OR: ANY service principal with no actor passes (test matrix at 187-208 pins it, incl. service:canopy-web+caseworker). Runs are deliberately ORG-VISIBLE (module doc 14-17) — no requester scoping. |
|
authorize_runs_read(&claims) |
no-actor-passes |
GET /v1/reporting/runs/{id} |
|
|
authorize_runs_read(&claims) |
no-actor-passes |
GET /v1/reporting/runs |
|
|
enqueue_run(requested_by: &str) → report_runs.requested_by |
attribution-resolution |
the five generate POSTs |
String-param seam; handlers always pass claims.sub, and the generate POSTs are supervisor-gated, so requested_by is always a human sub today. |
|
require_least_privilege_role(pool, allow_broad) — DB session-role boot guard |
other |
process boot (main.rs:87), not a route |
AUTHZ OUTSIDE HTTP CLAIMS (#1456/ADR-004 A8b): probes pg_roles for privileged bits + pg_has_role(current_user,'canopy_reporting_app','member') (87-97); evaluate() at 61-75: env==development auto-allows, compliant app role passes, allow_broad_db_role=true proceeds with loud WARN (OverrideAllowed), else refuses to boot. Override knob config.rs:75 (default false, config.rs:141). Grant matrix: migrations/20261111000000_least_privilege_roles.sql (REVOKE PUBLIC + enumerated per-table grants to canopy_reporting_app); janitor reap is SECURITY DEFINER granted only to canopy_reporting_app (migrations/20261111000001_janitor_security_definer.sql:99-103). |
|
load_dek_only (readers never mint) + AAD natural-key binding tmsis_row_ctx (271-286) |
other |
T-MSIS sealed reads/writes (store level) |
Crypto-enforced integrity guard, not caller authz: binding person/enrollment/month into AAD means a DB actor with UPDATE cannot re-attribute a sealed payload by rewriting plaintext key columns (comment 264-270). Complements the restricted DB role. |
canopy-security (27 branches)
| Site | Construct | Classification | Route | Notes |
|---|---|---|---|---|
|
require_admin_or_quality_control |
pure-role-gate |
GET /v1/export/audit-events |
export_audit_events — human-only (admin or quality_control worker roles; service tokens lack these). Bulk FOIA/QC export, capped at 50k rows. |
|
audit payload "actor": claims.sub (self-audit publish, 122-152) |
audit-only |
GET /v1/export/audit-events |
Every export publishes audit.export.requested attributed to claims.sub (guaranteed human by :94). Publish failure is WARN-and-continue — documented best-effort, not a gate. |
|
if !claims.is_service() { claims.require_admin()? } (gate at 175-176) |
no-actor-passes |
GET /v1/security/events |
list_events dual gate: a no-actor service bearer bypasses the admin check entirely. canopy-web reads the audit log on behalf of workers with its service token — per-worker identity is never enforced or recorded at this boundary. This exact dual-gate shape repeats 14x in this file. |
|
is_service() bypass / require_admin fallback (224-225) |
no-actor-passes |
GET /v1/security/persons/{person_id}/fact-history/{resource} |
fact_change_history — per-person PII change history readable by any service bearer, no actor. |
|
is_service() bypass / require_admin fallback (255-256) |
no-actor-passes |
GET /v1/security/events/{id} |
get_event. |
|
require_service_caller |
no-actor-passes |
POST /v1/security/audit/ingest |
ingest_audit_event — PORTAL TARGET (portal lookup.rs:282, bare service bearer, fire-and-forget). Attribution (user_id, user_role, ip_address, household_id) is CALLER-SUPPLIED BODY DATA lifted into the hash chain (lines 338-341), never derived from claims/actor. A no-actor bearer both passes AND names arbitrary users in the tamper-evident audit chain. |
|
require_service_caller |
pure-role-gate |
POST /v1/security/signing-keys |
register_signing_key — boot-time machine registration; first gate of a three-stage check. |
|
claims.service_id().ok_or(ApiError::Forbidden) |
other |
POST /v1/security/signing-keys |
Fail-closed service-identity resolution: a service bearer with no resolvable service:<id> role (azp fallback also empty) is refused. The resolved id feeds BOTH the program allowlist and row attribution (#1261 — same identity for gate and record). Note service_id() falls back to azp (claims.rs:246), a string-typed identity. |
|
require_service_caller |
pure-role-gate |
GET /v1/security/signing-keys/{program}/jwks |
signing_key_jwks — public-key-only read; doc notes public exposure for external verifiers is a follow-up. |
|
is_service() bypass / require_admin fallback (468-469) |
no-actor-passes |
GET /v1/security/alerts |
list_alerts. |
|
is_service() bypass / require_admin fallback (496-497) |
no-actor-passes |
GET /v1/security/alerts/{id} |
get_alert. |
|
is_service() bypass / require_admin fallback (524-525) |
no-actor-passes |
PATCH /v1/security/alerts/{id} |
update_alert — a WRITE (breach-alert triage state) reachable by any no-actor service bearer. |
|
req.resolved_by passed to update_alert_status |
audit-only |
PATCH /v1/security/alerts/{id} |
Alert-resolution attribution is CALLER-SUPPLIED body text, not claims-derived — a migration slice should move this to actor()/sub-derived attribution. |
|
is_service() bypass / require_admin fallback (552-553) |
no-actor-passes |
GET /v1/security/nist-controls |
list_nist_controls — static reference data, low sensitivity, same dual gate. |
|
is_service() bypass / require_admin fallback (585-586) |
no-actor-passes |
GET /v1/security/summary |
get_summary. |
|
is_service() bypass / require_admin fallback (684-685) |
no-actor-passes |
GET /v1/security/chain/status |
chain_status. |
|
is_service() bypass / require_admin fallback (795-796) |
no-actor-passes |
POST /v1/security/chain/verify |
chain_verify_enqueue — a no-actor service bearer can enqueue verification work (bounded by queue cap + in-flight dedupe). |
|
requested_by = if claims.is_service() { service_id() } else { format!("admin:{}", claims.sub) } (848-855) |
attribution-resolution |
POST /v1/security/chain/verify |
Decides WHO to record, never allow/deny. Comment documents why bare service_id() would misfile admins (worker tokens carry the BFF client id in azp). String-typed attribution ("admin:{sub}" / service id) in requested_by. |
|
is_service() bypass / require_admin fallback (931-932) |
no-actor-passes |
GET /v1/security/chain/verify-jobs/{id} |
chain_verify_job entry gate. |
|
if claims.is_service() && job.requested_by != claims.service_id().unwrap_or("") → 404 |
ownership-gate |
GET /v1/security/chain/verify-jobs/{id} |
Requester scoping: service callers see only their own jobs (indistinguishable 404 for foreign); admin sees all; a service token with no resolvable id matches nothing (requested_by non-empty by CHECK) — fail closed. String comparison against the string requested_by column. |
|
is_service() bypass / require_admin fallback (991-992) |
no-actor-passes |
GET /v1/security/chain/attest |
chain_attest — citation-PDF attestation input; canopy-web calls with service token. |
|
is_service() bypass / require_admin fallback (1177-1178) |
no-actor-passes |
GET /v1/security/archive |
list_archived. |
|
require_admin (no is_service() arm — deliberate) |
pure-role-gate |
POST /v1/security/archive |
run_archive is ADMIN-ONLY by #1208 decision 10: service tokens are REJECTED because requested_by must name a person; doc comment (1219-1223) explicitly warns not to add the is_service() arm. The one route in security where a service bearer is refused a decision the admin can take. |
|
requested_by = format!("admin:{}", claims.sub) |
attribution-resolution |
POST /v1/security/archive |
Accountability attribution from the bearer’s sub (guaranteed a human by the :1231 gate). String-typed. |
|
is_service() bypass / require_admin fallback (1290-1291) |
no-actor-passes |
GET /v1/security/archive-runs/{id} |
get_archive_run — reads stay dual (decision 10 restricts only the POST). No requester scoping here, unlike verify-jobs :944. |
|
service_may_sign_for(service_id, program) → Forbidden (allowlist at :35-44) |
ownership-gate |
POST /v1/security/signing-keys |
Program-binding allowlist (#1259): a service may register keys ONLY for its own program(s); closes the cross-program determination-forgery path. Pure function; kid derived server-side (:87-92), caller-supplied kid validated-not-trusted. |
|
bind(registrant_service_id) into INSERT … ON CONFLICT DO NOTHING |
audit-only |
POST /v1/security/signing-keys |
Store-level attribution: registrant_service_id is the authenticated caller and the conflict-free insert means a later caller can never overwrite it (doc lines 16-18). Attribution is the authenticated service id — never a human actor. |
canopy-snap (38 branches)
| Site | Construct | Classification | Route | Notes |
|---|---|---|---|---|
|
require_service_or_caseworker_or_above |
no-actor-passes |
POST /v1/abawd/activity |
Write path (records activity, updates tracking status, stages abawd.warning/time_limit_reached events) — no attribution captured anywhere in the row or events. |
|
require_service_or_caseworker_or_above |
no-actor-passes |
GET /v1/abawd/tracking |
Interactive per-person/household read; caseworker-reachable by design (#1249 contrast with batchGet). |
|
require_service_caller |
pure-role-gate |
POST /v1/abawd/tracking:batchGet |
§B4 bulk read is service-tier only (#1203/#1249 least-privilege): caseworkers deliberately excluded from the batch surface. |
|
require_service_or_caseworker_or_above |
no-actor-passes |
GET /v1/abawd/tracking/{id}/activities |
Dual read. |
|
require_service_or_caseworker_or_above |
no-actor-passes |
POST /v1/categorical-eligibility/participations |
Write, no attribution captured. |
|
require_service_or_caseworker_or_above |
no-actor-passes |
GET /v1/categorical-eligibility/participations |
Dual read. |
|
require_service_or_caseworker_or_above |
no-actor-passes |
POST /v1/student-status |
Write, no attribution captured. |
|
require_service_or_caseworker_or_above |
no-actor-passes |
GET /v1/student-status |
Dual read. |
|
require_service_caller |
pure-role-gate |
POST /v1/determine |
ADR-019 hard cutover (#439): service-only; orchestrator (canopy-eligibility) is the production caller. No actor consideration; bare service bearer passes by design. |
|
require_service_caller |
pure-role-gate |
POST /v1/determine/dry-run |
Service-only like /determine; write-free but replays sealed-determination policy so stays service-gated. |
|
require_service_or_caseworker_or_above |
no-actor-passes |
GET /v1/determinations/{id} |
is_service() arm short-circuits: bare service bearer (no actor) reads a worker-facing surface; no audit of absence. |
|
require_service_or_caseworker_or_above |
no-actor-passes |
GET /v1/determinations |
Same dual pattern; list read. |
|
require_service_caller().or_else(require_admin_or_quality_control) (chained, line 309-310) |
pure-role-gate |
GET /v1/determinations/{id}/snapshot |
Least-privilege composite (ADR-028 §57): services OR admin/QC humans; explicitly NOT general caseworkers. FTI-adjacent frozen snapshot passes to any bare service bearer via the first arm. |
|
require_data_steward (dedicated role) |
pure-role-gate |
POST /v1/determinations/{id}/redact |
Irreversible DEK shred (ADR-036 Decision M); admins do NOT auto-hold data_steward (mirrors fti_auditor separation). Effectively user-only — service tokens don’t carry the role. |
|
audit-attribution (claims.sub into determination.redacted event) |
audit-only |
POST /v1/determinations/{id}/redact |
Records the bearer’s sub directly (not actor-aware); safe today because the gate forces the bearer to BE the data_steward human. Event staged atomically with the shred (ADR-018). |
|
claims.service_id() != Some("canopy-eligibility") → 403 on non-fallback as_of |
other |
POST /v1/determine |
Service-identity pin (#1467 C12): time-travel as_of is orchestrator-only; any other service identity (or worker token, service_id falls back to azp) is refused. Allow/deny keyed on service identity, not roles or actor. |
|
claims.service_id() != Some("canopy-eligibility") → 403 on trigger assertion |
other |
POST /v1/determine |
Same identity pin for cause-class (trigger) assertions (#1213 D-6). Both branches warn-log the rejected caller’s service_id. |
|
require_admin_or_quality_control |
pure-role-gate |
GET /v1/export/determinations |
Human-role gate (admin/QC — FNS-QC sampling); is_service() is NOT consulted, so effectively user-only. Bulk-extract sensitive operation. |
|
audit-attribution: "actor": claims.sub in snap.export.requested payload |
audit-only |
GET /v1/export/determinations |
Direct sub capture (fine — gate forces human bearer). BUT the audit staging (lines 131-145) is best-effort: publish/commit failure only warn-logs and the export still ships — the export-audit chain can silently lose entries. |
|
require_service_caller |
no-actor-passes |
GET /v1/determinations/{id}/hearing-view |
Appeals reads 'on a worker’s behalf' (module doc) but no actor is required or recorded. Deliberately FTI-safe projection (ADR-028 §70/Amendment 4) — identities only, never sealed leaf values. |
|
require_service_or_caseworker_or_above |
no-actor-passes |
GET /v1/overpayments |
Keyset page consumed by canopy-reporting’s roll-up (service arm is the production path). |
|
require_service_or_caseworker_or_above |
no-actor-passes |
POST /v1/overpayments |
Files a money claim on a bare service bearer; the only attribution is body-supplied CreateClaimRequest.discovered_by (nullable). |
|
require_service_or_caseworker_or_above |
no-actor-passes |
GET /v1/overpayments/{id} |
Dual read. |
|
require_service_or_caseworker_or_above |
no-actor-passes |
POST /v1/overpayments/{id}/repayment-plans |
Money-adjacent write, no attribution column on repayment_plans. |
|
require_service_or_caseworker_or_above |
no-actor-passes |
POST /v1/overpayments/{id}/recoupments |
Ledger append on bare service bearer; recoupment_ledger actor not set from claims. |
|
require_service_or_caseworker_or_above |
no-actor-passes |
GET /v1/overpayments/{id}/ledger |
Dual read. |
|
require_service_or_caseworker_or_above |
no-actor-passes |
GET /v1/params |
THE portal-called route: canopy-portal’s /apply/snap-params proxy hits it with the portal’s own service token, no actor — passes via the is_service() arm. Non-PII policy parameters only. |
|
require_service_caller |
pure-role-gate |
GET /v1/params/provenance |
Dispatch-side service-to-service discovery read (#1467 C10). |
|
require_service_caller |
no-actor-passes |
POST /v1/determinations/{id}/overpayment-recompute |
Comment says 'Worker-actioned, mediated by the BFF/CLI as a service caller' — a money decision (files #382 claims) passes on a bare service bearer; worker identity is never verifiable here because snap has no ActorVerifier. |
|
audit-attribution: caller_uuid — claims.actor().map_or(claims.sub, |a| a.sub) (actor() at line 238) |
attribution-resolution |
POST /v1/determinations/{id}/overpayment-recompute |
Decides only WHO to record as requested_by. Since actor() is always None in snap (no verifier), it ALWAYS records the service subject; a non-UUID sub warn-logs and records Uuid::nil(). Doc-comment 'Audit-only — require_service_caller is the gate' is accurate. |
|
require_service_or_caseworker_or_above |
no-actor-passes |
GET /v1/tsnap/{id} |
Dual read. |
|
require_service_or_caseworker_or_above |
no-actor-passes |
GET /v1/tsnap |
Dual read by household_id. |
|
require_service_or_caseworker_or_above |
no-actor-passes |
GET /v1/verification/discrepancies |
Dual read; exactly-one-of application_id/household_id filter (422 otherwise). |
|
require_service_or_caseworker_or_above |
no-actor-passes |
GET /v1/verification/ievs-matches |
Dual read. |
|
require_service_or_caseworker_or_above |
no-actor-passes |
PUT /v1/verification/discrepancies/{id}/resolve |
Write passes on bare service bearer; attribution (resolved_by_sub) is taken from the REQUEST BODY (lines 149-150), not from verified claims — see flags. |
|
audit-attribution: body-supplied req.resolved_by / req.resolved_by_sub / resolved_fact_id threaded to store (store/verification.rs:238-239) and the ievs.discrepancy_resolved event |
attribution-resolution |
PUT /v1/verification/discrepancies/{id}/resolve |
Attribution decided by the caller’s body, unverifiable server-side. contracts doc calls resolved_by_sub 'the real attribution' (T1-9 #677). Any passing bearer can write arbitrary worker attribution. |
|
audit-attribution: discovered_by: Some(base.requested_by) on the #382 claim (requested_by also persisted on every recompute audit row, lines 65/170; INSERT store/recomputes.rs:169-189) |
audit-only |
POST /v1/determinations/{id}/overpayment-recompute |
Carries caller_uuid’s resolution into durable rows. Today always the service subject or Uuid::nil() — never a worker — because actor() cannot resolve in snap. |
|
string-param actor: void_claim(actor: Option<&str>) bound into the compensating void adjustment (append_adjustment binds body req.actor at line 490) |
audit-only |
MQ subscriber appeal.overpayment_assessment_voided (no HTTP route in snap) |
Production caller main.rs:295-299 passes the literal string "appeal.overpayment_assessment_voided" as the actor. Free-string attribution, not a verified principal. append_adjustment has no HTTP caller in snap (tests only). |
canopy-tanf (38 branches)
| Site | Construct | Classification | Route | Notes |
|---|---|---|---|---|
|
require_service_caller |
no-actor-passes-with-audit |
POST /v1/verification/discrepancies/{id}/resolve |
#448: called by canopy-web’s #392 BFF action, which mints X-Canopy-Actor (#961). A no-actor service bearer still passes this user-facing resolve action; the absence is recorded only as resolved_by='system' on the tanf_discrepancies domain row (line 84), not a dedicated audit table. |
|
claims.actor().map(|a| a.sub.as_str()).unwrap_or("system") |
attribution-resolution |
POST /v1/verification/discrepancies/{id}/resolve |
Decides WHO is written to tanf_discrepancies.resolved_by (TEXT). No-actor → literal 'system' sentinel persisted. The plan-named attribution site. |
|
require_fti_auditor |
pure-role-gate |
GET /v1/fti-audit-log |
Dedicated fti_auditor role (Pub 1075 §9, #383); admin does NOT hold it. Actor never consulted. |
|
require_fti_auditor |
pure-role-gate |
GET /v1/fti-audit-log/{id} |
Same dedicated-role gate. |
|
require_fti_auditor |
pure-role-gate |
GET /v1/fti-audit-log/summary |
Same dedicated-role gate. |
|
require_service_or_caseworker_or_above |
no-actor-passes |
POST /v1/grg/payments |
Dual gate on a payment-recording write; no actor attribution captured on the payment row. |
|
require_service_or_caseworker_or_above |
no-actor-passes |
GET /v1/grg/payments/{person_id} |
Dual gate; unscoped person_id read. |
|
require_service_caller |
no-actor-passes-with-audit |
POST /v1/determine |
ADR-019 hard cutover (#439): service-only. A service bearer with no actor passes; the same-tx FTI audit-chain entry (determine.rs:901-923) records accessed_by via the line-75 fallback, so the access is always audited (attributed to the service’s own sub when no actor). |
|
claims.actor().map_or(claims.sub.as_str(), |a| a.sub.as_str()) |
attribution-resolution |
POST /v1/determine |
Lines 75-78. ADR-028 §52 / ADR-014: decides WHO the FTI audit-chain entry records — prefers the on-behalf-of worker actor, falls back to the service caller’s own sub. Threaded as string param |
|
require_service_caller (month-param arm) |
no-actor-passes |
GET /v1/determinations |
#1249 least privilege: the |
|
require_service_or_caseworker_or_above (unscoped arm) |
no-actor-passes |
GET /v1/determinations |
Dual gate: is_service() short-circuits before the role check (claims.rs:266-274), so a no-actor service bearer passes this caseworker-tier read unaudited. |
|
require_service_or_caseworker_or_above |
no-actor-passes |
GET /v1/determinations/{id} |
Dual gate; no ownership scoping on the determination id. |
|
require_data_steward |
pure-role-gate |
POST /v1/determinations/{id}/redact |
Dedicated data_steward role (ADR-036 Decision M); admins do NOT auto-hold it (claims.rs:214-216). Actor never consulted — the bearer itself must carry the role, so this is a human-bearer surface in practice. |
|
audit-attribution (Some(claims.sub.as_str()) into determination.redacted event) |
attribution-resolution |
POST /v1/determinations/{id}/redact |
Records claims.sub directly into the tamper-evident redaction event — never consults actor() (consistent, since data_steward is on the bearer). String-typed Option<&str> param on events::publish_determination_redacted. |
|
require_service_or_caseworker_or_above |
no-actor-passes |
GET /v1/overpayments |
Dual gate; canopy-reporting drains this page-looped (service caller). |
|
require_service_or_caseworker_or_above |
no-actor-passes |
POST /v1/overpayments |
Dual gate on claim creation; CreateClaimRequest.discovered_by is a body field, not a verified claim. |
|
require_service_or_caseworker_or_above |
no-actor-passes |
GET /v1/overpayments/{id} |
Dual gate. |
|
require_service_or_caseworker_or_above |
no-actor-passes |
POST /v1/overpayments/{id}/repayment-plans |
Dual gate on a write; no attribution. |
|
require_service_or_caseworker_or_above |
no-actor-passes |
POST /v1/overpayments/{id}/recoupments |
Dual gate on a money-ledger write; no attribution. |
|
require_service_or_caseworker_or_above |
no-actor-passes |
GET /v1/overpayments/{id}/ledger |
Dual gate. |
|
require_service_or_caseworker_or_above |
no-actor-passes |
GET /v1/personal-responsibilities/{application_id} |
Dual gate. |
|
require_service_or_caseworker_or_above |
no-actor-passes |
POST /v1/personal-responsibilities/{application_id} |
Dual gate on a write; no attribution. |
|
require_service_or_caseworker_or_above |
no-actor-passes |
PUT /v1/personal-responsibilities/status/{id} |
Dual gate on a compliance-status write (sanction-relevant); no attribution of who set the status. |
|
require_service_or_caseworker_or_above |
no-actor-passes |
GET /v1/work-requirements/{person_id} |
Dual gate. NOTE: this GET is get-or-CREATE (writes a row) — a no-actor service bearer can create tanf_work_requirements rows unattributed. |
|
require_service_caller |
no-actor-passes |
POST /v1/work-requirements:batchGet |
§B4 bulk read, service-tier only (#1203/#1249). Read-only; no audit row. |
|
require_service_or_caseworker_or_above |
no-actor-passes |
POST /v1/work-requirements/{person_id}/activities |
Dual gate on a write (log_activity). The staged work_requirement_updated event carries no actor attribution. |
|
require_service_or_caseworker_or_above |
no-actor-passes |
POST /v1/work-requirements/evaluate |
Dual gate; thin forwarder to canopy-rules using tanf’s OWN service token outbound (ADR-019), never the inbound bearer. |
|
require_service_or_caseworker_or_above |
no-actor-passes |
GET /v1/work-requirements/{person_id}/activities |
Dual gate; unscoped person_id read. |
|
require_service_or_caseworker_or_above |
no-actor-passes |
GET /v1/work-requirements/{person_id}/activities/summary |
Dual gate; ACF-199 WPR input read. |
|
require_service_caller |
no-actor-passes |
POST /v1/work-requirements/activities/summary:batchGet |
§B4 bulk read, service-tier only (#1252). Read-only. |
|
require_service_or_caseworker_or_above |
no-actor-passes |
GET /v1/time-limits/{person_id} |
Dual gate. Also get-or-CREATE (writes a tanf_time_limits row on first read). |
|
require_service_caller |
no-actor-passes |
POST /v1/time-limits:batchGet |
§B4 bulk read, service-tier only (#1203). Read-only. |
|
require_service_or_caseworker_or_above |
no-actor-passes |
GET /v1/determinations/{id}/explanation |
Dual gate. |
|
require_service_or_caseworker_or_above |
no-actor-passes |
GET /v1/tanf/sanctions/rollup |
Dual gate; jurisdiction-wide aggregate (supervisor dashboard #496) yet reachable at plain caseworker tier. |
|
audit-attribution (FtiAuditEntry.accessed_by = accessed_by param) |
audit-only |
POST /v1/determine |
Sink of the handlers.rs:75 resolution — the FTI audit-chain row committed in the SAME tx as the determination + snapshot (ADR-028 §52/ADR-014; lines 891-926). accessed_by arrives as &str (determine.rs:313) — string-param actor. |
|
with_actor_verifier wiring (#961; key sourcing lines 175-186) |
other |
all /v1/* on canopy-tanf |
Boot-time fail-loud: CANOPY_TANF__ACTOR_VERIFYING_KEY env or .keys/web-actor-public.pem is REQUIRED (expect at 177-180) so the discrepancy resolver identity comes from a verified claim, never a body field. kid derived from raw PEM — env PEM must be byte-identical to keygen export. |
|
fti_audited(accessed_by, …) wrapper on fti_tax_data read |
audit-only |
Mandatory Pub 1075 audit wrapper; accessed_by is a &str param (string-param actor). #[expect(dead_code)] — dormant until FTI income-verification wiring (#810), but the plumbing is live and a migration slice must convert the param type. |
|
|
fti_audited(accessed_by, …) wrapper on fti_tax_data write |
audit-only |
Same as line 26; dead_code (#810), string-param actor. |
canopy-verification (14 branches)
| Site | Construct | Classification | Route | Notes |
|---|---|---|---|---|
|
internal-api-key (X-Service-Api-Key; validate_api_key fn at ievs.rs:50) |
other |
POST /internal/v1/ievs/match |
API-KEY SURFACE. Mounted OUTSIDE the /v1 JWT middleware (main.rs:169-173 merges onto the root router), so no Claims exist at all — no actor concept possible. Shared secret CANOPY_INTERNAL_API_KEY; comparison is plain |
|
audit-attribution (persist_hits → ievs_hits rows) |
audit-only |
POST /internal/v1/ievs/match |
Best-effort ievs_hits persistence per populated record for the worker panel; records application/household/person/member_name but NO caller identity (no claims exist on this surface). DB failure logs warn and does not fail the match. |
|
require_service_or_caseworker_or_above |
no-actor-passes |
GET /v1/verifications/ievs/discrepancies |
Worker-dashboard IEVS alerts panel read (#522); service bearer with no actor passes. |
|
internal-api-key (X-Service-Api-Key; validate_api_key fn at save.rs:37) |
other |
POST /internal/v1/save/verify |
API-KEY SURFACE, same pattern as ievs.rs: outside JWT middleware, shared key, non-constant-time compare, no Claims/actor. |
|
internal-api-key (X-Service-Api-Key) |
other |
POST /internal/v1/save/additional-verification |
Second SAVE handler on the same api-key surface (handle_additional). |
|
internal-api-key (X-Service-Api-Key; validate_api_key fn at ssa.rs:36) |
other |
POST /internal/v1/ssa/solq |
API-KEY SURFACE (#384, Medicaid SOLQ under the SSA CMA). Same third copy of validate_api_key — three duplicated implementations across ievs.rs/save.rs/ssa.rs. |
|
require_service_or_caseworker_or_above |
no-actor-passes |
GET /v1/verifications |
Worker-dashboard pending panel + portal inbox feed. is_service() short-circuits before any role check; a bare service bearer (portal’s, canopy-web’s) passes with no actor and no audit of the absence. |
|
require_service_caller |
pure-role-gate |
POST /v1/verifications |
Producer create; canopy-eligibility orchestrator is the only production caller (service-to-service, not user-facing). ADR-025 household existence check follows at 121-123 (referential integrity, not authz). |
|
require_service_or_caseworker_or_above |
no-actor-passes |
POST /v1/verifications/{id}/resolve |
Worker resolves a verification. Service bearer with no actor passes; who resolved is recorded from caller-supplied body field completed_by (UUID, contracts verifications.rs:93) — never from claims/actor. |
|
require_service_or_caseworker_or_above |
no-actor-passes |
POST /v1/verifications/{id}/respond |
The applicant-portal write path: canopy-portal calls with a bare service token (portal verifications.rs:204 bearer_auth, no X-Canopy-Actor anywhere in portal src). Applicant identity arrives as body fields person_id/application_id derived from the portal session. |
|
ownership gate (verification.application_id == req.application_id) |
ownership-gate |
POST /v1/verifications/{id}/respond |
Cross-household boundary: stored application_id is authoritative; mismatch=403, unscoped=422. But the compared req.application_id is caller-supplied under a shared service bearer — the gate is only as strong as service-tier trust, since any service-token holder can read the verification and echo its application_id. |
|
require_service_or_caseworker_or_above |
no-actor-passes |
GET /v1/verifications/{id}/responses |
Worker case-detail read; service bearer with no actor passes. |
|
require_real_adapters (boot-time gate) |
other |
n/a (boot) |
NOT request authz: fail-closed boot refusal of fabricated IEVS/SAVE/SSA adapters outside development (#1265). Listed to account for the raw require_* grep noise; excluded from the authz count. |
|
api-key provisioning (canopy_secrets::require_with_dev_fallback) |
other |
all 4 /internal/v1/* routes |
The single CANOPY_INTERNAL_API_KEY feeds all three internal states (main.rs:120,142,149); dev fallback literal 'canopy-internal-dev-key'. Required outside development (expect at :87). |
canopy-wic (11 branches)
| Site | Construct | Classification | Route | Notes |
|---|---|---|---|---|
|
require_service_caller |
no-actor-passes |
POST /v1/wic/households/{household_id}/appointments |
USER-FACING mutation via canopy-web actions_wic.rs:166 (#448 BFF action) under bare service bearer. Attribution is body field scheduled_by: String (contracts appointments.rs:33) persisted verbatim into wic_appointments — a free-text, caller-supplied actor string. |
|
require_service_caller |
no-actor-passes |
GET /v1/wic/appointments/upcoming |
Worker-dashboard panel feed (#521, canopy-web upcoming_appointments.rs:53) via service bearer, no actor. Tenancy-unscoped: returns ALL households' scheduled appointments (LIMIT 50). |
|
require_service_caller |
pure-role-gate |
POST /v1/determine |
ADR-019 hard cutover: orchestrator-only, service-to-service. |
|
require_service_or_caseworker_or_above |
no-actor-passes |
GET /v1/determinations/{id} |
Dual read; service bearer passes without actor. |
|
require_data_steward |
pure-role-gate |
POST /v1/determinations/{id}/redact |
Identical twin of caps handlers.rs:203 (shared T2-6 #687 pattern). The other site missing from the plan-time count. |
|
audit-attribution (Some(claims.sub) into determination.redacted event) |
attribution-resolution |
POST /v1/determinations/{id}/redact |
claims.sub recorded directly, actor() never consulted; events.rs:81-92 takes actor_sub: Option<&str> string param. Twin of caps handlers.rs:238. |
|
require_service_or_caseworker_or_above |
no-actor-passes |
GET /v1/participants/{id} |
Dual read; service bearer passes without actor. |
|
require_service_or_caseworker_or_above |
no-actor-passes |
POST /v1/nutritional-risk-assessments |
WRITE accepting the full store model as request body: assessor_worker_id (models.rs:113) is caller-supplied, never checked against claims — a service bearer can write an assessment attributed to any worker UUID. |
|
require_service_or_caseworker_or_above |
no-actor-passes |
GET /v1/determinations?household_id=X |
Household filter is a query param, not an ownership gate. |
|
require_service_or_caseworker_or_above |
no-actor-passes |
GET /v1/nutritional-risk-assessments?person_id=X |
Person filter is a query param, not an ownership gate. |
|
require_service_or_caseworker_or_above |
no-actor-passes |
GET /v1/nutritional-risk-assessments/{id} |
Dual read; service bearer passes without actor. |
Route classification
canopy-web (BFF mutation authorization, #1516 / ADR-044)
All 66 mutating registrations carry a machine-enforced PROGRAM-scope
classification on top of the #1004 role-tier extractor — the census lives in
SCOPE_POLICY (xtask/src/cmd/route_authz.rs) and is a cargo xtask
validate gate (an unlisted mutation fails the build), so this page records
the model, not the rows:
-
RequireExtractor — the program is a compile-time fact; the handler takes
ProgramScope<Snap|Tanf|Medicaid|Caps|Wic>(sealed tags; a shadow type cannot match), which 403s before the form body is read. Covers the ~30 per-program case actions and both IEVS handlers (SNAP authoritatively — they mutate the snap service regardless of the posted label). -
RequireAuthorizedWrite — the program set is a property of the resource: the household’s participating programs (any-of, shared facts); the application row’s
programs_requested(all-of — approve, deny, request-verification, the document actions); the path program on the per-program routes (run-determination and the section/data-collection proxies, unknown slugs refused); all selected programs on file-application; any-of SNAP/TANF on ELE consent (its trigger pair). Empty, unrecognized or non-string sets fail closed (422), never a permitted write. -
NotProgramScoped(reason) — the 9 composition overrides + 2 studio wizard routes, each with its recorded reason.
Enforcement is structural, not remembered: the internal clients' write verbs
are module-private, reachable only through ScopedClients in exchange for an
AuthorizedResource proof (or the enumerated NeutralWrite — one variant:
the audit-citation render RPC), so deleting a handler’s check stops
compiling; the audit adds classification completeness, extractor presence,
authorization reach, and class-borrowing symmetry on top. Routed tampering
matrix: services/canopy-web/src/api/scope_authz_route_tests.rs.
canopy-web (BFF read authorization, #1518 / ADR-044)
All 30 protected GET registrations carry a READ_SCOPE_POLICY classification
(xtask/src/cmd/route_authz.rs) under the same gate: unlisted, stale, and
count-mismatched entries all fail cargo xtask validate, and a ScopedRead
entry must REACH a scope carrier (the query-filter/participation/proof
mechanisms) via the #1516 fixpoint engine — a name-level tripwire,
deliberately over-approximate: removing a handler’s LAST carrier un-reaches
it and fails the audit, while the classification table itself stays the
human-audited truth. The model:
-
Participation gate (case detail, its tab/fact-history fragments, the cross-program summary): the household must participate in an in-scope program — the union of
programs_requestedacross its applications, the same authority as the #1516 fact-write gate (any-of). On the FULL PAGE an empty union is an honest 404 — no household-existence oracle — and an unparseable one 422s, both rendering the shared error page; the tab and fact-history FRAGMENTS keep their banner denial shapes until #1526; a lookup failure refuses the view everywhere. -
Row gate (case search, command palette): each candidate household passes the participation check before its row is BUILT; unknown participation drops fail-closed. Case search additionally degrades the fragment so an outage cannot read as "not found"; the per-keystroke palette omits silently (no degraded-state UI by design). A person with no household participates in nothing and never renders.
-
Query-time filters (application/notices/appeals indexes, team queue, renewals legs, the dashboard hero and panels): every list fetch carries the worker’s
programs=storage slugs (or fans per-program routes over canonical slugs) — the degenerate unfiltered fetch has no call site. -
Artifact authorization (notice PDF, document bytes): the artifact is authorized by its OWNING case (the notice’s program or its household’s participation; the document’s application
programs_requested) before a byte streams — possession of the id is not access. -
Panels are linkme plugins dispatched by slug, invisible to the route walker: the per-panel matrix is the
PANEL_SCOPE_POLICYexhaustiveness test inservices/canopy-web/src/dashboard/panels/mod.rs(22 panels; per-program panels render an explicit scoped-out card, never a fetch). -
Audit surfaces (#1519): audit rows carry the authoritative
programsset upstream (canopy-security: publisher envelope assertion → routing-key derivation → curated neutral families → NULL); the audit page, CSV export, the dashboard panel, the case-detail Audit section and the Activity tab all send the worker’sprograms=query-time, and citation-by-id authorizes against the ROW’s set (neutral admits; overlap required; a no-assertion row is a 404 before attestation is even consulted). The BFF’sevent_programclassifier is retired as an authorization input. Pre-#1519 rows have no assertion and drop from every scoped worker’s view — the recorded forward-only posture (see the20261128000000migration header andsecurity-operations.adoc). -
/sse (#1520): the stream emits minimal invalidation messages (routing key + household id — never the envelope, which pre-#1520 carried every program’s determination and notice payloads to every authenticated worker); each connection filters fail-closed on the event’s program metadata (publisher assertion, else routing-key derivation, else NOT delivered — the #1519 precedence), and a mid-connection scope change terminates the stream so the browser reconnects through the full auth path. Read-denial shape unification: #1526.
Live disclosure regression: tests/e2e/specs/program-scope-reads.spec.ts
(the tanf-only worker, whose legitimate universe is the seed’s one
snap+tanf household).
canopy-appeals
Plan-time index: appeals 27 — read-verified require_* sites: 27. Exact match, all read-verified: 17 require_service_caller in services/canopy-appeals/src/api/mod.rs (lines 331, 586, 619, 672, 700, 742, 798, 991, 1371, 1560, 1743, 1837, 1902, 2002, 2208, 2272, 2304) + 10 in services/canopy-appeals/src/ipv/api.rs (lines 80, 152, 178, 216, 264, 359, 464, 506, 625, 664). Every one is require_service_caller — no other require_* variant, no role checks, no ownership gates, and zero claims.actor()/is_service()/service_id() call sites anywhere in the service source. Uniform: every registered route is service-only behind the shared bearer-auth middleware, one gate per handler as the first statement.
| Method | Path | Handler | Class | Portal target |
|---|---|---|---|---|
POST |
|
file_appeal |
service-only |
|
GET |
|
list_appeals |
service-only |
|
GET |
|
appeals_queue |
service-only |
|
GET |
|
upcoming_hearings |
service-only |
|
GET |
|
get_appeal |
service-only |
|
GET |
|
get_appeal_hearing_view |
service-only |
|
PUT |
|
schedule_hearing |
service-only |
|
PUT |
|
record_decision |
service-only |
|
PUT |
|
record_final_appeal |
service-only |
|
PUT |
|
record_final_appeal_decision |
service-only |
|
PUT |
|
withdraw_appeal |
service-only |
|
PUT |
|
confirm_withdrawal |
service-only |
|
PUT |
|
reinstate_appeal |
service-only |
|
PUT |
|
finalize_withdrawal |
service-only |
|
POST |
|
record_postponement |
service-only |
|
POST |
|
trigger_clock_check |
service-only |
|
POST |
|
trigger_reconcile |
service-only |
|
POST |
|
ipv::api::create_referral |
service-only |
|
GET |
|
ipv::api::list_cases |
service-only |
|
GET |
|
ipv::api::get_case |
service-only |
|
PUT |
|
ipv::api::schedule_adh |
service-only |
|
PUT |
|
ipv::api::send_notice |
service-only |
|
PUT |
|
ipv::api::record_decision |
service-only |
|
PUT |
|
ipv::api::record_waiver |
service-only |
|
PUT |
|
ipv::api::impose_disqualification |
service-only |
|
PUT |
|
ipv::api::withdraw_case |
service-only |
|
GET |
|
ipv::api::check_active_disqualification |
service-only |
|
GET |
|
canopy_api::livez_check (framework, mounted by ApiServer::router) |
public |
|
GET |
|
canopy_api::readyz_check (framework) |
public |
|
GET |
|
SwaggerUi (framework) |
public |
|
POST |
|
test_clock_router (compile-stripped from release builds; canopy-api/src/lib.rs:224) |
public |
-
STRING-PARAM ACTOR (the surface the prompt asked for): 8 request bodies in crates/canopy-contracts-appeals/src/appeals.rs carry
pub actor: String('The recording worker’s identity (audited on the row)') — RecordDecisionRequest (:329), RecordFinalAppealRequest (:384), RecordFinalAppealDecisionRequest (:405), WithdrawAppealRequest (:440), ConfirmWithdrawalRequest (:453), ReinstateAppealRequest (:463), FinalizeWithdrawalRequest (:472), RecordPostponementRequest (:482). Appeals persists and cross-service-forwards this unverified string. canopy-web derives it from the authenticated session (services/canopy-web/src/api/actions_snap_appeal_decision.rs:157 'actor is the AUTHENTICATED worker identity — never a form field'), but on appeals' wire ANY service bearer can assert any actor value. Migration: replace body actor with the verified X-Canopy-Actor claims chain; ScheduleHearingRequest (:263) has no actor at all and needs one added. -
STALE COMMENT services/canopy-appeals/src/clients.rs:190-191 (verified false): SnapHearingClient doc says 'Unlike [EnrollmentClient] (which forwards the worker’s bearer), this carries appeals' own ADR-019 service token'. EnrollmentClient stopped bearer-forwarding at the #1105 cutover — it now holds its own ServiceTokenSource (clients.rs:68) and mints a service identity per call (send_get, :89-102); its own doc-comment (:58-63) explicitly says 'the pre-#1105 bearer-forwarding mode died with the inline writer'. Fix the contrast clause in the appeals slice.
-
STALE COMMENT services/canopy-appeals/src/clients.rs:284 (verified false, same defect): AdverseActionsClient doc says '(like [SnapHearingClient], unlike the bearer-forwarding [EnrollmentClient])'. All three clients now carry appeals' own service identity; no bearer forwarding exists anywhere in the service.
-
NO ACTORVERIFIER WIRED — MIGRATION ORDER CONSTRAINT: canopy_api::bootstrap builds AuthLayer::new(jwks) with no with_actor_verifier (crates/canopy-api/src/bootstrap.rs:180), and appeals never adds one. The shared middleware 401-rejects any request carrying X-Canopy-Actor when no verifier is configured (crates/canopy-auth/src/middleware.rs:138-144, 'actor verifier not configured'). If the BFF starts sending actor headers to appeals before the appeals slice wires a verifier, EVERY appeals call breaks. Verifier wiring must land first or together.
-
claims.actor() is called ZERO times in canopy-appeals src — there are no attribution-resolution branches and no no-actor-passes-with-audit branches (nothing observes or records the ABSENCE of an actor; the body-string audit records whatever the caller asserted). The whole service is uniformly pattern 1.
-
6 IPV mutations have ZERO attribution of any kind (no body actor, no claims actor): schedule_adh, send_notice, record-decision, waiver, impose-disqualification, withdraw (ipv/api.rs:216, 264, 359, 464, 506, 625) — impose_disqualification is a permanent-consequence action with an anonymous audit trail. schedule_hearing (api/mod.rs:798) likewise. The slice should route these straight to verified claims actor, not replicate the body-string pattern.
-
No household/person ownership gates anywhere: any service bearer reads/mutates any appeal or IPV case (get_appeal :700, list_cases :152, etc.). Tenancy is delegated entirely to callers — consistent today (everything is worker-side via the BFF) but load-bearing if any route is ever exposed toward applicants.
-
Applicant portal does NOT call appeals: grep of services/canopy-portal/src has zero appeals paths, and appeals is not among the 8 portal-target services. All portal_target=false.
-
Background workers use appeals' own ADR-019 service identity exclusively (mandatory at boot for the assessment pipeline, main.rs:35-41) and self-label cross-service commands with STAY_ACTOR="canopy-appeals" (cb_stay.rs:35); enrollment records the authenticated service JWT sub alongside (#1093: caller-supplied identity is labeling, not authentication). This labeling convention is the model the API-layer string actor should converge to (label + verified claim, never label alone).
-
Devstack-only unauthenticated test-clock route is mounted at root by the framework when the test-clock feature is on (crates/canopy-api/src/lib.rs:219-225); compile-stripped from release builds — not an appeals-specific surface but present in dev inventories.
-
OpenAPI doc test pins 25 paths (api/mod.rs:2330-2335) — a migration slice that adds/splits routes must update that assertion.
Fifteenth and FINAL receiver of the epic &52 chain, a TERMINAL exchange target with ZERO user-only routes (the enforce flag is inert, set for fleet consistency). Deltas against the survey above:
-
THE TWO WEB-DRIVEN WORKER WRITES widened
require_service_caller→require_service_or_exchanged(CASEWORKER_OR_ABOVE_ROLES): the filing (POST /v1/appeals, api/mod.rs:334) and the decision (PUT /v1/appeals/{id}/decision, :1022). Their two BFF senders flipped onto exchanged bearers through the shared #1560 dispatch (appeals_write_clientin web actions.rs — shared by both modules, the renewals precedent); the decision action’s ownership pre-check read and the filing’s SSR pre-resolution stay service-class (FU-A). -
EVERYTHING ELSE stays service-only per FU-B / ADR-023 D4: the hearing lifecycle (schedule, final-appeal, final-appeal-decision — distinct from the WIDENED hearing decision), the four-route withdraw lifecycle, postponements, both /v1/internal triggers, and ALL TEN IPV routes. The survey’s attribution flags STAND unchanged — the 8 body-string
actorfields, the zero-attribution IPV mutations (including impose_disqualification), and ScheduleHearingRequest’s missing actor are follow-on work, not this slice (the exchanged bearer now carries the verified worker identity in-token on the two widened routes for that follow-on to consume). -
No portal callers exist; no service calls appeals inbound over HTTP (its cross-service coupling is outbound + MQ) — so the widening can strand nothing.
-
Azp allowlist:
canopy-web-exchangerONLY. -
Conformance: the F4 matrix activates
canopy-appeals(6 service-only probe-safe GET rows; appeals has no audit-on-read middleware). The two widened writes are pinned by receiver_contract_test.rs: direct 403 on both; the exchanged and service positives die at neutral pre-write failures (the filing at the 422 adverse-action binding check against the placeholder — the binding validates against enrollment BEFORE persist; the decision at the placeholder 404); rogue azp 403 on the decision path where the legit exchanger provably reaches 404.
canopy-applications
Plan-time index: 36+3+1 — read-verified require_* sites: 39. Read-verified claims-gate call sites: 35 require_service_caller + 3 require_service_or_caseworker_or_above (sections.rs:112/187/222) + 1 require_service_or_applicant_or_caseworker_or_above (api/mod.rs:397) = 39. The plan-time 36 for require_service_caller is a grep-hit count: grep matches 36 lines in src/api but one is the module doc-comment at api/assignments.rs:5 ('all gated claims.require_service_caller()'), not a call — only 35 are executable sites (assignments 4, authorized_reps 5, documents 5, documents_scan 2, recovery 3, mod.rs 16). Alternative reading: 36 = 35 + the main.rs:118 require_scanned_uploads call, which is a boot config guard, not request authz. Either way no gate exists that the plan count implies and this read missed; additionally this service has 2 local actor-gate helpers (require_supervisor_actor, verified_reviewer/verified_supervisor) and 3 attribution sites the require_* count does not cover.
| Method | Path | Handler | Class | Portal target |
|---|---|---|---|---|
POST |
|
create_application |
service-only |
|
GET |
|
list_applications |
service-only |
|
POST |
|
batch_get_applications |
service-only |
|
GET |
|
get_caseload_trend |
service-only |
|
GET |
|
get_application |
service-only |
yes |
PUT |
|
update_application |
service-only |
|
DELETE |
|
withdraw_application |
service-only |
|
POST |
|
waive_interview |
service-only |
|
POST |
|
complete_interview |
service-only |
|
POST |
|
record_determination |
service-only |
|
POST |
|
authorized_reps::create_rep |
service-only |
|
GET |
|
authorized_reps::list_reps_by_household |
service-only |
|
GET |
|
authorized_reps::get_rep |
service-only |
|
PUT |
|
authorized_reps::update_rep |
service-only |
|
DELETE |
|
authorized_reps::delete_rep |
service-only |
|
POST |
|
assignments::create_assignment (service + supervisor/admin-actor-or-NO-actor) |
service-only |
|
GET |
|
assignments::list_assignments_by_worker |
service-only |
|
DELETE |
|
assignments::delete_assignment (service + supervisor/admin-actor-or-NO-actor) |
service-only |
|
GET |
|
assignments::list_assignments_by_household |
service-only |
|
PUT |
|
sections::upsert_section (service OR caseworker-or-above) |
dual |
|
GET |
|
sections::list_sections (service OR caseworker-or-above) |
dual |
|
POST |
|
sections::complete_data_collection (service OR caseworker-or-above) |
dual |
|
POST |
|
record_ele_consent (service OR applicant OR caseworker-or-above) |
dual |
|
POST |
|
verify_credential |
service-only |
yes |
POST |
|
create_draft |
service-only |
yes |
PATCH |
|
patch_draft |
service-only |
yes |
GET |
|
get_draft |
service-only |
yes |
POST |
|
finalize_draft |
service-only |
yes |
POST |
|
reap_drafts |
service-only |
|
POST |
|
recovery::recover_initiate |
service-only |
yes |
POST |
|
recovery::recover_kill |
service-only |
yes |
GET |
|
recovery::recover_get |
service-only |
|
POST |
|
documents::upload_document |
service-only |
yes |
GET |
|
documents::list_documents |
service-only |
yes |
GET |
|
documents::get_document_content |
service-only |
|
POST |
|
documents::accept_document (service + verified worker actor REQUIRED) |
service-only |
|
POST |
|
documents::reject_document (service + verified worker actor REQUIRED) |
service-only |
|
POST |
|
documents_scan::scan_override_document (service + verified supervisor/admin actor REQUIRED) |
service-only |
|
POST |
|
documents_scan::rescan_document |
service-only |
|
GET |
|
canopy-api livez_check (crates/canopy-api/src/lib.rs:215) |
public |
|
GET |
|
canopy-api readyz_check (crates/canopy-api/src/lib.rs:216) |
public |
|
GET |
|
canopy-api SwaggerUi merge (crates/canopy-api/src/lib.rs:233) |
public |
-
no-actor-passes on MUTATING routes: services/canopy-applications/src/api/assignments.rs:36 lets a bare service token create/delete household assignments with no actor — documented as intentional for 'pure system traffic — seeding, scheduled assignment workflows' (lines 16-18). A slice requiring actors everywhere must preserve or explicitly kill this system path.
-
Inconsistent attribution patterns across the service: sections.rs editor_uuid prefers actor() and 422s on non-UUID; ele-consent (api/mod.rs:427-428) NEVER consults actor() and silently falls back to app.submitted_by on non-UUID sub — a worker attesting via BFF service token is recorded as the APPLICANT, not the worker; finalize hardcodes submitted_by_role='applicant' (finalize_saga.rs:677); create_application takes submitted_by_role as spoofable body data (api/mod.rs:956-969).
-
String-param actor subjects: actor.sub and claims.sub are Strings parsed to Uuid at 4 sites (sections.rs:36, documents_scan.rs:30, documents_scan.rs:52, api/mod.rs:428) with divergent failure semantics (422 vs silent fallback).
-
Applicant flows carry NO actor at all: ADR-026 made the portal Postgres-free with opaque (non-JWT) sessions, so no applicant actor JWT exists to verify (documents.rs module doc lines 6-16 records this as deferred defence-in-depth needing an applicant-token signer). IDOR boundary for all /v1/applicants/* and portal document routes is the portal BFF session, not this origin.
-
No household-ownership gates anywhere at this origin: any service-class bearer can read/mutate ANY application, document, draft, or rep. Tenancy = ADR-019 service-class trust + BFF scoping; store-level scoping is application_id-join only (documents/sections queries prevent cross-application ID reach, not cross-caller reach).
-
Actor verifier registry holds ONLY canopy-web’s web-actor public key (main.rs:62-78); env-supplied PEM must be byte-identical to the keygen export or every actor JWT fails closed as UnknownKid — a second actor-minting service (e.g. a future applicant-token signer) needs registry wiring here.
-
rescan_document (documents_scan.rs:162-216) revokes document acceptance service-only with no actor identity — audit trail is only the scan_requeued event’s 'manual' trigger string; contrast scan-override which demands a verified supervisor.
-
Non-claims allow/deny surfaces a migration must keep in scope: applicant credential verify (store/credentials.rs:26, timing-equalized), recovery DOB second factor + confidential/kill-lock gate (api/recovery.rs:70, store/recovery.rs:71-72), 256-bit kill-switch token possession (recover_kill), document quarantine viewability gate (documents.rs:376, data-state not principal).
-
require_service_or_caseworker_or_above is documented as TRANSITIONAL ADR-019-cutover sugar (crates/canopy-auth/src/claims.rs:261-265) — the 3 dual sections routes + dual ele-consent are the cutover-era surfaces to resolve.
-
is_service()/service_id() mechanics: role-prefix scan for 'service:' in Keycloak realm_access.roles with azp fallback (claims.rs:231-247); require_service_caller never inspects actor(), so every service-only route is implicitly no-actor-passes at the gate level.
-
No api-key surfaces in this service — all bearer JWT via canopy-api auth_middleware on /v1; public: /livez, /readyz, /swagger-ui, /api-doc/openapi.json; devstack test-clock router is compile-stripped from release builds.
-
guard.rs:62 require_scanned_uploads (called main.rs:118) matches require_* greps but is a boot-time fail-closed scanner-config guard (ADR-041 accountable-override pattern), not request authorization.
-
Portal (canopy-portal/src) calls exactly: POST verify-credential (apply.rs:327, lookup.rs:130), POST drafts (apply.rs:68), PATCH drafts/{id} (apply.rs:145), GET drafts/{id} (apply.rs:361), POST drafts/{id}/finalize (apply.rs:214), POST recover/initiate (recover.rs:96), POST recover/kill/{token} (recover.rs:158), GET applications/{id} (persona.rs, home.rs, notices.rs, documents.rs:321, verifications.rs:385), GET+POST applications/{id}/documents (documents.rs:101/258, verifications.rs:371). Portal does NOT call document content, ele-consent (ele_consent rides the finalize payload), sections, or any worker/ops route.
Fifth receiver — the fleet’s FIRST zero-swap adoption: applications has no pure human-role gates at all, so NO route Class changes and the survey table above stays exact. What the slice adds:
-
The
exchanged_gateon the whole API router (threaded throughapp::build_routerso the shared prod/test assembly carries it), plusExtension(ReceiverContract).ACCEPT_OWN_AUDIENCE=truemakes exchangedaud=canopy-applicationsbearers validate; the gate vets exact-audience/azp; the 35 service-only guards then 403 them, and the 4 dual routes' existing role bars admit well-formed exchanged workers with no handler changes.ENFORCE_USER_ONLY_ROUTESis inert for guards here (norequire_user_onlysites) — set for fleet consistency. -
The azp allowlist is
canopy-web-exchangerONLY (the persons least-privilege precedent — applications is not anEXCHANGE_TARGETSprogram). -
The wired ActorVerifier is untouched: service+actor bearers are Service-shaped and pass the gate; an exchanged bearer carrying an actor header 401s in the middleware before the gate. The three actor-gated document routes (accept/reject/scan-override) stay on the verified-actor path — their exchange migration is C1’s actor-retirement scope, where the verified_reviewer/verified_supervisor attribution must resolve across BOTH the ViaActor and Direct-exchanged arms.
-
Incidental attribution improvement (no code change): sections
editor_uuidalready prefers actor-else-sub — once the BFF sends an exchanged Direct bearer, it records the real worker instead of today’s service-sub misattribution (the flag above stands until then). -
Conformance: the F4 matrix activates
canopy-applications(3 service-only + 2 dual rows; the actor-gated document routes excluded — actor-required and mutating; ele-consent also excluded because its guard vocabulary INCLUDES the applicant role, so the harness’s wrong-role principal legitimately passes auth there — live-verified, its applicant-arm coverage belongs to P2/P3); a new receiver_contract_test.rs pins the dual exchanged-arm positive, both gate kills (rogue azp, multi-audience), and the service-only 403-for-exchanged.
canopy-persons
Plan-time index: persons 28 — read-verified require_* sites: 51. Read-verified require_* call sites in services/canopy-persons/src: 26 require_service_caller (api/mod.rs 255,483,531,572,613,642,710,834,903,986,1106,1177,1273,1334,1376,1404,1430,1624,1690,1755,1820,1873,2079,2114,2153,2328) + 11 require_finalize_caller (258,645,1109,1276,1627,1693,1758 conditional; 2080,2115,2154,2329 unconditional) + 3 require_data_steward (1928,1987,2445) + 1 require_admin_or_quality_control (export.rs:170) + 7 require_fact_ownership (1286,1337,1642,1707,1772,1824,1937) + 2 require_member_ownership (1122,1182) + 1 require_active_generation (mod.rs:314; defined store/finalize.rs:46) = 51. If the plan counted only bearer-claims-tier gates (claims.require_*: 26+3+1) that is 30 today; 30 − households:batchGet’s require_service_caller (mod.rs:986, landed with #1203/#1249) − compensate_finalize_orphan’s require_data_steward (mod.rs:2445, ADR-038 MR9) = 28, exactly the plan-time figure — the plan index almost certainly predates those two merges and excluded the helper-level ownership/finalize/generation gates. main.rs:21 require_kek is boot key-loading, not authz, and is excluded.
| Method | Path | Handler | Class | Portal target |
|---|---|---|---|---|
POST |
|
create_person |
service-only |
|
GET |
|
list_persons |
service-only |
|
GET |
|
get_person |
service-only |
yes |
PUT |
|
update_person |
service-only |
|
DELETE |
|
delete_person |
service-only |
|
POST |
|
create_household |
service-only |
|
GET |
|
get_household |
service-only |
|
GET |
|
get_household_full |
service-only |
|
POST |
|
batch_get_persons |
service-only |
|
POST |
|
batch_get_households |
service-only |
|
POST |
|
claim_household_member |
service-only |
|
DELETE |
|
close_household_member_claim |
service-only |
|
GET |
|
list_income |
service-only |
|
POST |
|
claim_income |
service-only |
|
DELETE |
|
close_income_claim |
service-only |
|
GET |
|
list_assets |
service-only |
|
POST |
|
claim_asset |
service-only |
|
GET |
|
list_expenses |
service-only |
|
POST |
|
claim_expense |
service-only |
|
GET |
|
list_addresses |
service-only |
|
POST |
|
claim_address |
service-only |
|
DELETE |
|
close_address_claim |
service-only |
|
GET |
|
export::export_persons |
user-only (mechanical, #1428) |
|
POST |
|
post_redact_fact |
user-only (mechanical, #1428) |
|
POST |
|
post_redact_ssn |
user-only (mechanical, #1428) |
|
POST |
|
register_finalize_operation |
service-only |
|
POST |
|
release_finalize_operation |
service-only |
|
POST |
|
cancel_finalize_operation |
service-only |
|
GET |
|
get_finalize_operation |
service-only |
|
POST |
|
compensate_finalize_orphan |
user-only (mechanical, #1428) |
|
GET |
|
canopy_api::livez_check (mounted by ApiServer::router, not api::routes()) |
public |
|
GET |
|
canopy_api::readyz_check (mounted by ApiServer::router, not api::routes()) |
public |
-
ZERO actor() call sites in canopy-persons: no attribution-resolution branches exist anywhere. Every Pub-1075 ssn.accessed audit attributes claims.sub raw (api/mod.rs:265,500,539,584,869,954; api/export.rs:217) and both redaction events use claims.sub (mod.rs:1946-1955, 2010-2016). A verified X-Canopy-Actor is silently ignored — migration must introduce actor().map_or(claims.sub, \|a\| a.sub) shapes at 9+ attribution sites.
-
Portal reaches persons with a bare service bearer and NO X-Canopy-Actor: canopy-portal fetch_json (services/canopy-portal/src/persona.rs:117-127) sends bearer_auth only; call sites persona.rs:110 and home.rs:209 hit GET /v1/persons/{id}. The resulting ssn.accessed audit row names the portal service sub, not the applicant — the response includes ssn_last_four so the audit DOES fire.
-
Fact-claim attribution is caller-supplied wire data: req.author (Author::Worker/Member) rides the request body and is only checked as not-System (reject_system_author, api/mod.rs:1443); it is never cross-checked against the bearer Claims or actor. A migration slice must decide whether body-author must match the verified actor.
-
All close/delete surfaces are attribution-free by design: publish_member_closed/publish_income_closed/publish_address_closed hardcode author=None citing 'the ADR-019 on-behalf-of limitation' (services/canopy-persons/src/events.rs:214, 276-278, 339) — these comments are the explicit TODO markers for the actor migration; delete_person (mod.rs:608-620) emits no event at all.
-
require_finalize_caller depends on service_id()'s role-suffix-before-azp ordering (crates/canopy-auth/src/claims.rs:241-247): standing alone it would accept ANY token whose azp is canopy-applications (e.g. a user token minted through that client). Safe today only because require_service_caller precedes every one of its 11 call sites; the coupling is documented (finalize.rs:86-88) but not enforced in the helper — keep the pairing when regenerating handlers.
-
Inconsistent audit posture: ssn.accessed is fail-closed (request fails if the audit can’t stage, mod.rs:419 doc), but persons.export.requested is warn-and-continue (export.rs:264-271) — a FOIA/portability export can succeed with no audit event.
-
The three user-only routes (export, redact-fact/ssn, compensate-finalize-orphan) gate on bearer realm roles only, with no is_service() exclusion — a service token granted admin/quality_control/data_steward realm roles would pass. User-only is intent, not mechanism.
-
Stale OpenAPI 403 descriptions: most service-only routes still say 'Caller lacks admin role' (e.g. api/mod.rs:243,472,520,560,604,631,701,822,892) though the actual gate is require_service_caller — misleading for anyone deriving the authz model from persons.json.
-
No caller-scoped tenancy anywhere: any service-class bearer can read/write ANY person/household (ownership gates require_fact_ownership/require_member_ownership are data-integrity within the path subject, not caller scoping). Household-ownership-by-actor gating (the enrollment gate_household_actor_access pattern) does not exist in persons.
-
No api-key surfaces, no string-typed actor params in handlers (attribution flows as &str claims.sub into events); auth middleware is the shared canopy-api /v1 nest (JWT for every registered route), /livez + /readyz public via canopy-api.
Fourth receiver — user-only-enforcement ONLY on a hard service-only data
service (no hop-2; the 22 data-plane guards and the applications-scoped
finalize surface stay untouched; persons mounts no admin router, so the
exchanged_gate wraps the whole API router). Deltas against the survey
above (retained as the migration baseline):
-
The four pure human-role gates became MECHANICAL user-only routes:
require_user_only(["data_steward"])on redact-fact (api/mod.rs:1931), redact-ssn (:1994), and compensate-finalize-orphan (:2458), andrequire_user_only(["admin", "quality_control"])on the FOIA/ portability bulk export (export.rs:175). This RETIRES the survey flag "user-only is intent, not mechanism": service class is now 403service_class_on_user_onlyunconditionally, and underCANOPY_PERSONS__ENFORCE_USER_ONLY_ROUTES=true(devstack: on) a legacy broad-audience worker bearer is 403aud_not_exact. -
Attribution via
EffectiveUserat the four user-only sites (both redact event actors, the export SSN-access audit + payload actor). The service-onlyaudit_ssn_accesssites deliberately stayclaims.sub— those callers are System-shaped, so the projection is byte-identical and the churn buys nothing; the survey’s "9+ site" actor migration remains open for whichever unit wires real actor propagation. -
ALLOWLIST DIVERGENCE (least privilege): persons allowlists ONLY
canopy-web-exchanger— persons is not inEXCHANGE_TARGETS, so the eligibility exchanger never legitimately mints persons-audience user tokens; allowlisting it would be surplus authority. (The realm still grants the scope to both exchangers; the per-service azp allowlist is the enforcement point.) -
Operator tooling:
cargo xtask sweep-finalize-orphans --applyand the canopy-cli redact commands exchange natively since #1501 (RFC 8693 via thecanopy-web-exchangerpair by default, overridable per deployment) — a raw worker token never reaches the user-only routes; the finalize-orphan-sweep runbook is back to a single mint. -
Conformance: the F4 matrix activates
canopy-persons— the three steward mutations probe 404-before-write; the bulk export is excluded write-unsafe (real read + outbox publish) and pinned by the new dedicated export_test.rs (its four arms closed a zero-coverage gap).
canopy-renewals
Plan-time index: 25 — read-verified require_* sites: 27. 27 require_service_caller call sites read-verified, all in services/canopy-renewals/src/api/mod.rs (one per handler; 27 handlers across 26 OpenAPI paths — SNAP_CERTIFICATIONS carries POST+GET). The +2 over the plan-time 25 are the two universe-snapshot gates at api/mod.rs:548 and api/mod.rs:583, added by #1470 (commit 1a9927bc, 'immutable SNAP universe snapshot generations') after the plan count was taken. No other require_* variants exist in the service (no require_caseworker_or_above, no require_service_or_* transitional gates).
| Method | Path | Handler | Class | Portal target |
|---|---|---|---|---|
POST |
|
create_certification |
service-only |
|
GET |
|
get_active_certification |
service-only |
|
GET |
|
get_certification |
service-only |
|
GET |
|
list_due |
service-only |
|
POST |
|
create_universe_snapshot |
service-only |
|
GET |
|
list_universe_snapshot_rows |
service-only |
|
GET |
|
list_program_due |
service-only |
|
GET |
|
list_overdue |
service-only |
|
GET |
|
get_caseload_trend |
service-only |
|
GET |
|
list_interim_contacts_due |
service-only |
|
POST |
|
record_interim_contact |
service-only |
|
POST |
|
create_change_report |
service-only |
|
GET |
|
list_recert_nudges |
service-only |
|
POST |
|
action_recert_nudge |
service-only |
|
POST |
|
record_program_interim_contact |
service-only |
|
POST |
|
create_program_change_report |
service-only |
|
GET |
|
list_periodic_reports |
service-only |
|
GET |
|
get_periodic_report |
service-only |
|
POST |
|
record_periodic_report_form |
service-only |
|
POST |
|
send_periodic_report_vcl |
service-only |
|
POST |
|
record_periodic_report_verified |
service-only |
|
POST |
|
complete_periodic_report |
service-only |
|
POST |
|
reopen_periodic_report |
service-only |
|
GET |
|
list_redeterminations |
service-only |
|
POST |
|
action_redetermination |
service-only |
|
POST |
|
run_scheduler_pass |
service-only |
|
POST |
|
run_caseload_rollup_refresh |
service-only |
-
Zero actor consumption: no claims.actor(), is_service()-beyond-the-guard, or service_id() call anywhere in services/canopy-renewals/src — every one of the 27 gates is the bare require_service_caller (is_service()-only, canopy-auth claims.rs:253-259). A no-actor service bearer passes every user-facing decision in the service.
-
String/UUID-param actors on all worker-decision writes: req.action_by (body UUID) at api/mod.rs:1207 and api/mod.rs:1852; req.actor (free string) at api/mod.rs:1610 forwarded into canopy-enrollment’s audit record. A migration slice must replace these with verified X-Canopy-Actor claims and update canopy-web to send the header.
-
canopy-web BFF calls every renewals write with plain .post (no post_with_actor) — actions.rs:87/155/261, actions_snap.rs:320, actions_tanf/medicaid/caps/wic — so no X-Canopy-Actor even arrives at renewals today; flipping renewals to no-actor-rejects requires the canopy-web sender change in the same slice.
-
Several worker writes record NO worker identity at all (not even a body field): record_interim_contact, create_change_report, program variants, all five periodic-report transition commands (form/vcl/verified/complete) — the change-report and cycle rows have no actor column populated from the request.
-
No household-ownership gates anywhere: household_id is a trusted caller-supplied query/body param on every household-scoped read/write (HouseholdQuery api/mod.rs:66-69, NudgeListQuery api/mod.rs:1139-1144, program change-report bodies). Safe only while callers are service-class; any future user-bearer opening must add ownership checks.
-
Store layer has zero authz predicates; the WHERE action_taken IS NULL guards (store.rs:1017-1036, pr_pipeline.rs:785-795) are idempotency/first-decision-wins, not authorization.
-
Machine attribution is the constant PIPELINE_ACTOR = 'canopy-renewals periodic-report pipeline' (pr_pipeline.rs:63), stamped at pr_pipeline.rs:271/284/402/413 on scheduler-driven adverse actions — enrollment-side audit cannot distinguish this constant from a caller-spoofed string.
-
Pattern is uniformly clean: one construct (require_service_caller) across all 27 handlers, no #429-era per-endpoint role enumeration and no transitional require_service_or_caseworker_or_above — a single mechanical migration surface.
-
reopen_periodic_report has a deployment-identity gate (api/mod.rs:1580-1588): Extension<Option<EnrollmentClient>> is None without ADR-019 OIDC creds and the endpoint 500s rather than half-reopening — not caller authz, but a slice touching service identity wiring must preserve it.
-
OpenAPI regression test pins 26 paths (api/mod.rs:2003-2006) — any route added/removed in a migration slice must update it.
-
canopy-renewals is NOT an applicant-portal target (not in the 8-service list) and canopy-portal/src contains zero renewals calls (only a comment at services/canopy-portal/src/home.rs:284), so portal_target=false on all routes.
-
Stale-comment risk for slices: utoipa 403 descriptions on all 27 handlers say 'caller not authorized for this operation' generically; if gates gain actor requirements the OpenAPI responses and the '#1470: service-class only' / 'Service-caller only' doc-comments (api/mod.rs:547, 789) need matching updates.
Twelfth receiver on canopy_auth::ReceiverContract, a TERMINAL
exchange target with ZERO user-only routes (the verification/
enrollment precedent — the enforce flag is inert, set for fleet
consistency). Deltas against the survey above:
-
THE SIX WEB-DRIVEN WORKER WRITES widened
require_service_caller→require_service_or_exchanged(CASEWORKER_OR_ABOVE_ROLES): certification create (mod.rs:319), snap interim-contact (:923), snap change-report (:998), program interim-contact (:1069), program change-report (:1122), nudge action (:1219). Their eleven BFF senders flipped onto exchanged bearers through the shared #1560 dispatch (renewals_write_clientin web actions.rs — ONE shared helper, not per-file, because every renewals sender targets the same audience and error surface; fail-on-denied). SSR pre-checks inside the create-certification action (cert lookup, determination resolve) stay on the service identity (FU-A). -
Everything else stays service-only per FU-B / ADR-023 D4: the machine surfaces (universe snapshots, scheduler run, rollup refresh, the five periodic-report pipeline commands, redetermination action — NONE of the machine surfaces has a web sender; test-lib journeys drive the worker-shaped ones service-class), and every SSR read.
-
The survey’s attribution flags STAND:
req.action_by/req.actorbody attribution is unchanged this slice (the exchanged bearer now carries the worker’s verified identity in-token on the widened routes, so a follow-on can derive attribution fromEffectiveUser— not done here). -
Azp allowlist:
canopy-web-exchangerONLY. -
Conformance: the F4 matrix activates
canopy-renewals(6 service-only probe-safe GET rows). The six widened writes are mutating surfaces, pinned instead by receiver_contract_test.rs: direct 403 on ALL SIX; exchanged 404 on the three placeholder-safe writes; the create’s pre-insert date-order 422 on exchanged AND service arms (the two program-parameterized writes insert directly, so their exchanged arm has no safe probe); rogue azp 403 pinned on a widened write where the legit exchanger provably reaches 404.
canopy-snap
Plan-time index: snap 7+21 — read-verified require_* sites: 31. The plan-time 7+21 matches exactly what reading found for those two constructs: 7 require_service_caller sites (determine_handler.rs:77,199,309; recompute_handler.rs:156; hearing_view_handler.rs:73; abawd_handler.rs:212; params_handler.rs:101) and 21 require_service_or_caseworker_or_above sites (verification_handler.rs:61,91,124; tsnap_handler.rs:32,58; categorical_handler.rs:37,73,96,126; overpayments_handler.rs:89,127,152,181,209,234; abawd_handler.rs:56,160,242; determine_handler.rs:244,272; params_handler.rs:47). The delta of +3 is other require_* variants the plan count did not enumerate: require_admin_or_quality_control x2 (export.rs:93 and the or_else fallback at determine_handler.rs:310) and require_data_steward x1 (determine_handler.rs:378). Not counted in the 31 (outside services/canopy-snap/src): one more require_service_caller in the shared canopy-api admin replay handler (crates/canopy-api/src/admin.rs:129) that snap mounts into its router, plus non-authorization require_* (main.rs:316 require_kek, main.rs:362 require_with_dev_fallback — secrets loading, not gates).
| Method | Path | Handler | Class | Portal target |
|---|---|---|---|---|
POST |
|
determine_handler::post_determine |
service-only |
|
POST |
|
determine_handler::post_determine_dry_run |
service-only |
|
GET |
|
determine_handler::get_determination |
dual |
|
GET |
|
determine_handler::list_determinations |
dual |
|
GET |
|
determine_handler::get_determination_snapshot |
dual |
|
GET |
|
hearing_view_handler::get_determination_hearing_view |
service-only |
|
POST |
|
recompute_handler::post_overpayment_recompute |
service-only |
|
POST |
|
determine_handler::post_redact_determination |
user-only |
|
POST |
|
categorical_handler::post_participation |
dual |
|
GET |
|
categorical_handler::list_participations |
dual |
|
POST |
|
categorical_handler::post_student_status |
dual |
|
GET |
|
categorical_handler::list_student_statuses |
dual |
|
GET |
|
verification_handler::list_discrepancies |
dual |
|
PUT |
|
verification_handler::resolve_discrepancy |
dual |
|
GET |
|
verification_handler::list_ievs_matches |
dual |
|
GET |
|
params_handler::get_params |
dual |
yes |
GET |
|
params_handler::get_params_provenance |
service-only |
|
GET |
|
tsnap_handler::get_tsnap |
dual |
|
GET |
|
tsnap_handler::list_tsnap |
dual |
|
POST |
|
abawd_handler::record_activity |
dual |
|
GET |
|
abawd_handler::list_tracking |
dual |
|
POST |
|
abawd_handler::batch_get_tracking |
service-only |
|
GET |
|
abawd_handler::list_activities |
dual |
|
GET |
|
export::export_determinations |
user-only |
|
POST |
|
overpayments_handler::post_claim |
dual |
|
GET |
|
overpayments_handler::list_claims |
dual |
|
GET |
|
overpayments_handler::get_claim |
dual |
|
POST |
|
overpayments_handler::post_repayment_plan |
dual |
|
POST |
|
overpayments_handler::post_recoupment |
dual |
|
GET |
|
overpayments_handler::get_ledger |
dual |
|
POST |
|
canopy_api::admin::admin_replay_handler (shared crate, mounted main.rs:405-411) |
service-only |
|
GET |
|
canopy_api::livez_check (ApiServer::router, outside the /v1 auth nest) |
public |
|
GET |
|
canopy_api::readyz_check (ApiServer::router, outside the /v1 auth nest) |
public |
-
NO ACTOR VERIFIER IN SNAP (governs everything): canopy_api::bootstrap builds AuthLayer::new(jwks) without with_actor_verifier (crates/canopy-api/src/bootstrap.rs:180); canopy-auth middleware then 401-rejects ANY request carrying X-Canopy-Actor ('actor verifier not configured', middleware.rs:138-147). claims.actor() is unconditionally None on every snap route. Only canopy-applications and canopy-tanf wire verifiers. Any slice introducing actor-required or actor-audited gates to snap must wire the verifier first or every BFF-forwarded actor header will hard-fail the request.
-
DEAD ADMIN SURFACE: POST /v1/admin/events/replay (shared canopy-api admin.rs:129-133, mounted by snap main.rs:405-411) requires service caller + verified actor with admin role — unsatisfiable in snap today (header→401 at middleware; no header→actor None→403). Operator event replay on canopy-snap cannot authorize until the verifier lands.
-
STALE/ASPIRATIONAL COMMENTS: recompute_handler.rs:155 ('Worker-actioned, mediated by the BFF/CLI as a service caller') and caller_uuid’s doc ('The authenticated worker behind the service call (the actor)') describe actor plumbing snap’s middleware cannot deliver; requested_by/discovered_by on overpayment_recomputes and #382 claims (recompute_persist.rs:65,145,170) always record the SERVICE subject, or Uuid::nil() + warn when the service sub is not a UUID (recompute_handler.rs:240-243).
-
BODY-SUPPLIED ATTRIBUTION (string-param actors): PUT /v1/verification/discrepancies/{id}/resolve trusts resolved_by_sub/resolved_by/resolved_fact_id from the request body (canopy-contracts-snap/src/verification.rs:116-133 — doc calls resolved_by_sub 'the real attribution'); POST /v1/overpayments trusts CreateClaimRequest.discovered_by from the body. Any bearer passing the dual gate can forge worker attribution. Migration should re-source these from verified actor claims.
-
FREE-STRING ACTOR AT STORE LAYER: store/overpayments.rs void_claim/append_adjustment take actor as Option<&str>; the appeal.overpayment_assessment_voided subscriber stamps the literal event name as the actor (main.rs:299 → overpayments.rs:552). claim_adjustments.actor / recoupment_ledger.actor are untyped strings.
-
API-KEY SURFACE (outbound): canopy-snap→canopy-verification uses CANOPY_INTERNAL_API_KEY with dev fallback 'canopy-internal-dev-key' (main.rs:362-367, verification_client) — a pre-OIDC internal-key hop; inbound snap routes are all OIDC-bearer. A later slice should move this to ADR-019 service tokens like the persons/enrollment/rules clients already are.
-
BEST-EFFORT AUDIT ON BULK EXPORT: export.rs:131-145 — if staging/committing snap.export.requested fails it only warn-logs and the bulk export still returns 200; the export audit chain can silently lose entries.
-
SERVICE-IDENTITY PINS EXIST (a fifth construct beyond role gates): determine_policy.rs:82,90 pin as_of/trigger to service_id()=='canopy-eligibility'. Note service_id() falls back to azp for non-service tokens (canopy-auth claims.rs:241-247), so the comparison is against whatever azp Keycloak sets on worker tokens — currently never 'canopy-eligibility', but the fallback makes the pin azp-sensitive.
-
PORTAL SURFACE IS EXACTLY ONE ROUTE: canopy-portal calls only GET /v1/params (snap_params.rs:69-73, via its /apply/snap-params proxy) using the portal’s own service token, no actor — it passes through the is_service() arm of the dual gate. All other snap_url references in portal src are test fixtures.
-
BROAD DUAL GATES ON MONEY WRITES: POST /v1/overpayments, /repayment-plans, /recoupments, /abawd/activity, /categorical-eligibility/participations, /student-status all pass ANY service token in the mesh (require_service_or_caseworker_or_above is_service() arm) with zero attribution captured — the widest no-actor-passes exposure in this service.
-
MQ SUBSCRIBERS WRITE WITH NO PRINCIPAL: ipv.not_established (ipv_claim.rs, discovered_by: None), appeal.overpayment_assessed (main.rs:173-239, discovered_by: None), appeal.overpayment_assessment_voided (main.rs:275-300, literal-string actor) create/void claims outside any Claims context — expected for event-driven paths but relevant if slices add row-level attribution requirements.
-
GET /v1/determinations/{id}/snapshot serves the FTI-adjacent frozen snapshot (proven facts, income, household composition, DOB/disability) to any bare service bearer via the require_service_caller arm — the in-code least-privilege note (ADR-028 §57) excludes caseworkers but not unattributed services.
-
Devstack-only test-clock routes are mounted unauthenticated at root when the test-clock feature is compiled (canopy-api lib.rs:218-225); compile-stripped from release builds — not a production surface but worth knowing when auditing devstack.
-
Store layer has NO household-ownership/tenancy gates anywhere in services/canopy-snap/src/store — all row scoping is by caller-supplied IDs after the role gate; there is no applicant-facing ownership concept in this service.
Seventh receiver on canopy_auth::ReceiverContract, a TERMINAL exchange
target (single-exact audience — no hop-2 pair; that shape is
eligibility-only). Deltas against the survey above (retained as the
migration baseline; where they conflict, this block is current):
-
POST /v1/determine (determine_handler.rs:63) moved
require_service_caller→require_service_or_exchanged— the orchestrator’s service token, or the hop-2 exchanged bearer it re-exchanges from the S-eligibility pair (EXCHANGE_TARGETSnow includes canopy-snap in the devstack). A DIRECT worker bearer stays 403 (the #439 posture, pinned by receiver_contract_test.rs). /v1/determine/dry-run deliberately staysrequire_service_caller— it is a #1213 service self-call surface with no human caller. -
The 2 effectively-user-only gates became MECHANICAL user-only routes:
require_user_only(["data_steward"])(redact, determine_handler.rs:377) andrequire_user_only(["admin", "quality_control"])(export, export.rs:96). Service class is 403service_class_on_user_onlyunconditionally; withCANOPY_SNAP__ENFORCE_USER_ONLY_ROUTES=true(devstack: on) a broad-audience worker bearer is 403aud_not_exact. -
THE SERVICE-IDENTITY PINS flag above is UPDATED: the as_of/trigger pins (determine_policy.rs) now accept BOTH orchestrator shapes via
orchestrator_caller— the exactcanopy-eligibilityservice identity ORazp == canopy-eligibility-exchanger(the hop-2 bearer only eligibility’s exchanger can mint; canopy-web’s exchanger mints a different azp, so a worker cannot fabricate the shape). Without the second arm the UTC-stampedas_ofcrossing the legal-timezone day boundary would 403 every exchanged interactive dispatch each ET evening — the underlying date seam is #1561 (this slice fixes the identity predicate, not the date convention). -
Attribution converged on
EffectiveUserat three sites: the redaction event actor, the export audit actor, and recompute_handler’scaller_uuid(the service’s ONE genuineactor().map_orshape — value-identical, since snap has no actor verifier and every admitted user-only bearer is non-service). The STALE/ASPIRATIONAL COMMENTS flag above is resolved for caller_uuid; the BODY-SUPPLIED ATTRIBUTION flag (resolved_by_sub, discovered_by) is UNCHANGED — server-side re-derivation is #874’s scope, deliberately not this slice’s. -
POST /v1/determinations/{id}/overpayment-recompute (recompute_handler.rs) moved
require_service_caller→require_service_or_exchanged: the BFF now sends the worker’s own exchanged bearer, socaller_uuidfinally records the WORKER instead of the BFF’s service subject — resolving the survey’s "worker identity is never verifiable here" flag (the sender flip without this widening would 403; caught in the slice’s J-review). Service class (CLI/ops) keeps working; a direct worker bearer stays 403. -
Every route sits behind the
exchanged_gate(main.rs, layered onapi::routes()before the admin merge); the rest of the dual mass is unchanged. Azp allowlist:canopy-web-exchanger,canopy-eligibility-exchanger(snap is an EXCHANGE_TARGETS program — the tanf/medicaid posture). -
Senders switched for THIS target: the five canopy-web worker writes (overpayment-recompute, abawd-activity, snap discrepancy-resolve, IEVS accept/reject flips) ride the shared #1560 dispatch with exchanged
aud=canopy-snapbearers (fail-on-denied, no downgrade); the IEVS accept’s persons fact write stays service-path (that target’s slice owns its sender). The orchestrator fan-out to snap exchanges when a subject exists (interactive); bulk/order dispatches stay service-class by design. -
Conformance: the F4 matrix activates
canopy-snap(9 rows: 4 probe-safe dual incl. the PORTAL-TARGET /v1/params, the redact user-only row — guard → reason check → 404 on the placeholder, before any shred — 3 service-only, and the overpayment-recompute ServiceOrExchanged row pinning the widened money route; the F4 SEED determine row also reclassifies ServiceOnly → ServiceOrExchanged, probing empty-body 400-after-auth). Floor 430 → 510. Excluded from the NEW tranche: a determine/dry-run row (a valid probe body runs a real determination) and export (stages an audit event; pinned with the cross-service pair-replay 401 by receiver_contract_test.rs).
canopy-tanf
Plan-time index: tanf 6+21 — read-verified require_* sites: 31. Plan-time 6+21=27 counts exactly the two migration-relevant families, and both reconcile perfectly: 6 require_service_caller (handlers.rs:60,227; work_requirement_handlers.rs:108,485,1011; discrepancy_handlers.rs:83) + 21 require_service_or_caseworker_or_above (handlers.rs:229,295; work_requirement_handlers.rs:54,142,227,290,397,967,1050,1117; grg_handlers.rs:43,89; personal_responsibility_handlers.rs:44,72,119; overpayments_handler.rs:89,127,152,181,209,234). The read-verified total in services/canopy-tanf/src is 31 because 4 additional require_* gates exist that the plan family-count excluded: 3 require_fti_auditor (fti_audit_handlers.rs:57,94,121) + 1 require_data_steward (handlers.rs:335). A 32nd require_service_caller sits at crates/canopy-api/src/admin.rs:129 on the shared POST /v1/admin/events/replay route that tanf’s router merges (main.rs:198-204) — outside the service tree but on this service’s live surface. main.rs:98 require_kek is key-material loading, not authorization, and is excluded.
| Method | Path | Handler | Class | Portal target |
|---|---|---|---|---|
POST |
|
handlers::post_determine |
service-or-exchanged (#1425) |
|
GET |
|
handlers::list_determinations |
dual |
|
GET |
|
handlers::get_determination |
dual |
|
POST |
|
handlers::post_redact_determination |
user-only (mechanical, #1425) |
|
GET |
|
work_requirement_handlers::get_determination_explanation |
dual |
|
POST |
|
work_requirement_handlers::evaluate_work_requirements |
dual |
|
GET |
|
work_requirement_handlers::get_work_requirements |
dual |
|
POST |
|
work_requirement_handlers::batch_get_work_requirements |
service-only |
|
POST |
|
work_requirement_handlers::log_activity |
dual |
|
GET |
|
work_requirement_handlers::list_activities |
dual |
|
GET |
|
work_requirement_handlers::activities_summary |
dual |
|
POST |
|
work_requirement_handlers::batch_activities_summary |
service-only |
|
GET |
|
work_requirement_handlers::get_time_limits |
dual |
|
POST |
|
work_requirement_handlers::batch_get_time_limits |
service-only |
|
POST |
|
grg_handlers::create_grg_payment |
dual |
|
GET |
|
grg_handlers::list_grg_payments |
dual |
|
GET |
|
personal_responsibility_handlers::list_personal_responsibilities |
dual |
|
POST |
|
personal_responsibility_handlers::create_personal_responsibility |
dual |
|
PUT |
|
personal_responsibility_handlers::update_personal_responsibility |
dual |
|
GET |
|
fti_audit_handlers::list_fti_audit |
user-only (mechanical, #1425) |
|
GET |
|
fti_audit_handlers::fti_audit_summary |
user-only (mechanical, #1425) |
|
GET |
|
fti_audit_handlers::get_fti_audit_entry |
user-only (mechanical, #1425) |
|
POST |
|
overpayments_handler::post_claim |
dual |
|
GET |
|
overpayments_handler::list_claims |
dual |
|
GET |
|
overpayments_handler::get_claim |
dual |
|
POST |
|
overpayments_handler::post_repayment_plan |
dual |
|
POST |
|
overpayments_handler::post_recoupment |
dual |
|
GET |
|
overpayments_handler::get_ledger |
dual |
|
POST |
|
discrepancy_handlers::resolve_discrepancy |
service-or-exchanged (#1425) |
|
GET |
|
work_requirement_handlers::get_sanctions_rollup |
dual |
|
POST |
|
canopy_api::admin::admin_replay_handler (shared crate, merged at main.rs:198-204) |
service-only |
|
GET |
|
canopy_api::livez_check (shared ApiServer::router) |
public |
|
GET |
|
canopy_api::readyz_check (shared ApiServer::router) |
public |
|
GET |
|
SwaggerUi (canopy-api lib.rs:233) |
public |
-
The ONLY no-actor-rejects on the tanf surface is in the SHARED crate (crates/canopy-api/src/admin.rs:130, claims.actor().ok_or(Forbidden) on POST /v1/admin/events/replay) — a slice that edits only services/canopy-tanf/src will miss it; conversely every other service mounting AdminRoutes shares this exact branch, so changing it is a fleet-wide change.
-
String-param actors throughout the attribution plumbing: determine::determine takes accessed_by: &str (determine.rs:313 → FtiAuditEntry.accessed_by at :903); store/fti.rs read_fti_tax_data/store_fti_tax_data take accessed_by: &str (dead_code until #810 FTI income wiring goes live — the migration must retype BEFORE #810 activates them); events::publish_determination_redacted takes Option<&str> (fed claims.sub at handlers.rs:370).
-
'system' sentinel: discrepancy_handlers.rs:84 persists the literal string 'system' into tanf_discrepancies.resolved_by (TEXT) when a service bearer has no actor — any later attribution migration must preserve/translate this sentinel in existing rows and decide whether a no-actor resolve should remain legal at all (canopy-web always mints an actor per #961).
-
NO ownership gates anywhere in canopy-tanf: neither handlers nor the store layer scope person_id/application_id/household_id path params to the caller — any caseworker-or-above bearer (or any no-actor service bearer via the is_service() short-circuit) can read or write ANY person’s TANF data. The store layer (store/mod.rs, store/overpayments.rs, store/batch.rs) contains zero Claims/role/ownership checks — authorization is 100% handler-level.
-
require_service_or_caseworker_or_above is documented in canopy-auth (claims.rs:261-265) as a TRANSITIONAL ADR-019-cutover guard; 21 of tanf’s 31 gates still sit on it — the F1a migration must assign each a final posture (several guard writes with no attribution: log_activity, GRG payment creation, personal-responsibility status, overpayment recoupments).
-
Read-that-writes under a dual gate: GET /v1/work-requirements/{person_id} (work_requirement_handlers.rs:56) and GET /v1/time-limits/{person_id} (:969) are get-or-CREATE — row creation reachable by an unattributed no-actor service bearer; the :batchGet siblings were added specifically as read-only (#1203) and are service-only.
-
GET /v1/determinations changes privilege tier BY QUERY PARAM (handlers.rs:226-230): month set → service-only (#1249), unscoped → dual. Route-level classifiers/middleware that assume one tier per path will misclassify this route.
-
Stale doc comment: services/canopy-tanf/src/api/overpayments_handler.rs:3 says 'SNAP overpayment claim' — this is the TANF copy (copy-paste residue).
-
Middleware posture (crates/canopy-auth/src/middleware.rs:109-147, wired fail-loud in tanf main.rs:175-194 per #961): X-Canopy-Actor on a non-service bearer → 401; invalid actor JWT → 401; header without verifier → 401; but an ABSENT actor always passes middleware — actor presence is never enforced centrally, only per-handler (and in tanf src, no handler enforces it; only shared admin.rs:130 does).
-
fti_auditor and data_steward are dedicated roles that admin does NOT auto-hold (claims.rs:198-216, separation of duties) — the 4 gates using them are pure human-role gates; by convention no service token carries them.
-
Inconsistent route registration: FTI-audit-log and overpayments routes register with literal path strings (api/mod.rs:183-214) while everything else uses canopy-contracts-tanf path constants — a path-constant-driven migration sweep will miss those 8 method-routes.
-
OpenAPI surface pin: api/mod.rs:244-252 asserts exactly 27 paths — any slice adding/removing tanf routes must bump the pin deliberately.
-
Devstack-only unauthenticated test-clock routes mount at root when the test-clock feature is on (canopy-api lib.rs:218-225); compile-stripped from release builds — not a production surface.
-
No api-key surfaces in canopy-tanf (bearer JWT only). Event-subscriber paths (main.rs overpayments inbox) run with no Claims at all — trust derives from broker topology, and provenance is stamped from event payload fields, not verified claims.
-
canopy-tanf is NOT one of the 8 applicant-portal targets; grep-confirmed canopy-portal/src never calls tanf HTTP routes (its 'tanf' hits at home.rs:399,720,758 are program-name display strings and test fixtures).
canopy-tanf is the first receiver on canopy_auth::ReceiverContract. Deltas
against the plan-time survey above (the survey text is retained as the
migration baseline; where they conflict, this block is current):
-
POST /v1/determine (handlers.rs:64) and the discrepancy resolve (discrepancy_handlers.rs:87) moved
require_service_caller→require_service_or_exchanged— an exchanged user-context token (exactaud=canopy-tanf, allowlistedazp, caseworker-or-above role) now passes alongside service callers. -
The 4 pure-role gates became MECHANICAL user-only routes:
require_user_only(["fti_auditor"])(fti_audit_handlers.rs:62/104/136) andrequire_user_only(["data_steward"])(handlers.rs:347). Service-class bearers are 403service_class_on_user_onlyunconditionally; withCANOPY_TANF__ENFORCE_USER_ONLY_ROUTES=true(devstack: on) a legacy broad-audience worker bearer is 403aud_not_exact— the by-convention "no service token carries these roles" flag above is now enforced in code. -
Every other route in the service’s API router sits behind the
exchanged_gatemiddleware (main.rs:220 — layered onapi::routes()BEFORE the admin merge, so the shared/v1/admin/events/replayroute and the public livez/readyz are outside it; outcome-equivalent for admin, whoserequire_service_callerrejects exchanged bearers anyway): an exchanged-shaped bearer failing exact-audience/azp checks is 403 uniformly, so the 21 transitionalrequire_service_or_caseworker_or_abovedual gates accept well-formed exchanged tokens with no handler changes (their final-posture assignment remains open as flagged above). -
Attribution: determine’s FTI
accessed_by(handlers.rs:84), redaction’s event actor (handlers.rs:385), and the discrepancyresolved_by(discrepancy_handlers.rs:93) now resolve viaEffectiveUser— an exchanged bearer attributes its ownsub; the'system'sentinel for unattributed service resolves is preserved. Theclaims.actor()line-cites in the flags above predate this. -
Senders: canopy-web’s two tanf BFF write actions and the orchestrator’s tanf dispatch exchange-when-configured, fail-not-fallback (ADR-043 no-silent-downgrade). Conformance: the F4 matrix activates
canopy-tanf(manifest rows cite the guard lines; exchange rows live).
canopy-enrollment
Plan-time index: enrollment 18 — read-verified require_* sites: 17. 17 require_service_caller call sites read-verified in services/canopy-enrollment/src (api/mod.rs:338,396,420,449,681,715,801,851,913; api/adverse_actions.rs:81,283,335,373,423,479; api/enact_sweep.rs:32; api/reopen.rs:75). No other require_* auth guards exist in src/ (require_role/require_caseworker/etc. are unused here). The 18th plan-time count is most plausibly the shared admin replay handler’s require_service_caller (crates/canopy-api/src/admin.rs:129), which IS mounted into this service’s router via AdminRoutes at main.rs:304-310 — counting it gives exactly 18 require_service_caller sites reachable through canopy-enrollment’s router. (main.rs:85 'require_issuance_days' matches a require_* grep but is a jurisdiction-config loader closure, not an auth gate.)
| Method | Path | Handler | Class | Portal target |
|---|---|---|---|---|
POST |
|
api::create_enrollment |
service-only |
|
GET |
|
api::list_enrollments |
service-only |
|
GET |
|
api::get_enrollment |
service-only |
|
POST |
|
api::issue_benefits |
service-only |
|
GET |
|
api::list_issuances |
service-only |
|
GET |
|
api::list_issuances_for_household |
service-only |
|
POST |
|
api::batch_get_household_issuances |
service-only |
|
GET |
|
api::get_household_annual_summary |
service-only |
yes |
POST |
|
api::terminate_enrollment (410 Gone tombstone) |
service-only |
|
POST |
|
api::adverse_actions::schedule_adverse_action |
service-only |
|
GET |
|
api::adverse_actions::list_adverse_actions |
service-only |
|
GET |
|
api::adverse_actions::get_adverse_action |
service-only |
|
POST |
|
api::adverse_actions::cancel_adverse_action |
service-only |
|
PUT |
|
api::adverse_actions::stay_adverse_action |
service-only |
|
GET |
|
api::adverse_actions::get_appeal_stay |
service-only |
|
POST |
|
api::enact_sweep::trigger_enact_sweep |
service-only |
|
POST |
|
api::reopen::reopen_adverse_action |
service-only |
|
POST |
|
canopy_api::admin::admin_replay_handler (shared, merged at main.rs:304-310) |
service-only |
|
GET |
|
canopy_api::livez_check (shared ApiServer::router) |
public |
|
GET |
|
canopy_api::readyz_check (shared ApiServer::router) |
public |
-
TAXONOMY DRIFT on the exemplar: api/mod.rs:123 (gate_household_actor_access) is cited in the plan as the no-actor-passes-with-audit exemplar, but the no-actor arm returns Ok(()) with NO audit recording the absence — audit_household_read (mod.rs:176-204) fires only when an actor IS present, and its doc-comment (mod.rs:172-175) states actor-less reads 'are not audited here'. If the program intends pattern 2 (audit the absence), that audit must be ADDED in the migration slice, not assumed present.
-
STRING-PARAM ACTORS on the wire: schedule/cancel/stay/reopen request bodies all carry a free-text 'actor' field. schedule_adverse_action (api/adverse_actions.rs:151, plus exemption.authority/actor at 142-145) persists it VERBATIM with no authenticated-principal binding; cancel (374), stay (433), and reopen (reopen.rs:78) prepend claims.sub. Inconsistent attribution pattern within one file — a migration slice replacing string actors with X-Canopy-Actor must touch the schedule path’s row-persisted actor, not just the sub-prefixed commands.
-
Attribution records the SERVICE principal, not the human: the sub+actor format strings use claims.sub of the service bearer and never consult claims.actor() — after OIDC actor propagation these sites should move to the actor().map_or(claims.sub, \|a\| a.sub) shape or they will permanently attribute BFF-originated commands to the BFF service account.
-
Whole service is service-class-only (post-#439; doc comment api/adverse_actions.rs:7-8: 'workers arrive via the canopy-web BFF') — there are no user-only or dual routes; every worker/human authorization decision in enrollment rides the X-Canopy-Actor header behind a service bearer.
-
batch_get_household_issuances (api/mod.rs:801) is DELIBERATELY outside the #408 gate/audit with a test pinning zero #408 events (doc mod.rs:769-779) — a migration adding actor requirements to household reads must preserve this actor-less reporting-pipeline path or split it.
-
The only no-actor-rejects surface reachable through this router is SHARED-CRATE code: canopy-api admin.rs:130 (POST /v1/admin/events/replay, service bearer + admin actor). Migrating its pattern changes every service that mounts AdminRoutes, not just enrollment.
-
Actor sub must parse as a UUID: gate_household_actor_access (api/mod.rs:129-135) 403s fail-closed on a non-UUID actor.sub, and audit_household_read (mod.rs:183) silently skips audit for the same shape — Keycloak-style UUID subs assumed; any future non-UUID subject format breaks worker access to household reads.
-
All #408 audit emissions are best-effort warn-and-continue (mod.rs:151-168 deny path, 185-202 allow path) — audit failure never blocks or fails the request; a slice that upgrades audit to mandatory changes error semantics.
-
No store-level or domain-level authz exists: store.rs and adverse_actions.rs (domain) contain zero claims/role checks — every guard lives in the API layer; the actor strings persisted by the domain are attribution data passed down from handlers. DB constraints (one-live-per-household, one-open-action, lifecycle_revision fences) are integrity, not authorization.
-
No service-local middleware: authn is entirely the shared canopy_api::ApiServer::router auth layer (canopy_auth::middleware::auth_middleware) nesting all routes under /v1; /livez and /readyz are the only public routes. Event-driven writes (auto-enroll, notice evidence, appeal resolutions, periodic-report tombstones — main.rs:374-480) bypass HTTP authz entirely and trust the broker.
-
terminate_enrollment (api/mod.rs:913) is a 410 Gone tombstone that still carries require_service_caller — keep the gate ordering (403 before 410) when migrating.
-
Portal usage confirmed by grep+read of services/canopy-portal/src: exactly ONE enrollment route is called — GET /v1/households/{household_id}/annual-summary (home.rs:266-268, actor-less service-token fetch for the 'Your year' recap). No other enrollment path appears in portal source.
Eleventh receiver on canopy_auth::ReceiverContract, a TERMINAL
exchange target with ZERO user-only routes (the enforce flag is inert,
set for fleet consistency). Deltas against the survey above:
-
THE #408 HOUSEHOLD GATE MIGRATED TO
EffectiveUser: the survey’s actor arms were UNREACHABLE (enrollment has no actor verifier, soclaims.actor()was always None and every read passed the no-actor arm; the middleware 401s any presented actor header). An EXCHANGED worker bearer now carries the human identity in the token itself, so the assignment check + both #408 audit arms are live for worker traffic for the first time — live-verified: an exchanged caseworker without an assignment is denied (the deny path also publishes the #408 audit event), a supervisor bypasses by design, and service/system traffic (the portal BFF) keeps the pass-through arm.audit_household_readlikewise fires on any human-carrying bearer. -
THE TWO #408-GATED HOUSEHOLD READS (issuances mod.rs:729, annual summary :868) widened
require_service_caller→require_service_or_exchanged(CASEWORKER_OR_ABOVE_ROLES)— the widening is what makes the gate reachable for worker bearers at all. The batchGet aggregate stays service-only (documented SSR fan-out). -
THE TWO WEB-DRIVEN ADVERSE-ACTION WRITES (schedule adverse_actions.rs:86, cancel :381) moved
require_service_caller→require_service_or_exchangedwith their BFF senders flipped onto exchanged bearers (#1560 dispatch; fail-on-denied). Thesub=; actor=attribution string on cancel now carries the WORKER’s own sub under an exchanged bearer; schedule’s verbatimreq.actorinconsistency stays flagged. Stay/reopen (appeals nested hops), enact-sweep, and the enrollments CRUD stay service-only (FU-B / ADR-023 D4). -
Azp allowlist:
canopy-web-exchangerONLY. -
Conformance: the F4 matrix activates
canopy-enrollment(6 rows: 4 service-only + the 2 widened household reads, whose exchanged probe rides jane.doe’s supervisor bypass). The mutating adverse-action writes and the gate semantics are pinned by receiver_contract_test.rs (direct worker 403 on both writes / exchanged 404-past-auth on both / the #408 assignment-deny + supervisor-bypass + service-pass ladder).
canopy-medicaid
Plan-time index: 4+14 — read-verified require_* sites: 24. Found 24 require_* call sites in services/canopy-medicaid/src (read-verified). The plan’s 4 matches exactly the 4 require_service_caller sites (handlers.rs:69, handlers.rs:292, cmd_handlers.rs:81, cmd_handlers.rs:118). The plan’s 14 matches the non-service-caller gates in handlers.rs (11: 8x require_service_or_caseworker_or_above at 161/294/364/400/433/468/496/661, require_data_steward at 201, 2x require_admin_or_quality_control at 545/720) plus fti_audit_handlers.rs (3x require_fti_auditor at 57/94/121). The +6 delta is overpayments_handler.rs (6x require_service_or_caseworker_or_above at 89/127/152/181/209/234), which the plan-time index evidently missed. One additional require_service_caller guards a medicaid-registered route from the shared crate (crates/canopy-api/src/admin.rs:129, /v1/admin/events/replay) — outside the service-src count. main.rs:1021 canopy_crypto_shred::require_kek is a boot-time key loader, not a Claims guard; excluded.
| Method | Path | Handler | Class | Portal target |
|---|---|---|---|---|
POST |
|
handlers::post_determine |
service-or-exchanged (#1426) |
|
GET |
|
handlers::list_determinations |
dual |
|
GET |
|
handlers::get_determination |
dual |
|
POST |
|
handlers::post_redact_determination |
user-only (mechanical, #1426) |
|
GET |
|
handlers::get_eligible_categories |
dual |
|
GET |
|
handlers::get_explanation |
dual |
|
GET |
|
handlers::list_tma_coverage |
dual |
|
GET |
|
handlers::ele_chain_status |
dual |
|
GET |
|
handlers::get_ele_status |
dual |
|
POST |
|
handlers::revoke_ele |
user-only (mechanical, #1426) |
|
GET |
|
handlers::get_ele_household_summary |
dual |
|
POST |
|
handlers::run_ele_renewals |
user-only (mechanical, #1426) |
|
GET |
|
fti_audit_handlers::list_fti_audit |
user-only (mechanical, #1426) |
|
GET |
|
fti_audit_handlers::fti_audit_summary |
user-only (mechanical, #1426) |
|
GET |
|
fti_audit_handlers::get_fti_audit_entry |
user-only (mechanical, #1426) |
|
POST |
|
overpayments_handler::post_claim |
dual |
|
GET |
|
overpayments_handler::list_claims |
dual |
|
GET |
|
overpayments_handler::get_claim |
dual |
|
POST |
|
overpayments_handler::post_repayment_plan |
dual |
|
POST |
|
overpayments_handler::post_recoupment |
dual |
|
GET |
|
overpayments_handler::get_ledger |
dual |
|
POST |
|
cmd_handlers::ingest_cmd_update |
service-only |
|
POST |
|
cmd_handlers::requeue_determination |
service-only |
|
POST |
|
canopy_api::admin::admin_replay_handler (shared, merged at main.rs:1090) |
service-only |
|
GET |
|
canopy_api livez_check (ApiServer::router) |
public |
|
GET |
|
canopy_api readyz_check (ApiServer::router) |
public |
|
GET |
|
SwaggerUi (ApiServer::router) |
public |
-
NO ACTOR VERIFIER WIRED: canopy-medicaid uses the bootstrap default AuthLayer::new(jwks) (crates/canopy-api/src/bootstrap.rs:180) and never calls with_actor_verifier (only canopy-applications and canopy-tanf do, per repo-wide grep). Consequences: (a) Claims::actor() is ALWAYS None in this service, so the handlers.rs:84-87 FTI accessed_by attribution always records the calling service’s sub, never the on-behalf-of worker; (b) any request carrying X-Canopy-Actor is 401-rejected fail-closed (crates/canopy-auth/src/middleware.rs:139-145); (c) POST /v1/admin/events/replay (merged at main.rs:1084-1090) requires a verified admin actor (admin.rs:130-131) and is therefore permanently 403/unusable in medicaid. Any migration slice moving medicaid to actor-attributed calls MUST wire an ActorVerifier first or every actor-carrying caller breaks with 401.
-
STRING-PARAM ACTORS: cmd_handlers.rs:94 persists caller-supplied req.submitted_by into medicaid_cmd_events.submitted_by, and cmd_handlers.rs:149 logs req.resolved_by, both from the request body with no cross-check against Claims — attribution rides on caller honesty on two service-only routes fronted by canopy-web.
-
INCONSISTENT ATTRIBUTION PATTERNS across the service: post_determine uses actor-else-sub (handlers.rs:84-87), redact uses bare claims.sub (handlers.rs:236), ELE revoke parses claims.sub as UUID and silently (warn-logged) drops attribution for non-UUID subs (handlers.rs:573-582), cmd routes use body strings. A migration slice should converge these on one resolver.
-
SYSTEM EVENTS HAVE NO ACTOR SENTINEL: ELE chain events from schedulers/subscribers write actor_id=None (scheduler.rs:305, scheduler.rs:425, main.rs:182, main.rs:795); a manually triggered sweep (POST /v1/ele/renewals/run, admin/QC-gated) also produces actor_id=None rows, losing the triggering admin’s identity.
-
DORMANT FTI READ SURFACE: store/fti.rs read_fti_tax_data (line 34) and read_fti_for_magi (line 78) are #[expect(dead_code)] pending #785; when wired, their accessed_by parameter must be threaded from a real resolved identity — flag for whichever slice lands #785.
-
STALE COMMENT: overpayments_handler.rs:3-4 doc header says 'SNAP overpayment claim' endpoints but this is the medicaid service (copy-paste from the shared canopy-overpayments extraction).
-
SPLIT GATE ON A QUERY PARAM: GET /v1/determinations is dual-classed but its
monthparam arm is service-only (#1249, handlers.rs:291-295) — route-level classification alone under-describes it; keep the arm split in any migration. -
NO OWNERSHIP GATES ANYWHERE: no household/person-ownership checks exist in this service (worker/service surface only; medicaid is not a portal target and canopy-portal/src contains no medicaid API calls — only display labels in home.rs). Every id/param is trusted after the role gate.
-
LITERAL ROUTE STRINGS: /fti-audit-log*, /overpayments*, and /ele/renewals/run are registered as literal strings in api/mod.rs:129-160 (not contracts-crate constants) — path-constant-driven migration tooling will miss them.
-
IDEMPOTENCY PRINCIPAL COLLAPSE: the shared idempotency middleware keys by actor-else-sub (crates/canopy-api/src/idempotency.rs:678); with actor always None in medicaid, all workers behind one BFF service token share one idempotency principal for guarded POSTs.
Second receiver on canopy_auth::ReceiverContract, following the tanf
template. Deltas against the plan-time survey above (retained as the
migration baseline; where they conflict, this block is current):
-
POST /v1/determine (handlers.rs:72) moved
require_service_caller→require_service_or_exchanged— an exchanged user-context token (exactaud=canopy-medicaid, allowlistedazp, caseworker-or-above role) now passes alongside service callers (the hop-2 prepare; the orchestrator’sEXCHANGE_TARGETSincludes canopy-medicaid in the devstack). -
The 6 pure-role gates became MECHANICAL user-only routes:
require_user_only(["fti_auditor"])(fti_audit_handlers.rs:62/101/130),require_user_only(["data_steward"])(handlers.rs:211), andrequire_user_only(["admin", "quality_control"])on the two ELE ops routes (handlers.rs:564 revoke, :747 renewals/run — pure human-role gates with no service or BFF caller). Service class is 403service_class_on_user_onlyunconditionally; withCANOPY_MEDICAID__ENFORCE_USER_ONLY_ROUTES=true(devstack: on) a legacy broad-audience worker bearer is 403aud_not_exact. Noquality_controluser exists in the devstack realm — the positive arm is exercised viaadmin(jane.admin). -
Every other route in the API router sits behind the
exchanged_gatemiddleware (main.rs — layered onapi::routes()BEFORE the admin merge); dual routes accept well-formed exchanged tokens with no handler changes. -
Attribution converged on
EffectiveUserat the three inconsistent handler sites flagged above: determine’s FTIaccessed_by, the redaction event actor, and the ELE revokeactor_id(an exchanged bearer attributes its own preservedsub). This CLOSES the "accessed_by always records the service sub" consequence of the no-actor-verifier flag for exchanged traffic — a per-target user-context bearer needs no actor header at all (one is 401-rejected on non-service bearers). The cmd body-string sites and the renewals-sweepactor_id=Noneevent rows are unchanged. -
CHIP: the orchestrator’s hop-2 audience derivation would mint
aud=canopy-chip, which this service’s single-audience gate 403s — CHIP dispatch deliberately stays on the ADR-019 service token (EXCHANGE_TARGETSlists only canopy-medicaid). -
Conformance: the F4 matrix activates
canopy-medicaid(11 manifest rows citing the guard lines; POST /v1/determine and POST /v1/ele/renewals/run excluded as unsafe-to-probe, covered by medicaid_test / ele_lapse_e2e_test).
canopy-eligibility+canopy-reporting
Plan-time index: eligibility 1+8+1 · reporting 0+1 — read-verified require_* sites: 43. Read-verified handler-level authorization call sites: eligibility 19 (8x require_service_or_caseworker_or_above in handlers.rs:120,195,234,258,281,385,443,499; 1x require_supervisor_or_above probe inside enforce_worker_id_identity at handlers.rs:318; 4x require_admin at bulk_runs.rs:65,172,364,415; 2x require_supervisor_or_above at bulk_runs.rs:271,318; 4x require_reader at bulk_runs_read.rs:42,80,122,163) + reporting 24 (21x require_supervisor_or_above in api/mod.rs:207,369,396,420,453,567,593,665,697,721,734,768,813,870,937,971,995,1023,1076,1134,1205; 1x require_service_or_caseworker_or_above at api/mod.rs:1273; 2x authorize_runs_read at api/runs.rs:79,114). Two helper-internal delegations (bulk_runs_support.rs:78, runs.rs:43) are not double-counted. Delta vs plan: the plan-time figures index only the ADR-019 dual-class constructs — eligibility '1+8+1' maps cleanly to 1 service_id() exact-identity gate (handlers.rs:140) + 8 require_service_or_caseworker_or_above + 1 is_service-OR helper (require_reader); reporting '0+1' to 0 require_service_caller + 1 require_service_or_caseworker_or_above. The plan count excludes the pure human-role gates (require_admin / require_supervisor_or_above / require_reader call sites / authorize_runs_read), which dominate numerically (6 eligibility bulk mutations + 22 reporting sites + 4 bulk-read sites + 2 runs-read sites + the enforce_worker_id_identity probe). Every plan-time-indexed site was found; the delta is purely additive — no plan-indexed site is missing. Non-authz require_* helpers (require_enabled H18 feature gate at bulk_runs_support.rs:39, require_published_inputs data-guard at reporting api/mod.rs:309) were excluded as not authorization.
| Method | Path | Handler | Class | Portal target |
|---|---|---|---|---|
POST |
|
api::handlers::post_determine |
dual |
|
POST |
|
api::handlers::post_determine_dry_run |
dual |
|
GET |
|
api::handlers::get_request |
dual |
|
GET |
|
api::handlers::get_request_determinations |
dual |
|
GET |
|
api::handlers::get_result |
dual |
|
GET |
|
api::handlers::scoped_cross_program_alerts |
dual |
assignment-scoped (#596) |
GET |
|
api::handlers::list_cross_program_alerts_all |
dual |
supervisor-or-canopy-web only (#596) |
GET |
|
api::handlers::get_case_status |
dual |
|
GET |
|
api::handlers::list_determinations_by_household |
dual |
yes |
POST |
|
api::bulk_runs::create_bulk_run |
user-only |
|
GET |
|
api::bulk_runs_read::list_bulk_runs |
dual |
|
GET |
|
api::bulk_runs_read::get_bulk_run |
dual |
|
GET |
|
api::bulk_runs_read::list_bulk_run_failures |
dual |
|
GET |
|
api::bulk_runs_read::list_bulk_run_actions |
dual |
|
POST |
|
api::bulk_runs::enact_bulk_run |
user-only |
|
POST |
|
api::bulk_runs::pause_bulk_run |
user-only |
|
POST |
|
api::bulk_runs::resume_bulk_run |
user-only |
|
POST |
|
api::bulk_runs::cancel_bulk_run |
user-only |
|
POST |
|
api::bulk_runs::retry_bulk_run_failures |
user-only |
|
POST |
|
api::generate_fns_388 |
user-only |
|
GET |
|
api::list_reports |
user-only |
|
GET |
|
api::get_report |
user-only |
|
POST |
|
api::generate_qc_snapshot |
user-only |
|
GET |
|
api::get_qc_universe |
user-only |
|
GET |
|
api::export_qc_csv |
user-only |
|
POST |
|
api::generate_tanf_acf199 |
user-only |
|
GET |
|
api::list_tanf_acf199 |
user-only |
|
GET |
|
api::export_tanf_acf199_csv |
user-only |
|
POST |
|
api::generate_tanf_acf196 |
user-only |
|
GET |
|
api::list_tanf_acf196 |
user-only |
|
POST |
|
api::generate_tanf_wpr |
user-only |
|
GET |
|
api::list_tanf_wpr |
user-only |
|
POST |
|
api::generate_medicaid_tmsis |
user-only |
|
GET |
|
api::list_medicaid_tmsis |
user-only |
|
GET |
|
api::export_medicaid_tmsis_csv |
user-only |
|
POST |
|
api::generate_medicaid_cms64 |
user-only |
|
GET |
|
api::list_medicaid_cms64 |
user-only |
|
POST |
|
api::generate_medicaid_cms416 |
user-only |
|
GET |
|
api::list_medicaid_cms416 |
user-only |
|
GET |
|
api::export_overpayments_csv |
user-only |
|
GET |
|
api::get_overpayments_summary |
dual |
|
GET |
|
api::runs::list_runs |
dual |
|
GET |
|
api::runs::get_run_status |
dual |
-
NO ACTOR VERIFIER IN EITHER SERVICE: both use the default AuthLayer::new(jwks) from canopy_api::bootstrap (crates/canopy-api/src/bootstrap.rs:180); only canopy-applications and canopy-tanf call with_actor_verifier. So Claims::actor is ALWAYS None here, every is_service() pass is a true no-actor pass, and — per crates/canopy-auth/src/middleware.rs:129-144 — a service bearer that PRESENTS X-Canopy-Actor gets 401 'actor verifier not configured'. Any migration slice that starts sending actor headers into eligibility/reporting must wire verifiers in the same slice or every call 401s.
-
STRING-PARAM ACTOR (eligibility): DetermineRequest.requested_by is a caller-supplied request-body string ('for audit attribution', crates/canopy-contracts-eligibility/src/determine.rs:34) persisted verbatim to eligibility_requests.requested_by (services/canopy-eligibility/src/store/mod.rs:80 via orchestrator.rs:1296). Attribution is self-declared by the caller — a migration must derive it server-side from actor()/sub or validate it against claims.
-
PORTAL COUPLING: canopy-portal’s only eligibility call is GET /v1/eligibility/determinations?household_id= (services/canopy-portal/src/home.rs:247) with a bare bearer token (home.rs:561 bearer_auth, no actor header). It passes on the no-actor service arm of require_service_or_caseworker_or_above with zero server-side household-ownership check — moving that route to actor-required or ownership-gated breaks the portal home page unless the portal slice lands first. canopy-reporting is NOT a portal target (no reporting calls in services/canopy-portal/src).
-
SERVICE-OPEN UNSCOPED READS (eligibility): requests/{id}, results/{application_id}, case-status, determinations, are readable by ANY service bearer with no actor and no tenancy scoping. The cross-program-alerts feeds are the exception since #596: the worker path is assignment-scoped with a canopy-web-only service allowlist, and /all is supervisor-or-canopy-web only.
-
API-KEY SURFACE (outbound, eligibility): CANOPY_INTERNAL_API_KEY loaded at services/canopy-eligibility/src/main.rs:84-86 and sent as x-service-api-key to canopy-verification’s internal SOLQ endpoint (#384, handlers.rs:26-29). A non-OIDC bearer path a later slice must retire; the inbound gate lives in canopy-verification.
-
AZP-FALLBACK SUBTLETY: the D-5 exact-identity gate (handlers.rs:140) relies on Claims::service_id(), which falls back to the azp claim when no service: role exists (crates/canopy-auth/src/claims.rs:241-247). The gate sits behind require_service_or_caseworker_or_above, so a HUMAN caseworker token whose azp is 'canopy-eligibility' would satisfy service_id()==Some("canopy-eligibility") without being a service — pattern to normalize in migration.
-
BULK SELF-CALLS carry with_service_identity(jwt) only, never an actor header (services/canopy-eligibility/src/bulk/consumer.rs:174-182 preview dry-run, 291-297 enact) — the no-actor service caller the D-5 gate exists to admit; keep exempt from any actor-required policy.
-
AUDIT LEDGER IS BEST-EFFORT: bulk-run H22 action rows append AFTER the committed transition; a failed append is only tracing::error (bulk_runs_support.rs:444-472) — attribution rows can be silently missing from /actions.
-
basis_of() (bulk_runs_support.rs:433-438) knows only role:admin\|role:supervisor — if a slice ever opens bulk mutations to service callers the ledger mislabels them role:supervisor.
-
REPORTING DB-ROLE SURFACE (authz outside HTTP claims): boot-time least-privilege guard services/canopy-reporting/src/guard.rs:108 (called main.rs:87) — env==development auto-allows (guard.rs:62-64), allow_broad_db_role=true (config.rs:75, default false) proceeds with a loud WARN naming the cutover runbook, otherwise refuses boot. Grant matrix in migrations/20261111000000_least_privilege_roles.sql (REVOKE ALL FROM PUBLIC + enumerated grants to NOLOGIN canopy_reporting_app; owner role split); janitor reap is SECURITY DEFINER, EXECUTE granted only to canopy_reporting_app (20261111000001_janitor_security_definer.sql:99-103). Plus crypto-side: T-MSIS sealed payloads AAD-bind natural keys so a DB actor with UPDATE cannot re-attribute rows (store/restricted.rs:264-286).
-
REPORTING RUNS ARE ORG-VISIBLE BY DESIGN (api/runs.rs:14-17): deliberate deviation from #1205 requester-scoping so the 409 conflict handle stays pollable — do not 'fix' with requester scoping in a migration slice.
-
ASYMMETRY TO PRESERVE OR RESOLVE: reporting report READS (incl. decrypted T-MSIS PHI) are supervisor-only and service-CLOSED, while run-status reads and the overpayments summary are service-open; eligibility bulk-run reads (incl. failures pages with household/application ids) are open to ANY service identity via require_reader — the migration should decide per-surface which service identities belong.
-
Route paths: eligibility api::routes() registers 18 paths/19 method-handlers (count refreshed at #1430; the survey’s 17/18 predated the #596 feed split), reporting 17 paths/24 method-handlers, all nested under /v1 behind auth_middleware by ApiServer::router (crates/canopy-api/src/lib.rs:190-214); the only unauthenticated routes are canopy-api’s own /livez and /readyz — neither service registers any public or portal-only route itself.
-
Stale-comment risk: orchestrator.rs:1513-1517 asserts program services' /v1/determine checks require_service_caller 'post-MR-3 cutover' — verify against the program services when their slice runs; eligibility itself contains zero require_service_caller sites. RESOLVED in #1430: the comment now names the require_service_or_exchanged posture on flipped slices.
Sixth receiver on canopy_auth::ReceiverContract, and the fleet’s ONE
hop-2 receiver. Deltas against the survey above (retained as the
migration baseline; where they conflict, this block is current):
-
The contract is built
with_hop2_exchanger("canopy-eligibility-exchanger"): the user-context arm accepts exactaud=canopy-eligibilityOR exactly the{canopy-eligibility, canopy-eligibility-exchanger}pair — the delegable hop-1 shape canopy-web mints for the two determine senders (approve / run-determination), which the orchestrator re-exchanges for itsEXCHANGE_TARGETSfan-out (hop-2, live end-to-end as of this slice). Any other multi-audience shape stays 403aud_not_exact; a pair token replayed at any single-exact service is 403 there. -
The 6 bulk-run mutations became MECHANICAL user-only routes:
require_user_only(["admin"])(create bulk_runs.rs:70, enact :183, cancel :384, retry-failures :441) andrequire_user_only(["supervisor", "admin"])(pause :286, resume :335) — same role bars as therequire_admin/require_supervisor_or_abovethey replace. Service class is 403service_class_on_user_onlyunconditionally; withCANOPY_ELIGIBILITY__ENFORCE_USER_ONLY_ROUTES=true(devstack: on) a legacy broad-audience worker bearer is 403aud_not_exact— bulk-run operators mint through exchange (the persons-slice runbook pattern, #1501). -
Every route sits behind the
exchanged_gatemiddleware (main.rs, layered onapi::routes()); the 13 dual routes accept well-formed exchanged workers with no handler changes. The azp allowlist is web-exchanger-ONLY (the eligibility exchanger mints tokens FOR the fan-out targets, never for eligibility itself). -
Attribution converged on
EffectiveUserat the four user-only ledger sites (created_by, enacted_by, canceled_by, the H22 append_action actor) — value-identical to the rawclaims.subthey replace for every bearer those routes admit (both admitted shapes are non-service ⇒Direct). The STRING-PARAM ACTOR flag above (DetermineRequest. requested_by) is UNCHANGED — its server-side derivation is #985-class follow-on work, not this slice. -
The #596 gates are semantically unchanged: an exchanged worker now reaches
resolve_effective_workerwith verified identity (the caseworker path-binding finally binds to a cryptographically-carriedsub), while the canopy-web service arms remain for the BFF’s service-class SSR reads (dashboard panels, /all feed) — the FU-A residual C1 retires. -
Senders switched for THIS target only (the §B rule): canopy-web’s two determine writes mint the hop-2 pair via
exchange_for_target(…, Some("canopy-eligibility-exchanger")); configured-but-failed exchange fails the action (no silent downgrade, ADR-043). SSR reads stay service-class. The G5 outcome write-back to canopy-applications stays on the service path (that target’s posture is its own slice). -
Conformance: the F4 matrix activates
canopy-eligibility(4 probe-safe dual rows; the 6 bulk-run mutations are write-arm surfaces excluded per the S-security precedent, enforcement pinned by receiver_contract_test.rs, which also pins the pair-positive, the rogue-azp and non-pair multi-audience kills, and both user-only kills).
Fourteenth receiver on canopy_auth::ReceiverContract — and the FIRST
whose dominant class is USER-ONLY. Deltas against the survey above
(canopy-eligibility landed separately as S-eligibility):
-
ALL 21 SUPERVISOR REPORT SURFACES flipped
claims.require_supervisor_or_above()→contract.require_user_only(&claims, SUPERVISOR_OR_ABOVE)(the same supervisor/admin bar, now behind the §C user-context arm). Devstack ENFORCES: a broad-audience direct worker bearer is 403aud_not_exact; service bearers are 403service_class_on_user_only; only the exchanged user-context arm (exactaud=canopy-reporting, allowlisted azp, supervisor role) reaches the report readers/generators — live-verified ladder on a 404-deterministic read. -
THE THREE DUAL SURFACES ARE UNCHANGED by design: the overpayments summary (the BFF panel’s service-class sender — the survey’s "deliberately service-open" note stands) and the two org-visible runs reads (
authorize_runs_read, runs.rs:39 — do NOT requester- scope). The runs reads' residual arms are live-pinned under enforcement (the list by test, both by their F4 Dual rows); the summary’s service arm rides its daily BFF sender. -
NO web sender changes: canopy-web’s only reporting call is the dual summary (service-class SSR panel, FU-A). The 21 user-only surfaces have no BFF sender — the devstack test suites are their worker callers, and reporting_test.rs + snap_reader_generations_test.rs migrated to jane.doe’s EXCHANGED bearer (the steward-client precedent).
-
Attribution:
generate_fns_388persistsclaims.subas requested_by — value-identical under the exchanged bearer (the EffectiveUser invariant). -
Azp allowlist:
canopy-web-exchangerONLY. -
Conformance: the F4 matrix activates
canopy-reporting(3 UserOnly rows on 404-deterministic 1970 periods — a 200 read stages a #1404 audit outbox event, so 200-empty list/export surfaces stay out per the S-security/S-persons precedent — plus the 2 Dual runs rows). receiver_contract_test.rs pins the full user-only ladder, the FNS-388 generate deny arms (no safe exchanged probe — a passing POST enqueues a real run; the other four generates share the identical guard shape, unprobed), the runs-list residual arms, and the rogue-azp kill.
canopy-notices + canopy-security
Plan-time index: notices 8 · security 3 — read-verified require_* sites: 27. The plan-time numbers exactly match a require_service_caller-only grep: notices has 8 (api/mod.rs:124,197,239,286,321,390,416,448) and security has 3 (api/mod.rs:301,382,427). Reading the full handlers surfaces 19 more require_* call sites the plan index missed, all in canopy-security: 14 require_admin fallbacks inside the repeated if !claims.is_service() { claims.require_admin()?; } dual gates (api/mod.rs:176,225,256,469,497,525,553,586,685,796,932,992,1178,1291), the standalone admin-only require_admin on POST /security/archive (api/mod.rs:1231), and require_admin_or_quality_control on the export endpoint (api/export.rs:94). Total in-service require_* = 8 + 19 = 27. One additional shared-crate require_service_caller (crates/canopy-api/src/admin.rs:129, the /v1/admin/events/replay handler) is mounted by BOTH services' routers but lives outside services/*/src, so it is not in the 27.
| Method | Path | Handler | Class | Portal target |
|---|---|---|---|---|
POST |
|
canopy-notices::api::generate_notice |
service-only |
|
GET |
|
canopy-notices::api::list_notices |
service-only |
yes |
GET |
|
canopy-notices::api::list_delivery_queue |
service-only |
|
GET |
|
canopy-notices::api::get_notice |
service-only |
yes |
GET |
|
canopy-notices::api::get_notice_pdf |
service-only |
yes |
POST |
|
canopy-notices::api::mark_notice_read |
service-only |
yes |
POST |
|
canopy-notices::api::resend_notice |
service-only |
|
POST |
|
canopy-notices::api::render_document |
service-only |
|
POST |
|
canopy-api::admin::admin_replay_handler (notices mount, main.rs:245) |
service-only |
|
GET |
|
canopy-api::livez_check/readyz_check (lib.rs:215-216) |
public |
|
GET |
|
canopy-security::api::list_events |
dual |
|
GET |
|
canopy-security::api::get_event |
dual |
|
GET |
|
canopy-security::api::fact_change_history |
dual |
|
POST |
|
canopy-security::api::ingest_audit_event |
service-only |
yes |
GET |
|
canopy-security::api::list_alerts |
dual |
|
GET |
|
canopy-security::api::get_alert |
dual |
|
PATCH |
|
canopy-security::api::update_alert |
dual |
|
GET |
|
canopy-security::api::list_nist_controls |
dual |
|
GET |
|
canopy-security::api::get_summary |
dual |
|
GET |
|
canopy-security::api::chain_status |
dual |
|
POST |
|
canopy-security::api::chain_verify_enqueue |
dual |
|
GET |
|
canopy-security::api::chain_verify_job (requester-scoped for service callers) |
dual |
|
GET |
|
canopy-security::api::chain_attest |
dual |
|
GET |
|
canopy-security::api::list_archived |
dual |
|
POST |
|
canopy-security::api::run_archive |
user-only (mechanical, #1427) |
|
GET |
|
canopy-security::api::get_archive_run |
dual |
|
GET |
|
canopy-security::api::export::export_audit_events |
user-only (mechanical, #1427) |
|
POST |
|
canopy-security::api::register_signing_key (program-allowlisted) |
service-only |
|
GET |
|
canopy-security::api::signing_key_jwks |
service-only |
|
POST |
|
canopy-api::admin::admin_replay_handler (security mount, main.rs:149) |
service-only |
|
GET |
|
canopy-api::livez_check/readyz_check (lib.rs:215-216) |
public |
-
NO ActorVerifier is wired in either service: canopy_api bootstrap.rs:180 builds AuthLayer::new(jwks) plain, and repo-wide only canopy-applications (main.rs:78) and canopy-tanf (main.rs:194) call with_actor_verifier. In canopy-notices and canopy-security claims.actor() is therefore ALWAYS None, and any request carrying X-Canopy-Actor is rejected 401 by canopy-auth middleware.rs:139-145. Every migration slice that wants actor-aware gates in these services must first wire a verifier.
-
POST /v1/admin/events/replay is fail-closed but INOPERABLE in both services: admin.rs:130 demands claims.actor() with the admin role, but with no ActorVerifier the actor can never be populated (and sending the header 401s at middleware). Stale doc risk: the handler doc says 'Gated by service-class JWT + actor with the admin role' as if usable.
-
String-param actor identities throughout canopy-security: requested_by is a bare String holding either a service id or 'admin:{sub}' (api/mod.rs:848-855, :1238), compared as a string for requester scoping (:944); Claims::service_id() falls back to the azp string (claims.rs:246) — the api/mod.rs:846 comment documents the misattribution hazard that fallback already caused once (worker tokens attributing to 'canopy-api').
-
Caller-supplied attribution surfaces (body-trusted, not claims-derived): POST /v1/security/audit/ingest takes user_id/user_role/ip_address/household_id from the request body into the tamper-evident chain (api/mod.rs:338-341) — the portal posts these with a bare service token (portal lookup.rs:290-297); PATCH /v1/security/alerts/{id} takes resolved_by from the body (api/mod.rs:531).
-
The
if !claims.is_service() { claims.require_admin()?; }dual-gate is copy-pasted 14x in canopy-security/src/api/mod.rs (176,225,256,469,497,525,553,586,685,796,932,992,1178,1291) — a migration slice must sweep all 14 atomically or the surface becomes inconsistent; two deliberate exceptions (POST /security/archive admin-only per #1208 decision 10 with a do-NOT-add-is_service comment at 1221-1223, and the export endpoint’s admin-or-QC gate) must survive the sweep. -
canopy-notices is 100% require_service_caller with the IDOR/tenancy boundary explicitly delegated to BFFs (api/mod.rs:384 comment; portal does an owner pre-check GET before pdf/mark-read, portal notices.rs:173-180) — GET /v1/notices?household_id= is a filter, not an enforced gate; any service bearer can enumerate any household’s notices and read any notice PDF by id.
-
Portal (an applicant-facing caller) mints NO actor tokens at all — zero ACTOR_HEADER/with_actor hits in services/canopy-portal/src — so applicant actions arrive at notices (list/get/pdf/mark-read) and security (audit ingest) as pure no-actor service calls; applicant attribution today exists only in portal-composed audit event bodies.
-
Outbound attribution loss: canopy-notices calls applications/persons with bare service identity (applications_client.rs:66,:105 with_service_identity) — no actor context is forwarded on internal hops.
-
POST /v1/notices/{id}/resend has no in-tree caller (canopy-web/canopy-portal grep clean) and hardcodes delivery channel 'test' (api/mod.rs:426) — decide keep+gate or retire during migration.
-
Non-HTTP authz surfaces in canopy-security (out of F1a claims scope, catalogued for completeness): the audit/FTI chain-verify subsystem runs on a dedicated Postgres verify role — preimage views are verify-role-only, C8 (chain_verify/status.rs:107, :697); drainer park columns are excluded from the app role’s UPDATE grant (chain_staging/drainer.rs:166); the MQ wildcard subscriber ingests audit events broker-side with payload-carried attribution (main.rs consume_audit_event) — none of these consult HTTP Claims.
-
GET /v1/security/archive-runs/{id} has NO requester scoping (any service bearer or admin reads any run), unlike the sibling verify-jobs poll (api/mod.rs:944) which is requester-scoped — pattern inconsistency to reconcile.
-
GET /v1/security/signing-keys/{program}/jwks doc (api/mod.rs:408-409) records that public (unauthenticated) exposure for external verifiers is an intended follow-up — a later slice may need to reclassify this route from service-only to public.
-
Shared public surfaces on both services: /livez, /readyz (canopy-api lib.rs:215-216, unauthenticated by design), /swagger-ui + /api-doc/openapi.json, and the devstack test-clock router (compile-stripped from release builds, lib.rs:218-225).
Third receiver on canopy_auth::ReceiverContract — user-only-enforcement
ONLY (canopy-security is not a program service; there is no hop-2 route,
and audit ingest + the signing-key registry stay genuine
service-to-service require_service_caller). Deltas against the survey
above (retained as the migration baseline):
-
The two pure human-role gates became MECHANICAL user-only routes:
require_user_only(["admin"])on POST /v1/security/archive (api/mod.rs — the decision-10 "service tokens are rejected" posture is now the class kill) andrequire_user_only(["admin", "quality_control"])on GET /v1/export/audit-events. WithCANOPY_SECURITY__ENFORCE_USER_ONLY_ROUTES=true(devstack: on) a legacy broad-audience worker bearer is 403aud_not_exact; service class is 403service_class_on_user_onlyunconditionally. -
The 14 copy-pasted
is_service() || require_admindual sites stay untouched behind theexchanged_gatemiddleware (layered onapi::routes()before the admin merge) — a well-formed exchanged admin token passes them with no handler changes. -
Attribution via
EffectiveUser: the archiverequested_by(admin:{sub}) and the export self-auditactor— an exchanged bearer attributes its own preservedsub. Security still wires no ActorVerifier (the flag above stands); user-only routes only ever see the Direct arm, so none is needed. -
Conformance: the F4 matrix activates
canopy-securitywith READ rows only — BOTH user-only routes are excluded as write-unsafe to probe (a passing probe enqueues a real archive run / publishes an audit.export.requested event); their enforcement arms are pinned by security_test (exchanged-admin positive, exchanged-wrong-role, and both service-class kills). -
The dual READ rows carry the Admin conformance subject — security’s human arm is admin-gated, unlike the caseworker-or-above program services.
Thirteenth receiver on canopy_auth::ReceiverContract, a TERMINAL
exchange target with ZERO user-only routes (the enforce flag is
inert, set for fleet consistency). Deltas against the notices survey
above (canopy-security is untouched by this slice):
-
ONE ROUTE WIDENED: the citation render RPC (POST /v1/documents/render, api/mod.rs:140) moved
require_service_caller→require_service_or_exchanged(CITATION_RENDER_ROLES)— the exchanged arm mirrors the web-side citation-download bar (admin / studio_admin / auditor as realm roles), NOT the caseworker set, because auditors are the primary citation consumers and sit outsideCASEWORKER_OR_ABOVE_ROLES. Its one sender (canopy-web audit_log.rs) flips onto the worker’s exchanged bearer through the #1560 dispatch via the NEWInternalClient::into_neutralaccessor — theNeutralWritecounterpart ofinto_authorized; the #1004 route audit recognizesinto_neutralexactly likeneutral_writer(fail-on-denied; no service-identity downgrade). -
Everything else stays service-only per FU-B / ADR-023 D4: the machine surfaces (generate, delivery queue, resend), the portal-driven applicant reads + mark-read (the portal BFF holds a bare service bearer — the survey’s IDOR-delegation flags stand), and the web SSR reads. The recipient.rs:60 household-membership gate on the generation worker path is untouched.
-
The resend route’s no-in-tree-caller + channel-literal flag stands (keep-or-retire stays a follow-on decision, not this slice).
-
Azp allowlist:
canopy-web-exchangerONLY. -
Conformance: the F4 matrix activates
canopy-notices(4 service-only probe-safe GET rows + the render row as ServiceOrExchanged riding WorkerSubject::Admin — jane.doe’s caseworker set is OUTSIDE the mirrored bar). Pinned by receiver_contract_test.rs: direct admin 403 / exchanged admin 400-past-auth (idempotent unknown-template probe, nothing persisted) / exchanged caseworker 403 / service 400-past-auth / rogue azp 403 on the same path.
canopy-verification + canopy-caps + canopy-wic
Plan-time index: verification 1+5 (plus the api-key surface) · caps 9+4 · wic 3+6 = 28 JWT require_* sites — read-verified require_* sites: 30. Read-verified: verification 6 (1 require_service_caller + 5 require_service_or_caseworker_or_above — exact match); caps 14 (9 require_service_caller + 4 require_service_or_caseworker_or_above + 1 require_data_steward); wic 10 (3 require_service_caller + 6 require_service_or_caseworker_or_above + 1 require_data_steward). Delta of +2 vs plan = the two require_data_steward redact gates (caps handlers.rs:203, wic handlers.rs:213) that the plan-time count omitted. A raw grep of 'require_' returns 35 lines; the 5 excluded are boot-time, not request authz: require_kek (caps main.rs:80, wic main.rs:78), require_real_adapters (verification guard.rs:60 definition + main.rs:94 call), require_with_dev_fallback (verification main.rs:82). The verification api-key surface is 4 validate_api_key call sites (ievs.rs:67, save.rs:54, save.rs:83, ssa.rs:53) across 3 duplicated validate_api_key implementations, catalogued as the separate internal-api-key construct per the plan.
| Method | Path | Handler | Class | Portal target |
|---|---|---|---|---|
GET |
|
verifications::list_verifications |
dual |
yes |
POST |
|
verifications::create_verification |
service-only |
|
POST |
|
verifications::resolve_verification |
dual |
|
POST |
|
verifications::respond_verification |
dual |
yes |
GET |
|
verifications::list_verification_responses |
dual |
|
GET |
|
ievs_discrepancies::list_ievs_discrepancies |
dual |
|
POST |
|
ievs::handle_ievs_match (X-Service-Api-Key, outside JWT middleware) |
service-only |
|
POST |
|
save::handle_verify (X-Service-Api-Key) |
service-only |
|
POST |
|
save::handle_additional (X-Service-Api-Key) |
service-only |
|
POST |
|
ssa::handle_query (X-Service-Api-Key) |
service-only |
|
GET |
|
canopy-api livez_check/readyz_check (also /swagger-ui, /api-doc/openapi.json) |
public |
|
POST |
|
caps handlers::post_determine |
service-only |
|
GET |
|
caps handlers::list_determinations_by_household |
dual |
|
GET |
|
caps handlers::get_determination |
dual |
|
POST |
|
caps handlers::post_redact_determination (data_steward role; a service token granted that role would also pass) |
user-only |
|
GET |
|
caps handlers::list_authorizations_for_determination |
dual |
|
POST |
|
caps handlers::batch_get_active_authorizations |
service-only |
|
GET |
|
caps handlers::get_authorization |
dual |
|
PUT |
|
caps handlers::update_authorization (worker-driven via canopy-web service identity, no actor) |
service-only |
|
PUT |
|
caps handlers::switch_provider (worker-driven via BFF, no actor) |
service-only |
|
POST |
|
caps providers::create_provider |
service-only |
|
GET |
|
caps providers::list_providers |
service-only |
|
GET |
|
caps providers::get_provider (read by canopy-web determination_view.rs:874) |
service-only |
|
PUT |
|
caps providers::update_provider |
service-only |
|
DELETE |
|
caps providers::delete_provider (soft-delete) |
service-only |
|
GET |
|
canopy-api livez_check/readyz_check (also /swagger-ui, /api-doc/openapi.json) |
public |
|
POST |
|
wic handlers::post_determine |
service-only |
|
GET |
|
wic handlers::list_determinations_by_household |
dual |
|
GET |
|
wic handlers::get_determination |
dual |
|
POST |
|
wic handlers::post_redact_determination (data_steward role) |
user-only |
|
GET |
|
wic handlers::get_participant |
dual |
|
POST |
|
wic handlers::create_assessment |
dual |
|
GET |
|
wic handlers::list_assessments_by_person |
dual |
|
GET |
|
wic handlers::get_assessment |
dual |
|
POST |
|
wic appointment_handlers::schedule_appointment (worker-driven via canopy-web actions_wic.rs:166) |
service-only |
|
GET |
|
wic appointment_handlers::list_upcoming_appointments (dashboard feed via BFF) |
service-only |
|
GET |
|
canopy-api livez_check/readyz_check (also /swagger-ui, /api-doc/openapi.json) |
public |
-
API-KEY SURFACE (verification): 4 routes under /internal/v1/* (ievs/match, save/verify, save/additional-verification, ssa/solq) are merged onto the ROOT router (main.rs:169-173), fully outside the JWT auth middleware — no Claims, so no actor/role/service_id concept exists there at all. Migration must decide whether these become service-JWT routes or keep the shared key.
-
The api-key is a single shared secret (CANOPY_INTERNAL_API_KEY) for all 4 internal routes, with a dev fallback literal 'canopy-internal-dev-key' (verification main.rs:82-87); the header check is plain string equality
key == expected(non-constant-time) duplicated in 3 files (ievs.rs:50, save.rs:37, ssa.rs:36). -
ZERO actor() consumption: no file in any of the three services calls claims.actor(), is_service() directly, or service_id(); grep for actor() across all three src trees returns nothing. Every guard is require_* only. There are no no-actor-passes-with-audit or no-actor-rejects sites anywhere in these services.
-
String/UUID caller-supplied attribution throughout (never claims-derived): resolve_verification trusts body completed_by UUID; respond_verification trusts body person_id + application_id + responded_by_source enum string; WIC schedule_appointment persists body scheduled_by free-text string; WIC create_assessment persists body assessor_worker_id UUID. A migration to actor-derived attribution touches all four.
-
canopy-caps DISCARDS worker attribution on mutations: UpdateAuthorizationRequest.updated_by and SwitchProviderRequest.switched_by are accepted-but-ignored by design (contracts-caps authorizations.rs:63-65, 85-88 — 'audit lives on canopy-web’s tracing log'). The only durable record of WHO changed a childcare authorization is the BFF’s tracing output. Also effective_date on switch is accepted-but-ignored.
-
Possible path mismatch: canopy-web actions_caps.rs:182/241 PUT to '/v1/caps/authorizations/{id}[/provider]' but canopy-caps serves '/v1/authorizations/{id}[/provider]' (contracts-caps paths.rs:23-27) with base_url pointing straight at the caps service (clients.rs:2201 test pins http://localhost:8016). Verify whether a gateway rewrite exists or these worker actions 404 in production — either way the migration slice for caps mutations must pin the real path.
-
The two require_data_steward redact gates (caps:203, wic:213) are role-only, principal-class-agnostic: a service token GRANTED data_steward would pass and the audit event would then attribute the service client id (claims.sub, handlers caps:238 / wic:248) — the attribution site never consults actor(). The redacted-by parameter is a stringly Option<&str> (events.rs publish_determination_redacted, caps:80-92 / wic:81-92).
-
respond_verification’s ownership gate (verification’s stored application_id vs body application_id) is the ONLY household/ownership gate in all three services; caps/wic household_id/person_id query filters are scoping conveniences, not authz. Under the shared-service-bearer model the gate is bypassable by any service-token holder that first reads the verification.
-
WIC GET /v1/wic/appointments/upcoming is tenancy-unscoped (all households, LIMIT 50) behind bare require_service_caller; caps POST /v1/authorizations/active:batchGet is intentionally service-tier bulk (#1249) — both are cross-household reads a least-privilege slice should keep off any user-reachable path.
-
caps provider-registry mutations (POST/PUT/DELETE /v1/providers*) and GET /v1/providers have NO in-repo caller (only GET /v1/providers/{id} is called, by canopy-web determination_view.rs:874) — confirm the intended caller before choosing their migrated tier.
-
canopy-portal calls exactly two verification routes, both with a bare service token and NO X-Canopy-Actor: GET /v1/verifications?application_id&status=pending (portal verifications.rs:101, home.rs:228) and POST /v1/verifications/{id}/respond (portal verifications.rs:197-204). canopy-web’s post_with_actor (X-Canopy-Actor, clients.rs:981) exists but is used only in actions.rs (3 sites) and actions_tanf.rs (1) — never toward verification/caps/wic.
-
Doc-comment drift risk for the migration: verification verifications.rs:5-8 module doc frames the guards as 'service-class JWT auth; service-or-caseworker-or-above' — accurate today, but any tier change must update it, plus the per-handler #[utoipa::path] 403 descriptions which encode the current tier in the public OpenAPI.
Eighth receiver on canopy_auth::ReceiverContract, a TERMINAL exchange
target (single-exact audience). Deltas against the survey above:
-
POST /v1/determine (handlers.rs:63) moved
require_service_caller→require_service_or_exchanged— the orchestrator’s service token or its re-exchanged hop-2 bearer (EXCHANGE_TARGETSgains canopy-caps); a direct worker bearer stays 403. No as_of/trigger provenance pins exist in caps (unlike snap/medicaid) — nothing to widen. -
POST /v1/determinations/{id}/redact became a MECHANICAL user-only route (
require_user_only(["data_steward"]), handlers.rs:206) withEffectiveUseron the redaction event actor (value-identical). The survey’s principal-class-agnostic caveat is closed: a service token granteddata_stewardno longer passes. -
THE TWO WORKER-FACING AUTHORIZATION WRITES (PUT /v1/authorizations/{id} :420, PUT /v1/authorizations/{id}/provider :453) moved
require_service_caller→require_service_or_exchanged— the S-snap recompute precedent: the BFF now sends the worker’s exchanged bearer, so the survey’s worker-identity-as-ignored-body-field exposure gains a cryptographically-carried identity. Row-level attribution capture is STILL absent by design (no schema change this slice); the flag stays open for a follow-on. -
Discovered en route (#1564, fixed in the same MR): both web actions had been PUTting to nonexistent
/v1/caps/*paths since #448 — a bare route-miss 404 rendered as the error fragment on every submit; the caps-actions e2e smoke asserts only< 500(the #872/#1562 blind-assert class) so it never caught it. -
Every route sits behind the
exchanged_gate; azp allowlist =canopy-web-exchanger,canopy-eligibility-exchanger. Providers registry + batchGet stay service-only unchanged. -
Conformance: the F4 matrix activates
canopy-caps(3 dual, the redact user-only row, 2 service-only, and the authorization-update ServiceOrExchanged row — the matrix’s first PUT surface). Excluded: determine (real determinations; caps_test covers) and the provider-switch PUT (same guard shape as the included update). receiver_contract_test.rs pins the enforcement kills, the exchanged steward/worker positives, and the direct-worker 403s.
Ninth receiver on canopy_auth::ReceiverContract, a TERMINAL exchange
target. Deltas against the survey above:
-
POST /v1/determine (handlers.rs:57) moved
require_service_caller→require_service_or_exchanged(EXCHANGE_TARGETSgains canopy-wic); a direct worker bearer stays 403. No provenance pins exist in wic. -
POST /v1/determinations/{id}/redact became a MECHANICAL user-only route (handlers.rs:216, the caps twin) with
EffectiveUseron the redaction event actor (value-identical). -
POST /v1/wic/households/{household_id}/appointments (appointment_handlers.rs:99) moved
require_service_caller→require_service_or_exchangedand its BFF sender flipped — the survey’s USER-FACING-mutation-under-bare-service-bearer flag gains a cryptographically-carried worker identity.scheduled_bystays the caller-supplied string (no schema change; flag stays open). The nutritional-risk write’s sender also flipped (dual route — no guard change);assessor_worker_idremains caller-supplied (flag stays). -
GET /v1/wic/appointments/upcoming stays service-only (SSR dashboard feed; the tenancy-unscoped flag stays for a least-privilege follow-on).
-
Azp allowlist:
canopy-web-exchanger,canopy-eligibility-exchanger. -
Conformance: the F4 matrix activates
canopy-wic(4 dual, the redact user-only row, 1 service-only). Excluded: determine (real determinations), the appointment create (a bare INSERT with no existence check — any authorized probe writes a row; its direct-worker 403 is pinned by receiver_contract_test.rs), the risk-assessment create (write), and the person-scoped assessments list (a fifth dual read identical in shape to the four rowed ones — deliberately unrowed).
Tenth receiver on canopy_auth::ReceiverContract, a TERMINAL exchange
target with ZERO user-only routes (the applications precedent — the
enforce flag is inert, set for fleet consistency). Deltas:
-
POST /v1/verifications (verifications.rs:123) moved
require_service_caller→require_service_or_exchanged: the survey’s "orchestrator is the only production caller" note was STALE — the BFF’s request-verification action posts it under service identity, and BOTH web verification writes now send the worker’s exchanged bearer via the shared #1560 dispatch — the create, and the G3 auto-resolve leg inside accept_document (best-effort semantics kept: a denied exchange degrades to manual resolution, never a downgrade). A direct worker bearer stays 403 on the create. The dual mass (list/resolve/respond/responses/discrepancies) is guard-unchanged behind theexchanged_gate;completed_bystays the caller-supplied body field (flag open). -
The PORTAL surfaces (
GET /v1/verifications,POST /v1/verifications/{id}/respond) carry the #1441 portal arm since P2 (require_dual_or_portalonportal:verifications:read/portal:verifications:respond); the respond ownership-gate caveat stands until P3 (#1442). -
THE API-KEY SURFACE (
/internal/v1/{ievs/match, save/*, ssa/solq}, X-Service-Api-Key) is CLASSIFIED, not migrated: it is mounted outside the JWT router entirely (no Claims exist), so no receiver-contract arm applies. Its retirement to ADR-019 service tokens remains the survey-flagged follow-on; N1 fixes the stale security.adoc bullet. -
Azp allowlist:
canopy-web-exchangerONLY (not an EXCHANGE_TARGETS program). -
Conformance: the F4 matrix activates
canopy-verification— the two SEED rows go live (the create row reclassified ServiceOnly → ServiceOrExchanged, still the ADR-025 422-zero-write probe) plus two new dual rows (ievs/discrepancies, {id}/responses). resolve (mutating dual) and the portal respond (P2 scope) stay unrowed; receiver_contract_test.rs pins the create posture BOTH ways (direct worker 403, exchanged worker 422-past-auth-zero-write).
The portal caller rows above predate #1440 and their mechanics moved:
LookupDeps.service_token (the ONE process-wide broad source every
row’s token came from) no longer exists — acquisition is per-target
(services/canopy-portal/src/tokens.rs, PortalTokenSources: eight
scope-aware sources, one per backend target, each self-validating
against its own target audience), so cited deps.service_token call
sites and their line numbers are stale as locations while the ROUTES
each row documents are unchanged. The cross-target token reuse several
rows implied (one token fanned across applications + persons
verification + eligibility + enrollment in home; applications
security in lookup) is GONE — each hop now carries a token valid only
at that hop’s target (lateral kill live-pinned by
services/canopy-portal/tests/narrow_token_test.rs).
The cross-owner hole inside the classified surface is closed. The portal
signs a 120s X-Canopy-Applicant claim per resource-keyed call
(application sub + resolved household/person; key ≠ the OAuth2 client
secret, so a stolen narrow bearer cannot mint one) and six origins
verify + enforce: applications drafts patch/get/finalize, the
application read, document list/upload (upload also binds the SUBJECT
person before any object write — the old caller-supplied person_id
hole); persons GET /v1/persons/{id} (session’s submitting person
only); notices list (absent household filter denies) and
get/pdf/mark-read (post-load compares, UNIFORM 404 — denial never
confirms a foreign notice exists; mark-read pre-reads before stamping);
verification list + respond (the citizen session binding runs BEFORE
the legacy 403/422 arms, closing their existence oracle; the response’s
subject person is bound); eligibility determinations and enrollment’s
annual summary (claim-local household compares). Non-citizen
principals pass every ownership guard untouched. Exempt by design:
create-draft, verify-credential and the recovery flows (they ARE the
authentication), snap params (no applicant resource) + security audit-ingest —
exempt because nothing is READ back and the server mints the chain
identity (a replayed event_id cannot fork the chain), NOT because the
body is resource-free: the portal’s session events do carry a
body-trusted resource_id, an attribution-integrity residue that
predates P3 (a stolen portal bearer could stage misattributed audit
rows; tracked as follow-on work). Neither service is claim-wired. F4: CrossOwnerAccess live
on the 6 pre-load classified rows; live pins in narrow_token_test
(claim-missing / cross-owner / garbage-claim-401, codes pinned), the
applications draft suites, and the enrollment receiver tests. This
closes #665 (the applicant-token-signer premise is superseded — the
signer now exists as the portal-applicant keypair).
The receiver half landed. The portal credential is a compiled CITIZEN
CLASS in canopy-auth (CITIZEN_CLASS_SERVICE_IDS = ["canopy-portal"];
recognition checks the service:* role half and the azp half
independently): Claims::require_service_caller refuses it — 403
portal_on_non_portal_route — and every service-accepting contract arm
delegates there, so EVERY route in the portal’s 8 targets that is not
explicitly portal-classified rejects the narrowed token through one
check (the negative the P1 block above deferred). The 21
portal-reachable routes re-admit it on azp allowlist + per-route
operation scope (12-scope portal:* vocabulary, minted per target by
PortalTokenSources and defined as realm optional client scopes with
include.in.token.scope=true): the applications applicant flows
(verify-credential, drafts ×4, recover initiate/kill) are PORTAL-ONLY
on portal:intake — ordinary service bearers are 403
portal_only_route there now; application GET + document list/upload,
security audit-ingest (portal:audit:write — service-or-portal, the
exchange-audit sinks keep their service arm), persons GET, and the
notices reads/ack take require_service_or_portal; verification
list/respond, eligibility household determinations, and snap params
take require_dual_or_portal; enrollment’s annual summary takes
require_service_or_exchanged_or_portal (issuances stays
portal-killed). A missing scope is 403 portal_scope_missing. F4:
PortalLateralAccess live on all 47 rows of the 8 targets
PortalScopeMissing on the 9 classified rows (floor 945 → 1000); live
pins: narrow_token_test (intra-target kill, aud-only scope kill,
8-target positive sweep incl. the ack/respond/documents-list arms)
the enrollment receiver tests. Ownership binding (a stolen portal
credential crossing RESOURCE boundaries inside its classified surface)
remains P3 (#1442).
Caller manifest
Every user-context edge, nested hop, and background caller — verified at HEAD.
| Caller | Target | Kind | Evidence |
|---|---|---|---|
canopy-web |
canopy-applications |
user-context |
services/canopy-web/src/clients.rs:1538-1541 (roster target); all calls carry canopy-web’s own OIDC service bearer via with_service_identity (clients.rs:1457-1490 → with_token 1413-1415 → bearer_auth in prepare, clients.rs:507-508). Worker identity additionally rides X-Canopy-Actor JWT on exactly 3 applications sites: accept document api/actions.rs:570, reject document api/actions.rs:697, scan override api/actions.rs:772 (minted by mint_actor_jwt actions.rs:526-544, header attached clients.rs:989-990 via canopy_auth::client_ext::ACTOR_HEADER). |
canopy-web |
canopy-tanf |
user-context |
clients.rs:1554-1557 (roster); service bearer everywhere + X-Canopy-Actor on the work-requirement action: api/actions_tanf.rs:264 (mint) and :272-278 (post_with_actor). This is the 4th and last actor-attaching site in the whole fleet. |
canopy-web |
canopy-persons |
user-context |
clients.rs:1534-1537 (roster); service bearer only (with_service_identity, e.g. api/members.rs:103, api/income.rs:127). No actor header on any persons call — worker attribution rides in request bodies. |
canopy-web |
canopy-eligibility |
user-context |
clients.rs:1542-1545; service bearer only (api/actions_ele.rs:114, api/actions_intake.rs:224). |
canopy-web |
canopy-enrollment |
user-context |
clients.rs:1546-1549; service bearer only (api/actions_snap_enrollment.rs:287). Issuance reads therefore arrive actor-less at enrollment’s #408 gate. |
canopy-web |
canopy-snap |
user-context |
clients.rs:1550-1553; service bearer only (api/actions_snap.rs:98, :310). |
canopy-web |
canopy-medicaid |
user-context |
clients.rs:1558-1561; service bearer only (api/actions_medicaid.rs:43-236). |
canopy-web |
canopy-caps |
user-context |
clients.rs:1562-1565; service bearer only (api/actions_caps.rs:46-231). |
canopy-web |
canopy-wic |
user-context |
clients.rs:1566-1569; service bearer only (api/actions_wic.rs:42-215). |
canopy-web |
canopy-renewals |
user-context |
clients.rs:1570-1573; service bearer only (dashboard/case panels, e.g. api/cases.rs:135-141 stamps deadline then with_service_identity). |
canopy-web |
canopy-notices |
user-context |
clients.rs:1574-1577; service bearer only (api/notices.rs handlers). |
canopy-web |
canopy-appeals |
user-context |
clients.rs:1578-1581; service bearer only (api/appeals.rs:55, api/actions_snap_appeal.rs:252, api/actions_snap_appeal_decision.rs:242). |
canopy-web |
canopy-security |
user-context |
clients.rs:1582-1585; service bearer only (audit-log reads / chain status via get_terminal_status, clients.rs:841-880). |
canopy-web |
canopy-reporting |
user-context |
clients.rs:1586-1589; service bearer only. |
canopy-web |
canopy-verification |
user-context |
clients.rs:1590-1593; service bearer only (worker-portal /v1 surface — distinct from the X-Service-Api-Key /internal/v1 IEVS surface, see clients.rs:1355-1359 comment); e.g. team_queue.rs:72, fact_history.rs:129. |
canopy-portal |
canopy-applications |
user-context |
Pure canopy-portal service bearer, never an applicant actor: apply.rs:68-69 (POST /v1/applicants/drafts, bearer_auth(&token) from deps.service_token.current() apply.rs:60), apply.rs:327 (verify-credential), documents.rs:260-265 (multipart upload, bearer), documents.rs:103/138 (document list), lookup.rs:130-134 (verify-credential), recover.rs:96-100 + 160-162 (recover initiate / kill-switch), home.rs:184, persona.rs:103, verifications.rs:116, notices.rs:136. Applicant ownership is enforced only inside the portal BFF session, not on the wire. |
canopy-portal |
canopy-notices |
user-context |
notices.rs:231 (list by household), :180-181 (owner check GET /v1/notices/{id}), :325-326 (pdf), :390-391 (mark-read) — all .bearer_auth(service token) from service_token() helper notices.rs:161-163. |
canopy-portal |
canopy-persons |
user-context |
persona.rs:110 + :123 (GET /v1/persons/{submitted_by}, bearer_auth(token)); home.rs:209 same shape via fetch_json (home.rs:561 .bearer_auth(token)). |
canopy-portal |
canopy-verification |
user-context |
verifications.rs:101 (list for application), :199-204 (POST /v1/verifications/{id}/respond, bearer_auth(&token)); home.rs:228 (pending count). |
canopy-portal |
canopy-eligibility |
user-context |
home.rs:246-249 (GET /v1/eligibility/determinations?household_id=…, service bearer via fetch_json home.rs:561). |
canopy-portal |
canopy-enrollment |
user-context |
home.rs:265-268 (GET /v1/households/{id}/annual-summary, service bearer via fetch_json). |
canopy-portal |
canopy-snap |
user-context |
snap_params.rs:61-73 (GET snap params, .bearer_auth(&token) from deps.service_token.current()). |
canopy-portal |
canopy-security |
background |
lookup.rs:275-303: fire-and-forget tokio::spawn POST {security_url}/v1/security/audit/ingest with service bearer (lookup.rs:282, :298) — applicant.session.minted audit; best-effort by design (ADR-026), dropped on error. |
canopy-eligibility(orchestrator) |
canopy-persons |
nested-hop |
orchestrator.rs:229-231 GET {persons_base_url}/v1/households/{id}/full with service jwt (service_token.current() orchestrator.rs:215-216); runs inside POST /v1/eligibility/determine (api/handlers.rs:111-170 wires DetermineConfig with its own ServiceTokenSource handlers.rs:65/93/130). Originating worker actor is NOT propagated. |
canopy-eligibility(orchestrator) |
canopy-snap/tanf/medicaid/caps/wic |
nested-hop |
orchestrator.rs:1545 POST {base_url}/v1/determine per registry program (+ snap dry-run/baseline legs orchestrator.rs:1099-1196, dispatch 873-925), all with svc_jwt via with_service_identity; no actor forwarded. |
canopy-eligibility(orchestrator) |
canopy-verification |
nested-hop |
Two surfaces: POST /v1/verifications (orchestrator.rs:804-808, bearer_auth service token from :787) creating pending verifications; and POST /internal/v1/ssa/solq (orchestrator.rs:617-621) which sends the service jwt PLUS a static x-service-api-key header (legacy internal-API-key auth, orchestrator.rs:620). |
canopy-eligibility |
canopy-security |
nested-hop |
key_history.rs:20-21, 52-57: HttpKeyHistoryProvider GET on security_base_url with with_service_identity(&svc_jwt), memoized via MemoizedKeyHistory (main.rs:126-127) — signing-key-history lookups during verification of chained records. |
canopy-snap (also tanf/medicaid/caps/wic) |
canopy-rules |
nested-hop |
crates/canopy-rules-client/src/lib.rs:274-287 (POST /v1/evaluate, bearer_auth(t) at :287) + :346 (GET /v1/corpus); used inside snap determine (determine_handler.rs:20/64), tanf work-requirements (work_requirement_handlers.rs:32-33/222), caps determine.rs, wic determine.rs, medicaid rules_client.rs. Service token only; the determine call is itself already actor-less. |
canopy-snap |
canopy-enrollment |
nested-hop |
enrollment_client.rs:62-79: GET /v1/households/{id}/issuances with with_service_identity(&token) inside POST /determinations/{id}/overpayment-recompute (recompute_handler.rs:113-121, 150-157; handler is require_service_caller — worker action mediated by the BFF). |
canopy-snap |
canopy-persons |
nested-hop |
recompute_handler.rs:119 (PersonsClient dep) inside the same overpayment-recompute request; PersonsClient attaches the service token via with_service_identity (crates/canopy-persons-client/src/lib.rs:104 etc.). |
canopy-enrollment |
canopy-applications |
nested-hop |
api/mod.rs:115-144 gate_household_actor_access: when an X-Canopy-Actor worker is present (claims.actor(), api/mod.rs:123), calls clients/mod.rs:66-82 GET /v1/households/{id}/assignments with with_service_identity(service_token) (clients/mod.rs:79); actor absent ⇒ allowed unconditionally with no hop (api/mod.rs:123-125). |
canopy-appeals |
canopy-enrollment |
nested-hop |
AdverseActionsClient (clients.rs:286-309, service token; wired main.rs:132 on enrollment_url) — filing-time GET /v1/adverse-actions/{id} (clients.rs:395-401) and fenced stay PUT/GET (clients.rs:415-443) used in the appeal-filing handler (api/mod.rs:328) and the inline continued-benefits step (cb_stay.rs:15-16, execute_pending_stay cb_stay.rs:65). Actor not forwarded. |
canopy-appeals |
canopy-snap |
nested-hop |
SnapHearingClient (clients.rs:197-241, appeals' own service token — doc at clients.rs:191-196 notes a forwarded worker bearer would be rejected by the service-gated /hearing-view) GET /v1/determinations/{id}/hearing-view inside get_appeal_hearing_view (api/mod.rs:47/116, wired main.rs:206 on snap_url). |
canopy-applications |
canopy-persons |
nested-hop |
finalize saga on applicant submit: finalize_saga.rs:46/66/180 uses canopy_persons_client::PersonsClient (built main.rs:124-137 with service_token_source); the client attaches only the ADR-019 service token per call (crates/canopy-persons-client/src/lib.rs:78, :104, :131, :171, :305, :372, :390 with_service_identity). Applicant identity does not ride the hop. |
canopy-eligibility (bulk pipeline) |
canopy-renewals + canopy-persons + program services |
background |
bulk/worker.rs:378-384 GET {renewals_url}/v1/renewals/snap/universe-snapshots/{id}/rows with with_service_identity(&jwt); bulk consumer/worker re-drive the orchestrator DetermineConfig (persons + snap dispatch) under the service token, no actor (bulk/consumer.rs, bulk/worker.rs). |
canopy-renewals (scheduler + MQ subscriber) |
canopy-eligibility + canopy-enrollment |
background |
scheduler.rs:261-292 drains drive pr_pipeline; eligibility_client.rs:66-71 POST /v1/eligibility/determine/dry-run and enrollment_client.rs:121-129 POST /v1/adverse-actions (+ :163 reopen), both under renewals' service token; subscriber.rs consumes MQ and uses the same clients. |
canopy-notices (render worker + recovery subscriber) |
canopy-applications + canopy-persons |
background |
worker.rs:24/55-56 (PersonsClient + ApplicationsClient, built worker.rs:415-419); applications_client.rs:3-14 documents the service-token-per-call pattern (recovery-detail endpoint is service-caller-gated); recovery.rs drives GET /v1/applicants/recover/{recovery_id}. |
canopy-medicaid (ex-parte scheduler + ELE MQ consumer) |
canopy-persons + canopy-rules |
background |
scheduler.rs:57-61, :466-489 GET {persons_url}/v1/households/{id} and /v1/persons/{id}/income with bearer_auth(token); ELE consumer main.rs:554-585 (household fetch bearer_auth(&token) main.rs:580) + rules evaluate via medicaid rules_client.rs. Nacks to DLQ without a service token (main.rs:562-567). |
canopy-appeals (assessment worker + reconcile + stay-retry) |
canopy-enrollment |
background |
EnrollmentClient clients.rs:58-99 (doc: only consumer since #1105 is the background assessment worker — 'no inbound caller whose bearer it could forward'); assessment_worker.rs, reconcile.rs, cb_stay.rs spawn_stay_retry_task (cb_stay.rs:16-17, run_pending_stay_pass :286). |
canopy-applications (finalize reconciler) |
canopy-persons |
background |
reconciler.rs POST /v1/internal/finalize-operations/{op}/{generation}/cancel|release (paths reconciler.rs:372/398 as exercised by its persons mock); started from main.rs:145-148, :204-212 with the persons client’s service token. |
canopy-reporting (run worker) |
canopy-renewals/persons/applications/enrollment/snap/tanf/medicaid |
background |
clients/mod.rs:84-91 (7-service roster), :108-114 with_token(service token source), bearer attached per attempt clients/mod.rs:205-211; run worker spawned detached in main.rs:42 (spawn_run_worker) / :120 — the requesting worker’s identity does not ride the outbound calls. |
No-slice confirmations
-
canopy-exchange: CONFIRMED stub — no migration slice needed. services/canopy-exchange/src/api/mod.rs:6-8 is the entire business router:
pub fn routes() → Router<AppState> { Router::new() }(empty). main.rs:23-37 mounts only that empty router into ApiServer::router plus mq/outbox health Extensions; no route handlers, no Claims usage, no outbound service calls (adapters.rs/events.rs are MQ/partner scaffolding with no authorization branches). -
canopy-rules: CONFIRMED all-service — no migration slice needed. Exactly 5 routes registered (services/canopy-rules/src/api/mod.rs:67-74: LIST_RULE_SETS, GET_RULE_SET, EVALUATE, LIST_EVALUATIONS, GET_CORPUS) and every handler’s first authorization act is claims.require_service_caller(): get_corpus api/mod.rs:91 (comment 'ADR-019: service-class callers only, like every rules read'), list_rule_sets :117, get_rule_set :150, evaluate :181, list_evaluations :237 — each read-verified. No actor(), role, or household branch exists anywhere in the file (258 lines); main.rs:56-74 adds no other routes.
Cross-cutting caller flags
-
Actor coverage is 4 sites fleet-wide: only canopy-web ever attaches X-Canopy-Actor outbound (clients.rs:989-990), and only on applications document accept/reject/scan-override (actions.rs:570/697/772) and the tanf work-requirement action (actions_tanf.rs:272-278). Every other worker-actioned write and ALL reads leave canopy-web as bare service-bearer traffic; worker attribution elsewhere rides unverified request-body fields (e.g. requested_by/completed_by shapes).
-
canopy-portal attaches NO applicant identity on any outbound call — pure canopy-portal service bearer everywhere (apply/documents/notices/persona/verifications/home/lookup/recover/snap_params). Downstream services cannot distinguish 'applicant acting on own case' from 'any service traffic'; ownership is enforced only inside the portal BFF (e.g. notices.rs:180-186 owner check happens portal-side before the pdf proxy). Every portal edge lands in the no-actor branch downstream.
-
No nested hop propagates the originating actor: eligibility→persons/verification/programs, program→rules, enrollment→applications, snap→persons/enrollment, appeals→enrollment/snap, applications→persons all re-mint their OWN service identity (with_service_identity / bearer_auth); the fleet grep for ACTOR_HEADER attachment outbound hits only canopy-web. Once a request is one hop past the BFF, user identity is gone — audit attribution downstream falls back to claims.sub = the calling service.
-
enrollment’s #408 household-assignment RBAC gate (api/mod.rs:115-145) is dormant for worker-portal traffic: it only bites when claims.actor() is Some, but canopy-web never attaches an actor on issuance/annual-summary reads, so worker reads take the no-actor 'pure system traffic' allow branch (api/mod.rs:123-125) — a no-actor-passes instance the migration slices must close.
-
eligibility→verification SOLQ uses a legacy static x-service-api-key header in ADDITION to the OIDC service jwt (orchestrator.rs:617-621); the /internal/v1 IEVS/SAVE/SOLQ surface is a parallel non-OIDC auth mechanism (also noted in web clients.rs:1355-1359) and needs explicit handling in the F1a plan.
-
canopy-web fails OPEN at the auth layer on the un-stamped path: with_service_identity returns clients WITHOUT any bearer when token fetch fails (clients.rs:1459-1467, deliberate — graceful degradation, downstream 401s render as error panels); the stamped path fast-fails AuthUnavailable instead (clients.rs:1470-1489). Downstream services are the only enforcement point for these unauthenticated calls.
-
Portal→security audit ingest is fire-and-forget from a detached tokio::spawn (lookup.rs:289-303) with SILENT-OK on failure — applicant session-mint audit events can be silently lost by design (ADR-026); if session-mint auditing becomes an authorization-relevant record under OIDC F1a, this delivery guarantee is insufficient.
-
Positive confirmation for the migration plan: no forwarded end-user bearer exists anywhere in fleet outbound traffic — the pre-#1105 bearer-forwarding mode in appeals is dead (clients.rs:62-63 doc), and web’s with_token JWT-pass-through (clients.rs:1410-1412 doc) has zero non-service-token callers in src. All 14 slices start from a uniform service-bearer(+rare-actor) baseline.