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.

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>}
/>
  • 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()

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):

  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.

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_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.