Role-enforcement audit (Story 2.5)

Status: complete. Last verified 2026-06-17 against backend/src/main.rs router and all routes*.rs files.

This document enumerates every route handler mounted under /api/v1 (plus /health), the role required to call it, how that requirement is enforced, and the name of the test that asserts a 403 Forbidden for an under-privileged caller (or why no 403 test applies).

Supersession notes (orchestrator decisions — do NOT change)

Story 2.5’s acceptance criteria describe a flat error envelope {"error":"forbidden","message":"This action requires admin role"}. That AC is superseded by the project-wide error contract and is intentionally NOT implemented:

  1. Error envelope is uniform. Every AppError renders as {"error":{"code":..,"message":..}} via backend/src/shared/error.rs. AppError::Forbidden → HTTP 403 with code = "FORBIDDEN", message = "forbidden". Changing this to the AC’s flat shape would break the entire API contract and every existing error-shape test. The envelope is kept as-is.

  2. AppError::Forbidden stays a unit variant with the fixed message "forbidden". The AC’s specific human string ("This action requires admin role") is NOT plumbed in — a per-call custom message would diverge from the uniform envelope. The 403 status code and machine-readable code are the contract; the message is uniform.

Enforcement mechanisms

  • ctx.require_admin()? — synchronous role check at the top of the handler. Returns AppError::Forbidden (→ 403 FORBIDDEN) when ctx.role != UserRole::Admin. Fires before any DB query, so a 403 test needs only an employee TenantContext + a router — no seeded data (one exception: the time-tracking surface, which runs require_enabled first; see below).

  • RLS-scoped, any-authenticated — no role gate. The handler is readable (or, for a few self-service writes, writable) by any authenticated caller. Tenant isolation is enforced by Postgres Row-Level Security (<table>_isolation policies) via ctx.rls_transaction(...); cross-tenant ids return NotFound, never another tenant’s data. These deliberately have no 403 test.

  • Customer JWT — the public form surface (/f/{slug}*); any authenticated caller, with an in-handler tenant_id (and optional pinned-customer) check after the SECURITY DEFINER slug lookup that bypasses RLS.

  • Public — /health, no auth at all (mounted outside the auth middleware).

Audit table

Method Path Required role Enforcement 403 test

GET

/health

public

none (outside /api/v1, no auth middleware)

n/a — public

GET

/api/v1/me

any-authenticated

TenantContext extractor (401 if no token)

n/a — any-authenticated

GET

/api/v1/tenant/settings

admin

ctx.require_admin()? (tenants/routes.rs:32)

tenants::routes::tests::get_settings_non_admin_is_403

PATCH

/api/v1/tenant/settings

admin

ctx.require_admin()? (tenants/routes.rs:70)

tenants::routes::tests::non_admin_patch_is_403 (pre-existing)

GET

/api/v1/roles

any-authenticated

RLS-scoped (roles::list) — employees need the role catalogue

n/a — any-authenticated, RLS-scoped

POST

/api/v1/roles

admin

ctx.require_admin()? (shifts/routes/roles.rs:57)

shifts::routes::roles::tests::create_role_non_admin_is_403

PATCH

/api/v1/roles/{id}

admin

ctx.require_admin()? (shifts/routes/roles.rs:83)

shifts::routes::roles::tests::update_role_non_admin_is_403

DELETE

/api/v1/roles/{id}

admin

ctx.require_admin()? (shifts/routes/roles.rs:115)

shifts::routes::roles::tests::delete_role_non_admin_is_403

GET

/api/v1/locations

any-authenticated

RLS-scoped (locations::list)

n/a — any-authenticated, RLS-scoped

POST

/api/v1/locations

admin

ctx.require_admin()? (shifts/routes/locations.rs:102)

shifts::routes::locations::tests::create_location_non_admin_is_403

PATCH

/api/v1/locations/{id}

admin

ctx.require_admin()? (shifts/routes/locations.rs:148)

shifts::routes::locations::tests::update_location_non_admin_is_403

DELETE

/api/v1/locations/{id}

admin

ctx.require_admin()? (shifts/routes/locations.rs:212)

shifts::routes::locations::tests::delete_location_non_admin_is_403

GET

/api/v1/users

admin

ctx.require_admin()? (shifts/routes/users.rs:110)

shifts::routes::users::tests::list_employees_non_admin_is_403

GET

/api/v1/users/{id}

admin

ctx.require_admin()? (shifts/routes/users.rs:133)

shifts::routes::users::tests::get_user_non_admin_is_403

POST

