Skip to content

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.

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.

MethodPathPurpose
GET/usersList users.
SEARCH/usersRead users with the request body.
GET/users/meThe current authenticated user.
GET/users/<id>Read a single user.
POST/usersCreate one or many users.
PATCH/usersUpdate many users (three body shapes).
PATCH/users/meUpdate the current authenticated user.
PATCH/users/me/track/pageRecord the last admin app page the user visited.
PATCH/users/<id>Update a single user.
DELETE/usersDelete many users.
DELETE/users/<id>Delete a single user.
POST/users/inviteEmail an invitation to one or more recipients.
POST/users/invite/acceptAccept an invitation and set the password.
POST/users/me/tfa/generateBegin TFA enrollment for the current user.
POST/users/me/tfa/enableComplete TFA enrollment.
POST/users/me/tfa/disableDisable TFA for the current user.
POST/users/<id>/tfa/disableAdmin-only: disable TFA for another user.

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 to directus_roles.
  • status — one of draft, invited, active, suspended, archived. The auth flow rejects logins for any status other than active.
  • 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.

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.

Invites one or more recipients by email, optionally pointing at a custom invitation URL.

POST /users/invite
Content-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.

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/accept
Content-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.

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.

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.

MethodPathPurpose
GET/rolesList roles.
SEARCH/rolesRead roles with the request body.
GET/roles/<id>Read a single role.
POST/rolesCreate one or many roles.
PATCH/rolesUpdate many roles.
PATCH/roles/<id>Update a single role.
DELETE/rolesDelete many roles.
DELETE/roles/<id>Delete a single role.
  • id (UUID) — primary key.
  • key — short stable identifier used by config-as-code. If a role is created without a key, the service derives one from name (lowercased, normalized, deduplicated). The Public role’s key is public and 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) — when true, all permission checks are bypassed. Admins can read and write everything regardless of the permissions table.
  • app_access (bool) — when true, 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) — when true, 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.

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.

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.

MethodPathPurpose
GET/permissionsList permissions.
SEARCH/permissionsRead permissions with the request body.
GET/permissions/<id>Read a single permission.
POST/permissionsCreate one or many permissions.
PATCH/permissionsUpdate many permissions.
PATCH/permissions/<id>Update a single permission.
DELETE/permissionsDelete 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.
  • id (auto-incrementing integer) — primary key.
  • role — UUID of the role this permission applies to.
  • collection — the collection name the permission gates.
  • action — one of create, 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 return FAILED_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.

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/42

The response contains an ItemPermissions object.

{
"data": {
"update": { "access": true, "fields": ["*"] },
"delete": { "access": false },
"share": { "access": false }
}
}
  • update.access, delete.access, and share.access show whether the user can perform each action on the item.
  • update.fields lists writable fields. ["*"] allows all fields that support updates. A list of names allows only those fields. [] allows no fields. null means 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.

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 null returns INVALID_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.

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.

MethodPathPurpose
GET/sharesList shares.
SEARCH/sharesRead 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/sharesCreate one or many shares.
PATCH/sharesUpdate many shares.
PATCH/shares/<id>Update a single share.
DELETE/sharesDelete many shares.
DELETE/shares/<id>Delete a single share.
POST/shares/authExchange a share ID and optional password for a scoped access token.
POST/shares/inviteEmail a share link to one or more recipients.
  • 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.

The visitor-side login. Returns an access token scoped to the share’s role and item.

POST /shares/auth
Content-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.

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.

Sends share-link emails to one or more recipients. Requires create permission on directus_shares.

POST /shares/invite
Content-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.

Two endpoints snapshot and apply role and permission state across deployments:

MethodPathPurpose
GET/config/snapshotReturn the current roles and permissions as a CairnConfig payload.
POST/config/applyApply 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/snapshot has no manifest request body. Optional manifest_version and comma-separated resources parameters 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=yaml for a YAML attachment.
  • POST /config/apply takes a manifest in its JSON or YAML body, so that manifest defines managed scope. Pass ?dry_run=true to preview without writing or ?destructive=true to authorize deletions. Each flag must be exactly true or false, and any other value is rejected with CONFIG_INVALID before any state is read. A dry run returns the plan as data; a mutating apply returns the result as data and the plan under meta.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.

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/me runs 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 by id: { _eq: $CURRENT_ROLE }). Writes are admin-only.
  • directus_permissions — app-access roles get read on permissions belonging to their own role (filtered by role: { _eq: $CURRENT_ROLE }). Writes are admin-only.
  • directus_shares — app-access roles get read on shares they themselves created (filtered by user_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.

  • 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.