Permissions & Maker-Checker
4-layer hybrid permission engine, RBAC management API, Redis caching, and multi-level approval workflows.
The system implements a hybrid permission model combining role-based access control (RBAC) with conditional rules, amount thresholds, and multi-level approval workflows. The permission engine is a pure function in packages/shared — no I/O, runs identically on backend and frontend.
Permission Engine
Decision Flow
Request
→ L0: System Admin Bypass (roleSlug === "SYSTEM_ADMIN" → allow)
→ L1: Base Permission Matrix (role × entity × action → deny if missing)
→ L2b: VALIDATION Rules (tenant-wide hard blocks → deny if match)
→ L2b: PERMISSION Rules (role-specific overrides → first match wins)
→ L2a: Amount Thresholds (currency + amount range → strict deny if no match)
→ Fallback: L1 resultEach layer can short-circuit. The engine returns a PermissionResult:
{
allowed: boolean;
requiredLevels?: number; // 0-3 approval levels needed
reason?: string;
meta?: {
layer?: "L0" | "L1" | "L2a" | "L2b";
matchedRuleId?: string;
thresholdApplied?: boolean;
}
}Layer 0 — System Admin Bypass
If actor.roleSlug === "SYSTEM_ADMIN", the engine returns { allowed: true, requiredLevels: 0 } immediately. No further evaluation occurs.
Layer 1 — Base Permission Matrix (Gatekeeper)
Looks up cache.permissions[entityType][action]. If the entry is missing or allowed === false, access is denied immediately. This is the hard floor — Layers 2a/2b can only refine what Layer 1 already allows.
Each entry also carries a requiredLevel (0 = no approval needed, 1–3 = needs that many approval levels).
Layer 2b — Condition-Based Rules
Two subtypes, both evaluated before thresholds:
VALIDATION rules (tenant-wide, no role binding):
- Hard blocks that apply to all roles. If conditions match → deny with a custom
validationMessage. - Example: "No transactions on holidays."
PERMISSION rules (role-specific):
- Override base permissions when conditions match. Can change
requiredLevelsandcanCreate/canApprove_l1/l2/l3. - Sorted by priority — first match wins.
- Example: "High-risk customers need Level 3 approval."
Condition evaluation supports operators: EQ, NE, GT, LT, IN, NOT_IN, CONTAINS combined with AND/OR logical operators. Missing data for a condition = condition fails (strict).
Layer 2a — Amount Thresholds
Only runs when data.amount is present. Requires data.currency. Finds the matching threshold by currency + min/max amount range. If found, maps the action to the threshold's canCreate/canApprove_l1/l2/l3 booleans. If no matching range exists, access is denied (strict mode).
Overlapping ranges for the same role/entity/currency are rejected at creation time.
Permission Context
Every permission check receives:
{
actor: { userId, roleId, roleSlug, tenantId, branchId?, branchRestrict? }
resource: { entityType, entityId? }
action: string // "create" | "read" | "update" | "delete" | "export" | "approve_l1" | "approve_l2" | "approve_l3"
data?: { amount?, currency?, ...fields }
}Entity Registry
The system defines 38 entity types across these categories:
| Category | Examples |
|---|---|
| Identity & Access | role, permission_matrix, transaction_threshold, transaction_rule, user |
| License | license (special actions: update_contract, update_config) |
| System/Config | branch, department, cost_centre, tenant_currency, custom_field_definition, etc. |
| Reference Data | customer_type, customer_rating, title, gender, source_funds, etc. |
| Financial Config | account_type, product, trans_code, work_day_year, sequence, etc. |
| CRM | customer, agent |
| Ledger | account, facility, account_mandate, account_statement, etc. |
| Product Rules | account_condition, commission_type, facility_class, activity_rule |
Some entities have restricted actions (e.g., product_balance is read/export only; account_statement is read/export only; work_day_month is read/update only).
Redis Caching
Permissions are cached per role at key permissions:{tenantId}:{roleId} with a 1-hour TTL. All users sharing a role share one cache entry.
The cache contains all three layers: base matrix, thresholds, and rules — built from DB by buildUserPermissionCache() on cache miss.
Invalidation triggers:
- Role-specific mutation (matrix, threshold, or permission rule change) →
invalidateRolePermissions(tenantId, roleId)deletes the specific key - Tenant-wide mutation (validation rule change) →
invalidateTenantPermissions(tenantId)pattern-scans and deletes allpermissions:{tenantId}:*keys
Access Control API
All routes under /api/v1/access-control. Every endpoint requires requireAuth + requirePermission guards.
Role Management
| Method | Path | Permission | Description |
|---|---|---|---|
| POST | /roles | role / create | Create role. Auto-generates slug from name. SYSTEM_ADMIN provides tenantId; tenant users scoped to own tenant. |
| GET | /roles | role / read | List roles with pagination, search, isActive filter, custom field filters. |
| GET | /roles/:id | role / read | Get role details (includes tenantName from License). |
| PATCH | /roles/:id | role / update | Update name, description, isActive, customFields. Cannot modify SYSTEM_ADMIN. |
| DELETE | /roles/:id | role / delete | Soft-delete. Cannot delete SYSTEM_ADMIN or roles with assigned users. |
Permission Matrix (Layer 1)
| Method | Path | Permission | Description |
|---|---|---|---|
| GET | /roles/:id/permissions | permission_matrix / read | Full matrix for a role (all entity types and their allowed actions). |
| PUT | /roles/:id/permissions | permission_matrix / update | Replace actions for a specific entityType. Transaction: delete old → create new. Cannot modify SYSTEM_ADMIN. |
Thresholds (Layer 2a)
| Method | Path | Permission | Description |
|---|---|---|---|
| POST | /roles/:id/thresholds | transaction_threshold / create | Create amount threshold. Validates no overlapping ranges for same currency/entity. |
| GET | /roles/:id/thresholds | transaction_threshold / read | List all thresholds for a role. |
| DELETE | /thresholds/:id | transaction_threshold / delete | Soft-delete a threshold. |
Rules (Layer 2b)
| Method | Path | Permission | Description |
|---|---|---|---|
| POST | /roles/:id/rules | transaction_rule / create | Create a PERMISSION rule (role-specific) with conditions. |
| POST | /rules | transaction_rule / create | Create a global VALIDATION rule (tenant-wide). SYSTEM_ADMIN provides tenantId. |
| GET | /rules | transaction_rule / read | List rules with optional entityType and tenantId filters. |
| DELETE | /rules/:id | transaction_rule / delete | Soft-delete a rule. |
Protected Roles
The SYSTEM_ADMIN role (tenantId "SYSTEM") cannot be renamed, deactivated, deleted, or have its permissions modified through the API.
Tenant Admin Default Permissions
When a new tenant is provisioned (license created), a TENANT_ADMIN role is automatically seeded with full access (create, read, update, delete) to all 37 non-license entities. For license, it receives only read and update_config — no create, delete, or update_contract (those are System Admin only).
Guard Integration
Two patterns are used in route handlers:
Declarative — for standard CRUD:
preHandler: [requirePermission("customer", "create")]The guard fetches the cached permissions, calls checkPermission(), attaches request.permissionResult (including requiredLevels), and denies with audit log on failure.
Imperative — for approval workflows where the action depends on record state:
const cache = await request.server.permissions.getUserPermissions(user.tenantId, user.roleId);
const requiredAction = getApprovalAction(entity.recordStatus); // approve_l1, l2, or l3
const result = checkPermission(cache, { actor, resource, action: requiredAction, data });
if (!result.allowed) throw new UnauthorizedError(result.reason);Maker-Checker Workflow
Entities Requiring Approval
11 operational/financial entities require the maker-checker workflow:
customer, agent, account, facility, product, trans_code, work_day_year, account_condition, commission_type, facility_class, activity_rule
Approval State Machine
CAPTURED → PENDING_AUTH_L3 → PENDING_AUTH_L2 → PENDING_AUTH_L1 → AUTHORIZED
↘ REJECTED (can resubmit)
↘ DENIED (terminal)State Transitions
| Action | Valid From | Result |
|---|---|---|
submit | CAPTURED, REJECTED | Enters approval chain at the level determined by requiredLevels (0 → AUTHORIZED, 1 → PENDING_AUTH_L1, 2 → L2, 3 → L3) |
approve | Any PENDING_AUTH_L* | Advances to next level (L3→L2→L1→AUTHORIZED) |
reject | Any PENDING_AUTH_L* | REJECTED (allows editing and resubmission) |
deny | Any PENDING_AUTH_L*, REJECTED | DENIED (terminal, no resubmission) |
Edit Restrictions
- Editable: CAPTURED and REJECTED only
- Read-only: All PENDING states and terminal states (AUTHORIZED, DENIED)
Integration with Permission Engine
The requiredLevels that determines entry point into the approval chain comes from the permission engine result. It can originate from:
- Layer 1 base
requiredLevelon the permission matrix entry - Layer 2a threshold override (higher amounts → more approvals)
- Layer 2b rule override (conditions like risk rating → more approvals)
Each approval action (approve_l1, approve_l2, approve_l3) is itself a permission-checked action. The approver must have that specific action allowed for the entity type in their own role's permission matrix.
Workflow Helpers
Pure utility functions in packages/shared/src/workflow/helpers.ts:
| Function | Purpose |
|---|---|
validateStatusTransition(status, action) | Guards illegal transitions, throws on violation |
getNextApprovalStatus(status) | Advances chain: L3→L2→L1→AUTHORIZED |
getInitialPendingStatus(requiredLevels) | Maps 0→AUTHORIZED, 1→L1, 2→L2, 3→L3 |
canEditRecord(status) | True only for CAPTURED and REJECTED |
isTerminalStatus(status) | True for AUTHORIZED and DENIED |
isPendingApproval(status) | True for any PENDING_AUTH_L* |
getApprovalLevel(status) | Returns 1, 2, or 3 (or null) |