Skip to main content

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-adminAdminProfile.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-orgreq.user.businessId from 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_id from the partner).

The matrix

Auth surface

Method + pathAuthTenant scopeActorRoleAudit
* /api/auth/* (better-auth)varies (own routes)nonesessionbetter-auth internal

Health + metrics + banner

Method + pathAuthTenant scopeActorRoleAudit
GET /api/healthz@Publicnonenone
GET /api/readyz@Publicnonenone
GET /api/metrics@Publicnonenone
GET /api@Publicnonenone

Bank integration (webhooks)

Method + pathAuthTenant scopeActorRoleAudit
POST /api/integration/bank-callback/:partner@Public + HMACpayload (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 + pathTenant scopeRoleAudit
GET /api/businessfiltered server-side by user's membershipsany auth usernone (read)
GET /api/business/:id:idmember / admin / owner of :id OR platform-adminnone (read)
GET /api/business/:id/employee-count:idmember / admin / owner of :id OR platform-adminnone (read)
GET /api/business/:id/onboarding-progress:idplatform-admin only (inline assertPlatformAdmin)none (read)
POST /api/businessnone (creating tenant)platform-admin only (inline assertPlatformAdmin; pre-MFA gate to avoid AdminMfaGuard)CreateBusiness
PUT /api/business/:id:idadmin / owner of :id OR platform-adminUpdateBusiness (todo)
PUT /api/business/:id/verify-kyc:idplatform-admin onlyVerifyBusinessKyc (todo)
PUT /api/business/:id/status:idplatform-admin onlyChangeBusinessStatus (todo)
DELETE /api/business/:id:idowner of :id OR platform-adminDeleteBusiness (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 + pathTenant scopeRoleAudit
GET /api/business/:id/admins:idplatform-admin onlynone (read)
POST /api/business/:id/admins:idplatform-admin onlyAddBusinessAdmin (todo)
POST /api/business/:id/admins/:adminId/suspend:idplatform-admin onlySuspendBusinessAdmin (todo)
POST /api/business/:id/admins/:adminId/reactivate:idplatform-admin onlyReactivateBusinessAdmin (todo)
POST /api/business/:id/admins/:adminId/reset-password:idplatform-admin onlynone (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 + pathTenant scopeRoleAudit
GET /api/business/:id/team-members:idplatform-admin OR owner/admin Member of :idnone (read)
GET /api/business/:id/team-members/:memberId:idplatform-admin OR owner/admin Member of :idnone (read)
POST /api/business/:id/team-members:idplatform-admin OR owner/admin Member of :idAddTeamMember (todo)
PUT /api/business/:id/team-members/:memberId:idplatform-admin OR owner/admin Member of :idUpdateTeamMemberProfile (todo)
DELETE /api/business/:id/team-members/:memberId:idplatform-admin OR owner/admin Member of :id (pending invites only)RemoveTeamMember (todo)
POST /api/business/:id/team-members/:memberId/resend-setup:idplatform-admin OR owner/admin Member of :idnone (sends email)
POST /api/business/:id/team-members/:memberId/ban:idplatform-admin OR owner/admin Member of :id; last active owner cannot be bannedBanTeamMember (todo)
POST /api/business/:id/team-members/:memberId/unban:idplatform-admin OR owner/admin Member of :idUnbanTeamMember (todo)

Business — KYC documents

Platform-admin only (KYC is a platform compliance surface).

Method + pathTenant scopeRoleAudit
GET /api/business/:id/documents:idplatform-admin onlynone (read)
PUT /api/business/:id/documents/:docType:idplatform-admin onlySetBusinessDocument (todo)
DELETE /api/business/:id/documents/:docType:idplatform-admin onlyRemoveBusinessDocument (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 + pathTenant scopeRoleAudit
POST /api/uploadsactive-org from sessionmember / admin / owner of active-orgnone
POST /api/uploads?tenantId=:id:id from queryplatform-admin only (inline check)none
GET /api/uploads/:tenantId/:key:tenantId from pathmember of :tenantId OR platform-adminnone

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 + pathTenant scopeRoleAudit
GET /api/financial-institutions/:id/team-members:idplatform-admin OR owner/admin of :idnone (read)
GET /api/financial-institutions/:id/team-members/:memberId:idplatform-admin OR owner/admin of :idnone (read)
POST /api/financial-institutions/:id/team-members:idplatform-admin OR owner/admin of :idAddTeamMember (todo)
PATCH /api/financial-institutions/:id/team-members/:memberId:idplatform-admin OR owner/admin of :idUpdateTeamMemberProfile (todo)
POST /api/financial-institutions/:id/team-members/:memberId/resend-setup:idplatform-admin OR owner/admin of :idnone (email)
POST /api/financial-institutions/:id/team-members/:memberId/ban:idplatform-admin OR owner/admin of :id (not last owner)BanTeamMember (todo)
POST /api/financial-institutions/:id/team-members/:memberId/unban:idplatform-admin OR owner/admin of :idUnbanTeamMember (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 + pathTenant scopeRoleAudit
GET /api/financial-institutions/:id/documents:idplatform-admin onlynone (read)
PUT /api/financial-institutions/:id/documents/:docType:idplatform-admin onlySetFIDocument (todo)
DELETE /api/financial-institutions/:id/documents/:docType:idplatform-admin onlyRemoveFIDocument (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 + pathTenant scopeRoleAudit
GET /api/merchants/:id/documents:idplatform-admin onlynone (read)
PUT /api/merchants/:id/documents/:docType:idplatform-admin onlySetMerchantDocument (todo)
DELETE /api/merchants/:id/documents/:docType:idplatform-admin onlyRemoveMerchantDocument (todo)

Employee CRUD (/api/employees)

Every endpoint requires authentication. Tenant comes from the active organization unless the path explicitly carries :businessId.

Method + pathTenant scopeRoleAudit
GET /api/employeesactive-orgmember / admin / ownernone (read)
GET /api/employees/active/:businessId:businessIdmember / admin / owner of :businessIdnone (read)
GET /api/employees/department/:businessId/:department:businessIdmember / admin / owner of :businessIdnone (read)
GET /api/employees/:idactive-org (404 if cross-tenant)member / admin / ownernone (read)
POST /api/employeesactive-org (force-set; body mismatch → 403)admin / ownerCreateEmployee (todo)
POST /api/employees/bulk-importactive-org (force-set; per-row mismatch → 403)admin / ownerBulkImportEmployees (todo)
PUT /api/employees/:idactive-org (404 if cross-tenant)admin / ownerUpdateEmployee (todo)
PUT /api/employees/:id/statusactive-orgadmin / ownerChangeEmployeeStatus (todo)
DELETE /api/employees/:idactive-orgadmin / ownerDeleteEmployee (todo, soft delete)

EWA (/api/ewa)

Per A2/D3 pilot-temporary restriction: all endpoints admin/owner only until Employee.userId lands (Phase B).

Method + pathTenant scopeRoleAudit
GET /api/ewa/eligibilityactive-orgadmin / ownernone (read-through cache)
POST /api/ewa/requestsactive-orgadmin / ownerRequestEwa
POST /api/ewa/requests/:id/disburseactive-orgadmin / ownerDisburseEwa

Lending (/api/loans)

Per A2/D3 pilot-temporary restriction.

Method + pathTenant scopeRoleAudit
POST /api/loans/quotenone (compute)admin / ownernone (idempotent compute)
POST /api/loansactive-orgadmin / ownerRequestLoan
POST /api/loans/:id/disburseactive-orgadmin / ownerDisburseLoan
POST /api/loans/:id/installments/:idx/record-repaymentactive-orgadmin / ownerRecordLoanRepayment

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 + pathTenant scopeRolePermissionAudit
GET /api/payroll/:id/settlementactive-orgadmin / ownernone (read)
GET /api/payroll/:id/auditactive-orgadmin / ownernone (read)
GET /api/payroll/:id/payslips/:employeeId.pdfactive-orgadmin / ownerpayroll.viewnone (read; PDF)
GET /api/payroll/:id/payslips.zipactive-orgadmin / ownerpayroll.viewnone (read; bulk)
GET /api/payroll/:id/report.court-orders.jsonactive-orgadmin / ownerpayroll.viewnone (read)
GET /api/payroll/:id/report.court-orders.xmlactive-orgadmin / ownerpayroll.viewnone (read)
POST /api/payroll/:id/report.court-orders/submitactive-orgadmin / ownerpayroll.approveSubmitCourtOrderRemit
POST /api/payroll/auto-lock-policyactive-orgadmin / ownerpayroll.editUpsertAutoLockPolicy (todo)
DELETE /api/payroll/auto-lock-policyactive-orgadmin / ownerpayroll.editDeleteAutoLockPolicy (todo)
POST /api/payroll/tenant-settingsactive-orgadmin / ownerpayroll.editUpsertPayrollSettings (todo)
DELETE /api/payroll/tenant-settingsactive-orgadmin / ownerpayroll.editDeletePayrollSettings (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 + pathTenant scopeRolePermissionAudit
GET /api/departments, GET /api/positionsactive-orgadmin / owneremployees.viewnone (read)
POST /api/departments, POST /api/positionsactive-orgadmin / owneremployees.addCreateDepartment / CreatePosition (todo)
PUT/DELETE /api/departments/:id, /api/positions/:idactive-orgadmin / owneremployees.edit / .delete(todo)
GET /api/employment-types, GET /api/payment-frequenciesactive-orgadmin / owneremployees.viewnone (read)
POST/PUT/DELETE /api/employment-types/*, /api/payment-frequencies/*active-orgadmin / owneremployees.edit(todo)
GET /api/allowances, GET /api/deductionsactive-orgadmin / ownerallowances.view / deductions.viewnone (read)
POST/PUT/DELETE /api/allowances/*, /api/deductions/*active-orgadmin / ownerallowances.edit / deductions.edit(todo)
* /api/employees/:employeeId/allowancesactive-orgadmin / ownerallowances.view / .edit(todo)
* /api/employees/:employeeId/deductionsactive-orgadmin / ownerdeductions.view / .edit(todo)

Members + self-service (/api/members, /api/me/*)

Method + pathTenant scopeRoleAudit
GET /api/members/meactive-organy auth user (returns own membership)none (read)
GET /api/members/:userIdactive-orgadmin / owner of active-orgnone (read)
PATCH /api/members/:userId/rolesactive-orgadmin / owner of active-orgUpdateMemberRoles (todo)
DELETE /api/members/:userIdactive-orgadmin / owner of active-orgRemoveMember (todo)
* /api/me/equb/*active-organy 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:

  1. OrgRoleGuard asks "is this caller a member of this org with at least role X?" (coarse — owner/admin/member).
  2. PermissionGuard asks "does any of this caller's assigned Roles grant module.action?" (granular — payroll.approve, employees.delete, …).
  3. Inline gates (e.g. assertAdminOfBusiness on OrganizationController) ask "is this caller a platform-admin OR an owner/admin Member of THIS specific business?" Used where we want to skip @RequirePlatformAdmin's associated AdminMfaGuard (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 inline assertPlatformAdmin like OrganizationController does.
    • 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 in packages/shared/auth/src/lib/permissions.ts — keep the catalog in sync with MODULES_BY_KIND for 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 businessId in 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)

  1. 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).
  2. 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.
  3. 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.
  4. Platform-admin seedingAdminProfile.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 /status are unreachable from the API surface.
  5. Member.role backfill — any migration that leaves Member.role NULL 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.

  • 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)). Reads req.member.appRoles → joins Role.permissions → 403 PERMISSION_DENIED.
  • apps/api/src/identity/auth/current-member.decorator.ts@CurrentMember() + @CurrentUserId() param decorators.
  • packages/shared/auth/src/lib/rbac.tsRequireOrgRole + RequirePlatformAdmin + RequirePermission decorators.
  • packages/shared/auth/src/lib/permissions.ts — catalog: statement (module → actions), MODULES_BY_KIND (per-kind allowlist), buildOrgAdminMinimalPermissions (full kit for the organization_admin Role).
  • apps/api/src/identity/organization/organization.controller.ts — illustrates the inline assertPlatformAdmin / assertAdminOfBusiness pattern 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.