Adds a roles collection to Payload where editors manage permissions in the admin panel through a Create/Read/Update/Delete checkbox matrix. A roles field is added to auth collections, and access control is applied to every collection and global that does not define its own. Roles can be predefined in code and seeded on init. Requires Payload 3.27 or newer.
Role based access control plugin for Payload. Roles live in the database with a per-collection Create/Read/Update/Delete permission matrix, can be predefined in code and seeded on init, and are enforced across all collections and globals.
Installation
npm i @whatworks/payload-rbac15
Stars
1
Forks
1
Open Issues
2 weeks ago
Last Updated
27
Weekly Downloads
v0.6.0
npm Version
281.7 KB
Unpacked Size
0
Dependencies
Trends appear after 7 days of data. Daily snapshots have not started yet.
Shared by 12 plugins in what-works-global/payload-packages.
Trends appear after 7 days of data. Daily snapshots have not started yet.
Each point is npm's rolling 7-day download count.
Health Score
Composite score based on GitHub activity, stars, npm downloads, publish recency, dependency count, and package size.
Badge for your README
[](https://payloaddirectory.dev/plugins/1064440892-rbac)<a href="https://payloaddirectory.dev/plugins/1064440892-rbac"><img src="https://payloaddirectory.dev/badge/1064440892-rbac.svg" alt="Payload Directory"></a>README
Role based access control for Payload where the roles live in the database. Editors manage roles in the admin panel through a per-collection Create / Read / Update / Delete checkbox matrix; the plugin enforces those permissions across every collection and global.
'*') that covers everything, including collections added later. Wildcard permissions ('pages:*', '*:read') are supported and render as checked, locked cells in the matrix.protected: true to flip ownership: it stays code-owned — locked in the admin panel and synced from code on every restart.roles relationship field to your auth collections (multi-role; a user's permissions are the union of their roles).compose.roles:update — for everyone else the roles field renders read-only in the admin panel. And a role your remaining roles could not re-grant cannot be removed from your own account, so you can never accidentally strip your own access.adminRole): a role the plugin locks to full access (['*']) — it can never be downgraded, renamed, or deleted through the API, so you can never lose your last full-access role. Only its holders can assign it (even '*' through another role is not enough). It is auto-assigned to the first user created, and at least one user always holds it — removing it from (or deleting) the last administrator is blocked, and if the database is damaged so badly that nobody holds it, any signed-in user can claim it for themselves. Other roles can opt into the same code-locking with protected: true.users:* permissions they hold, even full access through another role. See below.credentialChanges: 'anyone'. Always on for the admin role, so an administrator account can never be taken over by another user with users:update.
pnpm add @whatworks/payload-rbac
import { rbacPlugin } from '@whatworks/payload-rbac'
import { buildConfig } from 'payload'
export default buildConfig({
// ...
plugins: [
// Register last so collections added by other plugins are controlled too.
rbacPlugin({
adminRole: 'Administrator',
roles: [
{
name: 'Editor',
permissions: ['posts:create', 'posts:read', 'posts:update', 'posts:delete'],
},
],
}),
],
})
Then regenerate the import map so the admin panel can resolve the permissions-matrix component:
payload generate:importmap
That's it — a roles collection appears in the admin panel, every auth user gets a roles field, and every operation on every collection and global now requires a matching permission.
Role names are plain display strings (the roles collection's title field) that double as the seeding key — permissions never reference them — so human-friendly names like 'Administrator' and 'Editor' are encouraged.
A permission is a plain string, stored on the role document:
'*' — full access to every controlled collection and global, present and future.'<slug>:<action>' — one action on one entity, e.g. 'posts:update'. Actions are create/read/update/delete for collections and read/update for globals.'<slug>:*' — every action on one entity, e.g. 'pages:*'.'*:<action>' — one action on every controlled entity, present and future, e.g. '*:read'. ('*:create' and '*:delete' only ever match collections — globals have no such actions.)A user's permission set is the union of all their roles. Requests without a user are denied wherever the plugin controls access — see Public access below. readVersions maps to the entity's read permission and unlock to update.
Payload's internal collections (payload-preferences, payload-migrations, payload-locked-documents, payload-folders, …) are created after plugins run; they are never controlled and never appear in the matrix.
Access you define explicitly on a collection wins for that operation, so public reads keep working the way you wrote them:
{
slug: 'posts',
access: {
// Explicit — the plugin leaves it alone: anyone can read published posts.
read: ({ req }) => (req.user ? true : { _status: { equals: 'published' } }),
// create/update/delete are not defined — the plugin fills them with role checks.
},
}
To combine your own rule with a role check, compose the exported helpers:
import { hasPermission, requirePermission } from '@whatworks/payload-rbac'
{
access: {
// Access-function factory:
update: requirePermission('posts:update'),
// Or inside your own logic:
delete: async ({ req }) =>
(await hasPermission(req, 'posts:delete')) && !req.user?.suspended,
},
}
getUserPermissions(req) returns the resolved permission set (memoized per request — the roles are fetched at most once per request, no matter how many access functions run).
compose)Gap-filling has a sharp edge: when a collection (or another plugin, or a plugin ordered before this one) defines access.read, the RBAC read permission for that collection never runs. Nothing errors — the permission is simply inert. compose is the opt-in fix, per entity and per action:
rbacPlugin({
compose: {
// read AND update on posts, update on the global — each becomes
// `yourAccess AND permissionCheck`.
posts: ['read', 'update'],
'site-settings': ['update'],
},
})
false from either denies, true is the identity, and two Where constraints become one { and: [yours, theirs] } filter. Your function short-circuits the permission check when it denies.compose.readVersions follows read and unlock follows update, matching how those operations map to permissions.read/update); anything else throws at startup rather than silently doing nothing. The roles collection is off-limits — its access belongs to the plugin (use rolesCollection.override).The same AND is available directly for hand-written access: composeAccess(yours, requirePermission('posts:read')), with andAccessResults(first, second) for the result-combining rule on its own.
Why gap-filling stays the default. Composing everywhere would silently break public access. A collection whose create is () => true so anonymous visitors can submit a form would suddenly also require a role — and an anonymous request holds no roles at all, so every submission would 403. The plugin cannot tell a deliberate public rule from an oversight, so it never overrules what you wrote; compose is how you say which ones to tighten.
Because an inert permission is quiet, the plugin logs one informational line on init naming every controlled entity that defines access of its own, and the operations affected:
[payload-rbac] These controlled entities define access of their own, so the plugin left those
operations alone and their permissions may not apply — tags (collection): read;
site-settings (global): update. …
It is informational, not a warning: whether those functions already check permissions themselves (requirePermission, hasPermission) is not knowable from the config, which is why it says permissions may not apply. Operations you opted into compose are not listed — there the role check does run. Nothing is logged when there is nothing to report, and quiet: true silences it once you have reviewed the list.
Enabled by default (preventPrivilegeEscalation):
'*' role requires holding '*'.'pages:*' covers granting 'pages:read', and holding every action on an entity covers granting its '<slug>:*'. But '*:<action>' spans entities added in the future, so nothing short of holding '*:<action>' itself (or full access) covers granting it — the same way '*' works. A role whose '*:<action>' wildcards span all four actions is equivalent to '*' and treated as full access everywhere.adminRole is stricter still: it can only be assigned by a user who already holds it — full access through another role is not enough (see lockout prevention below).Viewer while you keep a '*' role) works; removing the role your access comes from is rejected with a 403. Removing roles from other users is not restricted.Note that managing users also requires the relevant collection permissions themselves (e.g. users:update to edit users, roles:read to see the roles being assigned).
roles:update — know the semanticsChanging anyone's roles — your own account included — requires the roles:update permission. This is enforced as field-level access on the generated roles field, which is what makes the admin panel render the field read-only: an editor can see their roles but cannot touch them, so nobody unchecks their own access by accident. Field access comes with Payload's standard semantics, which are worth knowing before you debug:
roles:update succeeds — every other field saves, the response is a 200 — but the roles value is dropped and the stored value kept. Nothing errors and nothing is logged. If a role assignment "didn't stick", check that the requesting user holds roles:update.roles:update produces a user with no roles — roles in the create data are dropped the same way.users:update to save their document at all, plus roles:update for the field.roles:update gets a 403 error (not a silent keep) when removing a role from their own account that their kept roles cannot cover — see the escalation rules above.adminRole configured there is no such relaxation: make sure some role keeps roles:update, or roles can only be assigned through the local API.roles:update field gate does not. Use rolesField.override to replace the generated field's access when you want different rules.Escalation protection alone has a failure mode: removals are always allowed, so if you remove a permission from your only full-access role, nobody holds it anymore — and now nobody is allowed to grant it back. You would be permanently locked out of that permission.
The adminRole closes that hole: the plugin defines that role itself with permissions: ['*'], seeds it, assigns it to the first user, and permanently protects it — you cannot list it in roles, so it cannot even be predefined with anything less than full access. Any other predefined role can opt into the same locking with protected: true. A protected role is locked to its code definition:
'*' user. The one write that is accepted is restoring the exact code-defined list; that restore is exempt from the escalation guard, so anyone still holding roles:update can repair a drifted protected role without holding the permissions being restored.'*' user cannot join the admin tier on their own. The restriction relaxes only while nobody holds the role at all (a fresh rename, or the plugin newly added to an existing project): then any user whose permissions cover it may step up, and init logs a warning until someone does.roles:update. Nothing is granted automatically — an administrator exists again only when someone claims it — and init logs a warning whenever the admin role has no holder. The moment anyone holds full access again, the exemption switches off.To change a protected role's permissions, change the code definition and restart. Writes without a user (local API, init scripts) are not restricted, matching the other guards.
Renaming adminRole seeds a fresh role under the new name with no holders; your existing administrators still hold full access through the old role, and since the new role has no holders yet, the holder-only rule stands aside and lets them assign it in the admin panel (init logs a warning until someone does). The old role stays in the database as an ordinary, no-longer-protected role — delete it when you're done with it.
Lockout prevention aside, protected is also the switch between two ownership models — so it's how you keep a role permanently defined, and updated, in code:
protected: true: the code owns the role. The permissions list is enforced on every restart, so a definition derived from your config stays in sync automatically — in exchange, the role is locked in the admin panel and API.const contentSlugs = ['pages', 'posts', 'news'] as const
rbacPlugin({
adminRole: 'Super Admin',
roles: [
// Database-owned: seeded once; edit it in the admin panel from then on.
{ name: 'Office Manager', permissions: ['pages:*', 'forms:read'] },
// Code-owned: adding a slug to contentSlugs updates the role on the
// next restart — no migration or admin-panel edit needed.
{
name: 'Editor',
protected: true,
permissions: contentSlugs.map((slug) => `${slug}:*`),
},
],
})
Two boundaries to know:
permissions is code-owned. The description is written at first seed and never synced again — the guard locks name and permissions, so the description stays editable in the admin panel.name is the seeding key. Renaming a protected role in code does not rename the database role: the next init seeds a fresh role under the new name with no holders, and the old one stays behind as an ordinary, no-longer-protected role that keeps its holders. Reassign users to the new role, then delete the old one — the same flow as renaming adminRole above.Permissions alone cannot protect an account from other users: users:update is enough to set someone else's password — or to change their email and request a password reset to it — and take the account over. So credential changes get their own rule: the password, email, and username of a user can only be changed by that user, no matter what permissions everyone else holds. Helping a locked-out colleague means sending them a password-reset email. Email and username are locked together with password deliberately — an editable email plus the reset flow would take the account over anyway.
This is on by default for every role — the ones you predefine in code and the ones editors create in the admin panel — so credential takeover is closed out of the box, no per-role setting required. The only way to open it back up is to mark a predefined role credentialChanges: 'anyone', restoring Payload's default where anyone with update access can change its holders' credentials. A user is exempt only when every role they hold is opted out that way; hold one self-only role — including any database-defined role — and you stay protected (most restrictive wins). The adminRole is always 'self' and cannot be opted out.
Because the opt-out lives on the predefined role definition, it follows the role name: pair credentialChanges: 'anyone' with protected: true if you need it to survive a rename. Writes without a user (local API, seeds) and a user editing their own account are never restricted, like the other guards.
A common handover: the agency keeps a developer account, and the client gets an administrator account of their own. Give the client full access — no need to enumerate permissions per collection — and keep the developer tier to yourself:
rbacPlugin({
// The developer/agency tier — only holders can grant it, credentials self-only.
adminRole: 'Super Admin',
roles: [
{
name: 'Admin',
description: 'Site administrator.',
// Full access to everything, including collections added later.
permissions: ['*'],
protected: true,
},
],
})
The client runs the whole site — content, users, roles, future collections — but the developer account stays out of reach:
'Super Admin' account at all. Managing an administrator requires being one: users:create/users:update/users:delete — even full access ('*') through another role — is not enough. This covers the developer's password, email, and username (credential takeover is closed) and every other field on the document. See administrators can only be managed by administrators below.'Super Admin': even with '*', assigning the admin role requires already holding it.Because managing an administrator requires being one, only another Super Admin can touch a Super Admin account — and even they cannot strip the role from, or delete, the last holder. Administrators trust each other; with two or more Super Admins, one can still remove or delete another down to the last.
When an adminRole is configured, a user who does not hold it cannot create, update, or delete an account that does — regardless of their users:* permissions, and regardless of full access held through some other role. Holding the admin role is the only key.
An administrator manages other administrators normally, subject to the last-holder guard. Writes without a user (local API, seeds, the first-user bootstrap) are never restricted, matching the other guards — and the break-glass self-claim still works, since the account being claimed does not yet hold the role at the moment of the write.
rbacPlugin({
// Which collections are access-controlled and appear in the matrix. Defaults to all
// collections present when the plugin runs (register the plugin last).
// - true — every collection (default)
// - ['posts', 'pages'] — only these
// - { exclude: ['media'] } — all except these
collections: true,
// Which globals are controlled (read/update). Same semantics. Defaults to all.
globals: true,
// Opt individual actions into `yourAccess AND permissionCheck` instead of the
// default gap-filling (which leaves an entity's own access alone). Per entity,
// per action; unlisted entities and actions are unaffected. Default: {}.
compose: { posts: ['read', 'update'], 'site-settings': ['update'] },
// Silence the init notice that names controlled entities whose own access the
// plugin left in place (whose permissions may therefore never run). Default: false.
quiet: false,
// Auth collections that receive the roles field.
// Defaults to every auth-enabled collection.
userCollections: ['users'],
// The built-in administrator role: always full access (['*']), always protected
// (no API edits/renames/deletes, drift repaired on init), auto-assigned to the
// first user created. A name, or { name, description }. Default: false.
adminRole: 'Administrator',
// Additional roles seeded on init when no role with the same name exists.
// `protected: true` locks a role to its code definition the same way the
// adminRole is locked (default: false — the database wins). Holders' credentials
// (password/email/username) are self-only by default; set `credentialChanges:
// 'anyone'` to let anyone with update access change them instead. The adminRole
// is always 'self'.
roles: [{ name: 'Editor', permissions: ['posts:read', 'posts:update'] }],
// Users may read/update their own user document without holding the collection
// permission. Default: ['read', 'update']. Set false to disable.
ownAccountAccess: ['read', 'update'],
// Block users from granting roles/permissions beyond their own. Default: true.
preventPrivilegeEscalation: true,
// The roles collection added by the plugin.
rolesCollection: {
slug: 'roles', // default
// Escape hatch: adjust labels, admin group, extra fields, hooks, …
override: (collection) => ({ ...collection, admin: { ...collection.admin, group: 'Admin' } }),
},
// The roles field added to user collections. Changing it requires the
// `roles:update` permission (it renders read-only in the admin panel
// otherwise); `override` can replace the field's `access` to change that.
rolesField: {
name: 'roles', // default
override: (field) => ({ ...field, saveToJWT: false }),
},
// Seed `roles` and the `adminRole` on init. Set false when the connection
// cannot write — seeding builds the roles indexes before its first create, and
// that build is a write, so init throws on a read-only credential. Only safe
// when the roles already exist. Default: true.
seedRoles: true,
// Disable the plugin entirely (useful per-environment). Default: true.
enabled: true,
})
Validating an application against a copy of production — or against production itself through a read-only credential — fails at init, because seeding writes before any of your code runs:
user is not allowed to do action [createIndex] on [production.roles]
Turn seeding off for those runs, and leave everything else in place so access control still behaves as it does in production:
rbacPlugin({ seedRoles: !process.env.DATABASE_READ_ONLY })
Note this covers the plugin's own init writes only. Mongoose separately builds
indexes for every model on first use, which a read-only credential also cannot
do, so pass connectOptions: { autoCreate: false, autoIndex: false } to
mongooseAdapter for the same runs.
access.admin) is not restricted by the plugin; a user with no roles can log in but sees nothing. Add your own access.admin to auth collections if you want to gate the panel itself.saveToJWT) for consumers reading the token directly.roles:update field gate does not.compose), and lists what it left alone in one info log per boot.multi tenant
Manage multiple tenants with scoped content and access from a single Payload admin panel
10090.4k/wk45.4kv3
payload plugin rbac
Database-driven role-based access control with a permission matrix and data scoping for Payload
5627/wk10v3
rbac
Database-driven role-based access control with a per-collection permissions matrix in the Payload admin
530/wk0v4
abac
Attribute-based access control that compiles user attributes into Payload where clauses
520/wk181v3
Payload RBAC Plugin
Database-backed role and permission management with row-level conditions for Payload collections
500/wk6v3
rbac
Role-based access control with ready-made access functions and query filters for Payload auth collections
4418/wk142v1