/api/v1/users/{id}/qualifications/{role_id}

admin

ctx.require_admin()? (shifts/routes/users.rs:167)

shifts::routes::users::tests::grant_qualification_non_admin_is_403

PATCH

/api/v1/users/{id}/qualifications/{role_id}

admin

ctx.require_admin()? (shifts/routes/users.rs:268)

shifts::routes::users::tests::set_qualification_wage_non_admin_is_403

DELETE

/api/v1/users/{id}/qualifications/{role_id}

admin

ctx.require_admin()? (shifts/routes/users.rs:227)

shifts::routes::users::tests::revoke_qualification_non_admin_is_403

GET

/api/v1/me/qualifications

any-authenticated

self-service; scoped to ctx.user_id + RLS

n/a — any-authenticated, RLS-scoped

GET

/api/v1/events

any-authenticated

RLS-scoped + role filter (non-admins see published only)

n/a — any-authenticated, RLS-scoped

POST

/api/v1/events

admin

ctx.require_admin()? (shifts/routes/events.rs:72)

shifts::routes::events::tests::create_event_non_admin_is_403

GET

/api/v1/events/{id}

any-authenticated

RLS-scoped (events::get_event)

n/a — any-authenticated, RLS-scoped

PATCH

/api/v1/events/{id}

admin

ctx.require_admin()? (shifts/routes/events.rs:181)

shifts::routes::events::tests::update_event_non_admin_is_403

POST

/api/v1/events/{id}/publish

admin

ctx.require_admin()? (shifts/routes/events.rs:96)

shifts::routes::events::tests::publish_event_non_admin_is_403

POST

/api/v1/form_submissions/{id}/promote

admin

ctx.require_admin()? (shifts/routes/events.rs:148)

shifts::routes::events::tests::promote_non_admin_is_403

GET

/api/v1/shifts

any-authenticated

RLS-scoped (admin roster + employee "my shifts")

n/a — any-authenticated, RLS-scoped

POST

/api/v1/shifts

admin

ctx.require_admin()? (shifts/routes/shifts.rs:173)

shifts::routes::shifts::tests::create_shift_non_admin_is_403

GET

/api/v1/shifts/{id}

any-authenticated

RLS-scoped (shifts::get_shift)

n/a — any-authenticated, RLS-scoped

GET

/api/v1/shifts/{id}/slots

any-authenticated

RLS-scoped (employee claim picker)

n/a — any-authenticated, RLS-scoped

POST

/api/v1/shifts/{id}/publish

admin

ctx.require_admin()? (shifts/routes/shifts.rs:198)

shifts::routes::shifts::tests::customer_role_returns_403_on_admin_publish_endpoint (pre-existing, Customer) + publish_shift_non_admin_is_403 (Employee)

POST

/api/v1/shifts/{id}/split/{n}

admin

ctx.require_admin()? (shifts/routes/shifts.rs:277)

shifts::routes::shifts::tests::split_shift_non_admin_is_403

GET

/api/v1/events/{event_id}/shifts

any-authenticated

RLS-scoped (alias of ?event_id=)

n/a — any-authenticated, RLS-scoped

POST

/api/v1/events/{event_id}/shifts/bulk

admin

ctx.require_admin()? (shifts/routes/shifts.rs:303)

shifts::routes::shifts::tests::bulk_generate_non_admin_is_403

POST

/api/v1/shifts/{shift_id}/slots/{slot_id}/claim

any-authenticated

self-service; claim_slot enforces qualification + capacity

n/a — any-authenticated (self-claim)

POST

/api/v1/shifts/{shift_id}/slots/{slot_id}/assign

admin

ctx.require_admin()? (shifts/routes/assignments.rs:85)

shifts::routes::assignments::tests::assign_non_admin_is_403

GET

/api/v1/time-tracking/balance

any-authenticated

self-service (own balance); require_enabled only

n/a — any-authenticated (own balance)

GET

/api/v1/time-tracking/balances

admin

require_enabledctx.require_admin()? (time_tracking/routes.rs:109)

time_tracking::routes::tests::list_all_balances_non_admin_is_403

GET

/api/v1/time-tracking/balance/{user_id}

admin

require_enabledctx.require_admin()? (time_tracking/routes.rs:87)

time_tracking::routes::tests::get_user_balance_non_admin_is_403

POST

/api/v1/time-tracking/entries

admin

require_enabledctx.require_admin()? (time_tracking/routes.rs:133)

time_tracking::routes::tests::record_time_non_admin_is_403

POST

/api/v1/time-tracking/payouts

