Docs
Systhema Design (opens in new tab)
Unreleased

Access control

Capabilities, built-in roles, custom roles and the access helpers.

On this page

@systhemaui/payload ships a capability-based access-control system. Roles map to capabilities (dot-notation strings), and access functions check capabilities instead of role names.

Capability formatLink to this section

  • {collection}.{operation} — pages.create, media.delete, users.read.
  • {collection}.{field}.{operation} — pages.seo.update.
  • global.{global}.{operation} — global.header.update.
  • global.{global}.{tab}.{operation} — global.general-settings.cookieConsent.update.
  • * — wildcard, all capabilities.

Built-in rolesLink to this section

RoleCapabilities
dev* (everything). Hidden from non-dev users in every admin list and relationship picker, such as a post's Author, unless the account also holds admin. A field already set to a hidden account shows only its ID.
adminAll built-in Systhema features (including global.* so the Cookie Consent tab is editable out of the box).
editorRead + update content collections. Sees only the SEO tab of General Settings, read-only (global.general-settings.seo.read) — the rest of the global is invisible, matching the standalone SEO global's old read behavior.
seoManagerSEO fields across collections, plus the SEO tab of General Settings only — read and update (global.general-settings.seo.*). No visibility into Pages, Emails, or Cookie Consent fields (the data-free Troubleshoot tab renders for anyone who can open the global, but its action is capability-gated).
publisherFull posts + taxonomies + publish + media upload (Posts module only — see below).
authorWrite own posts + SEO + media upload, read taxonomies; no publish/delete (Posts module only).

publisher and author register only when the Posts module is enabled, so they stay hidden from the Users role select otherwise.

Assigning a role or capability that the acting user does not hold is refused with an error. Earlier versions silently removed the ungrantable value. A non-dev user can never assign the dev role, and a user cannot remove every role from their own account.

Uploading files requires the uploads.create capability. The Uploads collection gates create on it (consistent with read/update/delete), so a role granted only uploads.read is read-only on media. Built-in content roles carry it — admin/editor via uploads.*, seoManager, and the posts module's author/publisher — so a custom role that should be able to upload must include uploads.create (or uploads.*).

The General Settings global ships a base read + bulk-update capability, plus one tab-scoped update and one tab-scoped read capability per data-carrying tab (some tabs are always registered, others only when their gating option is on — see the "Registered when" column; the data-free Troubleshoot tab has an update capability only). All default to the dev role and are inherited by admin via global.*:

CapabilityRegistered whenPurpose
global.general-settings.readalwaysFull read of every tab.
global.general-settings.updatealwaysBulk update for non-tab-scoped fields.
global.general-settings.pages.updatealwaysEdit only the Pages tab.
global.general-settings.pages.readalwaysRead only the Pages tab's fields.
global.general-settings.troubleshoot.updatealwaysUse the Troubleshoot tab's actions (e.g. revalidate all pages).
global.general-settings.posts.updateposts enabledEdit only the Posts tab (implies read).
global.general-settings.posts.readposts enabledRead only the Posts tab's fields.
global.general-settings.seo.updateseo is not disabled (default on)Edit only the SEO tab (implies read of it).
global.general-settings.seo.readseo is not disabled (default on)Read only the SEO tab's fields.
global.general-settings.emails.updateemails: configured + email adapter liveEdit only the Emails tab (implies read).
global.general-settings.emails.reademails: configured + email adapter liveRead only the Emails tab's fields.
global.general-settings.cookieConsent.updatecookieConsent.enabled is trueEdit only the Cookie Consent tab (implies read).
global.general-settings.cookieConsent.readcookieConsent.enabled is trueRead only the Cookie Consent tab's fields.

Field-level read is enforced server-side (REST, GraphQL, and the Local API alike — not just the admin UI): a user can read a given tab's fields if they hold any of global.general-settings.read, global.general-settings.update, the tab's own .read capability, or the tab's own .update capability (update implies read). Holding global.general-settings.read — the pre-existing capability — keeps its original meaning of "see every tab" unchanged, so any custom role or per-user grant that already has it is unaffected. Opening the global at all (its entry in the admin nav) requires holding at least one of the capabilities above for at least one registered tab. The UI-only SEO Preview field has no data of its own, so it isn't gated — it simply renders whatever title/description the viewer is otherwise allowed to read (blank if those fields are read-denied).

