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:

  • Users collection with role field
  • Orders collection
    • A beforeChange hook to save which user created the order to a createdBy field
    • Access Control functions to restrict Admin Panel access to admin roles or the creator of the order
    • admin-only field level access

Initialize Project

We'll be using create-payload-app to build out the initial project.

  1. Run npx create-payload-app payload-rbac
  2. Select javascript for language
  3. Select blank for our template
  4. 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:

  1. A user has the 'admin' role - access all orders
  2. A user created the order - allow access to only those orders
  3. 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.
Payload login

Create an Order with the admin user.
Create Order as admin
Save Order as admin

Create an additional user with the user role by navigating to the Users collection, selecting Create New, entering an email/password, then saving.
Create Normal User
Save Normal User

Log out of your admin user by selecting the icon in the bottom left, then log in with the second user.
Normal User Login

You'll notice if we go to the Orders collection, no Orders will be shown. This indicates that the access control is working properly.
View Orders as Normal User
No Orders Show

Create another Order. Note that the current user will be saved to Created By in the sidebar.
Create Order as Normal User

Navigate back to Orders list on the dashboard. There will only be the single order created by the current user.
View Single Order As Normal 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.
View All Orders As Admin

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

32