Roles & Permissions
Mental model
| Layer | What it is | Where |
|---|---|---|
| Catalog | Permission names (resource:action) | /v1/permissions |
| Definitions | Roles + which permissions they carry | /v1/roles |
| Direct assign | User ↔ role | /v1/users/{id}/roles → 23 |
| Entity assign | User ↔ entity + roles | /v1/memberships → 54 |
This page is catalog + definitions only.
from outlabs_auth.routers import get_roles_router, get_permissions_router
app.include_router(get_roles_router(auth, prefix="/v1/roles"))
app.include_router(get_permissions_router(auth, prefix="/v1/permissions"))
Permissions catalog (/v1/permissions)
| Method | Path | Permission | Notes |
|---|---|---|---|
GET | / | permission:read | Paginated. Query: page, limit, resource |
POST | / | permission:create + global actor (Enterprise) | PermissionCreateRequest — name must be resource:action (409 on duplicate). is_system must be false (422 otherwise) — see System definitions |
GET | /{permission_id} | permission:read | One permission |
PATCH | /{permission_id} | permission:update + global actor (Enterprise) | Display/status/tags — not rename. System permissions rejected |
DELETE | /{permission_id} | permission:delete + global actor (Enterprise) | System permissions rejected |
GET | /me | Authenticated | Current user’s permission names (list[str]); optional entity_id |
GET | /user/{user_id} | permission:read + target in tenant scope | Another user’s permission names; optional entity_id. Out-of-scope users → 404 |
POST | /check | permission:check + target in tenant scope | Batch check; optional entity_id for entity/tree context |
GET | /{permission_id}/history | permission:read | Append-only definition history |
Schemas: PermissionResponse, PermissionCreateRequest,
PermissionUpdateRequest, PermissionCheckRequest /
PermissionCheckResponse (has_all_permissions, results map).
Without
entity_id,/meand/user/{id}return the historical aggregate (the union across contexts,*:*for superusers). Withentity_idthey return the catalog permissions held at that entity — direct grants there plus_tree/_allgrants inherited from ancestors — the same semantics asPOST /check. Gate entity-level UI actions on the entity-context set.
For effective permissions with sources (role vs membership), use
GET /v1/users/{id}/permissions on the users router.
ABAC condition groups/conditions also hang off /{permission_id}/… when you
use ABAC — same pattern as roles below.
The catalog is shared by every tenant. On EnterpriseRBAC with
enforce_user_scope(the default), creating, updating or deleting a permission, and every write to its conditions or condition groups, needs a global actor — a superuser, a holder of a direct system-wide role, a service token or an unanchored integration principal. A tenant-scoped holder ofpermission:*gets 403 (DD-061). Reads are unchanged.
System definitions
is_system (permissions) and is_system_role (roles) mark the library-owned
catalog. System definitions are immutable once created — no update,
re-tagging, condition edits, or delete — so the HTTP API refuses to mint them:
POST /v1/permissions/ rejects is_system=true, and RoleCreateRequest has
no is_system_role field at all. They come from two places only:
seed_system_records(...)(outlabs_auth.bootstrap), which seeds the library's permission catalog (SYSTEM_PERMISSION_CATALOG, overridable via itspermission_catalogparameter) at startup, idempotently.- Host code calling the services directly (
permission_service. create_permission(..., is_system=True)/role_service.create_role(..., is_system_role=True)) — creation-timetags,permission_names, andentity_type_permissionsall work there; immutability begins after the row exists.
Rationale and history: DD-060 in docs/DESIGN_DECISIONS.md.
Roles (/v1/roles)
CRUD and permission sets
| Method | Path | Permission | Notes |
|---|---|---|---|
GET | / | role:read | Paginated. Query: page, limit, search, is_global, root_entity_id |
POST | / | role:create | RoleCreateRequest. Actor must already hold every permission they attach |
GET | /{role_id} | role:read | One role |
PATCH | /{role_id} | role:update | RoleUpdateRequest; permissions replaces the set when sent. Widening changes (activation, is_global, hierarchy scope, auto-assign, new or cleared assignable_at_types) re-run containment for the role's permissions |
GET | /{role_id}/history | role:read | Append-only definition history (visibility rules apply) |
DELETE | /{role_id} | role:delete | 204 |
POST | /{role_id}/permissions | role:update | Body: list[str] permission names to add |
DELETE | /{role_id}/permissions | role:update | Body: list[str] to remove |
Enterprise helper
| Method | Path | Permission | Notes |
|---|---|---|---|
GET | /entity/{entity_id} | Tree role:read | Roles available for that entity (pickers / memberships UI) |
Role response fields worth knowing
RoleResponse includes: name, display_name, permissions[],
is_system_role, is_global, status, optional root_entity_id /
scope_entity_id, scope (entity_only | hierarchy),
assignable_at_types[], is_auto_assigned.
Enterprise tips:
- Non-global actors only manage roles in their trees; system-wide roles they cannot touch → 403; out-of-tree → 404. A personal API key carries its owner's tenant scope (narrowed to its anchor when it has one), never global scope on its own (DD-061)
- Writes to an entity-local role (create with
scope_entity_id, update, delete, permission and ABAC condition changes) also need its scope entity in the tree of a non-global actor's own root (403 otherwise, DD-061 decision 17): a role seen only through a membership left in another tenant's tree by an earlier cross-root move is read-only, and unrooted legacy administrators change no entity-local roles - In an entity-context check a role assigned directly to a user follows the same reach as a membership role: an org-scoped role only inside its own organization's tree, an entity-local role only inside its scope, system-wide roles everywhere (DD-054, DD-061)
- SimpleRBAC treats list/create visibility as effectively global
assignable_at_typeslimits which entity types may receive the role; an empty list means assignable everywhere, so clearing it counts as wideningis_auto_assignedcan auto-apply on membership create/update
ABAC on roles (when enabled)
Under /{role_id}/condition-groups and /{role_id}/conditions — CRUD with
role:read / role:update. Walkthrough: ABAC.
Simple vs Enterprise
| Concern | SimpleRBAC | EnterpriseRBAC |
|---|---|---|
| Role visibility | System-wide | Scoped to actor’s entity access |
GET /roles/entity/{id} | Rarely useful | Primary picker for memberships |
| Tree permissions | Unused | resource:action_tree + /permissions/check?entity_id= |
| Assign path | Users router direct roles | Prefer memberships; direct assign for org-wide roles |
Suggested host flow
- Seed or create permissions (
resource:action). - Create roles and attach permission lists (delegation check applies).
- Simple:
POST /v1/users/{id}/roles.
Enterprise:POST /v1/memberships/withrole_ids; useGET /v1/roles/entity/{entity_id}in the UI. - Gate product UI with
POST /v1/permissions/check(andentity_idwhen needed). - Optional attribute conditions: ABAC.