Build Your Own Role-Based Access Control in Payload
Elliot DeNolf
Posted on July 14, 2021
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:
- Users collection with
role
field - Orders collection
- 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
- A
Initialize Project
We'll be using create-payload-app
to build out the initial project.
- Run
npx create-payload-app payload-rbac
- Select
javascript
for language - Select
blank
for our template - Follow all other prompts
This 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
Modify Users Collection
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;
Create Orders Collection
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.
Set the createdBy
Attribute Using a Hook
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.
Access Control
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:
- A user has the 'admin' role - access all orders
- A user created the order - allow access to only those orders
- Any other user - disallow access
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.
Put It All Together
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
Create your initial user with the admin
role.
Create an Order with the admin
user.
Create an additional user with the user
role by navigating to the Users collection, selecting Create New, entering an email/password, then saving.
Log out of your admin
user by selecting the icon in the bottom left, then log in with the second user.
You'll notice if we go to the Orders collection, no Orders will be shown. This indicates that the access control is working properly.
Create another Order. Note that the current user will be saved to Created By
in the sidebar.
Navigate back to Orders list on the dashboard. There will only be the single order created by the current user.
Log out, then back in with your admin
user. You should be able to see the original Order as well as the Order created by the second user.
Field Level Access Control
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.
What Other Improvements Can Be Made?
Now that we have a basic example working. What are some ways that this could be improved?
- Ideally, we'd want to use both the hook and the access control function across multiple collections in our application. Since it's just JavaScript, we can extract each of these functions into their own file for re-use.
- Add additional roles, such as an
editor
role which allows reading and editing, but disallows creating. This all can be customized specifically to your needs.
Questions or Comments? Join us on GitHub Discussions
I hope you enjoyed the introduction to doing role-based access control with Payload!
Come join the Payload discussions on GitHub.
Further Reading
- Source Code for this post
- Documentation
Posted on July 14, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
August 3, 2023