├── .editorconfig ├── .github ├── dependabot.yml └── workflows │ └── npm.yml ├── .gitignore ├── LICENSE ├── README.md ├── demo ├── .env.example ├── .gitignore ├── nodemon.json ├── package.json ├── src │ ├── collections │ │ ├── Categories.ts │ │ ├── Media.ts │ │ ├── Posts.ts │ │ ├── Tags.ts │ │ └── Users.ts │ ├── payload.config.ts │ └── server.ts ├── tsconfig.json └── yarn.lock ├── package.json ├── src ├── accessControl │ ├── hasRole.ts │ ├── hasRoleField.ts │ ├── index.ts │ ├── public.ts │ └── publishedOnly.ts ├── index.ts ├── mocks │ └── serverModule.js ├── types.ts └── utilities │ ├── extendWebpackConfig.ts │ └── starterRoles.ts ├── tsconfig.json ├── types.d.ts ├── types.js └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | end_of_line = lf 10 | max_line_length = null 11 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/workflows/npm.yml: -------------------------------------------------------------------------------- 1 | name: Publish to NPM 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | - uses: actions/setup-node@v1 13 | with: 14 | node-version: "14.x" 15 | registry-url: "https://registry.npmjs.org" 16 | - run: yarn install 17 | - run: yarn build 18 | - run: npm publish --access public 19 | env: 20 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | dist 4 | .env 5 | demo/uploads 6 | build 7 | .DS_Store 8 | package-lock.json 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 NouanceLabs 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Payload RBAC Plugin (BETA) 2 | 3 | **Needs more testing!** 4 | 5 | A plugin for [Payload CMS](https://github.com/payloadcms/payload) to provide a baseline to help handle user roles and permissions. 6 | 7 | ## Installation 8 | 9 | ```bash 10 | yarn add @nouance/payload-simple-rbac 11 | # OR 12 | npm i @nouance/payload-simple-rbac 13 | ``` 14 | 15 | ## How it works 16 | 17 | On the face of it, this plugin only aims to get you up and started with some form of Role-Based Access Control. 18 | You create an array of roles in **order of priority** with the latter being the most important, for example this means that an admin can do everything an editor can and more but an editor will be limited to their level of access. 19 | 20 | ## Basic Usage 21 | 22 | In the `plugins` array of your [Payload config](https://payloadcms.com/docs/configuration/overview), call the plugin with [options](#options): 23 | 24 | ```ts 25 | import { buildConfig } from "payload/config"; 26 | import payloadSimpleRBAC, { starterRoles } from "@nouance/payload-simple-rbac"; 27 | 28 | const config = buildConfig({ 29 | // ... rest of my config 30 | plugins: [ 31 | payloadSimpleRBAC({ 32 | roles: starterRoles, 33 | users: [Users.slug], 34 | defaultRole: "editor", // set a default 35 | collections: [ 36 | { 37 | slug: Posts.slug, 38 | permissions: { 39 | read: "publishedOnly", 40 | update: "editor", 41 | create: "editor", 42 | delete: "manager", 43 | }, 44 | }, 45 | ], 46 | }), 47 | ], 48 | }); 49 | 50 | export default config; 51 | ``` 52 | 53 | ### Options 54 | 55 | - `Roles` 56 | 57 | `Array` | **Required** 58 | 59 | An array of roles as strings in order of priority, roles at the end have a higher priority. 60 | 61 | - `users` 62 | 63 | `Array` | **Required** 64 | 65 | An array of user slugs to attach the `role` field to. 66 | 67 | - `defaultRole` 68 | 69 | `String` | Required 70 | 71 | Set the default role for users to have. 72 | 73 | - `fieldName` 74 | 75 | `String` | Optional | Defaults to `role` 76 | 77 | Allows you to configure the machine name of the role field to avoid conflicts. 78 | 79 | - `collections` 80 | 81 | `Array` | Optional 82 | 83 | Must contain the following 84 | 85 | - slug : `String` 86 | - permissions : `read` | `update` | `create` | `delete` 87 | 88 | - `globals` 89 | 90 | `Array` | Optional 91 | 92 | Must contain the following 93 | 94 | - slug : `String` 95 | - permissions : `read` | `update` 96 | 97 | ### Available permissions 98 | 99 | - `role` 100 | 101 | This is your custom role name, any user with the same role or higher will pass this permission. 102 | 103 | - `publishedOnly` 104 | 105 | Allow access only to published content, based on [Payload's draft system](https://payloadcms.com/docs/versions/drafts). So your collection must have it enabled. 106 | 107 | - `public` 108 | 109 | This allows **full public access**. The actual function is 110 | 111 | ```ts 112 | publicAccess = () => true; 113 | ``` 114 | 115 | ### Starter roles 116 | 117 | ```ts 118 | import { starterRoles } from "@nouance/payload-simple-rbac"; 119 | 120 | // ... 121 | roles: starterRoles, 122 | // ... 123 | ``` 124 | 125 | `starterRoles` is an array containing 3 roles, `editor` | `manager` | `admin` . 126 | 127 | ## Functions 128 | 129 | We've exposed our internal functions in case you want to use them in your own code or in combination with other access controls. 130 | 131 | - `hasRole` 132 | 133 | Takes in your target role and full list of roles. 134 | 135 | ```ts 136 | read: hasRole("editor", myRoles); 137 | ``` 138 | 139 | - `hasRoleField` 140 | 141 | Takes in your target role and full list of roles. Specifically used for field access control. 142 | 143 | ```ts 144 | { 145 | name: "metadata", 146 | type: "json", 147 | access: { 148 | read: hasRoleField("admin", myRoles), 149 | }, 150 | }, 151 | ``` 152 | 153 | - `publicAccess` 154 | 155 | Anyone can pass this access control. 156 | 157 | ```ts 158 | read: publicAccess(); 159 | ``` 160 | 161 | - `publishedOnly` 162 | 163 | Any logged in user can pass this access control, non-authenticated requests can only pass published checks. 164 | You can also pass an array of strings for auth collection slugs to limit the auth'd access only to a specific set of users. 165 | 166 | ```ts 167 | read: publishedOnly(); 168 | ``` 169 | 170 | ```ts 171 | // limited to 'user' collection slug, other auth collections will not pass 172 | read: publishedOnly(["user"]); 173 | ``` 174 | 175 | ## Full example 176 | 177 | ```ts 178 | import { buildConfig } from "payload/config"; 179 | import payloadSimpleRBAC from "@nouance/payload-simple-rbac"; 180 | 181 | const config = buildConfig({ 182 | plugins: [ 183 | payloadSimpleRBAC({ 184 | roles: ["customer", "editor", "manager", "admin"], 185 | users: [Users.slug], 186 | defaultRole: "customer", 187 | collections: [ 188 | { 189 | slug: Posts.slug, 190 | permissions: { 191 | read: "publishedOnly", 192 | update: "editor", 193 | create: "editor", 194 | delete: "manager", 195 | }, 196 | }, 197 | { 198 | slug: Categories.slug, 199 | permissions: { 200 | read: "public", 201 | }, 202 | }, 203 | { 204 | slug: Tags.slug, 205 | permissions: { 206 | read: "publishedOnly", 207 | create: "manager", 208 | update: "manager", 209 | delete: "admin", 210 | }, 211 | }, 212 | ], 213 | }), 214 | ], 215 | }); 216 | 217 | export default config; 218 | ``` 219 | 220 | ## Development 221 | 222 | For development purposes, there is a full working example of how this plugin might be used in the [demo](./demo) of this repo: 223 | 224 | ```bash 225 | git clone git@github.com:NouanceLabs/payload-simple-rbac.git \ 226 | cd payload-simple-rbac && yarn \ 227 | cd demo && yarn \ 228 | cp .env.example .env \ 229 | vim .env \ # add your creds to this file 230 | yarn dev 231 | ``` 232 | -------------------------------------------------------------------------------- /demo/.env.example: -------------------------------------------------------------------------------- 1 | MONGODB_URI= 2 | PAYLOAD_SECRET= 3 | -------------------------------------------------------------------------------- /demo/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | media 3 | 4 | .env 5 | -------------------------------------------------------------------------------- /demo/nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "ext": "ts", 3 | "exec": "ts-node src/server.ts" 4 | } 5 | -------------------------------------------------------------------------------- /demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "payload-simple-rbac-demo", 3 | "description": "Payload project created from blog template", 4 | "version": "1.0.0", 5 | "main": "dist/server.js", 6 | "license": "MIT", 7 | "scripts": { 8 | "dev": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts nodemon", 9 | "build:payload": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts payload build", 10 | "build:server": "tsc", 11 | "build": "yarn copyfiles && yarn build:payload && yarn build:server", 12 | "serve": "cross-env PAYLOAD_CONFIG_PATH=dist/payload.config.js NODE_ENV=production node dist/server.js", 13 | "copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,scss,ttf,woff,woff2,eot,svg,jpg,png}\" dist/", 14 | "generate:types": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts payload generate:types", 15 | "generate:graphQLSchema": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts payload generate:graphQLSchema" 16 | }, 17 | "dependencies": { 18 | "dotenv": "^8.2.0", 19 | "express": "^4.17.1", 20 | "payload": "^1.6.15" 21 | }, 22 | "devDependencies": { 23 | "@types/express": "^4.17.9", 24 | "copyfiles": "^2.4.1", 25 | "cross-env": "^7.0.3", 26 | "nodemon": "^2.0.6", 27 | "ts-node": "^9.1.1", 28 | "typescript": "^5" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /demo/src/collections/Categories.ts: -------------------------------------------------------------------------------- 1 | import { CollectionConfig } from "payload/types"; 2 | 3 | const Categories: CollectionConfig = { 4 | slug: "categories", 5 | admin: { 6 | useAsTitle: "name", 7 | }, 8 | access: { 9 | read: () => true, 10 | }, 11 | versions: { 12 | drafts: { 13 | autosave: true, 14 | }, 15 | }, 16 | fields: [ 17 | { 18 | name: "name", 19 | type: "text", 20 | }, 21 | ], 22 | timestamps: true, 23 | }; 24 | 25 | export default Categories; 26 | -------------------------------------------------------------------------------- /demo/src/collections/Media.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import type { CollectionConfig } from 'payload/types'; 3 | 4 | const Media: CollectionConfig = { 5 | slug: 'media', 6 | upload: { 7 | staticDir: path.resolve(__dirname, '../../media'), 8 | // Specify the size name that you'd like to use as admin thumbnail 9 | adminThumbnail: 'thumbnail', 10 | imageSizes: [ 11 | { 12 | height: 400, 13 | width: 400, 14 | crop: 'center', 15 | name: 'thumbnail', 16 | }, 17 | { 18 | width: 900, 19 | height: 450, 20 | crop: 'center', 21 | name: 'sixteenByNineMedium', 22 | }, 23 | ], 24 | }, 25 | fields: [], 26 | }; 27 | 28 | export default Media; 29 | -------------------------------------------------------------------------------- /demo/src/collections/Posts.ts: -------------------------------------------------------------------------------- 1 | import { CollectionConfig } from "payload/types"; 2 | 3 | const Posts: CollectionConfig = { 4 | slug: "posts", 5 | admin: { 6 | defaultColumns: ["title", "author", "category", "tags", "status"], 7 | useAsTitle: "title", 8 | }, 9 | access: { 10 | read: () => true, 11 | }, 12 | versions: { 13 | drafts: { 14 | autosave: true, 15 | }, 16 | }, 17 | fields: [ 18 | { 19 | name: "title", 20 | type: "text", 21 | }, 22 | { 23 | name: "author", 24 | type: "relationship", 25 | relationTo: "users", 26 | }, 27 | { 28 | name: "publishedDate", 29 | type: "date", 30 | }, 31 | { 32 | name: "category", 33 | type: "relationship", 34 | relationTo: "categories", 35 | }, 36 | { 37 | name: "featuredImage", 38 | type: "upload", 39 | relationTo: "media", 40 | }, 41 | { 42 | name: "tags", 43 | type: "relationship", 44 | relationTo: "tags", 45 | hasMany: true, 46 | }, 47 | { 48 | name: "views", 49 | type: "number", 50 | defaultValue: 0, 51 | }, 52 | { 53 | name: "content", 54 | type: "richText", 55 | }, 56 | { 57 | name: "status", 58 | type: "select", 59 | options: [ 60 | { 61 | value: "draft", 62 | label: "Draft", 63 | }, 64 | { 65 | value: "published", 66 | label: "Published", 67 | }, 68 | ], 69 | defaultValue: "draft", 70 | admin: { 71 | position: "sidebar", 72 | }, 73 | }, 74 | ], 75 | }; 76 | 77 | export default Posts; 78 | -------------------------------------------------------------------------------- /demo/src/collections/Tags.ts: -------------------------------------------------------------------------------- 1 | import { CollectionConfig } from "payload/types"; 2 | import { hasRoleField } from "../../../src"; 3 | import { myRoles } from "../payload.config"; 4 | 5 | const Tags: CollectionConfig = { 6 | slug: "tags", 7 | admin: { 8 | useAsTitle: "name", 9 | }, 10 | access: { 11 | read: () => true, 12 | }, 13 | versions: { 14 | drafts: { 15 | autosave: true, 16 | }, 17 | }, 18 | fields: [ 19 | { 20 | name: "name", 21 | type: "text", 22 | }, 23 | { 24 | name: "metadata", 25 | type: "json", 26 | access: { 27 | read: hasRoleField("admin", myRoles), 28 | }, 29 | }, 30 | ], 31 | timestamps: true, 32 | }; 33 | 34 | export default Tags; 35 | -------------------------------------------------------------------------------- /demo/src/collections/Users.ts: -------------------------------------------------------------------------------- 1 | import { CollectionConfig } from 'payload/types'; 2 | 3 | const Users: CollectionConfig = { 4 | slug: 'users', 5 | auth: true, 6 | admin: { 7 | useAsTitle: 'email', 8 | }, 9 | access: { 10 | read: () => true, 11 | }, 12 | fields: [ 13 | // Email added by default 14 | { 15 | name: 'name', 16 | type: 'text', 17 | } 18 | ], 19 | }; 20 | 21 | export default Users; -------------------------------------------------------------------------------- /demo/src/payload.config.ts: -------------------------------------------------------------------------------- 1 | import { buildConfig } from "payload/config"; 2 | import path from "path"; 3 | import Categories from "./collections/Categories"; 4 | import Posts from "./collections/Posts"; 5 | import Tags from "./collections/Tags"; 6 | import Users from "./collections/Users"; 7 | import Media from "./collections/Media"; 8 | import payloadSimpleRBAC from "../../src/index"; 9 | 10 | export const myRoles = ["customer", "editor", "manager", "admin"]; 11 | 12 | export default buildConfig({ 13 | serverURL: "http://localhost:3000", 14 | admin: { 15 | user: Users.slug, 16 | webpack: (config) => { 17 | const newConfig = { 18 | ...config, 19 | resolve: { 20 | ...config.resolve, 21 | alias: { 22 | ...config.resolve.alias, 23 | react: path.join(__dirname, "../node_modules/react"), 24 | "react-dom": path.join(__dirname, "../node_modules/react-dom"), 25 | payload: path.join(__dirname, "../node_modules/payload"), 26 | }, 27 | }, 28 | }; 29 | 30 | return newConfig; 31 | }, 32 | }, 33 | collections: [Categories, Posts, Tags, Users, Media], 34 | typescript: { 35 | outputFile: path.resolve(__dirname, "payload-types.ts"), 36 | }, 37 | graphQL: { 38 | schemaOutputFile: path.resolve(__dirname, "generated-schema.graphql"), 39 | }, 40 | plugins: [ 41 | payloadSimpleRBAC({ 42 | roles: myRoles, 43 | users: [Users.slug], 44 | defaultRole: "customer", 45 | collections: [ 46 | { 47 | slug: Posts.slug, 48 | permissions: { 49 | read: "publishedOnly", 50 | update: "editor", 51 | create: "editor", 52 | delete: "manager", 53 | }, 54 | }, 55 | { 56 | slug: Categories.slug, 57 | permissions: { 58 | read: "public", 59 | }, 60 | }, 61 | { 62 | slug: Tags.slug, 63 | permissions: { 64 | read: "publishedOnly", 65 | create: "manager", 66 | update: "manager", 67 | delete: "admin", 68 | }, 69 | }, 70 | ], 71 | }), 72 | ], 73 | }); 74 | -------------------------------------------------------------------------------- /demo/src/server.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import payload from "payload"; 3 | 4 | require("dotenv").config(); 5 | const app = express(); 6 | 7 | // Redirect root to Admin panel 8 | app.get("/", (_, res) => { 9 | res.redirect("/admin"); 10 | }); 11 | 12 | const start = async () => { 13 | // Initialize Payload 14 | await payload.init({ 15 | secret: process.env.PAYLOAD_SECRET, 16 | mongoURL: process.env.MONGODB_URI, 17 | express: app, 18 | onInit: async () => { 19 | payload.logger.info(`Payload Admin URL: ${payload.getAdminURL()}`); 20 | }, 21 | }); 22 | 23 | // Add your own express routes here 24 | 25 | app.listen(3000); 26 | }; 27 | 28 | start(); 29 | -------------------------------------------------------------------------------- /demo/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "strict": false, 7 | "esModuleInterop": true, 8 | "skipLibCheck": true, 9 | "outDir": "./dist", 10 | "rootDir": "../", 11 | "jsx": "react", 12 | "paths": { 13 | "payload/generated-types": ["./src/payload-types.ts"] 14 | } 15 | }, 16 | "include": ["src"], 17 | "exclude": ["node_modules", "dist", "build"], 18 | "ts-node": { 19 | "transpileOnly": true, 20 | "swc": true 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@nouance/payload-simple-rbac", 3 | "version": "0.2.0", 4 | "homepage:": "https://nouance.io", 5 | "repository": "git@github.com:NouanceLabs/payload-rbac.git", 6 | "description": "PayloadCMS plugin to help handle simple RBAC permissions.", 7 | "main": "dist/index.js", 8 | "types": "dist/index.d.ts", 9 | "scripts": { 10 | "build": "tsc", 11 | "test": "echo \"Error: no test specified\" && exit 1" 12 | }, 13 | "keywords": [ 14 | "payload", 15 | "roles", 16 | "permissions", 17 | "rbac", 18 | "cms", 19 | "plugin", 20 | "typescript" 21 | ], 22 | "author": "dev@nouance.io", 23 | "license": "MIT", 24 | "peerDependencies": { 25 | "payload": "^1.6.22", 26 | "react": "^18.2.0" 27 | }, 28 | "dependencies": {}, 29 | "devDependencies": { 30 | "@types/react-router-dom": "^5.3.3", 31 | "payload": "^1.6.22", 32 | "typescript": "^5" 33 | }, 34 | "files": [ 35 | "dist", 36 | "types.js", 37 | "types.d.ts" 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /src/accessControl/hasRole.ts: -------------------------------------------------------------------------------- 1 | import type { Access } from "payload/types"; 2 | 3 | export function hasRole( 4 | role: string, 5 | allRoles: string[] 6 | ): Access { 7 | return async ({ req, id, data }) => { 8 | const user = req.user; 9 | 10 | const userRole = user?.role; 11 | if (userRole === role) return true; 12 | 13 | if (allRoles.indexOf(userRole) >= allRoles.indexOf(role)) return true; 14 | return false; 15 | }; 16 | } 17 | -------------------------------------------------------------------------------- /src/accessControl/hasRoleField.ts: -------------------------------------------------------------------------------- 1 | import type { FieldAccess } from "payload/types"; 2 | import type { TypeWithID } from "payload/dist/globals/config/types"; 3 | 4 | export function hasRoleField( 5 | role: string, 6 | allRoles: string[] 7 | ): FieldAccess { 8 | return async ({ req, id, data }) => { 9 | const user = req.user; 10 | 11 | const userRole = user?.role; 12 | 13 | if (!userRole || !allRoles) return false; 14 | 15 | if (userRole === role) return true; 16 | 17 | if (allRoles.indexOf(userRole) >= allRoles.indexOf(role)) return true; 18 | 19 | return false; 20 | }; 21 | } 22 | -------------------------------------------------------------------------------- /src/accessControl/index.ts: -------------------------------------------------------------------------------- 1 | import { hasRole } from "./hasRole"; 2 | import { publicAccess } from "./public"; 3 | import { publishedOnly } from "./publishedOnly"; 4 | import type { Access } from "payload/config"; 5 | 6 | function accessControl( 7 | permission: string | "public" | "publishedOnly", 8 | roles: string[] 9 | ): Access { 10 | switch (permission) { 11 | case "public": 12 | return publicAccess(); 13 | case "publishedOnly": 14 | return publishedOnly(); 15 | default: 16 | return hasRole(permission, roles); 17 | } 18 | } 19 | 20 | export default accessControl; 21 | -------------------------------------------------------------------------------- /src/accessControl/public.ts: -------------------------------------------------------------------------------- 1 | import type { Access } from "payload/config"; 2 | 3 | export function publicAccess(): Access { 4 | return () => true; 5 | } 6 | -------------------------------------------------------------------------------- /src/accessControl/publishedOnly.ts: -------------------------------------------------------------------------------- 1 | import type { Access } from "payload/config"; 2 | 3 | export function publishedOnly(slugs?: string[]): Access { 4 | return ({ req: { user } }) => { 5 | if (slugs) { 6 | if (user?.collection && slugs.includes(user.collection)) return true; 7 | } else { 8 | if (user) return true; 9 | } 10 | 11 | return { 12 | _status: { 13 | equals: "published", 14 | }, 15 | }; 16 | }; 17 | } 18 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import type { Config as PayloadConfig } from "payload/config"; 2 | import type { CollectionConfig } from "payload/dist/collections/config/types"; 3 | import type { GlobalConfig } from "payload/types"; 4 | import type { PluginConfig } from "./types"; 5 | import { extendWebpackConfig } from "./utilities/extendWebpackConfig"; 6 | import accessControl from "./accessControl/index"; 7 | import starterRoles from "./utilities/starterRoles"; 8 | import { hasRole } from "./accessControl/hasRole"; 9 | import { hasRoleField } from "./accessControl/hasRoleField"; 10 | import { publicAccess } from "./accessControl/public"; 11 | import { publishedOnly } from "./accessControl/publishedOnly"; 12 | 13 | const payloadSimpleRBAC = 14 | (incomingConfig: PluginConfig) => 15 | (config: PayloadConfig): PayloadConfig => { 16 | const { collections, admin } = config; 17 | const { users, roles, defaultRole, globals, fieldName } = incomingConfig; 18 | 19 | if (!roles || !users) return config; 20 | 21 | const roleField = fieldName ?? "role"; 22 | 23 | const processedConfig: PayloadConfig = { 24 | admin: { 25 | ...admin, 26 | webpack: extendWebpackConfig(config), 27 | }, 28 | ...config, 29 | ...(collections && { 30 | collections: collections.map((collection) => { 31 | if (users.includes(collection.slug)) { 32 | const configWithRoleFields: CollectionConfig = { 33 | ...collection, 34 | fields: [ 35 | ...collection.fields, 36 | { 37 | name: roleField, 38 | type: "select", 39 | defaultValue: defaultRole, 40 | options: [ 41 | ...roles.map((role) => { 42 | return { 43 | label: role, 44 | value: role, 45 | }; 46 | }), 47 | ], 48 | }, 49 | ], 50 | }; 51 | 52 | return configWithRoleFields; 53 | } 54 | 55 | const targetCollection = incomingConfig.collections?.find((col) => { 56 | if (col.slug === collection.slug) return true; 57 | return false; 58 | }); 59 | 60 | if (targetCollection) { 61 | const collectionConfigWithHooks: CollectionConfig = { 62 | ...collection, 63 | access: { 64 | ...collection.access, 65 | ...(targetCollection.permissions.read && { 66 | read: accessControl(targetCollection.permissions.read, roles), 67 | }), 68 | ...(targetCollection.permissions.update && { 69 | update: accessControl( 70 | targetCollection.permissions.update, 71 | roles 72 | ), 73 | }), 74 | ...(targetCollection.permissions.create && { 75 | create: accessControl( 76 | targetCollection.permissions.create, 77 | roles 78 | ), 79 | }), 80 | ...(targetCollection.permissions.delete && { 81 | delete: accessControl( 82 | targetCollection.permissions.delete, 83 | roles 84 | ), 85 | }), 86 | }, 87 | }; 88 | 89 | return collectionConfigWithHooks; 90 | } 91 | 92 | return collection; 93 | }), 94 | }), 95 | ...(globals && { 96 | globals: config.globals?.map((global) => { 97 | const targetGlobal = globals.find((glob) => { 98 | return global.slug === glob.slug; 99 | }); 100 | 101 | if (targetGlobal) { 102 | const globalConfigWithHooks: GlobalConfig = { 103 | ...global, 104 | access: { 105 | ...global.access, 106 | ...(targetGlobal.permissions.read && { 107 | read: accessControl(targetGlobal.permissions.read, roles), 108 | }), 109 | ...(targetGlobal.permissions.update && { 110 | update: accessControl(targetGlobal.permissions.update, roles), 111 | }), 112 | }, 113 | }; 114 | 115 | return globalConfigWithHooks; 116 | } 117 | 118 | return global; 119 | }), 120 | }), 121 | }; 122 | 123 | return processedConfig; 124 | }; 125 | 126 | export default payloadSimpleRBAC; 127 | 128 | export { starterRoles, hasRole, publicAccess, publishedOnly, hasRoleField }; 129 | -------------------------------------------------------------------------------- /src/mocks/serverModule.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; 2 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export type Users = string[]; 2 | 3 | export type Collection = { 4 | slug: string; 5 | permissions: { 6 | read?: string | "public" | "publishedOnly"; 7 | create?: string; 8 | update?: string; 9 | delete?: string; 10 | }; 11 | }; 12 | 13 | export type Global = { 14 | slug: string; 15 | permissions: { 16 | read?: string | "public" | "publishedOnly"; 17 | update?: string; 18 | }; 19 | }; 20 | 21 | export interface PluginConfig { 22 | roles: string[]; 23 | users: string[]; 24 | defaultRole: string; 25 | fieldName?: string; 26 | collections?: Collection[]; 27 | globals?: Global[]; 28 | } 29 | 30 | export type SanitizedPluginConfig = PluginConfig & {}; 31 | -------------------------------------------------------------------------------- /src/utilities/extendWebpackConfig.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "payload/config"; 2 | import path from "path"; 3 | import type { Configuration as WebpackConfig } from "webpack"; 4 | 5 | const mockModulePath = path.resolve(__dirname, "mocks/serverModule.js"); 6 | 7 | export const extendWebpackConfig = 8 | (config: Config): ((webpackConfig: WebpackConfig) => WebpackConfig) => 9 | (webpackConfig) => { 10 | const existingWebpackConfig = 11 | typeof config.admin?.webpack === "function" 12 | ? config.admin.webpack(webpackConfig) 13 | : webpackConfig; 14 | 15 | return { 16 | ...existingWebpackConfig, 17 | resolve: { 18 | ...(existingWebpackConfig.resolve || {}), 19 | alias: { 20 | ...(existingWebpackConfig.resolve?.alias 21 | ? existingWebpackConfig.resolve.alias 22 | : {}), 23 | express: mockModulePath, 24 | }, 25 | }, 26 | }; 27 | }; 28 | -------------------------------------------------------------------------------- /src/utilities/starterRoles.ts: -------------------------------------------------------------------------------- 1 | const starterRoles = ["editor", "manager", "admin"]; 2 | 3 | export default starterRoles; 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "outDir": "./dist", 5 | "allowJs": true, 6 | "module": "commonjs", 7 | "sourceMap": true, 8 | "jsx": "react", 9 | "esModuleInterop": true, 10 | "declaration": true, 11 | "declarationDir": "./dist", 12 | "skipLibCheck": true, 13 | "strict": true, 14 | }, 15 | "include": [ 16 | "src/**/*" 17 | ], 18 | } 19 | -------------------------------------------------------------------------------- /types.d.ts: -------------------------------------------------------------------------------- 1 | export * from './dist/types'; 2 | -------------------------------------------------------------------------------- /types.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./dist/types'); 2 | --------------------------------------------------------------------------------