Roles & Permissions
Executive Summary
SetldHub implements a deny-by-default RBAC model with 36 fine-grained permissions organized across 12 domains and bundled into 9 launch roles (4 internal, 3 external, 2 machine). The approach is hybrid: Frontegg assigns coarse roles via the JWT roles[] claim, while a code-owned shared catalog (@setldpay/rbac) maps those roles to granular <entity>:<action> permissions. Both the frontend and backend resolve permissions from the same catalog at runtime, ensuring consistency by construction.
Data scope is orthogonal to permissions. A principal's clientIds and programIds — already carried in the token — determine which data they can see (row-level isolation), while permissions determine which actions they can perform (feature-level gating). The two compose: an action check (403) runs before a scope check (404), so an out-of-scope target never discloses existence.
Every unresolved product question was shipped as a denied cell (no permissive defaults). All 14 numbered decisions are now resolved. The model supports multi-role composition (permissions union across roles) and purpose-built machine integration roles for API-key principals. The pre-launch legacy role aliases were retired at launch: unknown role names resolve to zero permissions (fail-closed).
Capability Inventory
The platform surface spans 12 domains. Audience indicates whether the capability is offered to external client users (always composed with their own-client / own-program scope).
| Domain | Mutating actions | External audience |
|---|---|---|
| programs | program config, group association | read: yes. config: internal-only. associate: yes (own client) |
| merchants | create/edit, set-status, membership | read/write: yes. set-status (incl. go-live): client-admin |
| merchant-groups | create/edit, delete, set-status, membership | read: yes. write: yes (client-admin). delete: no |
| terminals | create, set-status, confirm | read/write/confirm: yes. set-status: no (internal-only) |
| merchant-onboarding | attach/start/confirm, sign, go-live | read/write: yes. sign: yes. go-live: client-admin |
| transactions | (read only) export button (hidden, no API) | read: yes. export: internal-only (FE-reserved) |
| cards | (read only) | read: yes (PII split: balance vs identity) |
| agreements | author template | read: yes. write: no (internal-only) |
| agreement-authorizations | record signing | read: yes (client-admin). write: yes (client-scoped by design) |
| mccs | (read only) | read: yes (global reference) |
| mcc-imports | upload/delete/publish | no (internal-only, super-admin) |
| audit-events | (read only) | read: client-admin only (not client-viewer) |
Permission Catalog
Conventions: lowercase snake_case entity, lowercase verb, : separator. R = read, W = mutate.
dashboard:read R Access the hub UI. Granted to every human role; excluded from machine roles.program:read R List and detail for all programs in scope.program:write W Change program fraud posture (restriction mode). Internal-only.program:link_merchant_group W Associate or disassociate a program with a merchant group.merchant:read R List and detail for merchants in scope.merchant:write W Create a merchant or edit merchant details.merchant:set_status W Activate or deactivate a merchant (including go-live via onboarding).merchant_group:read R List, detail, members, and linked programs.merchant_group:write W Create or edit a merchant group.merchant_group:delete W Hard-delete a group. Destructive, internal-only.merchant_group:set_status W Activate or deactivate a group (cascades to member merchants).merchant_group:manage_members W Add or remove merchants from a group.terminal:read R List terminals for a merchant.terminal:write W Create a terminal.terminal:set_status W Activate or deactivate a terminal. Internal-only.terminal:confirm W Confirm onboarding tap-to-provision.onboarding:read R View progress, worklists, and the onboarding wizard.onboarding:write W Attach merchant, start/resume, and confirm detection steps.onboarding:sign_agreement W In-wizard signing (legally binding, captures signatory PII).onboarding_card:read R View verification cards and poll detections (scope varies by role).transaction:read R View transactions across all sub-scoped views (program, merchant, card).transaction:export R(bulk) Bulk data export (FE-reserved, no backend route yet). Internal-only.card:read R Card existence and metadata.card:read_balance R Funding and balance data (confidential financial data).card:read_pii R Cardholder identity (name, address, DOB). Actual PII.agreement:read R List agreement versions and view current template body.agreement:write W Author or version a binding agreement template. Internal-only.agreement_authorization:read R Read signed authorizations (includes signatory PII).agreement_authorization:write W Record a merchant signing. Client-scoped by design.mcc:read R Global MCC code lookup. Safe for any authenticated user.mcc_import:read R View MCC import batches. Internal reference-data admin.mcc_import:write W Upload or delete pending MCC batches. Internal-only.mcc_import:publish W Publish an MCC batch (rebuilds global table). Most restricted.audit_event:read R Client-scoped audit log (no program filter). Internal-actor rows hidden from external roles.api_key:read R Inspect API keys. Internal key-management surface.api_key:write W Create, rotate, or revoke API keys. Internal ops only.Role Catalog
Coarse roles assigned in Frontegg (delivered as roles[]). Scope (clientIds/programIds) is assigned separately on the Frontegg user / API key and is orthogonal. Permission counts are derived from the shipped catalog.
Internal (SetldPay staff)
External (client-org users)
Machine (non-interactive)
The Phase 0 legacy aliases (RANUIAdministrator, onboarding-admin) were retired at launch — the catalog no longer maps them, so they resolve to zero permissions (fail-closed).
Role composition (multi-role)
A user may hold multiple roles; effective permissions are the union across them (the roles[] claim is an array; permissionsForRoles unions). Additive-only — there is no deny role, so combining roles only ever grants more.
- Compose, don't duplicate. Keep roles coarse and non-overlapping; assign combinations for "two-hats" users.
- Union can't narrow. To grant someone less than an existing role, define a dedicated narrow role.
- Scope is per-principal. Effective data scope is the token's single
clientIds/programIdsregardless of role count. The lone exception is the role-driven cross-tenant widener (sp-super-admin/sp-ops-adminover the onboarding-card pool). - Tier-mixing guard (load-bearing). Never assign an internal
sp-*role and an externalclient-*role to the same user. Enforce via assignment governance and by having the cross-tenant widener also gate on internaltokenType(defense in depth).
Role x Permission Matrix
Interactive matrix showing all 9 roles and 36 permissions. Click a role header to see its full grant set, or click a permission row to see which roles hold it. Use the toolbar to filter by tier, domain, or external-facing permissions.
| Permission | internal | external | machine | ||||||
|---|---|---|---|---|---|---|---|---|---|
| SA | OA | T2 | T1 | CA | CV | SCM | CI | CIR | |
| platform | |||||||||
| dashboard:readR | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ||
| programs | |||||||||
| program:readR | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| program:writeW | ✓ | ✓ | |||||||
| program:link_merchant_groupW | ✓ | ✓ | ✓ | ||||||
| merchants | |||||||||
| merchant:readR | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| merchant:writeW | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | |||
| merchant:set_statusW | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | |||
| merchant-groups | |||||||||
| merchant_group:readR | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| merchant_group:writeW | ✓ | ✓ | ✓ | ||||||
| merchant_group:deleteW | ✓ | ✓ | |||||||
| merchant_group:set_statusW | ✓ | ✓ | ✓ | ||||||
| merchant_group:manage_membersW | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | |||
| terminals | |||||||||
| terminal:readR | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| terminal:writeW | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | |||
| terminal:set_statusW | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | |||
| terminal:confirmW | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | |||
| onboarding | |||||||||
| onboarding:readR | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| onboarding:writeW | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | |||
| onboarding:sign_agreementW | ✓ | ✓ | ✓ | ✓ | |||||
| onboarding_card:readR | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| transactions | |||||||||
| transaction:readR | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| transaction:exportR(bulk) | ✓ | ✓ | |||||||
| cards | |||||||||
| card:readR | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| card:read_balanceR | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| card:read_piiR | ✓ | ✓ | ✓ | ✓ | ✓ | ||||
| agreements | |||||||||
| agreement:readR | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| agreement:writeW | ✓ | ||||||||
| agreement_authorization:readR | ✓ | ✓ | ✓ | ✓ | ✓ | ||||
| agreement_authorization:writeW | ✓ | ✓ | ✓ | ✓ | |||||
| mccs | |||||||||
| mcc:readR | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| mcc_import:readR | ✓ | ||||||||
| mcc_import:writeW | ✓ | ||||||||
| mcc_import:publishW | ✓ | ||||||||
| audit | |||||||||
| audit_event:readR | ✓ | ✓ | ✓ | ✓ | ✓ | ||||
| access | |||||||||
| api_key:readR | ✓ | ✓ | |||||||
| api_key:writeW | ✓ | ✓ | |||||||
Enforcement Architecture
Every request takes the same path, regardless of how the caller authenticated. Two gates compose, in a fixed order: the action gate answers "may this principal do this at all?" and the scope gate answers "is this row theirs?" — so an out-of-scope target is never revealed to a caller who couldn't act on it anyway.
spk_roles[]@setldpay/rbacUI enforcement (advisory)
The frontend derives permissions client-side from the JWT roles[] claim via the shared @setldpay/rbac catalog. A rewritten PermissionsService exposes can(permission) signals that drive route guards, sidebar visibility (requires keys per nav item), and a *spCan structural directive for inline UI elements (buttons, menus, tabs). This layer is advisory — it shapes the user experience but is not a security boundary. A stale catalog at worst shows a button that returns 403; it never grants access.
Backend enforcement (security gate)
A requirePermission preHandler on each route is the real security gate. It resolves effective permissions once per request from the token's roles[] via the same shared catalog, then asserts the route's required permission before the handler runs (403 on denial). This composes with the existing per-service data-scope isolation: permissions answer "may this principal do this action at all?" while scope answers "is this row theirs?" (404). Scoped endpoints return 403 then 404; global-resource endpoints (MCC imports) use the permission gate as their sole control.
Machine-to-machine access (API keys)
Client API keys (spk_ tokens) are first-class authorization principals resolved through the same catalog. Keys carry a roles[] column mapped to purpose-built integration roles (client-integration for read + safe writes, client-integration-ro for read-only reporting). All token types — Frontegg, internal, and API key — resolve permissions identically via permissionsForRoles(). Scope (clientIds/programIds) on the key drives row-level isolation, composing with permissions the same way it does for human principals.