Skip to content

Roles & permissions (dynamic RBAC)

Tenant admins compose custom roles from a curated set of permissions and assign them to users in their workspace. A user's effective permissions are the union of their platform role and every custom role assigned to them; the server re-checks every permission on every request — including sessions that are already open: a platform-role downgrade or a custom-role change is enforced on that session's very next request (the session stays signed in with the reduced set; nothing needs to be re-issued). These endpoints and the SPA's role editor are a convenience layer, never the authorization boundary.

All routes below require the ROLE_MANAGE permission and are tenant-scoped to the caller's own workspace — the acting tenant is taken from the authenticated session, never from the request body or URL. A caller with no tenant is refused (403).

Two role classes exist:

  • Custom roles (origin: "user") — created and edited here, scoped to the tenant.
  • System roles (origin: "system") — Myra-curated, global, read-only through this API and not assignable in this release (listed for reference only).

Delegation & fences (what is rejected)

Every write is validated fail-closed before any database change:

Rule Rejection
You may only grant a permission you hold yourself (subset-gate) 403 { "error": "cannot grant a permission you do not hold", "key": … }
A cross-tenant / platform permission may never sit on a tenant role 400 { "error": "permission not grantable on a tenant role", "key": … }
An unknown permission key 400 { "error": "unknown permission", "key": … }
permissions not a JSON array / a key not a string / more than 200 keys 400
name missing or longer than 80 characters 400
description not a string or longer than 255 bytes 400 (rejected, never truncated)
A role name already used in the tenant 409
Assigning a role that grants a permission you do not hold 403 (delegation — prevents escalating the target past yourself)
Assigning a system role 400 { "error": "system roles are not assignable" }

Assignment routes additionally pass through the same access check as other per-user admin routes: a tenant admin cannot touch a platform admin's roles or reach across tenants (rank guard).

These fences are enforced again at the storage boundary independent of the client — a role can never carry a platform-class or non-catalog permission even if the API validation were bypassed.

Listing the permission catalog

GET /admin/v1/roles/permissions

Returns the tenant-grantable catalog grouped by module (the role editor's tabs). Platform-class (cross-tenant) permissions are not included. Each entry carries held — whether the calling admin holds it — so the editor renders a permission the admin lacks as disabled.

{ "modules": { "gateways": [ { "key": "GATEWAYS_MANAGE", "label": "Manage gateways and routing", "held": true } ], "…": [] } }

Listing roles

GET /admin/v1/roles

Returns the tenant's custom roles and the global system roles. Each row carries id, tenant_id, name, origin, description, created_at (Unix seconds), plus grant_count and assign_count. [] never appears as {} (array-encoded).

assign_count is scoped to the caller's own tenant and counts only active (non-deleted) users: for a system role it is the number of your tenant's users who hold it, never a cross-tenant total. grant_count is the role's own permission count (for system roles this is the Myra-curated definition, identical for every tenant).

Getting one role

GET /admin/v1/roles/{id}

Returns the role and its perm_keys (a string array), alongside the same identity fields as the list row (id, tenant_id, name, origin, description, created_at). A role in another tenant is 404.

Creating a custom role

POST /admin/v1/roles — body { "name": string, "description"?: string, "permissions"?: string[] }

On success 201 { "id": "<role-id>" }. permissions is optional (an empty role is valid). See the fences table for every rejection.

Renaming / re-describing a custom role

PATCH /admin/v1/roles/{id} — body { "name": string, "description"?: string } → 200.

Recomposing a custom role's permissions

PUT /admin/v1/roles/{id}/permissions — body { "permissions": string[] } → 200. This is a full replace: the role's grants become exactly the supplied set. Every supplied key is subset-gated against the actor's held permissions fail-closed: if any key is one the actor does not hold, the whole request is rejected 403 { "error": "cannot grant a permission you do not hold", "key": … } and nothing changes — it does not silently strip. Consequently a role that already carries a permission you do not hold cannot be recomposed by you (any PUT you send must re-list that key, which 403s); it can still be renamed (PATCH), but changing its permissions requires an admin who holds the full set. The SPA editor reflects this: it detects such a role up front, locks the permission editor, and offers rename only.

Deleting a custom role

DELETE /admin/v1/roles/{id} → 200. The role's grants and all its user assignments are removed with it. System roles cannot be deleted (404); the role is resolved (tenant-fenced) before anything else, so a system or foreign id is a plain 404 and never reveals how many users hold it.

Refused while a holder is out of your reach. Deleting a role changes the effective permissions of everyone who holds it, so it is allowed only if you could also unassign it from each holder under the same rank guard the assignment routes use (a tenant admin cannot reach a platform admin of the same tenant). Otherwise:

409 { "error": "role is assigned to a user you cannot manage",
      "code": "role_held_by_unreachable_user", "count": 1 }

and nothing changes — the check and the delete run in one transaction (the holder rows are locked, so an assignment landing concurrently cannot slip past the check). count is the number of holders you cannot manage, deactivated accounts included (a deactivated user regains the role on restore), so it can exceed the list's active-only assign_count. A row whose user was moved to another tenant is permission-inert and outside the tenant fence; it is cascaded silently.

The SPA (Custom roles → delete) asks a one-click confirmation for a role nobody holds and a typed confirmation (the role name) naming the active holder count for a held role; a 409 surfaces as an error toast with count.

A user's roles

GET /admin/v1/users/{user_id}/roles — the roles assigned to a user (custom + any system role), for the assignment UI.

Assigning / unassigning a role

PUT /admin/v1/users/{user_id}/roles/{role_id} → 200 — assign a custom role to a user in the tenant (idempotent). Subject to the delegation and rank-guard rules above; system roles are 400.

DELETE /admin/v1/users/{user_id}/roles/{role_id} → 200 — remove the assignment.