Role-matrix audit runbook
1. Intro
This runbook drives a reproducible manual sweep of every role declared in
docs/PERMISSIONS.md §3 against the live app. The
goal is to validate that the deployed permission matrix matches real user
experience — i.e. every role sees exactly the pages / sections / buttons
§5 promises, and nothing more. Automated tests
(src/test/permissions.matrix.test.ts) catch structural drift between
code and PERMISSIONS.md, but they cannot catch behavioural drift:
conditional UI that renders a button the matrix says should be hidden, a
route guard that permits a role it shouldn't, or a server handler missing
requirePermission. Those only show up when a human logs in as each
role and clicks through.
Run this sweep any time docs/PERMISSIONS.md changes materially — a new
role added, a role renamed, a permission redistributed, or a new page /
section gated. It is not a gate on every PR (570+ unit tests already
cover that) — treat it as a quarterly-or-on-material-change audit.
The app is currently pre-production (no real customer data), so we drive the sweep with disposable test accounts seeded directly via the Supabase Admin API.
1.5. Automated API-level audit
Before the human sweep, run the automated API-level audit. It logs in as
every seeded test user and confirms the server-side permission gate
(auth_user_has_permission() plus RLS) agrees with docs/PERMISSIONS.md
§5 and §6 for every probe in a curated matrix.
# Env — uses the anon (publishable) key to sign in as each test user.
# No service role required for this script.
export SUPABASE_URL="https://<your-project>.supabase.co"
export SUPABASE_PUBLISHABLE_KEY="<your anon key>" # same as VITE_SUPABASE_ANON_KEY
# Prereq: test users must already be seeded (§2.1 below).
npm run audit:role-api
When to run:
- Before every release (catches API-layer drift introduced by new handlers or migrations).
- After any PR that touches
docs/PERMISSIONS.md, anysupabase/migrations/*permission*, or any newrequirePermission(...)call insrc/lib/api.ts. - When the static audit (
npm run audit:role-permissions) passes but you suspect a runtime discrepancy — this one actually fires the permission check as each user, which catches cases the static audit can't (e.g.UserPermissionoverrides, RLS policy bugs).
What it does:
- Reads the canonical role list from
docs/PERMISSIONS.md§3. - For each role, signs in via
supabase.auth.signInWithPassword(). - For each (role, probe) pair in the matrix (
~40probes ×~18roles ≈ 700+ checks), invokes the server RPCauth_user_has_permission(perm_name)using that user's JWT — the same checkrequirePermission('...')performs inside every mutating handler insrc/lib/api.ts. - Compares actual allow/deny to the expected outcome derived from §5 and §6, and records mismatches.
What it doesn't do:
- Does NOT replace the manual UI sweep below. It catches drift in the
API permission layer only. Route guards that render the wrong page,
<PermissionGate>wrappers that hide or expose the wrong button, sidebar filtering, and workflow rules (e.g. maker-checker on journals) are all outside its scope. - Does NOT test business-logic invariants, ownership-scoped portal endpoints beyond "AGENT/CUSTOMER hold zero staff permissions", or any write path's downstream behaviour. Every write-probe is designed to short-circuit before touching state (either denied by RLS or rejected as an invalid body).
- Does NOT exhaustively enumerate every permission in the catalog — it covers ~40 high-value boundaries. Wiring additional probes as new permission names land is expected.
Outputs:
docs/operations/role-api-audit-findings.md— human-readable report with per-permission mismatch tables and remediation guidance. Run the script; read this file.docs/operations/role-api-audit-snapshot.json— machine-readable snapshot (gitignored; regenerated each run).- Exit 0 on zero mismatches, exit 1 otherwise (so CI can gate on it).
2. Setup
2.1 Seed the test users
You'll need the service-role key for your dev Supabase project. Export
the env vars (see env.example for the full list) and run:
# Required
export SUPABASE_URL="https://<your-project>.supabase.co"
export SUPABASE_SERVICE_ROLE_KEY="<service-role jwt>"
# Optional — defaults are fine
# export TEST_USER_PASSWORD='AlhudaTest!2026'
# export TEST_USER_EMAIL_DOMAIN='test.alhuda.local'
npm run seed:test-users # 17 internal staff roles
npm run seed:test-portal-users # AGENT (partner) + CUSTOMER
Both scripts are idempotent — re-running is safe and will reset passwords
+ refresh metadata for any test user that already exists. If you add a
new role to docs/PERMISSIONS.md §3, re-run the staff script and it
will pick it up; removed roles leave stale test users behind (delete
those manually via the admin UI if you care).
The scripts print a summary table at the end. Paste the relevant row into §2.2 below so the auditor always has the latest credentials.
2.2 Test-user credentials
- Login URL (staff):
/auth - Login URL (partner portal):
/partner/auth - Login URL (customer portal):
/customer/auth - Shared password: value of
TEST_USER_PASSWORD(defaultAlhudaTest!2026)
| Role | |
|---|---|
| CEO | ceo@test.alhuda.local |
| GM | gm@test.alhuda.local |
| IT_ADMIN | it-admin@test.alhuda.local |
| ADMIN_HR | admin-hr@test.alhuda.local |
| SALES_MANAGER | sales-manager@test.alhuda.local |
| SALES_EXEC | sales-exec@test.alhuda.local |
| B2B_MANAGER | b2b-manager@test.alhuda.local |
| B2B_EXEC | b2b-exec@test.alhuda.local |
| OPS_MANAGER | ops-manager@test.alhuda.local |
| OPS_EXEC | ops-exec@test.alhuda.local |
| FINANCE_MANAGER | finance-manager@test.alhuda.local |
| ACCOUNTANT | accountant@test.alhuda.local |
| CASHIER | cashier@test.alhuda.local |
| TICKET_MANAGER | ticket-manager@test.alhuda.local |
| VISA_OFFICER | visa-officer@test.alhuda.local |
| AUDITOR | auditor@test.alhuda.local |
| AGENT (portal) | agent@test.alhuda.local |
| CUSTOMER (portal) | customer@test.alhuda.local |
These emails assume the default
TEST_USER_EMAIL_DOMAIN=test.alhuda.local. If you set a different domain the pattern is<role-lowercased-with-dashes>@<domain>.
3. Audit procedure — per role
For each role below, log in with the credentials from §2.2 and walk through each bucket. Check items in the Findings list as you confirm them; leave a note on anything that doesn't match.
Cross-reference against docs/PERMISSIONS.md §5
(role grants) and §6 (page-by-page action matrix) — that document is
authoritative; if you find a conflict between this runbook and
PERMISSIONS.md, PERMISSIONS.md wins and this runbook should be
updated.
3.1 CEO / GM / IT_ADMIN (super-admins)
All three behave identically — seeded every permission via
migration 20260416020000. Audit any one (spot-check the others with a
single page each).
Should see: - Every sidebar item: Dashboard, Inventory (Airline Blocks, FIT, B2B, Ground, Hotels, Visa), Sales (Leads, Requests, Quotations, Bookings), Groups, Finance, Customers, Business Partners, Suppliers, People, Workflow (Ops / Corrections / Finance / Tickets / WhatsApp / Communications / Internal Chat), Reports, Admin. - Every create / edit / delete button, including destructive ones (delete booking, delete hotel, delete supplier). - Admin → Permissions matrix page (edit allowed). - Finance → maker-checker override buttons (approve-own / reverse-own).
Should NOT see: nothing hidden — this is the bypass tier.
Workflows to exercise:
- Reopen a closed accounting period (Finance → Settings → Periods).
Verifies finance.periods.unlock works for super-admins even with no
role-name shortcut.
- Approve a journal you created yourself (maker-checker override).
Confirm the button is visible and succeeds — this distinguishes
super-admin from everyone else.
- Delete a custom role from Admin → Permissions. Non-super-admins must
not be able to do this (admin.roles.delete).
Findings: - [ ] All sidebar items visible - [ ] Every destructive action button visible - [ ] Can reopen closed period - [ ] Approve-own journal works - [ ] Delete custom role works
3.2 ADMIN_HR
Should see:
- Every staff page (bookings, finance, groups, hotels, etc.) — ADMIN_HR
is seeded every permission EXCEPT finance.journals.approve_own and
finance.journals.reverse_own.
- Admin → Permissions matrix, Users, Audit.
Should NOT see: - Approve-own-journal / reverse-own-journal buttons on a journal they created. The UI should hide those; the API must reject them.
Workflows to exercise: - Create a manual journal → try to approve it yourself → expect block with maker-checker error. - Create a staff user and assign a role. Reset their password. Delete them.
Findings: - [ ] Can access all staff pages - [ ] Approve-own-journal blocked with clear error - [ ] User management full CRUD works
3.4 SALES_MANAGER
Should see: - Sidebar: Dashboard, Sales (Leads, Requests, Quotations, Bookings), Groups, Customers, Reports. - Full CRUD on bookings. - Create / edit / delete customers. - Create quotations; create groups. - Group pricing edit; group invoice create / edit.
Should NOT see:
- Finance module (finance.view not granted).
- Admin page (admin.view not granted).
- Business Partners page (agents.view not granted).
- Suppliers, Hotels, Inventory, Visa, Tickets modules.
- Issue / cancel group invoice buttons (those are FINANCE_MANAGER-only).
Workflows to exercise:
- Create a quotation → convert to booking → submit for approval →
confirm Sales cannot approve its own submission.
- Create a group → edit pricing → create a draft invoice → verify
"Issue" button is disabled or hidden (requires group_invoices.issue).
Findings: - [ ] Finance sidebar item hidden - [ ] Admin sidebar item hidden - [ ] Cannot issue group invoice - [ ] Cannot approve own booking submission
3.5 SALES_EXEC
Should see: same as SALES_MANAGER except no customer-delete, no group-create, no reports-export, no group-pricing/invoice-edit.
Should NOT see: - Customer delete button (customers.delete is manager-only). - Group create button. - Export buttons on reports. - Group pricing edit, invoice create/edit.
Workflows to exercise:
- Create a booking → add passengers → add payment (expect
finance.create gate; SALES_EXEC doesn't have it so the "Add
Payment" button should be hidden).
- Try to delete a customer; expect no delete button.
Findings: - [ ] Cannot delete customer - [ ] Cannot create group - [ ] Cannot add payment on booking - [ ] Cannot export reports
3.6 B2B_MANAGER
Should see: - Business Partners (Agents) list + full CRUD. - Bookings full CRUD, customer create, quotations. - Reports with export. - Group pricing edit, invoice create/edit.
Should NOT see: - Finance module. - Admin module. - Suppliers, Hotels, Inventory. - Issue / cancel group invoice.
Workflows to exercise: - Create a new business partner with a login → log out → log in as that partner on the partner portal → verify isolation (sees only own data). - Edit partner commission rate; verify the field saves.
Findings: - [ ] Partner CRUD works - [ ] Partner login provisioning works end-to-end - [ ] Cannot access Finance
3.7 B2B_EXEC
Should see: Partners list (view only), bookings full CRUD, customer create, quotations.
Should NOT see: Partner create / delete, finance, reports export, group invoice actions beyond view.
Findings: - [ ] Partner list view-only (no Add button) - [ ] Bookings CRUD works - [ ] Finance module hidden
3.8 OPS_MANAGER
Should see: - Sidebar: Dashboard, Inventory (all subsections), Hotels, Groups, Workflow (Ops Approvals), Reports, Suppliers. - Approvals queue — can approve/reject ops-tier. - Suppliers full CRUD. - Hotels full CRUD; Inventory full CRUD. - Group create.
Should NOT see:
- Sales menu (bookings/customers/quotations/leads).
Note: bookings.view is granted, so they may see a bookings link —
if so, confirm list-view is the only thing they can do (no create,
edit, delete).
- Finance module.
- Admin module.
- Business Partners (agents).
Workflows to exercise:
- Approve an ops-submitted booking → verify it moves to finance stage.
- Create a hotel → assign it to a group → record a supplier payment
(expect gate: finance.create not granted, so payment flow blocked).
Findings: - [ ] Ops approvals queue works - [ ] Suppliers CRUD works - [ ] Cannot record supplier payment directly (Finance-only)
3.9 OPS_EXEC
Should see: Inventory view + edit, Hotels edit + export, Suppliers edit. Bookings view.
Should NOT see: Create anything from scratch (no create permissions except inventory.edit which covers some update flows); no approvals queue; no finance; no admin.
Findings: - [ ] Inventory view + edit works - [ ] Approvals queue hidden - [ ] Admin hidden
3.10 FINANCE_MANAGER
Should see: - Finance module — every tab (Accounts, Payments, Vouchers, Journals, Approvals, Cancellations, Reports, Settings, TDS). - Workflow → Finance Approval queue. - Group invoices: create, edit, issue, cancel. - Reports with export. - Bookings (view only).
Should NOT see: - Approve-own-journal / reverse-own override (super-admin only). - Admin module. - Sales create / edit pages.
Workflows to exercise: - Create a journal → ask ADMIN_HR to approve it → verify it posts to Day Book. - Try to approve a journal you created yourself → expect maker-checker rejection with a clear message. - Issue a draft group invoice → verify revenue + tax journal posts. - Cancel an issued invoice → verify reversing credit-note journal posts.
Findings: - [ ] All finance tabs visible - [ ] Approve-own journal blocked - [ ] Group invoice issue works - [ ] Group invoice cancel works (posts credit-note)
3.11 ACCOUNTANT
Should see: - Finance module (Accounts, Payments, Vouchers, Journals, Reports, Settings, TDS). - Group invoices: create, edit (no issue, no cancel). - Reports with export. - Bookings view.
Should NOT see:
- Finance → Approvals tab approve/reject buttons (approvals.approve
not granted — ACCOUNTANT can view but not act).
- Group invoice issue or cancel buttons.
- Reverse-own / approve-own journal buttons.
- Finance → Profit & Loss report (sectional permission
finance.reports.profit_loss.view not granted — see PERMISSIONS.md
§4.4 and §8 item 10 — this is the canonical "Accountant can see Trial
Balance but not P&L" example).
Workflows to exercise: - Record a payment → verify it lands in the approvals queue for a finance manager to act on. - Visit Finance → Reports → confirm P&L tab hidden, Trial Balance tab visible.
Findings: - [ ] Finance tabs visible but Approvals actions hidden - [ ] Group invoice issue/cancel hidden - [ ] P&L report hidden, Trial Balance visible
3.12 CASHIER
Should see: - Finance → Payments / Vouchers (only). - Bookings view.
Should NOT see:
- Accounts, Journals, Approvals, Reports, Settings, TDS — CASHIER has
finance.create + finance.payments.record only; no finance.edit,
no approvals.approve, no reports.export.
- Anything outside Finance (no Sales, no Admin, no Inventory).
Workflows to exercise: - Record a cash receipt against an agent → verify it posts but remains pending approval. - Try to access Finance → Accounts → expect 403 or redirect.
Findings: - [ ] Payments / Vouchers tabs visible - [ ] All other Finance tabs hidden - [ ] Sidebar shows only Finance + Bookings
3.13 TICKET_MANAGER
Should see: Tickets queue, ticket detail, sync tickets, bulk update names, request exception, approve exception, issue ticket. Reports with export. Bookings view.
Should NOT see: Everything else (no Sales, no Finance create, no Admin).
Workflows to exercise: - Bulk-update passenger names on a batch of tickets. - Request an exception → approve it → issue ticket → verify status transition.
Findings: - [ ] Tickets queue works - [ ] Approve exception + issue ticket both work - [ ] No Finance create capability
3.14 VISA_OFFICER
Should see: Visa pipeline, visa case detail, create case, update status, upload docs, delete visa docs. Bookings view.
Should NOT see: Finance, Sales create, Admin, Inventory.
Workflows to exercise: - Create a visa case for a passenger on a booking → upload passport → move through statuses → verify history log.
Findings: - [ ] Visa CRUD works - [ ] Bookings list visible but no edit - [ ] All other modules hidden
3.15 AUDITOR
Should see: Every *.view page (read-only across the board).
Reports with export.
Should NOT see: Any create / edit / delete button on any page.
Workflows to exercise: - Walk through every sidebar item. For each page, confirm the header has no "New / Add / Create" button and no row-level edit or delete. - Export a report → verify it downloads.
Findings: - [ ] Every page loads read-only - [ ] No create/edit/delete buttons anywhere - [ ] Report export works
3.16 AGENT (partner portal)
Log in at /partner/auth.
Should see:
- Partner dashboard with own KPIs only.
- Own bookings, own customers, own quotations.
- Partner wizard to create a booking (always on_account payment).
- Own invoices list; create / edit / print own invoice.
- Own reports (bookings, revenue, customers, invoices).
- Profile (edit contact person, manage sessions).
Should NOT see:
- Any staff page (/app, /sales, /finance, /admin redirect away).
- Other agents' data — ownership is enforced by the API.
Workflows to exercise:
- Create a booking via the partner wizard → confirm it shows up only
for this agent.
- Log in as a second agent (provision another via the admin UI) →
verify you cannot see the first agent's bookings.
- Try navigating directly to /app → expect redirect to partner
dashboard.
Findings:
- [ ] Partner dashboard loads
- [ ] Own data isolation holds
- [ ] Staff routes redirect away
- [ ] Wizard forces on_account payment method
3.17 CUSTOMER (customer portal)
Log in at /customer/auth.
Should see: - Own bookings, own invoices (download), own visa status / docs, own statement, own profile. - Accept / reject quotations sent to them. - Create / respond to service requests. - Upload payment proof with receipt.
Should NOT see: - Any staff page. - Other customers' data.
Workflows to exercise: - Open a quotation sent by staff → accept it → verify a booking is created and shows in Own Bookings. - Upload a payment proof against an invoice → verify it enters the finance approvals queue on the staff side.
Findings: - [ ] Customer dashboard loads - [ ] Quotation accept creates booking - [ ] Payment upload reaches staff approvals - [ ] Staff routes redirect away
4. Known drift
docs/PERMISSIONS.md §8 already tracks the structural drift items that
the automated drift test cannot catch (because the mismatch is a
deliberate not-yet-wired placeholder rather than a bug). Read §8 before
filing anything from this sweep, in case the issue is already
documented. Hot list:
/correctionsusesbookings.view(should becorrections.view)./communicationsand/whatsappusecustomers.view(should becommunications.view)./suppliersusesinventory.view(should besuppliers.view).- Several
*.delete/*.allocatepermissions are RLS-enforced but not UI-gated yet, so buttons remain visible. admin.users.*granular permissions exist in §4.4 but are not yet split fromadmin.view./appdashboard usesallowedRolesinstead ofdashboard.view.- Granular §4.4 permissions are seeded but mostly unwired; only the finance report button-filter currently honours them.
If the behaviour you observe matches one of these, note it against the existing §8 entry instead of opening a new issue.
5. How to file findings
For each unchecked or failing item above that is NOT already in §8:
- Open a GitHub issue with the title prefix
[role-audit]. - Tag it with the
permissions-driftlabel. - In the body include:
- Role affected (e.g.
ACCOUNTANT) - Expected behaviour (quoting the relevant row from
docs/PERMISSIONS.md§5 / §6) - Observed behaviour (what actually happened)
- Reproduction steps (login → page → action)
- Screenshot if the drift is a visible button that shouldn't render
If the fix involves changing the matrix itself, the issue becomes a
PR against docs/PERMISSIONS.md + the corresponding seed migration
under supabase/migrations/ + any <PermissionGate> / route-guard /
requirePermission code references. Run npm test before merging —
src/test/permissions.matrix.test.ts will fail otherwise.