@whatworks/payload-rbac 0.1.0 → 0.2.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.
package/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
 
10
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
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.
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. Wildcard permissions (`'pages:*'`, `'*:read'`) are supported and render as checked, locked cells in the matrix.
13
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
14
  - Adds a `roles` relationship field to your auth collections (multi-role; a user's permissions are the union of their roles).
15
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.
@@ -64,6 +64,8 @@ A permission is a plain string, stored on the role document:
64
64
 
65
65
  - `'*'` — full access to every controlled collection and global, present and future.
66
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
+ - `'<slug>:*'` — every action on one entity, e.g. `'pages:*'`.
68
+ - `'*:<action>'` — one action on every controlled entity, **present and future**, e.g. `'*:read'`. (`'*:create'` and `'*:delete'` only ever match collections — globals have no such actions.)
67
69
 
68
70
  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
71
 
@@ -107,6 +109,7 @@ import { hasPermission, requirePermission } from '@whatworks/payload-rbac'
107
109
  Enabled by default (`preventPrivilegeEscalation`):
108
110
 
109
111
  - Assigning a role to a user requires the assigner's own permissions to cover everything that role grants. Assigning a `'*'` role requires holding `'*'`.
112
+ - Coverage is wildcard-aware: holding `'pages:*'` covers granting `'pages:read'`, and holding every action on an entity covers granting its `'<slug>:*'`. But `'*:<action>'` spans entities added in the future, so nothing short of holding `'*:<action>'` itself (or full access) covers granting it — the same way `'*'` works. A role whose `'*:<action>'` wildcards span all four actions is equivalent to `'*'` and treated as full access everywhere.
110
113
  - 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
114
  - 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
115
  - Adding permissions to a role (or creating a role) is limited to permissions the editor already holds.
@@ -1,4 +1,4 @@
1
- import { permissionsGrant } from "../permissions.js";
1
+ import { permissionCovers, permissionsGrant } from "../permissions.js";
2
2
  import { getUserPermissions } from "../utilities/getUserPermissions.js";
3
3
 
4
4
  //#region src/access/rbacAccess.ts
@@ -24,8 +24,7 @@ const createRbacAccess = ({ slug, action, ownAccountActions = [] }) => {
24
24
  const requirePermission = (permission) => {
25
25
  return async ({ req }) => {
26
26
  if (!req.user) return false;
27
- const permissions = await getUserPermissions(req);
28
- return permissions.has("*") || permissions.has(permission);
27
+ return permissionCovers(await getUserPermissions(req), permission);
29
28
  };
30
29
  };
31
30
 
@@ -1 +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"}
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 { permissionCovers, 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 permissionCovers(permissions, 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;AAGT,SAAO,iBADa,MAAM,mBAAmB,IAAI,EACZ,WAAW"}
@@ -13,9 +13,10 @@ type CreateRolesCollectionArgs = {
13
13
  };
14
14
  /**
15
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.
16
+ * `'<slug>:<action>'` strings (plus `'*'` and the `'<slug>:*'`/`'*:<action>'`
17
+ * wildcards) in a `select` field, so every value is validated against the known
18
+ * collections and globals; the admin UI renders them through the plugin's
19
+ * checkbox-matrix field component.
19
20
  */
20
21
  declare const createRolesCollection: ({
21
22
  slug,
@@ -1,21 +1,32 @@
1
+ import { collectionActions, permissionsMatrixFieldPath } from "../shared.js";
1
2
  import { FULL_ACCESS, permissionFor } from "../permissions.js";
2
- import { permissionsMatrixFieldPath } from "../shared.js";
3
3
 
4
4
  //#region src/collections/createRolesCollection.ts
5
5
  /**
6
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.
7
+ * `'<slug>:<action>'` strings (plus `'*'` and the `'<slug>:*'`/`'*:<action>'`
8
+ * wildcards) in a `select` field, so every value is validated against the known
9
+ * collections and globals; the admin UI renders them through the plugin's
10
+ * checkbox-matrix field component.
10
11
  */
11
12
  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
- })))];
13
+ const options = [
14
+ {
15
+ label: "Full access",
16
+ value: FULL_ACCESS
17
+ },
18
+ ...collectionActions.map((action) => ({
19
+ label: `Everything: ${action}`,
20
+ value: `*:${action}`
21
+ })),
22
+ ...matrixRows.flatMap((row) => [{
23
+ label: `${row.label}: all actions`,
24
+ value: `${row.slug}:*`
25
+ }, ...row.actions.map((action) => ({
26
+ label: `${row.label}: ${action}`,
27
+ value: permissionFor(row.slug, action)
28
+ }))])
29
+ ];
19
30
  const collection = {
20
31
  slug,
21
32
  access,
@@ -1 +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"}
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 { collectionActions, 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 `'*'` and the `'<slug>:*'`/`'*:<action>'`\n * wildcards) in a `select` field, so every value is validated against the known\n * collections and globals; the admin UI renders them through the plugin's\n * 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 ...collectionActions.map((action) => ({\n label: `Everything: ${action}`,\n value: `*:${action}`,\n })),\n ...matrixRows.flatMap((row) => [\n { label: `${row.label}: all actions`, value: `${row.slug}:*` },\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":";;;;;;;;;;;AAwBA,MAAa,yBAAyB,EACpC,MACA,QACA,OACA,YACA,UACA,qBAAqB,EAAE,OAC0B;CACjD,MAAMA,UAAoB;EACxB;GAAE,OAAO;GAAe,OAAO;GAAa;EAC5C,GAAG,kBAAkB,KAAK,YAAY;GACpC,OAAO,eAAe;GACtB,OAAO,KAAK;GACb,EAAE;EACH,GAAG,WAAW,SAAS,QAAQ,CAC7B;GAAE,OAAO,GAAG,IAAI,MAAM;GAAgB,OAAO,GAAG,IAAI,KAAK;GAAK,EAC9D,GAAG,IAAI,QAAQ,KAAK,YAAY;GAC9B,OAAO,GAAG,IAAI,MAAM,IAAI;GACxB,OAAO,cAAc,IAAI,MAAM,OAAO;GACvC,EAAE,CACJ,CAAC;EACH;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"}
@@ -12,8 +12,10 @@ type PermissionsMatrixFieldProps = {
12
12
  /**
13
13
  * Renders the roles collection's `permissions` select as a matrix of collections ×
14
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.
15
+ * master toggle for `'*'`, an "All collections and globals" row for the
16
+ * `'*:<action>'` wildcards, and cells granted through a wildcard — `'<slug>:*'`
17
+ * or `'*:<action>'` — shown checked and locked. The stored value stays a plain
18
+ * array of permission strings, so the REST/local APIs are unaffected.
17
19
  */
18
20
  declare const PermissionsMatrixField: React.FC<PermissionsMatrixFieldProps>;
19
21
  //#endregion
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
 
3
- import { FULL_ACCESS, permissionFor } from "../permissions.js";
4
3
  import { collectionActions } from "../shared.js";
4
+ import { FULL_ACCESS, permissionFor } from "../permissions.js";
5
5
  import { FieldDescription, FieldLabel, useField, useFormFields } from "@payloadcms/ui";
6
6
  import React, { useCallback, useMemo } from "react";
7
7
  import { jsx, jsxs } from "react/jsx-runtime";
@@ -16,11 +16,15 @@ const rowLabelStyle = {
16
16
  ...cellStyle,
17
17
  textAlign: "left"
18
18
  };
19
+ const columnWildcard = (action) => `*:${action}`;
20
+ const rowWildcard = (slug) => `${slug}:*`;
19
21
  /**
20
22
  * Renders the roles collection's `permissions` select as a matrix of collections ×
21
23
  * Create/Read/Update/Delete checkboxes (globals: Read/Update), with a "full access"
22
- * master toggle for `'*'`. The stored value stays a plain array of permission
23
- * strings, so the REST/local APIs are unaffected.
24
+ * master toggle for `'*'`, an "All collections and globals" row for the
25
+ * `'*:<action>'` wildcards, and cells granted through a wildcard — `'<slug>:*'`
26
+ * or `'*:<action>'` — shown checked and locked. The stored value stays a plain
27
+ * array of permission strings, so the REST/local APIs are unaffected.
24
28
  */
25
29
  const PermissionsMatrixField = (props) => {
26
30
  const { field, path, protectedRoleNames = [], readOnly, rows = [] } = props;
@@ -37,19 +41,20 @@ const PermissionsMatrixField = (props) => {
37
41
  setValue(Array.from(next).sort());
38
42
  }, [selected, setValue]);
39
43
  const toggleRow = useCallback((row) => {
40
- const permissions = row.actions.map((action) => permissionFor(row.slug, action));
41
- const allSelected = permissions.every((permission) => selected.has(permission));
44
+ const allSelected = row.actions.every((action) => selected.has(rowWildcard(row.slug)) || selected.has(columnWildcard(action)) || selected.has(permissionFor(row.slug, action)));
42
45
  const next = new Set(selected);
43
- for (const permission of permissions) if (allSelected) next.delete(permission);
44
- else next.add(permission);
46
+ for (const action of row.actions) if (allSelected) next.delete(permissionFor(row.slug, action));
47
+ else next.add(permissionFor(row.slug, action));
48
+ if (allSelected) next.delete(rowWildcard(row.slug));
45
49
  setValue(Array.from(next).sort());
46
50
  }, [selected, setValue]);
47
51
  const hasGlobals = rows.some((row) => row.entity === "global");
48
52
  const hasCollections = rows.some((row) => row.entity === "collection");
49
53
  const renderRows = (entity) => rows.filter((row) => row.entity === entity).map((row) => {
50
- const rowPermissions = row.actions.map((action) => permissionFor(row.slug, action));
51
- const allChecked = fullAccess || rowPermissions.every((permission) => selected.has(permission));
52
- const someChecked = rowPermissions.some((permission) => selected.has(permission));
54
+ const hasRowWildcard = selected.has(rowWildcard(row.slug));
55
+ const actionChecked = (action) => hasRowWildcard || selected.has(columnWildcard(action)) || selected.has(permissionFor(row.slug, action));
56
+ const allChecked = fullAccess || row.actions.every(actionChecked);
57
+ const someChecked = row.actions.some(actionChecked);
53
58
  return /* @__PURE__ */ jsxs("tr", { children: [
54
59
  /* @__PURE__ */ jsx("td", {
55
60
  style: rowLabelStyle,
@@ -71,12 +76,13 @@ const PermissionsMatrixField = (props) => {
71
76
  collectionActions.map((action) => {
72
77
  const available = row.actions.includes(action);
73
78
  const permission = permissionFor(row.slug, action);
79
+ const wildcarded = hasRowWildcard || selected.has(columnWildcard(action));
74
80
  return /* @__PURE__ */ jsx("td", {
75
81
  style: cellStyle,
76
82
  children: available ? /* @__PURE__ */ jsx("input", {
77
83
  "aria-label": `${row.label}: ${action}`,
78
- checked: fullAccess || selected.has(permission),
79
- disabled: locked || fullAccess,
84
+ checked: fullAccess || wildcarded || selected.has(permission),
85
+ disabled: locked || fullAccess || wildcarded,
80
86
  onChange: () => toggle(permission),
81
87
  type: "checkbox"
82
88
  }) : /* @__PURE__ */ jsx("span", {
@@ -159,6 +165,29 @@ const PermissionsMatrixField = (props) => {
159
165
  children: action
160
166
  }, action))
161
167
  ] }) }), /* @__PURE__ */ jsxs("tbody", { children: [
168
+ /* @__PURE__ */ jsxs("tr", { children: [
169
+ /* @__PURE__ */ jsx("td", {
170
+ style: rowLabelStyle,
171
+ children: "Everything"
172
+ }),
173
+ /* @__PURE__ */ jsx("td", {
174
+ style: cellStyle,
175
+ children: /* @__PURE__ */ jsx("span", {
176
+ "aria-hidden": true,
177
+ children: "—"
178
+ })
179
+ }),
180
+ collectionActions.map((action) => /* @__PURE__ */ jsx("td", {
181
+ style: cellStyle,
182
+ children: /* @__PURE__ */ jsx("input", {
183
+ "aria-label": `Everything: ${action}`,
184
+ checked: fullAccess || selected.has(columnWildcard(action)),
185
+ disabled: locked || fullAccess,
186
+ onChange: () => toggle(columnWildcard(action)),
187
+ type: "checkbox"
188
+ })
189
+ }, action))
190
+ ] }),
162
191
  hasCollections && hasGlobals && sectionHeading("Collections"),
163
192
  renderRows("collection"),
164
193
  hasGlobals && sectionHeading("Globals"),
@@ -1 +1 @@
1
- {"version":3,"file":"PermissionsMatrixField.js","names":["cellStyle: React.CSSProperties","rowLabelStyle: React.CSSProperties","PermissionsMatrixField: React.FC<PermissionsMatrixFieldProps>"],"sources":["../../src/fields/PermissionsMatrixField.tsx"],"sourcesContent":["'use client'\n\nimport type { SelectFieldClientProps } from 'payload'\n\nimport { FieldDescription, FieldLabel, useField, useFormFields } from '@payloadcms/ui'\nimport React, { useCallback, useMemo } from 'react'\n\nimport type { MatrixRow, RbacAction } from '../shared.js'\n\nimport { FULL_ACCESS, permissionFor } from '../permissions.js'\nimport { collectionActions } from '../shared.js'\n\nconst cellStyle: React.CSSProperties = {\n border: '1px solid var(--theme-elevation-150)',\n padding: '8px 12px',\n textAlign: 'center',\n}\n\nconst rowLabelStyle: React.CSSProperties = {\n ...cellStyle,\n textAlign: 'left',\n}\n\nexport type PermissionsMatrixFieldProps = {\n /** Names of code-locked roles the matrix renders read-only for. */\n protectedRoleNames?: string[]\n /** Serialized by the plugin into the field's `clientProps`. */\n rows?: MatrixRow[]\n} & SelectFieldClientProps\n\n/**\n * Renders the roles collection's `permissions` select as a matrix of collections ×\n * Create/Read/Update/Delete checkboxes (globals: Read/Update), with a \"full access\"\n * master toggle for `'*'`. The stored value stays a plain array of permission\n * strings, so the REST/local APIs are unaffected.\n */\nexport const PermissionsMatrixField: React.FC<PermissionsMatrixFieldProps> = (props) => {\n const { field, path, protectedRoleNames = [], readOnly, rows = [] } = props\n const { setValue, showError, value } = useField<string[]>({ path })\n const roleName = useFormFields(([fields]) => fields?.name?.value)\n\n const selected = useMemo(() => new Set(value ?? []), [value])\n const fullAccess = selected.has(FULL_ACCESS)\n // Best-effort UX only — the beforeChange guard on the server is authoritative.\n const isProtected = typeof roleName === 'string' && protectedRoleNames.includes(roleName)\n const locked = Boolean(readOnly) || isProtected\n\n const toggle = useCallback(\n (permission: string) => {\n const next = new Set(selected)\n if (next.has(permission)) {\n next.delete(permission)\n } else {\n next.add(permission)\n }\n setValue(Array.from(next).sort())\n },\n [selected, setValue],\n )\n\n const toggleRow = useCallback(\n (row: MatrixRow) => {\n const permissions = row.actions.map((action) => permissionFor(row.slug, action))\n const allSelected = permissions.every((permission) => selected.has(permission))\n const next = new Set(selected)\n for (const permission of permissions) {\n if (allSelected) {\n next.delete(permission)\n } else {\n next.add(permission)\n }\n }\n setValue(Array.from(next).sort())\n },\n [selected, setValue],\n )\n\n const hasGlobals = rows.some((row) => row.entity === 'global')\n const hasCollections = rows.some((row) => row.entity === 'collection')\n\n const renderRows = (entity: MatrixRow['entity']) =>\n rows\n .filter((row) => row.entity === entity)\n .map((row) => {\n const rowPermissions = row.actions.map((action) => permissionFor(row.slug, action))\n const allChecked =\n fullAccess || rowPermissions.every((permission) => selected.has(permission))\n const someChecked = rowPermissions.some((permission) => selected.has(permission))\n return (\n <tr key={`${row.entity}:${row.slug}`}>\n <td style={rowLabelStyle}>{row.label}</td>\n <td style={cellStyle}>\n <input\n aria-label={`${row.label}: all actions`}\n checked={allChecked}\n disabled={locked || fullAccess}\n onChange={() => toggleRow(row)}\n ref={(el) => {\n if (el) {\n el.indeterminate = !allChecked && someChecked\n }\n }}\n type=\"checkbox\"\n />\n </td>\n {collectionActions.map((action: RbacAction) => {\n const available = row.actions.includes(action)\n const permission = permissionFor(row.slug, action)\n return (\n <td key={action} style={cellStyle}>\n {available ? (\n <input\n aria-label={`${row.label}: ${action}`}\n checked={fullAccess || selected.has(permission)}\n disabled={locked || fullAccess}\n onChange={() => toggle(permission)}\n type=\"checkbox\"\n />\n ) : (\n <span aria-hidden>—</span>\n )}\n </td>\n )\n })}\n </tr>\n )\n })\n\n const sectionHeading = (label: string) => (\n <tr>\n <th colSpan={collectionActions.length + 2} style={{ ...rowLabelStyle, fontWeight: 600 }}>\n {label}\n </th>\n </tr>\n )\n\n return (\n <div className=\"field-type rbac-permissions-matrix\" style={{ marginBottom: 'var(--base)' }}>\n <FieldLabel label={field?.label ?? 'Permissions'} path={path} />\n {showError && (\n <div style={{ color: 'var(--theme-error-500)', marginBottom: 8 }}>\n Invalid permissions value\n </div>\n )}\n {isProtected && (\n <div style={{ color: 'var(--theme-elevation-500)', margin: '8px 0' }}>\n This role is protected — its permissions are defined in code and cannot be edited here.\n </div>\n )}\n <label style={{ alignItems: 'center', display: 'flex', gap: 8, margin: '8px 0 12px' }}>\n <input\n aria-label=\"Full access\"\n checked={fullAccess}\n disabled={locked}\n onChange={() => toggle(FULL_ACCESS)}\n type=\"checkbox\"\n />\n <span>\n Full access — every action on every collection and global, including ones added in the\n future\n </span>\n </label>\n <div style={{ opacity: fullAccess ? 0.5 : 1, overflowX: 'auto' }}>\n <table style={{ borderCollapse: 'collapse', width: '100%' }}>\n <thead>\n <tr>\n <th aria-label=\"Collection or global\" style={rowLabelStyle} />\n <th style={cellStyle}>All</th>\n {collectionActions.map((action) => (\n <th key={action} style={{ ...cellStyle, textTransform: 'capitalize' }}>\n {action}\n </th>\n ))}\n </tr>\n </thead>\n <tbody>\n {hasCollections && hasGlobals && sectionHeading('Collections')}\n {renderRows('collection')}\n {hasGlobals && sectionHeading('Globals')}\n {renderRows('global')}\n </tbody>\n </table>\n </div>\n <FieldDescription\n description={field?.admin?.description}\n marginPlacement=\"bottom\"\n path={path}\n />\n </div>\n )\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"PermissionsMatrixField.js","names":["cellStyle: React.CSSProperties","rowLabelStyle: React.CSSProperties","PermissionsMatrixField: React.FC<PermissionsMatrixFieldProps>"],"sources":["../../src/fields/PermissionsMatrixField.tsx"],"sourcesContent":["'use client'\n\nimport type { SelectFieldClientProps } from 'payload'\n\nimport { FieldDescription, FieldLabel, useField, useFormFields } from '@payloadcms/ui'\nimport React, { useCallback, useMemo } from 'react'\n\nimport type { MatrixRow, RbacAction } from '../shared.js'\n\nimport { FULL_ACCESS, permissionFor } from '../permissions.js'\nimport { collectionActions } from '../shared.js'\n\nconst cellStyle: React.CSSProperties = {\n border: '1px solid var(--theme-elevation-150)',\n padding: '8px 12px',\n textAlign: 'center',\n}\n\nconst rowLabelStyle: React.CSSProperties = {\n ...cellStyle,\n textAlign: 'left',\n}\n\nexport type PermissionsMatrixFieldProps = {\n /** Names of code-locked roles the matrix renders read-only for. */\n protectedRoleNames?: string[]\n /** Serialized by the plugin into the field's `clientProps`. */\n rows?: MatrixRow[]\n} & SelectFieldClientProps\n\nconst columnWildcard = (action: RbacAction): string => `*:${action}`\nconst rowWildcard = (slug: string): string => `${slug}:*`\n\n/**\n * Renders the roles collection's `permissions` select as a matrix of collections ×\n * Create/Read/Update/Delete checkboxes (globals: Read/Update), with a \"full access\"\n * master toggle for `'*'`, an \"All collections and globals\" row for the\n * `'*:<action>'` wildcards, and cells granted through a wildcard — `'<slug>:*'`\n * or `'*:<action>'` — shown checked and locked. The stored value stays a plain\n * array of permission strings, so the REST/local APIs are unaffected.\n */\nexport const PermissionsMatrixField: React.FC<PermissionsMatrixFieldProps> = (props) => {\n const { field, path, protectedRoleNames = [], readOnly, rows = [] } = props\n const { setValue, showError, value } = useField<string[]>({ path })\n const roleName = useFormFields(([fields]) => fields?.name?.value)\n\n const selected = useMemo(() => new Set(value ?? []), [value])\n const fullAccess = selected.has(FULL_ACCESS)\n // Best-effort UX only — the beforeChange guard on the server is authoritative.\n const isProtected = typeof roleName === 'string' && protectedRoleNames.includes(roleName)\n const locked = Boolean(readOnly) || isProtected\n\n const toggle = useCallback(\n (permission: string) => {\n const next = new Set(selected)\n if (next.has(permission)) {\n next.delete(permission)\n } else {\n next.add(permission)\n }\n setValue(Array.from(next).sort())\n },\n [selected, setValue],\n )\n\n const toggleRow = useCallback(\n (row: MatrixRow) => {\n const allSelected = row.actions.every(\n (action) =>\n selected.has(rowWildcard(row.slug)) ||\n selected.has(columnWildcard(action)) ||\n selected.has(permissionFor(row.slug, action)),\n )\n const next = new Set(selected)\n for (const action of row.actions) {\n if (allSelected) {\n next.delete(permissionFor(row.slug, action))\n } else {\n next.add(permissionFor(row.slug, action))\n }\n }\n if (allSelected) {\n next.delete(rowWildcard(row.slug))\n }\n setValue(Array.from(next).sort())\n },\n [selected, setValue],\n )\n\n const hasGlobals = rows.some((row) => row.entity === 'global')\n const hasCollections = rows.some((row) => row.entity === 'collection')\n\n const renderRows = (entity: MatrixRow['entity']) =>\n rows\n .filter((row) => row.entity === entity)\n .map((row) => {\n const hasRowWildcard = selected.has(rowWildcard(row.slug))\n const actionChecked = (action: RbacAction) =>\n hasRowWildcard ||\n selected.has(columnWildcard(action)) ||\n selected.has(permissionFor(row.slug, action))\n const allChecked = fullAccess || row.actions.every(actionChecked)\n const someChecked = row.actions.some(actionChecked)\n return (\n <tr key={`${row.entity}:${row.slug}`}>\n <td style={rowLabelStyle}>{row.label}</td>\n <td style={cellStyle}>\n <input\n aria-label={`${row.label}: all actions`}\n checked={allChecked}\n disabled={locked || fullAccess}\n onChange={() => toggleRow(row)}\n ref={(el) => {\n if (el) {\n el.indeterminate = !allChecked && someChecked\n }\n }}\n type=\"checkbox\"\n />\n </td>\n {collectionActions.map((action: RbacAction) => {\n const available = row.actions.includes(action)\n const permission = permissionFor(row.slug, action)\n const wildcarded = hasRowWildcard || selected.has(columnWildcard(action))\n return (\n <td key={action} style={cellStyle}>\n {available ? (\n <input\n aria-label={`${row.label}: ${action}`}\n checked={fullAccess || wildcarded || selected.has(permission)}\n disabled={locked || fullAccess || wildcarded}\n onChange={() => toggle(permission)}\n type=\"checkbox\"\n />\n ) : (\n <span aria-hidden>—</span>\n )}\n </td>\n )\n })}\n </tr>\n )\n })\n\n const sectionHeading = (label: string) => (\n <tr>\n <th colSpan={collectionActions.length + 2} style={{ ...rowLabelStyle, fontWeight: 600 }}>\n {label}\n </th>\n </tr>\n )\n\n return (\n <div className=\"field-type rbac-permissions-matrix\" style={{ marginBottom: 'var(--base)' }}>\n <FieldLabel label={field?.label ?? 'Permissions'} path={path} />\n {showError && (\n <div style={{ color: 'var(--theme-error-500)', marginBottom: 8 }}>\n Invalid permissions value\n </div>\n )}\n {isProtected && (\n <div style={{ color: 'var(--theme-elevation-500)', margin: '8px 0' }}>\n This role is protected — its permissions are defined in code and cannot be edited here.\n </div>\n )}\n <label style={{ alignItems: 'center', display: 'flex', gap: 8, margin: '8px 0 12px' }}>\n <input\n aria-label=\"Full access\"\n checked={fullAccess}\n disabled={locked}\n onChange={() => toggle(FULL_ACCESS)}\n type=\"checkbox\"\n />\n <span>\n Full access — every action on every collection and global, including ones added in the\n future\n </span>\n </label>\n <div style={{ opacity: fullAccess ? 0.5 : 1, overflowX: 'auto' }}>\n <table style={{ borderCollapse: 'collapse', width: '100%' }}>\n <thead>\n <tr>\n <th aria-label=\"Collection or global\" style={rowLabelStyle} />\n <th style={cellStyle}>All</th>\n {collectionActions.map((action) => (\n <th key={action} style={{ ...cellStyle, textTransform: 'capitalize' }}>\n {action}\n </th>\n ))}\n </tr>\n </thead>\n <tbody>\n <tr>\n {/* The `'*:<action>'` wildcards: one action on every collection and\n global, present and future. Create/delete only ever match\n collections — globals have no such actions. */}\n <td style={rowLabelStyle}>Everything</td>\n <td style={cellStyle}>\n <span aria-hidden>—</span>\n </td>\n {collectionActions.map((action) => (\n <td key={action} style={cellStyle}>\n <input\n aria-label={`Everything: ${action}`}\n checked={fullAccess || selected.has(columnWildcard(action))}\n disabled={locked || fullAccess}\n onChange={() => toggle(columnWildcard(action))}\n type=\"checkbox\"\n />\n </td>\n ))}\n </tr>\n {hasCollections && hasGlobals && sectionHeading('Collections')}\n {renderRows('collection')}\n {hasGlobals && sectionHeading('Globals')}\n {renderRows('global')}\n </tbody>\n </table>\n </div>\n <FieldDescription\n description={field?.admin?.description}\n marginPlacement=\"bottom\"\n path={path}\n />\n </div>\n )\n}\n"],"mappings":"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"}
package/dist/index.d.ts CHANGED
@@ -10,10 +10,10 @@ import { ProtectedRolesChangeArgs, ProtectedRolesDeleteArgs, createProtectedRole
10
10
  import { ProtectLastAdminArgs, createProtectLastAdminChangeHook, createProtectLastAdminDeleteHook } from "./hooks/protectLastAdmin.js";
11
11
  import { ProtectRolesCollectionArgs, createProtectRolesCollectionHook } from "./hooks/protectRolesCollection.js";
12
12
  import { ProtectRolesFieldArgs, createProtectRolesFieldHook, normalizeRoleIds } from "./hooks/protectRolesField.js";
13
- import { FULL_ACCESS, missingPermissions, permissionFor, permissionsGrant, samePermissions } from "./permissions.js";
13
+ import { FULL_ACCESS, fullAccessPermissions, missingPermissions, permissionCovers, permissionFor, permissionsGrant, samePermissions } from "./permissions.js";
14
14
  import { rbacPlugin } from "./plugin.js";
15
15
  import { SeedPredefinedRolesArgs, seedPredefinedRoles } from "./seed.js";
16
16
  import { entityLabel } from "./utilities/entityLabel.js";
17
17
  import { RoleHolderQueryArgs, WarnIfAdminRoleUnheldArgs, anyUserHoldsRole, findFullAccessRoleIds, warnIfAdminRoleUnheld } from "./utilities/fullAccessHolders.js";
18
18
  import { getUserPermissions, hasPermission } from "./utilities/getUserPermissions.js";
19
- export { type AssignFirstUserRoleArgs, type CreateRbacAccessArgs, type CreateRolesCollectionArgs, type CreateRolesFieldAccessArgs, type CreateRolesFieldArgs, FULL_ACCESS, type MatrixRow, type PredefinedRole, type ProtectCredentialsArgs, type ProtectLastAdminArgs, type ProtectRolesCollectionArgs, type ProtectRolesFieldArgs, type ProtectedRolesChangeArgs, type ProtectedRolesDeleteArgs, type RbacAction, type RbacCustomConfig, type RbacEntitySelection, type RbacPermission, type RbacPluginConfig, type RoleHolderQueryArgs, type SeedPredefinedRolesArgs, type WarnIfAdminRoleUnheldArgs, anyUserHoldsRole, collectionActions, createAssignFirstUserRoleHook, createProtectCredentialsHook, createProtectLastAdminChangeHook, createProtectLastAdminDeleteHook, createProtectRolesCollectionHook, createProtectRolesFieldHook, createProtectedRolesChangeHook, createProtectedRolesDeleteHook, createRbacAccess, createRolesCollection, createRolesField, createRolesFieldAccess, entityLabel, findFullAccessRoleIds, getRbacCustomConfig, getUserPermissions, globalActions, hasPermission, missingPermissions, normalizeRoleIds, permissionFor, permissionsGrant, permissionsMatrixFieldPath, pluginKey, rbacPlugin, requirePermission, samePermissions, seedPredefinedRoles, warnIfAdminRoleUnheld };
19
+ export { type AssignFirstUserRoleArgs, type CreateRbacAccessArgs, type CreateRolesCollectionArgs, type CreateRolesFieldAccessArgs, type CreateRolesFieldArgs, FULL_ACCESS, type MatrixRow, type PredefinedRole, type ProtectCredentialsArgs, type ProtectLastAdminArgs, type ProtectRolesCollectionArgs, type ProtectRolesFieldArgs, type ProtectedRolesChangeArgs, type ProtectedRolesDeleteArgs, type RbacAction, type RbacCustomConfig, type RbacEntitySelection, type RbacPermission, type RbacPluginConfig, type RoleHolderQueryArgs, type SeedPredefinedRolesArgs, type WarnIfAdminRoleUnheldArgs, anyUserHoldsRole, collectionActions, createAssignFirstUserRoleHook, createProtectCredentialsHook, createProtectLastAdminChangeHook, createProtectLastAdminDeleteHook, createProtectRolesCollectionHook, createProtectRolesFieldHook, createProtectedRolesChangeHook, createProtectedRolesDeleteHook, createRbacAccess, createRolesCollection, createRolesField, createRolesFieldAccess, entityLabel, findFullAccessRoleIds, fullAccessPermissions, getRbacCustomConfig, getUserPermissions, globalActions, hasPermission, missingPermissions, normalizeRoleIds, permissionCovers, permissionFor, permissionsGrant, permissionsMatrixFieldPath, pluginKey, rbacPlugin, requirePermission, samePermissions, seedPredefinedRoles, warnIfAdminRoleUnheld };
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
- import { FULL_ACCESS, missingPermissions, permissionFor, permissionsGrant, samePermissions } from "./permissions.js";
2
1
  import { collectionActions, getRbacCustomConfig, globalActions, permissionsMatrixFieldPath, pluginKey } from "./shared.js";
2
+ import { FULL_ACCESS, fullAccessPermissions, missingPermissions, permissionCovers, permissionFor, permissionsGrant, samePermissions } from "./permissions.js";
3
3
  import { getUserPermissions, hasPermission } from "./utilities/getUserPermissions.js";
4
4
  import { createRbacAccess, requirePermission } from "./access/rbacAccess.js";
5
5
  import { anyUserHoldsRole, findFullAccessRoleIds, warnIfAdminRoleUnheld } from "./utilities/fullAccessHolders.js";
@@ -16,4 +16,4 @@ import { seedPredefinedRoles } from "./seed.js";
16
16
  import { entityLabel } from "./utilities/entityLabel.js";
17
17
  import { rbacPlugin } from "./plugin.js";
18
18
 
19
- export { FULL_ACCESS, anyUserHoldsRole, collectionActions, createAssignFirstUserRoleHook, createProtectCredentialsHook, createProtectLastAdminChangeHook, createProtectLastAdminDeleteHook, createProtectRolesCollectionHook, createProtectRolesFieldHook, createProtectedRolesChangeHook, createProtectedRolesDeleteHook, createRbacAccess, createRolesCollection, createRolesField, createRolesFieldAccess, entityLabel, findFullAccessRoleIds, getRbacCustomConfig, getUserPermissions, globalActions, hasPermission, missingPermissions, normalizeRoleIds, permissionFor, permissionsGrant, permissionsMatrixFieldPath, pluginKey, rbacPlugin, requirePermission, samePermissions, seedPredefinedRoles, warnIfAdminRoleUnheld };
19
+ export { FULL_ACCESS, anyUserHoldsRole, collectionActions, createAssignFirstUserRoleHook, createProtectCredentialsHook, createProtectLastAdminChangeHook, createProtectLastAdminDeleteHook, createProtectRolesCollectionHook, createProtectRolesFieldHook, createProtectedRolesChangeHook, createProtectedRolesDeleteHook, createRbacAccess, createRolesCollection, createRolesField, createRolesFieldAccess, entityLabel, findFullAccessRoleIds, fullAccessPermissions, getRbacCustomConfig, getUserPermissions, globalActions, hasPermission, missingPermissions, normalizeRoleIds, permissionCovers, permissionFor, permissionsGrant, permissionsMatrixFieldPath, pluginKey, rbacPlugin, requirePermission, samePermissions, seedPredefinedRoles, warnIfAdminRoleUnheld };
@@ -5,6 +5,24 @@ import { RbacAction } from "./shared.js";
5
5
  declare const FULL_ACCESS = "*";
6
6
  /** Builds the permission string for one action on one collection or global. */
7
7
  declare const permissionFor: (slug: string, action: RbacAction) => string;
8
+ /**
9
+ * Whether a permission set amounts to full access: the `'*'` token, or an
10
+ * `'*:<action>'` wildcard for every action — the action set is closed, so
11
+ * together they cover everything `'*'` covers, present and future entities
12
+ * included.
13
+ */
14
+ declare const fullAccessPermissions: (permissions: ReadonlySet<string>) => boolean;
15
+ /**
16
+ * Whether a granted permission set covers one required permission, wildcards
17
+ * included:
18
+ * - `'<slug>:<action>'` is covered by itself, `'<slug>:*'`, `'*:<action>'`, or
19
+ * full access.
20
+ * - `'<slug>:*'` is additionally covered by holding every action on that slug
21
+ * individually — the action set is closed, so the expansion is exact.
22
+ * - `'*:<action>'` spans entities added in the future, so nothing short of
23
+ * itself or full access covers it.
24
+ */
25
+ declare const permissionCovers: (granted: ReadonlySet<string>, required: string) => boolean;
8
26
  /** Whether a permission set grants an action on a collection or global. */
9
27
  declare const permissionsGrant: (permissions: ReadonlySet<string>, slug: string, action: RbacAction) => boolean;
10
28
  /**
@@ -19,5 +37,5 @@ declare const samePermissions: (value: unknown, permissions: readonly string[])
19
37
  */
20
38
  declare const missingPermissions: (granted: ReadonlySet<string>, required: readonly string[]) => string[];
21
39
  //#endregion
22
- export { FULL_ACCESS, missingPermissions, permissionFor, permissionsGrant, samePermissions };
40
+ export { FULL_ACCESS, fullAccessPermissions, missingPermissions, permissionCovers, permissionFor, permissionsGrant, samePermissions };
23
41
  //# sourceMappingURL=permissions.d.ts.map
@@ -1,11 +1,42 @@
1
+ import { collectionActions } from "./shared.js";
2
+
1
3
  //#region src/permissions.ts
2
4
  /** Grants every action on every controlled collection and global. */
3
5
  const FULL_ACCESS = "*";
4
6
  /** Builds the permission string for one action on one collection or global. */
5
7
  const permissionFor = (slug, action) => `${slug}:${action}`;
8
+ /**
9
+ * Whether a permission set amounts to full access: the `'*'` token, or an
10
+ * `'*:<action>'` wildcard for every action — the action set is closed, so
11
+ * together they cover everything `'*'` covers, present and future entities
12
+ * included.
13
+ */
14
+ const fullAccessPermissions = (permissions) => {
15
+ return permissions.has(FULL_ACCESS) || collectionActions.every((action) => permissions.has(`*:${action}`));
16
+ };
17
+ /**
18
+ * Whether a granted permission set covers one required permission, wildcards
19
+ * included:
20
+ * - `'<slug>:<action>'` is covered by itself, `'<slug>:*'`, `'*:<action>'`, or
21
+ * full access.
22
+ * - `'<slug>:*'` is additionally covered by holding every action on that slug
23
+ * individually — the action set is closed, so the expansion is exact.
24
+ * - `'*:<action>'` spans entities added in the future, so nothing short of
25
+ * itself or full access covers it.
26
+ */
27
+ const permissionCovers = (granted, required) => {
28
+ if (granted.has(required) || fullAccessPermissions(granted)) return true;
29
+ const separator = required.indexOf(":");
30
+ if (separator === -1) return false;
31
+ const slug = required.slice(0, separator);
32
+ const action = required.slice(separator + 1);
33
+ if (slug === "*") return false;
34
+ if (action === "*") return collectionActions.every((each) => granted.has(permissionFor(slug, each)) || granted.has(`*:${each}`));
35
+ return granted.has(`${slug}:*`) || granted.has(`*:${action}`);
36
+ };
6
37
  /** Whether a permission set grants an action on a collection or global. */
7
38
  const permissionsGrant = (permissions, slug, action) => {
8
- return permissions.has(FULL_ACCESS) || permissions.has(permissionFor(slug, action));
39
+ return permissionCovers(permissions, permissionFor(slug, action));
9
40
  };
10
41
  /**
11
42
  * Order-insensitive equality between a stored permissions value (unknown shape —
@@ -22,10 +53,9 @@ const samePermissions = (value, permissions) => {
22
53
  * guards: an empty result means the grantor holds everything they are handing out.
23
54
  */
24
55
  const missingPermissions = (granted, required) => {
25
- if (granted.has(FULL_ACCESS)) return [];
26
- return required.filter((permission) => !granted.has(permission));
56
+ return required.filter((permission) => !permissionCovers(granted, permission));
27
57
  };
28
58
 
29
59
  //#endregion
30
- export { FULL_ACCESS, missingPermissions, permissionFor, permissionsGrant, samePermissions };
60
+ export { FULL_ACCESS, fullAccessPermissions, missingPermissions, permissionCovers, permissionFor, permissionsGrant, samePermissions };
31
61
  //# sourceMappingURL=permissions.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"permissions.js","names":[],"sources":["../src/permissions.ts"],"sourcesContent":["import type { RbacAction } from './shared.js'\n\n/** Grants every action on every controlled collection and global. */\nexport const FULL_ACCESS = '*'\n\n/** Builds the permission string for one action on one collection or global. */\nexport const permissionFor = (slug: string, action: RbacAction): string => `${slug}:${action}`\n\n/** Whether a permission set grants an action on a collection or global. */\nexport const permissionsGrant = (\n permissions: ReadonlySet<string>,\n slug: string,\n action: RbacAction,\n): boolean => {\n return permissions.has(FULL_ACCESS) || permissions.has(permissionFor(slug, action))\n}\n\n/**\n * Order-insensitive equality between a stored permissions value (unknown shape —\n * may be missing or malformed) and a code-defined permission list. Used by the\n * protected-role guard and the drift repair in seeding.\n */\nexport const samePermissions = (value: unknown, permissions: readonly string[]): boolean => {\n const stored = new Set(\n Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [],\n )\n const expected = new Set(permissions)\n return stored.size === expected.size && [...expected].every((entry) => stored.has(entry))\n}\n\n/**\n * The subset of `required` not covered by `granted`. Used by the privilege-escalation\n * guards: an empty result means the grantor holds everything they are handing out.\n */\nexport const missingPermissions = (\n granted: ReadonlySet<string>,\n required: readonly string[],\n): string[] => {\n if (granted.has(FULL_ACCESS)) {\n return []\n }\n return required.filter((permission) => !granted.has(permission))\n}\n"],"mappings":";;AAGA,MAAa,cAAc;;AAG3B,MAAa,iBAAiB,MAAc,WAA+B,GAAG,KAAK,GAAG;;AAGtF,MAAa,oBACX,aACA,MACA,WACY;AACZ,QAAO,YAAY,IAAI,YAAY,IAAI,YAAY,IAAI,cAAc,MAAM,OAAO,CAAC;;;;;;;AAQrF,MAAa,mBAAmB,OAAgB,gBAA4C;CAC1F,MAAM,SAAS,IAAI,IACjB,MAAM,QAAQ,MAAM,GAAG,MAAM,QAAQ,UAA2B,OAAO,UAAU,SAAS,GAAG,EAAE,CAChG;CACD,MAAM,WAAW,IAAI,IAAI,YAAY;AACrC,QAAO,OAAO,SAAS,SAAS,QAAQ,CAAC,GAAG,SAAS,CAAC,OAAO,UAAU,OAAO,IAAI,MAAM,CAAC;;;;;;AAO3F,MAAa,sBACX,SACA,aACa;AACb,KAAI,QAAQ,IAAI,YAAY,CAC1B,QAAO,EAAE;AAEX,QAAO,SAAS,QAAQ,eAAe,CAAC,QAAQ,IAAI,WAAW,CAAC"}
1
+ {"version":3,"file":"permissions.js","names":[],"sources":["../src/permissions.ts"],"sourcesContent":["import type { RbacAction } from './shared.js'\n\nimport { collectionActions } from './shared.js'\n\n/** Grants every action on every controlled collection and global. */\nexport const FULL_ACCESS = '*'\n\n/** Builds the permission string for one action on one collection or global. */\nexport const permissionFor = (slug: string, action: RbacAction): string => `${slug}:${action}`\n\n/**\n * Whether a permission set amounts to full access: the `'*'` token, or an\n * `'*:<action>'` wildcard for every action — the action set is closed, so\n * together they cover everything `'*'` covers, present and future entities\n * included.\n */\nexport const fullAccessPermissions = (permissions: ReadonlySet<string>): boolean => {\n return (\n permissions.has(FULL_ACCESS) ||\n collectionActions.every((action) => permissions.has(`*:${action}`))\n )\n}\n\n/**\n * Whether a granted permission set covers one required permission, wildcards\n * included:\n * - `'<slug>:<action>'` is covered by itself, `'<slug>:*'`, `'*:<action>'`, or\n * full access.\n * - `'<slug>:*'` is additionally covered by holding every action on that slug\n * individually — the action set is closed, so the expansion is exact.\n * - `'*:<action>'` spans entities added in the future, so nothing short of\n * itself or full access covers it.\n */\nexport const permissionCovers = (granted: ReadonlySet<string>, required: string): boolean => {\n if (granted.has(required) || fullAccessPermissions(granted)) {\n return true\n }\n const separator = required.indexOf(':')\n if (separator === -1) {\n return false\n }\n const slug = required.slice(0, separator)\n const action = required.slice(separator + 1)\n if (slug === '*') {\n return false\n }\n if (action === '*') {\n return collectionActions.every(\n (each) => granted.has(permissionFor(slug, each)) || granted.has(`*:${each}`),\n )\n }\n return granted.has(`${slug}:*`) || granted.has(`*:${action}`)\n}\n\n/** Whether a permission set grants an action on a collection or global. */\nexport const permissionsGrant = (\n permissions: ReadonlySet<string>,\n slug: string,\n action: RbacAction,\n): boolean => {\n return permissionCovers(permissions, permissionFor(slug, action))\n}\n\n/**\n * Order-insensitive equality between a stored permissions value (unknown shape —\n * may be missing or malformed) and a code-defined permission list. Used by the\n * protected-role guard and the drift repair in seeding.\n */\nexport const samePermissions = (value: unknown, permissions: readonly string[]): boolean => {\n const stored = new Set(\n Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [],\n )\n const expected = new Set(permissions)\n return stored.size === expected.size && [...expected].every((entry) => stored.has(entry))\n}\n\n/**\n * The subset of `required` not covered by `granted`. Used by the privilege-escalation\n * guards: an empty result means the grantor holds everything they are handing out.\n */\nexport const missingPermissions = (\n granted: ReadonlySet<string>,\n required: readonly string[],\n): string[] => {\n return required.filter((permission) => !permissionCovers(granted, permission))\n}\n"],"mappings":";;;;AAKA,MAAa,cAAc;;AAG3B,MAAa,iBAAiB,MAAc,WAA+B,GAAG,KAAK,GAAG;;;;;;;AAQtF,MAAa,yBAAyB,gBAA8C;AAClF,QACE,YAAY,IAAI,YAAY,IAC5B,kBAAkB,OAAO,WAAW,YAAY,IAAI,KAAK,SAAS,CAAC;;;;;;;;;;;;AAcvE,MAAa,oBAAoB,SAA8B,aAA8B;AAC3F,KAAI,QAAQ,IAAI,SAAS,IAAI,sBAAsB,QAAQ,CACzD,QAAO;CAET,MAAM,YAAY,SAAS,QAAQ,IAAI;AACvC,KAAI,cAAc,GAChB,QAAO;CAET,MAAM,OAAO,SAAS,MAAM,GAAG,UAAU;CACzC,MAAM,SAAS,SAAS,MAAM,YAAY,EAAE;AAC5C,KAAI,SAAS,IACX,QAAO;AAET,KAAI,WAAW,IACb,QAAO,kBAAkB,OACtB,SAAS,QAAQ,IAAI,cAAc,MAAM,KAAK,CAAC,IAAI,QAAQ,IAAI,KAAK,OAAO,CAC7E;AAEH,QAAO,QAAQ,IAAI,GAAG,KAAK,IAAI,IAAI,QAAQ,IAAI,KAAK,SAAS;;;AAI/D,MAAa,oBACX,aACA,MACA,WACY;AACZ,QAAO,iBAAiB,aAAa,cAAc,MAAM,OAAO,CAAC;;;;;;;AAQnE,MAAa,mBAAmB,OAAgB,gBAA4C;CAC1F,MAAM,SAAS,IAAI,IACjB,MAAM,QAAQ,MAAM,GAAG,MAAM,QAAQ,UAA2B,OAAO,UAAU,SAAS,GAAG,EAAE,CAChG;CACD,MAAM,WAAW,IAAI,IAAI,YAAY;AACrC,QAAO,OAAO,SAAS,SAAS,QAAQ,CAAC,GAAG,SAAS,CAAC,OAAO,UAAU,OAAO,IAAI,MAAM,CAAC;;;;;;AAO3F,MAAa,sBACX,SACA,aACa;AACb,QAAO,SAAS,QAAQ,eAAe,CAAC,iBAAiB,SAAS,WAAW,CAAC"}
package/dist/plugin.js CHANGED
@@ -1,5 +1,5 @@
1
- import { FULL_ACCESS, permissionFor } from "./permissions.js";
2
1
  import { collectionActions, globalActions, pluginKey } from "./shared.js";
2
+ import { FULL_ACCESS, permissionFor } from "./permissions.js";
3
3
  import { createRbacAccess } from "./access/rbacAccess.js";
4
4
  import { anyUserHoldsRole, findFullAccessRoleIds, warnIfAdminRoleUnheld } from "./utilities/fullAccessHolders.js";
5
5
  import { createRolesFieldAccess } from "./access/rolesFieldAccess.js";
@@ -81,8 +81,12 @@ const rbacPlugin = (pluginConfig = {}) => {
81
81
  label: entityLabel(global.label, global.slug)
82
82
  }))
83
83
  ];
84
- const validPermissions = new Set([FULL_ACCESS, ...matrixRows.flatMap((row) => row.actions.map((action) => permissionFor(row.slug, action)))]);
85
- for (const role of predefinedRoles) for (const permission of role.permissions) if (!validPermissions.has(permission)) throw new Error(`[payload-rbac] Predefined role "${role.name}" grants unknown permission "${permission}". Permissions must be '*' or '<slug>:<action>' for a controlled collection (create/read/update/delete) or global (read/update).`);
84
+ const validPermissions = new Set([
85
+ FULL_ACCESS,
86
+ ...collectionActions.map((action) => `*:${action}`),
87
+ ...matrixRows.flatMap((row) => [`${row.slug}:*`, ...row.actions.map((action) => permissionFor(row.slug, action))])
88
+ ]);
89
+ for (const role of predefinedRoles) for (const permission of role.permissions) if (!validPermissions.has(permission)) throw new Error(`[payload-rbac] Predefined role "${role.name}" grants unknown permission "${permission}". Permissions must be '*', '<slug>:<action>', '<slug>:*', or '*:<action>' for a controlled collection (create/read/update/delete) or global (read/update).`);
86
90
  const withCollectionAccess = (collection) => {
87
91
  const access = { ...collection.access };
88
92
  for (const action of collectionActions) if (access[action] === void 0) access[action] = createRbacAccess({
@@ -1 +1 @@
1
- {"version":3,"file":"plugin.js","names":["adminRole: { description?: string; name?: string }","matrixRows: MatrixRow[]","rolesAccess: CollectionConfig['access']","rolesBeforeChange: NonNullable<CollectionConfig['hooks']>['beforeChange']","rolesHooks: CollectionConfig['hooks']","customConfig: RbacCustomConfig"],"sources":["../src/plugin.ts"],"sourcesContent":["import type { CollectionConfig, Config, Field, GlobalConfig, Plugin } from 'payload'\n\nimport type { MatrixRow, RbacCustomConfig } from './shared.js'\nimport type { RbacEntitySelection, RbacPluginConfig } from './types.js'\n\nimport { createRbacAccess } from './access/rbacAccess.js'\nimport { createRolesFieldAccess } from './access/rolesFieldAccess.js'\nimport { createRolesCollection } from './collections/createRolesCollection.js'\nimport { createRolesField } from './fields/createRolesField.js'\nimport { createAssignFirstUserRoleHook } from './hooks/assignFirstUserRole.js'\nimport { createProtectCredentialsHook } from './hooks/protectCredentials.js'\nimport {\n createProtectedRolesChangeHook,\n createProtectedRolesDeleteHook,\n} from './hooks/protectedRoles.js'\nimport {\n createProtectLastAdminChangeHook,\n createProtectLastAdminDeleteHook,\n} from './hooks/protectLastAdmin.js'\nimport { createProtectRolesCollectionHook } from './hooks/protectRolesCollection.js'\nimport { createProtectRolesFieldHook } from './hooks/protectRolesField.js'\nimport { FULL_ACCESS, permissionFor } from './permissions.js'\nimport { seedPredefinedRoles } from './seed.js'\nimport { collectionActions, globalActions, pluginKey } from './shared.js'\nimport { entityLabel } from './utilities/entityLabel.js'\nimport {\n anyUserHoldsRole,\n findFullAccessRoleIds,\n warnIfAdminRoleUnheld,\n} from './utilities/fullAccessHolders.js'\n\nconst createSelector = <TSlug extends string>(\n selection: RbacEntitySelection<TSlug> | undefined,\n): ((slug: string) => boolean) => {\n if (selection === undefined || selection === true) {\n return () => true\n }\n if (Array.isArray(selection)) {\n const included = new Set<string>(selection)\n return (slug) => included.has(slug)\n }\n const excluded = new Set<string>(selection.exclude)\n return (slug) => !excluded.has(slug)\n}\n\nconst hasNamedField = (fields: Field[], name: string): boolean => {\n return fields.some((field) => 'name' in field && field.name === name)\n}\n\nconst resolveUserCollections = (config: Config, pluginConfig: RbacPluginConfig): string[] => {\n if (pluginConfig.userCollections?.length) {\n return pluginConfig.userCollections\n }\n const authSlugs = (config.collections ?? [])\n .filter((collection) => Boolean(collection.auth))\n .map((collection) => collection.slug)\n if (authSlugs.length) {\n return authSlugs\n }\n return [config.admin?.user ?? 'users']\n}\n\nexport const rbacPlugin = (pluginConfig: RbacPluginConfig = {}): Plugin => {\n return (incomingConfig: Config): Config => {\n const config = { ...incomingConfig }\n\n if (pluginConfig.enabled === false) {\n return config\n }\n\n const rolesCollectionSlug = pluginConfig.rolesCollection?.slug ?? 'roles'\n const rolesFieldName = pluginConfig.rolesField?.name ?? 'roles'\n const preventEscalation = pluginConfig.preventPrivilegeEscalation !== false\n const ownAccountActions =\n pluginConfig.ownAccountAccess === false\n ? []\n : (pluginConfig.ownAccountAccess ?? ['read', 'update'])\n const adminRole: { description?: string; name?: string } =\n !pluginConfig.adminRole || typeof pluginConfig.adminRole === 'string'\n ? { name: pluginConfig.adminRole || undefined }\n : pluginConfig.adminRole\n\n if (adminRole.name && (pluginConfig.roles ?? []).some((role) => role.name === adminRole.name)) {\n throw new Error(\n `[payload-rbac] \"${adminRole.name}\" is the adminRole and is defined by the plugin — ` +\n `it always has permissions ['*'] and is protected. Remove it from roles, or use a different name.`,\n )\n }\n\n // The admin role is always full-access and protected: an unprotected one can\n // be downgraded irreversibly — once nobody holds a permission, the escalation\n // guard blocks everyone from granting it back. Its credentials are always\n // self-only: with `users:update` enough to set an administrator's password\n // or email, any admin account could be taken over by another.\n const predefinedRoles = [\n ...(adminRole.name\n ? [\n {\n name: adminRole.name,\n credentialChanges: 'self' as const,\n description: adminRole.description ?? 'Full access to everything.',\n permissions: [FULL_ACCESS],\n protected: true,\n },\n ]\n : []),\n ...(pluginConfig.roles ?? []).map((role) => ({\n ...role,\n protected: role.protected ?? false,\n })),\n ]\n const protectedRoles = predefinedRoles.filter((role) => role.protected)\n const protectedRoleNames = protectedRoles.map((role) => role.name)\n const selfOnlyCredentialRoleNames = predefinedRoles\n .filter((role) => role.credentialChanges === 'self')\n .map((role) => role.name)\n\n if ((config.collections ?? []).some((collection) => collection.slug === rolesCollectionSlug)) {\n throw new Error(\n `[payload-rbac] A collection with the slug \"${rolesCollectionSlug}\" already exists. ` +\n `Pass rolesCollection.slug to use a different slug for the roles collection.`,\n )\n }\n\n const shouldControlCollection = createSelector(pluginConfig.collections)\n const shouldControlGlobal = createSelector(pluginConfig.globals)\n const userCollections = resolveUserCollections(config, pluginConfig)\n const adminUserCollection = config.admin?.user ?? 'users'\n\n const matrixRows: MatrixRow[] = [\n ...(config.collections ?? [])\n .filter((collection) => shouldControlCollection(collection.slug))\n .map((collection) => ({\n slug: collection.slug,\n actions: collectionActions,\n entity: 'collection' as const,\n label: entityLabel(collection.labels?.plural, collection.slug),\n })),\n {\n slug: rolesCollectionSlug,\n actions: collectionActions,\n entity: 'collection' as const,\n label: 'Roles',\n },\n ...(config.globals ?? [])\n .filter((global) => shouldControlGlobal(global.slug))\n .map((global) => ({\n slug: global.slug,\n actions: globalActions,\n entity: 'global' as const,\n label: entityLabel(global.label, global.slug),\n })),\n ]\n\n const validPermissions = new Set<string>([\n FULL_ACCESS,\n ...matrixRows.flatMap((row) => row.actions.map((action) => permissionFor(row.slug, action))),\n ])\n for (const role of predefinedRoles) {\n for (const permission of role.permissions) {\n if (!validPermissions.has(permission)) {\n throw new Error(\n `[payload-rbac] Predefined role \"${role.name}\" grants unknown permission \"${permission}\". ` +\n `Permissions must be '*' or '<slug>:<action>' for a controlled collection ` +\n `(create/read/update/delete) or global (read/update).`,\n )\n }\n }\n }\n\n // Explicit access defined on a collection wins for that operation; the plugin\n // only fills the gaps. Compose `requirePermission` into your own access\n // functions to combine both.\n const withCollectionAccess = (collection: CollectionConfig): CollectionConfig => {\n const access = { ...collection.access }\n for (const action of collectionActions) {\n if (access[action] === undefined) {\n access[action] = createRbacAccess({ slug: collection.slug, action, ownAccountActions })\n }\n }\n if (access.readVersions === undefined) {\n access.readVersions = createRbacAccess({ slug: collection.slug, action: 'read' })\n }\n if (access.unlock === undefined) {\n access.unlock = createRbacAccess({ slug: collection.slug, action: 'update' })\n }\n return { ...collection, access }\n }\n\n const withGlobalAccess = (global: GlobalConfig): GlobalConfig => {\n const access = { ...global.access }\n for (const action of globalActions) {\n if (access[action] === undefined) {\n access[action] = createRbacAccess({ slug: global.slug, action })\n }\n }\n if (access.readVersions === undefined) {\n access.readVersions = createRbacAccess({ slug: global.slug, action: 'read' })\n }\n return { ...global, access }\n }\n\n // Changing role membership requires `roles:update`. Field-level access is\n // what makes the admin panel render the field read-only for everyone else,\n // so a user can never accidentally remove their own roles — a hook would\n // only reject the save after the fact. While no administrator exists the\n // field stays writable, or the break-glass self-claim could never reach the\n // escalation guard that vets it.\n const rolesFieldAccess = createRolesFieldAccess({\n rolesCollectionSlug,\n rolesFieldName,\n ...(adminRole.name ? { breakGlass: { userCollections } } : {}),\n })\n\n const withRolesField = (collection: CollectionConfig): CollectionConfig => {\n // If the collection already defines a field with this name, leave the field\n // entirely user-managed — the guard hooks still apply to it, but the\n // `roles:update` field gate does not.\n const fields = hasNamedField(collection.fields, rolesFieldName)\n ? collection.fields\n : [\n ...collection.fields,\n createRolesField({\n name: rolesFieldName,\n access: { create: rolesFieldAccess, update: rolesFieldAccess },\n override: pluginConfig.rolesField?.override,\n rolesCollectionSlug,\n }),\n ]\n\n const beforeChange = [...(collection.hooks?.beforeChange ?? [])]\n const beforeDelete = [...(collection.hooks?.beforeDelete ?? [])]\n if (selfOnlyCredentialRoleNames.length > 0) {\n beforeChange.push(\n createProtectCredentialsHook({\n rolesCollectionSlug,\n rolesFieldName,\n selfOnlyRoleNames: selfOnlyCredentialRoleNames,\n userCollectionSlug: collection.slug,\n }),\n )\n }\n if (preventEscalation || adminRole.name) {\n beforeChange.push(\n createProtectRolesFieldHook({\n preventEscalation,\n rolesCollectionSlug,\n rolesFieldName,\n ...(adminRole.name\n ? {\n // Break-glass: with no administrator left (database-level\n // damage), denying a self-claim would leave the system\n // unrecoverable.\n breakGlass: { adminRoleName: adminRole.name, userCollections },\n // Only holders may grant the admin tier — a client\n // administrator holding '*' through another role must not be\n // able to join it on their own.\n holderOnly: { roleNames: [adminRole.name], userCollections },\n }\n : {}),\n }),\n )\n }\n if (adminRole.name) {\n // There must always be at least one user holding the admin role: with\n // nobody left holding '*', the escalation guard would block every path\n // to assigning it again.\n const protectLastAdminArgs = {\n adminRoleName: adminRole.name,\n rolesCollectionSlug,\n rolesFieldName,\n userCollections,\n userCollectionSlug: collection.slug,\n }\n beforeChange.push(createProtectLastAdminChangeHook(protectLastAdminArgs))\n beforeDelete.push(createProtectLastAdminDeleteHook(protectLastAdminArgs))\n }\n if (adminRole.name && collection.slug === adminUserCollection) {\n beforeChange.push(\n createAssignFirstUserRoleHook({\n firstUserRole: adminRole.name,\n rolesCollectionSlug,\n rolesFieldName,\n }),\n )\n }\n\n return { ...collection, fields, hooks: { ...collection.hooks, beforeChange, beforeDelete } }\n }\n\n const rolesAccess: CollectionConfig['access'] = {}\n for (const action of collectionActions) {\n rolesAccess[action] = createRbacAccess({ slug: rolesCollectionSlug, action })\n }\n if (adminRole.name) {\n const baseRead = rolesAccess.read\n // Break-glass visibility: while no user holds full access, signed-in users\n // may read roles — the account page must be able to list the admin role\n // for the self-claim, and nothing here is sensitive.\n rolesAccess.read = async (args) => {\n const base = await baseRead?.(args)\n if (base || !args.req.user) {\n return base ?? false\n }\n const fullAccessRoleIds = await findFullAccessRoleIds(args.req.payload, rolesCollectionSlug)\n return !(await anyUserHoldsRole(args.req.payload, fullAccessRoleIds, {\n rolesFieldName,\n userCollections,\n }))\n }\n }\n\n // The protected-role guard runs before the escalation guard so its clearer\n // error wins, and it applies even when preventPrivilegeEscalation is off —\n // the two solve different problems.\n const rolesBeforeChange: NonNullable<CollectionConfig['hooks']>['beforeChange'] = []\n if (protectedRoles.length > 0) {\n rolesBeforeChange.push(createProtectedRolesChangeHook({ protectedRoles }))\n }\n if (preventEscalation) {\n rolesBeforeChange.push(createProtectRolesCollectionHook({ protectedRoleNames }))\n }\n const rolesHooks: CollectionConfig['hooks'] = {\n ...(rolesBeforeChange.length > 0 ? { beforeChange: rolesBeforeChange } : {}),\n ...(protectedRoles.length > 0\n ? {\n beforeDelete: [\n createProtectedRolesDeleteHook({ protectedRoleNames, rolesCollectionSlug }),\n ],\n }\n : {}),\n }\n\n const rolesCollection = createRolesCollection({\n slug: rolesCollectionSlug,\n access: rolesAccess,\n hooks: Object.keys(rolesHooks).length > 0 ? rolesHooks : undefined,\n matrixRows,\n override: pluginConfig.rolesCollection?.override,\n protectedRoleNames,\n })\n\n config.collections = [\n ...(config.collections ?? []).map((collection) => {\n let result = shouldControlCollection(collection.slug)\n ? withCollectionAccess(collection)\n : collection\n if (userCollections.includes(collection.slug)) {\n result = withRolesField(result)\n }\n return result\n }),\n rolesCollection,\n ]\n\n config.globals = (config.globals ?? []).map((global) =>\n shouldControlGlobal(global.slug) ? withGlobalAccess(global) : global,\n )\n\n const incomingOnInit = config.onInit\n config.onInit = async (payload) => {\n if (predefinedRoles.length > 0) {\n await seedPredefinedRoles(payload, { roles: predefinedRoles, rolesCollectionSlug })\n }\n if (adminRole.name) {\n await warnIfAdminRoleUnheld(payload, {\n adminRoleName: adminRole.name,\n rolesCollectionSlug,\n rolesFieldName,\n userCollections,\n })\n }\n await incomingOnInit?.(payload)\n }\n\n const customConfig: RbacCustomConfig = {\n rolesCollectionSlug,\n rolesFieldName,\n userCollections,\n }\n\n config.custom = {\n ...config.custom,\n [pluginKey]: customConfig,\n }\n\n return config\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"plugin.js","names":["adminRole: { description?: string; name?: string }","matrixRows: MatrixRow[]","rolesAccess: CollectionConfig['access']","rolesBeforeChange: NonNullable<CollectionConfig['hooks']>['beforeChange']","rolesHooks: CollectionConfig['hooks']","customConfig: RbacCustomConfig"],"sources":["../src/plugin.ts"],"sourcesContent":["import type { CollectionConfig, Config, Field, GlobalConfig, Plugin } from 'payload'\n\nimport type { MatrixRow, RbacCustomConfig } from './shared.js'\nimport type { RbacEntitySelection, RbacPluginConfig } from './types.js'\n\nimport { createRbacAccess } from './access/rbacAccess.js'\nimport { createRolesFieldAccess } from './access/rolesFieldAccess.js'\nimport { createRolesCollection } from './collections/createRolesCollection.js'\nimport { createRolesField } from './fields/createRolesField.js'\nimport { createAssignFirstUserRoleHook } from './hooks/assignFirstUserRole.js'\nimport { createProtectCredentialsHook } from './hooks/protectCredentials.js'\nimport {\n createProtectedRolesChangeHook,\n createProtectedRolesDeleteHook,\n} from './hooks/protectedRoles.js'\nimport {\n createProtectLastAdminChangeHook,\n createProtectLastAdminDeleteHook,\n} from './hooks/protectLastAdmin.js'\nimport { createProtectRolesCollectionHook } from './hooks/protectRolesCollection.js'\nimport { createProtectRolesFieldHook } from './hooks/protectRolesField.js'\nimport { FULL_ACCESS, permissionFor } from './permissions.js'\nimport { seedPredefinedRoles } from './seed.js'\nimport { collectionActions, globalActions, pluginKey } from './shared.js'\nimport { entityLabel } from './utilities/entityLabel.js'\nimport {\n anyUserHoldsRole,\n findFullAccessRoleIds,\n warnIfAdminRoleUnheld,\n} from './utilities/fullAccessHolders.js'\n\nconst createSelector = <TSlug extends string>(\n selection: RbacEntitySelection<TSlug> | undefined,\n): ((slug: string) => boolean) => {\n if (selection === undefined || selection === true) {\n return () => true\n }\n if (Array.isArray(selection)) {\n const included = new Set<string>(selection)\n return (slug) => included.has(slug)\n }\n const excluded = new Set<string>(selection.exclude)\n return (slug) => !excluded.has(slug)\n}\n\nconst hasNamedField = (fields: Field[], name: string): boolean => {\n return fields.some((field) => 'name' in field && field.name === name)\n}\n\nconst resolveUserCollections = (config: Config, pluginConfig: RbacPluginConfig): string[] => {\n if (pluginConfig.userCollections?.length) {\n return pluginConfig.userCollections\n }\n const authSlugs = (config.collections ?? [])\n .filter((collection) => Boolean(collection.auth))\n .map((collection) => collection.slug)\n if (authSlugs.length) {\n return authSlugs\n }\n return [config.admin?.user ?? 'users']\n}\n\nexport const rbacPlugin = (pluginConfig: RbacPluginConfig = {}): Plugin => {\n return (incomingConfig: Config): Config => {\n const config = { ...incomingConfig }\n\n if (pluginConfig.enabled === false) {\n return config\n }\n\n const rolesCollectionSlug = pluginConfig.rolesCollection?.slug ?? 'roles'\n const rolesFieldName = pluginConfig.rolesField?.name ?? 'roles'\n const preventEscalation = pluginConfig.preventPrivilegeEscalation !== false\n const ownAccountActions =\n pluginConfig.ownAccountAccess === false\n ? []\n : (pluginConfig.ownAccountAccess ?? ['read', 'update'])\n const adminRole: { description?: string; name?: string } =\n !pluginConfig.adminRole || typeof pluginConfig.adminRole === 'string'\n ? { name: pluginConfig.adminRole || undefined }\n : pluginConfig.adminRole\n\n if (adminRole.name && (pluginConfig.roles ?? []).some((role) => role.name === adminRole.name)) {\n throw new Error(\n `[payload-rbac] \"${adminRole.name}\" is the adminRole and is defined by the plugin — ` +\n `it always has permissions ['*'] and is protected. Remove it from roles, or use a different name.`,\n )\n }\n\n // The admin role is always full-access and protected: an unprotected one can\n // be downgraded irreversibly — once nobody holds a permission, the escalation\n // guard blocks everyone from granting it back. Its credentials are always\n // self-only: with `users:update` enough to set an administrator's password\n // or email, any admin account could be taken over by another.\n const predefinedRoles = [\n ...(adminRole.name\n ? [\n {\n name: adminRole.name,\n credentialChanges: 'self' as const,\n description: adminRole.description ?? 'Full access to everything.',\n permissions: [FULL_ACCESS],\n protected: true,\n },\n ]\n : []),\n ...(pluginConfig.roles ?? []).map((role) => ({\n ...role,\n protected: role.protected ?? false,\n })),\n ]\n const protectedRoles = predefinedRoles.filter((role) => role.protected)\n const protectedRoleNames = protectedRoles.map((role) => role.name)\n const selfOnlyCredentialRoleNames = predefinedRoles\n .filter((role) => role.credentialChanges === 'self')\n .map((role) => role.name)\n\n if ((config.collections ?? []).some((collection) => collection.slug === rolesCollectionSlug)) {\n throw new Error(\n `[payload-rbac] A collection with the slug \"${rolesCollectionSlug}\" already exists. ` +\n `Pass rolesCollection.slug to use a different slug for the roles collection.`,\n )\n }\n\n const shouldControlCollection = createSelector(pluginConfig.collections)\n const shouldControlGlobal = createSelector(pluginConfig.globals)\n const userCollections = resolveUserCollections(config, pluginConfig)\n const adminUserCollection = config.admin?.user ?? 'users'\n\n const matrixRows: MatrixRow[] = [\n ...(config.collections ?? [])\n .filter((collection) => shouldControlCollection(collection.slug))\n .map((collection) => ({\n slug: collection.slug,\n actions: collectionActions,\n entity: 'collection' as const,\n label: entityLabel(collection.labels?.plural, collection.slug),\n })),\n {\n slug: rolesCollectionSlug,\n actions: collectionActions,\n entity: 'collection' as const,\n label: 'Roles',\n },\n ...(config.globals ?? [])\n .filter((global) => shouldControlGlobal(global.slug))\n .map((global) => ({\n slug: global.slug,\n actions: globalActions,\n entity: 'global' as const,\n label: entityLabel(global.label, global.slug),\n })),\n ]\n\n const validPermissions = new Set<string>([\n FULL_ACCESS,\n ...collectionActions.map((action) => `*:${action}`),\n ...matrixRows.flatMap((row) => [\n `${row.slug}:*`,\n ...row.actions.map((action) => permissionFor(row.slug, action)),\n ]),\n ])\n for (const role of predefinedRoles) {\n for (const permission of role.permissions) {\n if (!validPermissions.has(permission)) {\n throw new Error(\n `[payload-rbac] Predefined role \"${role.name}\" grants unknown permission \"${permission}\". ` +\n `Permissions must be '*', '<slug>:<action>', '<slug>:*', or '*:<action>' for a ` +\n `controlled collection (create/read/update/delete) or global (read/update).`,\n )\n }\n }\n }\n\n // Explicit access defined on a collection wins for that operation; the plugin\n // only fills the gaps. Compose `requirePermission` into your own access\n // functions to combine both.\n const withCollectionAccess = (collection: CollectionConfig): CollectionConfig => {\n const access = { ...collection.access }\n for (const action of collectionActions) {\n if (access[action] === undefined) {\n access[action] = createRbacAccess({ slug: collection.slug, action, ownAccountActions })\n }\n }\n if (access.readVersions === undefined) {\n access.readVersions = createRbacAccess({ slug: collection.slug, action: 'read' })\n }\n if (access.unlock === undefined) {\n access.unlock = createRbacAccess({ slug: collection.slug, action: 'update' })\n }\n return { ...collection, access }\n }\n\n const withGlobalAccess = (global: GlobalConfig): GlobalConfig => {\n const access = { ...global.access }\n for (const action of globalActions) {\n if (access[action] === undefined) {\n access[action] = createRbacAccess({ slug: global.slug, action })\n }\n }\n if (access.readVersions === undefined) {\n access.readVersions = createRbacAccess({ slug: global.slug, action: 'read' })\n }\n return { ...global, access }\n }\n\n // Changing role membership requires `roles:update`. Field-level access is\n // what makes the admin panel render the field read-only for everyone else,\n // so a user can never accidentally remove their own roles — a hook would\n // only reject the save after the fact. While no administrator exists the\n // field stays writable, or the break-glass self-claim could never reach the\n // escalation guard that vets it.\n const rolesFieldAccess = createRolesFieldAccess({\n rolesCollectionSlug,\n rolesFieldName,\n ...(adminRole.name ? { breakGlass: { userCollections } } : {}),\n })\n\n const withRolesField = (collection: CollectionConfig): CollectionConfig => {\n // If the collection already defines a field with this name, leave the field\n // entirely user-managed — the guard hooks still apply to it, but the\n // `roles:update` field gate does not.\n const fields = hasNamedField(collection.fields, rolesFieldName)\n ? collection.fields\n : [\n ...collection.fields,\n createRolesField({\n name: rolesFieldName,\n access: { create: rolesFieldAccess, update: rolesFieldAccess },\n override: pluginConfig.rolesField?.override,\n rolesCollectionSlug,\n }),\n ]\n\n const beforeChange = [...(collection.hooks?.beforeChange ?? [])]\n const beforeDelete = [...(collection.hooks?.beforeDelete ?? [])]\n if (selfOnlyCredentialRoleNames.length > 0) {\n beforeChange.push(\n createProtectCredentialsHook({\n rolesCollectionSlug,\n rolesFieldName,\n selfOnlyRoleNames: selfOnlyCredentialRoleNames,\n userCollectionSlug: collection.slug,\n }),\n )\n }\n if (preventEscalation || adminRole.name) {\n beforeChange.push(\n createProtectRolesFieldHook({\n preventEscalation,\n rolesCollectionSlug,\n rolesFieldName,\n ...(adminRole.name\n ? {\n // Break-glass: with no administrator left (database-level\n // damage), denying a self-claim would leave the system\n // unrecoverable.\n breakGlass: { adminRoleName: adminRole.name, userCollections },\n // Only holders may grant the admin tier — a client\n // administrator holding '*' through another role must not be\n // able to join it on their own.\n holderOnly: { roleNames: [adminRole.name], userCollections },\n }\n : {}),\n }),\n )\n }\n if (adminRole.name) {\n // There must always be at least one user holding the admin role: with\n // nobody left holding '*', the escalation guard would block every path\n // to assigning it again.\n const protectLastAdminArgs = {\n adminRoleName: adminRole.name,\n rolesCollectionSlug,\n rolesFieldName,\n userCollections,\n userCollectionSlug: collection.slug,\n }\n beforeChange.push(createProtectLastAdminChangeHook(protectLastAdminArgs))\n beforeDelete.push(createProtectLastAdminDeleteHook(protectLastAdminArgs))\n }\n if (adminRole.name && collection.slug === adminUserCollection) {\n beforeChange.push(\n createAssignFirstUserRoleHook({\n firstUserRole: adminRole.name,\n rolesCollectionSlug,\n rolesFieldName,\n }),\n )\n }\n\n return { ...collection, fields, hooks: { ...collection.hooks, beforeChange, beforeDelete } }\n }\n\n const rolesAccess: CollectionConfig['access'] = {}\n for (const action of collectionActions) {\n rolesAccess[action] = createRbacAccess({ slug: rolesCollectionSlug, action })\n }\n if (adminRole.name) {\n const baseRead = rolesAccess.read\n // Break-glass visibility: while no user holds full access, signed-in users\n // may read roles — the account page must be able to list the admin role\n // for the self-claim, and nothing here is sensitive.\n rolesAccess.read = async (args) => {\n const base = await baseRead?.(args)\n if (base || !args.req.user) {\n return base ?? false\n }\n const fullAccessRoleIds = await findFullAccessRoleIds(args.req.payload, rolesCollectionSlug)\n return !(await anyUserHoldsRole(args.req.payload, fullAccessRoleIds, {\n rolesFieldName,\n userCollections,\n }))\n }\n }\n\n // The protected-role guard runs before the escalation guard so its clearer\n // error wins, and it applies even when preventPrivilegeEscalation is off —\n // the two solve different problems.\n const rolesBeforeChange: NonNullable<CollectionConfig['hooks']>['beforeChange'] = []\n if (protectedRoles.length > 0) {\n rolesBeforeChange.push(createProtectedRolesChangeHook({ protectedRoles }))\n }\n if (preventEscalation) {\n rolesBeforeChange.push(createProtectRolesCollectionHook({ protectedRoleNames }))\n }\n const rolesHooks: CollectionConfig['hooks'] = {\n ...(rolesBeforeChange.length > 0 ? { beforeChange: rolesBeforeChange } : {}),\n ...(protectedRoles.length > 0\n ? {\n beforeDelete: [\n createProtectedRolesDeleteHook({ protectedRoleNames, rolesCollectionSlug }),\n ],\n }\n : {}),\n }\n\n const rolesCollection = createRolesCollection({\n slug: rolesCollectionSlug,\n access: rolesAccess,\n hooks: Object.keys(rolesHooks).length > 0 ? rolesHooks : undefined,\n matrixRows,\n override: pluginConfig.rolesCollection?.override,\n protectedRoleNames,\n })\n\n config.collections = [\n ...(config.collections ?? []).map((collection) => {\n let result = shouldControlCollection(collection.slug)\n ? withCollectionAccess(collection)\n : collection\n if (userCollections.includes(collection.slug)) {\n result = withRolesField(result)\n }\n return result\n }),\n rolesCollection,\n ]\n\n config.globals = (config.globals ?? []).map((global) =>\n shouldControlGlobal(global.slug) ? withGlobalAccess(global) : global,\n )\n\n const incomingOnInit = config.onInit\n config.onInit = async (payload) => {\n if (predefinedRoles.length > 0) {\n await seedPredefinedRoles(payload, { roles: predefinedRoles, rolesCollectionSlug })\n }\n if (adminRole.name) {\n await warnIfAdminRoleUnheld(payload, {\n adminRoleName: adminRole.name,\n rolesCollectionSlug,\n rolesFieldName,\n userCollections,\n })\n }\n await incomingOnInit?.(payload)\n }\n\n const customConfig: RbacCustomConfig = {\n rolesCollectionSlug,\n rolesFieldName,\n userCollections,\n }\n\n config.custom = {\n ...config.custom,\n [pluginKey]: customConfig,\n }\n\n return config\n }\n}\n"],"mappings":"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"}
package/dist/types.d.ts CHANGED
@@ -4,10 +4,14 @@ import { CollectionConfig, CollectionSlug, GlobalSlug, RelationshipField } from
4
4
  //#region src/types.d.ts
5
5
 
6
6
  /**
7
- * A single grant. Either:
7
+ * A single grant. One of:
8
8
  * - `'*'` — full access to every controlled collection and global, present and future.
9
9
  * - `'<slug>:<action>'` — one action on one collection (`create`/`read`/`update`/`delete`)
10
10
  * or global (`read`/`update`), e.g. `'posts:update'`.
11
+ * - `'<slug>:*'` — every action on one collection or global, e.g. `'pages:*'`.
12
+ * - `'*:<action>'` — one action on every controlled collection and global, present and
13
+ * future, e.g. `'*:read'`. `'*:create'` and `'*:delete'` only ever match collections —
14
+ * globals have no such actions.
11
15
  */
12
16
  type RbacPermission = string;
13
17
  /**
@@ -6,7 +6,10 @@ type RoleHolderQueryArgs = {
6
6
  /** Every collection carrying the roles field — holders are counted across all of them. */
7
7
  userCollections: string[];
8
8
  };
9
- /** IDs of every role that grants full access (`'*'`). */
9
+ /**
10
+ * IDs of every role that grants full access — `'*'`, or the equivalent
11
+ * `'*:<action>'` wildcard for every action.
12
+ */
10
13
  declare const findFullAccessRoleIds: (payload: Payload, rolesCollectionSlug: string) => Promise<(number | string)[]>;
11
14
  /** Whether any user across the user collections holds one of the given roles. */
12
15
  declare const anyUserHoldsRole: (payload: Payload, roleIds: (number | string)[], {
@@ -1,7 +1,10 @@
1
- import { FULL_ACCESS } from "../permissions.js";
1
+ import { fullAccessPermissions } from "../permissions.js";
2
2
 
3
3
  //#region src/utilities/fullAccessHolders.ts
4
- /** IDs of every role that grants full access (`'*'`). */
4
+ /**
5
+ * IDs of every role that grants full access — `'*'`, or the equivalent
6
+ * `'*:<action>'` wildcard for every action.
7
+ */
5
8
  const findFullAccessRoleIds = async (payload, rolesCollectionSlug) => {
6
9
  const { docs } = await payload.find({
7
10
  collection: rolesCollectionSlug,
@@ -10,7 +13,7 @@ const findFullAccessRoleIds = async (payload, rolesCollectionSlug) => {
10
13
  });
11
14
  return docs.filter((doc) => {
12
15
  const permissions = doc.permissions;
13
- return Array.isArray(permissions) && permissions.includes(FULL_ACCESS);
16
+ return Array.isArray(permissions) && fullAccessPermissions(new Set(permissions.filter((entry) => typeof entry === "string")));
14
17
  }).map((doc) => doc.id);
15
18
  };
16
19
  /** Whether any user across the user collections holds one of the given roles. */
@@ -1 +1 @@
1
- {"version":3,"file":"fullAccessHolders.js","names":[],"sources":["../../src/utilities/fullAccessHolders.ts"],"sourcesContent":["import type { Payload } from 'payload'\n\nimport { FULL_ACCESS } from '../permissions.js'\n\nexport type RoleHolderQueryArgs = {\n rolesFieldName: string\n /** Every collection carrying the roles field — holders are counted across all of them. */\n userCollections: string[]\n}\n\n/** IDs of every role that grants full access (`'*'`). */\nexport const findFullAccessRoleIds = async (\n payload: Payload,\n rolesCollectionSlug: string,\n): Promise<(number | string)[]> => {\n const { docs } = await payload.find({\n collection: rolesCollectionSlug,\n depth: 0,\n pagination: false,\n })\n return docs\n .filter((doc) => {\n const permissions = (doc as { permissions?: unknown }).permissions\n return Array.isArray(permissions) && permissions.includes(FULL_ACCESS)\n })\n .map((doc) => (doc as { id: number | string }).id)\n}\n\n/** Whether any user across the user collections holds one of the given roles. */\nexport const anyUserHoldsRole = async (\n payload: Payload,\n roleIds: (number | string)[],\n { rolesFieldName, userCollections }: RoleHolderQueryArgs,\n): Promise<boolean> => {\n if (roleIds.length === 0) {\n return false\n }\n for (const slug of userCollections) {\n const { totalDocs } = await payload.count({\n collection: slug,\n where: { [rolesFieldName]: { in: roleIds } },\n })\n if (totalDocs > 0) {\n return true\n }\n }\n return false\n}\n\nexport type WarnIfAdminRoleUnheldArgs = {\n adminRoleName: string\n rolesCollectionSlug: string\n} & RoleHolderQueryArgs\n\n/**\n * Init-time check, run after seeding: logs how to recover when no user holds the\n * admin role. The guards prevent reaching this state through the API; it arises\n * from what they cannot see — the roles collection wiped at the database level\n * (re-seeded roles get new IDs, so users' role references dangle) or a renamed\n * `adminRole` (a fresh role seeded under the new name with no holders). Silent\n * when there are no users at all: the first-user bootstrap covers that.\n */\nexport const warnIfAdminRoleUnheld = async (\n payload: Payload,\n {\n adminRoleName,\n rolesCollectionSlug,\n rolesFieldName,\n userCollections,\n }: WarnIfAdminRoleUnheldArgs,\n): Promise<void> => {\n const { docs } = await payload.find({\n collection: rolesCollectionSlug,\n depth: 0,\n limit: 1,\n where: { name: { equals: adminRoleName } },\n })\n const adminRoleId = (docs[0] as { id?: number | string } | undefined)?.id\n if (adminRoleId === undefined) {\n return\n }\n\n const holderArgs = { rolesFieldName, userCollections }\n if (await anyUserHoldsRole(payload, [adminRoleId], holderArgs)) {\n return\n }\n\n let usersExist = false\n for (const slug of userCollections) {\n const { totalDocs } = await payload.count({ collection: slug })\n if (totalDocs > 0) {\n usersExist = true\n break\n }\n }\n if (!usersExist) {\n return\n }\n\n const fullAccessRoleIds = await findFullAccessRoleIds(payload, rolesCollectionSlug)\n const otherFullAccessRoleIds = fullAccessRoleIds.filter(\n (id) => String(id) !== String(adminRoleId),\n )\n if (await anyUserHoldsRole(payload, otherFullAccessRoleIds, holderArgs)) {\n payload.logger.warn(\n `[payload-rbac] No user holds the \"${adminRoleName}\" role. A user with full access through another role can assign it in the admin panel.`,\n )\n return\n }\n payload.logger.warn(\n `[payload-rbac] No user holds the \"${adminRoleName}\" role and no user has full access. Any signed-in user may assign \"${adminRoleName}\" to themselves from their account page — the escalation guard permits this while no administrator exists.`,\n )\n}\n"],"mappings":";;;;AAWA,MAAa,wBAAwB,OACnC,SACA,wBACiC;CACjC,MAAM,EAAE,SAAS,MAAM,QAAQ,KAAK;EAClC,YAAY;EACZ,OAAO;EACP,YAAY;EACb,CAAC;AACF,QAAO,KACJ,QAAQ,QAAQ;EACf,MAAM,cAAe,IAAkC;AACvD,SAAO,MAAM,QAAQ,YAAY,IAAI,YAAY,SAAS,YAAY;GACtE,CACD,KAAK,QAAS,IAAgC,GAAG;;;AAItD,MAAa,mBAAmB,OAC9B,SACA,SACA,EAAE,gBAAgB,sBACG;AACrB,KAAI,QAAQ,WAAW,EACrB,QAAO;AAET,MAAK,MAAM,QAAQ,iBAAiB;EAClC,MAAM,EAAE,cAAc,MAAM,QAAQ,MAAM;GACxC,YAAY;GACZ,OAAO,GAAG,iBAAiB,EAAE,IAAI,SAAS,EAAE;GAC7C,CAAC;AACF,MAAI,YAAY,EACd,QAAO;;AAGX,QAAO;;;;;;;;;;AAgBT,MAAa,wBAAwB,OACnC,SACA,EACE,eACA,qBACA,gBACA,sBAEgB;CAClB,MAAM,EAAE,SAAS,MAAM,QAAQ,KAAK;EAClC,YAAY;EACZ,OAAO;EACP,OAAO;EACP,OAAO,EAAE,MAAM,EAAE,QAAQ,eAAe,EAAE;EAC3C,CAAC;CACF,MAAM,cAAe,KAAK,IAA6C;AACvE,KAAI,gBAAgB,OAClB;CAGF,MAAM,aAAa;EAAE;EAAgB;EAAiB;AACtD,KAAI,MAAM,iBAAiB,SAAS,CAAC,YAAY,EAAE,WAAW,CAC5D;CAGF,IAAI,aAAa;AACjB,MAAK,MAAM,QAAQ,iBAAiB;EAClC,MAAM,EAAE,cAAc,MAAM,QAAQ,MAAM,EAAE,YAAY,MAAM,CAAC;AAC/D,MAAI,YAAY,GAAG;AACjB,gBAAa;AACb;;;AAGJ,KAAI,CAAC,WACH;AAOF,KAAI,MAAM,iBAAiB,UAJD,MAAM,sBAAsB,SAAS,oBAAoB,EAClC,QAC9C,OAAO,OAAO,GAAG,KAAK,OAAO,YAAY,CAC3C,EAC2D,WAAW,EAAE;AACvE,UAAQ,OAAO,KACb,qCAAqC,cAAc,wFACpD;AACD;;AAEF,SAAQ,OAAO,KACb,qCAAqC,cAAc,qEAAqE,cAAc,4GACvI"}
1
+ {"version":3,"file":"fullAccessHolders.js","names":[],"sources":["../../src/utilities/fullAccessHolders.ts"],"sourcesContent":["import type { Payload } from 'payload'\n\nimport { fullAccessPermissions } from '../permissions.js'\n\nexport type RoleHolderQueryArgs = {\n rolesFieldName: string\n /** Every collection carrying the roles field — holders are counted across all of them. */\n userCollections: string[]\n}\n\n/**\n * IDs of every role that grants full access `'*'`, or the equivalent\n * `'*:<action>'` wildcard for every action.\n */\nexport const findFullAccessRoleIds = async (\n payload: Payload,\n rolesCollectionSlug: string,\n): Promise<(number | string)[]> => {\n const { docs } = await payload.find({\n collection: rolesCollectionSlug,\n depth: 0,\n pagination: false,\n })\n return docs\n .filter((doc) => {\n const permissions = (doc as { permissions?: unknown }).permissions\n return (\n Array.isArray(permissions) &&\n fullAccessPermissions(\n new Set(permissions.filter((entry): entry is string => typeof entry === 'string')),\n )\n )\n })\n .map((doc) => (doc as { id: number | string }).id)\n}\n\n/** Whether any user across the user collections holds one of the given roles. */\nexport const anyUserHoldsRole = async (\n payload: Payload,\n roleIds: (number | string)[],\n { rolesFieldName, userCollections }: RoleHolderQueryArgs,\n): Promise<boolean> => {\n if (roleIds.length === 0) {\n return false\n }\n for (const slug of userCollections) {\n const { totalDocs } = await payload.count({\n collection: slug,\n where: { [rolesFieldName]: { in: roleIds } },\n })\n if (totalDocs > 0) {\n return true\n }\n }\n return false\n}\n\nexport type WarnIfAdminRoleUnheldArgs = {\n adminRoleName: string\n rolesCollectionSlug: string\n} & RoleHolderQueryArgs\n\n/**\n * Init-time check, run after seeding: logs how to recover when no user holds the\n * admin role. The guards prevent reaching this state through the API; it arises\n * from what they cannot see — the roles collection wiped at the database level\n * (re-seeded roles get new IDs, so users' role references dangle) or a renamed\n * `adminRole` (a fresh role seeded under the new name with no holders). Silent\n * when there are no users at all: the first-user bootstrap covers that.\n */\nexport const warnIfAdminRoleUnheld = async (\n payload: Payload,\n {\n adminRoleName,\n rolesCollectionSlug,\n rolesFieldName,\n userCollections,\n }: WarnIfAdminRoleUnheldArgs,\n): Promise<void> => {\n const { docs } = await payload.find({\n collection: rolesCollectionSlug,\n depth: 0,\n limit: 1,\n where: { name: { equals: adminRoleName } },\n })\n const adminRoleId = (docs[0] as { id?: number | string } | undefined)?.id\n if (adminRoleId === undefined) {\n return\n }\n\n const holderArgs = { rolesFieldName, userCollections }\n if (await anyUserHoldsRole(payload, [adminRoleId], holderArgs)) {\n return\n }\n\n let usersExist = false\n for (const slug of userCollections) {\n const { totalDocs } = await payload.count({ collection: slug })\n if (totalDocs > 0) {\n usersExist = true\n break\n }\n }\n if (!usersExist) {\n return\n }\n\n const fullAccessRoleIds = await findFullAccessRoleIds(payload, rolesCollectionSlug)\n const otherFullAccessRoleIds = fullAccessRoleIds.filter(\n (id) => String(id) !== String(adminRoleId),\n )\n if (await anyUserHoldsRole(payload, otherFullAccessRoleIds, holderArgs)) {\n payload.logger.warn(\n `[payload-rbac] No user holds the \"${adminRoleName}\" role. A user with full access through another role can assign it in the admin panel.`,\n )\n return\n }\n payload.logger.warn(\n `[payload-rbac] No user holds the \"${adminRoleName}\" role and no user has full access. Any signed-in user may assign \"${adminRoleName}\" to themselves from their account page — the escalation guard permits this while no administrator exists.`,\n )\n}\n"],"mappings":";;;;;;;AAcA,MAAa,wBAAwB,OACnC,SACA,wBACiC;CACjC,MAAM,EAAE,SAAS,MAAM,QAAQ,KAAK;EAClC,YAAY;EACZ,OAAO;EACP,YAAY;EACb,CAAC;AACF,QAAO,KACJ,QAAQ,QAAQ;EACf,MAAM,cAAe,IAAkC;AACvD,SACE,MAAM,QAAQ,YAAY,IAC1B,sBACE,IAAI,IAAI,YAAY,QAAQ,UAA2B,OAAO,UAAU,SAAS,CAAC,CACnF;GAEH,CACD,KAAK,QAAS,IAAgC,GAAG;;;AAItD,MAAa,mBAAmB,OAC9B,SACA,SACA,EAAE,gBAAgB,sBACG;AACrB,KAAI,QAAQ,WAAW,EACrB,QAAO;AAET,MAAK,MAAM,QAAQ,iBAAiB;EAClC,MAAM,EAAE,cAAc,MAAM,QAAQ,MAAM;GACxC,YAAY;GACZ,OAAO,GAAG,iBAAiB,EAAE,IAAI,SAAS,EAAE;GAC7C,CAAC;AACF,MAAI,YAAY,EACd,QAAO;;AAGX,QAAO;;;;;;;;;;AAgBT,MAAa,wBAAwB,OACnC,SACA,EACE,eACA,qBACA,gBACA,sBAEgB;CAClB,MAAM,EAAE,SAAS,MAAM,QAAQ,KAAK;EAClC,YAAY;EACZ,OAAO;EACP,OAAO;EACP,OAAO,EAAE,MAAM,EAAE,QAAQ,eAAe,EAAE;EAC3C,CAAC;CACF,MAAM,cAAe,KAAK,IAA6C;AACvE,KAAI,gBAAgB,OAClB;CAGF,MAAM,aAAa;EAAE;EAAgB;EAAiB;AACtD,KAAI,MAAM,iBAAiB,SAAS,CAAC,YAAY,EAAE,WAAW,CAC5D;CAGF,IAAI,aAAa;AACjB,MAAK,MAAM,QAAQ,iBAAiB;EAClC,MAAM,EAAE,cAAc,MAAM,QAAQ,MAAM,EAAE,YAAY,MAAM,CAAC;AAC/D,MAAI,YAAY,GAAG;AACjB,gBAAa;AACb;;;AAGJ,KAAI,CAAC,WACH;AAOF,KAAI,MAAM,iBAAiB,UAJD,MAAM,sBAAsB,SAAS,oBAAoB,EAClC,QAC9C,OAAO,OAAO,GAAG,KAAK,OAAO,YAAY,CAC3C,EAC2D,WAAW,EAAE;AACvE,UAAQ,OAAO,KACb,qCAAqC,cAAc,wFACpD;AACD;;AAEF,SAAQ,OAAO,KACb,qCAAqC,cAAc,qEAAqE,cAAc,4GACvI"}
@@ -8,7 +8,8 @@ import { PayloadRequest } from "payload";
8
8
  */
9
9
  declare const getUserPermissions: (req: PayloadRequest) => Promise<ReadonlySet<string>>;
10
10
  /**
11
- * Whether the requesting user holds a permission (or `'*'`), e.g.
11
+ * Whether the requesting user holds a permission — directly or through a
12
+ * wildcard (`'posts:*'`, `'*:update'`, `'*'`), e.g.
12
13
  * `await hasPermission(req, 'posts:update')`.
13
14
  */
14
15
  declare const hasPermission: (req: PayloadRequest, permission: string) => Promise<boolean>;
@@ -1,5 +1,5 @@
1
- import { FULL_ACCESS } from "../permissions.js";
2
1
  import { getRbacCustomConfig } from "../shared.js";
2
+ import { permissionCovers } from "../permissions.js";
3
3
 
4
4
  //#region src/utilities/getUserPermissions.ts
5
5
  const emptySet = /* @__PURE__ */ new Set();
@@ -59,12 +59,12 @@ const getUserPermissions = (req) => {
59
59
  return resolved;
60
60
  };
61
61
  /**
62
- * Whether the requesting user holds a permission (or `'*'`), e.g.
62
+ * Whether the requesting user holds a permission — directly or through a
63
+ * wildcard (`'posts:*'`, `'*:update'`, `'*'`), e.g.
63
64
  * `await hasPermission(req, 'posts:update')`.
64
65
  */
65
66
  const hasPermission = async (req, permission) => {
66
- const permissions = await getUserPermissions(req);
67
- return permissions.has(FULL_ACCESS) || permissions.has(permission);
67
+ return permissionCovers(await getUserPermissions(req), permission);
68
68
  };
69
69
 
70
70
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"getUserPermissions.js","names":["emptySet: ReadonlySet<string>","idsToFetch: (number | string)[]"],"sources":["../../src/utilities/getUserPermissions.ts"],"sourcesContent":["import type { PayloadRequest } from 'payload'\n\nimport { FULL_ACCESS } from '../permissions.js'\nimport { getRbacCustomConfig } from '../shared.js'\n\nconst emptySet: ReadonlySet<string> = new Set()\n\n/**\n * Access functions run once per collection and operation when the admin panel\n * computes permissions, so the resolved set is memoized per request. Roles\n * referenced by ID cost at most one indexed find per request.\n */\nconst requestCache = new WeakMap<PayloadRequest, Promise<ReadonlySet<string>>>()\n\nconst normalizeRoleValue = (\n entry: unknown,\n): { id: number | string } | { permissions: string[] } | null => {\n if (typeof entry === 'number' || typeof entry === 'string') {\n return { id: entry }\n }\n if (entry && typeof entry === 'object') {\n const doc = entry as Record<string, unknown>\n if (Array.isArray(doc.permissions)) {\n return { permissions: doc.permissions.filter((p): p is string => typeof p === 'string') }\n }\n if (typeof doc.id === 'number' || typeof doc.id === 'string') {\n return { id: doc.id }\n }\n }\n return null\n}\n\nconst resolvePermissions = async (req: PayloadRequest): Promise<ReadonlySet<string>> => {\n const custom = getRbacCustomConfig(req.payload.config)\n if (!custom || !req.user) {\n return emptySet\n }\n\n const value = (req.user as Record<string, unknown>)[custom.rolesFieldName]\n if (!Array.isArray(value) || value.length === 0) {\n return emptySet\n }\n\n const permissions = new Set<string>()\n const idsToFetch: (number | string)[] = []\n\n // `req.user` carries role IDs at the default auth depth 0, or populated role\n // documents at depth > 0 — use in-document permissions when present.\n for (const entry of value) {\n const normalized = normalizeRoleValue(entry)\n if (!normalized) {\n continue\n }\n if ('permissions' in normalized) {\n for (const permission of normalized.permissions) {\n permissions.add(permission)\n }\n } else {\n idsToFetch.push(normalized.id)\n }\n }\n\n if (idsToFetch.length > 0) {\n // System read: a user's permissions must resolve even when they cannot read\n // the roles collection themselves.\n const { docs } = await req.payload.find({\n collection: custom.rolesCollectionSlug,\n depth: 0,\n overrideAccess: true,\n pagination: false,\n req,\n where: { id: { in: idsToFetch } },\n })\n for (const doc of docs) {\n if (Array.isArray(doc.permissions)) {\n for (const permission of doc.permissions) {\n if (typeof permission === 'string') {\n permissions.add(permission)\n }\n }\n }\n }\n }\n\n return permissions\n}\n\n/**\n * Resolves the union of permission strings granted by the requesting user's roles.\n * Memoized per request; returns an empty set for unauthenticated requests.\n */\nexport const getUserPermissions = (req: PayloadRequest): Promise<ReadonlySet<string>> => {\n if (!req.user) {\n return Promise.resolve(emptySet)\n }\n const cached = requestCache.get(req)\n if (cached) {\n return cached\n }\n const resolved = resolvePermissions(req)\n requestCache.set(req, resolved)\n return resolved\n}\n\n/**\n * Whether the requesting user holds a permission (or `'*'`), e.g.\n * `await hasPermission(req, 'posts:update')`.\n */\nexport const hasPermission = async (req: PayloadRequest, permission: string): Promise<boolean> => {\n const permissions = await getUserPermissions(req)\n return permissions.has(FULL_ACCESS) || permissions.has(permission)\n}\n"],"mappings":";;;;AAKA,MAAMA,2BAAgC,IAAI,KAAK;;;;;;AAO/C,MAAM,+BAAe,IAAI,SAAuD;AAEhF,MAAM,sBACJ,UAC+D;AAC/D,KAAI,OAAO,UAAU,YAAY,OAAO,UAAU,SAChD,QAAO,EAAE,IAAI,OAAO;AAEtB,KAAI,SAAS,OAAO,UAAU,UAAU;EACtC,MAAM,MAAM;AACZ,MAAI,MAAM,QAAQ,IAAI,YAAY,CAChC,QAAO,EAAE,aAAa,IAAI,YAAY,QAAQ,MAAmB,OAAO,MAAM,SAAS,EAAE;AAE3F,MAAI,OAAO,IAAI,OAAO,YAAY,OAAO,IAAI,OAAO,SAClD,QAAO,EAAE,IAAI,IAAI,IAAI;;AAGzB,QAAO;;AAGT,MAAM,qBAAqB,OAAO,QAAsD;CACtF,MAAM,SAAS,oBAAoB,IAAI,QAAQ,OAAO;AACtD,KAAI,CAAC,UAAU,CAAC,IAAI,KAClB,QAAO;CAGT,MAAM,QAAS,IAAI,KAAiC,OAAO;AAC3D,KAAI,CAAC,MAAM,QAAQ,MAAM,IAAI,MAAM,WAAW,EAC5C,QAAO;CAGT,MAAM,8BAAc,IAAI,KAAa;CACrC,MAAMC,aAAkC,EAAE;AAI1C,MAAK,MAAM,SAAS,OAAO;EACzB,MAAM,aAAa,mBAAmB,MAAM;AAC5C,MAAI,CAAC,WACH;AAEF,MAAI,iBAAiB,WACnB,MAAK,MAAM,cAAc,WAAW,YAClC,aAAY,IAAI,WAAW;MAG7B,YAAW,KAAK,WAAW,GAAG;;AAIlC,KAAI,WAAW,SAAS,GAAG;EAGzB,MAAM,EAAE,SAAS,MAAM,IAAI,QAAQ,KAAK;GACtC,YAAY,OAAO;GACnB,OAAO;GACP,gBAAgB;GAChB,YAAY;GACZ;GACA,OAAO,EAAE,IAAI,EAAE,IAAI,YAAY,EAAE;GAClC,CAAC;AACF,OAAK,MAAM,OAAO,KAChB,KAAI,MAAM,QAAQ,IAAI,YAAY,EAChC;QAAK,MAAM,cAAc,IAAI,YAC3B,KAAI,OAAO,eAAe,SACxB,aAAY,IAAI,WAAW;;;AAOrC,QAAO;;;;;;AAOT,MAAa,sBAAsB,QAAsD;AACvF,KAAI,CAAC,IAAI,KACP,QAAO,QAAQ,QAAQ,SAAS;CAElC,MAAM,SAAS,aAAa,IAAI,IAAI;AACpC,KAAI,OACF,QAAO;CAET,MAAM,WAAW,mBAAmB,IAAI;AACxC,cAAa,IAAI,KAAK,SAAS;AAC/B,QAAO;;;;;;AAOT,MAAa,gBAAgB,OAAO,KAAqB,eAAyC;CAChG,MAAM,cAAc,MAAM,mBAAmB,IAAI;AACjD,QAAO,YAAY,IAAI,YAAY,IAAI,YAAY,IAAI,WAAW"}
1
+ {"version":3,"file":"getUserPermissions.js","names":["emptySet: ReadonlySet<string>","idsToFetch: (number | string)[]"],"sources":["../../src/utilities/getUserPermissions.ts"],"sourcesContent":["import type { PayloadRequest } from 'payload'\n\nimport { permissionCovers } from '../permissions.js'\nimport { getRbacCustomConfig } from '../shared.js'\n\nconst emptySet: ReadonlySet<string> = new Set()\n\n/**\n * Access functions run once per collection and operation when the admin panel\n * computes permissions, so the resolved set is memoized per request. Roles\n * referenced by ID cost at most one indexed find per request.\n */\nconst requestCache = new WeakMap<PayloadRequest, Promise<ReadonlySet<string>>>()\n\nconst normalizeRoleValue = (\n entry: unknown,\n): { id: number | string } | { permissions: string[] } | null => {\n if (typeof entry === 'number' || typeof entry === 'string') {\n return { id: entry }\n }\n if (entry && typeof entry === 'object') {\n const doc = entry as Record<string, unknown>\n if (Array.isArray(doc.permissions)) {\n return { permissions: doc.permissions.filter((p): p is string => typeof p === 'string') }\n }\n if (typeof doc.id === 'number' || typeof doc.id === 'string') {\n return { id: doc.id }\n }\n }\n return null\n}\n\nconst resolvePermissions = async (req: PayloadRequest): Promise<ReadonlySet<string>> => {\n const custom = getRbacCustomConfig(req.payload.config)\n if (!custom || !req.user) {\n return emptySet\n }\n\n const value = (req.user as Record<string, unknown>)[custom.rolesFieldName]\n if (!Array.isArray(value) || value.length === 0) {\n return emptySet\n }\n\n const permissions = new Set<string>()\n const idsToFetch: (number | string)[] = []\n\n // `req.user` carries role IDs at the default auth depth 0, or populated role\n // documents at depth > 0 — use in-document permissions when present.\n for (const entry of value) {\n const normalized = normalizeRoleValue(entry)\n if (!normalized) {\n continue\n }\n if ('permissions' in normalized) {\n for (const permission of normalized.permissions) {\n permissions.add(permission)\n }\n } else {\n idsToFetch.push(normalized.id)\n }\n }\n\n if (idsToFetch.length > 0) {\n // System read: a user's permissions must resolve even when they cannot read\n // the roles collection themselves.\n const { docs } = await req.payload.find({\n collection: custom.rolesCollectionSlug,\n depth: 0,\n overrideAccess: true,\n pagination: false,\n req,\n where: { id: { in: idsToFetch } },\n })\n for (const doc of docs) {\n if (Array.isArray(doc.permissions)) {\n for (const permission of doc.permissions) {\n if (typeof permission === 'string') {\n permissions.add(permission)\n }\n }\n }\n }\n }\n\n return permissions\n}\n\n/**\n * Resolves the union of permission strings granted by the requesting user's roles.\n * Memoized per request; returns an empty set for unauthenticated requests.\n */\nexport const getUserPermissions = (req: PayloadRequest): Promise<ReadonlySet<string>> => {\n if (!req.user) {\n return Promise.resolve(emptySet)\n }\n const cached = requestCache.get(req)\n if (cached) {\n return cached\n }\n const resolved = resolvePermissions(req)\n requestCache.set(req, resolved)\n return resolved\n}\n\n/**\n * Whether the requesting user holds a permission — directly or through a\n * wildcard (`'posts:*'`, `'*:update'`, `'*'`), e.g.\n * `await hasPermission(req, 'posts:update')`.\n */\nexport const hasPermission = async (req: PayloadRequest, permission: string): Promise<boolean> => {\n const permissions = await getUserPermissions(req)\n return permissionCovers(permissions, permission)\n}\n"],"mappings":";;;;AAKA,MAAMA,2BAAgC,IAAI,KAAK;;;;;;AAO/C,MAAM,+BAAe,IAAI,SAAuD;AAEhF,MAAM,sBACJ,UAC+D;AAC/D,KAAI,OAAO,UAAU,YAAY,OAAO,UAAU,SAChD,QAAO,EAAE,IAAI,OAAO;AAEtB,KAAI,SAAS,OAAO,UAAU,UAAU;EACtC,MAAM,MAAM;AACZ,MAAI,MAAM,QAAQ,IAAI,YAAY,CAChC,QAAO,EAAE,aAAa,IAAI,YAAY,QAAQ,MAAmB,OAAO,MAAM,SAAS,EAAE;AAE3F,MAAI,OAAO,IAAI,OAAO,YAAY,OAAO,IAAI,OAAO,SAClD,QAAO,EAAE,IAAI,IAAI,IAAI;;AAGzB,QAAO;;AAGT,MAAM,qBAAqB,OAAO,QAAsD;CACtF,MAAM,SAAS,oBAAoB,IAAI,QAAQ,OAAO;AACtD,KAAI,CAAC,UAAU,CAAC,IAAI,KAClB,QAAO;CAGT,MAAM,QAAS,IAAI,KAAiC,OAAO;AAC3D,KAAI,CAAC,MAAM,QAAQ,MAAM,IAAI,MAAM,WAAW,EAC5C,QAAO;CAGT,MAAM,8BAAc,IAAI,KAAa;CACrC,MAAMC,aAAkC,EAAE;AAI1C,MAAK,MAAM,SAAS,OAAO;EACzB,MAAM,aAAa,mBAAmB,MAAM;AAC5C,MAAI,CAAC,WACH;AAEF,MAAI,iBAAiB,WACnB,MAAK,MAAM,cAAc,WAAW,YAClC,aAAY,IAAI,WAAW;MAG7B,YAAW,KAAK,WAAW,GAAG;;AAIlC,KAAI,WAAW,SAAS,GAAG;EAGzB,MAAM,EAAE,SAAS,MAAM,IAAI,QAAQ,KAAK;GACtC,YAAY,OAAO;GACnB,OAAO;GACP,gBAAgB;GAChB,YAAY;GACZ;GACA,OAAO,EAAE,IAAI,EAAE,IAAI,YAAY,EAAE;GAClC,CAAC;AACF,OAAK,MAAM,OAAO,KAChB,KAAI,MAAM,QAAQ,IAAI,YAAY,EAChC;QAAK,MAAM,cAAc,IAAI,YAC3B,KAAI,OAAO,eAAe,SACxB,aAAY,IAAI,WAAW;;;AAOrC,QAAO;;;;;;AAOT,MAAa,sBAAsB,QAAsD;AACvF,KAAI,CAAC,IAAI,KACP,QAAO,QAAQ,QAAQ,SAAS;CAElC,MAAM,SAAS,aAAa,IAAI,IAAI;AACpC,KAAI,OACF,QAAO;CAET,MAAM,WAAW,mBAAmB,IAAI;AACxC,cAAa,IAAI,KAAK,SAAS;AAC/B,QAAO;;;;;;;AAQT,MAAa,gBAAgB,OAAO,KAAqB,eAAyC;AAEhG,QAAO,iBADa,MAAM,mBAAmB,IAAI,EACZ,WAAW"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@whatworks/payload-rbac",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Role based access control plugin for Payload. Roles live in the database with a per-collection Create/Read/Update/Delete permission matrix, can be predefined in code and seeded on init, and are enforced across all collections and globals.",
5
5
  "license": "MIT",
6
6
  "type": "module",