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):
- Bypass roles — if the user has CEO, GM, or IT_ADMIN, allow.
- Explicit user-level deny —
UserPermission.allowed = falsefor that permission, deny. (Deny wins.) - Explicit user-level grant —
UserPermission.allowed = true, allow. - Role-based grant — any of the user's
UserRolerows grants the permission viaRolePermission, allow. - 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
- Add a row in a NEW seed migration (don't edit the old ones — additive only):
- Grant to the appropriate roles in the same migration.
- Use it in the UI:
<PermissionGate permission="invoices.void">…</PermissionGate> - Use it in the API handler:
await requirePermission('invoices.void'); - 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.tscovers the 4 resolution cases forrequirePermission()src/test/harness/mockSupabase.tsexposessetUser({ 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