---
title: "Access control"
description: "Capabilities, built-in roles, custom roles and the access helpers."
requested_language: sk
language: en
translation_notice: "This page isn't translated yet"
url: https://docs.systhema.app/sk/payload/access-control
version: unreleased (main)
docs_index: https://docs.systhema.app/sk/llms.txt
---
> This page isn't translated yet. Showing English.


`@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 format

- `{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 roles

| Role         | Capabilities                                                                                                                                                                                                                                                                                                  |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `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.                                                                                           |
| `admin`      | All built-in Systhema features (including `global.*` so the Cookie Consent tab is editable out of the box).                                                                                                                                                                                                   |
| `editor`     | Read + 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.                                                                               |
| `seoManager` | SEO 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). |
| `publisher`  | Full posts + taxonomies + publish + media upload (Posts module only — see below).                                                                                                                                                                                                                             |
| `author`     | Write own posts + SEO + media upload, read taxonomies; no publish/delete (Posts module only).                                                                                                                                                                                                                 |

`publisher` and `author` register **only when the [Posts module](https://docs.systhema.app/sk/payload/posts.md) 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.*`:

| Capability                                     | Registered when                           | Purpose                                                         |
| ---------------------------------------------- | ----------------------------------------- | --------------------------------------------------------------- |
| `global.general-settings.read`                 | always                                    | Full read of every tab.                                         |
| `global.general-settings.update`               | always                                    | Bulk update for non-tab-scoped fields.                          |
| `global.general-settings.pages.update`         | always                                    | Edit only the Pages tab.                                        |
| `global.general-settings.pages.read`           | always                                    | Read only the Pages tab's fields.                               |
| `global.general-settings.troubleshoot.update`  | always                                    | Use the Troubleshoot tab's actions (e.g. revalidate all pages). |
| `global.general-settings.posts.update`         | `posts` enabled                           | Edit only the Posts tab (implies read).                         |
| `global.general-settings.posts.read`           | `posts` enabled                           | Read only the Posts tab's fields.                               |
| `global.general-settings.seo.update`           | `seo` is not disabled (default on)        | Edit only the SEO tab (implies read of it).                     |
| `global.general-settings.seo.read`             | `seo` is not disabled (default on)        | Read only the SEO tab's fields.                                 |
| `global.general-settings.emails.update`        | `emails:` configured + email adapter live | Edit only the Emails tab (implies read).                        |
| `global.general-settings.emails.read`          | `emails:` configured + email adapter live | Read only the Emails tab's fields.                              |
| `global.general-settings.cookieConsent.update` | `cookieConsent.enabled` is `true`         | Edit only the Cookie Consent tab (implies read).                |
| `global.general-settings.cookieConsent.read`   | `cookieConsent.enabled` is `true`         | Read 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](https://docs.systhema.app/sk/guides/cookie-consent/editor.md#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.

> [!NOTE]
> **Cosmetic limitation:** when a user's capabilities read-deny every field in a tab, Payload still renders that tab's (empty) header in the admin UI — there's no per-request way to hide a static tab definition server-side. This doesn't leak data (the fields themselves render nothing), just an empty tab label.

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):

| Capability      | Granted to (default)              | Purpose                                                                             |
| --------------- | --------------------------------- | ----------------------------------------------------------------------------------- |
| `ai.generate`   | `dev`, `admin`, `editor`          | Gate all AI generation (compose, editor actions, image, SEO, alt-text, whole-page). |
| `ai.usage.read` | `dev` only (via its `*` wildcard) | Read the hidden `systhema-ai-usage` log (dev-only by default).                      |

Likewise, when the [analytics dashboard](https://docs.systhema.app/sk/payload/analytics.md) is enabled, one more conditional capability is registered:

| Capability       | Granted to (default)                   | Purpose                                                            |
| ---------------- | -------------------------------------- | ------------------------------------------------------------------ |
| `analytics.read` | `dev`, `admin`, `editor`, `seoManager` | Read the analytics data endpoints (and see the dashboard widgets). |

Likewise, when the [Cloudflare integration](https://docs.systhema.app/sk/payload/cloudflare.md) is enabled, three more conditional capabilities are registered:

| Capability                 | Granted to (default)                   | Purpose                                                                           |
| -------------------------- | -------------------------------------- | --------------------------------------------------------------------------------- |
| `cloudflare.read`          | `dev`, `admin`, `editor`, `seoManager` | Read the Cloudflare data endpoints (and see the dashboard widgets).               |
| `cloudflare.purge`         | `dev`, `admin`, `editor`               | Purge the edge cache (the `/purge` endpoint and the admin-bar buttons).           |
| `global.cloudflare.update` | `dev`, `admin`                         | Patch zone settings, run the Optimize bundle, and edit the Cloudflare admin page. |

## Custom roles & capabilities

Pass them through the plugin options:

```ts
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 helpers

Use these inside collection/field `access` blocks:

```ts
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.
