Access control
This page covers the four system collections that together make up CairnCMS’s access-control layer (users, roles, permissions, shares) and the /config/* endpoints that move role and permission state between deployments. Each collection follows the standard CRUD shape documented in Items, so this page focuses on the per-collection field shapes, the bespoke endpoints that sit alongside the standard CRUD, and the cross-cutting semantics of the access-control surface.
Users (/users)
Section titled “Users (/users)”The user record is what every authenticated request resolves to. The collection has the standard CRUD shape under /users plus several user-specific endpoints for the current user, invitations, and TFA setup.
Endpoints
Section titled “Endpoints”| Method | Path | Purpose |
|---|---|---|
GET | /users | List users. |
SEARCH | /users | Read users with the request body. |
GET | /users/me | The current authenticated user. |
GET | /users/<id> | Read a single user. |
POST | /users | Create one or many users. |
PATCH | /users | Update many users (three body shapes). |
PATCH | /users/me | Update the current authenticated user. |
PATCH | /users/me/track/page | Record the last admin app page the user visited. |
PATCH | /users/<id> | Update a single user. |
DELETE | /users | Delete many users. |
DELETE | /users/<id> | Delete a single user. |
POST | /users/invite | Email an invitation to one or more recipients. |
POST | /users/invite/accept | Accept an invitation and set the password. |
POST | /users/me/tfa/generate | Begin TFA enrollment for the current user. |
POST | /users/me/tfa/enable | Complete TFA enrollment. |
POST | /users/me/tfa/disable | Disable TFA for the current user. |
POST | /users/<id>/tfa/disable | Admin-only: disable TFA for another user. |
User record fields
Section titled “User record fields”The user record carries identity, account state, and role assignment:
id(UUID) — primary key.first_name,last_name,email— operator-set identity fields.password— Argon2 hash. Always omitted on read; only writable by self or by an admin.location,title,description,tags,avatar,language,theme— display and preference fields.role— UUID reference todirectus_roles.status— one ofdraft,invited,active,suspended,archived. The auth flow rejects logins for any status other thanactive.token— static token. Stored plain text; admin-readable only by default.tfa_secret— TOTP secret. Always omitted on read; written through the TFA endpoints.provider,external_identifier— populated for SSO users to track which provider authenticated them.auth_data— provider-specific data captured during SSO login.last_access,last_page— read-tracking fields populated by the auth flow and/users/me/track/page.
GET /users/me and PATCH /users/me
Section titled “GET /users/me and PATCH /users/me”Convenience routes that resolve to the authenticated user without requiring the caller to know their own ID. Both accept the same query options as /users/<id> and PATCH /users/<id>.
These routes do not bypass permissions. PATCH /users/me runs through the normal accountability path, so a user can update only the fields their role grants update permission on. The default app-access self-update permission covers a limited preference-style field set (theme, language, avatar, and so on); it does not unconditionally grant edit access to the user record. Roles that need broader self-update have to grant it explicitly through directus_permissions.
POST /users/invite
Section titled “POST /users/invite”Invites one or more recipients by email, optionally pointing at a custom invitation URL.
POST /users/inviteContent-Type: application/json
{ "email": ["user@example.com", "another@example.com"], "role": "<role-uuid>", "invite_url": "https://app.example.com/accept-invite"}email accepts a single email or an array. role is the UUID of the role to assign on acceptance. invite_url is optional; without it, the email points at <PUBLIC_URL>/admin/accept-invite. When supplied, the URL must be on the configured USER_INVITE_URL_ALLOW_LIST to prevent open-redirect attacks.
POST /users/invite/accept
Section titled “POST /users/invite/accept”Completes an invitation. The token comes from the URL the recipient receives in their email; password is the new account’s password.
POST /users/invite/acceptContent-Type: application/json
{ "token": "<token-from-email>", "password": "<new-password>"}On success, the invited user’s status flips to active and the password is set.
TFA endpoints
Section titled “TFA endpoints”The TFA endpoints are documented in Authentication / Two-factor authentication. The admin-only POST /users/<id>/tfa/disable is the bypass for a user who has lost their TFA device.
Roles (/roles)
Section titled “Roles (/roles)”A role is a named set of access capabilities. Users belong to a role; permissions belong to a role; the platform applies the role’s access flags and IP restrictions to every authenticated request.
Endpoints
Section titled “Endpoints”| Method | Path | Purpose |
|---|---|---|
GET | /roles | List roles. |
SEARCH | /roles | Read roles with the request body. |
GET | /roles/<id> | Read a single role. |
POST | /roles | Create one or many roles. |
PATCH | /roles | Update many roles. |
PATCH | /roles/<id> | Update a single role. |
DELETE | /roles | Delete many roles. |
DELETE | /roles/<id> | Delete a single role. |
Role record fields
Section titled “Role record fields”id(UUID) — primary key.key— short stable identifier used by config-as-code. If a role is created without akey, the service derives one fromname(lowercased, normalized, deduplicated). The Public role’s key ispublicand is reserved. Once set, a role’s key cannot be changed; create a new role and migrate users instead.name— display name.icon,description— display metadata.admin_access(bool) — whentrue, all permission checks are bypassed. Admins can read and write everything regardless of the permissions table.app_access(bool) — whentrue, the role can sign into the admin app and gets the platform-managed minimum permissions on system collections required for the app to function.enforce_tfa(bool) — whentrue, members of the role are expected to enroll in TFA. The flag is policy carried on the role; the API does not gate requests behind it. See Authentication / Two-factor authentication.ip_access— comma-separated list of allowed source IPs (empty string for unrestricted).users— alias field listing the users assigned to this role.
The Public role
Section titled “The Public role”A reserved role with the sentinel UUID 00000000-0000-0000-0000-000000000000 and the key public. Permissions assigned to this role apply to unauthenticated requests. The platform protects it from deletion and from being assigned to a user; config-as-code captures its permissions but not the role record itself. See Permissions for the full operator-side model.
The “no admin role left after this delete” guard at the engine level enforces that at least one role with admin_access: true always exists. There is no override.
Permissions (/permissions)
Section titled “Permissions (/permissions)”A permission row is a tuple of role, collection, and action plus the rules that gate that action. Every read, create, update, and delete the platform performs is filtered by the calling role’s permissions on the targeted collection.
Endpoints
Section titled “Endpoints”| Method | Path | Purpose |
|---|---|---|
GET | /permissions | List permissions. |
SEARCH | /permissions | Read permissions with the request body. |
GET | /permissions/<id> | Read a single permission. |
POST | /permissions | Create one or many permissions. |
PATCH | /permissions | Update many permissions. |
PATCH | /permissions/<id> | Update a single permission. |
DELETE | /permissions | Delete many permissions. |
DELETE | /permissions/<id> | Delete a single permission. |
Permission record fields
Section titled “Permission record fields”id(auto-incrementing integer) — primary key.role— UUID of the role this permission applies to.collection— the collection name the permission gates.action— one ofcreate,read,update,delete,comment,share.permissions— a filter expression evaluated against the row to decide whether the action is allowed. Uses the same query DSL as request-level filters; see Filters and queries.validation— a filter expression evaluated against the incoming payload for create and update actions. Failures returnFAILED_VALIDATION.presets— default values automatically merged into create payloads.fields— array of field names the role is allowed to see (on read) or modify (on create/update). The wildcard*allows all fields.
A read or write that matches no permission row for a non-admin role is denied. The permissions, validation, and presets filters can reference filter variables ($NOW, $CURRENT_USER, $CURRENT_ROLE) to scope rules per caller.
Permissions on system collections work the same way as permissions on user collections, with one caveat: the platform-managed minimum permissions for app-access roles are projected at read time rather than stored as rows, so they are invisible to /permissions queries. See Config as code / What a config snapshot captures for the full picture.
Shares (/shares)
Section titled “Shares (/shares)”A share is a public-link grant that lets unauthenticated visitors view a specific item with a specific role’s permissions. Useful for sharing a draft article with an external reviewer, exposing a private dashboard to a stakeholder, and similar scoped-access scenarios.
Endpoints
Section titled “Endpoints”| Method | Path | Purpose |
|---|---|---|
GET | /shares | List shares. |
SEARCH | /shares | Read shares with the request body. |
GET | /shares/<id> | Read a single share. |
GET | /shares/info/<id> | Public-readable subset of share metadata (password requirement, validity dates). |
POST | /shares | Create one or many shares. |
PATCH | /shares | Update many shares. |
PATCH | /shares/<id> | Update a single share. |
DELETE | /shares | Delete many shares. |
DELETE | /shares/<id> | Delete a single share. |
POST | /shares/auth | Exchange a share ID and optional password for a scoped access token. |
POST | /shares/invite | Email a share link to one or more recipients. |
Share record fields
Section titled “Share record fields”id(UUID) — primary key. Used in the shareable URL.name— operator-set label.collection,item— the row the share grants access to.role— UUID of the role whose permissions apply to share visitors. Often a custom read-only role with a narrow filter.password— optional password gating the share. Stored hashed; visitors authenticate by submitting the password to/shares/auth.max_uses— optional cap on how many times the share can be activated.times_used— counter incremented on each successful activation.date_start,date_end— optional validity window.date_created,user_created— accountability.
POST /shares/auth
Section titled “POST /shares/auth”The visitor-side login. Returns an access token scoped to the share’s role and item.
POST /shares/authContent-Type: application/json
{ "share": "<share-id>", "password": "<password-if-required>"}The response body carries access_token and expires in the standard data envelope. The refresh token is set as the configured refresh-token cookie rather than returned in the body, so visitor clients in a browser context get automatic refresh without having to handle the token directly. The returned access token works the same way a regular access token does, but resolves to a constrained accountability that only sees the shared item.
GET /shares/info/<id>
Section titled “GET /shares/info/<id>”A public endpoint (no token required) that returns the bare minimum needed to render a share-access UI: whether a password is required, whether the share is currently within its validity window, and how many uses remain. The full record is not exposed; clients call this before prompting for a password.
POST /shares/invite
Section titled “POST /shares/invite”Sends share-link emails to one or more recipients. Requires create permission on directus_shares.
POST /shares/inviteContent-Type: application/json
{ "share": "<share-id>", "emails": ["reviewer@example.com"]}The email contains the share URL with the share’s ID embedded. The invitation does not include the password (if one is set); the operator must communicate that out-of-band.
Config-as-code endpoints
Section titled “Config-as-code endpoints”Two endpoints snapshot and apply role and permission state across deployments:
| Method | Path | Purpose |
|---|---|---|
GET | /config/snapshot | Return the current roles and permissions as a CairnConfig payload. |
POST | /config/apply | Apply a CairnConfig payload, with optional dry-run and destructive flags. |
These are admin-only and operator-facing rather than collection-CRUD. They wrap the same engine that powers cairncms config snapshot and cairncms config apply. See Config as code for the full reference, including the payload shape, the dry-run and destructive flags, the field-level omit-vs-null semantics, and the validation surface.
In short:
GET /config/snapshotreturns JSON by default. Pass?export=yamlto get a YAML attachment instead.POST /config/applyaccepts JSON or any of three YAML media types. Pass?dry_run=trueto preview the plan without writing; pass?destructive=trueto allow deletion of orphan roles and permissions.
There is no /config/diff endpoint. The apply endpoint computes the plan internally on every call.
GraphQL
Section titled “GraphQL”All four collections are exposed on /graphql/system with the standard generated CRUD shape (users, users_by_id, create_users_item, etc.; same for roles, permissions, shares). Filter, sort, and pagination arguments work the same way as on user collections; see Filters and queries / GraphQL.
The auth mutations on /graphql/system are documented in Authentication / GraphQL. They include users_invite, users_invite_accept, and the current-user TFA mutations (users_me_tfa_generate, users_me_tfa_enable, users_me_tfa_disable). The admin-only POST /users/<id>/tfa/disable does not have a GraphQL equivalent; use REST when an admin needs to disable TFA on another user’s account.
The config-as-code endpoints are REST-only.
Permission semantics for these collections
Section titled “Permission semantics for these collections”The four access-control collections are gated by their own permissions, which produces a meta-level question: who can edit directus_permissions?
By default:
directus_users— app-access roles get read on their own row (a limited field set covering the user’s own profile and TFA secret), and update on a similarly-scoped subset of fields (first_name, last_name, email, password, location, title, description, avatar, language, theme, tfa_secret).PATCH /users/meruns through the normal accountability path against this projected permission, so the writable field set is exactly what the role permits and not broader. Anything outside that field set requires admin or a custom permission grant.directus_roles— app-access roles get read on their own role only (filtered byid: { _eq: $CURRENT_ROLE }). Writes are admin-only.directus_permissions— app-access roles get read on permissions belonging to their own role (filtered byrole: { _eq: $CURRENT_ROLE }). Writes are admin-only.directus_shares— app-access roles get read on shares they themselves created (filtered byuser_created: { _eq: $CURRENT_USER }). Create, update, and delete are admin-only by default; operators frequently grant create access to specific roles that need to issue shares.
Granting non-admin write access to directus_permissions is a privilege escalation surface (a role with permission to edit permissions can grant itself anything). Treat any grant on directus_permissions outside of admin as a deliberate decision with the corresponding scrutiny.
Where to go next
Section titled “Where to go next”- Authentication — the auth, TFA, and password-reset flows that work against the user collection.
- Filters and queries — the filter DSL used by both request-level filters and permission rules.
- Permissions — the operator-side model for designing role and permission sets.
- Config as code — the full reference for the
/config/*endpoints and the CLI equivalents. - Users — the operator-side workflows for inviting, suspending, and managing users in the admin app.