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.