@whatworks/payload-rbac 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +243 -0
  3. package/dist/access/rbacAccess.d.ts +33 -0
  4. package/dist/access/rbacAccess.js +34 -0
  5. package/dist/access/rbacAccess.js.map +1 -0
  6. package/dist/access/rolesFieldAccess.d.ts +33 -0
  7. package/dist/access/rolesFieldAccess.js +31 -0
  8. package/dist/access/rolesFieldAccess.js.map +1 -0
  9. package/dist/collections/createRolesCollection.d.ts +30 -0
  10. package/dist/collections/createRolesCollection.js +63 -0
  11. package/dist/collections/createRolesCollection.js.map +1 -0
  12. package/dist/exports/client.d.ts +2 -0
  13. package/dist/exports/client.js +5 -0
  14. package/dist/fields/PermissionsMatrixField.d.ts +21 -0
  15. package/dist/fields/PermissionsMatrixField.js +180 -0
  16. package/dist/fields/PermissionsMatrixField.js.map +1 -0
  17. package/dist/fields/createRolesField.d.ts +23 -0
  18. package/dist/fields/createRolesField.js +27 -0
  19. package/dist/fields/createRolesField.js.map +1 -0
  20. package/dist/hooks/assignFirstUserRole.d.ts +22 -0
  21. package/dist/hooks/assignFirstUserRole.js +40 -0
  22. package/dist/hooks/assignFirstUserRole.js.map +1 -0
  23. package/dist/hooks/protectCredentials.d.ts +29 -0
  24. package/dist/hooks/protectCredentials.js +43 -0
  25. package/dist/hooks/protectCredentials.js.map +1 -0
  26. package/dist/hooks/protectLastAdmin.d.ts +28 -0
  27. package/dist/hooks/protectLastAdmin.js +76 -0
  28. package/dist/hooks/protectLastAdmin.js.map +1 -0
  29. package/dist/hooks/protectRolesCollection.d.ts +25 -0
  30. package/dist/hooks/protectRolesCollection.js +30 -0
  31. package/dist/hooks/protectRolesCollection.js.map +1 -0
  32. package/dist/hooks/protectRolesField.d.ts +57 -0
  33. package/dist/hooks/protectRolesField.js +99 -0
  34. package/dist/hooks/protectRolesField.js.map +1 -0
  35. package/dist/hooks/protectedRoles.d.ts +34 -0
  36. package/dist/hooks/protectedRoles.js +50 -0
  37. package/dist/hooks/protectedRoles.js.map +1 -0
  38. package/dist/index.d.ts +19 -0
  39. package/dist/index.js +19 -0
  40. package/dist/permissions.d.ts +23 -0
  41. package/dist/permissions.js +31 -0
  42. package/dist/permissions.js.map +1 -0
  43. package/dist/plugin.d.ts +8 -0
  44. package/dist/plugin.js +255 -0
  45. package/dist/plugin.js.map +1 -0
  46. package/dist/seed.d.ts +24 -0
  47. package/dist/seed.js +47 -0
  48. package/dist/seed.js.map +1 -0
  49. package/dist/shared.d.ts +38 -0
  50. package/dist/shared.js +19 -0
  51. package/dist/shared.js.map +1 -0
  52. package/dist/types.d.ts +194 -0
  53. package/dist/utilities/entityLabel.d.ts +10 -0
  54. package/dist/utilities/entityLabel.js +17 -0
  55. package/dist/utilities/entityLabel.js.map +1 -0
  56. package/dist/utilities/fullAccessHolders.d.ts +36 -0
  57. package/dist/utilities/fullAccessHolders.js +68 -0
  58. package/dist/utilities/fullAccessHolders.js.map +1 -0
  59. package/dist/utilities/getUserPermissions.d.ts +17 -0
  60. package/dist/utilities/getUserPermissions.js +72 -0
  61. package/dist/utilities/getUserPermissions.js.map +1 -0
  62. package/package.json +88 -0
