Permissions System
How every gated action in the ERP is enforced, where the canonical list lives, and how to add a new permission without breaking the drift test.
The single source of truth
docs/PERMISSIONS.md is the canonical permissions matrix. Any code, seed migration, or RLS policy that disagrees with it is a bug — and the drift test (src/test/permissions.matrix.test.ts) will say so.
The three-layer enforcement model
Permissions are checked at three layers, every one of which is required. Skipping any one of them is a security regression.
flowchart LR
User((User action)) --> Route{Route guard}
Route -->|ProtectedRoute| Gate{UI gate}
Gate -->|PermissionGate| Mutation[API mutation]
Mutation --> API[requirePermission - src/lib/api.ts]
API --> DB[(Postgres RLS - auth_user_has_permission)]
DB -->|pass| Result[Write committed]
DB -->|fail| Deny[403]
API -->|fail| Deny
Gate -->|fail| Hidden[Control hidden]
Route -->|fail| Redirect[Redirect to allowed page]
1. Route-level — <ProtectedRoute>
Gates whole pages. Defined in src/components/auth/ProtectedRoute.tsx. Used in src/App.tsx for every non-public route:
<Route
path="/sales/bookings"
element={<ProtectedRoute requiredPermissions={['bookings.view']}><Bookings /></ProtectedRoute>}
/>
requiredPermissionsrequires all named permissions (logical AND) — seeProtectedRoute.tsx:42-49.allowedRolesalone is role-gated (portals use this); can be combined withrequiredPermissions.- On failure, the user is redirected — customers to
/customer, agents to/partner, staff to/app.
2. UI-level — <PermissionGate>
Gates individual buttons, menu items, and sections inside a page. Defined in src/components/auth/PermissionGate.tsx:
<PermissionGate permission="bookings.create">
<Button onClick={openWizard}>New Booking</Button>
</PermissionGate>
Falsy permission → the child is replaced with the fallback (default null). This hides the control; it does not enforce it. The server-side check is the real guard — <PermissionGate> is UX only.
3. API-level — requirePermission()
The authoritative check. Every write handler in src/lib/api.ts calls await requirePermission('x.y') as its first line:
// src/lib/api.ts:136
async function requirePermission(name: string): Promise<void> {
const userId = await getCurrentUserId();
if (!userId) throw new ApiError(401, { message: 'Authentication required' });
if (!(await userHasPermission(userId, name))) {
throw new ApiError(403, { message: `Permission denied: ${name}` });
}
}
The resolution order matches the SQL function (src/lib/api.ts:99-134):
- Explicit user-level deny (
UserPermission.allowed = false) → denied. - Explicit user-level allow (
UserPermission.allowed = true) → granted. - Role grant via
RolePermissionfor any of the user'sUserRolerows → granted. - Otherwise → denied.
Never skip requirePermission
A direct supabase.from('Booking').insert(...) call from the browser will hit RLS, but also bypasses audit hooks, idempotency keys, and the typed error shape. Always go through src/lib/api.ts.
4. Database-level — RLS via auth_user_has_permission()
Supabase Postgres has RLS on every user-facing table. Sensitive tables (Booking, Customer, Agent, Supplier, Payment, JournalEntry, JournalLine, FinanceConfig, and more) carry RESTRICTIVE policies that AND-in auth_user_has_permission('x.y'):
-- supabase/migrations/20260415200000_permission_aware_rls.sql:147
DROP POLICY IF EXISTS booking_perm_insert ON public."Booking";
CREATE POLICY booking_perm_insert ON public."Booking"
AS RESTRICTIVE
FOR INSERT
TO authenticated
WITH CHECK (public.auth_user_has_permission('bookings.create'));
Policies combine as: (is_staff_user() = true) AND (auth_user_has_permission(...) = true). service_role bypasses RLS for migrations and Edge Functions. See RLS.
The "no role-name shortcut" rule
CEO, GM, and IT_ADMIN are super-admins — but not because the code contains if (role === 'CEO') return true. The original auth_user_has_permission() did have such a shortcut; it was removed in supabase/migrations/20260416020000_remove_permission_bypass.sql.
Instead, that migration explicitly inserts one RolePermission row per super-admin role per permission:
-- supabase/migrations/20260416020000_remove_permission_bypass.sql:13
WITH bypass_roles AS (SELECT id, name FROM public."Role" WHERE name::text IN ('CEO', 'GM', 'IT_ADMIN')),
all_perms AS (SELECT id FROM public."Permission")
INSERT INTO public."RolePermission" (id, "roleId", "permissionId")
SELECT gen_random_uuid(), br.id, ap.id
FROM bypass_roles br CROSS JOIN all_perms ap
WHERE NOT EXISTS (...);
Consequences:
- Super-admins have every permission, but each grant is an auditable row in
RolePermission. - You can revoke a specific row from a super-admin (e.g. for duty separation or a compliance investigation) without touching code.
- If you need elevated access, grant the permission — do not add a role-name shortcut in code or policy.
Break-glass exceptions are the only named-role check
The two override permissions finance.journals.approve_own and finance.journals.reverse_own are seeded only to CEO / GM / IT_ADMIN (see supabase/migrations/20260418150000_journal_maker_checker_overrides.sql). They are still enforced through RolePermission, not a hardcoded role check.
The drift test
src/test/permissions.matrix.test.ts parses docs/PERMISSIONS.md and compares the catalog to every permission string referenced in code. It fails the build when:
- Code uses a permission name that isn't declared in PERMISSIONS.md (false positive would mean a typo or forgotten catalog update).
- PERMISSIONS.md declares a permission no code references (false positive would mean a stale entry, or a granular permission that was seeded ahead of UI wiring — see the
ALLOWED_DOC_ONLYgrace list atsrc/test/permissions.matrix.test.ts:26).
The grace list enumerates exactly which permissions are seeded-but-unwired, and each PR that wires a batch removes its entries. Running the test is part of npm test and is wired into CI.
How to add a new permission (5-step checklist)
Mirror the flow in CLAUDE.md §Permissions and docs/PERMISSIONS.md §9.
- Add a row to
docs/PERMISSIONS.md§6 under the relevant page. - If new, add to §4 (catalog) and §5 (role grants).
- Add a seed migration under
supabase/migrations/inserting the permission intoPermissionand granting it inRolePermissionto the appropriate roles. Keep the migration idempotent (INSERT ... WHERE NOT EXISTS). - Reference it in code:
- Route —
<ProtectedRoute requiredPermissions={['x.y']}>insrc/App.tsx. - Action —
<PermissionGate permission="x.y">around the button / menu. - Handler —
await requirePermission('x.y')as the first line in the API handler insrc/lib/api.ts. - RLS — add RESTRICTIVE policies on the table(s) using
auth_user_has_permission('x.y'). - Run
npm test. The drift test will catch any orphan or missing link.
Renaming a permission
Same PR should:
- Update
docs/PERMISSIONS.md. - Grep and rename every code reference.
- Add a migration renaming the
Permission.nameand every RLS policy that cites it. npm test.
Deleting a permission
- Remove every code reference first.
- Remove from
docs/PERMISSIONS.md§4 / §5. - Add a migration that drops
RolePermissionrows and then thePermissionrow. npm test.
Portal scoping (non-staff users)
Portal users (AGENT, CUSTOMER) do not hold staff permissions. Their routes are gated by allowedRoles only, and their data access is enforced by ownership checks inside each handler (e.g. customerId === auth.uid(), or a partner's agentId matching their own record). See docs/PERMISSIONS.md §4.5 and §6.10 / §6.11.
Related reading
docs/PERMISSIONS.md— canonical matrix.CLAUDE.md— repo-wide contribution rules.- RLS — database enforcement deep-dive.
src/test/permissions.matrix.test.ts— drift test source.