admin

require_enabledctx.require_admin()? (time_tracking/routes.rs:189)

time_tracking::routes::tests::create_payout_non_admin_is_403

GET

/api/v1/time-tracking/payouts/me

any-authenticated

self-service (own payouts); require_enabled only

n/a — any-authenticated (own payouts)

GET

/api/v1/time-tracking/payouts/{user_id}

admin

require_enabledctx.require_admin()? (time_tracking/routes.rs:266)

time_tracking::routes::tests::list_payouts_non_admin_is_403

GET

/api/v1/forms

admin

ctx.require_admin()? (forms/routes.rs:85)

forms::routes::tests::list_forms_non_admin_is_403

POST

/api/v1/forms

admin

ctx.require_admin()? (forms/routes.rs:110)

forms::routes::tests::create_form_non_admin_is_403

GET

/api/v1/forms/{form_id}/submissions

admin

ctx.require_admin()? (forms/routes.rs:411)

forms::routes::tests::list_submissions_non_admin_is_403

POST

/api/v1/forms/{form_id}/submissions/{sub_id}/convert

admin

ctx.require_admin()? (forms/routes.rs:442)

forms::routes::tests::convert_submission_non_admin_is_403

POST

/api/v1/events/{event_id}/documents/upload

admin

ctx.require_admin()? (forms/routes.rs:472)

forms::routes::tests::request_upload_url_non_admin_is_403

GET

/api/v1/f/{slug}

customer (any-auth)

post-lookup tenant_id + pinned-customer check; bypasses RLS via slug lookup

n/a — customer surface (returns 403 only on customer mismatch, not role)

POST

/api/v1/f/{slug}/submit

customer (any-auth)

post-lookup tenant_id check + rate limiter

n/a — customer surface (any-authenticated submitter)

GET

/api/v1/customers

admin

ctx.require_admin()? (customers/routes.rs:71)

customers::routes::tests::list_customers_non_admin_is_403

POST

/api/v1/customers

admin

ctx.require_admin()? (customers/routes.rs:94)

customers::routes::tests::post_customers_returns_403_for_employee (pre-existing)

GET

/api/v1/customers/{id}

admin

ctx.require_admin()? (customers/routes.rs:119)

customers::routes::tests::get_customer_non_admin_is_403

PATCH

/api/v1/customers/{id}

admin

ctx.require_admin()? (customers/routes.rs:145)

customers::routes::tests::patch_customer_non_admin_is_403

DELETE

/api/v1/customers/{id}

admin

ctx.require_admin()? (customers/routes.rs:170)

customers::routes::tests::delete_customer_non_admin_is_403

GET

/api/v1/notifications

any-authenticated

self-service; scoped to ctx.user_id

n/a — any-authenticated (own notifications)

POST

/api/v1/notifications/{id}/mark-read

any-authenticated

self-service; scoped to ctx.user_id (404 if not owned)

n/a — any-authenticated (own notifications)

POST

/api/v1/notifications/mark-all-read

any-authenticated

self-service; scoped to ctx.user_id

n/a — any-authenticated (own notifications)

POST

/api/v1/push-subscriptions

any-authenticated

self-service; scoped to ctx.user_id

n/a — any-authenticated (own subscription)

DELETE

/api/v1/push-subscriptions/{id}

any-authenticated

self-service; scoped to ctx.user_id

n/a — any-authenticated (own subscription)

Findings

  • No missing guards found. Every admin-mutating handler already called ctx.require_admin()?. No behavior changes were required — Story 2.5 was a coverage + documentation pass.

  • Time-tracking ordering. The admin-only time-tracking handlers run require_enabled(...) before ctx.require_admin()?. A tenant without time tracking enabled returns 404 for the whole surface regardless of role (intentional — "hidden" beats "forbidden" to avoid leaking feature existence). The 403 tests therefore seed a tenant with time_tracking_enabled = true so the role gate is actually reached.

  • Self-service endpoints are correctly any-authenticated. GET /me/qualifications, GET /time-tracking/balance, GET /time-tracking/payouts/me, all notification + push endpoints, and POST .../claim scope to ctx.user_id (and RLS) by design — no admin gate, no 403 test. Recorded above as such.

  • GET list endpoints that are RLS-scoped (/roles, /locations, /events, /shifts, /events/{id}/shifts, slot lists) are intentionally readable by employees so the employee surface can render the catalogue and available shifts. They are NOT admin-gated; tenant isolation is RLS-only. Recorded above as "any-authenticated, RLS-scoped".

</content> </invoke>