@whatworks/payload-rbac 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +243 -0
  3. package/dist/access/rbacAccess.d.ts +33 -0
  4. package/dist/access/rbacAccess.js +34 -0
  5. package/dist/access/rbacAccess.js.map +1 -0
  6. package/dist/access/rolesFieldAccess.d.ts +33 -0
  7. package/dist/access/rolesFieldAccess.js +31 -0
  8. package/dist/access/rolesFieldAccess.js.map +1 -0
  9. package/dist/collections/createRolesCollection.d.ts +30 -0
  10. package/dist/collections/createRolesCollection.js +63 -0
  11. package/dist/collections/createRolesCollection.js.map +1 -0
  12. package/dist/exports/client.d.ts +2 -0
  13. package/dist/exports/client.js +5 -0
  14. package/dist/fields/PermissionsMatrixField.d.ts +21 -0
  15. package/dist/fields/PermissionsMatrixField.js +180 -0
  16. package/dist/fields/PermissionsMatrixField.js.map +1 -0
  17. package/dist/fields/createRolesField.d.ts +23 -0
  18. package/dist/fields/createRolesField.js +27 -0
  19. package/dist/fields/createRolesField.js.map +1 -0
  20. package/dist/hooks/assignFirstUserRole.d.ts +22 -0
  21. package/dist/hooks/assignFirstUserRole.js +40 -0
  22. package/dist/hooks/assignFirstUserRole.js.map +1 -0
  23. package/dist/hooks/protectCredentials.d.ts +29 -0
  24. package/dist/hooks/protectCredentials.js +43 -0
  25. package/dist/hooks/protectCredentials.js.map +1 -0
  26. package/dist/hooks/protectLastAdmin.d.ts +28 -0
  27. package/dist/hooks/protectLastAdmin.js +76 -0
  28. package/dist/hooks/protectLastAdmin.js.map +1 -0
  29. package/dist/hooks/protectRolesCollection.d.ts +25 -0
  30. package/dist/hooks/protectRolesCollection.js +30 -0
  31. package/dist/hooks/protectRolesCollection.js.map +1 -0
  32. package/dist/hooks/protectRolesField.d.ts +57 -0
  33. package/dist/hooks/protectRolesField.js +99 -0
  34. package/dist/hooks/protectRolesField.js.map +1 -0
  35. package/dist/hooks/protectedRoles.d.ts +34 -0
  36. package/dist/hooks/protectedRoles.js +50 -0
  37. package/dist/hooks/protectedRoles.js.map +1 -0
  38. package/dist/index.d.ts +19 -0
  39. package/dist/index.js +19 -0
  40. package/dist/permissions.d.ts +23 -0
  41. package/dist/permissions.js +31 -0
  42. package/dist/permissions.js.map +1 -0
  43. package/dist/plugin.d.ts +8 -0
  44. package/dist/plugin.js +255 -0
  45. package/dist/plugin.js.map +1 -0
  46. package/dist/seed.d.ts +24 -0
  47. package/dist/seed.js +47 -0
  48. package/dist/seed.js.map +1 -0
  49. package/dist/shared.d.ts +38 -0
  50. package/dist/shared.js +19 -0
  51. package/dist/shared.js.map +1 -0
  52. package/dist/types.d.ts +194 -0
  53. package/dist/utilities/entityLabel.d.ts +10 -0
  54. package/dist/utilities/entityLabel.js +17 -0
  55. package/dist/utilities/entityLabel.js.map +1 -0
  56. package/dist/utilities/fullAccessHolders.d.ts +36 -0
  57. package/dist/utilities/fullAccessHolders.js +68 -0
  58. package/dist/utilities/fullAccessHolders.js.map +1 -0
  59. package/dist/utilities/getUserPermissions.d.ts +17 -0
  60. package/dist/utilities/getUserPermissions.js +72 -0
  61. package/dist/utilities/getUserPermissions.js.map +1 -0
  62. package/package.json +88 -0
