Skip to content

Roles & Permissions

Superseded — kept for history only

This page was written in April 2026 and is not a description of how the system works today. It is kept so the reasoning behind early decisions stays readable. Current behaviour is described in PERMISSIONS.md and Access control (ACC). Do not build from this page.

This document describes how authorization works in the app. There are three layers that all enforce the same model: UI (PermissionGate), API (requirePermission() in src/lib/api.ts), and DB (RLS policies).

Roles

Defined as the RoleType Postgres enum (18 values). Stored in the Role table and assigned to users via UserRole(userId, roleId).

Bypass tier — full access, skip all permission checks

  • CEO — top-level executive
  • GM — General Manager
  • IT_ADMIN — system administrator

Manager tier — scoped admin in their domain

  • SALES_MANAGER — sales team lead
  • B2B_MANAGER — business-partner channel lead
  • OPS_MANAGER — operations lead (hotels, ground, food)
  • FINANCE_MANAGER — finance team lead
  • TICKET_MANAGER — ticketing lead
  • ADMIN_HR — HR + people operations

Executive tier — operational, more limited

  • SALES_EXEC — sales team member
  • B2B_EXEC — partner-channel team member
  • OPS_EXEC — operations team member
  • ACCOUNTANT — finance day-to-day
  • CASHIER — payment posting only
  • VISA_OFFICER — visa pipeline owner
  • AUDITOR — read-only review across all modules

External tier

  • AGENT — B2B partners with their own portal
  • CUSTOMER — end users with the customer portal

Permissions

32 named permissions across the modules. The canonical source is the combination of these three migrations:

  • supabase/migrations/20260115000000_seed_ui_permissions.sql (22 initial)
  • supabase/migrations/20260402210000_add_groups_view_edit_permissions.sql (2 group-related)
  • supabase/migrations/20260415210000_seed_orphan_permissions.sql (10 added when the new auth model landed)

Naming convention: <module>.<action>, e.g. bookings.create, finance.edit.

Resolution order

Identical in all three enforcement layers (UI / API / DB):

  1. Bypass roles — if the user has CEO, GM, or IT_ADMIN, allow.
  2. Explicit user-level deny — UserPermission.allowed = false for that permission, deny. (Deny wins.)
  3. Explicit user-level grant — UserPermission.allowed = true, allow.
  4. Role-based grant — any of the user's UserRole rows grants the permission via RolePermission, allow.
  5. Otherwise, deny.

Default role grants (from the seed migrations)

Adjust as your team's RACI evolves; these are starting points:

Role Permissions granted
CEO, GM, ADMIN_HR, IT_ADMIN All seeded permissions (admin tier)
SALES_MANAGER customers.edit, customers.delete
SALES_EXEC customers.edit
B2B_MANAGER partners.create, partners.edit, partners.delete
B2B_EXEC partners.edit
OPS_MANAGER suppliers.create, suppliers.edit, suppliers.delete
OPS_EXEC suppliers.edit
FINANCE_MANAGER, ACCOUNTANT finance.edit

How to add a new permission

  1. Add a row in a NEW seed migration (don't edit the old ones — additive only):
    INSERT INTO public."Permission" (id, module, action, name)
    SELECT gen_random_uuid(), 'invoices', 'void', 'invoices.void'
    WHERE NOT EXISTS (SELECT 1 FROM public."Permission" WHERE name = 'invoices.void');
    
  2. Grant to the appropriate roles in the same migration.
  3. Use it in the UI: <PermissionGate permission="invoices.void">…</PermissionGate>
  4. Use it in the API handler: await requirePermission('invoices.void');
  5. Optionally: add an RLS RESTRICTIVE policy that calls auth_user_has_permission('invoices.void').

How to assign roles

Use Admin → Permissions Matrix. That UI writes to UserRole + RolePermission which is the system of record.

The old People → User Management page edits a deprecated profiles.role enum that the new auth model ignores. It now displays a deprecation banner and its mutation controls are disabled. It will be removed in a future release.

Bypassing roles for service work

The service_role key (used by Supabase admin scripts and the deploy pipeline) bypasses RLS entirely. The requirePermission() helper at the API layer also short-circuits because such requests don't carry a user JWT.

Testing the model

  • src/lib/api.permissions.test.ts covers the 4 resolution cases for requirePermission()
  • src/test/harness/mockSupabase.ts exposes setUser({ id, permissions? }) for tests that need to scope a user's permissions
  • The default test user has CEO bypass, so existing tests don't need to worry about permissions unless they explicitly want to test denial