Roles & permissions
The seeded roles and their exact permission sets, the full permission catalogue, app-scoped grants, and the role / user / session admin endpoints.
Access to the dashboard is governed by role-based access control. An admin user holds one or more roles; each role bundles a set of permission keys; and each role assignment is either global or scoped to a single app (RBAC-4).
These endpoints are all admin-audience, session-authenticated — send your session token, never an API key:
-H "Authorization: Bearer SESSION_TOKEN"All responses use the standard envelope. It is shown in full once below; afterwards
only the data payload is shown.
{
"data": null,
"meta": { "success": true, "message": null, "errorCode": null, "timestamp": "2026-06-30T12:00:00Z" },
"pagination": null
}How a permission is resolved
A request's effective permissions are the union of the permission keys carried by every role the user is assigned, each tagged with that assignment's app scope. The seeded Super Admin role is special: a global Super Admin assignment bypasses every check, for every app.
Coarse method gating uses the permission key directly
(@PreAuthorize("hasAuthority('user:manage')")). App-scoped checks additionally
narrow to the apps the matching grant covers, so a cross-app search only returns
rows from apps you can see — consistent with app isolation.
Seeded roles
Five system roles ship by default. They are marked system: true and cannot be
deleted. The table below is the exact seed, rebuilt from migrations V2 (the first
14 permissions + the five roles), V20 (which added customer:read), and V23
(which added the six catalog permissions to Super Admin only).
| Role | system | Intended for | Permission count |
|---|---|---|---|
| Super Admin | true | Platform owners | All (and bypasses checks) |
| Admin | true | Operations leads | Everything except user:manage and the catalog keys |
| Finance | true | Finance / ops | Money movement + read, no refund:approve |
| Support | true | First-line support | Read payments, invoices, customers |
| Viewer | true | Read-only stakeholders | Read payments, invoices, refunds, reports, customers |
Exact permission matrix
✓ = granted by the seed; — = not granted (a custom role can still add it).
| Permission | Super Admin | Admin | Finance | Support | Viewer |
|---|---|---|---|---|---|
payment:read | ✓ | ✓ | ✓ | ✓ | ✓ |
payment:export | ✓ | ✓ | ✓ | — | — |
invoice:read | ✓ | ✓ | ✓ | ✓ | ✓ |
invoice:create | ✓ | ✓ | ✓ | — | — |
invoice:void | ✓ | ✓ | ✓ | — | — |
refund:read | ✓ | ✓ | ✓ | — | ✓ |
refund:create | ✓ | ✓ | ✓ | — | — |
refund:approve | ✓ | ✓ | — | — | — |
report:read | ✓ | ✓ | ✓ | — | ✓ |
customer:read | ✓ | ✓ | ✓ | ✓ | ✓ |
app:read | ✓ | ✓ | — | — | — |
app:manage | ✓ | ✓ | — | — | — |
user:read | ✓ | ✓ | — | — | — |
user:manage | ✓ | — | — | — | — |
audit:read | ✓ | ✓ | — | — | — |
product:read | ✓ | — | — | — | — |
product:write | ✓ | — | — | — | — |
benefit:read | ✓ | — | — | — | — |
benefit:write | ✓ | — | — | — | — |
subscription:read | ✓ | — | — | — | — |
subscription:write | ✓ | — | — | — | — |
Reading the matrix:
- Super Admin holds every key, and additionally bypasses all checks (so even a permission not in the catalogue would still pass for a global Super Admin).
- Admin is "everything except
user:manage" — plus it never received the catalog keys, whichV23granted to Super Admin only. So Admin can approve refunds and manage apps, but cannot create users or manage products/benefits/subscriptions. - Finance can move money (
refund:create,invoice:create/void) and export, but cannot approve above-threshold refunds (refund:approveis Admin/Super Admin only). - Support is read-only on payments, invoices, and customers.
- Viewer is read-only across payments, invoices, refunds, reports, and customers.
customer:read was added later in migration V20 and granted to all five
read-capable roles (Super Admin, Admin, Finance, Support, Viewer). The original
V2 seed gave Support only payment:read + invoice:read, and Viewer only
payment:read + invoice:read + refund:read + report:read; the matrix above
reflects the current state after V20.
Permission catalogue
Permissions are resource:action keys. There are 21 in the catalogue, exposed
read-only at GET /admin/permissions. Grouped by resource:
| Resource | Keys |
|---|---|
| Payments | payment:read, payment:export |
| Invoices | invoice:read, invoice:create, invoice:void |
| Refunds | refund:read, refund:create, refund:approve |
| Reports | report:read |
| Customers | customer:read |
| Apps | app:read, app:manage |
| Users & roles | user:read, user:manage |
| Audit | audit:read |
| Catalog | product:read, product:write, benefit:read, benefit:write, subscription:read, subscription:write |
What each one allows:
| Permission | Allows |
|---|---|
payment:read | View payments / search transactions. |
payment:export | Export payment data as CSV. |
invoice:read | View invoices. |
invoice:create | Author admin invoices. |
invoice:void | Void invoices. |
refund:read | View refunds across apps. |
refund:create | Initiate a refund (and retry failed ones). |
refund:approve | Approve a refund above the per-app threshold. |
report:read | View reports and reconciliation. |
customer:read | View customer profiles and aggregated history. |
app:read | View registered apps. |
app:manage | Onboard apps, rotate credentials, set config. |
user:read | View admin users and roles. |
user:manage | Create / disable / enable users and assign roles. |
audit:read | View the audit trail and delivery logs. |
product:read | View products and their prices. |
product:write | Create, edit, publish, and archive products and prices. |
benefit:read | View the benefit catalogue and product attachments. |
benefit:write | Manage the benefit catalogue and product attachments. |
subscription:read | View subscriptions. |
subscription:write | Create, cancel, and pause subscriptions and one-time purchases. |
Catalog permissions are Super-Admin-only by default
The six catalog keys (product:*, benefit:*, subscription:*) were added in
migration V23 and, per PRD §6.1, granted to Super Admin only. No other seeded
role — not even Admin — receives them out of the box. To let a Finance or Ops user
manage products & prices,
benefits, or subscriptions,
create a custom role that includes the keys and assign it.
Global vs app-scoped grants
A role assignment carries an optional app scope (appScope):
- Global (
appScope: null) — the role's permissions apply to every app. - App-scoped (
appScope: 5) — the role's permissions apply to app 5 only.
The same role can be assigned to one user multiple times with different scopes. A unique constraint prevents holding the same role twice at the same scope (global is folded so "global" can't be granted twice either).
Cross-app search endpoints automatically narrow their results to the apps your grants cover. A Super Admin assigned at global scope bypasses this narrowing and sees every app.
The platform protects against locking everyone out: you cannot disable, or revoke
the Super Admin role from, the last active Super Admin. The attempt fails with
HTTP 409 and errorCode: "LAST_SUPER_ADMIN_PROTECTED".
Managing the catalogue and roles
GET reads require user:read. Creating a role requires user:manage plus
step-up re-authentication.
List the permission catalogue
curl http://localhost:8080/api/v1/admin/permissions \
-H "Authorization: Bearer SESSION_TOKEN"PermissionDto[]:
| Field | Type | Required | Notes |
|---|---|---|---|
id | number | — | Catalogue row id. |
key | string | — | The resource:action permission key. |
description | string | — | Human-readable description. |
{
"data": [
{ "id": 1, "key": "payment:read", "description": "View payments" },
{ "id": 6, "key": "refund:read", "description": "View refunds" },
{ "id": 8, "key": "refund:approve", "description": "Approve refunds above the per-app threshold" },
{ "id": 15, "key": "customer:read", "description": "View customer profiles and history" },
{ "id": 16, "key": "product:read", "description": "View products and their prices" }
]
}List roles
curl http://localhost:8080/api/v1/admin/roles \
-H "Authorization: Bearer SESSION_TOKEN"RoleSummaryDto[]:
| Field | Type | Required | Notes |
|---|---|---|---|
id | number | — | Role id. |
name | string | — | Role name (unique). |
description | string | — | Optional description. |
system | boolean | — | true for the five seeded roles; they cannot be deleted. |
Get one role with its keys
curl http://localhost:8080/api/v1/admin/roles/3 \
-H "Authorization: Bearer SESSION_TOKEN"RoleDto:
| Field | Type | Required | Notes |
|---|---|---|---|
id | number | — | Role id. |
name | string | — | Role name. |
description | string | — | Optional description. |
system | boolean | — | Whether this is a seeded system role. |
permissionKeys | string[] | — | The exact permission keys the role bundles. |
createdAt | string (date-time) | — | When the role was created. |
{
"data": {
"id": 3,
"name": "Finance",
"description": "Day-to-day finance/ops: refunds and voids",
"system": true,
"permissionKeys": [
"payment:read", "payment:export", "invoice:read", "invoice:create",
"invoice:void", "refund:read", "refund:create", "report:read", "customer:read"
],
"createdAt": "2026-01-04T09:00:00"
}
}Create a custom role
Requires user:manage + recent re-auth. The action is written to the
append-only audit log as role.create.
curl -X POST http://localhost:8080/api/v1/admin/roles \
-H "Authorization: Bearer SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Catalog Manager",
"description": "Manage products, benefits, and subscriptions",
"permissionKeys": ["product:read", "product:write", "benefit:read", "benefit:write", "subscription:read", "subscription:write"]
}'CreateRoleRequest:
| Field | Type | Required | Notes |
|---|---|---|---|
name | string | Yes | Not blank. Max 64 characters. Must be unique. |
description | string | No | Max 255 characters. |
permissionKeys | string[] | Yes | Non-empty set. Every key must exist in the catalogue. |
Returns 201 with the new RoleDto (system: false):
{
"data": {
"id": 6,
"name": "Catalog Manager",
"description": "Manage products, benefits, and subscriptions",
"system": false,
"permissionKeys": ["product:read", "product:write", "benefit:read", "benefit:write", "subscription:read", "subscription:write"],
"createdAt": "2026-06-30T12:00:00"
}
}Failure cases:
| Condition | Status | errorCode |
|---|---|---|
name blank, or permissionKeys empty | 400 | VALIDATION_ERROR |
| A key is not in the catalogue | 400 | VALIDATION_ERROR (Unknown permission keys: …) |
name already exists | 409 | RESOURCE_ALREADY_EXISTS |
| Re-auth window elapsed | 403 | REAUTH_REQUIRED |
Missing user:manage | 403 | — |
Managing users, role assignments, and sessions
User management lives under /admin/users. Reads require user:read; every
mutation requires user:manage plus recent re-auth, and is audited.
Apps & credentials
The app:read / app:manage endpoints these grants gate.
Audit & logs
Where every sensitive RBAC action is recorded.
List / get users
curl "http://localhost:8080/api/v1/admin/users?page=0&size=20&search=ops&status=ACTIVE" \
-H "Authorization: Bearer SESSION_TOKEN"Query parameters: page (default 0), size (default 20), sortBy (default
createdAt), order (default DESC), paginate (default true), search
(optional), status (optional — e.g. ACTIVE, DISABLED). Returns paginated
AdminUserSummaryDto[] (id, email, name, status, lastLoginAt).
GET /admin/users/{id} returns the detail AdminUserDto:
| Field | Type | Required | Notes |
|---|---|---|---|
id | number | — | User id. |
email | string | — | Login email (unique, case-insensitive). |
name | string | — | Display name. |
status | string | — | Account status, e.g. ACTIVE / DISABLED. |
roles | object[] | — | Current assignments (see below). |
lastLoginAt | string (date-time) | — | Last successful login. |
createdAt | string (date-time) | — | When the account was created. |
Each roles[] entry (UserRoleDto): id (the assignment id, used to revoke),
roleId, roleName, and appScope (null = global).
Create a user
CreateAdminUserRequest:
| Field | Type | Required | Notes |
|---|---|---|---|
email | string | Yes | Not blank; must be a valid email. |
name | string | Yes | Not blank. |
password | string | Yes | Not blank; checked against the password policy in the use case. |
roles | object[] | No | Optional initial assignments, each an AssignRoleRequest. |
curl -X POST http://localhost:8080/api/v1/admin/users \
-H "Authorization: Bearer SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"email": "[email protected]",
"name": "Ops Lead",
"password": "Sup3r-Str0ng-Passw0rd!",
"roles": [ { "roleId": 2, "appScope": null } ]
}'Assign and revoke roles
Assign with POST /admin/users/{id}/roles. AssignRoleRequest:
| Field | Type | Required | Notes |
|---|---|---|---|
roleId | number | Yes | Must reference an existing role. |
appScope | number | No | App id to scope the grant to; null (or omitted) = all apps. |
# Grant role 3 (Finance), scoped to app 5 only
curl -X POST http://localhost:8080/api/v1/admin/users/42/roles \
-H "Authorization: Bearer SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "roleId": 3, "appScope": 5 }'Revoke a specific assignment by its assignment id (the roles[].id from the
user detail), not the role id:
curl -X DELETE http://localhost:8080/api/v1/admin/users/42/roles/8 \
-H "Authorization: Bearer SESSION_TOKEN"Both return the refreshed AdminUserDto. Revoking the last Super Admin assignment
fails with 409 / LAST_SUPER_ADMIN_PROTECTED.
Disable / enable a user
curl -X POST http://localhost:8080/api/v1/admin/users/42/disable \
-H "Authorization: Bearer SESSION_TOKEN"Disabling immediately revokes the user's active sessions. Disabling the last active
Super Admin fails with 409 / LAST_SUPER_ADMIN_PROTECTED. Re-enable with
POST /admin/users/{id}/enable.
Revoke a session
user:manage holders can revoke any admin session by id. Revocation takes effect on
the session's next request and is audited (session.revoke).
curl -X DELETE http://localhost:8080/api/v1/admin/sessions/777 \
-H "Authorization: Bearer SESSION_TOKEN"Returns 200 with data: null.
Sensitive actions: step-up & audit
The RBAC mutations are sensitive actions. Beyond the user:manage authority,
they call requireRecentReauth(): if your session's last re-authentication is older
than the configured window, the request is rejected with 403 /
REAUTH_REQUIRED, and you must re-confirm your password before retrying. Each one
also writes an entry to the append-only audit log.
| Action | Authority | Step-up re-auth | Audit action |
|---|---|---|---|
| Create role | user:manage | ✓ | role.create |
| Create user | user:manage | ✓ | user.create |
| Disable user | user:manage | ✓ | user.disable |
| Enable user | user:manage | ✓ | user.enable |
| Assign role | user:manage | ✓ | user.role.assign |
| Revoke role | user:manage | ✓ | user.role.revoke |
| Revoke session | user:manage | — | session.revoke |
Re-authenticate
Call the admin re-auth endpoint with your password to refresh the session's
lastReauthAt. See the admin overview for the step-up flow.
Retry the mutation
Within the re-auth window, repeat the original request. It now passes the
SensitiveActionGuard and is recorded in the audit trail.