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:
-
Error envelope is uniform. Every
AppErrorrenders as{"error":{"code":..,"message":..}}viabackend/src/shared/error.rs.AppError::Forbidden→ HTTP403withcode = "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. -
AppError::Forbiddenstays 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-readablecodeare the contract; the message is uniform.
Enforcement mechanisms
-
ctx.require_admin()?— synchronous role check at the top of the handler. ReturnsAppError::Forbidden(→ 403FORBIDDEN) whenctx.role != UserRole::Admin. Fires before any DB query, so a 403 test needs only an employeeTenantContext+ a router — no seeded data (one exception: the time-tracking surface, which runsrequire_enabledfirst; 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>_isolationpolicies) viactx.rls_transaction(...); cross-tenant ids returnNotFound, 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-handlertenant_id(and optional pinned-customer) check after theSECURITY DEFINERslug 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 |
|
public |
none (outside |
n/a — public |
GET |
|
any-authenticated |
|
n/a — any-authenticated |
GET |
|
admin |
|
|
PATCH |
|
admin |
|
|
GET |
|
any-authenticated |
RLS-scoped ( |
n/a — any-authenticated, RLS-scoped |
POST |
|
admin |
|
|
PATCH |
|
admin |
|
|
DELETE |
|
admin |
|
|
GET |
|
any-authenticated |
RLS-scoped ( |
n/a — any-authenticated, RLS-scoped |
POST |
|
admin |
|
|
PATCH |
|
admin |
|
|
DELETE |
|
admin |
|
|
GET |
|
admin |
|
|
GET |
|
admin |
|
|
POST |
|
admin |
|
|
PATCH |
|
admin |
|
|
DELETE |
|
admin |
|
|
GET |
|
any-authenticated |
self-service; scoped to |
n/a — any-authenticated, RLS-scoped |
GET |
|
any-authenticated |
RLS-scoped + role filter (non-admins see |
n/a — any-authenticated, RLS-scoped |
POST |
|
admin |
|
|
GET |
|
any-authenticated |
RLS-scoped ( |
n/a — any-authenticated, RLS-scoped |
PATCH |
|
admin |
|
|
POST |
|
admin |
|
|
POST |
|
admin |
|
|
GET |
|
any-authenticated |
RLS-scoped (admin roster + employee "my shifts") |
n/a — any-authenticated, RLS-scoped |
POST |
|
admin |
|
|
GET |
|
any-authenticated |
RLS-scoped ( |
n/a — any-authenticated, RLS-scoped |
GET |
|
any-authenticated |
RLS-scoped (employee claim picker) |
n/a — any-authenticated, RLS-scoped |
POST |
|
admin |
|
|
POST |
|
admin |
|
|
GET |
|
any-authenticated |
RLS-scoped (alias of |
n/a — any-authenticated, RLS-scoped |
POST |
|
admin |
|
|
POST |
|
any-authenticated |
self-service; |
n/a — any-authenticated (self-claim) |
POST |
|
admin |
|
|
GET |
|
any-authenticated |
self-service (own balance); |
n/a — any-authenticated (own balance) |
GET |
|
admin |
|
|
GET |
|
admin |
|
|
POST |
|
admin |
|
|
POST |
|
admin |
|
|
GET |
|
any-authenticated |
self-service (own payouts); |
n/a — any-authenticated (own payouts) |
GET |
|
admin |
|
|
GET |
|
admin |
|
|
POST |
|
admin |
|
|
GET |
|
admin |
|
|
POST |
|
admin |
|
|
POST |
|
admin |
|
|
GET |
|
customer (any-auth) |
post-lookup |
n/a — customer surface (returns 403 only on customer mismatch, not role) |
POST |
|
customer (any-auth) |
post-lookup |
n/a — customer surface (any-authenticated submitter) |
GET |
|
admin |
|
|
POST |
|
admin |
|
|
GET |
|
admin |
|
|
PATCH |
|
admin |
|
|
DELETE |
|
admin |
|
|
GET |
|
any-authenticated |
self-service; scoped to |
n/a — any-authenticated (own notifications) |
POST |
|
any-authenticated |
self-service; scoped to |
n/a — any-authenticated (own notifications) |
POST |
|
any-authenticated |
self-service; scoped to |
n/a — any-authenticated (own notifications) |
POST |
|
any-authenticated |
self-service; scoped to |
n/a — any-authenticated (own subscription) |
DELETE |
|
any-authenticated |
self-service; scoped to |
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(...)beforectx.require_admin()?. A tenant without time tracking enabled returns404for the whole surface regardless of role (intentional — "hidden" beats "forbidden" to avoid leaking feature existence). The 403 tests therefore seed a tenant withtime_tracking_enabled = trueso 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, andPOST .../claimscope toctx.user_id(and RLS) by design — no admin gate, no 403 test. Recorded above as such. -
GETlist 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>