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.
/roles writes that change administrator access use a serializable transaction. A conflicting write returns 409 CONCURRENCY_CONFLICT without changes; re-read the role and retry manually.
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. |
GET | /permissions/me/<collection>/<key> | Check the current user’s permissions for one item. |
GET | /permissions/me/<collection> | Check the current user’s permissions for a singleton. |
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.
Check permissions for an item
Section titled “Check permissions for an item”Use GET /permissions/me/<collection>/<key> to check whether the current user can update, delete, or share an item. Your app can use the result to enable or disable buttons and fields. In the SDK, use readItemPermissions(collection, key?).
CairnCMS checks the item’s saved values. It checks permissions again when you submit an update, delete, or share request. A successful check does not guarantee that a later request will succeed.
These routes require an authenticated user. Anonymous requests and requests using a share link receive 401.
GET /permissions/me/articles/42The response contains an ItemPermissions object.
{ "data": { "update": { "access": true, "fields": ["*"] }, "delete": { "access": false }, "share": { "access": false } }}update.access,delete.access, andshare.accessshow whether the user can perform each action on the item.update.fieldslists writable fields.["*"]allows all fields that support updates. A list of names allows only those fields.[]allows no fields.nullmeans update is denied.
For a singleton collection, which holds at most one item, use GET /permissions/me/<collection> without a key. You must be an administrator or have update, delete, or share permission on the collection. An empty singleton returns false for all three actions. Its first save requires create permission.
Other collections return 400 without a key. Non-admin users with none of these collection permissions also receive 400 without a key, even for singletons or unknown collections.
With a key, the response does not distinguish a missing item from one the user cannot update, delete, or share. Both return false for every access value and null for update.fields. Unknown collections and invalid keys return the same result.
Failures other than permission denials return the usual API error response.
These checks can trigger query hooks, read hooks, and flows even if the user lacks read permission. User and system collections keep their usual event names. The number of events can vary by request.
Updating related items
Section titled “Updating related items”Include the primary key when updating a related item or importing changes to an existing record. The API uses the key to select the record and leaves it out of the update data passed to hooks and permission validation. Update hooks receive record IDs in meta.keys. You can still supply a primary key when creating a record.
The field that links a related item to its parent has these rules.
- If the item is already linked to the parent, leaving out the link field keeps it out of the update data. The stored link stays unchanged.
- Supplying the link field requires permission to update it, even if its value is unchanged. Moving an item to another parent also requires this permission.
- A supplied link must match the parent being saved. A different parent ID or
nullreturnsINVALID_PAYLOAD. - When creating a parent and its related items together, leave out the link field. CairnCMS sets it after creating the parent.
Submitting only the key of an item already linked to the parent does not update that item or emit its update event. The caller must still be authorized for that item: either permission to update it, or permission to read its link to the parent (its link field, or its presence in the parent’s list of related items). A caller with neither is denied, so the response cannot reveal which items are linked. These checks can read the related item or its parent and trigger query hooks, read hooks, and flows. Submitting field values still counts as an update, even if the values are unchanged.
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 admin-only endpoints back remote CLI mode and custom HTTP automation. CLI users do not need to construct the requests themselves. See Config as code for the full reference.
In short:
GET /config/snapshothas no manifest request body. Optionalmanifest_versionand comma-separatedresourcesparameters select the manifest in the response. Omission means the current version and all kinds;resources=means an empty managed scope. The remote CLI derives these values from the local manifest automatically. Pass?export=yamlfor a YAML attachment.POST /config/applytakes a manifest in its JSON or YAML body, so that manifest defines managed scope. Pass?dry_run=trueto preview without writing or?destructive=trueto authorize deletions. Each flag must be exactlytrueorfalse, and any other value is rejected withCONFIG_INVALIDbefore any state is read. A dry run returns the plan asdata; a mutating apply returns the result asdataand the plan undermeta.plan.
There is no /config/diff endpoint. The apply endpoint computes the plan internally on every call.
The cairncms CLI can call these endpoints on a remote instance with cairncms config apply --url and cairncms config snapshot --url, authenticating with an administrator token. See Config as code / Applying to a remote instance.
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.