Appearance
Roles, Policies & Permissions (RBAC)
Overview
ODP uses a Role-Based Access Control (RBAC) model:
- Roles — Group users and define system-level access flags.
- Policies — Define granular read/write/delete permissions on specific collections and fields.
- Permissions — Individual permission rules within a policy (collection + action + field restrictions + filter conditions).
Roles
Data Model
Table: odp_roles
| Column | Type | Description |
|---|---|---|
id | UUID | Primary key |
name | varchar | Display name |
icon | varchar | UI icon name |
description | text | Role description |
parent | UUID | FK to odp_roles.id — role hierarchy (nullable) |
admin_access | boolean | Bypasses all permission checks |
tech_access | boolean | System/debug access (implies admin_access) |
app_access | boolean | Can access the app panel |
impersonate_access | boolean | Can use impersonation endpoints |
enforce_tfa | boolean | Require TFA for users in this role (read at login — see note below) |
protected | boolean | Locked default role (Administrator, Public) — cannot be deleted |
Access Flag Hierarchy:
tech_access → admin_access (implied)
admin_access → bypasses all RBAC checks
app_access → can log into the app panel
impersonate_access → can impersonate other usersRole Endpoints
POST /roles
Create a role. Admin only.
Request Body
json
{
"name": "Editor",
"icon": "edit",
"description": "Can create and edit content",
"app_access": true,
"admin_access": false,
"tech_access": false
}Response 200
json
{ "data": "role-uuid" }GET /roles
List all roles.
Auth required: Yes
Supports standard query system.
GET /roles/:id
Read a single role.
PATCH /roles/:id
Update a role. Admin only.
DELETE /roles/:id
Delete a role. Admin only.
A protected role (the seeded Administrator and Public roles) cannot be deleted —
RolesServicethrows422 UnprocessableContentError(src/services/roles.ts:64-72).
Policies
Policies are named permission sets that can be attached to roles.
Data Model
Table: odp_policies
| Column | Type | Description |
|---|---|---|
id | UUID | Primary key |
name | varchar | Policy name |
icon | varchar | UI icon |
description | text | Description |
admin_access | boolean | Policy grants admin access |
app_access | boolean | Policy grants app access |
enforce_tfa | boolean | Legacy — no longer enforced. TFA enforcement moved to odp_roles.enforce_tfa (#44); the login flow reads the role, not the policy. This column is kept for backward compatibility but is not read. |
Policy Endpoints
POST /policies
Create a policy. Admin only.
json
{
"name": "Content Manager",
"description": "Full access to content collections",
"app_access": true
}GET /policies
List all policies. Supports standard query system.
GET /policies/:id
Read a single policy (with its permissions nested).
PATCH /policies/:id
Update a policy. Admin only.
DELETE /policies/:id
Delete a policy. Admin only.
Permissions
Permissions define what a policy can do on a specific collection.
Data Model
Table: odp_permissions
| Column | Type | Description |
|---|---|---|
id | integer | Primary key |
policy | UUID | FK to odp_policies.id |
collection | varchar | Collection name (e.g., articles) |
action | varchar | create, read, update, delete, share |
fields | json | Array of allowed field names (["*"] = all) |
permissions | json | Filter condition — limits which items are accessible |
validation | json | Zod-compatible validation rules for write operations |
presets | json | Default field values applied on create |
Permission Actions
| Action | Description |
|---|---|
create | Insert new items |
read | Read items (with optional row-level filter) |
update | Modify existing items |
delete | Remove items |
share | Share items publicly |
Field-Level Permissions
The fields array controls which columns are returned/writable:
json
{ "fields": ["title", "status", "published_at"] }Use ["*"] to allow all fields.
Row-Level Permissions (Filter Conditions)
The permissions JSON uses the same filter syntax as query filters:
json
{
"permissions": {
"author_id": { "_eq": "$CURRENT_USER" }
}
}Row-level filters are enforced on **READ only**
The permissions filter (and its dynamic variables like $CURRENT_USER) is injected into the query AST only on the read path (processAst, called from ItemsService.readByQuery — src/permissions/process-ast.ts). On write actions (create, update, delete), validateAccess only checks that a matching permission record exists — it does not resolve $CURRENT_USER or apply the filter as an ownership guard. A user with an update permission carrying { author_id: { _eq: "$CURRENT_USER" } } can still update rows owned by others. Enforce write-time ownership at the route/service level, not via the permission filter.
Dynamic variables:
$CURRENT_USER— Current user's UUID$CURRENT_ROLE— Current user's primary role UUID$CURRENT_ROLES— Array of current user's role UUIDs$CURRENT_POLICIES— Array of the current user's policy UUIDs (not available on accountability — resolves to[])$NOW— Current timestamp
Permission Endpoints
Table prefix: /permissions
POST /permissions
json
{
"policy": "policy-uuid",
"collection": "articles",
"action": "read",
"fields": ["*"],
"permissions": {
"status": { "_eq": "published" }
}
}GET /permissions
List all permissions. Admin access.
GET /permissions/me
Get all permissions applicable to the current user (resolves from roles and policies). Any authenticated user.
GET /permissions/:id
Read a single permission. Admin access.
PATCH /permissions/:id
Update a permission. Admin access.
DELETE /permissions/:id
Delete a permission. Admin access.
App-Level Permissions
Beyond collection-level RBAC, certain modules (Workflow, etc.) use app-level permissions stored in odp_app_permissions. These control access to specific module actions:
| Module | Actions |
|---|---|
workflow | view, start, participate, manage |
App permissions are checked via validateAppAccess(accountability, module, action, collection, knex) in src/permissions/index.ts.
How Permissions Are Evaluated
- Admin users (
admin_access: true) bypass all RBAC. - Tech users (
tech_access: true) implyadmin_access. - App-access synthetic grant — an
app_accessuser passesvalidateAccessfor the collections inappAccessMinimalPermissionseven with no explicit permission record, and returns before the policy system runs (src/permissions/validate-access.ts:53). These grants are whole-collection with no$CURRENT_USERscoping, so any new write path into those collections must enforce authorization itself at the route level.- #34 fail-closed own-row scoping (READ): on the read path, when an app-access user hits a user-scoped collection (activity, notifications, presets, shares, user-providers) with no explicit record,
processAstinjects the collection's own-rows filter instead of returning the whole table (src/permissions/process-ast.ts:78-87,src/permissions/app-access-permissions.ts:103-134). Global/shared collections keep read-all.
- #34 fail-closed own-row scoping (READ): on the read path, when an app-access user hits a user-scoped collection (activity, notifications, presets, shares, user-providers) with no explicit record,
- For non-admins without a synthetic grant, the service loads all policies attached to the user's roles.
- Policies are merged — if any policy grants access, the action is allowed.
- Row-level filters from all matching permissions are
OR-combined. - Field restrictions are
AND-intersected (most restrictive wins).
Role-Policy Assignment
Roles are linked to policies via the odp_access junction table.
Assign a policy to a role:
bash
PATCH /roles/:roleId
{
"policies": ["policy-uuid-1", "policy-uuid-2"]
}Example: Setting Up an Editor Role
bash
# 1. Create a policy
POST /policies
{
"name": "Article Editor",
"app_access": true
}
# Returns: { "data": "policy-abc" }
# 2. Add permissions to the policy
POST /permissions
{
"policy": "policy-abc",
"collection": "articles",
"action": "read",
"fields": ["*"]
}
# NOTE: the "permissions" filter below is NOT enforced on update — $CURRENT_USER
# resolves on READ only (see the READ-only warning above). This grants update on
# the allowed fields but does not restrict it to the author's own rows.
POST /permissions
{
"policy": "policy-abc",
"collection": "articles",
"action": "update",
"fields": ["title", "content", "status"]
}
# 3. Create the role and assign the policy
POST /roles
{
"name": "Editor",
"app_access": true,
"policies": ["policy-abc"]
}
# 4. Assign role to user
PATCH /users/:userId
{
"role": "role-uuid"
}