Runbook: API permission matrix
Owner: Engineering lead + Security lead Severity for changes: every change to this matrix is a security change. Every PR that adds an endpoint to the API MUST update this file in the same PR.
What this is
A single-page list of every API endpoint that requires authentication, its tenant-scope, its actor-source, its role requirements, and its audit-emission expectation. Read this before:
- Adding a new endpoint.
- Triaging a "user X cannot access endpoint Y" support ticket.
- Onboarding a new engineer.
- Answering a partner-bank security questionnaire about RBAC.
Status legend
- member — Member.role =
'member'for the relevant Organization. - admin — Member.role =
'admin'. - owner — Member.role =
'owner'. - platform-admin —
AdminProfile.checkType = 'PLATFORM'exists for the user. @Public()— endpoint exempt from the global AuthGuard.- HMAC — endpoint authenticates the caller via HMAC signature, not via session.
- Tenant source — active-org —
req.user.businessIdfrom the better-auth active organization. - Tenant source —
:param— Member is checked against the path param's businessId. - Tenant source — payload — tenant comes from a body field whose value is verified independently (currently only the bank webhook, which carries
tenant_idfrom the partner).
The matrix
Auth surface
| Method + path | Auth | Tenant scope | Actor | Role | Audit |
|---|---|---|---|---|---|
* /api/auth/* (better-auth) | varies (own routes) | none | session | — | better-auth internal |
Health + metrics + banner
| Method + path | Auth | Tenant scope | Actor | Role | Audit |
|---|---|---|---|---|---|
GET /api/healthz | @Public | none | — | — | none |
GET /api/readyz | @Public | none | — | — | none |
GET /api/metrics | @Public | none | — | — | none |
GET /api | @Public | none | — | — | none |
Bank integration (webhooks)
| Method + path | Auth | Tenant scope | Actor | Role | Audit |
|---|---|---|---|---|---|
POST /api/integration/bank-callback/:partner | @Public + HMAC | payload (tenant_id from partner) | 'partner:<name>' | — | BankWebhookApplied |
The webhook controller does NOT go through AuthGuard or OrgRoleGuard; its security relies entirely on HMAC verification of ${timestamp}.${nonce}.${body} with a 5-minute clock skew window. See webhook-failure.md.
Business CRUD (/api/business)
Every endpoint requires authentication (AuthGuard). RBAC per-endpoint:
| Method + path | Tenant scope | Role | Audit |
|---|---|---|---|
GET /api/business | filtered server-side by user's memberships | any auth user | none (read) |
GET /api/business/:id | :id | member / admin / owner of :id OR platform-admin | none (read) |
GET /api/business/:id/employee-count | :id | member / admin / owner of :id OR platform-admin | none (read) |
GET /api/business/:id/onboarding-progress | :id | platform-admin only (inline assertPlatformAdmin) | none (read) |
POST /api/business | none (creating tenant) | platform-admin only (inline assertPlatformAdmin; pre-MFA gate to avoid AdminMfaGuard) | CreateBusiness |
PUT /api/business/:id | :id | admin / owner of :id OR platform-admin | UpdateBusiness (todo) |
PUT /api/business/:id/verify-kyc | :id | platform-admin only | VerifyBusinessKyc (todo) |
PUT /api/business/:id/status | :id | platform-admin only | ChangeBusinessStatus (todo) |
DELETE /api/business/:id | :id | owner of :id OR platform-admin | DeleteBusiness (todo) |
The (todo) audit emissions are tracked as a Phase B item — the controllers do not yet emit audit rows. Pilot launches without them only if explicitly accepted as a known gap.
Business — admin management (platform staff add/suspend/reset owners)
Platform-admin only. Inline gate (assertPlatformAdmin) instead of @RequirePlatformAdmin so the AdminMfaGuard isn't applied during pre-MFA onboarding.
| Method + path | Tenant scope | Role | Audit |
|---|---|---|---|
GET /api/business/:id/admins | :id | platform-admin only | none (read) |
POST /api/business/:id/admins | :id | platform-admin only | AddBusinessAdmin (todo) |
POST /api/business/:id/admins/:adminId/suspend | :id | platform-admin only | SuspendBusinessAdmin (todo) |
POST /api/business/:id/admins/:adminId/reactivate | :id | platform-admin only | ReactivateBusinessAdmin (todo) |
POST /api/business/:id/admins/:adminId/reset-password | :id | platform-admin only | none (sends email) |
Business — team-member management (org-internal invites)
Replaces the deleted /api/invitations module. Gate is either platform-admin or an owner/admin Member of THIS business — checked inline by assertAdminOfBusiness. Granted Demoz Role is chosen from the org's own catalog, not hardcoded.
| Method + path | Tenant scope | Role | Audit |
|---|---|---|---|
GET /api/business/:id/team-members | :id | platform-admin OR owner/admin Member of :id | none (read) |
GET /api/business/:id/team-members/:memberId | :id | platform-admin OR owner/admin Member of :id | none (read) |
POST /api/business/:id/team-members | :id | platform-admin OR owner/admin Member of :id | AddTeamMember (todo) |
PUT /api/business/:id/team-members/:memberId | :id | platform-admin OR owner/admin Member of :id | UpdateTeamMemberProfile (todo) |
DELETE /api/business/:id/team-members/:memberId | :id | platform-admin OR owner/admin Member of :id (pending invites only) | RemoveTeamMember (todo) |
POST /api/business/:id/team-members/:memberId/resend-setup | :id | platform-admin OR owner/admin Member of :id | none (sends email) |
POST /api/business/:id/team-members/:memberId/ban | :id | platform-admin OR owner/admin Member of :id; last active owner cannot be banned | BanTeamMember (todo) |
POST /api/business/:id/team-members/:memberId/unban | :id | platform-admin OR owner/admin Member of :id | UnbanTeamMember (todo) |
Business — KYC documents
Platform-admin only (KYC is a platform compliance surface).
| Method + path | Tenant scope | Role | Audit |
|---|---|---|---|
GET /api/business/:id/documents | :id | platform-admin only | none (read) |
PUT /api/business/:id/documents/:docType | :id | platform-admin only | SetBusinessDocument (todo) |
DELETE /api/business/:id/documents/:docType | :id | platform-admin only | RemoveBusinessDocument (todo) |
File uploads (/api/uploads)
POST /api/uploads accepts an optional ?tenantId=… query for the platform-admin path (used by admin-web when staff are managing a business that isn't their own active org).
| Method + path | Tenant scope | Role | Audit |
|---|---|---|---|
POST /api/uploads | active-org from session | member / admin / owner of active-org | none |
POST /api/uploads?tenantId=:id | :id from query | platform-admin only (inline check) | none |
GET /api/uploads/:tenantId/:key | :tenantId from path | member of :tenantId OR platform-admin | none |
The platform-admin path returns 404 if the target tenant doesn't exist (refuses to write to a ghost org); 403 if the caller isn't a platform admin. The member-of path returns 403 with NOT_A_MEMBER if the caller has no Member row for the active org.
FI — team-member management (org-internal invites)
Replicates the BUSINESS pattern via OrganizationService (kind-agnostic). Gate: platform-admin OR an owner/admin Member of THIS FI.
| Method + path | Tenant scope | Role | Audit |
|---|---|---|---|
GET /api/financial-institutions/:id/team-members | :id | platform-admin OR owner/admin of :id | none (read) |
GET /api/financial-institutions/:id/team-members/:memberId | :id | platform-admin OR owner/admin of :id | none (read) |
POST /api/financial-institutions/:id/team-members | :id | platform-admin OR owner/admin of :id | AddTeamMember (todo) |
PATCH /api/financial-institutions/:id/team-members/:memberId | :id | platform-admin OR owner/admin of :id | UpdateTeamMemberProfile (todo) |
POST /api/financial-institutions/:id/team-members/:memberId/resend-setup | :id | platform-admin OR owner/admin of :id | none (email) |
POST /api/financial-institutions/:id/team-members/:memberId/ban | :id | platform-admin OR owner/admin of :id (not last owner) | BanTeamMember (todo) |
POST /api/financial-institutions/:id/team-members/:memberId/unban | :id | platform-admin OR owner/admin of :id | UnbanTeamMember (todo) |
Merchant — team-member management (org-internal invites)
Same shape as the FI rows above, mounted at /api/merchants/:id/team-members/*. Same OrganizationService backend; same gate (assertAdminOfMerchant).
FI — KYC documents
Platform-admin only (KYC is a platform compliance surface). NBE licence, AML/CFT policy, articles of association, board resolution, etc. Catalog in apps/api/src/identity/financial-institution/fi-documents.ts. Persisted as JSON on Organization.kycDocuments.
| Method + path | Tenant scope | Role | Audit |
|---|---|---|---|
GET /api/financial-institutions/:id/documents | :id | platform-admin only | none (read) |
PUT /api/financial-institutions/:id/documents/:docType | :id | platform-admin only | SetFIDocument (todo) |
DELETE /api/financial-institutions/:id/documents/:docType | :id | platform-admin only | RemoveFIDocument (todo) |
Merchant — KYC documents
Platform-admin only. Trade licence, TIN, owner ID, settlement-account proof, etc. Catalog in apps/api/src/identity/merchant/merchant-documents.ts. Same shared Organization.kycDocuments column.
| Method + path | Tenant scope | Role | Audit |
|---|---|---|---|
GET /api/merchants/:id/documents | :id | platform-admin only | none (read) |
PUT /api/merchants/:id/documents/:docType | :id | platform-admin only | SetMerchantDocument (todo) |
DELETE /api/merchants/:id/documents/:docType | :id | platform-admin only | RemoveMerchantDocument (todo) |
Employee CRUD (/api/employees)
Every endpoint requires authentication. Tenant comes from the active organization unless the path explicitly carries :businessId.
| Method + path | Tenant scope | Role | Audit |
|---|---|---|---|
GET /api/employees | active-org | member / admin / owner | none (read) |
GET /api/employees/active/:businessId | :businessId | member / admin / owner of :businessId | none (read) |
GET /api/employees/department/:businessId/:department | :businessId | member / admin / owner of :businessId | none (read) |
GET /api/employees/:id | active-org (404 if cross-tenant) | member / admin / owner | none (read) |
POST /api/employees | active-org (force-set; body mismatch → 403) | admin / owner | CreateEmployee (todo) |
POST /api/employees/bulk-import | active-org (force-set; per-row mismatch → 403) | admin / owner | BulkImportEmployees (todo) |
PUT /api/employees/:id | active-org (404 if cross-tenant) | admin / owner | UpdateEmployee (todo) |
PUT /api/employees/:id/status | active-org | admin / owner | ChangeEmployeeStatus (todo) |
DELETE /api/employees/:id | active-org | admin / owner | DeleteEmployee (todo, soft delete) |
EWA (/api/ewa)
Per A2/D3 pilot-temporary restriction: all endpoints admin/owner only until Employee.userId lands (Phase B).
| Method + path | Tenant scope | Role | Audit |
|---|---|---|---|
GET /api/ewa/eligibility | active-org | admin / owner | none (read-through cache) |
POST /api/ewa/requests | active-org | admin / owner | RequestEwa |
POST /api/ewa/requests/:id/disburse | active-org | admin / owner | DisburseEwa |
Lending (/api/loans)
Per A2/D3 pilot-temporary restriction.
| Method + path | Tenant scope | Role | Audit |
|---|---|---|---|
POST /api/loans/quote | none (compute) | admin / owner | none (idempotent compute) |
POST /api/loans | active-org | admin / owner | RequestLoan |
POST /api/loans/:id/disburse | active-org | admin / owner | DisburseLoan |
POST /api/loans/:id/installments/:idx/record-repayment | active-org | admin / owner | RecordLoanRepayment |
Payroll subroutes (/api/payroll)
The payroll-run create/approve/verify endpoints are not yet shipped. The currently-exposed payroll surface is read-only PDFs + court-order remit + tenant config:
| Method + path | Tenant scope | Role | Permission | Audit |
|---|---|---|---|---|
GET /api/payroll/:id/settlement | active-org | admin / owner | — | none (read) |
GET /api/payroll/:id/audit | active-org | admin / owner | — | none (read) |
GET /api/payroll/:id/payslips/:employeeId.pdf | active-org | admin / owner | payroll.view | none (read; PDF) |
GET /api/payroll/:id/payslips.zip | active-org | admin / owner | payroll.view | none (read; bulk) |
GET /api/payroll/:id/report.court-orders.json | active-org | admin / owner | payroll.view | none (read) |
GET /api/payroll/:id/report.court-orders.xml | active-org | admin / owner | payroll.view | none (read) |
POST /api/payroll/:id/report.court-orders/submit | active-org | admin / owner | payroll.approve | SubmitCourtOrderRemit |
POST /api/payroll/auto-lock-policy | active-org | admin / owner | payroll.edit | UpsertAutoLockPolicy (todo) |
DELETE /api/payroll/auto-lock-policy | active-org | admin / owner | payroll.edit | DeleteAutoLockPolicy (todo) |
POST /api/payroll/tenant-settings | active-org | admin / owner | payroll.edit | UpsertPayrollSettings (todo) |
DELETE /api/payroll/tenant-settings | active-org | admin / owner | payroll.edit | DeletePayrollSettings (todo) |
Employee compensation catalogs
The "what kinds of allowances/deductions can a business pay?" catalogs. All gated on employees.view (or employees.edit for mutations) via @RequirePermission:
| Method + path | Tenant scope | Role | Permission | Audit |
|---|---|---|---|---|
GET /api/departments, GET /api/positions | active-org | admin / owner | employees.view | none (read) |
POST /api/departments, POST /api/positions | active-org | admin / owner | employees.add | CreateDepartment / CreatePosition (todo) |
PUT/DELETE /api/departments/:id, /api/positions/:id | active-org | admin / owner | employees.edit / .delete | (todo) |
GET /api/employment-types, GET /api/payment-frequencies | active-org | admin / owner | employees.view | none (read) |
POST/PUT/DELETE /api/employment-types/*, /api/payment-frequencies/* | active-org | admin / owner | employees.edit | (todo) |
GET /api/allowances, GET /api/deductions | active-org | admin / owner | allowances.view / deductions.view | none (read) |
POST/PUT/DELETE /api/allowances/*, /api/deductions/* | active-org | admin / owner | allowances.edit / deductions.edit | (todo) |
* /api/employees/:employeeId/allowances | active-org | admin / owner | allowances.view / .edit | (todo) |
* /api/employees/:employeeId/deductions | active-org | admin / owner | deductions.view / .edit | (todo) |
Members + self-service (/api/members, /api/me/*)
| Method + path | Tenant scope | Role | Audit |
|---|---|---|---|
GET /api/members/me | active-org | any auth user (returns own membership) | none (read) |
GET /api/members/:userId | active-org | admin / owner of active-org | none (read) |
PATCH /api/members/:userId/roles | active-org | admin / owner of active-org | UpdateMemberRoles (todo) |
DELETE /api/members/:userId | active-org | admin / owner of active-org | RemoveMember (todo) |
* /api/me/equb/* | active-org | any auth user (server-side filters by own memberId) | per use-case |
How the guards compose
┌─────────────────────────────────────────────────────────────┐
│ HTTP request │
└──────────────────┬──────────────────────────────────────────┘
▼
SessionMiddleware (where applied)
— populates req.user from better-auth cookie
│
▼
TenantContextMiddleware (where applied)
— 400 if req.headers['x-actor-id'] present ← A1 / GL-01
— sets AsyncLocalStorage {tenantId, actorId}
│
▼
AuthGuard (APP_GUARD)
— checks @Public()
— 401 unless req.user.id
│
▼
OrgRoleGuard (APP_GUARD)
— checks @Public()
— reads @RequireOrgRole / @RequirePlatformAdmin metadata
— short-circuit if platform-admin
— looks up Member(userId, organizationId) OR
AdminProfile(userId, 'PLATFORM')
— 403 with specific code:
PLATFORM_ADMIN_REQUIRED
TENANT_CONTEXT_MISSING
NOT_A_MEMBER
INSUFFICIENT_ROLE
│
▼
PermissionGuard (APP_GUARD) ← granular layer (NEW)
— checks @Public()
— reads @RequirePermission(module, action) metadata
— joins req.member.appRoles → Role.permissions[module]
— 403 PERMISSION_DENIED if module/action not in the union
— short-circuit if platform-admin (full kit)
│
▼
Controller handler runs
Three layers, three different questions:
OrgRoleGuardasks "is this caller a member of this org with at least role X?" (coarse — owner/admin/member).PermissionGuardasks "does any of this caller's assigned Roles grantmodule.action?" (granular —payroll.approve,employees.delete, …).- Inline gates (e.g.
assertAdminOfBusinessonOrganizationController) ask "is this caller a platform-admin OR an owner/admin Member of THIS specific business?" Used where we want to skip@RequirePlatformAdmin's associatedAdminMfaGuard(Demoz platform admin onboarding is pre-MFA).
Adding a new endpoint — checklist
Before merging:
- Choose the right RBAC decorator (coarse layer):
@Public()— only for health probes, metrics, public banner, signed webhooks.@RequireOrgRole('role', 'role')— the tenant is the user's active org.@RequireOrgRole(['role'], { fromParam: 'paramName' })— the tenant is the path param (cross-org access).@RequirePlatformAdmin()— only for platform-level operations that don't break the AdminMfaGuard pre-MFA path. If your endpoint runs during platform-admin onboarding, prefer an inlineassertPlatformAdminlikeOrganizationControllerdoes.- No decorator (with AuthGuard) — every authenticated user is allowed. Use only when followed by server-side filtering (
GET /api/business,GET /api/me/*).
- If your endpoint maps to a permission module/action in the catalog, add
@RequirePermission(module, action)(granular layer). Examples:payroll.approve,employees.delete,paye_tax.export. New permission modules are declared inpackages/shared/auth/src/lib/permissions.ts— keep the catalog in sync withMODULES_BY_KINDfor the org kind that will assign it. - Add the endpoint to this matrix in the same PR.
- If the endpoint mutates state, ensure the service layer emits an audit row in the same transaction (ADR-008).
- If the endpoint accepts a
businessIdin body OR path, the service MUST verify the value matches the caller's tenant. Never trust the body. - Write a unit test that asserts the guard rejects a cross-tenant attempt.
- If the endpoint takes PII, ensure its DTO fields are covered by the Pino redact list in
apps/api/src/_infra/observability/pino-logger.ts.
Known gaps (tracked as Phase B / pilot follow-ups)
- Employee.userId link — currently Employee is not linked to a better-auth User. As a consequence, an employee cannot act on their own EWA/Loan from their own session. The D3 pilot-temporary restriction routes everything through admin/owner. Lifting this requires a schema migration (Phase B).
- Audit emission on Business / Employee CRUD — controllers do not yet emit audit rows. RBAC denies unauthorized access, but successful authorized writes are not auditable. Phase B item.
- OrgRoleGuard caching — the guard does 1-2 DB lookups per RBAC-protected route. At pilot scale this is fine. At production scale, add a Redis-backed per-(user, org) role cache with 60s TTL.
- Platform-admin seeding —
AdminProfile.checkType='PLATFORM'rows are not seeded by any migration. Operational note: bootstrap the first platform-admin via SQL (infra/sql/bootstrap-platform-admin.sql, todo). Until then,POST /api/business,PUT /verify-kyc,PUT /statusare unreachable from the API surface. Member.rolebackfill — any migration that leavesMember.roleNULL or with an unknown value will lock the affected user out of role-checked endpoints. Always backfill role values in the same migration that creates Member rows.
Verification (mandatory pre-deploy SQL audit — V9)
Before any deploy that promotes RBAC code:
-- Every Member must have a non-null role recognised by the guard.
SELECT COUNT(*) FROM "Member" WHERE role IS NULL;
-- expected: 0
SELECT DISTINCT role FROM "Member";
-- expected: subset of {'owner', 'admin', 'member'}
-- Every Business must have a paired Organization.
SELECT b.id FROM "Business" b
LEFT JOIN "Organization" o ON o.id = b.id
WHERE o.id IS NULL;
-- expected: empty result
-- Bootstrap at least one platform admin.
SELECT COUNT(*) FROM "AdminProfile" WHERE "checkType" = 'PLATFORM';
-- expected: ≥ 1 (else POST /api/business is unreachable)
If any of these audits fail, the deploy is blocked.
Related
apps/api/src/identity/auth/org-role.guard.ts— coarse-layer enforcement (@RequireOrgRole,@RequirePlatformAdmin).apps/api/src/identity/auth/org-role.guard.spec.ts— 11 unit tests covering every branch in this matrix.apps/api/src/identity/auth/permission.guard.ts— granular-layer enforcement (@RequirePermission(module, action)). Readsreq.member.appRoles→ joinsRole.permissions→ 403PERMISSION_DENIED.apps/api/src/identity/auth/current-member.decorator.ts—@CurrentMember()+@CurrentUserId()param decorators.packages/shared/auth/src/lib/rbac.ts—RequireOrgRole+RequirePlatformAdmin+RequirePermissiondecorators.packages/shared/auth/src/lib/permissions.ts— catalog:statement(module → actions),MODULES_BY_KIND(per-kind allowlist),buildOrgAdminMinimalPermissions(full kit for theorganization_adminRole).apps/api/src/identity/organization/organization.controller.ts— illustrates the inlineassertPlatformAdmin/assertAdminOfBusinesspattern for endpoints that need to skip AdminMfaGuard.apps/api/src/identity/tenant/tenant-context.middleware.ts— actor + tenant resolution, x-actor-id rejection.docs/plans/PERMISSION_ENFORCEMENT_PLAN.md— the 3-layer model (backend gates → frontend page guards → sidebar visibility) and how the granular layer rolls out per portal.- ADR-013 (tenant isolation), GL-01, GL-02, GL-03 in
docs/architecture/GO_LIVE_BLOCKERS.md.