@whatworks/payload-rbac 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +243 -0
  3. package/dist/access/rbacAccess.d.ts +33 -0
  4. package/dist/access/rbacAccess.js +34 -0
  5. package/dist/access/rbacAccess.js.map +1 -0
  6. package/dist/access/rolesFieldAccess.d.ts +33 -0
  7. package/dist/access/rolesFieldAccess.js +31 -0
  8. package/dist/access/rolesFieldAccess.js.map +1 -0
  9. package/dist/collections/createRolesCollection.d.ts +30 -0
  10. package/dist/collections/createRolesCollection.js +63 -0
  11. package/dist/collections/createRolesCollection.js.map +1 -0
  12. package/dist/exports/client.d.ts +2 -0
  13. package/dist/exports/client.js +5 -0
  14. package/dist/fields/PermissionsMatrixField.d.ts +21 -0
  15. package/dist/fields/PermissionsMatrixField.js +180 -0
  16. package/dist/fields/PermissionsMatrixField.js.map +1 -0
  17. package/dist/fields/createRolesField.d.ts +23 -0
  18. package/dist/fields/createRolesField.js +27 -0
  19. package/dist/fields/createRolesField.js.map +1 -0
  20. package/dist/hooks/assignFirstUserRole.d.ts +22 -0
  21. package/dist/hooks/assignFirstUserRole.js +40 -0
  22. package/dist/hooks/assignFirstUserRole.js.map +1 -0
  23. package/dist/hooks/protectCredentials.d.ts +29 -0
  24. package/dist/hooks/protectCredentials.js +43 -0
  25. package/dist/hooks/protectCredentials.js.map +1 -0
  26. package/dist/hooks/protectLastAdmin.d.ts +28 -0
  27. package/dist/hooks/protectLastAdmin.js +76 -0
  28. package/dist/hooks/protectLastAdmin.js.map +1 -0
  29. package/dist/hooks/protectRolesCollection.d.ts +25 -0
  30. package/dist/hooks/protectRolesCollection.js +30 -0
  31. package/dist/hooks/protectRolesCollection.js.map +1 -0
  32. package/dist/hooks/protectRolesField.d.ts +57 -0
  33. package/dist/hooks/protectRolesField.js +99 -0
  34. package/dist/hooks/protectRolesField.js.map +1 -0
  35. package/dist/hooks/protectedRoles.d.ts +34 -0
  36. package/dist/hooks/protectedRoles.js +50 -0
  37. package/dist/hooks/protectedRoles.js.map +1 -0
  38. package/dist/index.d.ts +19 -0
  39. package/dist/index.js +19 -0
  40. package/dist/permissions.d.ts +23 -0
  41. package/dist/permissions.js +31 -0
  42. package/dist/permissions.js.map +1 -0
  43. package/dist/plugin.d.ts +8 -0
  44. package/dist/plugin.js +255 -0
  45. package/dist/plugin.js.map +1 -0
  46. package/dist/seed.d.ts +24 -0
  47. package/dist/seed.js +47 -0
  48. package/dist/seed.js.map +1 -0
  49. package/dist/shared.d.ts +38 -0
  50. package/dist/shared.js +19 -0
  51. package/dist/shared.js.map +1 -0
  52. package/dist/types.d.ts +194 -0
  53. package/dist/utilities/entityLabel.d.ts +10 -0
  54. package/dist/utilities/entityLabel.js +17 -0
  55. package/dist/utilities/entityLabel.js.map +1 -0
  56. package/dist/utilities/fullAccessHolders.d.ts +36 -0
  57. package/dist/utilities/fullAccessHolders.js +68 -0
  58. package/dist/utilities/fullAccessHolders.js.map +1 -0
  59. package/dist/utilities/getUserPermissions.d.ts +17 -0
  60. package/dist/utilities/getUserPermissions.js +72 -0
  61. package/dist/utilities/getUserPermissions.js.map +1 -0
  62. package/package.json +88 -0