@@ -0,0 +1,17 @@
1
+ //#region src/utilities/entityLabel.ts
2
+ /** Humanizes a slug, e.g. `'site-settings'` → `'Site Settings'`. */
3
+ const humanizeSlug = (slug) => {
4
+ return slug.split(/[-_]+/).filter(Boolean).map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
5
+ };
6
+ /**
7
+ * Resolves a display label for a collection or global at plugin time. Matrix rows
8
+ * cross to the client as serialized props, so only plain-string labels can be used —
9
+ * localized objects and label functions fall back to the humanized slug.
10
+ */
11
+ const entityLabel = (label, slug) => {
12
+ return typeof label === "string" && label.trim() !== "" ? label : humanizeSlug(slug);
13
+ };
14
+
15
+ //#endregion
16
+ export { entityLabel };
17
+ //# sourceMappingURL=entityLabel.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"entityLabel.js","names":[],"sources":["../../src/utilities/entityLabel.ts"],"sourcesContent":["/** Humanizes a slug, e.g. `'site-settings'` → `'Site Settings'`. */\nconst humanizeSlug = (slug: string): string => {\n return slug\n .split(/[-_]+/)\n .filter(Boolean)\n .map((word) => word.charAt(0).toUpperCase() + word.slice(1))\n .join(' ')\n}\n\n/**\n * Resolves a display label for a collection or global at plugin time. Matrix rows\n * cross to the client as serialized props, so only plain-string labels can be used —\n * localized objects and label functions fall back to the humanized slug.\n */\nexport const entityLabel = (label: unknown, slug: string): string => {\n return typeof label === 'string' && label.trim() !== '' ? label : humanizeSlug(slug)\n}\n"],"mappings":";;AACA,MAAM,gBAAgB,SAAyB;AAC7C,QAAO,KACJ,MAAM,QAAQ,CACd,OAAO,QAAQ,CACf,KAAK,SAAS,KAAK,OAAO,EAAE,CAAC,aAAa,GAAG,KAAK,MAAM,EAAE,CAAC,CAC3D,KAAK,IAAI;;;;;;;AAQd,MAAa,eAAe,OAAgB,SAAyB;AACnE,QAAO,OAAO,UAAU,YAAY,MAAM,MAAM,KAAK,KAAK,QAAQ,aAAa,KAAK"}
@@ -0,0 +1,36 @@
1
+ import { Payload } from "payload";
2
+
3
+ //#region src/utilities/fullAccessHolders.d.ts
4
+ type RoleHolderQueryArgs = {
5
+ rolesFieldName: string;
6
+ /** Every collection carrying the roles field — holders are counted across all of them. */
7
+ userCollections: string[];
8
+ };
9
+ /** IDs of every role that grants full access (`'*'`). */
10
+ declare const findFullAccessRoleIds: (payload: Payload, rolesCollectionSlug: string) => Promise<(number | string)[]>;
11
+ /** Whether any user across the user collections holds one of the given roles. */
12
+ declare const anyUserHoldsRole: (payload: Payload, roleIds: (number | string)[], {
13
+ rolesFieldName,
14
+ userCollections
15
+ }: RoleHolderQueryArgs) => Promise<boolean>;
16
+ type WarnIfAdminRoleUnheldArgs = {
17
+ adminRoleName: string;
18
+ rolesCollectionSlug: string;
19
+ } & RoleHolderQueryArgs;
20
+ /**
21
+ * Init-time check, run after seeding: logs how to recover when no user holds the
22
+ * admin role. The guards prevent reaching this state through the API; it arises
23
+ * from what they cannot see — the roles collection wiped at the database level
24
+ * (re-seeded roles get new IDs, so users' role references dangle) or a renamed
25
+ * `adminRole` (a fresh role seeded under the new name with no holders). Silent
26
+ * when there are no users at all: the first-user bootstrap covers that.
27
+ */
28
+ declare const warnIfAdminRoleUnheld: (payload: Payload, {
29
+ adminRoleName,
30
+ rolesCollectionSlug,
31
+ rolesFieldName,
32
+ userCollections
33
+ }: WarnIfAdminRoleUnheldArgs) => Promise<void>;
34
+ //#endregion
35
+ export { RoleHolderQueryArgs, WarnIfAdminRoleUnheldArgs, anyUserHoldsRole, findFullAccessRoleIds, warnIfAdminRoleUnheld };
36
+ //# sourceMappingURL=fullAccessHolders.d.ts.map
@@ -0,0 +1,68 @@
1
+ import { FULL_ACCESS } from "../permissions.js";
2
+
3
+ //#region src/utilities/fullAccessHolders.ts
4
+ /** IDs of every role that grants full access (`'*'`). */
5
+ const findFullAccessRoleIds = async (payload, rolesCollectionSlug) => {
6
+ const { docs } = await payload.find({
7
+ collection: rolesCollectionSlug,
8
+ depth: 0,
9
+ pagination: false
10
+ });
11
+ return docs.filter((doc) => {
12
+ const permissions = doc.permissions;
13
+ return Array.isArray(permissions) && permissions.includes(FULL_ACCESS);
14
+ }).map((doc) => doc.id);
15
+ };
16
+ /** Whether any user across the user collections holds one of the given roles. */
17
+ const anyUserHoldsRole = async (payload, roleIds, { rolesFieldName, userCollections }) => {
18
+ if (roleIds.length === 0) return false;
19
+ for (const slug of userCollections) {
20
+ const { totalDocs } = await payload.count({
21
+ collection: slug,
22
+ where: { [rolesFieldName]: { in: roleIds } }
23
+ });
24
+ if (totalDocs > 0) return true;
25
+ }
26
+ return false;
27
+ };
28
+ /**
29
+ * Init-time check, run after seeding: logs how to recover when no user holds the
30
+ * admin role. The guards prevent reaching this state through the API; it arises
31
+ * from what they cannot see — the roles collection wiped at the database level
32
+ * (re-seeded roles get new IDs, so users' role references dangle) or a renamed
33
+ * `adminRole` (a fresh role seeded under the new name with no holders). Silent
34
+ * when there are no users at all: the first-user bootstrap covers that.
35
+ */
36
+ const warnIfAdminRoleUnheld = async (payload, { adminRoleName, rolesCollectionSlug, rolesFieldName, userCollections }) => {
37
+ const { docs } = await payload.find({
38
+ collection: rolesCollectionSlug,
39
+ depth: 0,
40
+ limit: 1,
41
+ where: { name: { equals: adminRoleName } }
42
+ });
43
+ const adminRoleId = docs[0]?.id;
44
+ if (adminRoleId === void 0) return;
45
+ const holderArgs = {
46
+ rolesFieldName,
47
+ userCollections
48
+ };
49
+ if (await anyUserHoldsRole(payload, [adminRoleId], holderArgs)) return;
50
+ let usersExist = false;
51
+ for (const slug of userCollections) {
52
+ const { totalDocs } = await payload.count({ collection: slug });
53
+ if (totalDocs > 0) {
54
+ usersExist = true;
55
+ break;
56
+ }
57
+ }
58
+ if (!usersExist) return;
59
+ if (await anyUserHoldsRole(payload, (await findFullAccessRoleIds(payload, rolesCollectionSlug)).filter((id) => String(id) !== String(adminRoleId)), holderArgs)) {
60
+ payload.logger.warn(`[payload-rbac] No user holds the "${adminRoleName}" role. A user with full access through another role can assign it in the admin panel.`);
61
+ return;
62
+ }
63
+ payload.logger.warn(`[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.`);
64
+ };
65
+
66
+ //#endregion
67
+ export { anyUserHoldsRole, findFullAccessRoleIds, warnIfAdminRoleUnheld };
68
+ //# sourceMappingURL=fullAccessHolders.js.map
@@ -0,0 +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"}
@@ -0,0 +1,17 @@
1
+ import { PayloadRequest } from "payload";
2
+
3
+ //#region src/utilities/getUserPermissions.d.ts
4
+
5
+ /**
6
+ * Resolves the union of permission strings granted by the requesting user's roles.
7
+ * Memoized per request; returns an empty set for unauthenticated requests.
8
+ */
9
+ declare const getUserPermissions: (req: PayloadRequest) => Promise<ReadonlySet<string>>;
10
+ /**
11
+ * Whether the requesting user holds a permission (or `'*'`), e.g.
12
+ * `await hasPermission(req, 'posts:update')`.
13
+ */
14
+ declare const hasPermission: (req: PayloadRequest, permission: string) => Promise<boolean>;
15
+ //#endregion
16
+ export { getUserPermissions, hasPermission };
17
+ //# sourceMappingURL=getUserPermissions.d.ts.map
@@ -0,0 +1,72 @@
1
+ import { FULL_ACCESS } from "../permissions.js";
2
+ import { getRbacCustomConfig } from "../shared.js";
3
+
4
+ //#region src/utilities/getUserPermissions.ts
5
+ const emptySet = /* @__PURE__ */ new Set();
6
+ /**
7
+ * Access functions run once per collection and operation when the admin panel
8
+ * computes permissions, so the resolved set is memoized per request. Roles
9
+ * referenced by ID cost at most one indexed find per request.
10
+ */
11
+ const requestCache = /* @__PURE__ */ new WeakMap();
12
+ const normalizeRoleValue = (entry) => {
13
+ if (typeof entry === "number" || typeof entry === "string") return { id: entry };
14
+ if (entry && typeof entry === "object") {
15
+ const doc = entry;
16
+ if (Array.isArray(doc.permissions)) return { permissions: doc.permissions.filter((p) => typeof p === "string") };
17
+ if (typeof doc.id === "number" || typeof doc.id === "string") return { id: doc.id };
18
+ }
19
+ return null;
20
+ };
21
+ const resolvePermissions = async (req) => {
22
+ const custom = getRbacCustomConfig(req.payload.config);
23
+ if (!custom || !req.user) return emptySet;
24
+ const value = req.user[custom.rolesFieldName];
25
+ if (!Array.isArray(value) || value.length === 0) return emptySet;
26
+ const permissions = /* @__PURE__ */ new Set();
27
+ const idsToFetch = [];
28
+ for (const entry of value) {
29
+ const normalized = normalizeRoleValue(entry);
30
+ if (!normalized) continue;
31
+ if ("permissions" in normalized) for (const permission of normalized.permissions) permissions.add(permission);
32
+ else idsToFetch.push(normalized.id);
33
+ }
34
+ if (idsToFetch.length > 0) {
35
+ const { docs } = await req.payload.find({
36
+ collection: custom.rolesCollectionSlug,
37
+ depth: 0,
38
+ overrideAccess: true,
39
+ pagination: false,
40
+ req,
41
+ where: { id: { in: idsToFetch } }
42
+ });
43
+ for (const doc of docs) if (Array.isArray(doc.permissions)) {
44
+ for (const permission of doc.permissions) if (typeof permission === "string") permissions.add(permission);
45
+ }
46
+ }
47
+ return permissions;
48
+ };
49
+ /**
50
+ * Resolves the union of permission strings granted by the requesting user's roles.
51
+ * Memoized per request; returns an empty set for unauthenticated requests.
52
+ */
53
+ const getUserPermissions = (req) => {
54
+ if (!req.user) return Promise.resolve(emptySet);
55
+ const cached = requestCache.get(req);
56
+ if (cached) return cached;
57
+ const resolved = resolvePermissions(req);
58
+ requestCache.set(req, resolved);
59
+ return resolved;
60
+ };
61
+ /**
62
+ * Whether the requesting user holds a permission (or `'*'`), e.g.
63
+ * `await hasPermission(req, 'posts:update')`.
64
+ */
65
+ const hasPermission = async (req, permission) => {
66
+ const permissions = await getUserPermissions(req);
67
+ return permissions.has(FULL_ACCESS) || permissions.has(permission);
68
+ };
69
+
70
+ //#endregion
71
+ export { getUserPermissions, hasPermission };
72
+ //# sourceMappingURL=getUserPermissions.js.map
@@ -0,0 +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"}
package/package.json ADDED
@@ -0,0 +1,88 @@
1
+ {
2
+ "name": "@whatworks/payload-rbac",
3
+ "version": "0.1.0",
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
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "files": [
10
+ "dist",
11
+ "LICENSE.md",
12
+ "README.md"
13
+ ],
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "https://github.com/what-works-global/payload-packages",
17
+ "directory": "packages/rbac"
18
+ },
19
+ "publishConfig": {
20
+ "access": "public",
21
+ "registry": "https://registry.npmjs.org/"
22
+ },
23
+ "exports": {
24
+ ".": {
25
+ "types": "./dist/index.d.ts",
26
+ "import": "./dist/index.js",
27
+ "default": "./dist/index.js"
28
+ },
29
+ "./client": {
30
+ "types": "./dist/exports/client.d.ts",
31
+ "import": "./dist/exports/client.js",
32
+ "default": "./dist/exports/client.js"
33
+ }
34
+ },
35
+ "peerDependencies": {
36
+ "@payloadcms/ui": ">=3.27.0 <4",
37
+ "next": ">=15.0.0 <17",
38
+ "payload": ">=3.27.0 <4",
39
+ "react": ">=19.0.0 <20",
40
+ "react-dom": ">=19.0.0 <20"
41
+ },
42
+ "whatworks": {
43
+ "peerMatrix": {
44
+ "payload": {
45
+ "min": "3.27.0"
46
+ }
47
+ }
48
+ },
49
+ "devDependencies": {
50
+ "@arethetypeswrong/cli": "0.18.2",
51
+ "@payloadcms/db-mongodb": "3.84.1",
52
+ "@payloadcms/eslint-config": "3.28.0",
53
+ "@payloadcms/next": "3.84.1",
54
+ "@payloadcms/ui": "3.84.1",
55
+ "@types/node": "22.19.9",
56
+ "@types/react": "19.2.9",
57
+ "@types/react-dom": "19.2.3",
58
+ "cross-env": "7.0.3",
59
+ "eslint": "9.23.0",
60
+ "eslint-config-next": "16.2.6",
61
+ "next": "16.2.6",
62
+ "payload": "3.84.1",
63
+ "publint": "0.3.12",
64
+ "react": "19.2.1",
65
+ "react-dom": "19.2.1",
66
+ "rimraf": "6.0.1",
67
+ "sharp": "0.34.2",
68
+ "tsdown": "0.18.0",
69
+ "typescript": "5.7.3",
70
+ "vitest": "3.1.2",
71
+ "@whatworks/dev-fixture": "0.0.0"
72
+ },
73
+ "scripts": {
74
+ "build": "tsdown",
75
+ "check:exports": "publint && node ../../scripts/check-package-exports.mjs",
76
+ "clean": "rimraf {dist,*.tsbuildinfo}",
77
+ "dev": "next dev dev --turbo",
78
+ "dev:generate-importmap": "pnpm dev:payload generate:importmap",
79
+ "dev:generate-types": "pnpm dev:payload generate:types",
80
+ "dev:payload": "cd dev && cross-env PAYLOAD_CONFIG_PATH=./payload.config.ts payload",
81
+ "generate:importmap": "pnpm dev:generate-importmap",
82
+ "generate:types": "pnpm dev:generate-types",
83
+ "lint": "eslint",
84
+ "lint:fix": "eslint --fix",
85
+ "test:peer": "vitest run",
86
+ "typecheck": "tsc --noEmit"
87
+ }
88
+ }