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.
Four layers, one of which is the boundary
Permissions are checked at four layers. The first three shape the screen and give a clean error; only the fourth stops anybody, because the first three all run in the browser (ACC-001). All four are required — skipping any one of them is a regression — but only one of them is security.
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()
Every write handler in src/lib/api.ts calls await requirePermission('x.y') before doing
any work. This gives a clean 403, keeps the intent visible next to the handler, and is
checked by a test (src/test/api.permission-coverage.test.ts). It is not a security
boundary: src/lib/api.ts is bundled into the page and runs in the user's browser.
// 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.
This layer is bypassable by design
A user who opens the console can call Supabase directly with their own token. That is
why every rule that protects money, data or access is enforced again in Postgres — an
RLS policy, a constraint, a trigger, or a SECURITY DEFINER function that takes the
actor from the session. Never add a control that exists only here.
Still, always go through src/lib/api.ts
A direct supabase.from('Booking').insert(...) from application code hits RLS but
bypasses the typed error shape, the idempotency guards and the reason-capture that
carries into the audit trail (UX-001).
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
There is no if (role === 'CEO') return true anywhere. The original
auth_user_has_permission() did have such a shortcut; it was removed in
supabase/migrations/20260416020000_remove_permission_bypass.sql.
SUPER_ADMIN is the only role that holds every permission
(ACC-011, ACC-012, decided 2026-09-17). It holds them
because every permission row is granted to it explicitly, and two triggers keep that true:
one grants each newly inserted Permission to SUPER_ADMIN, the other seeds every existing
permission to a SUPER_ADMIN role row created later.
Everyone else holds a bundle:
| Role | Bundle |
|---|---|
CEO, GM |
every *.view and *.export, the business approvals, finance.approvals.high_value, finance.supplier_transactions.correct |
IT_ADMIN |
system settings only — users, MFA reset, integrations, audit, currencies, cities, config. No business approvals, no finance write rights |
ADMIN_HR |
no finance, approval, invoice or permission-editing rights |
CASHIER |
finance.view, finance.payments.record, plus the general read bundle |
| the rest | their own job's bundle — PERMISSIONS.md §5 |
apply_role_bundles() converges each role onto its bundle and deletes anything outside
it, so a grant made by hand does not survive. Break-glass and destructive rights
(finance.journals.approve_own, .reverse_own, finance.ledger.rebuild, .reset,
finance.years.close, finance.periods.close, accounts.delete, admin.permissions.edit,
the role rights, admin.users.delete, agents.delete) are on a super-admin-only list, so
no bundle can pick them up.
Consequences:
- Every grant is an auditable row in
RolePermission. - Revoking one row really does restrict the holder, super admin included.
- If you need elevated access, grant the permission. Do not add a role-name shortcut in code or in a policy.
This is a change from the April model
Until 2026-09-17, CEO, GM and IT_ADMIN were each seeded every permission row and
were effectively three super-admins. Any page that still says so is out of date — see
decisions-log, 2026-09-17.
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.