44
Build Your Own Role-Based Access Control in Payload
Payload comes with open-ended access control. You can define whatever type of pattern that you can dream up, and best of all—it's all done with simple JavaScript.
A common pattern is Role-Based Access Control. Here, we'll walk you through how to create your own RBAC pattern on both the collection-level and field-level.
In more detail, here are the pieces that we will be building:
role
field- A
beforeChange
hook to save which user created the order to acreatedBy
field - Access Control functions to restrict Admin Panel access to
admin
roles or the creator of the order - admin-only field level access
We'll be using
create-payload-app
to build out the initial project.npx create-payload-app payload-rbac
javascript
for languageblank
for our templateThis will give us a simple project with a Payload config and Users collection. The structure of the project will be:
├─ payload.config.js
└─ collections/
└─ Users.js
└─ Orders.js
First, we will add the
role
field to our Users collection with 2 options: admin
and user
.const Users = {
slug: 'users',
auth: true,
admin: {
useAsTitle: 'email',
},
fields: [
{
name: 'role',
type: 'select',
options: [
{ label: 'Admin', value: 'admin' },
{ label: 'User', value: 'user' },
],
required: true,
defaultValue: 'user',
},
],
};
export default Users;
Next, we will create a new
Orders.js
collection in our collections/
directory and scaffold out basic fields and values - including the createdBy
relationship to the user.const Orders = {
slug: 'orders',
fields: [
{
name: 'items',
type: 'array',
fields: [
{
name: 'item',
type: 'text',
}
]
},
{
name: 'createdBy',
type: 'relationship',
relationTo: 'users',
access: {
update: () => false,
},
admin: {
readOnly: true,
position: 'sidebar',
condition: data => Boolean(data?.createdBy)
},
},
]
}
export default Orders;
The Orders collection has an
array
field for items and a createdBy
field which is a relationship to our Users
collection. The createdBy
field will feature a strict update
access control function so that it can never be changed.Notice we also have a
condition
function under the createdBy
field's access. This will hide createdBy
until it has a value.Next, we'll add a hook that will run before any order is created. This is done by adding a
beforeChange
hook to our collection definition.const Orders = {
slug: 'orders',
fields: [
// Collapsed
],
hooks: {
beforeChange: [
({ req, operation, data }) => {
if (operation === 'create') {
if (req.user) {
data.createdBy = req.user.id;
return data;
}
}
},
],
},
}
The logic in this hook sets the
createdBy
field to be the current user's id
value, only if it is on a create
operation. This will create a relationship between an order and the user who created it.Next, the access control for the collection can be defined. Payload's access control is based on functions. An access control function returns either a
boolean
value to allow/disallow access or it returns a query constraint that filters the data.We want our function to handle a few scenarios:
const isAdminOrCreatedBy = ({ req: { user } }) => {
// Scenario #1 - Check if user has the 'admin' role
if (user && user.role === 'admin') {
return true;
}
// Scenario #2 - Allow only documents with the current user set to the 'createdBy' field
if (user) {
// Will return access for only documents that were created by the current user
return {
createdBy: {
equals: user.id,
},
};
}
// Scenario #3 - Disallow all others
return false;
};
Once defined, this function is added to the
access
property of the collection definition:const Orders = {
slug: 'orders',
fields: [
// Collapsed
],
access: {
read: isAdminOrCreatedBy,
update: isAdminOrCreatedBy,
delete: isAdminOrCreatedBy,
},
hooks: {
// Collapsed
},
}
With this function added to the
read
, update
, and delete
access properties, the function will run whenever these operations are attempted on the collection.Note: Access functions default to requiring a logged-in user. This is why create
does not need to be defined for this example, since we want any logged in user to be able to create an order.
The last step is to add the collection to our
payload.config.js
import { buildConfig } from 'payload/config';
import Orders from './collections/Orders';
import Users from './collections/Users';
export default buildConfig({
serverURL: 'http://localhost:3000',
admin: {
user: Users.slug,
},
collections: [
Users,
Orders,
],
});
Let's verify the functionality:
Start up the project by running
npm run dev
or yarn dev
and navigate to http://localhost:3000/admin
With everything working at the collection level, we can carry the concepts further and see how they can be applied at the field level. Suppose we wanted to add a
paymentID
field only for Admin users. Create an isAdmin
function that checks the role as we did earlier.const isAdmin = ({ req: { user } }) => (user && user.role === 'admin');
Add a new field to Orders and set
create
, read
or update
access calls to use the isAdmin function.const Orders = {
slug: 'orders',
fields: [
// Collapsed
{
name: 'paymentId',
type: 'text',
access: {
create: isAdmin,
read: isAdmin,
update: isAdmin,
},
}
],
// Collapsed
}
The new paymentID field is not available to the users even on one's own Order. Field level access controls allow for greater granularity over document level access for Collections and Globals. This shows how easy it is to manage exact permissions throughout the admin UI, GraphQL and REST endpoints; it even works when querying relationships to keep data secure.
Now that we have a basic example working. What are some ways that this could be improved?
editor
role which allows reading and editing, but disallows creating. This all can be customized specifically to your needs.I hope you enjoyed the introduction to doing role-based access control with Payload!
Come join the Payload discussions on GitHub.
44