Roles & Permissions
Executive Summary
SetldHub implements a deny-by-default RBAC model with 36 fine-grained permissions organized across 12 domains and bundled into 14 roles (6 internal, 5 external, 3 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), purpose-built machine integration roles for API-key principals, and a phased migration path that avoids locking anyone out during rollout.
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): admin, PM, support, onboarder |
| 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: support + onboarder (in-field ops) |
| merchant-onboarding | attach/start/confirm, sign, go-live | read/write: yes. sign: yes. go-live: client-admin + program-manager |
| 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 (PM). 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 PM/CV) |
Permission Catalog
Conventions: lowercase snake_case entity, lowercase verb, : separator. R = read, W = mutate.
dashboard:readRAccess the hub UI. Granted to every human role; excluded from machine roles.program:readRList and detail for all programs in scope.program:writeWChange program fraud posture (restriction mode). Internal-only.program:link_merchant_groupWAssociate or disassociate a program with a merchant group.merchant:readRList and detail for merchants in scope.merchant:writeWCreate a merchant or edit merchant details.merchant:set_statusWActivate or deactivate a merchant (including go-live via onboarding).merchant_group:readRList, detail, members, and linked programs.merchant_group:writeWCreate or edit a merchant group.merchant_group:deleteWHard-delete a group. Destructive, internal-only.merchant_group:set_statusWActivate or deactivate a group (cascades to member merchants).merchant_group:manage_membersWAdd or remove merchants from a group.terminal:readRList terminals for a merchant.terminal:writeWCreate a terminal.terminal:set_statusWActivate or deactivate a terminal (incl. external support/onboarder for in-field troubleshooting).terminal:confirmWConfirm onboarding tap-to-provision.onboarding:readRView progress, worklists, and the onboarding wizard.onboarding:writeWAttach merchant, start/resume, and confirm detection steps.onboarding:sign_agreementWIn-wizard signing (legally binding, captures signatory PII).onboarding_card:readRView verification cards and poll detections (scope varies by role).transaction:readRView transactions across all sub-scoped views (program, merchant, card).transaction:exportR(bulk)Bulk data export (FE-reserved, no backend route yet). Internal-only.card:readRCard existence and metadata.card:read_balanceRFunding and balance data (confidential financial data).card:read_piiRCardholder identity (name, address, DOB). Actual PII.agreement:readRList agreement versions and view current template body.agreement:writeWAuthor or version a binding agreement template. Internal-only.agreement_authorization:readRRead signed authorizations (includes signatory PII).agreement_authorization:writeWRecord a merchant signing. Client-scoped by design.mcc:readRGlobal MCC code lookup. Safe for any authenticated user.mcc_import:readRView MCC import batches. Internal reference-data admin.mcc_import:writeWUpload or delete pending MCC batches. Internal-only.mcc_import:publishWPublish an MCC batch (rebuilds global table). Most restricted.audit_event:readRClient-scoped audit log (no program filter). Internal-actor rows hidden from external roles.api_key:readRInspect API keys. Internal key-management surface.api_key:writeWCreate, 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)
Bounded legacy aliases (Phase 0 migration): onboarding-admin → sp-onboarding , RANUIAdministrator → sp-ops-admin
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-onboarding/sp-super-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 14 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 | ONB | RC | SUP | AN | CA | PM | CV | CS | CO | SVC | 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.