Roles and Permissions
How KDIGI CMS decides who can read and write what: the role hierarchy, site-scoped access predicates, public versus authenticated reads, field-level secrets, and how users are assigned to sites.
Related: multi-site.md (the Sites model), collections.md (per-collection fields), rest-api.md (endpoints), architecture.md.
Terminology. The Sites collection (
sites) is the tenant boundary. Prose says "site"; code, the generated types and REST payloads call the same thingtenant(relationship fieldtenant, user fieldtenants[], cookiepayload-tenant).
The role select on Users (src/collections/users.ts) is required and defaults to editor. Helper predicates live in src/access/role-matrix.ts.
Hierarchy (additive): super-admin > admin > editor > author.
| Role | Scope | Summary |
|---|---|---|
super-admin | All sites | Bypasses every site filter. Only role that can create or delete users and sites, edit per-site storage settings (sites.r2), assign users to sites, and run jobs. |
admin | Assigned sites | Content CRUD, publishing, comment moderation, site settings, permanent delete (empty trash). |
editor | Assigned sites | Content CRUD, publishing, comment moderation, move to trash. |
author | Assigned sites | Create content and edit/trash only their own documents. Cannot publish. Cannot moderate comments. |
The first user created on a fresh install automatically becomes super-admin; see Assigning users to sites.
Helper predicates (src/access/role-matrix.ts)
Section titled “Helper predicates (src/access/role-matrix.ts)”| Helper | Roles that pass | Used for |
|---|---|---|
canManageContent | super-admin, admin, editor, author | create/update/trash on pages, posts, products, landings |
canPublish | super-admin, admin, editor | publish rule (author excluded; enforced by a hook, see below) |
canManageSiteSettings | super-admin, admin | site settings |
canModerateComments | super-admin, admin, editor | update/delete on comments |
canPermanentlyDelete | super-admin, admin | hard delete / empty trash |
isAuthorRole | author | ownership clauses and publish guard |
The author rules
Section titled “The author rules”Authors are restricted in two places:
- Access predicates (pages, posts, products, landings):
updatereturns{ createdBy: { equals: user.id } }forauthor; trash (deleteaccess) adds the same clause. enforceAuthorPermissions(src/hooks/enforce-author-permissions.ts,beforeChange):- throws a bilingual error if the effective status (
data.status ?? originalDoc.status) ispublished, so an author can neither publish nor edit a live document in place, nor restore a published document from the trash. Moving their own document to the trash is allowed; - sets
createdBy = user.idon create (createdByhas field-levelupdate: () => false, so it cannot be reassigned afterwards).
- throws a bilingual error if the effective status (
Authors can still create and edit taxonomies, authors, menus, faqs, shortcodes, redirects and media inside their sites: those collections use the plain site-scoped predicates without ownership clauses. Grant author accordingly.
Site-scoped predicates
Section titled “Site-scoped predicates”Defined in src/access/tenant-scoped.ts; membership helpers in src/lib/admin-selected-tenant.ts.
| Predicate | Behaviour |
|---|---|
tenantScopedRead | No user → false. super-admin → true. Otherwise { tenant: { in: <user's site ids> } }; a user with no sites gets false. |
tenantScopedWrite | Same shape, used for create/update/delete. |
tenantScopedVersionsRead | Version history (readVersions); same rule on version.tenant. Anonymous callers get nothing. |
hasAnyTenantOrSuperAdmin | Boolean gate for media and payload-folders create (the row does not exist yet): super-admin, or a user who belongs to at least one site. |
makeContentDeleteAccess | src/access/content-delete-access.ts. Trash = update with deletedAt; permanent delete = no data. Trash: any canManageContent role (authors only own docs, where createdBy exists); permanent: canPermanentlyDelete. Always AND-ed with tenantScopedWrite. |
publishedAndLive | src/access/published-and-live.ts. Anonymous content read, see below. |
A user's site ids come from user.tenants[].tenant (the array is injected on Users by the multi-tenant plugin).
Site ids supplied by the client
Section titled “Site ids supplied by the client”Endpoints and admin widgets that accept a site id (?tenantId=, body field, or the payload-tenant cookie) never trust it directly (src/lib/admin-selected-tenant.ts, src/lib/resolve-tenant-from-request.ts). The id is honoured only for a super-admin or a member of that site; otherwise it falls back to the user's first site. If nothing resolves, endpoints answer 400 rather than run unscoped.
Plugin filter versus the real boundary
Section titled “Plugin filter versus the real boundary”@payloadcms/plugin-multi-tenant is configured with tenantsSlug: 'sites' (src/payload.config.ts). It contributes two different things:
| Layer | What it does | Treat as |
|---|---|---|
Admin list filter (baseFilter + site selector, payload-tenant cookie) | Narrows lists in the admin UI to the selected site. | Advisory. A UI convenience, not a security control. |
Repo access predicates (src/access/*) | Explicit where constraints on every operation. | The boundary. Every site-scoped collection declares them itself. |
In the pinned plugin version (3.90.2) the plugin additionally wraps the access functions of listed collections, sites and users with a membership constraint for non-super-admin users (withTenantAccess in the plugin's dist/utilities). That is defence in depth; do not rely on it alone. When adding a site-scoped collection, add it to the plugin's collections map and declare tenantScopedRead/tenantScopedWrite (and readVersions if it has versions) on the collection.
media and payload-folders are not plugin-managed; they declare their own tenant field and predicates (src/collections/media.ts, src/collections/payload-folders-tenant-scope.ts).
Public versus authenticated reads
Section titled “Public versus authenticated reads”| Caller | Content collections (pages, posts, products, landings) |
|---|---|
| Anonymous | publishedAndLive(): not trashed (deletedAt absent), status = 'published', and publishedAt missing or <= now (evaluated per request, so scheduled posts go live on time). |
| Authenticated, non-super-admin | tenantScopedRead: all statuses (including drafts) for their sites. |
| Super-admin | Everything. |
Anonymous reads are not filtered by site. Neither publishedAndLive() nor the public read: () => true rules add a tenant clause, so a frontend MUST add where[tenant][equals]=<siteId> to every query or it will mix content from all sites. See multi-site.md.
API keys (Users has auth.useAPIKey: true) authenticate as the owning user and are subject to the same role and site rules. Authorization: users API-Key <key> is the supported header form.
Field-level secrets and protected fields
Section titled “Field-level secrets and protected fields”| Field | Rule |
|---|---|
sites.r2.accessKeyId, sites.r2.secretAccessKey | access.read: () => false: never in REST or GraphQL responses. Server code reads them with the Local API and overrideAccess: true. |
sites.r2 group | access.update: super-admin only. |
authors.email | access.read: () => false. |
comments.authorEmail | access.read: () => false. |
media.tenant, payload-folders.tenant | access.update: super-admin only (no reassigning ownership). |
posts/pages/products/landings.createdBy | access.update: () => false. |
commentCount (posts, products), kkStarRating (products) | access.update: () => false; maintained by hooks and the rating endpoint. |
users.tenants[] | Create/update: super-admin only (plugin arrayFieldAccess default with userHasAccessToAllTenants). |
Secrets are written through the write-only editors in the Sites form, which call POST /api/sites/:id/secret (super-admin only; allow-listed paths r2.accessKeyId, r2.secretAccessKey). GET /api/sites/:id/secret?field=<path> returns only { field, hasValue }, never the value (src/endpoints/site-secret-endpoint.ts).
overrideAccess: truebypasses field access too. Any custom handler that uses the Local API withoverrideAccess: truecan read secret fields. Such handlers must restrict what they return. The publicGET /api/sites/:siteKey/env-pageendpoint deliberately usesoverrideAccess: false.
Per-collection access summary
Section titled “Per-collection access summary”"Site-scoped" means the predicates above (super-admin all; others only their sites). "Public" means no authentication required.
| Collection | Read (anonymous) | Read (authenticated) | Create | Update | Delete |
|---|---|---|---|---|---|
users | none | super-admin all; others own document only | super-admin | super-admin any; others own document, but role and tenants are super-admin-only (writes by others are dropped) | super-admin |
sites | public | public rule; plugin narrows non-super-admin to assigned sites | super-admin | super-admin, admin (plugin narrows to assigned sites); r2 group super-admin only | super-admin |
media | public | public | any user with ≥1 site (hasAnyTenantOrSuperAdmin) | site-scoped | site-scoped |
payload-folders | none | site-scoped | hasAnyTenantOrSuperAdmin | site-scoped | site-scoped |
pages, posts, products, landings | publishedAndLive() | site-scoped (+ readVersions) | canManageContent | canManageContent; author only own | trash: canManageContent (author own); permanent: super-admin/admin; all site-scoped |
taxonomies | public | site-scoped (+ readVersions) | site-scoped | site-scoped | site-scoped |
authors | public (email hidden) | site-scoped | site-scoped | site-scoped | site-scoped |
menus | public | site-scoped | site-scoped | site-scoped | site-scoped |
faqs | public, not trashed | site-scoped (+ readVersions) | site-scoped | site-scoped | trash: any site-scoped writer; permanent: super-admin/admin |
shortcodes | public, not trashed | site-scoped (+ readVersions) | site-scoped | site-scoped | as faqs |
redirects | public | site-scoped | site-scoped | site-scoped | site-scoped |
comments | status = 'approved' only | site-scoped | site-scoped (public submissions use POST /api/comments/create) | canModerateComments + site-scoped | canModerateComments + site-scoped |
rating-events | none | none | none | none | none (written only by POST /api/ratings/submit with overrideAccess: true) |
Notes:
- The
faqsandshortcodesdelete rule usesmakeContentDeleteAccess({ hasCreatedBy: false }): they have nocreatedBy, so there is no author ownership clause, but the "permanent delete is admin-only" rule applies. - Trash (
trash: true) is enabled onpages,posts,products,landings,faqs,shortcodes. The anonymous predicates includedeletedAt: { exists: false }, so?trash=truecannot widen a public read. - Authenticated reads include trashed rows only when the caller passes
trash=true.
Assigning users to sites
Section titled “Assigning users to sites”- The very first user of a fresh install (created on the admin's "create first user" screen) is promoted to
super-adminregardless of the submitted role (promoteFirstUserToSuperAdmin, abeforeChangehook on Users). Every later user keeps the role it is created with, and creating users is super-admin-only. - A super-admin creates the user in Users and picks a
role(defaulteditor). - On the same document the super-admin adds one row per site to the
tenantsarray (label "Website" in the admin). The array is added by the multi-tenant plugin; do not redeclare it. - The user then sees only those sites in the admin site selector and in API reads/writes. A user with no rows has no access to any site-scoped document.
- Non-super-admin users can edit their own account (password, API key) but cannot change their own
roleortenants: therolefield hasaccess.updatesuper-admin-only and the plugin'stenantsArrayField.arrayFieldAccess.updateis super-admin-only (src/payload.config.ts). Regression:src/collections/users-self-escalation.integration.test.ts.
A site-scoped create through the REST API must carry tenant in the body: the field's default comes from the admin payload-tenant cookie, which API clients do not have. The plugin validates that the submitted site is one the user is assigned to (super-admins may use any).
Sessions: Users is configured with a 180-day token expiration (auth.tokenExpiration), so the admin login persists across browser restarts.
Demo accounts
Section titled “Demo accounts”For a public sandbox login, list the account's email in DEMO_ACCOUNT_EMAILS (configuration). Give it the editor role and a single site (for example demo): the ordinary role and site predicates above already keep it out of other sites, user management and site secrets. On top of that, lockDemoAccountSelfChange (beforeValidate) and blockDemoAccountSelfDelete (beforeDelete) in src/collections/users.ts make a request made by a listed account fail with 403 and a bilingual (vi / en) message when it changes its own email, password, apiKey or enableAPIKey, or deletes itself. Saving the unchanged account form still works. Requests without a user (Local API, pnpm demo:reset) and other users (a super-admin) are not blocked. With DEMO_ACCOUNT_EMAILS unset nothing changes: ordinary editors can still change their own password. Regression: src/collections/users-demo-account.integration.test.ts.
Jobs and other privileged operations
Section titled “Jobs and other privileged operations”| Operation | Rule |
|---|---|
Run queued jobs through /api/payload-jobs/run | super-admin only (jobs.access.run in src/payload.config.ts) |
| Background queue processing | Runs in-process only when ENABLE_JOBS_AUTORUN=true (set on exactly one instance). |
| Create/delete a site | super-admin |
| Write/inspect site secrets | super-admin |