Skip to content

Roles and Permissions

View markdown

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 thing tenant (relationship field tenant, user field tenants[], cookie payload-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.

RoleScopeSummary
super-adminAll sitesBypasses 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.
adminAssigned sitesContent CRUD, publishing, comment moderation, site settings, permanent delete (empty trash).
editorAssigned sitesContent CRUD, publishing, comment moderation, move to trash.
authorAssigned sitesCreate 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)”
HelperRoles that passUsed for
canManageContentsuper-admin, admin, editor, authorcreate/update/trash on pages, posts, products, landings
canPublishsuper-admin, admin, editorpublish rule (author excluded; enforced by a hook, see below)
canManageSiteSettingssuper-admin, adminsite settings
canModerateCommentssuper-admin, admin, editorupdate/delete on comments
canPermanentlyDeletesuper-admin, adminhard delete / empty trash
isAuthorRoleauthorownership clauses and publish guard

Authors are restricted in two places:

  1. Access predicates (pages, posts, products, landings): update returns { createdBy: { equals: user.id } } for author; trash (delete access) adds the same clause.
  2. enforceAuthorPermissions (src/hooks/enforce-author-permissions.ts, beforeChange):
    • throws a bilingual error if the effective status (data.status ?? originalDoc.status) is published, 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.id on create (createdBy has field-level update: () => false, so it cannot be reassigned afterwards).

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.

Defined in src/access/tenant-scoped.ts; membership helpers in src/lib/admin-selected-tenant.ts.

PredicateBehaviour
tenantScopedReadNo user → false. super-admin → true. Otherwise { tenant: { in: <user's site ids> } }; a user with no sites gets false.
tenantScopedWriteSame shape, used for create/update/delete.
tenantScopedVersionsReadVersion history (readVersions); same rule on version.tenant. Anonymous callers get nothing.
hasAnyTenantOrSuperAdminBoolean 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.
makeContentDeleteAccesssrc/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.
publishedAndLivesrc/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).

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.

@payloadcms/plugin-multi-tenant is configured with tenantsSlug: 'sites' (src/payload.config.ts). It contributes two different things:

LayerWhat it doesTreat 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).

CallerContent collections (pages, posts, products, landings)
AnonymouspublishedAndLive(): not trashed (deletedAt absent), status = 'published', and publishedAt missing or <= now (evaluated per request, so scheduled posts go live on time).
Authenticated, non-super-admintenantScopedRead: all statuses (including drafts) for their sites.
Super-adminEverything.

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.

FieldRule
sites.r2.accessKeyId, sites.r2.secretAccessKeyaccess.read: () => false: never in REST or GraphQL responses. Server code reads them with the Local API and overrideAccess: true.
sites.r2 groupaccess.update: super-admin only.
authors.emailaccess.read: () => false.
comments.authorEmailaccess.read: () => false.
media.tenant, payload-folders.tenantaccess.update: super-admin only (no reassigning ownership).
posts/pages/products/landings.createdByaccess.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: true bypasses field access too. Any custom handler that uses the Local API with overrideAccess: true can read secret fields. Such handlers must restrict what they return. The public GET /api/sites/:siteKey/env-page endpoint deliberately uses overrideAccess: false.

"Site-scoped" means the predicates above (super-admin all; others only their sites). "Public" means no authentication required.

CollectionRead (anonymous)Read (authenticated)CreateUpdateDelete
usersnonesuper-admin all; others own document onlysuper-adminsuper-admin any; others own document, but role and tenants are super-admin-only (writes by others are dropped)super-admin
sitespublicpublic rule; plugin narrows non-super-admin to assigned sitessuper-adminsuper-admin, admin (plugin narrows to assigned sites); r2 group super-admin onlysuper-admin
mediapublicpublicany user with ≥1 site (hasAnyTenantOrSuperAdmin)site-scopedsite-scoped
payload-foldersnonesite-scopedhasAnyTenantOrSuperAdminsite-scopedsite-scoped
pages, posts, products, landingspublishedAndLive()site-scoped (+ readVersions)canManageContentcanManageContent; author only owntrash: canManageContent (author own); permanent: super-admin/admin; all site-scoped
taxonomiespublicsite-scoped (+ readVersions)site-scopedsite-scopedsite-scoped
authorspublic (email hidden)site-scopedsite-scopedsite-scopedsite-scoped
menuspublicsite-scopedsite-scopedsite-scopedsite-scoped
faqspublic, not trashedsite-scoped (+ readVersions)site-scopedsite-scopedtrash: any site-scoped writer; permanent: super-admin/admin
shortcodespublic, not trashedsite-scoped (+ readVersions)site-scopedsite-scopedas faqs
redirectspublicsite-scopedsite-scopedsite-scopedsite-scoped
commentsstatus = 'approved' onlysite-scopedsite-scoped (public submissions use POST /api/comments/create)canModerateComments + site-scopedcanModerateComments + site-scoped
rating-eventsnonenonenonenonenone (written only by POST /api/ratings/submit with overrideAccess: true)

Notes:

  • The faqs and shortcodes delete rule uses makeContentDeleteAccess({ hasCreatedBy: false }): they have no createdBy, so there is no author ownership clause, but the "permanent delete is admin-only" rule applies.
  • Trash (trash: true) is enabled on pages, posts, products, landings, faqs, shortcodes. The anonymous predicates include deletedAt: { exists: false }, so ?trash=true cannot widen a public read.
  • Authenticated reads include trashed rows only when the caller passes trash=true.
  1. The very first user of a fresh install (created on the admin's "create first user" screen) is promoted to super-admin regardless of the submitted role (promoteFirstUserToSuperAdmin, a beforeChange hook on Users). Every later user keeps the role it is created with, and creating users is super-admin-only.
  2. A super-admin creates the user in Users and picks a role (default editor).
  3. On the same document the super-admin adds one row per site to the tenants array (label "Website" in the admin). The array is added by the multi-tenant plugin; do not redeclare it.
  4. 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.
  5. Non-super-admin users can edit their own account (password, API key) but cannot change their own role or tenants: the role field has access.update super-admin-only and the plugin's tenantsArrayField.arrayFieldAccess.update is 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.

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.

OperationRule
Run queued jobs through /api/payload-jobs/runsuper-admin only (jobs.access.run in src/payload.config.ts)
Background queue processingRuns in-process only when ENABLE_JOBS_AUTORUN=true (set on exactly one instance).
Create/delete a sitesuper-admin
Write/inspect site secretssuper-admin