@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,57 @@
1
+ import { CollectionBeforeChangeHook } from "payload";
2
+
3
+ //#region src/hooks/protectRolesField.d.ts
4
+ /** Extracts the role IDs from a relationship value (IDs or populated documents). */
5
+ declare const normalizeRoleIds: (value: unknown) => (number | string)[];
6
+ type ProtectRolesFieldArgs = {
7
+ /**
8
+ * Break-glass recovery for the named admin role: while no user in the system
9
+ * holds full access, a user may assign this role to themselves. Without it, a
10
+ * system whose administrators were lost to database-level damage could never
11
+ * regain one through the API.
12
+ */
13
+ breakGlass?: {
14
+ adminRoleName: string;
15
+ userCollections: string[];
16
+ };
17
+ /**
18
+ * Roles that only their holders may assign — permissions covering the role
19
+ * (even `'*'`) are not enough. The restriction relaxes while no user holds the
20
+ * role at all, so a newly introduced or renamed role can still be claimed by a
21
+ * user passing the ordinary permission check.
22
+ */
23
+ holderOnly?: {
24
+ roleNames: string[];
25
+ userCollections: string[];
26
+ };
27
+ /**
28
+ * Apply the permission-coverage check. The plugin disables this when
29
+ * `preventPrivilegeEscalation` is off while `holderOnly` must still apply.
30
+ *
31
+ * @default true
32
+ */
33
+ preventEscalation?: boolean;
34
+ rolesCollectionSlug: string;
35
+ rolesFieldName: string;
36
+ };
37
+ /**
38
+ * Privilege-escalation guard installed on each user collection: a user may only
39
+ * assign roles whose permissions their own roles already cover (users with `'*'`
40
+ * may assign anything), and `holderOnly` roles — the admin role — additionally
41
+ * only by users who already hold them. Removals mirror additions on the user's
42
+ * own account: a role may only be removed from yourself when the roles you keep
43
+ * still cover its permissions — otherwise you could never assign it back and
44
+ * would have locked yourself out. Removing roles from other users is not
45
+ * restricted, and writes without a user — seeds, init scripts, first-user
46
+ * registration — pass through.
47
+ */
48
+ declare const createProtectRolesFieldHook: ({
49
+ breakGlass,
50
+ holderOnly,
51
+ preventEscalation,
52
+ rolesCollectionSlug,
53
+ rolesFieldName
54
+ }: ProtectRolesFieldArgs) => CollectionBeforeChangeHook;
55
+ //#endregion
56
+ export { ProtectRolesFieldArgs, createProtectRolesFieldHook, normalizeRoleIds };
57
+ //# sourceMappingURL=protectRolesField.d.ts.map
@@ -0,0 +1,99 @@
1
+ import { missingPermissions } from "../permissions.js";
2
+ import { getUserPermissions } from "../utilities/getUserPermissions.js";
3
+ import { anyUserHoldsRole, findFullAccessRoleIds } from "../utilities/fullAccessHolders.js";
4
+ import { APIError } from "payload";
5
+
6
+ //#region src/hooks/protectRolesField.ts
7
+ /** Extracts the role IDs from a relationship value (IDs or populated documents). */
8
+ const normalizeRoleIds = (value) => {
9
+ if (!Array.isArray(value)) return [];
10
+ const ids = [];
11
+ for (const entry of value) if (typeof entry === "number" || typeof entry === "string") ids.push(entry);
12
+ else if (entry && typeof entry === "object") {
13
+ const id = entry.id;
14
+ if (typeof id === "number" || typeof id === "string") ids.push(id);
15
+ }
16
+ return ids;
17
+ };
18
+ /**
19
+ * Privilege-escalation guard installed on each user collection: a user may only
20
+ * assign roles whose permissions their own roles already cover (users with `'*'`
21
+ * may assign anything), and `holderOnly` roles — the admin role — additionally
22
+ * only by users who already hold them. Removals mirror additions on the user's
23
+ * own account: a role may only be removed from yourself when the roles you keep
24
+ * still cover its permissions — otherwise you could never assign it back and
25
+ * would have locked yourself out. Removing roles from other users is not
26
+ * restricted, and writes without a user — seeds, init scripts, first-user
27
+ * registration — pass through.
28
+ */
29
+ const createProtectRolesFieldHook = ({ breakGlass, holderOnly, preventEscalation, rolesCollectionSlug, rolesFieldName }) => {
30
+ const holderOnlyNames = new Set(holderOnly?.roleNames ?? []);
31
+ return async ({ collection, data, originalDoc, req }) => {
32
+ if (!req.user || !data || !(rolesFieldName in data)) return data;
33
+ const previousIds = normalizeRoleIds(originalDoc?.[rolesFieldName]);
34
+ const previousIdSet = new Set(previousIds.map(String));
35
+ const nextIdSet = new Set(normalizeRoleIds(data[rolesFieldName]).map(String));
36
+ const addedIds = normalizeRoleIds(data[rolesFieldName]).filter((id) => !previousIdSet.has(String(id)));
37
+ const targetId = originalDoc?.id;
38
+ const isSelf = req.user.collection === collection.slug && (typeof targetId === "number" || typeof targetId === "string") && String(targetId) === String(req.user.id);
39
+ const removedIds = isSelf && preventEscalation !== false ? previousIds.filter((id) => !nextIdSet.has(String(id))) : [];
40
+ if (addedIds.length === 0 && removedIds.length === 0) return data;
41
+ let stranded;
42
+ const systemIsStranded = async () => {
43
+ if (stranded === void 0) {
44
+ const fullAccessRoleIds = await findFullAccessRoleIds(req.payload, rolesCollectionSlug);
45
+ stranded = !await anyUserHoldsRole(req.payload, fullAccessRoleIds, {
46
+ rolesFieldName,
47
+ userCollections: breakGlass?.userCollections ?? []
48
+ });
49
+ }
50
+ return stranded;
51
+ };
52
+ if (addedIds.length > 0) {
53
+ const granted = preventEscalation === false ? /* @__PURE__ */ new Set() : await getUserPermissions(req);
54
+ const heldRoleIds = new Set(normalizeRoleIds(req.user[rolesFieldName]).map(String));
55
+ const { docs: addedRoles } = await req.payload.find({
56
+ collection: rolesCollectionSlug,
57
+ depth: 0,
58
+ overrideAccess: true,
59
+ pagination: false,
60
+ req,
61
+ where: { id: { in: addedIds } }
62
+ });
63
+ for (const role of addedRoles) {
64
+ const required = Array.isArray(role.permissions) ? role.permissions.filter((p) => typeof p === "string") : [];
65
+ const missing = preventEscalation === false ? [] : missingPermissions(granted, required);
66
+ let missingHolder = false;
67
+ if (typeof role.name === "string" && holderOnlyNames.has(role.name) && !heldRoleIds.has(String(role.id))) missingHolder = await anyUserHoldsRole(req.payload, [role.id], {
68
+ rolesFieldName,
69
+ userCollections: holderOnly?.userCollections ?? []
70
+ });
71
+ if (missing.length === 0 && !missingHolder) continue;
72
+ if (breakGlass && role.name === breakGlass.adminRoleName && isSelf && await systemIsStranded()) continue;
73
+ if (missingHolder) throw new APIError(`The role "${String(role.name)}" can only be assigned by a user who holds it.`, 403);
74
+ throw new APIError(`You cannot assign the role "${String(role.name)}" — it grants permissions you do not hold: ${missing.join(", ")}`, 403);
75
+ }
76
+ }
77
+ if (removedIds.length > 0) {
78
+ const { docs: previousRoles } = await req.payload.find({
79
+ collection: rolesCollectionSlug,
80
+ depth: 0,
81
+ overrideAccess: true,
82
+ pagination: false,
83
+ req,
84
+ where: { id: { in: previousIds } }
85
+ });
86
+ const keptPermissions = new Set(previousRoles.filter((role) => nextIdSet.has(String(role.id))).flatMap((role) => Array.isArray(role.permissions) ? role.permissions.filter((p) => typeof p === "string") : []));
87
+ for (const role of previousRoles) {
88
+ if (nextIdSet.has(String(role.id))) continue;
89
+ const missing = missingPermissions(keptPermissions, Array.isArray(role.permissions) ? role.permissions.filter((p) => typeof p === "string") : []);
90
+ if (missing.length > 0) throw new APIError(`You cannot remove the role "${String(role.name)}" from your own account — the roles you would keep do not cover ${missing.join(", ")}, so you could not assign it back. Another user with role management access must remove it.`, 403);
91
+ }
92
+ }
93
+ return data;
94
+ };
95
+ };
96
+
97
+ //#endregion
98
+ export { createProtectRolesFieldHook, normalizeRoleIds };
99
+ //# sourceMappingURL=protectRolesField.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"protectRolesField.js","names":["ids: (number | string)[]","targetId: unknown","stranded: boolean | undefined"],"sources":["../../src/hooks/protectRolesField.ts"],"sourcesContent":["import type { CollectionBeforeChangeHook } from 'payload'\n\nimport { APIError } from 'payload'\n\nimport { missingPermissions } from '../permissions.js'\nimport { anyUserHoldsRole, findFullAccessRoleIds } from '../utilities/fullAccessHolders.js'\nimport { getUserPermissions } from '../utilities/getUserPermissions.js'\n\n/** Extracts the role IDs from a relationship value (IDs or populated documents). */\nexport const normalizeRoleIds = (value: unknown): (number | string)[] => {\n if (!Array.isArray(value)) {\n return []\n }\n const ids: (number | string)[] = []\n for (const entry of value) {\n if (typeof entry === 'number' || typeof entry === 'string') {\n ids.push(entry)\n } else if (entry && typeof entry === 'object') {\n const id = (entry as Record<string, unknown>).id\n if (typeof id === 'number' || typeof id === 'string') {\n ids.push(id)\n }\n }\n }\n return ids\n}\n\nexport type ProtectRolesFieldArgs = {\n /**\n * Break-glass recovery for the named admin role: while no user in the system\n * holds full access, a user may assign this role to themselves. Without it, a\n * system whose administrators were lost to database-level damage could never\n * regain one through the API.\n */\n breakGlass?: {\n adminRoleName: string\n userCollections: string[]\n }\n /**\n * Roles that only their holders may assign — permissions covering the role\n * (even `'*'`) are not enough. The restriction relaxes while no user holds the\n * role at all, so a newly introduced or renamed role can still be claimed by a\n * user passing the ordinary permission check.\n */\n holderOnly?: {\n roleNames: string[]\n userCollections: string[]\n }\n /**\n * Apply the permission-coverage check. The plugin disables this when\n * `preventPrivilegeEscalation` is off while `holderOnly` must still apply.\n *\n * @default true\n */\n preventEscalation?: boolean\n rolesCollectionSlug: string\n rolesFieldName: string\n}\n\n/**\n * Privilege-escalation guard installed on each user collection: a user may only\n * assign roles whose permissions their own roles already cover (users with `'*'`\n * may assign anything), and `holderOnly` roles — the admin role — additionally\n * only by users who already hold them. Removals mirror additions on the user's\n * own account: a role may only be removed from yourself when the roles you keep\n * still cover its permissions — otherwise you could never assign it back and\n * would have locked yourself out. Removing roles from other users is not\n * restricted, and writes without a user — seeds, init scripts, first-user\n * registration — pass through.\n */\nexport const createProtectRolesFieldHook = ({\n breakGlass,\n holderOnly,\n preventEscalation,\n rolesCollectionSlug,\n rolesFieldName,\n}: ProtectRolesFieldArgs): CollectionBeforeChangeHook => {\n const holderOnlyNames = new Set(holderOnly?.roleNames ?? [])\n return async ({ collection, data, originalDoc, req }) => {\n if (!req.user || !data || !(rolesFieldName in data)) {\n return data\n }\n\n const previousIds = normalizeRoleIds(originalDoc?.[rolesFieldName])\n const previousIdSet = new Set(previousIds.map(String))\n const nextIdSet = new Set(normalizeRoleIds(data[rolesFieldName]).map(String))\n const addedIds = normalizeRoleIds(data[rolesFieldName]).filter(\n (id) => !previousIdSet.has(String(id)),\n )\n\n const targetId: unknown = originalDoc?.id\n const isSelf =\n req.user.collection === collection.slug &&\n (typeof targetId === 'number' || typeof targetId === 'string') &&\n String(targetId) === String(req.user.id)\n const removedIds =\n isSelf && preventEscalation !== false\n ? previousIds.filter((id) => !nextIdSet.has(String(id)))\n : []\n\n if (addedIds.length === 0 && removedIds.length === 0) {\n return data\n }\n\n let stranded: boolean | undefined\n const systemIsStranded = async (): Promise<boolean> => {\n if (stranded === undefined) {\n const fullAccessRoleIds = await findFullAccessRoleIds(req.payload, rolesCollectionSlug)\n stranded = !(await anyUserHoldsRole(req.payload, fullAccessRoleIds, {\n rolesFieldName,\n userCollections: breakGlass?.userCollections ?? [],\n }))\n }\n return stranded\n }\n\n if (addedIds.length > 0) {\n const granted =\n preventEscalation === false ? new Set<string>() : await getUserPermissions(req)\n const heldRoleIds = new Set(\n normalizeRoleIds((req.user as Record<string, unknown>)[rolesFieldName]).map(String),\n )\n\n const { docs: addedRoles } = await req.payload.find({\n collection: rolesCollectionSlug,\n depth: 0,\n overrideAccess: true,\n pagination: false,\n req,\n where: { id: { in: addedIds } },\n })\n\n for (const role of addedRoles) {\n const required = Array.isArray(role.permissions)\n ? role.permissions.filter((p): p is string => typeof p === 'string')\n : []\n const missing = preventEscalation === false ? [] : missingPermissions(granted, required)\n\n let missingHolder = false\n if (\n typeof role.name === 'string' &&\n holderOnlyNames.has(role.name) &&\n !heldRoleIds.has(String(role.id))\n ) {\n // Only holders may hand out a holder-only role — except while nobody\n // holds it at all (a fresh rename, or the plugin newly added to an\n // existing project), when a user passing the permission check steps up.\n missingHolder = await anyUserHoldsRole(req.payload, [role.id], {\n rolesFieldName,\n userCollections: holderOnly?.userCollections ?? [],\n })\n }\n\n if (missing.length === 0 && !missingHolder) {\n continue\n }\n\n if (\n breakGlass &&\n role.name === breakGlass.adminRoleName &&\n isSelf &&\n (await systemIsStranded())\n ) {\n continue\n }\n if (missingHolder) {\n throw new APIError(\n `The role \"${String(role.name)}\" can only be assigned by a user who holds it.`,\n 403,\n )\n }\n throw new APIError(\n `You cannot assign the role \"${String(role.name)}\" — it grants permissions you do not hold: ${missing.join(', ')}`,\n 403,\n )\n }\n }\n\n if (removedIds.length > 0) {\n // Removals mirror additions on your own account: the roles you keep must\n // cover what the removed role granted, or the escalation guard could never\n // let you re-assign it — one save away from locking yourself out. Roles\n // whose documents no longer exist grant nothing and may always be removed.\n const { docs: previousRoles } = await req.payload.find({\n collection: rolesCollectionSlug,\n depth: 0,\n overrideAccess: true,\n pagination: false,\n req,\n where: { id: { in: previousIds } },\n })\n const keptPermissions = new Set(\n previousRoles\n .filter((role) => nextIdSet.has(String(role.id)))\n .flatMap((role) =>\n Array.isArray(role.permissions)\n ? role.permissions.filter((p): p is string => typeof p === 'string')\n : [],\n ),\n )\n for (const role of previousRoles) {\n if (nextIdSet.has(String(role.id))) {\n continue\n }\n const required = Array.isArray(role.permissions)\n ? role.permissions.filter((p): p is string => typeof p === 'string')\n : []\n const missing = missingPermissions(keptPermissions, required)\n if (missing.length > 0) {\n throw new APIError(\n `You cannot remove the role \"${String(role.name)}\" from your own account — the roles you would keep do not cover ${missing.join(', ')}, so you could not assign it back. Another user with role management access must remove it.`,\n 403,\n )\n }\n }\n }\n\n return data\n }\n}\n"],"mappings":";;;;;;;AASA,MAAa,oBAAoB,UAAwC;AACvE,KAAI,CAAC,MAAM,QAAQ,MAAM,CACvB,QAAO,EAAE;CAEX,MAAMA,MAA2B,EAAE;AACnC,MAAK,MAAM,SAAS,MAClB,KAAI,OAAO,UAAU,YAAY,OAAO,UAAU,SAChD,KAAI,KAAK,MAAM;UACN,SAAS,OAAO,UAAU,UAAU;EAC7C,MAAM,KAAM,MAAkC;AAC9C,MAAI,OAAO,OAAO,YAAY,OAAO,OAAO,SAC1C,KAAI,KAAK,GAAG;;AAIlB,QAAO;;;;;;;;;;;;;AA8CT,MAAa,+BAA+B,EAC1C,YACA,YACA,mBACA,qBACA,qBACuD;CACvD,MAAM,kBAAkB,IAAI,IAAI,YAAY,aAAa,EAAE,CAAC;AAC5D,QAAO,OAAO,EAAE,YAAY,MAAM,aAAa,UAAU;AACvD,MAAI,CAAC,IAAI,QAAQ,CAAC,QAAQ,EAAE,kBAAkB,MAC5C,QAAO;EAGT,MAAM,cAAc,iBAAiB,cAAc,gBAAgB;EACnE,MAAM,gBAAgB,IAAI,IAAI,YAAY,IAAI,OAAO,CAAC;EACtD,MAAM,YAAY,IAAI,IAAI,iBAAiB,KAAK,gBAAgB,CAAC,IAAI,OAAO,CAAC;EAC7E,MAAM,WAAW,iBAAiB,KAAK,gBAAgB,CAAC,QACrD,OAAO,CAAC,cAAc,IAAI,OAAO,GAAG,CAAC,CACvC;EAED,MAAMC,WAAoB,aAAa;EACvC,MAAM,SACJ,IAAI,KAAK,eAAe,WAAW,SAClC,OAAO,aAAa,YAAY,OAAO,aAAa,aACrD,OAAO,SAAS,KAAK,OAAO,IAAI,KAAK,GAAG;EAC1C,MAAM,aACJ,UAAU,sBAAsB,QAC5B,YAAY,QAAQ,OAAO,CAAC,UAAU,IAAI,OAAO,GAAG,CAAC,CAAC,GACtD,EAAE;AAER,MAAI,SAAS,WAAW,KAAK,WAAW,WAAW,EACjD,QAAO;EAGT,IAAIC;EACJ,MAAM,mBAAmB,YAA8B;AACrD,OAAI,aAAa,QAAW;IAC1B,MAAM,oBAAoB,MAAM,sBAAsB,IAAI,SAAS,oBAAoB;AACvF,eAAW,CAAE,MAAM,iBAAiB,IAAI,SAAS,mBAAmB;KAClE;KACA,iBAAiB,YAAY,mBAAmB,EAAE;KACnD,CAAC;;AAEJ,UAAO;;AAGT,MAAI,SAAS,SAAS,GAAG;GACvB,MAAM,UACJ,sBAAsB,wBAAQ,IAAI,KAAa,GAAG,MAAM,mBAAmB,IAAI;GACjF,MAAM,cAAc,IAAI,IACtB,iBAAkB,IAAI,KAAiC,gBAAgB,CAAC,IAAI,OAAO,CACpF;GAED,MAAM,EAAE,MAAM,eAAe,MAAM,IAAI,QAAQ,KAAK;IAClD,YAAY;IACZ,OAAO;IACP,gBAAgB;IAChB,YAAY;IACZ;IACA,OAAO,EAAE,IAAI,EAAE,IAAI,UAAU,EAAE;IAChC,CAAC;AAEF,QAAK,MAAM,QAAQ,YAAY;IAC7B,MAAM,WAAW,MAAM,QAAQ,KAAK,YAAY,GAC5C,KAAK,YAAY,QAAQ,MAAmB,OAAO,MAAM,SAAS,GAClE,EAAE;IACN,MAAM,UAAU,sBAAsB,QAAQ,EAAE,GAAG,mBAAmB,SAAS,SAAS;IAExF,IAAI,gBAAgB;AACpB,QACE,OAAO,KAAK,SAAS,YACrB,gBAAgB,IAAI,KAAK,KAAK,IAC9B,CAAC,YAAY,IAAI,OAAO,KAAK,GAAG,CAAC,CAKjC,iBAAgB,MAAM,iBAAiB,IAAI,SAAS,CAAC,KAAK,GAAG,EAAE;KAC7D;KACA,iBAAiB,YAAY,mBAAmB,EAAE;KACnD,CAAC;AAGJ,QAAI,QAAQ,WAAW,KAAK,CAAC,cAC3B;AAGF,QACE,cACA,KAAK,SAAS,WAAW,iBACzB,UACC,MAAM,kBAAkB,CAEzB;AAEF,QAAI,cACF,OAAM,IAAI,SACR,aAAa,OAAO,KAAK,KAAK,CAAC,iDAC/B,IACD;AAEH,UAAM,IAAI,SACR,+BAA+B,OAAO,KAAK,KAAK,CAAC,6CAA6C,QAAQ,KAAK,KAAK,IAChH,IACD;;;AAIL,MAAI,WAAW,SAAS,GAAG;GAKzB,MAAM,EAAE,MAAM,kBAAkB,MAAM,IAAI,QAAQ,KAAK;IACrD,YAAY;IACZ,OAAO;IACP,gBAAgB;IAChB,YAAY;IACZ;IACA,OAAO,EAAE,IAAI,EAAE,IAAI,aAAa,EAAE;IACnC,CAAC;GACF,MAAM,kBAAkB,IAAI,IAC1B,cACG,QAAQ,SAAS,UAAU,IAAI,OAAO,KAAK,GAAG,CAAC,CAAC,CAChD,SAAS,SACR,MAAM,QAAQ,KAAK,YAAY,GAC3B,KAAK,YAAY,QAAQ,MAAmB,OAAO,MAAM,SAAS,GAClE,EAAE,CACP,CACJ;AACD,QAAK,MAAM,QAAQ,eAAe;AAChC,QAAI,UAAU,IAAI,OAAO,KAAK,GAAG,CAAC,CAChC;IAKF,MAAM,UAAU,mBAAmB,iBAHlB,MAAM,QAAQ,KAAK,YAAY,GAC5C,KAAK,YAAY,QAAQ,MAAmB,OAAO,MAAM,SAAS,GAClE,EAAE,CACuD;AAC7D,QAAI,QAAQ,SAAS,EACnB,OAAM,IAAI,SACR,+BAA+B,OAAO,KAAK,KAAK,CAAC,kEAAkE,QAAQ,KAAK,KAAK,CAAC,8FACtI,IACD;;;AAKP,SAAO"}
@@ -0,0 +1,34 @@
1
+ import { PredefinedRole } from "../types.js";
2
+ import { CollectionBeforeChangeHook, CollectionBeforeDeleteHook } from "payload";
3
+
4
+ //#region src/hooks/protectedRoles.d.ts
5
+ type ProtectedRolesChangeArgs = {
6
+ protectedRoles: PredefinedRole[];
7
+ };
8
+ type ProtectedRolesDeleteArgs = {
9
+ protectedRoleNames: string[];
10
+ rolesCollectionSlug: string;
11
+ };
12
+ /**
13
+ * Locks protected roles to their code definition: they cannot be renamed and their
14
+ * permissions cannot be changed through the API — the only permissions write
15
+ * accepted is restoring the exact code-defined list, so a drifted role can always
16
+ * be repaired but never downgraded. Applies regardless of the caller's own
17
+ * permissions (even `'*'`): the point is that the last full-access role can never
18
+ * be sawed off, leaving nobody able to grant permissions back. Writes without a
19
+ * user (seeding, local API scripts) pass through.
20
+ */
21
+ declare const createProtectedRolesChangeHook: ({
22
+ protectedRoles
23
+ }: ProtectedRolesChangeArgs) => CollectionBeforeChangeHook;
24
+ /**
25
+ * Blocks deleting protected roles through the API — deleting the only full-access
26
+ * role would lock everyone out just as surely as downgrading it.
27
+ */
28
+ declare const createProtectedRolesDeleteHook: ({
29
+ protectedRoleNames,
30
+ rolesCollectionSlug
31
+ }: ProtectedRolesDeleteArgs) => CollectionBeforeDeleteHook;
32
+ //#endregion
33
+ export { ProtectedRolesChangeArgs, ProtectedRolesDeleteArgs, createProtectedRolesChangeHook, createProtectedRolesDeleteHook };
34
+ //# sourceMappingURL=protectedRoles.d.ts.map
@@ -0,0 +1,50 @@
1
+ import { samePermissions } from "../permissions.js";
2
+ import { APIError } from "payload";
3
+
4
+ //#region src/hooks/protectedRoles.ts
5
+ /**
6
+ * Locks protected roles to their code definition: they cannot be renamed and their
7
+ * permissions cannot be changed through the API — the only permissions write
8
+ * accepted is restoring the exact code-defined list, so a drifted role can always
9
+ * be repaired but never downgraded. Applies regardless of the caller's own
10
+ * permissions (even `'*'`): the point is that the last full-access role can never
11
+ * be sawed off, leaving nobody able to grant permissions back. Writes without a
12
+ * user (seeding, local API scripts) pass through.
13
+ */
14
+ const createProtectedRolesChangeHook = ({ protectedRoles }) => {
15
+ const byName = new Map(protectedRoles.map((role) => [role.name, role]));
16
+ return ({ data, originalDoc, req }) => {
17
+ if (!req.user || !data) return data;
18
+ const originalName = typeof originalDoc?.name === "string" ? originalDoc.name : void 0;
19
+ const incomingName = typeof data.name === "string" ? data.name : void 0;
20
+ const role = byName.get(originalName ?? "") ?? byName.get(incomingName ?? "");
21
+ if (!role) return data;
22
+ if (originalName === role.name && incomingName !== void 0 && incomingName !== role.name) throw new APIError(`The role "${role.name}" is protected and cannot be renamed.`, 403);
23
+ if ("permissions" in data && !samePermissions(data.permissions, role.permissions)) throw new APIError(`The role "${role.name}" is protected — its permissions are defined in code and cannot be changed here.`, 403);
24
+ return data;
25
+ };
26
+ };
27
+ /**
28
+ * Blocks deleting protected roles through the API — deleting the only full-access
29
+ * role would lock everyone out just as surely as downgrading it.
30
+ */
31
+ const createProtectedRolesDeleteHook = ({ protectedRoleNames, rolesCollectionSlug }) => {
32
+ const names = new Set(protectedRoleNames);
33
+ return async ({ id, req }) => {
34
+ if (!req.user) return;
35
+ const doc = await req.payload.findByID({
36
+ id,
37
+ collection: rolesCollectionSlug,
38
+ depth: 0,
39
+ disableErrors: true,
40
+ overrideAccess: true,
41
+ req
42
+ });
43
+ const name = typeof doc?.name === "string" ? doc.name : void 0;
44
+ if (name !== void 0 && names.has(name)) throw new APIError(`The role "${name}" is protected and cannot be deleted.`, 403);
45
+ };
46
+ };
47
+
48
+ //#endregion
49
+ export { createProtectedRolesChangeHook, createProtectedRolesDeleteHook };
50
+ //# sourceMappingURL=protectedRoles.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"protectedRoles.js","names":[],"sources":["../../src/hooks/protectedRoles.ts"],"sourcesContent":["import type { CollectionBeforeChangeHook, CollectionBeforeDeleteHook } from 'payload'\n\nimport { APIError } from 'payload'\n\nimport type { PredefinedRole } from '../types.js'\n\nimport { samePermissions } from '../permissions.js'\n\nexport type ProtectedRolesChangeArgs = {\n protectedRoles: PredefinedRole[]\n}\n\nexport type ProtectedRolesDeleteArgs = {\n protectedRoleNames: string[]\n rolesCollectionSlug: string\n}\n\n/**\n * Locks protected roles to their code definition: they cannot be renamed and their\n * permissions cannot be changed through the API — the only permissions write\n * accepted is restoring the exact code-defined list, so a drifted role can always\n * be repaired but never downgraded. Applies regardless of the caller's own\n * permissions (even `'*'`): the point is that the last full-access role can never\n * be sawed off, leaving nobody able to grant permissions back. Writes without a\n * user (seeding, local API scripts) pass through.\n */\nexport const createProtectedRolesChangeHook = ({\n protectedRoles,\n}: ProtectedRolesChangeArgs): CollectionBeforeChangeHook => {\n const byName = new Map(protectedRoles.map((role) => [role.name, role]))\n return ({ data, originalDoc, req }) => {\n if (!req.user || !data) {\n return data\n }\n\n const originalName = typeof originalDoc?.name === 'string' ? originalDoc.name : undefined\n const incomingName = typeof data.name === 'string' ? data.name : undefined\n const role = byName.get(originalName ?? '') ?? byName.get(incomingName ?? '')\n if (!role) {\n return data\n }\n\n if (originalName === role.name && incomingName !== undefined && incomingName !== role.name) {\n throw new APIError(`The role \"${role.name}\" is protected and cannot be renamed.`, 403)\n }\n if ('permissions' in data && !samePermissions(data.permissions, role.permissions)) {\n throw new APIError(\n `The role \"${role.name}\" is protected — its permissions are defined in code and cannot be changed here.`,\n 403,\n )\n }\n\n return data\n }\n}\n\n/**\n * Blocks deleting protected roles through the API — deleting the only full-access\n * role would lock everyone out just as surely as downgrading it.\n */\nexport const createProtectedRolesDeleteHook = ({\n protectedRoleNames,\n rolesCollectionSlug,\n}: ProtectedRolesDeleteArgs): CollectionBeforeDeleteHook => {\n const names = new Set(protectedRoleNames)\n return async ({ id, req }) => {\n if (!req.user) {\n return\n }\n const doc = (await req.payload.findByID({\n id,\n collection: rolesCollectionSlug,\n depth: 0,\n disableErrors: true,\n overrideAccess: true,\n req,\n })) as { name?: unknown } | null\n const name = typeof doc?.name === 'string' ? doc.name : undefined\n if (name !== undefined && names.has(name)) {\n throw new APIError(`The role \"${name}\" is protected and cannot be deleted.`, 403)\n }\n }\n}\n"],"mappings":";;;;;;;;;;;;;AA0BA,MAAa,kCAAkC,EAC7C,qBAC0D;CAC1D,MAAM,SAAS,IAAI,IAAI,eAAe,KAAK,SAAS,CAAC,KAAK,MAAM,KAAK,CAAC,CAAC;AACvE,SAAQ,EAAE,MAAM,aAAa,UAAU;AACrC,MAAI,CAAC,IAAI,QAAQ,CAAC,KAChB,QAAO;EAGT,MAAM,eAAe,OAAO,aAAa,SAAS,WAAW,YAAY,OAAO;EAChF,MAAM,eAAe,OAAO,KAAK,SAAS,WAAW,KAAK,OAAO;EACjE,MAAM,OAAO,OAAO,IAAI,gBAAgB,GAAG,IAAI,OAAO,IAAI,gBAAgB,GAAG;AAC7E,MAAI,CAAC,KACH,QAAO;AAGT,MAAI,iBAAiB,KAAK,QAAQ,iBAAiB,UAAa,iBAAiB,KAAK,KACpF,OAAM,IAAI,SAAS,aAAa,KAAK,KAAK,wCAAwC,IAAI;AAExF,MAAI,iBAAiB,QAAQ,CAAC,gBAAgB,KAAK,aAAa,KAAK,YAAY,CAC/E,OAAM,IAAI,SACR,aAAa,KAAK,KAAK,mFACvB,IACD;AAGH,SAAO;;;;;;;AAQX,MAAa,kCAAkC,EAC7C,oBACA,0BAC0D;CAC1D,MAAM,QAAQ,IAAI,IAAI,mBAAmB;AACzC,QAAO,OAAO,EAAE,IAAI,UAAU;AAC5B,MAAI,CAAC,IAAI,KACP;EAEF,MAAM,MAAO,MAAM,IAAI,QAAQ,SAAS;GACtC;GACA,YAAY;GACZ,OAAO;GACP,eAAe;GACf,gBAAgB;GAChB;GACD,CAAC;EACF,MAAM,OAAO,OAAO,KAAK,SAAS,WAAW,IAAI,OAAO;AACxD,MAAI,SAAS,UAAa,MAAM,IAAI,KAAK,CACvC,OAAM,IAAI,SAAS,aAAa,KAAK,wCAAwC,IAAI"}
@@ -0,0 +1,19 @@
1
+ import { MatrixRow, RbacAction, RbacCustomConfig, collectionActions, getRbacCustomConfig, globalActions, permissionsMatrixFieldPath, pluginKey } from "./shared.js";
2
+ import { CreateRbacAccessArgs, createRbacAccess, requirePermission } from "./access/rbacAccess.js";
3
+ import { CreateRolesFieldAccessArgs, createRolesFieldAccess } from "./access/rolesFieldAccess.js";
4
+ import { CreateRolesCollectionArgs, createRolesCollection } from "./collections/createRolesCollection.js";
5
+ import { CreateRolesFieldArgs, createRolesField } from "./fields/createRolesField.js";
6
+ import { AssignFirstUserRoleArgs, createAssignFirstUserRoleHook } from "./hooks/assignFirstUserRole.js";
7
+ import { ProtectCredentialsArgs, createProtectCredentialsHook } from "./hooks/protectCredentials.js";
8
+ import { PredefinedRole, RbacEntitySelection, RbacPermission, RbacPluginConfig } from "./types.js";
9
+ import { ProtectedRolesChangeArgs, ProtectedRolesDeleteArgs, createProtectedRolesChangeHook, createProtectedRolesDeleteHook } from "./hooks/protectedRoles.js";
10
+ import { ProtectLastAdminArgs, createProtectLastAdminChangeHook, createProtectLastAdminDeleteHook } from "./hooks/protectLastAdmin.js";
11
+ import { ProtectRolesCollectionArgs, createProtectRolesCollectionHook } from "./hooks/protectRolesCollection.js";
12
+ import { ProtectRolesFieldArgs, createProtectRolesFieldHook, normalizeRoleIds } from "./hooks/protectRolesField.js";
13
+ import { FULL_ACCESS, missingPermissions, permissionFor, permissionsGrant, samePermissions } from "./permissions.js";
14
+ import { rbacPlugin } from "./plugin.js";
15
+ import { SeedPredefinedRolesArgs, seedPredefinedRoles } from "./seed.js";
16
+ import { entityLabel } from "./utilities/entityLabel.js";
17
+ import { RoleHolderQueryArgs, WarnIfAdminRoleUnheldArgs, anyUserHoldsRole, findFullAccessRoleIds, warnIfAdminRoleUnheld } from "./utilities/fullAccessHolders.js";
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 };
package/dist/index.js ADDED
@@ -0,0 +1,19 @@
1
+ import { FULL_ACCESS, missingPermissions, permissionFor, permissionsGrant, samePermissions } from "./permissions.js";
2
+ import { collectionActions, getRbacCustomConfig, globalActions, permissionsMatrixFieldPath, pluginKey } from "./shared.js";
3
+ import { getUserPermissions, hasPermission } from "./utilities/getUserPermissions.js";
4
+ import { createRbacAccess, requirePermission } from "./access/rbacAccess.js";
5
+ import { anyUserHoldsRole, findFullAccessRoleIds, warnIfAdminRoleUnheld } from "./utilities/fullAccessHolders.js";
6
+ import { createRolesFieldAccess } from "./access/rolesFieldAccess.js";
7
+ import { createRolesCollection } from "./collections/createRolesCollection.js";
8
+ import { createRolesField } from "./fields/createRolesField.js";
9
+ import { createAssignFirstUserRoleHook } from "./hooks/assignFirstUserRole.js";
10
+ import { createProtectRolesFieldHook, normalizeRoleIds } from "./hooks/protectRolesField.js";
11
+ import { createProtectCredentialsHook } from "./hooks/protectCredentials.js";
12
+ import { createProtectedRolesChangeHook, createProtectedRolesDeleteHook } from "./hooks/protectedRoles.js";
13
+ import { createProtectLastAdminChangeHook, createProtectLastAdminDeleteHook } from "./hooks/protectLastAdmin.js";
14
+ import { createProtectRolesCollectionHook } from "./hooks/protectRolesCollection.js";
15
+ import { seedPredefinedRoles } from "./seed.js";
16
+ import { entityLabel } from "./utilities/entityLabel.js";
17
+ import { rbacPlugin } from "./plugin.js";
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 };
@@ -0,0 +1,23 @@
1
+ import { RbacAction } from "./shared.js";
2
+
3
+ //#region src/permissions.d.ts
4
+ /** Grants every action on every controlled collection and global. */
5
+ declare const FULL_ACCESS = "*";
6
+ /** Builds the permission string for one action on one collection or global. */
7
+ declare const permissionFor: (slug: string, action: RbacAction) => string;
8
+ /** Whether a permission set grants an action on a collection or global. */
9
+ declare const permissionsGrant: (permissions: ReadonlySet<string>, slug: string, action: RbacAction) => boolean;
10
+ /**
11
+ * Order-insensitive equality between a stored permissions value (unknown shape —
12
+ * may be missing or malformed) and a code-defined permission list. Used by the
13
+ * protected-role guard and the drift repair in seeding.
14
+ */
15
+ declare const samePermissions: (value: unknown, permissions: readonly string[]) => boolean;
16
+ /**
17
+ * The subset of `required` not covered by `granted`. Used by the privilege-escalation
18
+ * guards: an empty result means the grantor holds everything they are handing out.
19
+ */
20
+ declare const missingPermissions: (granted: ReadonlySet<string>, required: readonly string[]) => string[];
21
+ //#endregion
22
+ export { FULL_ACCESS, missingPermissions, permissionFor, permissionsGrant, samePermissions };
23
+ //# sourceMappingURL=permissions.d.ts.map
@@ -0,0 +1,31 @@
1
+ //#region src/permissions.ts
2
+ /** Grants every action on every controlled collection and global. */
3
+ const FULL_ACCESS = "*";
4
+ /** Builds the permission string for one action on one collection or global. */
5
+ const permissionFor = (slug, action) => `${slug}:${action}`;
6
+ /** Whether a permission set grants an action on a collection or global. */
7
+ const permissionsGrant = (permissions, slug, action) => {
8
+ return permissions.has(FULL_ACCESS) || permissions.has(permissionFor(slug, action));
9
+ };
10
+ /**
11
+ * Order-insensitive equality between a stored permissions value (unknown shape —
12
+ * may be missing or malformed) and a code-defined permission list. Used by the
13
+ * protected-role guard and the drift repair in seeding.
14
+ */
15
+ const samePermissions = (value, permissions) => {
16
+ const stored = new Set(Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : []);
17
+ const expected = new Set(permissions);
18
+ return stored.size === expected.size && [...expected].every((entry) => stored.has(entry));
19
+ };
20
+ /**
21
+ * The subset of `required` not covered by `granted`. Used by the privilege-escalation
22
+ * guards: an empty result means the grantor holds everything they are handing out.
23
+ */
24
+ const missingPermissions = (granted, required) => {
25
+ if (granted.has(FULL_ACCESS)) return [];
26
+ return required.filter((permission) => !granted.has(permission));
27
+ };
28
+
29
+ //#endregion
30
+ export { FULL_ACCESS, missingPermissions, permissionFor, permissionsGrant, samePermissions };
31
+ //# sourceMappingURL=permissions.js.map
@@ -0,0 +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"}
@@ -0,0 +1,8 @@
1
+ import { RbacPluginConfig } from "./types.js";
2
+ import { Plugin } from "payload";
3
+
4
+ //#region src/plugin.d.ts
5
+ declare const rbacPlugin: (pluginConfig?: RbacPluginConfig) => Plugin;
6
+ //#endregion
7
+ export { rbacPlugin };
8
+ //# sourceMappingURL=plugin.d.ts.map