package/dist/plugin.js ADDED
@@ -0,0 +1,255 @@
1
+ import { FULL_ACCESS, permissionFor } from "./permissions.js";
2
+ import { collectionActions, globalActions, pluginKey } from "./shared.js";
3
+ import { createRbacAccess } from "./access/rbacAccess.js";
4
+ import { anyUserHoldsRole, findFullAccessRoleIds, warnIfAdminRoleUnheld } from "./utilities/fullAccessHolders.js";
5
+ import { createRolesFieldAccess } from "./access/rolesFieldAccess.js";
6
+ import { createRolesCollection } from "./collections/createRolesCollection.js";
7
+ import { createRolesField } from "./fields/createRolesField.js";
8
+ import { createAssignFirstUserRoleHook } from "./hooks/assignFirstUserRole.js";
9
+ import { createProtectRolesFieldHook } from "./hooks/protectRolesField.js";
10
+ import { createProtectCredentialsHook } from "./hooks/protectCredentials.js";
11
+ import { createProtectedRolesChangeHook, createProtectedRolesDeleteHook } from "./hooks/protectedRoles.js";
12
+ import { createProtectLastAdminChangeHook, createProtectLastAdminDeleteHook } from "./hooks/protectLastAdmin.js";
13
+ import { createProtectRolesCollectionHook } from "./hooks/protectRolesCollection.js";
14
+ import { seedPredefinedRoles } from "./seed.js";
15
+ import { entityLabel } from "./utilities/entityLabel.js";
16
+
17
+ //#region src/plugin.ts
18
+ const createSelector = (selection) => {
19
+ if (selection === void 0 || selection === true) return () => true;
20
+ if (Array.isArray(selection)) {
21
+ const included = new Set(selection);
22
+ return (slug) => included.has(slug);
23
+ }
24
+ const excluded = new Set(selection.exclude);
25
+ return (slug) => !excluded.has(slug);
26
+ };
27
+ const hasNamedField = (fields, name) => {
28
+ return fields.some((field) => "name" in field && field.name === name);
29
+ };
30
+ const resolveUserCollections = (config, pluginConfig) => {
31
+ if (pluginConfig.userCollections?.length) return pluginConfig.userCollections;
32
+ const authSlugs = (config.collections ?? []).filter((collection) => Boolean(collection.auth)).map((collection) => collection.slug);
33
+ if (authSlugs.length) return authSlugs;
34
+ return [config.admin?.user ?? "users"];
35
+ };
36
+ const rbacPlugin = (pluginConfig = {}) => {
37
+ return (incomingConfig) => {
38
+ const config = { ...incomingConfig };
39
+ if (pluginConfig.enabled === false) return config;
40
+ const rolesCollectionSlug = pluginConfig.rolesCollection?.slug ?? "roles";
41
+ const rolesFieldName = pluginConfig.rolesField?.name ?? "roles";
42
+ const preventEscalation = pluginConfig.preventPrivilegeEscalation !== false;
43
+ const ownAccountActions = pluginConfig.ownAccountAccess === false ? [] : pluginConfig.ownAccountAccess ?? ["read", "update"];
44
+ const adminRole = !pluginConfig.adminRole || typeof pluginConfig.adminRole === "string" ? { name: pluginConfig.adminRole || void 0 } : pluginConfig.adminRole;
45
+ if (adminRole.name && (pluginConfig.roles ?? []).some((role) => role.name === adminRole.name)) throw new Error(`[payload-rbac] "${adminRole.name}" is the adminRole and is defined by the plugin — it always has permissions ['*'] and is protected. Remove it from roles, or use a different name.`);
46
+ const predefinedRoles = [...adminRole.name ? [{
47
+ name: adminRole.name,
48
+ credentialChanges: "self",
49
+ description: adminRole.description ?? "Full access to everything.",
50
+ permissions: [FULL_ACCESS],
51
+ protected: true
52
+ }] : [], ...(pluginConfig.roles ?? []).map((role) => ({
53
+ ...role,
54
+ protected: role.protected ?? false
55
+ }))];
56
+ const protectedRoles = predefinedRoles.filter((role) => role.protected);
57
+ const protectedRoleNames = protectedRoles.map((role) => role.name);
58
+ const selfOnlyCredentialRoleNames = predefinedRoles.filter((role) => role.credentialChanges === "self").map((role) => role.name);
59
+ if ((config.collections ?? []).some((collection) => collection.slug === rolesCollectionSlug)) throw new Error(`[payload-rbac] A collection with the slug "${rolesCollectionSlug}" already exists. Pass rolesCollection.slug to use a different slug for the roles collection.`);
60
+ const shouldControlCollection = createSelector(pluginConfig.collections);
61
+ const shouldControlGlobal = createSelector(pluginConfig.globals);
62
+ const userCollections = resolveUserCollections(config, pluginConfig);
63
+ const adminUserCollection = config.admin?.user ?? "users";
64
+ const matrixRows = [
65
+ ...(config.collections ?? []).filter((collection) => shouldControlCollection(collection.slug)).map((collection) => ({
66
+ slug: collection.slug,
67
+ actions: collectionActions,
68
+ entity: "collection",
69
+ label: entityLabel(collection.labels?.plural, collection.slug)
70
+ })),
71
+ {
72
+ slug: rolesCollectionSlug,
73
+ actions: collectionActions,
74
+ entity: "collection",
75
+ label: "Roles"
76
+ },
77
+ ...(config.globals ?? []).filter((global) => shouldControlGlobal(global.slug)).map((global) => ({
78
+ slug: global.slug,
79
+ actions: globalActions,
80
+ entity: "global",
81
+ label: entityLabel(global.label, global.slug)
82
+ }))
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).`);
86
+ const withCollectionAccess = (collection) => {
87
+ const access = { ...collection.access };
88
+ for (const action of collectionActions) if (access[action] === void 0) access[action] = createRbacAccess({
89
+ slug: collection.slug,
90
+ action,
91
+ ownAccountActions
92
+ });
93
+ if (access.readVersions === void 0) access.readVersions = createRbacAccess({
94
+ slug: collection.slug,
95
+ action: "read"
96
+ });
97
+ if (access.unlock === void 0) access.unlock = createRbacAccess({
98
+ slug: collection.slug,
99
+ action: "update"
100
+ });
101
+ return {
102
+ ...collection,
103
+ access
104
+ };
105
+ };
106
+ const withGlobalAccess = (global) => {
107
+ const access = { ...global.access };
108
+ for (const action of globalActions) if (access[action] === void 0) access[action] = createRbacAccess({
109
+ slug: global.slug,
110
+ action
111
+ });
112
+ if (access.readVersions === void 0) access.readVersions = createRbacAccess({
113
+ slug: global.slug,
114
+ action: "read"
115
+ });
116
+ return {
117
+ ...global,
118
+ access
119
+ };
120
+ };
121
+ const rolesFieldAccess = createRolesFieldAccess({
122
+ rolesCollectionSlug,
123
+ rolesFieldName,
124
+ ...adminRole.name ? { breakGlass: { userCollections } } : {}
125
+ });
126
+ const withRolesField = (collection) => {
127
+ const fields = hasNamedField(collection.fields, rolesFieldName) ? collection.fields : [...collection.fields, createRolesField({
128
+ name: rolesFieldName,
129
+ access: {
130
+ create: rolesFieldAccess,
131
+ update: rolesFieldAccess
132
+ },
133
+ override: pluginConfig.rolesField?.override,
134
+ rolesCollectionSlug
135
+ })];
136
+ const beforeChange = [...collection.hooks?.beforeChange ?? []];
137
+ const beforeDelete = [...collection.hooks?.beforeDelete ?? []];
138
+ if (selfOnlyCredentialRoleNames.length > 0) beforeChange.push(createProtectCredentialsHook({
139
+ rolesCollectionSlug,
140
+ rolesFieldName,
141
+ selfOnlyRoleNames: selfOnlyCredentialRoleNames,
142
+ userCollectionSlug: collection.slug
143
+ }));
144
+ if (preventEscalation || adminRole.name) beforeChange.push(createProtectRolesFieldHook({
145
+ preventEscalation,
146
+ rolesCollectionSlug,
147
+ rolesFieldName,
148
+ ...adminRole.name ? {
149
+ breakGlass: {
150
+ adminRoleName: adminRole.name,
151
+ userCollections
152
+ },
153
+ holderOnly: {
154
+ roleNames: [adminRole.name],
155
+ userCollections
156
+ }
157
+ } : {}
158
+ }));
159
+ if (adminRole.name) {
160
+ const protectLastAdminArgs = {
161
+ adminRoleName: adminRole.name,
162
+ rolesCollectionSlug,
163
+ rolesFieldName,
164
+ userCollections,
165
+ userCollectionSlug: collection.slug
166
+ };
167
+ beforeChange.push(createProtectLastAdminChangeHook(protectLastAdminArgs));
168
+ beforeDelete.push(createProtectLastAdminDeleteHook(protectLastAdminArgs));
169
+ }
170
+ if (adminRole.name && collection.slug === adminUserCollection) beforeChange.push(createAssignFirstUserRoleHook({
171
+ firstUserRole: adminRole.name,
172
+ rolesCollectionSlug,
173
+ rolesFieldName
174
+ }));
175
+ return {
176
+ ...collection,
177
+ fields,
178
+ hooks: {
179
+ ...collection.hooks,
180
+ beforeChange,
181
+ beforeDelete
182
+ }
183
+ };
184
+ };
185
+ const rolesAccess = {};
186
+ for (const action of collectionActions) rolesAccess[action] = createRbacAccess({
187
+ slug: rolesCollectionSlug,
188
+ action
189
+ });
190
+ if (adminRole.name) {
191
+ const baseRead = rolesAccess.read;
192
+ rolesAccess.read = async (args) => {
193
+ const base = await baseRead?.(args);
194
+ if (base || !args.req.user) return base ?? false;
195
+ const fullAccessRoleIds = await findFullAccessRoleIds(args.req.payload, rolesCollectionSlug);
196
+ return !await anyUserHoldsRole(args.req.payload, fullAccessRoleIds, {
197
+ rolesFieldName,
198
+ userCollections
199
+ });
200
+ };
201
+ }
202
+ const rolesBeforeChange = [];
203
+ if (protectedRoles.length > 0) rolesBeforeChange.push(createProtectedRolesChangeHook({ protectedRoles }));
204
+ if (preventEscalation) rolesBeforeChange.push(createProtectRolesCollectionHook({ protectedRoleNames }));
205
+ const rolesHooks = {
206
+ ...rolesBeforeChange.length > 0 ? { beforeChange: rolesBeforeChange } : {},
207
+ ...protectedRoles.length > 0 ? { beforeDelete: [createProtectedRolesDeleteHook({
208
+ protectedRoleNames,
209
+ rolesCollectionSlug
210
+ })] } : {}
211
+ };
212
+ const rolesCollection = createRolesCollection({
213
+ slug: rolesCollectionSlug,
214
+ access: rolesAccess,
215
+ hooks: Object.keys(rolesHooks).length > 0 ? rolesHooks : void 0,
216
+ matrixRows,
217
+ override: pluginConfig.rolesCollection?.override,
218
+ protectedRoleNames
219
+ });
220
+ config.collections = [...(config.collections ?? []).map((collection) => {
221
+ let result = shouldControlCollection(collection.slug) ? withCollectionAccess(collection) : collection;
222
+ if (userCollections.includes(collection.slug)) result = withRolesField(result);
223
+ return result;
224
+ }), rolesCollection];
225
+ config.globals = (config.globals ?? []).map((global) => shouldControlGlobal(global.slug) ? withGlobalAccess(global) : global);
226
+ const incomingOnInit = config.onInit;
227
+ config.onInit = async (payload) => {
228
+ if (predefinedRoles.length > 0) await seedPredefinedRoles(payload, {
229
+ roles: predefinedRoles,
230
+ rolesCollectionSlug
231
+ });
232
+ if (adminRole.name) await warnIfAdminRoleUnheld(payload, {
233
+ adminRoleName: adminRole.name,
234
+ rolesCollectionSlug,
235
+ rolesFieldName,
236
+ userCollections
237
+ });
238
+ await incomingOnInit?.(payload);
239
+ };
240
+ const customConfig = {
241
+ rolesCollectionSlug,
242
+ rolesFieldName,
243
+ userCollections
244
+ };
245
+ config.custom = {
246
+ ...config.custom,
247
+ [pluginKey]: customConfig
248
+ };
249
+ return config;
250
+ };
251
+ };
252
+
253
+ //#endregion
254
+ export { rbacPlugin };
255
+ //# sourceMappingURL=plugin.js.map
@@ -0,0 +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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"}
package/dist/seed.d.ts ADDED
@@ -0,0 +1,24 @@
1
+ import { PredefinedRole } from "./types.js";
2
+ import { Payload } from "payload";
3
+
4
+ //#region src/seed.d.ts
5
+ type SeedPredefinedRolesArgs = {
6
+ roles: PredefinedRole[];
7
+ rolesCollectionSlug: string;
8
+ };
9
+ /**
10
+ * Creates each predefined role that does not exist yet, keyed by `name`. Existing
11
+ * roles are never updated or overwritten — the database is the source of truth once
12
+ * a role exists, so admin edits survive restarts — except `protected` roles, where
13
+ * code is the source of truth: drifted permissions are restored on every init, so a
14
+ * protected full-access role recovers even from direct database edits. Runs in
15
+ * `onInit` before any `onInit` defined on the config, so init seeds can rely on the
16
+ * roles being present.
17
+ */
18
+ declare const seedPredefinedRoles: (payload: Payload, {
19
+ roles,
20
+ rolesCollectionSlug
21
+ }: SeedPredefinedRolesArgs) => Promise<void>;
22
+ //#endregion
23
+ export { SeedPredefinedRolesArgs, seedPredefinedRoles };
24
+ //# sourceMappingURL=seed.d.ts.map
package/dist/seed.js ADDED
@@ -0,0 +1,47 @@
1
+ import { samePermissions } from "./permissions.js";
2
+
3
+ //#region src/seed.ts
4
+ /**
5
+ * Creates each predefined role that does not exist yet, keyed by `name`. Existing
6
+ * roles are never updated or overwritten — the database is the source of truth once
7
+ * a role exists, so admin edits survive restarts — except `protected` roles, where
8
+ * code is the source of truth: drifted permissions are restored on every init, so a
9
+ * protected full-access role recovers even from direct database edits. Runs in
10
+ * `onInit` before any `onInit` defined on the config, so init seeds can rely on the
11
+ * roles being present.
12
+ */
13
+ const seedPredefinedRoles = async (payload, { roles, rolesCollectionSlug }) => {
14
+ for (const role of roles) {
15
+ const existingDoc = (await payload.find({
16
+ collection: rolesCollectionSlug,
17
+ depth: 0,
18
+ limit: 1,
19
+ where: { name: { equals: role.name } }
20
+ })).docs[0];
21
+ if (existingDoc) {
22
+ if (role.protected && !samePermissions(existingDoc.permissions, role.permissions)) {
23
+ await payload.update({
24
+ id: existingDoc.id,
25
+ collection: rolesCollectionSlug,
26
+ data: { permissions: role.permissions },
27
+ depth: 0
28
+ });
29
+ payload.logger.info(`[payload-rbac] Restored permissions of protected role "${role.name}"`);
30
+ }
31
+ continue;
32
+ }
33
+ await payload.create({
34
+ collection: rolesCollectionSlug,
35
+ data: {
36
+ name: role.name,
37
+ description: role.description,
38
+ permissions: role.permissions
39
+ }
40
+ });
41
+ payload.logger.info(`[payload-rbac] Seeded role "${role.name}"`);
42
+ }
43
+ };
44
+
45
+ //#endregion
46
+ export { seedPredefinedRoles };
47
+ //# sourceMappingURL=seed.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"seed.js","names":[],"sources":["../src/seed.ts"],"sourcesContent":["import type { Payload } from 'payload'\n\nimport type { PredefinedRole } from './types.js'\n\nimport { samePermissions } from './permissions.js'\n\nexport type SeedPredefinedRolesArgs = {\n roles: PredefinedRole[]\n rolesCollectionSlug: string\n}\n\n/**\n * Creates each predefined role that does not exist yet, keyed by `name`. Existing\n * roles are never updated or overwritten — the database is the source of truth once\n * a role exists, so admin edits survive restarts — except `protected` roles, where\n * code is the source of truth: drifted permissions are restored on every init, so a\n * protected full-access role recovers even from direct database edits. Runs in\n * `onInit` before any `onInit` defined on the config, so init seeds can rely on the\n * roles being present.\n */\nexport const seedPredefinedRoles = async (\n payload: Payload,\n { roles, rolesCollectionSlug }: SeedPredefinedRolesArgs,\n): Promise<void> => {\n for (const role of roles) {\n const existing = await payload.find({\n collection: rolesCollectionSlug,\n depth: 0,\n limit: 1,\n where: { name: { equals: role.name } },\n })\n const existingDoc = existing.docs[0] as\n | { id: number | string; permissions?: unknown }\n | undefined\n if (existingDoc) {\n if (role.protected && !samePermissions(existingDoc.permissions, role.permissions)) {\n await payload.update({\n id: existingDoc.id,\n collection: rolesCollectionSlug,\n data: { permissions: role.permissions },\n depth: 0,\n })\n payload.logger.info(`[payload-rbac] Restored permissions of protected role \"${role.name}\"`)\n }\n continue\n }\n\n await payload.create({\n collection: rolesCollectionSlug,\n data: {\n name: role.name,\n description: role.description,\n permissions: role.permissions,\n },\n })\n payload.logger.info(`[payload-rbac] Seeded role \"${role.name}\"`)\n }\n}\n"],"mappings":";;;;;;;;;;;;AAoBA,MAAa,sBAAsB,OACjC,SACA,EAAE,OAAO,0BACS;AAClB,MAAK,MAAM,QAAQ,OAAO;EAOxB,MAAM,eANW,MAAM,QAAQ,KAAK;GAClC,YAAY;GACZ,OAAO;GACP,OAAO;GACP,OAAO,EAAE,MAAM,EAAE,QAAQ,KAAK,MAAM,EAAE;GACvC,CAAC,EAC2B,KAAK;AAGlC,MAAI,aAAa;AACf,OAAI,KAAK,aAAa,CAAC,gBAAgB,YAAY,aAAa,KAAK,YAAY,EAAE;AACjF,UAAM,QAAQ,OAAO;KACnB,IAAI,YAAY;KAChB,YAAY;KACZ,MAAM,EAAE,aAAa,KAAK,aAAa;KACvC,OAAO;KACR,CAAC;AACF,YAAQ,OAAO,KAAK,0DAA0D,KAAK,KAAK,GAAG;;AAE7F;;AAGF,QAAM,QAAQ,OAAO;GACnB,YAAY;GACZ,MAAM;IACJ,MAAM,KAAK;IACX,aAAa,KAAK;IAClB,aAAa,KAAK;IACnB;GACF,CAAC;AACF,UAAQ,OAAO,KAAK,+BAA+B,KAAK,KAAK,GAAG"}
@@ -0,0 +1,38 @@
1
+ import { Config } from "payload";
2
+
3
+ //#region src/shared.d.ts
4
+ declare const pluginKey = "@whatworks/payload-rbac";
5
+ declare const permissionsMatrixFieldPath = "@whatworks/payload-rbac/client#PermissionsMatrixField";
6
+ /** Actions available on a collection. */
7
+ declare const collectionActions: readonly ["create", "read", "update", "delete"];
8
+ /** Actions available on a global. */
9
+ declare const globalActions: readonly ["read", "update"];
10
+ type RbacAction = (typeof collectionActions)[number];
11
+ /**
12
+ * One row of the permissions matrix shown on a role document — a collection or
13
+ * global together with the actions that can be granted on it. Serialized into the
14
+ * matrix field component's `clientProps`, so it must stay JSON-safe.
15
+ */
16
+ type MatrixRow = {
17
+ actions: readonly RbacAction[];
18
+ entity: 'collection' | 'global';
19
+ label: string;
20
+ slug: string;
21
+ };
22
+ /**
23
+ * Plugin state stored on `config.custom[pluginKey]` so exported helpers
24
+ * (`getUserPermissions`, `hasPermission`) can find the roles collection and the
25
+ * roles field without access to the plugin closure. The root `custom` key is a
26
+ * server-only config property, stripped from the client config.
27
+ */
28
+ type RbacCustomConfig = {
29
+ rolesCollectionSlug: string;
30
+ rolesFieldName: string;
31
+ userCollections: string[];
32
+ };
33
+ declare const getRbacCustomConfig: (config: {
34
+ custom?: Record<string, unknown>;
35
+ } | Config) => RbacCustomConfig | undefined;
36
+ //#endregion
37
+ export { MatrixRow, RbacAction, RbacCustomConfig, collectionActions, getRbacCustomConfig, globalActions, permissionsMatrixFieldPath, pluginKey };
38
+ //# sourceMappingURL=shared.d.ts.map
package/dist/shared.js ADDED
@@ -0,0 +1,19 @@
1
+ //#region src/shared.ts
2
+ const pluginKey = "@whatworks/payload-rbac";
3
+ const permissionsMatrixFieldPath = `${pluginKey}/client#PermissionsMatrixField`;
4
+ /** Actions available on a collection. */
5
+ const collectionActions = [
6
+ "create",
7
+ "read",
8
+ "update",
9
+ "delete"
10
+ ];
11
+ /** Actions available on a global. */
12
+ const globalActions = ["read", "update"];
13
+ const getRbacCustomConfig = (config) => {
14
+ return config.custom?.[pluginKey];
15
+ };
16
+
17
+ //#endregion
18
+ export { collectionActions, getRbacCustomConfig, globalActions, permissionsMatrixFieldPath, pluginKey };
19
+ //# sourceMappingURL=shared.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"shared.js","names":[],"sources":["../src/shared.ts"],"sourcesContent":["import type { Config } from 'payload'\n\nexport const pluginKey = '@whatworks/payload-rbac'\n\nexport const permissionsMatrixFieldPath = `${pluginKey}/client#PermissionsMatrixField`\n\n/** Actions available on a collection. */\nexport const collectionActions = ['create', 'read', 'update', 'delete'] as const\n\n/** Actions available on a global. */\nexport const globalActions = ['read', 'update'] as const\n\nexport type RbacAction = (typeof collectionActions)[number]\n\n/**\n * One row of the permissions matrix shown on a role document — a collection or\n * global together with the actions that can be granted on it. Serialized into the\n * matrix field component's `clientProps`, so it must stay JSON-safe.\n */\nexport type MatrixRow = {\n actions: readonly RbacAction[]\n entity: 'collection' | 'global'\n label: string\n slug: string\n}\n\n/**\n * Plugin state stored on `config.custom[pluginKey]` so exported helpers\n * (`getUserPermissions`, `hasPermission`) can find the roles collection and the\n * roles field without access to the plugin closure. The root `custom` key is a\n * server-only config property, stripped from the client config.\n */\nexport type RbacCustomConfig = {\n rolesCollectionSlug: string\n rolesFieldName: string\n userCollections: string[]\n}\n\nexport const getRbacCustomConfig = (\n config: { custom?: Record<string, unknown> } | Config,\n): RbacCustomConfig | undefined => {\n return config.custom?.[pluginKey] as RbacCustomConfig | undefined\n}\n"],"mappings":";AAEA,MAAa,YAAY;AAEzB,MAAa,6BAA6B,GAAG,UAAU;;AAGvD,MAAa,oBAAoB;CAAC;CAAU;CAAQ;CAAU;CAAS;;AAGvE,MAAa,gBAAgB,CAAC,QAAQ,SAAS;AA4B/C,MAAa,uBACX,WACiC;AACjC,QAAO,OAAO,SAAS"}
@@ -0,0 +1,194 @@
1
+ import { RbacAction } from "./shared.js";
2
+ import { CollectionConfig, CollectionSlug, GlobalSlug, RelationshipField } from "payload";
3
+
4
+ //#region src/types.d.ts
5
+
6
+ /**
7
+ * A single grant. Either:
8
+ * - `'*'` — full access to every controlled collection and global, present and future.
9
+ * - `'<slug>:<action>'` — one action on one collection (`create`/`read`/`update`/`delete`)
10
+ * or global (`read`/`update`), e.g. `'posts:update'`.
11
+ */
12
+ type RbacPermission = string;
13
+ /**
14
+ * A role defined in code. Seeded into the roles collection on init when no role
15
+ * with the same `name` exists yet; existing roles are never overwritten (unless
16
+ * `protected`), so database edits always win.
17
+ */
18
+ type PredefinedRole = {
19
+ /**
20
+ * Who may change the credentials — password, email, and username — of users
21
+ * holding this role:
22
+ * - `'anyone'` — anyone with update access to the user (the default).
23
+ * - `'self'` — only the account owner. Everyone else gets a 403 regardless of
24
+ * their permissions and should send a password-reset email instead. Email and
25
+ * username are locked together with password deliberately: an editable email
26
+ * plus the reset flow would take the account over anyway.
27
+ *
28
+ * The role defined by `adminRole` is always `'self'`. The protection follows
29
+ * the role name, so pair `'self'` with `protected: true` to prevent the role
30
+ * being renamed away from it.
31
+ *
32
+ * @default 'anyone'
33
+ */
34
+ credentialChanges?: 'anyone' | 'self';
35
+ description?: string;
36
+ /**
37
+ * Unique role name, used as the seeding key and shown as the document title in
38
+ * the admin panel, so human-friendly names like `'Editor'` are encouraged.
39
+ */
40
+ name: string;
41
+ permissions: RbacPermission[];
42
+ /**
43
+ * Locks the role to this code definition. A protected role cannot be renamed,
44
+ * have its permissions changed, or be deleted through the API — the only
45
+ * permissions write the API accepts is restoring this exact list — and drifted
46
+ * permissions are repaired on init. To change a protected role's permissions,
47
+ * change this definition and restart. The role defined by `adminRole` is always
48
+ * protected; use this to additionally lock other roles.
49
+ *
50
+ * @default false
51
+ */
52
+ protected?: boolean;
53
+ };
54
+ /**
55
+ * Which collections/globals the plugin applies to.
56
+ * - `true` — every entity in the config (the default).
57
+ * - `string[]` — only these slugs.
58
+ * - `{ exclude }` — every entity except these slugs.
59
+ */
60
+ type RbacEntitySelection<TSlug extends string> = {
61
+ exclude: TSlug[];
62
+ } | true | TSlug[];
63
+ type RbacPluginConfig = {
64
+ /**
65
+ * The built-in administrator role, passed as a role name or `{ name, description }`.
66
+ * When set, the plugin defines a role with this name that always has full access
67
+ * (`permissions: ['*']`) and is always protected — it can never be downgraded,
68
+ * renamed, or deleted through the API, and drifted permissions are repaired on
69
+ * init — so there is always a role that can reach everything. It is auto-assigned
70
+ * to the first user created in the admin user collection (`admin.user`) by an
71
+ * unauthenticated request — the admin "create first user" screen or an init
72
+ * seed — so a fresh project never locks you out. Its holders' credentials are
73
+ * protected too: the role's `credentialChanges` is always `'self'`, so only
74
+ * each holder can change their own password, email, or username — another
75
+ * user with `users:update` cannot take the account over. And only holders can
76
+ * assign the role: full access through another role is not enough, so users
77
+ * below the admin tier can never join it on their own. (While nobody holds
78
+ * the role at all — a fresh rename, or the plugin newly added to an existing
79
+ * project — any user whose permissions cover it may step up; init logs a
80
+ * warning until someone does.) The plugin also guarantees at
81
+ * least one user always holds this role: removing it from — or deleting — the
82
+ * last holder is blocked. And should the system somehow end up with no
83
+ * administrator at all (the roles collection wiped at the database level, or
84
+ * this option renamed so a fresh role was seeded with no holders), any
85
+ * signed-in user may assign this role to themselves — the escalation guard
86
+ * permits exactly that write while no user holds full access, and init logs a
87
+ * warning describing the state. Do not also list it in `roles`; its definition
88
+ * is owned by the plugin.
89
+ *
90
+ * @default false
91
+ */
92
+ adminRole?: {
93
+ description?: string;
94
+ name: string;
95
+ } | false | string;
96
+ /**
97
+ * Which collections are access-controlled and appear in the permissions matrix.
98
+ *
99
+ * Defaults to `true`: every collection present in the config when the plugin runs —
100
+ * your own collections plus any added by plugins registered before this one, so
101
+ * register this plugin last. Payload's internal collections (`payload-preferences`,
102
+ * `payload-migrations`, `payload-locked-documents`, `payload-folders`, …) are created
103
+ * after plugins run and are never included. The roles collection itself is always
104
+ * controlled.
105
+ *
106
+ * @default true
107
+ */
108
+ collections?: RbacEntitySelection<CollectionSlug>;
109
+ /**
110
+ * Enables or disables the plugin.
111
+ *
112
+ * @default true
113
+ */
114
+ enabled?: boolean;
115
+ /**
116
+ * Which globals are access-controlled and appear in the permissions matrix
117
+ * (`read`/`update`). Same semantics as `collections`.
118
+ *
119
+ * @default true
120
+ */
121
+ globals?: RbacEntitySelection<GlobalSlug>;
122
+ /**
123
+ * Let every user read/update their own user document even without the matching
124
+ * collection permission — the admin account view breaks without self-read. Set to
125
+ * `false` (or narrow to `['read']`) to disable. The roles field stays out of reach
126
+ * regardless: changing it requires the `roles:update` permission, so users cannot
127
+ * remove their own roles by accident.
128
+ *
129
+ * @default ['read', 'update']
130
+ */
131
+ ownAccountAccess?: Exclude<RbacAction, 'create' | 'delete'>[] | false;
132
+ /**
133
+ * Blocks users from granting what they do not hold themselves: assigning a role
134
+ * whose permissions their own roles don't cover, and adding permissions to a role
135
+ * (or creating one) beyond their own. Removals mirror additions on the user's own
136
+ * account: a role can only be removed from yourself when the roles you keep cover
137
+ * its permissions — otherwise you could never assign it back, and one save would
138
+ * have locked you out. Users with `'*'` can grant anything. Writes without a user
139
+ * (local API seeds, init scripts, the first-user registration) are not restricted.
140
+ *
141
+ * @default true
142
+ */
143
+ preventPrivilegeEscalation?: boolean;
144
+ /**
145
+ * Roles predefined in code, seeded on init when missing. Roles marked `protected`
146
+ * are additionally locked to their code definition. The `adminRole` is defined
147
+ * separately and must not be repeated here. See {@link PredefinedRole}.
148
+ */
149
+ roles?: PredefinedRole[];
150
+ /**
151
+ * The roles collection added by the plugin.
152
+ */
153
+ rolesCollection?: {
154
+ /**
155
+ * Escape hatch to customize the generated roles collection — labels, admin group,
156
+ * extra fields, additional hooks.
157
+ */
158
+ override?: (collection: CollectionConfig) => CollectionConfig;
159
+ /**
160
+ * Slug of the roles collection.
161
+ *
162
+ * @default 'roles'
163
+ */
164
+ slug?: string;
165
+ };
166
+ /**
167
+ * The roles field added to each user collection. The generated field carries
168
+ * field-level access requiring the `roles:update` permission to change it, so
169
+ * it renders read-only in the admin panel for everyone else and API writes
170
+ * touching it without the permission are silently ignored (Payload's
171
+ * field-access semantics — the stored value is kept, no error is raised).
172
+ */
173
+ rolesField?: {
174
+ /**
175
+ * Field name.
176
+ *
177
+ * @default 'roles'
178
+ */
179
+ name?: string;
180
+ /**
181
+ * Escape hatch to customize the generated relationship field — including
182
+ * replacing its `access` to lift or change the `roles:update` requirement.
183
+ */
184
+ override?: (field: RelationshipField) => RelationshipField;
185
+ };
186
+ /**
187
+ * Auth-enabled collections that receive the roles field. Defaults to every
188
+ * auth-enabled collection in the config, falling back to `admin.user`.
189
+ */
190
+ userCollections?: CollectionSlug[];
191
+ };
192
+ //#endregion
193
+ export { PredefinedRole, RbacEntitySelection, RbacPermission, RbacPluginConfig };
194
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1,10 @@
1
+ //#region src/utilities/entityLabel.d.ts
2
+ /**
3
+ * Resolves a display label for a collection or global at plugin time. Matrix rows
4
+ * cross to the client as serialized props, so only plain-string labels can be used —
5
+ * localized objects and label functions fall back to the humanized slug.
6
+ */
7
+ declare const entityLabel: (label: unknown, slug: string) => string;
8
+ //#endregion
9
+ export { entityLabel };
10
+ //# sourceMappingURL=entityLabel.d.ts.map