@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.
- package/LICENSE.md +21 -0
- package/README.md +243 -0
- package/dist/access/rbacAccess.d.ts +33 -0
- package/dist/access/rbacAccess.js +34 -0
- package/dist/access/rbacAccess.js.map +1 -0
- package/dist/access/rolesFieldAccess.d.ts +33 -0
- package/dist/access/rolesFieldAccess.js +31 -0
- package/dist/access/rolesFieldAccess.js.map +1 -0
- package/dist/collections/createRolesCollection.d.ts +30 -0
- package/dist/collections/createRolesCollection.js +63 -0
- package/dist/collections/createRolesCollection.js.map +1 -0
- package/dist/exports/client.d.ts +2 -0
- package/dist/exports/client.js +5 -0
- package/dist/fields/PermissionsMatrixField.d.ts +21 -0
- package/dist/fields/PermissionsMatrixField.js +180 -0
- package/dist/fields/PermissionsMatrixField.js.map +1 -0
- package/dist/fields/createRolesField.d.ts +23 -0
- package/dist/fields/createRolesField.js +27 -0
- package/dist/fields/createRolesField.js.map +1 -0
- package/dist/hooks/assignFirstUserRole.d.ts +22 -0
- package/dist/hooks/assignFirstUserRole.js +40 -0
- package/dist/hooks/assignFirstUserRole.js.map +1 -0
- package/dist/hooks/protectCredentials.d.ts +29 -0
- package/dist/hooks/protectCredentials.js +43 -0
- package/dist/hooks/protectCredentials.js.map +1 -0
- package/dist/hooks/protectLastAdmin.d.ts +28 -0
- package/dist/hooks/protectLastAdmin.js +76 -0
- package/dist/hooks/protectLastAdmin.js.map +1 -0
- package/dist/hooks/protectRolesCollection.d.ts +25 -0
- package/dist/hooks/protectRolesCollection.js +30 -0
- package/dist/hooks/protectRolesCollection.js.map +1 -0
- package/dist/hooks/protectRolesField.d.ts +57 -0
- package/dist/hooks/protectRolesField.js +99 -0
- package/dist/hooks/protectRolesField.js.map +1 -0
- package/dist/hooks/protectedRoles.d.ts +34 -0
- package/dist/hooks/protectedRoles.js +50 -0
- package/dist/hooks/protectedRoles.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +19 -0
- package/dist/permissions.d.ts +23 -0
- package/dist/permissions.js +31 -0
- package/dist/permissions.js.map +1 -0
- package/dist/plugin.d.ts +8 -0
- package/dist/plugin.js +255 -0
- package/dist/plugin.js.map +1 -0
- package/dist/seed.d.ts +24 -0
- package/dist/seed.js +47 -0
- package/dist/seed.js.map +1 -0
- package/dist/shared.d.ts +38 -0
- package/dist/shared.js +19 -0
- package/dist/shared.js.map +1 -0
- package/dist/types.d.ts +194 -0
- package/dist/utilities/entityLabel.d.ts +10 -0
- package/dist/utilities/entityLabel.js +17 -0
- package/dist/utilities/entityLabel.js.map +1 -0
- package/dist/utilities/fullAccessHolders.d.ts +36 -0
- package/dist/utilities/fullAccessHolders.js +68 -0
- package/dist/utilities/fullAccessHolders.js.map +1 -0
- package/dist/utilities/getUserPermissions.d.ts +17 -0
- package/dist/utilities/getUserPermissions.js +72 -0
- package/dist/utilities/getUserPermissions.js.map +1 -0
- 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":";;;;;;;;;;;;;;;;;AA+BA,MAAM,kBACJ,cACgC;AAChC,KAAI,cAAc,UAAa,cAAc,KAC3C,cAAa;AAEf,KAAI,MAAM,QAAQ,UAAU,EAAE;EAC5B,MAAM,WAAW,IAAI,IAAY,UAAU;AAC3C,UAAQ,SAAS,SAAS,IAAI,KAAK;;CAErC,MAAM,WAAW,IAAI,IAAY,UAAU,QAAQ;AACnD,SAAQ,SAAS,CAAC,SAAS,IAAI,KAAK;;AAGtC,MAAM,iBAAiB,QAAiB,SAA0B;AAChE,QAAO,OAAO,MAAM,UAAU,UAAU,SAAS,MAAM,SAAS,KAAK;;AAGvE,MAAM,0BAA0B,QAAgB,iBAA6C;AAC3F,KAAI,aAAa,iBAAiB,OAChC,QAAO,aAAa;CAEtB,MAAM,aAAa,OAAO,eAAe,EAAE,EACxC,QAAQ,eAAe,QAAQ,WAAW,KAAK,CAAC,CAChD,KAAK,eAAe,WAAW,KAAK;AACvC,KAAI,UAAU,OACZ,QAAO;AAET,QAAO,CAAC,OAAO,OAAO,QAAQ,QAAQ;;AAGxC,MAAa,cAAc,eAAiC,EAAE,KAAa;AACzE,SAAQ,mBAAmC;EACzC,MAAM,SAAS,EAAE,GAAG,gBAAgB;AAEpC,MAAI,aAAa,YAAY,MAC3B,QAAO;EAGT,MAAM,sBAAsB,aAAa,iBAAiB,QAAQ;EAClE,MAAM,iBAAiB,aAAa,YAAY,QAAQ;EACxD,MAAM,oBAAoB,aAAa,+BAA+B;EACtE,MAAM,oBACJ,aAAa,qBAAqB,QAC9B,EAAE,GACD,aAAa,oBAAoB,CAAC,QAAQ,SAAS;EAC1D,MAAMA,YACJ,CAAC,aAAa,aAAa,OAAO,aAAa,cAAc,WACzD,EAAE,MAAM,aAAa,aAAa,QAAW,GAC7C,aAAa;AAEnB,MAAI,UAAU,SAAS,aAAa,SAAS,EAAE,EAAE,MAAM,SAAS,KAAK,SAAS,UAAU,KAAK,CAC3F,OAAM,IAAI,MACR,mBAAmB,UAAU,KAAK,oJAEnC;EAQH,MAAM,kBAAkB,CACtB,GAAI,UAAU,OACV,CACE;GACE,MAAM,UAAU;GAChB,mBAAmB;GACnB,aAAa,UAAU,eAAe;GACtC,aAAa,CAAC,YAAY;GAC1B,WAAW;GACZ,CACF,GACD,EAAE,EACN,IAAI,aAAa,SAAS,EAAE,EAAE,KAAK,UAAU;GAC3C,GAAG;GACH,WAAW,KAAK,aAAa;GAC9B,EAAE,CACJ;EACD,MAAM,iBAAiB,gBAAgB,QAAQ,SAAS,KAAK,UAAU;EACvE,MAAM,qBAAqB,eAAe,KAAK,SAAS,KAAK,KAAK;EAClE,MAAM,8BAA8B,gBACjC,QAAQ,SAAS,KAAK,sBAAsB,OAAO,CACnD,KAAK,SAAS,KAAK,KAAK;AAE3B,OAAK,OAAO,eAAe,EAAE,EAAE,MAAM,eAAe,WAAW,SAAS,oBAAoB,CAC1F,OAAM,IAAI,MACR,8CAA8C,oBAAoB,+FAEnE;EAGH,MAAM,0BAA0B,eAAe,aAAa,YAAY;EACxE,MAAM,sBAAsB,eAAe,aAAa,QAAQ;EAChE,MAAM,kBAAkB,uBAAuB,QAAQ,aAAa;EACpE,MAAM,sBAAsB,OAAO,OAAO,QAAQ;EAElD,MAAMC,aAA0B;GAC9B,IAAI,OAAO,eAAe,EAAE,EACzB,QAAQ,eAAe,wBAAwB,WAAW,KAAK,CAAC,CAChE,KAAK,gBAAgB;IACpB,MAAM,WAAW;IACjB,SAAS;IACT,QAAQ;IACR,OAAO,YAAY,WAAW,QAAQ,QAAQ,WAAW,KAAK;IAC/D,EAAE;GACL;IACE,MAAM;IACN,SAAS;IACT,QAAQ;IACR,OAAO;IACR;GACD,IAAI,OAAO,WAAW,EAAE,EACrB,QAAQ,WAAW,oBAAoB,OAAO,KAAK,CAAC,CACpD,KAAK,YAAY;IAChB,MAAM,OAAO;IACb,SAAS;IACT,QAAQ;IACR,OAAO,YAAY,OAAO,OAAO,OAAO,KAAK;IAC9C,EAAE;GACN;EAED,MAAM,mBAAmB,IAAI,IAAY,CACvC,aACA,GAAG,WAAW,SAAS,QAAQ,IAAI,QAAQ,KAAK,WAAW,cAAc,IAAI,MAAM,OAAO,CAAC,CAAC,CAC7F,CAAC;AACF,OAAK,MAAM,QAAQ,gBACjB,MAAK,MAAM,cAAc,KAAK,YAC5B,KAAI,CAAC,iBAAiB,IAAI,WAAW,CACnC,OAAM,IAAI,MACR,mCAAmC,KAAK,KAAK,+BAA+B,WAAW,kIAGxF;EAQP,MAAM,wBAAwB,eAAmD;GAC/E,MAAM,SAAS,EAAE,GAAG,WAAW,QAAQ;AACvC,QAAK,MAAM,UAAU,kBACnB,KAAI,OAAO,YAAY,OACrB,QAAO,UAAU,iBAAiB;IAAE,MAAM,WAAW;IAAM;IAAQ;IAAmB,CAAC;AAG3F,OAAI,OAAO,iBAAiB,OAC1B,QAAO,eAAe,iBAAiB;IAAE,MAAM,WAAW;IAAM,QAAQ;IAAQ,CAAC;AAEnF,OAAI,OAAO,WAAW,OACpB,QAAO,SAAS,iBAAiB;IAAE,MAAM,WAAW;IAAM,QAAQ;IAAU,CAAC;AAE/E,UAAO;IAAE,GAAG;IAAY;IAAQ;;EAGlC,MAAM,oBAAoB,WAAuC;GAC/D,MAAM,SAAS,EAAE,GAAG,OAAO,QAAQ;AACnC,QAAK,MAAM,UAAU,cACnB,KAAI,OAAO,YAAY,OACrB,QAAO,UAAU,iBAAiB;IAAE,MAAM,OAAO;IAAM;IAAQ,CAAC;AAGpE,OAAI,OAAO,iBAAiB,OAC1B,QAAO,eAAe,iBAAiB;IAAE,MAAM,OAAO;IAAM,QAAQ;IAAQ,CAAC;AAE/E,UAAO;IAAE,GAAG;IAAQ;IAAQ;;EAS9B,MAAM,mBAAmB,uBAAuB;GAC9C;GACA;GACA,GAAI,UAAU,OAAO,EAAE,YAAY,EAAE,iBAAiB,EAAE,GAAG,EAAE;GAC9D,CAAC;EAEF,MAAM,kBAAkB,eAAmD;GAIzE,MAAM,SAAS,cAAc,WAAW,QAAQ,eAAe,GAC3D,WAAW,SACX,CACE,GAAG,WAAW,QACd,iBAAiB;IACf,MAAM;IACN,QAAQ;KAAE,QAAQ;KAAkB,QAAQ;KAAkB;IAC9D,UAAU,aAAa,YAAY;IACnC;IACD,CAAC,CACH;GAEL,MAAM,eAAe,CAAC,GAAI,WAAW,OAAO,gBAAgB,EAAE,CAAE;GAChE,MAAM,eAAe,CAAC,GAAI,WAAW,OAAO,gBAAgB,EAAE,CAAE;AAChE,OAAI,4BAA4B,SAAS,EACvC,cAAa,KACX,6BAA6B;IAC3B;IACA;IACA,mBAAmB;IACnB,oBAAoB,WAAW;IAChC,CAAC,CACH;AAEH,OAAI,qBAAqB,UAAU,KACjC,cAAa,KACX,4BAA4B;IAC1B;IACA;IACA;IACA,GAAI,UAAU,OACV;KAIE,YAAY;MAAE,eAAe,UAAU;MAAM;MAAiB;KAI9D,YAAY;MAAE,WAAW,CAAC,UAAU,KAAK;MAAE;MAAiB;KAC7D,GACD,EAAE;IACP,CAAC,CACH;AAEH,OAAI,UAAU,MAAM;IAIlB,MAAM,uBAAuB;KAC3B,eAAe,UAAU;KACzB;KACA;KACA;KACA,oBAAoB,WAAW;KAChC;AACD,iBAAa,KAAK,iCAAiC,qBAAqB,CAAC;AACzE,iBAAa,KAAK,iCAAiC,qBAAqB,CAAC;;AAE3E,OAAI,UAAU,QAAQ,WAAW,SAAS,oBACxC,cAAa,KACX,8BAA8B;IAC5B,eAAe,UAAU;IACzB;IACA;IACD,CAAC,CACH;AAGH,UAAO;IAAE,GAAG;IAAY;IAAQ,OAAO;KAAE,GAAG,WAAW;KAAO;KAAc;KAAc;IAAE;;EAG9F,MAAMC,cAA0C,EAAE;AAClD,OAAK,MAAM,UAAU,kBACnB,aAAY,UAAU,iBAAiB;GAAE,MAAM;GAAqB;GAAQ,CAAC;AAE/E,MAAI,UAAU,MAAM;GAClB,MAAM,WAAW,YAAY;AAI7B,eAAY,OAAO,OAAO,SAAS;IACjC,MAAM,OAAO,MAAM,WAAW,KAAK;AACnC,QAAI,QAAQ,CAAC,KAAK,IAAI,KACpB,QAAO,QAAQ;IAEjB,MAAM,oBAAoB,MAAM,sBAAsB,KAAK,IAAI,SAAS,oBAAoB;AAC5F,WAAO,CAAE,MAAM,iBAAiB,KAAK,IAAI,SAAS,mBAAmB;KACnE;KACA;KACD,CAAC;;;EAON,MAAMC,oBAA4E,EAAE;AACpF,MAAI,eAAe,SAAS,EAC1B,mBAAkB,KAAK,+BAA+B,EAAE,gBAAgB,CAAC,CAAC;AAE5E,MAAI,kBACF,mBAAkB,KAAK,iCAAiC,EAAE,oBAAoB,CAAC,CAAC;EAElF,MAAMC,aAAwC;GAC5C,GAAI,kBAAkB,SAAS,IAAI,EAAE,cAAc,mBAAmB,GAAG,EAAE;GAC3E,GAAI,eAAe,SAAS,IACxB,EACE,cAAc,CACZ,+BAA+B;IAAE;IAAoB;IAAqB,CAAC,CAC5E,EACF,GACD,EAAE;GACP;EAED,MAAM,kBAAkB,sBAAsB;GAC5C,MAAM;GACN,QAAQ;GACR,OAAO,OAAO,KAAK,WAAW,CAAC,SAAS,IAAI,aAAa;GACzD;GACA,UAAU,aAAa,iBAAiB;GACxC;GACD,CAAC;AAEF,SAAO,cAAc,CACnB,IAAI,OAAO,eAAe,EAAE,EAAE,KAAK,eAAe;GAChD,IAAI,SAAS,wBAAwB,WAAW,KAAK,GACjD,qBAAqB,WAAW,GAChC;AACJ,OAAI,gBAAgB,SAAS,WAAW,KAAK,CAC3C,UAAS,eAAe,OAAO;AAEjC,UAAO;IACP,EACF,gBACD;AAED,SAAO,WAAW,OAAO,WAAW,EAAE,EAAE,KAAK,WAC3C,oBAAoB,OAAO,KAAK,GAAG,iBAAiB,OAAO,GAAG,OAC/D;EAED,MAAM,iBAAiB,OAAO;AAC9B,SAAO,SAAS,OAAO,YAAY;AACjC,OAAI,gBAAgB,SAAS,EAC3B,OAAM,oBAAoB,SAAS;IAAE,OAAO;IAAiB;IAAqB,CAAC;AAErF,OAAI,UAAU,KACZ,OAAM,sBAAsB,SAAS;IACnC,eAAe,UAAU;IACzB;IACA;IACA;IACD,CAAC;AAEJ,SAAM,iBAAiB,QAAQ;;EAGjC,MAAMC,eAAiC;GACrC;GACA;GACA;GACD;AAED,SAAO,SAAS;GACd,GAAG,OAAO;IACT,YAAY;GACd;AAED,SAAO"}
|
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
|
package/dist/seed.js.map
ADDED
|
@@ -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"}
|
package/dist/shared.d.ts
ADDED
|
@@ -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"}
|
package/dist/types.d.ts
ADDED
|
@@ -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
|