Skip to content

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

ColumnTypeDescription
idUUIDPrimary key
namevarcharDisplay name
iconvarcharUI icon name
descriptiontextRole description
parentUUIDFK to odp_roles.id — role hierarchy (nullable)
admin_accessbooleanBypasses all permission checks
tech_accessbooleanSystem/debug access (implies admin_access)
app_accessbooleanCan access the app panel
impersonate_accessbooleanCan use impersonation endpoints
enforce_tfabooleanRequire TFA for users in this role (read at login — see note below)
protectedbooleanLocked 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 users

Role 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 — RolesService throws 422 UnprocessableContentError (src/services/roles.ts:64-72).


Policies

Policies are named permission sets that can be attached to roles.

Data Model

Table: odp_policies

ColumnTypeDescription
idUUIDPrimary key
namevarcharPolicy name
iconvarcharUI icon
descriptiontextDescription
admin_accessbooleanPolicy grants admin access
app_accessbooleanPolicy grants app access
enforce_tfabooleanLegacy — 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

ColumnTypeDescription
idintegerPrimary key
policyUUIDFK to odp_policies.id
collectionvarcharCollection name (e.g., articles)
actionvarcharcreate, read, update, delete, share
fieldsjsonArray of allowed field names (["*"] = all)
permissionsjsonFilter condition — limits which items are accessible
validationjsonZod-compatible validation rules for write operations
presetsjsonDefault field values applied on create

Permission Actions

ActionDescription
createInsert new items
readRead items (with optional row-level filter)
updateModify existing items
deleteRemove items
shareShare 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.readByQuerysrc/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:

ModuleActions
workflowview, start, participate, manage

App permissions are checked via validateAppAccess(accountability, module, action, collection, knex) in src/permissions/index.ts.


How Permissions Are Evaluated

  1. Admin users (admin_access: true) bypass all RBAC.
  2. Tech users (tech_access: true) imply admin_access.
  3. App-access synthetic grant — an app_access user passes validateAccess for the collections in appAccessMinimalPermissions even 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_USER scoping, 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, processAst injects 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.
  4. For non-admins without a synthetic grant, the service loads all policies attached to the user's roles.
  5. Policies are merged — if any policy grants access, the action is allowed.
  6. Row-level filters from all matching permissions are OR-combined.
  7. 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"
}

ODP Internal API Documentation