Febasidocs
Guides

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:export that lives outside the system set.
  • A custom role Reporter (level 30) that owns reports:export and users:read.
  • Bob can promote Carol to Reporter. Bob cannot promote Carol to manager.

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 match scope:action — both lowercase, alphanumeric or underscore.
  • The endpoint checks the caller's own permissions, using userId from the JWT. API-key calls without a user context return USER_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.

On this page