Skip to content

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.


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 (default AlhudaTest!2026)
Role Email
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:

  • /corrections uses bookings.view (should be corrections.view).
  • /communications and /whatsapp use customers.view (should be communications.view).
  • /suppliers uses inventory.view (should be suppliers.view).
  • Several *.delete / *.allocate permissions 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 from admin.view.
  • /app dashboard uses allowedRoles instead of dashboard.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:

  1. Open a GitHub issue with the title prefix [role-audit].
  2. Tag it with the permissions-drift label.
  3. In the body include:
  4. Role affected (e.g. ACCOUNTANT)
  5. Expected behaviour (quoting the relevant row from docs/PERMISSIONS.md §5 / §6)
  6. Observed behaviour (what actually happened)
  7. Reproduction steps (login → page → action)
  8. 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.