package/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 What Works Global
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.
package/README.md ADDED
@@ -0,0 +1,243 @@
1
+ # @whatworks/payload-rbac
2
+
3
+ <a href="https://whatworks.com.au/?utm_source=github.com">
4
+ <picture>
5
+ <source media="(prefers-color-scheme: dark)" srcset="../../assets/blackbanner.svg">
6
+ <img alt="@whatworks/payload-rbac" src="../../assets/whitebanner.svg">
7
+ </picture>
8
+ </a>
9
+
10
+ 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.
11
+
12
+ - Adds a **roles collection** with a checkbox-matrix permissions editor — one row per collection (CRUD) and per global (Read/Update), plus a **Full access** toggle (`'*'`) that covers everything, including collections added later.
13
+ - **Predefine roles in code** — they are seeded on init when missing, and never overwritten afterwards, so the database stays the source of truth.
14
+ - Adds a `roles` relationship field to your auth collections (multi-role; a user's permissions are the union of their roles).
15
+ - **Applies access control automatically** to every collection and global. Access you define explicitly on a collection always wins for that operation — the plugin only fills the gaps.
16
+ - **Privilege-escalation protection**: users can only assign roles, and only add permissions to roles, that their own roles already cover.
17
+ - **Role assignment requires `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.
18
+ - **Built-in admin role** (`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`.
19
+ - **Per-role credential protection** (`credentialChanges: 'self'`): the password, email, and username of users holding such a role can only be changed by the account owner — anyone else sends a password-reset email instead. Always on for the admin role, so an administrator account can never be taken over by another user with `users:update`.
20
+ - Users can always read/update their own account document (configurable), so the admin account view keeps working for low-privilege users.
21
+
22
+ ## Installation
23
+
24
+ ```sh
25
+ pnpm add @whatworks/payload-rbac
26
+ ```
27
+
28
+ ## Usage
29
+
30
+ ```ts
31
+ import { rbacPlugin } from '@whatworks/payload-rbac'
32
+ import { buildConfig } from 'payload'
33
+
34
+ export default buildConfig({
35
+ // ...
36
+ plugins: [
37
+ // Register last so collections added by other plugins are controlled too.
38
+ rbacPlugin({
39
+ adminRole: 'Administrator',
40
+ roles: [
41
+ {
42
+ name: 'Editor',
43
+ permissions: ['posts:create', 'posts:read', 'posts:update', 'posts:delete'],
44
+ },
45
+ ],
46
+ }),
47
+ ],
48
+ })
49
+ ```
50
+
51
+ Then regenerate the import map so the admin panel can resolve the permissions-matrix component:
52
+
53
+ ```sh
54
+ payload generate:importmap
55
+ ```
56
+
57
+ 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.
58
+
59
+ 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.
60
+
61
+ ## Permissions
62
+
63
+ A permission is a plain string, stored on the role document:
64
+
65
+ - `'*'` — full access to every controlled collection and global, present and future.
66
+ - `'<slug>:<action>'` — one action on one entity, e.g. `'posts:update'`. Actions are `create`/`read`/`update`/`delete` for collections and `read`/`update` for globals.
67
+
68
+ 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](#public-access-and-custom-rules) below. `readVersions` maps to the entity's `read` permission and `unlock` to `update`.
69
+
70
+ 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.
71
+
72
+ ## Public access and custom rules
73
+
74
+ Access you define explicitly on a collection wins for that operation, so public reads keep working the way you wrote them:
75
+
76
+ ```ts
77
+ {
78
+ slug: 'posts',
79
+ access: {
80
+ // Explicit — the plugin leaves it alone: anyone can read published posts.
81
+ read: ({ req }) => (req.user ? true : { _status: { equals: 'published' } }),
82
+ // create/update/delete are not defined — the plugin fills them with role checks.
83
+ },
84
+ }
85
+ ```
86
+
87
+ To combine your own rule with a role check, compose the exported helpers:
88
+
89
+ ```ts
90
+ import { hasPermission, requirePermission } from '@whatworks/payload-rbac'
91
+
92
+ {
93
+ access: {
94
+ // Access-function factory:
95
+ update: requirePermission('posts:update'),
96
+ // Or inside your own logic:
97
+ delete: async ({ req }) =>
98
+ (await hasPermission(req, 'posts:delete')) && !req.user?.suspended,
99
+ },
100
+ }
101
+ ```
102
+
103
+ `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).
104
+
105
+ ## Privilege-escalation protection
106
+
107
+ Enabled by default (`preventPrivilegeEscalation`):
108
+
109
+ - Assigning a role to a user requires the assigner's own permissions to cover everything that role grants. Assigning a `'*'` role requires holding `'*'`.
110
+ - The `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](#the-admin-role-and-protected-roles-lockout-prevention) below).
111
+ - Removing roles from **your own account** mirrors assignment: the roles you keep must cover the removed role's permissions — otherwise you could never assign it back, and one save would have locked you out. Removing a redundant role (e.g. dropping `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.
112
+ - Adding permissions to a role (or creating a role) is limited to permissions the editor already holds.
113
+ - Writes without a user — local API seeds, init scripts, the first-user registration — are never restricted.
114
+
115
+ 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).
116
+
117
+ ### The roles field requires `roles:update` — know the semantics
118
+
119
+ Changing 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:
120
+
121
+ - **API writes are silently ignored, not rejected.** A create or update that includes the roles field without `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`.
122
+ - **Creating or duplicating a user without `roles:update` produces a user with no roles** — roles in the create data are dropped the same way.
123
+ - **Assigning roles to another user needs both permissions**: `users:update` to save their document at all, plus `roles:update` for the field.
124
+ - **Self-removals fail loudly, not silently**: a user who does hold `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.
125
+ - **Break-glass**: while no user in the system holds full access, the field stays writable for signed-in users so the [self-claim recovery](#the-admin-role-and-protected-roles-lockout-prevention) can proceed — the escalation guard still vets that write. Without an `adminRole` configured there is no such relaxation: make sure some role keeps `roles:update`, or roles can only be assigned through the local API.
126
+ - **A roles field you defined yourself is left alone**: if your user collection already has a field with the roles field name, the guard hooks apply to it but the `roles:update` field gate does not. Use `rolesField.override` to replace the generated field's `access` when you want different rules.
127
+
128
+ ## The admin role and protected roles (lockout prevention)
129
+
130
+ 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.
131
+
132
+ 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:
133
+
134
+ - Its permissions cannot be changed through the API — not even by a `'*'` 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.
135
+ - It cannot be renamed or deleted.
136
+ - Drifted permissions (e.g. from direct database edits) are restored on init.
137
+ - **Only holders can grant the admin role**: assigning it requires holding it yourself — even a `'*'` 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.
138
+ - **At least one user always holds the admin role**: removing it from the last holder, or deleting that user, is rejected (holders are counted across every collection that carries the roles field). Writes without a user (local API, init scripts) are exempt, like the other guards.
139
+ - **Break-glass recovery**: the guards cannot see database-level damage — if the roles collection is wiped, re-seeding on restart recreates the admin role under a new ID, so every user's role references dangle and nobody holds it. While **no user in the system holds full access**, any signed-in user may assign the admin role **to themselves**: open your account, add the role, save. The escalation guard permits exactly that write, the roles collection becomes readable to signed-in users so the role can be picked, and the roles field stays writable without `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.
140
+ - The admin panel renders its permissions matrix read-only with an explanatory note.
141
+
142
+ 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.
143
+
144
+ 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.
145
+
146
+ ## Self-only credentials
147
+
148
+ 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, per role: the password, email, and username of a user holding a `credentialChanges: 'self'` role 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.
149
+
150
+ The `adminRole` is always `'self'`; opt other roles in per role. The protection follows the role name, so pair `'self'` with `protected: true` to prevent the role being renamed away from it. Writes without a user (local API, seeds) are never restricted, like the other guards.
151
+
152
+ ### Protecting the developer account from client admins
153
+
154
+ 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:
155
+
156
+ ```ts
157
+ rbacPlugin({
158
+ // The developer/agency tier — only holders can grant it, credentials self-only.
159
+ adminRole: 'Super Admin',
160
+ roles: [
161
+ {
162
+ name: 'Admin',
163
+ description: 'Site administrator.',
164
+ // Full access to everything, including collections added later.
165
+ permissions: ['*'],
166
+ protected: true,
167
+ },
168
+ ],
169
+ })
170
+ ```
171
+
172
+ The client runs the whole site — content, users, roles, future collections — but the developer account stays out of reach:
173
+
174
+ - They cannot change the developer's password, email, or username (the admin role is always `credentialChanges: 'self'`), so credential takeover is closed.
175
+ - They cannot hold or hand out `'Super Admin'`: even with `'*'`, assigning the admin role requires already holding it.
176
+ - They cannot strip the role from — or delete — the last developer account, and the role itself cannot be renamed, downgraded, or deleted.
177
+
178
+ Note the last-holder guard only protects the _last_ holder: with two or more Super Admin accounts, a user with `users:update`/`users:delete` can still strip or delete all but one of them (their credentials stay protected regardless).
179
+
180
+ ## Options
181
+
182
+ ```ts
183
+ rbacPlugin({
184
+ // Which collections are access-controlled and appear in the matrix. Defaults to all
185
+ // collections present when the plugin runs (register the plugin last).
186
+ // - true — every collection (default)
187
+ // - ['posts', 'pages'] — only these
188
+ // - { exclude: ['media'] } — all except these
189
+ collections: true,
190
+
191
+ // Which globals are controlled (read/update). Same semantics. Defaults to all.
192
+ globals: true,
193
+
194
+ // Auth collections that receive the roles field.
195
+ // Defaults to every auth-enabled collection.
196
+ userCollections: ['users'],
197
+
198
+ // The built-in administrator role: always full access (['*']), always protected
199
+ // (no API edits/renames/deletes, drift repaired on init), auto-assigned to the
200
+ // first user created. A name, or { name, description }. Default: false.
201
+ adminRole: 'Administrator',
202
+
203
+ // Additional roles seeded on init when no role with the same name exists.
204
+ // `protected: true` locks a role to its code definition the same way the
205
+ // adminRole is locked (default: false — the database wins). `credentialChanges:
206
+ // 'self'` makes holders' password/email/username changeable only by themselves
207
+ // (default: 'anyone'; the adminRole is always 'self').
208
+ roles: [{ name: 'Editor', permissions: ['posts:read', 'posts:update'] }],
209
+
210
+ // Users may read/update their own user document without holding the collection
211
+ // permission. Default: ['read', 'update']. Set false to disable.
212
+ ownAccountAccess: ['read', 'update'],
213
+
214
+ // Block users from granting roles/permissions beyond their own. Default: true.
215
+ preventPrivilegeEscalation: true,
216
+
217
+ // The roles collection added by the plugin.
218
+ rolesCollection: {
219
+ slug: 'roles', // default
220
+ // Escape hatch: adjust labels, admin group, extra fields, hooks, …
221
+ override: (collection) => ({ ...collection, admin: { ...collection.admin, group: 'Admin' } }),
222
+ },
223
+
224
+ // The roles field added to user collections. Changing it requires the
225
+ // `roles:update` permission (it renders read-only in the admin panel
226
+ // otherwise); `override` can replace the field's `access` to change that.
227
+ rolesField: {
228
+ name: 'roles', // default
229
+ override: (field) => ({ ...field, saveToJWT: false }),
230
+ },
231
+
232
+ // Disable the plugin entirely (useful per-environment). Default: true.
233
+ enabled: true,
234
+ })
235
+ ```
236
+
237
+ ## Behaviour notes
238
+
239
+ - **Admin panel navigation** hides collections and globals the user cannot read — no extra configuration needed.
240
+ - **Admin panel login** (`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.
241
+ - **Roles are resolved from the database** on each request (one indexed query, memoized per request), so permission changes apply immediately — no re-login needed. The role IDs are also stored on the JWT (`saveToJWT`) for consumers reading the token directly.
242
+ - If a user collection already defines a field with the roles field name, the field is left entirely yours — the guard hooks still apply to it, but the `roles:update` field gate does not.
243
+ - Predefined role permissions are validated at startup against the known collections and globals, so a typo fails fast with a clear error.
@@ -0,0 +1,33 @@
1
+ import { RbacAction } from "../shared.js";
2
+ import { Access } from "payload";
3
+
4
+ //#region src/access/rbacAccess.d.ts
5
+ type CreateRbacAccessArgs = {
6
+ action: RbacAction;
7
+ /**
8
+ * Actions the requesting user is allowed on their own user document even without
9
+ * the collection permission. Only relevant on auth collections; grants a
10
+ * `{ id: { equals: user.id } }` query constraint.
11
+ */
12
+ ownAccountActions?: readonly RbacAction[];
13
+ slug: string;
14
+ };
15
+ /**
16
+ * Builds the access function the plugin installs for one operation on one
17
+ * collection or global: deny anonymous requests, allow when any of the user's
18
+ * roles grants the permission (or `'*'`), otherwise constrain to the user's own
19
+ * document when the own-account carve-out applies.
20
+ */
21
+ declare const createRbacAccess: ({
22
+ slug,
23
+ action,
24
+ ownAccountActions
25
+ }: CreateRbacAccessArgs) => Access;
26
+ /**
27
+ * Access factory for composing role checks into your own access control, e.g.
28
+ * `access: { update: requirePermission('posts:update') }`.
29
+ */
30
+ declare const requirePermission: (permission: string) => Access;
31
+ //#endregion
32
+ export { CreateRbacAccessArgs, createRbacAccess, requirePermission };
33
+ //# sourceMappingURL=rbacAccess.d.ts.map
@@ -0,0 +1,34 @@
1
+ import { permissionsGrant } from "../permissions.js";
2
+ import { getUserPermissions } from "../utilities/getUserPermissions.js";
3
+
4
+ //#region src/access/rbacAccess.ts
5
+ /**
6
+ * Builds the access function the plugin installs for one operation on one
7
+ * collection or global: deny anonymous requests, allow when any of the user's
8
+ * roles grants the permission (or `'*'`), otherwise constrain to the user's own
9
+ * document when the own-account carve-out applies.
10
+ */
11
+ const createRbacAccess = ({ slug, action, ownAccountActions = [] }) => {
12
+ return async ({ req }) => {
13
+ const { user } = req;
14
+ if (!user) return false;
15
+ if (permissionsGrant(await getUserPermissions(req), slug, action)) return true;
16
+ if (ownAccountActions.includes(action) && user.collection === slug) return { id: { equals: user.id } };
17
+ return false;
18
+ };
19
+ };
20
+ /**
21
+ * Access factory for composing role checks into your own access control, e.g.
22
+ * `access: { update: requirePermission('posts:update') }`.
23
+ */
24
+ const requirePermission = (permission) => {
25
+ return async ({ req }) => {
26
+ if (!req.user) return false;
27
+ const permissions = await getUserPermissions(req);
28
+ return permissions.has("*") || permissions.has(permission);
29
+ };
30
+ };
31
+
32
+ //#endregion
33
+ export { createRbacAccess, requirePermission };
34
+ //# sourceMappingURL=rbacAccess.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rbacAccess.js","names":[],"sources":["../../src/access/rbacAccess.ts"],"sourcesContent":["import type { Access } from 'payload'\n\nimport type { RbacAction } from '../shared.js'\n\nimport { permissionsGrant } from '../permissions.js'\nimport { getUserPermissions } from '../utilities/getUserPermissions.js'\n\nexport type CreateRbacAccessArgs = {\n action: RbacAction\n /**\n * Actions the requesting user is allowed on their own user document even without\n * the collection permission. Only relevant on auth collections; grants a\n * `{ id: { equals: user.id } }` query constraint.\n */\n ownAccountActions?: readonly RbacAction[]\n slug: string\n}\n\n/**\n * Builds the access function the plugin installs for one operation on one\n * collection or global: deny anonymous requests, allow when any of the user's\n * roles grants the permission (or `'*'`), otherwise constrain to the user's own\n * document when the own-account carve-out applies.\n */\nexport const createRbacAccess = ({\n slug,\n action,\n ownAccountActions = [],\n}: CreateRbacAccessArgs): Access => {\n return async ({ req }) => {\n const { user } = req\n if (!user) {\n return false\n }\n\n const permissions = await getUserPermissions(req)\n if (permissionsGrant(permissions, slug, action)) {\n return true\n }\n\n if (ownAccountActions.includes(action) && user.collection === slug) {\n return { id: { equals: user.id } }\n }\n\n return false\n }\n}\n\n/**\n * Access factory for composing role checks into your own access control, e.g.\n * `access: { update: requirePermission('posts:update') }`.\n */\nexport const requirePermission = (permission: string): Access => {\n return async ({ req }) => {\n if (!req.user) {\n return false\n }\n const permissions = await getUserPermissions(req)\n return permissions.has('*') || permissions.has(permission)\n }\n}\n"],"mappings":";;;;;;;;;;AAwBA,MAAa,oBAAoB,EAC/B,MACA,QACA,oBAAoB,EAAE,OACY;AAClC,QAAO,OAAO,EAAE,UAAU;EACxB,MAAM,EAAE,SAAS;AACjB,MAAI,CAAC,KACH,QAAO;AAIT,MAAI,iBADgB,MAAM,mBAAmB,IAAI,EACf,MAAM,OAAO,CAC7C,QAAO;AAGT,MAAI,kBAAkB,SAAS,OAAO,IAAI,KAAK,eAAe,KAC5D,QAAO,EAAE,IAAI,EAAE,QAAQ,KAAK,IAAI,EAAE;AAGpC,SAAO;;;;;;;AAQX,MAAa,qBAAqB,eAA+B;AAC/D,QAAO,OAAO,EAAE,UAAU;AACxB,MAAI,CAAC,IAAI,KACP,QAAO;EAET,MAAM,cAAc,MAAM,mBAAmB,IAAI;AACjD,SAAO,YAAY,IAAI,IAAI,IAAI,YAAY,IAAI,WAAW"}
@@ -0,0 +1,33 @@
1
+ import { FieldAccess } from "payload";
2
+
3
+ //#region src/access/rolesFieldAccess.d.ts
4
+ type CreateRolesFieldAccessArgs = {
5
+ /**
6
+ * Keep the field writable while no user in the system holds full access, so a
7
+ * stranded user can still claim the admin role from their own account page.
8
+ * Set only when an `adminRole` exists — without one there is no break-glass
9
+ * path to hold open.
10
+ */
11
+ breakGlass?: {
12
+ userCollections: string[];
13
+ };
14
+ rolesCollectionSlug: string;
15
+ rolesFieldName: string;
16
+ };
17
+ /**
18
+ * Field-level access for the roles field on user collections: changing role
19
+ * membership requires the `<roles>:update` permission. Field access is what
20
+ * makes the admin panel render the field read-only, so a user without it can
21
+ * never accidentally remove their own roles; API writes that include the field
22
+ * without the permission succeed with the field silently kept unchanged —
23
+ * Payload's field-access semantics. Writes without a user (seeds, init scripts,
24
+ * the first-user registration) pass through, like the guard hooks.
25
+ */
26
+ declare const createRolesFieldAccess: ({
27
+ breakGlass,
28
+ rolesCollectionSlug,
29
+ rolesFieldName
30
+ }: CreateRolesFieldAccessArgs) => FieldAccess;
31
+ //#endregion
32
+ export { CreateRolesFieldAccessArgs, createRolesFieldAccess };
33
+ //# sourceMappingURL=rolesFieldAccess.d.ts.map
@@ -0,0 +1,31 @@
1
+ import { permissionFor } from "../permissions.js";
2
+ import { hasPermission } from "../utilities/getUserPermissions.js";
3
+ import { anyUserHoldsRole, findFullAccessRoleIds } from "../utilities/fullAccessHolders.js";
4
+
5
+ //#region src/access/rolesFieldAccess.ts
6
+ /**
7
+ * Field-level access for the roles field on user collections: changing role
8
+ * membership requires the `<roles>:update` permission. Field access is what
9
+ * makes the admin panel render the field read-only, so a user without it can
10
+ * never accidentally remove their own roles; API writes that include the field
11
+ * without the permission succeed with the field silently kept unchanged —
12
+ * Payload's field-access semantics. Writes without a user (seeds, init scripts,
13
+ * the first-user registration) pass through, like the guard hooks.
14
+ */
15
+ const createRolesFieldAccess = ({ breakGlass, rolesCollectionSlug, rolesFieldName }) => {
16
+ const requiredPermission = permissionFor(rolesCollectionSlug, "update");
17
+ return async ({ req }) => {
18
+ if (!req.user) return true;
19
+ if (await hasPermission(req, requiredPermission)) return true;
20
+ if (!breakGlass) return false;
21
+ const fullAccessRoleIds = await findFullAccessRoleIds(req.payload, rolesCollectionSlug);
22
+ return !await anyUserHoldsRole(req.payload, fullAccessRoleIds, {
23
+ rolesFieldName,
24
+ userCollections: breakGlass.userCollections
25
+ });
26
+ };
27
+ };
28
+
29
+ //#endregion
30
+ export { createRolesFieldAccess };
31
+ //# sourceMappingURL=rolesFieldAccess.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rolesFieldAccess.js","names":[],"sources":["../../src/access/rolesFieldAccess.ts"],"sourcesContent":["import type { FieldAccess } from 'payload'\n\nimport { permissionFor } from '../permissions.js'\nimport { anyUserHoldsRole, findFullAccessRoleIds } from '../utilities/fullAccessHolders.js'\nimport { hasPermission } from '../utilities/getUserPermissions.js'\n\nexport type CreateRolesFieldAccessArgs = {\n /**\n * Keep the field writable while no user in the system holds full access, so a\n * stranded user can still claim the admin role from their own account page.\n * Set only when an `adminRole` exists — without one there is no break-glass\n * path to hold open.\n */\n breakGlass?: {\n userCollections: string[]\n }\n rolesCollectionSlug: string\n rolesFieldName: string\n}\n\n/**\n * Field-level access for the roles field on user collections: changing role\n * membership requires the `<roles>:update` permission. Field access is what\n * makes the admin panel render the field read-only, so a user without it can\n * never accidentally remove their own roles; API writes that include the field\n * without the permission succeed with the field silently kept unchanged —\n * Payload's field-access semantics. Writes without a user (seeds, init scripts,\n * the first-user registration) pass through, like the guard hooks.\n */\nexport const createRolesFieldAccess = ({\n breakGlass,\n rolesCollectionSlug,\n rolesFieldName,\n}: CreateRolesFieldAccessArgs): FieldAccess => {\n const requiredPermission = permissionFor(rolesCollectionSlug, 'update')\n return async ({ req }) => {\n if (!req.user) {\n return true\n }\n if (await hasPermission(req, requiredPermission)) {\n return true\n }\n if (!breakGlass) {\n return false\n }\n // Break-glass: while nobody holds full access the field must stay writable,\n // or the stranded self-claim would be stripped from the write before the\n // escalation guard — which still limits what that write may do — saw it.\n const fullAccessRoleIds = await findFullAccessRoleIds(req.payload, rolesCollectionSlug)\n return !(await anyUserHoldsRole(req.payload, fullAccessRoleIds, {\n rolesFieldName,\n userCollections: breakGlass.userCollections,\n }))\n }\n}\n"],"mappings":";;;;;;;;;;;;;;AA6BA,MAAa,0BAA0B,EACrC,YACA,qBACA,qBAC6C;CAC7C,MAAM,qBAAqB,cAAc,qBAAqB,SAAS;AACvE,QAAO,OAAO,EAAE,UAAU;AACxB,MAAI,CAAC,IAAI,KACP,QAAO;AAET,MAAI,MAAM,cAAc,KAAK,mBAAmB,CAC9C,QAAO;AAET,MAAI,CAAC,WACH,QAAO;EAKT,MAAM,oBAAoB,MAAM,sBAAsB,IAAI,SAAS,oBAAoB;AACvF,SAAO,CAAE,MAAM,iBAAiB,IAAI,SAAS,mBAAmB;GAC9D;GACA,iBAAiB,WAAW;GAC7B,CAAC"}
@@ -0,0 +1,30 @@
1
+ import { MatrixRow } from "../shared.js";
2
+ import { CollectionConfig } from "payload";
3
+
4
+ //#region src/collections/createRolesCollection.d.ts
5
+ type CreateRolesCollectionArgs = {
6
+ access: CollectionConfig['access'];
7
+ hooks?: CollectionConfig['hooks'];
8
+ matrixRows: MatrixRow[];
9
+ override?: (collection: CollectionConfig) => CollectionConfig;
10
+ /** Names of code-locked roles; the matrix renders read-only for them. */
11
+ protectedRoleNames?: string[];
12
+ slug: string;
13
+ };
14
+ /**
15
+ * Builds the roles collection. Permissions are stored as an array of
16
+ * `'<slug>:<action>'` strings (plus `'*'`) in a `select` field, so every value is
17
+ * validated against the known collections and globals; the admin UI renders them
18
+ * through the plugin's checkbox-matrix field component.
19
+ */
20
+ declare const createRolesCollection: ({
21
+ slug,
22
+ access,
23
+ hooks,
24
+ matrixRows,
25
+ override,
26
+ protectedRoleNames
27
+ }: CreateRolesCollectionArgs) => CollectionConfig;
28
+ //#endregion
29
+ export { CreateRolesCollectionArgs, createRolesCollection };
30
+ //# sourceMappingURL=createRolesCollection.d.ts.map
@@ -0,0 +1,63 @@
1
+ import { FULL_ACCESS, permissionFor } from "../permissions.js";
2
+ import { permissionsMatrixFieldPath } from "../shared.js";
3
+
4
+ //#region src/collections/createRolesCollection.ts
5
+ /**
6
+ * Builds the roles collection. Permissions are stored as an array of
7
+ * `'<slug>:<action>'` strings (plus `'*'`) in a `select` field, so every value is
8
+ * validated against the known collections and globals; the admin UI renders them
9
+ * through the plugin's checkbox-matrix field component.
10
+ */
11
+ const createRolesCollection = ({ slug, access, hooks, matrixRows, override, protectedRoleNames = [] }) => {
12
+ const options = [{
13
+ label: "Full access",
14
+ value: FULL_ACCESS
15
+ }, ...matrixRows.flatMap((row) => row.actions.map((action) => ({
16
+ label: `${row.label}: ${action}`,
17
+ value: permissionFor(row.slug, action)
18
+ })))];
19
+ const collection = {
20
+ slug,
21
+ access,
22
+ admin: {
23
+ defaultColumns: ["name", "description"],
24
+ description: "Roles control what users can access. Assign them on the user document.",
25
+ useAsTitle: "name"
26
+ },
27
+ fields: [
28
+ {
29
+ name: "name",
30
+ type: "text",
31
+ index: true,
32
+ label: "Name",
33
+ required: true,
34
+ unique: true
35
+ },
36
+ {
37
+ name: "description",
38
+ type: "textarea",
39
+ label: "Description"
40
+ },
41
+ {
42
+ name: "permissions",
43
+ type: "select",
44
+ admin: { components: { Field: {
45
+ clientProps: {
46
+ protectedRoleNames,
47
+ rows: matrixRows
48
+ },
49
+ path: permissionsMatrixFieldPath
50
+ } } },
51
+ hasMany: true,
52
+ label: "Permissions",
53
+ options
54
+ }
55
+ ],
56
+ hooks
57
+ };
58
+ return override ? override(collection) : collection;
59
+ };
60
+
61
+ //#endregion
62
+ export { createRolesCollection };
63
+ //# sourceMappingURL=createRolesCollection.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"createRolesCollection.js","names":["options: Option[]","collection: CollectionConfig"],"sources":["../../src/collections/createRolesCollection.ts"],"sourcesContent":["import type { CollectionConfig, Option } from 'payload'\n\nimport type { MatrixRow } from '../shared.js'\n\nimport { FULL_ACCESS, permissionFor } from '../permissions.js'\nimport { permissionsMatrixFieldPath } from '../shared.js'\n\nexport type CreateRolesCollectionArgs = {\n access: CollectionConfig['access']\n hooks?: CollectionConfig['hooks']\n matrixRows: MatrixRow[]\n override?: (collection: CollectionConfig) => CollectionConfig\n /** Names of code-locked roles; the matrix renders read-only for them. */\n protectedRoleNames?: string[]\n slug: string\n}\n\n/**\n * Builds the roles collection. Permissions are stored as an array of\n * `'<slug>:<action>'` strings (plus `'*'`) in a `select` field, so every value is\n * validated against the known collections and globals; the admin UI renders them\n * through the plugin's checkbox-matrix field component.\n */\nexport const createRolesCollection = ({\n slug,\n access,\n hooks,\n matrixRows,\n override,\n protectedRoleNames = [],\n}: CreateRolesCollectionArgs): CollectionConfig => {\n const options: Option[] = [\n { label: 'Full access', value: FULL_ACCESS },\n ...matrixRows.flatMap((row) =>\n row.actions.map((action) => ({\n label: `${row.label}: ${action}`,\n value: permissionFor(row.slug, action),\n })),\n ),\n ]\n\n const collection: CollectionConfig = {\n slug,\n access,\n admin: {\n defaultColumns: ['name', 'description'],\n description: 'Roles control what users can access. Assign them on the user document.',\n useAsTitle: 'name',\n },\n fields: [\n {\n name: 'name',\n type: 'text',\n index: true,\n label: 'Name',\n required: true,\n unique: true,\n },\n {\n name: 'description',\n type: 'textarea',\n label: 'Description',\n },\n {\n name: 'permissions',\n type: 'select',\n admin: {\n components: {\n Field: {\n clientProps: { protectedRoleNames, rows: matrixRows },\n path: permissionsMatrixFieldPath,\n },\n },\n },\n hasMany: true,\n label: 'Permissions',\n options,\n },\n ],\n hooks,\n }\n\n return override ? override(collection) : collection\n}\n"],"mappings":";;;;;;;;;;AAuBA,MAAa,yBAAyB,EACpC,MACA,QACA,OACA,YACA,UACA,qBAAqB,EAAE,OAC0B;CACjD,MAAMA,UAAoB,CACxB;EAAE,OAAO;EAAe,OAAO;EAAa,EAC5C,GAAG,WAAW,SAAS,QACrB,IAAI,QAAQ,KAAK,YAAY;EAC3B,OAAO,GAAG,IAAI,MAAM,IAAI;EACxB,OAAO,cAAc,IAAI,MAAM,OAAO;EACvC,EAAE,CACJ,CACF;CAED,MAAMC,aAA+B;EACnC;EACA;EACA,OAAO;GACL,gBAAgB,CAAC,QAAQ,cAAc;GACvC,aAAa;GACb,YAAY;GACb;EACD,QAAQ;GACN;IACE,MAAM;IACN,MAAM;IACN,OAAO;IACP,OAAO;IACP,UAAU;IACV,QAAQ;IACT;GACD;IACE,MAAM;IACN,MAAM;IACN,OAAO;IACR;GACD;IACE,MAAM;IACN,MAAM;IACN,OAAO,EACL,YAAY,EACV,OAAO;KACL,aAAa;MAAE;MAAoB,MAAM;MAAY;KACrD,MAAM;KACP,EACF,EACF;IACD,SAAS;IACT,OAAO;IACP;IACD;GACF;EACD;EACD;AAED,QAAO,WAAW,SAAS,WAAW,GAAG"}
@@ -0,0 +1,2 @@
1
+ import { PermissionsMatrixField, PermissionsMatrixFieldProps } from "../fields/PermissionsMatrixField.js";
2
+ export { PermissionsMatrixField, type PermissionsMatrixFieldProps };
@@ -0,0 +1,5 @@
1
+ 'use client';
2
+
3
+ import { PermissionsMatrixField } from "../fields/PermissionsMatrixField.js";
4
+
5
+ export { PermissionsMatrixField };
@@ -0,0 +1,21 @@
1
+ import { MatrixRow } from "../shared.js";
2
+ import { SelectFieldClientProps } from "payload";
3
+ import React from "react";
4
+
5
+ //#region src/fields/PermissionsMatrixField.d.ts
6
+ type PermissionsMatrixFieldProps = {
7
+ /** Names of code-locked roles the matrix renders read-only for. */
8
+ protectedRoleNames?: string[];
9
+ /** Serialized by the plugin into the field's `clientProps`. */
10
+ rows?: MatrixRow[];
11
+ } & SelectFieldClientProps;
12
+ /**
13
+ * Renders the roles collection's `permissions` select as a matrix of collections ×
14
+ * Create/Read/Update/Delete checkboxes (globals: Read/Update), with a "full access"
15
+ * master toggle for `'*'`. The stored value stays a plain array of permission
16
+ * strings, so the REST/local APIs are unaffected.
17
+ */
18
+ declare const PermissionsMatrixField: React.FC<PermissionsMatrixFieldProps>;
19
+ //#endregion
20
+ export { PermissionsMatrixField, PermissionsMatrixFieldProps };
21
+ //# sourceMappingURL=PermissionsMatrixField.d.ts.map