OI Payments Docs
Admin dashboard

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).

RolesystemIntended forPermission count
Super AdmintruePlatform ownersAll (and bypasses checks)
AdmintrueOperations leadsEverything except user:manage and the catalog keys
FinancetrueFinance / opsMoney movement + read, no refund:approve
SupporttrueFirst-line supportRead payments, invoices, customers
ViewertrueRead-only stakeholdersRead payments, invoices, refunds, reports, customers

Exact permission matrix

= granted by the seed; = not granted (a custom role can still add it).

PermissionSuper AdminAdminFinanceSupportViewer
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, which V23 granted 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:approve is 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:

ResourceKeys
Paymentspayment:read, payment:export
Invoicesinvoice:read, invoice:create, invoice:void
Refundsrefund:read, refund:create, refund:approve
Reportsreport:read
Customerscustomer:read
Appsapp:read, app:manage
Users & rolesuser:read, user:manage
Auditaudit:read
Catalogproduct:read, product:write, benefit:read, benefit:write, subscription:read, subscription:write

What each one allows:

PermissionAllows
payment:readView payments / search transactions.
payment:exportExport payment data as CSV.
invoice:readView invoices.
invoice:createAuthor admin invoices.
invoice:voidVoid invoices.
refund:readView refunds across apps.
refund:createInitiate a refund (and retry failed ones).
refund:approveApprove a refund above the per-app threshold.
report:readView reports and reconciliation.
customer:readView customer profiles and aggregated history.
app:readView registered apps.
app:manageOnboard apps, rotate credentials, set config.
user:readView admin users and roles.
user:manageCreate / disable / enable users and assign roles.
audit:readView the audit trail and delivery logs.
product:readView products and their prices.
product:writeCreate, edit, publish, and archive products and prices.
benefit:readView the benefit catalogue and product attachments.
benefit:writeManage the benefit catalogue and product attachments.
subscription:readView subscriptions.
subscription:writeCreate, 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[]:

FieldTypeRequiredNotes
idnumberCatalogue row id.
keystringThe resource:action permission key.
descriptionstringHuman-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[]:

FieldTypeRequiredNotes
idnumberRole id.
namestringRole name (unique).
descriptionstringOptional description.
systembooleantrue 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:

FieldTypeRequiredNotes
idnumberRole id.
namestringRole name.
descriptionstringOptional description.
systembooleanWhether this is a seeded system role.
permissionKeysstring[]The exact permission keys the role bundles.
createdAtstring (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:

FieldTypeRequiredNotes
namestringYesNot blank. Max 64 characters. Must be unique.
descriptionstringNoMax 255 characters.
permissionKeysstring[]YesNon-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:

ConditionStatuserrorCode
name blank, or permissionKeys empty400VALIDATION_ERROR
A key is not in the catalogue400VALIDATION_ERROR (Unknown permission keys: …)
name already exists409RESOURCE_ALREADY_EXISTS
Re-auth window elapsed403REAUTH_REQUIRED
Missing user:manage403

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.

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:

FieldTypeRequiredNotes
idnumberUser id.
emailstringLogin email (unique, case-insensitive).
namestringDisplay name.
statusstringAccount status, e.g. ACTIVE / DISABLED.
rolesobject[]Current assignments (see below).
lastLoginAtstring (date-time)Last successful login.
createdAtstring (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:

FieldTypeRequiredNotes
emailstringYesNot blank; must be a valid email.
namestringYesNot blank.
passwordstringYesNot blank; checked against the password policy in the use case.
rolesobject[]NoOptional 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:

FieldTypeRequiredNotes
roleIdnumberYesMust reference an existing role.
appScopenumberNoApp 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.

ActionAuthorityStep-up re-authAudit action
Create roleuser:managerole.create
Create useruser:manageuser.create
Disable useruser:manageuser.disable
Enable useruser:manageuser.enable
Assign roleuser:manageuser.role.assign
Revoke roleuser:manageuser.role.revoke
Revoke sessionuser:managesession.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.

On this page