Authorization with RBAC — a working tour
From "I have a fresh tenant" to "manager can edit users but not other managers" — a concrete walkthrough of the RBAC model.
This guide is a worked example of the RBAC model. Read Authorization first if you want the full reference; come here when you want to do something concrete.
We'll pretend you're setting up a fresh tenant called acme. Your goal:
- Three accounts:
alice(admin),bob(manager),carol(regular user). - A custom permission
reports:exportthat lives outside the system set. - A custom role
Reporter(level 30) that ownsreports:exportandusers:read. - Bob can promote Carol to
Reporter. Bob cannot promote Carol tomanager.
Step 1 — Create the custom permission
POST /api/v1/permissions
Authorization: Bearer <admin-jwt>{
"scope": "reports",
"action": "export",
"name": "reports:export",
"description": "Export the analytics dashboard as CSV"
}Returns the new permission with an id. is_system is false because we
created it by hand.
Step 2 — Create the custom role
POST /api/v1/roles
Authorization: Bearer <admin-jwt>{
"name": "Reporter",
"displayName": "Reporter",
"level": 30,
"description": "Can read users and export reports"
}This role is below manager (level 50) and above user (level 10), so
managers can assign and revoke it but regular users can't.
Step 3 — Attach permissions to the role
POST /api/v1/roles/{reporterId}/permissions
Authorization: Bearer <admin-jwt>{
"permissionIds": ["<reports:export id>", "<users:read id>"]
}Now any user who is assigned Reporter will have both reports:export and
users:read in their effective permission set.
Step 4 — Assign Bob the manager role
POST /api/v1/roles/assign{
"userId": "<bob>",
"roleId": "<manager>"
}Step 5 — Bob promotes Carol
Bob's JWT carries manager (level 50). When he assigns the Reporter role
(level 30) to Carol (level 10), the hierarchy check passes:
Bob's level (50) > Reporter's level (30) ✅ Bob's level (50) > Carol's current level (10) ✅
POST /api/v1/roles/assign
Authorization: Bearer <bob-jwt>{
"userId": "<carol>",
"roleId": "<reporter>"
}Step 6 — Bob tries to promote Carol to manager
The same endpoint, but the target role's level is 50 — equal to Bob's. The hierarchy rule fails:
{
"success": false,
"error": "Cannot manage role at or above your level",
"code": "HIERARCHY_VIOLATION",
"details": {
"actorLevel": 50,
"targetLevel": 50
}
}(HTTP 403.)
This is the rule in one sentence:
A user cannot assign, revoke, or otherwise manage anything whose level is greater than or equal to their own highest role level.
Step 7 — Carol's permissions, after the assignment
Carol's effective permissions are now the union of:
user(level 10) — system role permissions:auth:logs, etc.Reporter(level 30) —reports:export,users:read.- Direct grants — none.
GET /api/v1/permissions/user/<carol>{
"success": true,
"data": {
"rolePermissions": ["users:read", "reports:export", "auth:logs"],
"individualPermissions": [],
"effectivePermissions": ["users:read", "reports:export", "auth:logs"]
}
}When does the change take effect for Carol's tokens?
- In the database: immediately.
GET /permissions/user/{carol}reflects the new state right away. - In Carol's JWT: only at the next access-token issuance. Carol must
either log in again, hit
/refresh, or call any endpoint that rotates the token.
If you need a real-time check (e.g., guarding a destructive action right
after an assignment), call POST /api/v1/permissions/check directly:
POST /api/v1/permissions/check
Authorization: Bearer <carol-jwt>{ "permissionName": "reports:export" }{ "success": true, "data": { "hasPermission": true } }This bypasses the JWT cache and reads the caller's effective permissions from the live database.
- The body field is
permissionName. The value must matchscope:action— both lowercase, alphanumeric or underscore. - The endpoint checks the caller's own permissions, using
userIdfrom the JWT. API-key calls without a user context returnUSER_CONTEXT_REQUIRED(400).
Granting a single permission outside any role
For one-off cases, you can grant a permission directly to a user:
POST /api/v1/permissions/grant{
"userId": "<carol>",
"permissionId": "<reports:export id>",
"expiresAt": "2026-12-31T23:59:59Z"
}Direct grants survive role changes — they live in user_permissions, not
role_permissions. The expiresAt field is honored at read time; expired
grants are filtered out and never embedded in new tokens.