Skip to content

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>}
/>
  • requiredPermissions requires all named permissions (logical AND) — see ProtectedRoute.tsx:42-49.
  • allowedRoles alone is role-gated (portals use this); can be combined with requiredPermissions.
  • 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):

  1. Explicit user-level deny (UserPermission.allowed = false) → denied.
  2. Explicit user-level allow (UserPermission.allowed = true) → granted.
  3. Role grant via RolePermission for any of the user's UserRole rows → granted.
  4. 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_ONLY grace list at src/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.

  1. Add a row to docs/PERMISSIONS.md §6 under the relevant page.
  2. If new, add to §4 (catalog) and §5 (role grants).
  3. Add a seed migration under supabase/migrations/ inserting the permission into Permission and granting it in RolePermission to the appropriate roles. Keep the migration idempotent (INSERT ... WHERE NOT EXISTS).
  4. Reference it in code:
  5. Route — <ProtectedRoute requiredPermissions={['x.y']}> in src/App.tsx.
  6. Action — <PermissionGate permission="x.y"> around the button / menu.
  7. Handler — await requirePermission('x.y') as the first line in the API handler in src/lib/api.ts.
  8. RLS — add RESTRICTIVE policies on the table(s) using auth_user_has_permission('x.y').
  9. Run npm test. The drift test will catch any orphan or missing link.

Renaming a permission

Same PR should:

  1. Update docs/PERMISSIONS.md.
  2. Grep and rename every code reference.
  3. Add a migration renaming the Permission.name and every RLS policy that cites it.
  4. npm test.

Deleting a permission

  1. Remove every code reference first.
  2. Remove from docs/PERMISSIONS.md §4 / §5.
  3. Add a migration that drops RolePermission rows and then the Permission row.
  4. 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.