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.


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, any supabase/migrations/*permission*, or any new requirePermission(...) call in src/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. UserPermission overrides, 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 (~40 probes × ~18 roles ≈ 700+ checks), invokes the server RPC auth_user_has_permission(perm_name) using that user's JWT — the same check requirePermission('...') performs inside every mutating handler in src/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 (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.