To let a non-dev user manage one of the tabs, register a custom role with the tab-scoped capability — see Cookie consent → Access control for a worked example. The same pattern applies to global.general-settings.seo.* and global.general-settings.emails.*. The built-in seoManager role receives only global.general-settings.seo.* (the SEO tab's read + update pair) — not global.general-settings.read — so it sees exclusively the SEO tab and nothing else of General Settings. The built-in editor role receives only global.general-settings.seo.read — read-only visibility of the SEO tab, the same scope editors had when SEO was a standalone global.

When the AI assistant is enabled (ai.enabled), two more capabilities are registered (like the conditional Cookie Consent / Emails ones — absent entirely when AI is off):

CapabilityGranted to (default)Purpose
ai.generatedev, admin, editorGate all AI generation (compose, editor actions, image, SEO, alt-text, whole-page).
ai.usage.readdev only (via its * wildcard)Read the hidden systhema-ai-usage log (dev-only by default).

Likewise, when the analytics dashboard is enabled, one more conditional capability is registered:

CapabilityGranted to (default)Purpose
analytics.readdev, admin, editor, seoManagerRead the analytics data endpoints (and see the dashboard widgets).

Likewise, when the Cloudflare integration is enabled, three more conditional capabilities are registered:

CapabilityGranted to (default)Purpose
cloudflare.readdev, admin, editor, seoManagerRead the Cloudflare data endpoints (and see the dashboard widgets).
cloudflare.purgedev, admin, editorPurge the edge cache (the /purge endpoint and the admin-bar buttons).
global.cloudflare.updatedev, adminPatch zone settings, run the Optimize bundle, and edit the Cloudflare admin page.

Custom roles & capabilitiesLink to this section

Pass them through the plugin options:

const userSysthemaConfig: SysthemaPayloadPluginOptions = {
  roles: {
    custom: [
      {
        slug: 'translator',
        label: 'Translator',
        capabilities: ['pages.update', 'pages.translations.update'],
      },
    ],
    extend: {
      // Add capabilities to a built-in role.
      editor: { capabilities: ['custom.read'] },
    },
  },
}

Access helpersLink to this section

Use these inside collection/field access blocks:

import {
  capabilityOrPublished,
  capabilityOrSelf,
  publicOrCapability,
  requireAnyCapability,
  requireAnyCapabilityField,
  requireCapability,
  requireCapabilityField,
  requireUpdateOrScoped,
  addScopedFieldAccess,
} from '@systhemaui/payload'

export const Pages: CollectionConfig = {
  slug: 'pages',
  access: {
    create: requireCapability('pages.create'),
    delete: requireCapability('pages.delete'),
    read: capabilityOrPublished('pages.read'),
    // Pass just the collection SLUG: grants update if the user has `pages.update`
    // OR any scoped `pages.*.update` capability.
    update: requireUpdateOrScoped('pages'),
  },
  // The scoped field carries its OWN `access.update`; `addScopedFieldAccess`
  // leaves those untouched and locks EVERY other field to the collection-level
  // update capability passed as the second argument (a string).
  fields: addScopedFieldAccess(
    [
      {
        name: 'seo',
        type: 'group',
        access: { update: requireCapabilityField('pages.seo.update') },
        fields: [/* ... */],
      },
      // Your other fields — automatically guarded by `pages.update`.
    ],
    'pages.update',
  ),
}

Helper reference:

  • requireCapability(cap) — collection-level boolean access (works on admin, create, delete).
  • requireCapabilityField(cap) — field-level access.
  • requireAnyCapability(...caps) — collection-level "any of" access.
  • publicOrCapability(cap) — public reads OK; capability gate for admin.
  • capabilityOrPublished(cap) — capability holders see drafts; everyone else sees published.
  • capabilityOrSelf(cap) — capability holders see all; otherwise only own records.
  • requireUpdateOrScoped(updateCap, scopedCaps) — full update OR scoped field-level update.
  • addScopedFieldAccess(fields, accessMap) — apply field-level access functions by field path.