@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
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { FULL_ACCESS, permissionFor } from "../permissions.js";
|
|
4
|
+
import { collectionActions } from "../shared.js";
|
|
5
|
+
import { FieldDescription, FieldLabel, useField, useFormFields } from "@payloadcms/ui";
|
|
6
|
+
import React, { useCallback, useMemo } from "react";
|
|
7
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
8
|
+
|
|
9
|
+
//#region src/fields/PermissionsMatrixField.tsx
|
|
10
|
+
const cellStyle = {
|
|
11
|
+
border: "1px solid var(--theme-elevation-150)",
|
|
12
|
+
padding: "8px 12px",
|
|
13
|
+
textAlign: "center"
|
|
14
|
+
};
|
|
15
|
+
const rowLabelStyle = {
|
|
16
|
+
...cellStyle,
|
|
17
|
+
textAlign: "left"
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Renders the roles collection's `permissions` select as a matrix of collections ×
|
|
21
|
+
* Create/Read/Update/Delete checkboxes (globals: Read/Update), with a "full access"
|
|
22
|
+
* master toggle for `'*'`. The stored value stays a plain array of permission
|
|
23
|
+
* strings, so the REST/local APIs are unaffected.
|
|
24
|
+
*/
|
|
25
|
+
const PermissionsMatrixField = (props) => {
|
|
26
|
+
const { field, path, protectedRoleNames = [], readOnly, rows = [] } = props;
|
|
27
|
+
const { setValue, showError, value } = useField({ path });
|
|
28
|
+
const roleName = useFormFields(([fields]) => fields?.name?.value);
|
|
29
|
+
const selected = useMemo(() => new Set(value ?? []), [value]);
|
|
30
|
+
const fullAccess = selected.has(FULL_ACCESS);
|
|
31
|
+
const isProtected = typeof roleName === "string" && protectedRoleNames.includes(roleName);
|
|
32
|
+
const locked = Boolean(readOnly) || isProtected;
|
|
33
|
+
const toggle = useCallback((permission) => {
|
|
34
|
+
const next = new Set(selected);
|
|
35
|
+
if (next.has(permission)) next.delete(permission);
|
|
36
|
+
else next.add(permission);
|
|
37
|
+
setValue(Array.from(next).sort());
|
|
38
|
+
}, [selected, setValue]);
|
|
39
|
+
const toggleRow = useCallback((row) => {
|
|
40
|
+
const permissions = row.actions.map((action) => permissionFor(row.slug, action));
|
|
41
|
+
const allSelected = permissions.every((permission) => selected.has(permission));
|
|
42
|
+
const next = new Set(selected);
|
|
43
|
+
for (const permission of permissions) if (allSelected) next.delete(permission);
|
|
44
|
+
else next.add(permission);
|
|
45
|
+
setValue(Array.from(next).sort());
|
|
46
|
+
}, [selected, setValue]);
|
|
47
|
+
const hasGlobals = rows.some((row) => row.entity === "global");
|
|
48
|
+
const hasCollections = rows.some((row) => row.entity === "collection");
|
|
49
|
+
const renderRows = (entity) => rows.filter((row) => row.entity === entity).map((row) => {
|
|
50
|
+
const rowPermissions = row.actions.map((action) => permissionFor(row.slug, action));
|
|
51
|
+
const allChecked = fullAccess || rowPermissions.every((permission) => selected.has(permission));
|
|
52
|
+
const someChecked = rowPermissions.some((permission) => selected.has(permission));
|
|
53
|
+
return /* @__PURE__ */ jsxs("tr", { children: [
|
|
54
|
+
/* @__PURE__ */ jsx("td", {
|
|
55
|
+
style: rowLabelStyle,
|
|
56
|
+
children: row.label
|
|
57
|
+
}),
|
|
58
|
+
/* @__PURE__ */ jsx("td", {
|
|
59
|
+
style: cellStyle,
|
|
60
|
+
children: /* @__PURE__ */ jsx("input", {
|
|
61
|
+
"aria-label": `${row.label}: all actions`,
|
|
62
|
+
checked: allChecked,
|
|
63
|
+
disabled: locked || fullAccess,
|
|
64
|
+
onChange: () => toggleRow(row),
|
|
65
|
+
ref: (el) => {
|
|
66
|
+
if (el) el.indeterminate = !allChecked && someChecked;
|
|
67
|
+
},
|
|
68
|
+
type: "checkbox"
|
|
69
|
+
})
|
|
70
|
+
}),
|
|
71
|
+
collectionActions.map((action) => {
|
|
72
|
+
const available = row.actions.includes(action);
|
|
73
|
+
const permission = permissionFor(row.slug, action);
|
|
74
|
+
return /* @__PURE__ */ jsx("td", {
|
|
75
|
+
style: cellStyle,
|
|
76
|
+
children: available ? /* @__PURE__ */ jsx("input", {
|
|
77
|
+
"aria-label": `${row.label}: ${action}`,
|
|
78
|
+
checked: fullAccess || selected.has(permission),
|
|
79
|
+
disabled: locked || fullAccess,
|
|
80
|
+
onChange: () => toggle(permission),
|
|
81
|
+
type: "checkbox"
|
|
82
|
+
}) : /* @__PURE__ */ jsx("span", {
|
|
83
|
+
"aria-hidden": true,
|
|
84
|
+
children: "—"
|
|
85
|
+
})
|
|
86
|
+
}, action);
|
|
87
|
+
})
|
|
88
|
+
] }, `${row.entity}:${row.slug}`);
|
|
89
|
+
});
|
|
90
|
+
const sectionHeading = (label) => /* @__PURE__ */ jsx("tr", { children: /* @__PURE__ */ jsx("th", {
|
|
91
|
+
colSpan: collectionActions.length + 2,
|
|
92
|
+
style: {
|
|
93
|
+
...rowLabelStyle,
|
|
94
|
+
fontWeight: 600
|
|
95
|
+
},
|
|
96
|
+
children: label
|
|
97
|
+
}) });
|
|
98
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
99
|
+
className: "field-type rbac-permissions-matrix",
|
|
100
|
+
style: { marginBottom: "var(--base)" },
|
|
101
|
+
children: [
|
|
102
|
+
/* @__PURE__ */ jsx(FieldLabel, {
|
|
103
|
+
label: field?.label ?? "Permissions",
|
|
104
|
+
path
|
|
105
|
+
}),
|
|
106
|
+
showError && /* @__PURE__ */ jsx("div", {
|
|
107
|
+
style: {
|
|
108
|
+
color: "var(--theme-error-500)",
|
|
109
|
+
marginBottom: 8
|
|
110
|
+
},
|
|
111
|
+
children: "Invalid permissions value"
|
|
112
|
+
}),
|
|
113
|
+
isProtected && /* @__PURE__ */ jsx("div", {
|
|
114
|
+
style: {
|
|
115
|
+
color: "var(--theme-elevation-500)",
|
|
116
|
+
margin: "8px 0"
|
|
117
|
+
},
|
|
118
|
+
children: "This role is protected — its permissions are defined in code and cannot be edited here."
|
|
119
|
+
}),
|
|
120
|
+
/* @__PURE__ */ jsxs("label", {
|
|
121
|
+
style: {
|
|
122
|
+
alignItems: "center",
|
|
123
|
+
display: "flex",
|
|
124
|
+
gap: 8,
|
|
125
|
+
margin: "8px 0 12px"
|
|
126
|
+
},
|
|
127
|
+
children: [/* @__PURE__ */ jsx("input", {
|
|
128
|
+
"aria-label": "Full access",
|
|
129
|
+
checked: fullAccess,
|
|
130
|
+
disabled: locked,
|
|
131
|
+
onChange: () => toggle(FULL_ACCESS),
|
|
132
|
+
type: "checkbox"
|
|
133
|
+
}), /* @__PURE__ */ jsx("span", { children: "Full access — every action on every collection and global, including ones added in the future" })]
|
|
134
|
+
}),
|
|
135
|
+
/* @__PURE__ */ jsx("div", {
|
|
136
|
+
style: {
|
|
137
|
+
opacity: fullAccess ? .5 : 1,
|
|
138
|
+
overflowX: "auto"
|
|
139
|
+
},
|
|
140
|
+
children: /* @__PURE__ */ jsxs("table", {
|
|
141
|
+
style: {
|
|
142
|
+
borderCollapse: "collapse",
|
|
143
|
+
width: "100%"
|
|
144
|
+
},
|
|
145
|
+
children: [/* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsxs("tr", { children: [
|
|
146
|
+
/* @__PURE__ */ jsx("th", {
|
|
147
|
+
"aria-label": "Collection or global",
|
|
148
|
+
style: rowLabelStyle
|
|
149
|
+
}),
|
|
150
|
+
/* @__PURE__ */ jsx("th", {
|
|
151
|
+
style: cellStyle,
|
|
152
|
+
children: "All"
|
|
153
|
+
}),
|
|
154
|
+
collectionActions.map((action) => /* @__PURE__ */ jsx("th", {
|
|
155
|
+
style: {
|
|
156
|
+
...cellStyle,
|
|
157
|
+
textTransform: "capitalize"
|
|
158
|
+
},
|
|
159
|
+
children: action
|
|
160
|
+
}, action))
|
|
161
|
+
] }) }), /* @__PURE__ */ jsxs("tbody", { children: [
|
|
162
|
+
hasCollections && hasGlobals && sectionHeading("Collections"),
|
|
163
|
+
renderRows("collection"),
|
|
164
|
+
hasGlobals && sectionHeading("Globals"),
|
|
165
|
+
renderRows("global")
|
|
166
|
+
] })]
|
|
167
|
+
})
|
|
168
|
+
}),
|
|
169
|
+
/* @__PURE__ */ jsx(FieldDescription, {
|
|
170
|
+
description: field?.admin?.description,
|
|
171
|
+
marginPlacement: "bottom",
|
|
172
|
+
path
|
|
173
|
+
})
|
|
174
|
+
]
|
|
175
|
+
});
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
//#endregion
|
|
179
|
+
export { PermissionsMatrixField };
|
|
180
|
+
//# sourceMappingURL=PermissionsMatrixField.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"PermissionsMatrixField.js","names":["cellStyle: React.CSSProperties","rowLabelStyle: React.CSSProperties","PermissionsMatrixField: React.FC<PermissionsMatrixFieldProps>"],"sources":["../../src/fields/PermissionsMatrixField.tsx"],"sourcesContent":["'use client'\n\nimport type { SelectFieldClientProps } from 'payload'\n\nimport { FieldDescription, FieldLabel, useField, useFormFields } from '@payloadcms/ui'\nimport React, { useCallback, useMemo } from 'react'\n\nimport type { MatrixRow, RbacAction } from '../shared.js'\n\nimport { FULL_ACCESS, permissionFor } from '../permissions.js'\nimport { collectionActions } from '../shared.js'\n\nconst cellStyle: React.CSSProperties = {\n border: '1px solid var(--theme-elevation-150)',\n padding: '8px 12px',\n textAlign: 'center',\n}\n\nconst rowLabelStyle: React.CSSProperties = {\n ...cellStyle,\n textAlign: 'left',\n}\n\nexport type PermissionsMatrixFieldProps = {\n /** Names of code-locked roles the matrix renders read-only for. */\n protectedRoleNames?: string[]\n /** Serialized by the plugin into the field's `clientProps`. */\n rows?: MatrixRow[]\n} & SelectFieldClientProps\n\n/**\n * Renders the roles collection's `permissions` select as a matrix of collections ×\n * Create/Read/Update/Delete checkboxes (globals: Read/Update), with a \"full access\"\n * master toggle for `'*'`. The stored value stays a plain array of permission\n * strings, so the REST/local APIs are unaffected.\n */\nexport const PermissionsMatrixField: React.FC<PermissionsMatrixFieldProps> = (props) => {\n const { field, path, protectedRoleNames = [], readOnly, rows = [] } = props\n const { setValue, showError, value } = useField<string[]>({ path })\n const roleName = useFormFields(([fields]) => fields?.name?.value)\n\n const selected = useMemo(() => new Set(value ?? []), [value])\n const fullAccess = selected.has(FULL_ACCESS)\n // Best-effort UX only — the beforeChange guard on the server is authoritative.\n const isProtected = typeof roleName === 'string' && protectedRoleNames.includes(roleName)\n const locked = Boolean(readOnly) || isProtected\n\n const toggle = useCallback(\n (permission: string) => {\n const next = new Set(selected)\n if (next.has(permission)) {\n next.delete(permission)\n } else {\n next.add(permission)\n }\n setValue(Array.from(next).sort())\n },\n [selected, setValue],\n )\n\n const toggleRow = useCallback(\n (row: MatrixRow) => {\n const permissions = row.actions.map((action) => permissionFor(row.slug, action))\n const allSelected = permissions.every((permission) => selected.has(permission))\n const next = new Set(selected)\n for (const permission of permissions) {\n if (allSelected) {\n next.delete(permission)\n } else {\n next.add(permission)\n }\n }\n setValue(Array.from(next).sort())\n },\n [selected, setValue],\n )\n\n const hasGlobals = rows.some((row) => row.entity === 'global')\n const hasCollections = rows.some((row) => row.entity === 'collection')\n\n const renderRows = (entity: MatrixRow['entity']) =>\n rows\n .filter((row) => row.entity === entity)\n .map((row) => {\n const rowPermissions = row.actions.map((action) => permissionFor(row.slug, action))\n const allChecked =\n fullAccess || rowPermissions.every((permission) => selected.has(permission))\n const someChecked = rowPermissions.some((permission) => selected.has(permission))\n return (\n <tr key={`${row.entity}:${row.slug}`}>\n <td style={rowLabelStyle}>{row.label}</td>\n <td style={cellStyle}>\n <input\n aria-label={`${row.label}: all actions`}\n checked={allChecked}\n disabled={locked || fullAccess}\n onChange={() => toggleRow(row)}\n ref={(el) => {\n if (el) {\n el.indeterminate = !allChecked && someChecked\n }\n }}\n type=\"checkbox\"\n />\n </td>\n {collectionActions.map((action: RbacAction) => {\n const available = row.actions.includes(action)\n const permission = permissionFor(row.slug, action)\n return (\n <td key={action} style={cellStyle}>\n {available ? (\n <input\n aria-label={`${row.label}: ${action}`}\n checked={fullAccess || selected.has(permission)}\n disabled={locked || fullAccess}\n onChange={() => toggle(permission)}\n type=\"checkbox\"\n />\n ) : (\n <span aria-hidden>—</span>\n )}\n </td>\n )\n })}\n </tr>\n )\n })\n\n const sectionHeading = (label: string) => (\n <tr>\n <th colSpan={collectionActions.length + 2} style={{ ...rowLabelStyle, fontWeight: 600 }}>\n {label}\n </th>\n </tr>\n )\n\n return (\n <div className=\"field-type rbac-permissions-matrix\" style={{ marginBottom: 'var(--base)' }}>\n <FieldLabel label={field?.label ?? 'Permissions'} path={path} />\n {showError && (\n <div style={{ color: 'var(--theme-error-500)', marginBottom: 8 }}>\n Invalid permissions value\n </div>\n )}\n {isProtected && (\n <div style={{ color: 'var(--theme-elevation-500)', margin: '8px 0' }}>\n This role is protected — its permissions are defined in code and cannot be edited here.\n </div>\n )}\n <label style={{ alignItems: 'center', display: 'flex', gap: 8, margin: '8px 0 12px' }}>\n <input\n aria-label=\"Full access\"\n checked={fullAccess}\n disabled={locked}\n onChange={() => toggle(FULL_ACCESS)}\n type=\"checkbox\"\n />\n <span>\n Full access — every action on every collection and global, including ones added in the\n future\n </span>\n </label>\n <div style={{ opacity: fullAccess ? 0.5 : 1, overflowX: 'auto' }}>\n <table style={{ borderCollapse: 'collapse', width: '100%' }}>\n <thead>\n <tr>\n <th aria-label=\"Collection or global\" style={rowLabelStyle} />\n <th style={cellStyle}>All</th>\n {collectionActions.map((action) => (\n <th key={action} style={{ ...cellStyle, textTransform: 'capitalize' }}>\n {action}\n </th>\n ))}\n </tr>\n </thead>\n <tbody>\n {hasCollections && hasGlobals && sectionHeading('Collections')}\n {renderRows('collection')}\n {hasGlobals && sectionHeading('Globals')}\n {renderRows('global')}\n </tbody>\n </table>\n </div>\n <FieldDescription\n description={field?.admin?.description}\n marginPlacement=\"bottom\"\n path={path}\n />\n </div>\n )\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { RelationshipField } from "payload";
|
|
2
|
+
|
|
3
|
+
//#region src/fields/createRolesField.d.ts
|
|
4
|
+
type CreateRolesFieldArgs = {
|
|
5
|
+
access?: RelationshipField['access'];
|
|
6
|
+
name: string;
|
|
7
|
+
override?: (field: RelationshipField) => RelationshipField;
|
|
8
|
+
rolesCollectionSlug: string;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Builds the roles field added to each user collection. `saveToJWT` keeps the role
|
|
12
|
+
* IDs on the token payload for consumers reading the JWT directly; access checks
|
|
13
|
+
* always resolve permissions from the user document.
|
|
14
|
+
*/
|
|
15
|
+
declare const createRolesField: ({
|
|
16
|
+
name,
|
|
17
|
+
access,
|
|
18
|
+
override,
|
|
19
|
+
rolesCollectionSlug
|
|
20
|
+
}: CreateRolesFieldArgs) => RelationshipField;
|
|
21
|
+
//#endregion
|
|
22
|
+
export { CreateRolesFieldArgs, createRolesField };
|
|
23
|
+
//# sourceMappingURL=createRolesField.d.ts.map
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
//#region src/fields/createRolesField.ts
|
|
2
|
+
/**
|
|
3
|
+
* Builds the roles field added to each user collection. `saveToJWT` keeps the role
|
|
4
|
+
* IDs on the token payload for consumers reading the JWT directly; access checks
|
|
5
|
+
* always resolve permissions from the user document.
|
|
6
|
+
*/
|
|
7
|
+
const createRolesField = ({ name, access, override, rolesCollectionSlug }) => {
|
|
8
|
+
const field = {
|
|
9
|
+
name,
|
|
10
|
+
type: "relationship",
|
|
11
|
+
...access ? { access } : {},
|
|
12
|
+
admin: {
|
|
13
|
+
description: "Roles controlling what this user can access.",
|
|
14
|
+
position: "sidebar"
|
|
15
|
+
},
|
|
16
|
+
hasMany: true,
|
|
17
|
+
index: true,
|
|
18
|
+
label: "Roles",
|
|
19
|
+
relationTo: rolesCollectionSlug,
|
|
20
|
+
saveToJWT: true
|
|
21
|
+
};
|
|
22
|
+
return override ? override(field) : field;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
//#endregion
|
|
26
|
+
export { createRolesField };
|
|
27
|
+
//# sourceMappingURL=createRolesField.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"createRolesField.js","names":["field: RelationshipField"],"sources":["../../src/fields/createRolesField.ts"],"sourcesContent":["import type { RelationshipField } from 'payload'\n\nexport type CreateRolesFieldArgs = {\n access?: RelationshipField['access']\n name: string\n override?: (field: RelationshipField) => RelationshipField\n rolesCollectionSlug: string\n}\n\n/**\n * Builds the roles field added to each user collection. `saveToJWT` keeps the role\n * IDs on the token payload for consumers reading the JWT directly; access checks\n * always resolve permissions from the user document.\n */\nexport const createRolesField = ({\n name,\n access,\n override,\n rolesCollectionSlug,\n}: CreateRolesFieldArgs): RelationshipField => {\n const field: RelationshipField = {\n name,\n type: 'relationship',\n ...(access ? { access } : {}),\n admin: {\n description: 'Roles controlling what this user can access.',\n position: 'sidebar',\n },\n hasMany: true,\n index: true,\n label: 'Roles',\n relationTo: rolesCollectionSlug,\n saveToJWT: true,\n }\n\n return override ? override(field) : field\n}\n"],"mappings":";;;;;;AAcA,MAAa,oBAAoB,EAC/B,MACA,QACA,UACA,0BAC6C;CAC7C,MAAMA,QAA2B;EAC/B;EACA,MAAM;EACN,GAAI,SAAS,EAAE,QAAQ,GAAG,EAAE;EAC5B,OAAO;GACL,aAAa;GACb,UAAU;GACX;EACD,SAAS;EACT,OAAO;EACP,OAAO;EACP,YAAY;EACZ,WAAW;EACZ;AAED,QAAO,WAAW,SAAS,MAAM,GAAG"}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { CollectionBeforeChangeHook } from "payload";
|
|
2
|
+
|
|
3
|
+
//#region src/hooks/assignFirstUserRole.d.ts
|
|
4
|
+
type AssignFirstUserRoleArgs = {
|
|
5
|
+
firstUserRole: string;
|
|
6
|
+
rolesCollectionSlug: string;
|
|
7
|
+
rolesFieldName: string;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Bootstrap hook installed on the admin user collection: when the very first user
|
|
11
|
+
* is created without roles by an unauthenticated request — the admin "create first
|
|
12
|
+
* user" screen or an init seed — assign the configured role so a fresh project
|
|
13
|
+
* starts with a usable admin account.
|
|
14
|
+
*/
|
|
15
|
+
declare const createAssignFirstUserRoleHook: ({
|
|
16
|
+
firstUserRole,
|
|
17
|
+
rolesCollectionSlug,
|
|
18
|
+
rolesFieldName
|
|
19
|
+
}: AssignFirstUserRoleArgs) => CollectionBeforeChangeHook;
|
|
20
|
+
//#endregion
|
|
21
|
+
export { AssignFirstUserRoleArgs, createAssignFirstUserRoleHook };
|
|
22
|
+
//# sourceMappingURL=assignFirstUserRole.d.ts.map
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
//#region src/hooks/assignFirstUserRole.ts
|
|
2
|
+
/**
|
|
3
|
+
* Bootstrap hook installed on the admin user collection: when the very first user
|
|
4
|
+
* is created without roles by an unauthenticated request — the admin "create first
|
|
5
|
+
* user" screen or an init seed — assign the configured role so a fresh project
|
|
6
|
+
* starts with a usable admin account.
|
|
7
|
+
*/
|
|
8
|
+
const createAssignFirstUserRoleHook = ({ firstUserRole, rolesCollectionSlug, rolesFieldName }) => {
|
|
9
|
+
return async ({ collection, data, operation, req }) => {
|
|
10
|
+
if (operation !== "create" || req.user || !data) return data;
|
|
11
|
+
const existingRoles = data[rolesFieldName];
|
|
12
|
+
if (Array.isArray(existingRoles) && existingRoles.length > 0) return data;
|
|
13
|
+
const { totalDocs } = await req.payload.count({
|
|
14
|
+
collection: collection.slug,
|
|
15
|
+
req
|
|
16
|
+
});
|
|
17
|
+
if (totalDocs > 0) return data;
|
|
18
|
+
const { docs } = await req.payload.find({
|
|
19
|
+
collection: rolesCollectionSlug,
|
|
20
|
+
depth: 0,
|
|
21
|
+
limit: 1,
|
|
22
|
+
overrideAccess: true,
|
|
23
|
+
req,
|
|
24
|
+
where: { name: { equals: firstUserRole } }
|
|
25
|
+
});
|
|
26
|
+
const role = docs[0];
|
|
27
|
+
if (!role) {
|
|
28
|
+
req.payload.logger.warn(`[payload-rbac] firstUserRole "${firstUserRole}" not found — first user created without roles`);
|
|
29
|
+
return data;
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
...data,
|
|
33
|
+
[rolesFieldName]: [role.id]
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
//#endregion
|
|
39
|
+
export { createAssignFirstUserRoleHook };
|
|
40
|
+
//# sourceMappingURL=assignFirstUserRole.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"assignFirstUserRole.js","names":[],"sources":["../../src/hooks/assignFirstUserRole.ts"],"sourcesContent":["import type { CollectionBeforeChangeHook } from 'payload'\n\nexport type AssignFirstUserRoleArgs = {\n firstUserRole: string\n rolesCollectionSlug: string\n rolesFieldName: string\n}\n\n/**\n * Bootstrap hook installed on the admin user collection: when the very first user\n * is created without roles by an unauthenticated request — the admin \"create first\n * user\" screen or an init seed — assign the configured role so a fresh project\n * starts with a usable admin account.\n */\nexport const createAssignFirstUserRoleHook = ({\n firstUserRole,\n rolesCollectionSlug,\n rolesFieldName,\n}: AssignFirstUserRoleArgs): CollectionBeforeChangeHook => {\n return async ({ collection, data, operation, req }) => {\n if (operation !== 'create' || req.user || !data) {\n return data\n }\n\n const existingRoles = data[rolesFieldName]\n if (Array.isArray(existingRoles) && existingRoles.length > 0) {\n return data\n }\n\n const { totalDocs } = await req.payload.count({ collection: collection.slug, req })\n if (totalDocs > 0) {\n return data\n }\n\n const { docs } = await req.payload.find({\n collection: rolesCollectionSlug,\n depth: 0,\n limit: 1,\n overrideAccess: true,\n req,\n where: { name: { equals: firstUserRole } },\n })\n const role = docs[0]\n if (!role) {\n req.payload.logger.warn(\n `[payload-rbac] firstUserRole \"${firstUserRole}\" not found — first user created without roles`,\n )\n return data\n }\n\n return { ...data, [rolesFieldName]: [role.id] }\n }\n}\n"],"mappings":";;;;;;;AAcA,MAAa,iCAAiC,EAC5C,eACA,qBACA,qBACyD;AACzD,QAAO,OAAO,EAAE,YAAY,MAAM,WAAW,UAAU;AACrD,MAAI,cAAc,YAAY,IAAI,QAAQ,CAAC,KACzC,QAAO;EAGT,MAAM,gBAAgB,KAAK;AAC3B,MAAI,MAAM,QAAQ,cAAc,IAAI,cAAc,SAAS,EACzD,QAAO;EAGT,MAAM,EAAE,cAAc,MAAM,IAAI,QAAQ,MAAM;GAAE,YAAY,WAAW;GAAM;GAAK,CAAC;AACnF,MAAI,YAAY,EACd,QAAO;EAGT,MAAM,EAAE,SAAS,MAAM,IAAI,QAAQ,KAAK;GACtC,YAAY;GACZ,OAAO;GACP,OAAO;GACP,gBAAgB;GAChB;GACA,OAAO,EAAE,MAAM,EAAE,QAAQ,eAAe,EAAE;GAC3C,CAAC;EACF,MAAM,OAAO,KAAK;AAClB,MAAI,CAAC,MAAM;AACT,OAAI,QAAQ,OAAO,KACjB,iCAAiC,cAAc,gDAChD;AACD,UAAO;;AAGT,SAAO;GAAE,GAAG;IAAO,iBAAiB,CAAC,KAAK,GAAG;GAAE"}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { CollectionBeforeChangeHook } from "payload";
|
|
2
|
+
|
|
3
|
+
//#region src/hooks/protectCredentials.d.ts
|
|
4
|
+
type ProtectCredentialsArgs = {
|
|
5
|
+
rolesCollectionSlug: string;
|
|
6
|
+
rolesFieldName: string;
|
|
7
|
+
/** Names of the `credentialChanges: 'self'` roles (the adminRole always is one). */
|
|
8
|
+
selfOnlyRoleNames: string[];
|
|
9
|
+
/** Slug of the user collection this hook is installed on. */
|
|
10
|
+
userCollectionSlug: string;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Credential guard installed on each user collection: the password, email, and
|
|
14
|
+
* username of a user holding a `credentialChanges: 'self'` role can only be
|
|
15
|
+
* changed by that user — everyone else gets a 403 no matter what permissions
|
|
16
|
+
* they hold, and should send a password-reset email instead. Email and username
|
|
17
|
+
* are locked together with password deliberately: an editable email plus the
|
|
18
|
+
* reset flow would take the account over anyway. Writes without a user (seeds,
|
|
19
|
+
* local API scripts) pass through, matching the other guards.
|
|
20
|
+
*/
|
|
21
|
+
declare const createProtectCredentialsHook: ({
|
|
22
|
+
rolesCollectionSlug,
|
|
23
|
+
rolesFieldName,
|
|
24
|
+
selfOnlyRoleNames,
|
|
25
|
+
userCollectionSlug
|
|
26
|
+
}: ProtectCredentialsArgs) => CollectionBeforeChangeHook;
|
|
27
|
+
//#endregion
|
|
28
|
+
export { ProtectCredentialsArgs, createProtectCredentialsHook };
|
|
29
|
+
//# sourceMappingURL=protectCredentials.d.ts.map
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { normalizeRoleIds } from "./protectRolesField.js";
|
|
2
|
+
import { APIError } from "payload";
|
|
3
|
+
|
|
4
|
+
//#region src/hooks/protectCredentials.ts
|
|
5
|
+
const identityFields = ["email", "username"];
|
|
6
|
+
/**
|
|
7
|
+
* Credential guard installed on each user collection: the password, email, and
|
|
8
|
+
* username of a user holding a `credentialChanges: 'self'` role can only be
|
|
9
|
+
* changed by that user — everyone else gets a 403 no matter what permissions
|
|
10
|
+
* they hold, and should send a password-reset email instead. Email and username
|
|
11
|
+
* are locked together with password deliberately: an editable email plus the
|
|
12
|
+
* reset flow would take the account over anyway. Writes without a user (seeds,
|
|
13
|
+
* local API scripts) pass through, matching the other guards.
|
|
14
|
+
*/
|
|
15
|
+
const createProtectCredentialsHook = ({ rolesCollectionSlug, rolesFieldName, selfOnlyRoleNames, userCollectionSlug }) => {
|
|
16
|
+
const selfOnly = new Set(selfOnlyRoleNames);
|
|
17
|
+
return async ({ data, operation, originalDoc, req }) => {
|
|
18
|
+
if (!req.user || !data || operation !== "update") return data;
|
|
19
|
+
const targetId = originalDoc?.id;
|
|
20
|
+
if (req.user.collection === userCollectionSlug && (typeof targetId === "number" || typeof targetId === "string") && String(targetId) === String(req.user.id)) return data;
|
|
21
|
+
const changingPassword = typeof data.password === "string" && data.password.length > 0;
|
|
22
|
+
const changedField = identityFields.find((field) => data[field] != null && data[field] !== originalDoc?.[field]);
|
|
23
|
+
if (!changingPassword && !changedField) return data;
|
|
24
|
+
const targetRoleIds = normalizeRoleIds(originalDoc?.[rolesFieldName]);
|
|
25
|
+
if (targetRoleIds.length === 0) return data;
|
|
26
|
+
const { docs } = await req.payload.find({
|
|
27
|
+
collection: rolesCollectionSlug,
|
|
28
|
+
depth: 0,
|
|
29
|
+
overrideAccess: true,
|
|
30
|
+
pagination: false,
|
|
31
|
+
req,
|
|
32
|
+
where: { id: { in: targetRoleIds } }
|
|
33
|
+
});
|
|
34
|
+
const protectedRole = docs.find((doc) => typeof doc.name === "string" && selfOnly.has(doc.name));
|
|
35
|
+
if (!protectedRole) return data;
|
|
36
|
+
if (changingPassword) throw new APIError(`The password of a user holding the "${String(protectedRole.name)}" role can only be changed by that user — send them a password-reset email instead.`, 403);
|
|
37
|
+
throw new APIError(`The ${changedField} of a user holding the "${String(protectedRole.name)}" role can only be changed by that user.`, 403);
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
//#endregion
|
|
42
|
+
export { createProtectCredentialsHook };
|
|
43
|
+
//# sourceMappingURL=protectCredentials.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"protectCredentials.js","names":["targetId: unknown"],"sources":["../../src/hooks/protectCredentials.ts"],"sourcesContent":["import type { CollectionBeforeChangeHook } from 'payload'\n\nimport { APIError } from 'payload'\n\nimport { normalizeRoleIds } from './protectRolesField.js'\n\nexport type ProtectCredentialsArgs = {\n rolesCollectionSlug: string\n rolesFieldName: string\n /** Names of the `credentialChanges: 'self'` roles (the adminRole always is one). */\n selfOnlyRoleNames: string[]\n /** Slug of the user collection this hook is installed on. */\n userCollectionSlug: string\n}\n\nconst identityFields = ['email', 'username'] as const\n\n/**\n * Credential guard installed on each user collection: the password, email, and\n * username of a user holding a `credentialChanges: 'self'` role can only be\n * changed by that user — everyone else gets a 403 no matter what permissions\n * they hold, and should send a password-reset email instead. Email and username\n * are locked together with password deliberately: an editable email plus the\n * reset flow would take the account over anyway. Writes without a user (seeds,\n * local API scripts) pass through, matching the other guards.\n */\nexport const createProtectCredentialsHook = ({\n rolesCollectionSlug,\n rolesFieldName,\n selfOnlyRoleNames,\n userCollectionSlug,\n}: ProtectCredentialsArgs): CollectionBeforeChangeHook => {\n const selfOnly = new Set(selfOnlyRoleNames)\n return async ({ data, operation, originalDoc, req }) => {\n if (!req.user || !data || operation !== 'update') {\n return data\n }\n const targetId: unknown = originalDoc?.id\n if (\n req.user.collection === userCollectionSlug &&\n (typeof targetId === 'number' || typeof targetId === 'string') &&\n String(targetId) === String(req.user.id)\n ) {\n return data\n }\n\n // `data.password` is only deleted after collection beforeChange hooks run,\n // so a password change is visible here; identity fields are compared so a\n // full-document save with an unchanged email still passes.\n const changingPassword = typeof data.password === 'string' && data.password.length > 0\n const changedField = identityFields.find(\n (field) => data[field] != null && data[field] !== originalDoc?.[field],\n )\n if (!changingPassword && !changedField) {\n return data\n }\n\n const targetRoleIds = normalizeRoleIds(originalDoc?.[rolesFieldName])\n if (targetRoleIds.length === 0) {\n return data\n }\n const { docs } = await req.payload.find({\n collection: rolesCollectionSlug,\n depth: 0,\n overrideAccess: true,\n pagination: false,\n req,\n where: { id: { in: targetRoleIds } },\n })\n const protectedRole = docs.find((doc) => typeof doc.name === 'string' && selfOnly.has(doc.name))\n if (!protectedRole) {\n return data\n }\n\n if (changingPassword) {\n throw new APIError(\n `The password of a user holding the \"${String(protectedRole.name)}\" role can only be changed by that user — send them a password-reset email instead.`,\n 403,\n )\n }\n throw new APIError(\n `The ${changedField} of a user holding the \"${String(protectedRole.name)}\" role can only be changed by that user.`,\n 403,\n )\n }\n}\n"],"mappings":";;;;AAeA,MAAM,iBAAiB,CAAC,SAAS,WAAW;;;;;;;;;;AAW5C,MAAa,gCAAgC,EAC3C,qBACA,gBACA,mBACA,yBACwD;CACxD,MAAM,WAAW,IAAI,IAAI,kBAAkB;AAC3C,QAAO,OAAO,EAAE,MAAM,WAAW,aAAa,UAAU;AACtD,MAAI,CAAC,IAAI,QAAQ,CAAC,QAAQ,cAAc,SACtC,QAAO;EAET,MAAMA,WAAoB,aAAa;AACvC,MACE,IAAI,KAAK,eAAe,uBACvB,OAAO,aAAa,YAAY,OAAO,aAAa,aACrD,OAAO,SAAS,KAAK,OAAO,IAAI,KAAK,GAAG,CAExC,QAAO;EAMT,MAAM,mBAAmB,OAAO,KAAK,aAAa,YAAY,KAAK,SAAS,SAAS;EACrF,MAAM,eAAe,eAAe,MACjC,UAAU,KAAK,UAAU,QAAQ,KAAK,WAAW,cAAc,OACjE;AACD,MAAI,CAAC,oBAAoB,CAAC,aACxB,QAAO;EAGT,MAAM,gBAAgB,iBAAiB,cAAc,gBAAgB;AACrE,MAAI,cAAc,WAAW,EAC3B,QAAO;EAET,MAAM,EAAE,SAAS,MAAM,IAAI,QAAQ,KAAK;GACtC,YAAY;GACZ,OAAO;GACP,gBAAgB;GAChB,YAAY;GACZ;GACA,OAAO,EAAE,IAAI,EAAE,IAAI,eAAe,EAAE;GACrC,CAAC;EACF,MAAM,gBAAgB,KAAK,MAAM,QAAQ,OAAO,IAAI,SAAS,YAAY,SAAS,IAAI,IAAI,KAAK,CAAC;AAChG,MAAI,CAAC,cACH,QAAO;AAGT,MAAI,iBACF,OAAM,IAAI,SACR,uCAAuC,OAAO,cAAc,KAAK,CAAC,sFAClE,IACD;AAEH,QAAM,IAAI,SACR,OAAO,aAAa,0BAA0B,OAAO,cAAc,KAAK,CAAC,2CACzE,IACD"}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { CollectionBeforeChangeHook, CollectionBeforeDeleteHook } from "payload";
|
|
2
|
+
|
|
3
|
+
//#region src/hooks/protectLastAdmin.d.ts
|
|
4
|
+
type ProtectLastAdminArgs = {
|
|
5
|
+
adminRoleName: string;
|
|
6
|
+
rolesCollectionSlug: string;
|
|
7
|
+
rolesFieldName: string;
|
|
8
|
+
/** Every collection carrying the roles field — admin holders are counted across all of them. */
|
|
9
|
+
userCollections: string[];
|
|
10
|
+
/** Slug of the user collection this hook is installed on. */
|
|
11
|
+
userCollectionSlug: string;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Guards the last administrator: removing the admin role from the only user who
|
|
15
|
+
* holds it would strand the system — the escalation guard then blocks everyone
|
|
16
|
+
* from assigning a `'*'` role again. Applies to any authenticated write, including
|
|
17
|
+
* the last admin editing themselves; writes without a user (seeds, init scripts)
|
|
18
|
+
* pass through.
|
|
19
|
+
*/
|
|
20
|
+
declare const createProtectLastAdminChangeHook: (args: ProtectLastAdminArgs) => CollectionBeforeChangeHook;
|
|
21
|
+
/**
|
|
22
|
+
* Blocks deleting the last user holding the admin role — the counterpart of the
|
|
23
|
+
* change guard for the delete operation.
|
|
24
|
+
*/
|
|
25
|
+
declare const createProtectLastAdminDeleteHook: (args: ProtectLastAdminArgs) => CollectionBeforeDeleteHook;
|
|
26
|
+
//#endregion
|
|
27
|
+
export { ProtectLastAdminArgs, createProtectLastAdminChangeHook, createProtectLastAdminDeleteHook };
|
|
28
|
+
//# sourceMappingURL=protectLastAdmin.d.ts.map
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { normalizeRoleIds } from "./protectRolesField.js";
|
|
2
|
+
import { APIError } from "payload";
|
|
3
|
+
|
|
4
|
+
//#region src/hooks/protectLastAdmin.ts
|
|
5
|
+
const getAdminRoleId = async (req, { adminRoleName, rolesCollectionSlug }) => {
|
|
6
|
+
const { docs } = await req.payload.find({
|
|
7
|
+
collection: rolesCollectionSlug,
|
|
8
|
+
depth: 0,
|
|
9
|
+
limit: 1,
|
|
10
|
+
overrideAccess: true,
|
|
11
|
+
req,
|
|
12
|
+
where: { name: { equals: adminRoleName } }
|
|
13
|
+
});
|
|
14
|
+
const id = docs[0]?.id;
|
|
15
|
+
return typeof id === "number" || typeof id === "string" ? id : void 0;
|
|
16
|
+
};
|
|
17
|
+
const otherAdminUsersExist = async (req, adminRoleId, excludeId, { rolesFieldName, userCollections, userCollectionSlug }) => {
|
|
18
|
+
for (const slug of userCollections) {
|
|
19
|
+
const holdsAdminRole = { [rolesFieldName]: { in: [adminRoleId] } };
|
|
20
|
+
const { totalDocs } = await req.payload.count({
|
|
21
|
+
collection: slug,
|
|
22
|
+
overrideAccess: true,
|
|
23
|
+
req,
|
|
24
|
+
where: slug === userCollectionSlug && excludeId !== void 0 ? { and: [holdsAdminRole, { id: { not_equals: excludeId } }] } : holdsAdminRole
|
|
25
|
+
});
|
|
26
|
+
if (totalDocs > 0) return true;
|
|
27
|
+
}
|
|
28
|
+
return false;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Guards the last administrator: removing the admin role from the only user who
|
|
32
|
+
* holds it would strand the system — the escalation guard then blocks everyone
|
|
33
|
+
* from assigning a `'*'` role again. Applies to any authenticated write, including
|
|
34
|
+
* the last admin editing themselves; writes without a user (seeds, init scripts)
|
|
35
|
+
* pass through.
|
|
36
|
+
*/
|
|
37
|
+
const createProtectLastAdminChangeHook = (args) => {
|
|
38
|
+
const { adminRoleName, rolesFieldName } = args;
|
|
39
|
+
return async ({ data, originalDoc, req }) => {
|
|
40
|
+
if (!req.user || !data || !(rolesFieldName in data)) return data;
|
|
41
|
+
const after = new Set(normalizeRoleIds(data[rolesFieldName]).map(String));
|
|
42
|
+
const removedIds = normalizeRoleIds(originalDoc?.[rolesFieldName]).filter((id) => !after.has(String(id)));
|
|
43
|
+
if (removedIds.length === 0) return data;
|
|
44
|
+
const adminRoleId = await getAdminRoleId(req, args);
|
|
45
|
+
if (adminRoleId === void 0 || !removedIds.some((id) => String(id) === String(adminRoleId))) return data;
|
|
46
|
+
const excludeId = originalDoc?.id;
|
|
47
|
+
if (!await otherAdminUsersExist(req, adminRoleId, excludeId, args)) throw new APIError(`Cannot remove the "${adminRoleName}" role from the last user holding it — at least one administrator must remain.`, 403);
|
|
48
|
+
return data;
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Blocks deleting the last user holding the admin role — the counterpart of the
|
|
53
|
+
* change guard for the delete operation.
|
|
54
|
+
*/
|
|
55
|
+
const createProtectLastAdminDeleteHook = (args) => {
|
|
56
|
+
const { adminRoleName, rolesFieldName, userCollectionSlug } = args;
|
|
57
|
+
return async ({ id, req }) => {
|
|
58
|
+
if (!req.user) return;
|
|
59
|
+
const roleIds = normalizeRoleIds((await req.payload.findByID({
|
|
60
|
+
id,
|
|
61
|
+
collection: userCollectionSlug,
|
|
62
|
+
depth: 0,
|
|
63
|
+
disableErrors: true,
|
|
64
|
+
overrideAccess: true,
|
|
65
|
+
req
|
|
66
|
+
}))?.[rolesFieldName]).map(String);
|
|
67
|
+
if (roleIds.length === 0) return;
|
|
68
|
+
const adminRoleId = await getAdminRoleId(req, args);
|
|
69
|
+
if (adminRoleId === void 0 || !roleIds.includes(String(adminRoleId))) return;
|
|
70
|
+
if (!await otherAdminUsersExist(req, adminRoleId, id, args)) throw new APIError(`Cannot delete the last user holding the "${adminRoleName}" role — at least one administrator must remain.`, 403);
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
//#endregion
|
|
75
|
+
export { createProtectLastAdminChangeHook, createProtectLastAdminDeleteHook };
|
|
76
|
+
//# sourceMappingURL=protectLastAdmin.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"protectLastAdmin.js","names":[],"sources":["../../src/hooks/protectLastAdmin.ts"],"sourcesContent":["import type {\n CollectionBeforeChangeHook,\n CollectionBeforeDeleteHook,\n PayloadRequest,\n} from 'payload'\n\nimport { APIError } from 'payload'\n\nimport { normalizeRoleIds } from './protectRolesField.js'\n\nexport type ProtectLastAdminArgs = {\n adminRoleName: string\n rolesCollectionSlug: string\n rolesFieldName: string\n /** Every collection carrying the roles field — admin holders are counted across all of them. */\n userCollections: string[]\n /** Slug of the user collection this hook is installed on. */\n userCollectionSlug: string\n}\n\nconst getAdminRoleId = async (\n req: PayloadRequest,\n { adminRoleName, rolesCollectionSlug }: ProtectLastAdminArgs,\n): Promise<number | string | undefined> => {\n const { docs } = await req.payload.find({\n collection: rolesCollectionSlug,\n depth: 0,\n limit: 1,\n overrideAccess: true,\n req,\n where: { name: { equals: adminRoleName } },\n })\n const id = (docs[0] as { id?: unknown } | undefined)?.id\n return typeof id === 'number' || typeof id === 'string' ? id : undefined\n}\n\nconst otherAdminUsersExist = async (\n req: PayloadRequest,\n adminRoleId: number | string,\n excludeId: number | string | undefined,\n { rolesFieldName, userCollections, userCollectionSlug }: ProtectLastAdminArgs,\n): Promise<boolean> => {\n for (const slug of userCollections) {\n const holdsAdminRole = { [rolesFieldName]: { in: [adminRoleId] } }\n const { totalDocs } = await req.payload.count({\n collection: slug,\n overrideAccess: true,\n req,\n where:\n slug === userCollectionSlug && excludeId !== undefined\n ? { and: [holdsAdminRole, { id: { not_equals: excludeId } }] }\n : holdsAdminRole,\n })\n if (totalDocs > 0) {\n return true\n }\n }\n return false\n}\n\n/**\n * Guards the last administrator: removing the admin role from the only user who\n * holds it would strand the system — the escalation guard then blocks everyone\n * from assigning a `'*'` role again. Applies to any authenticated write, including\n * the last admin editing themselves; writes without a user (seeds, init scripts)\n * pass through.\n */\nexport const createProtectLastAdminChangeHook = (\n args: ProtectLastAdminArgs,\n): CollectionBeforeChangeHook => {\n const { adminRoleName, rolesFieldName } = args\n return async ({ data, originalDoc, req }) => {\n if (!req.user || !data || !(rolesFieldName in data)) {\n return data\n }\n\n const after = new Set(normalizeRoleIds(data[rolesFieldName]).map(String))\n const removedIds = normalizeRoleIds(originalDoc?.[rolesFieldName]).filter(\n (id) => !after.has(String(id)),\n )\n if (removedIds.length === 0) {\n return data\n }\n\n const adminRoleId = await getAdminRoleId(req, args)\n if (adminRoleId === undefined || !removedIds.some((id) => String(id) === String(adminRoleId))) {\n return data\n }\n\n const excludeId = (originalDoc as { id?: number | string } | undefined)?.id\n if (!(await otherAdminUsersExist(req, adminRoleId, excludeId, args))) {\n throw new APIError(\n `Cannot remove the \"${adminRoleName}\" role from the last user holding it — at least one administrator must remain.`,\n 403,\n )\n }\n\n return data\n }\n}\n\n/**\n * Blocks deleting the last user holding the admin role — the counterpart of the\n * change guard for the delete operation.\n */\nexport const createProtectLastAdminDeleteHook = (\n args: ProtectLastAdminArgs,\n): CollectionBeforeDeleteHook => {\n const { adminRoleName, rolesFieldName, userCollectionSlug } = args\n return async ({ id, req }) => {\n if (!req.user) {\n return\n }\n\n const doc = (await req.payload.findByID({\n id,\n collection: userCollectionSlug,\n depth: 0,\n disableErrors: true,\n overrideAccess: true,\n req,\n })) as null | Record<string, unknown>\n const roleIds = normalizeRoleIds(doc?.[rolesFieldName]).map(String)\n if (roleIds.length === 0) {\n return\n }\n\n const adminRoleId = await getAdminRoleId(req, args)\n if (adminRoleId === undefined || !roleIds.includes(String(adminRoleId))) {\n return\n }\n\n if (!(await otherAdminUsersExist(req, adminRoleId, id, args))) {\n throw new APIError(\n `Cannot delete the last user holding the \"${adminRoleName}\" role — at least one administrator must remain.`,\n 403,\n )\n }\n }\n}\n"],"mappings":";;;;AAoBA,MAAM,iBAAiB,OACrB,KACA,EAAE,eAAe,0BACwB;CACzC,MAAM,EAAE,SAAS,MAAM,IAAI,QAAQ,KAAK;EACtC,YAAY;EACZ,OAAO;EACP,OAAO;EACP,gBAAgB;EAChB;EACA,OAAO,EAAE,MAAM,EAAE,QAAQ,eAAe,EAAE;EAC3C,CAAC;CACF,MAAM,KAAM,KAAK,IAAqC;AACtD,QAAO,OAAO,OAAO,YAAY,OAAO,OAAO,WAAW,KAAK;;AAGjE,MAAM,uBAAuB,OAC3B,KACA,aACA,WACA,EAAE,gBAAgB,iBAAiB,yBACd;AACrB,MAAK,MAAM,QAAQ,iBAAiB;EAClC,MAAM,iBAAiB,GAAG,iBAAiB,EAAE,IAAI,CAAC,YAAY,EAAE,EAAE;EAClE,MAAM,EAAE,cAAc,MAAM,IAAI,QAAQ,MAAM;GAC5C,YAAY;GACZ,gBAAgB;GAChB;GACA,OACE,SAAS,sBAAsB,cAAc,SACzC,EAAE,KAAK,CAAC,gBAAgB,EAAE,IAAI,EAAE,YAAY,WAAW,EAAE,CAAC,EAAE,GAC5D;GACP,CAAC;AACF,MAAI,YAAY,EACd,QAAO;;AAGX,QAAO;;;;;;;;;AAUT,MAAa,oCACX,SAC+B;CAC/B,MAAM,EAAE,eAAe,mBAAmB;AAC1C,QAAO,OAAO,EAAE,MAAM,aAAa,UAAU;AAC3C,MAAI,CAAC,IAAI,QAAQ,CAAC,QAAQ,EAAE,kBAAkB,MAC5C,QAAO;EAGT,MAAM,QAAQ,IAAI,IAAI,iBAAiB,KAAK,gBAAgB,CAAC,IAAI,OAAO,CAAC;EACzE,MAAM,aAAa,iBAAiB,cAAc,gBAAgB,CAAC,QAChE,OAAO,CAAC,MAAM,IAAI,OAAO,GAAG,CAAC,CAC/B;AACD,MAAI,WAAW,WAAW,EACxB,QAAO;EAGT,MAAM,cAAc,MAAM,eAAe,KAAK,KAAK;AACnD,MAAI,gBAAgB,UAAa,CAAC,WAAW,MAAM,OAAO,OAAO,GAAG,KAAK,OAAO,YAAY,CAAC,CAC3F,QAAO;EAGT,MAAM,YAAa,aAAsD;AACzE,MAAI,CAAE,MAAM,qBAAqB,KAAK,aAAa,WAAW,KAAK,CACjE,OAAM,IAAI,SACR,sBAAsB,cAAc,iFACpC,IACD;AAGH,SAAO;;;;;;;AAQX,MAAa,oCACX,SAC+B;CAC/B,MAAM,EAAE,eAAe,gBAAgB,uBAAuB;AAC9D,QAAO,OAAO,EAAE,IAAI,UAAU;AAC5B,MAAI,CAAC,IAAI,KACP;EAWF,MAAM,UAAU,kBARH,MAAM,IAAI,QAAQ,SAAS;GACtC;GACA,YAAY;GACZ,OAAO;GACP,eAAe;GACf,gBAAgB;GAChB;GACD,CAAC,IACqC,gBAAgB,CAAC,IAAI,OAAO;AACnE,MAAI,QAAQ,WAAW,EACrB;EAGF,MAAM,cAAc,MAAM,eAAe,KAAK,KAAK;AACnD,MAAI,gBAAgB,UAAa,CAAC,QAAQ,SAAS,OAAO,YAAY,CAAC,CACrE;AAGF,MAAI,CAAE,MAAM,qBAAqB,KAAK,aAAa,IAAI,KAAK,CAC1D,OAAM,IAAI,SACR,4CAA4C,cAAc,mDAC1D,IACD"}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { CollectionBeforeChangeHook } from "payload";
|
|
2
|
+
|
|
3
|
+
//#region src/hooks/protectRolesCollection.d.ts
|
|
4
|
+
type ProtectRolesCollectionArgs = {
|
|
5
|
+
/**
|
|
6
|
+
* Roles locked to their code definition. The escalation check is skipped for
|
|
7
|
+
* them: the protected-role guard (which runs first) only lets through writes
|
|
8
|
+
* that exactly restore the code-defined permissions, and that restore must stay
|
|
9
|
+
* possible even when nobody holds the drifted-away permissions anymore.
|
|
10
|
+
*/
|
|
11
|
+
protectedRoleNames?: string[];
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Privilege-escalation guard installed on the roles collection: a user editing or
|
|
15
|
+
* creating a role may only add permissions their own roles already cover — without
|
|
16
|
+
* this, anyone with update access on the roles collection could widen their own
|
|
17
|
+
* role to full access. Users with `'*'` may grant anything; writes without a user
|
|
18
|
+
* pass through.
|
|
19
|
+
*/
|
|
20
|
+
declare const createProtectRolesCollectionHook: ({
|
|
21
|
+
protectedRoleNames
|
|
22
|
+
}?: ProtectRolesCollectionArgs) => CollectionBeforeChangeHook;
|
|
23
|
+
//#endregion
|
|
24
|
+
export { ProtectRolesCollectionArgs, createProtectRolesCollectionHook };
|
|
25
|
+
//# sourceMappingURL=protectRolesCollection.d.ts.map
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { missingPermissions } from "../permissions.js";
|
|
2
|
+
import { getUserPermissions } from "../utilities/getUserPermissions.js";
|
|
3
|
+
import { APIError } from "payload";
|
|
4
|
+
|
|
5
|
+
//#region src/hooks/protectRolesCollection.ts
|
|
6
|
+
/**
|
|
7
|
+
* Privilege-escalation guard installed on the roles collection: a user editing or
|
|
8
|
+
* creating a role may only add permissions their own roles already cover — without
|
|
9
|
+
* this, anyone with update access on the roles collection could widen their own
|
|
10
|
+
* role to full access. Users with `'*'` may grant anything; writes without a user
|
|
11
|
+
* pass through.
|
|
12
|
+
*/
|
|
13
|
+
const createProtectRolesCollectionHook = ({ protectedRoleNames = [] } = {}) => {
|
|
14
|
+
const protectedNames = new Set(protectedRoleNames);
|
|
15
|
+
return async ({ data, originalDoc, req }) => {
|
|
16
|
+
if (!req.user || !data || !("permissions" in data)) return data;
|
|
17
|
+
const name = typeof originalDoc?.name === "string" ? originalDoc.name : typeof data.name === "string" ? data.name : void 0;
|
|
18
|
+
if (name !== void 0 && protectedNames.has(name)) return data;
|
|
19
|
+
const previous = new Set(Array.isArray(originalDoc?.permissions) ? originalDoc.permissions : []);
|
|
20
|
+
const added = (Array.isArray(data.permissions) ? data.permissions : []).filter((permission) => typeof permission === "string" && !previous.has(permission));
|
|
21
|
+
if (added.length === 0) return data;
|
|
22
|
+
const missing = missingPermissions(await getUserPermissions(req), added);
|
|
23
|
+
if (missing.length > 0) throw new APIError(`You cannot grant permissions you do not hold: ${missing.join(", ")}`, 403);
|
|
24
|
+
return data;
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
//#endregion
|
|
29
|
+
export { createProtectRolesCollectionHook };
|
|
30
|
+
//# sourceMappingURL=protectRolesCollection.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"protectRolesCollection.js","names":[],"sources":["../../src/hooks/protectRolesCollection.ts"],"sourcesContent":["import type { CollectionBeforeChangeHook } from 'payload'\n\nimport { APIError } from 'payload'\n\nimport { missingPermissions } from '../permissions.js'\nimport { getUserPermissions } from '../utilities/getUserPermissions.js'\n\nexport type ProtectRolesCollectionArgs = {\n /**\n * Roles locked to their code definition. The escalation check is skipped for\n * them: the protected-role guard (which runs first) only lets through writes\n * that exactly restore the code-defined permissions, and that restore must stay\n * possible even when nobody holds the drifted-away permissions anymore.\n */\n protectedRoleNames?: string[]\n}\n\n/**\n * Privilege-escalation guard installed on the roles collection: a user editing or\n * creating a role may only add permissions their own roles already cover — without\n * this, anyone with update access on the roles collection could widen their own\n * role to full access. Users with `'*'` may grant anything; writes without a user\n * pass through.\n */\nexport const createProtectRolesCollectionHook = ({\n protectedRoleNames = [],\n}: ProtectRolesCollectionArgs = {}): CollectionBeforeChangeHook => {\n const protectedNames = new Set(protectedRoleNames)\n return async ({ data, originalDoc, req }) => {\n if (!req.user || !data || !('permissions' in data)) {\n return data\n }\n\n const name =\n typeof originalDoc?.name === 'string'\n ? originalDoc.name\n : typeof data.name === 'string'\n ? data.name\n : undefined\n if (name !== undefined && protectedNames.has(name)) {\n return data\n }\n\n const previous = new Set<string>(\n Array.isArray(originalDoc?.permissions) ? originalDoc.permissions : [],\n )\n const added = (Array.isArray(data.permissions) ? data.permissions : []).filter(\n (permission): permission is string =>\n typeof permission === 'string' && !previous.has(permission),\n )\n if (added.length === 0) {\n return data\n }\n\n const granted = await getUserPermissions(req)\n const missing = missingPermissions(granted, added)\n if (missing.length > 0) {\n throw new APIError(`You cannot grant permissions you do not hold: ${missing.join(', ')}`, 403)\n }\n\n return data\n }\n}\n"],"mappings":";;;;;;;;;;;;AAwBA,MAAa,oCAAoC,EAC/C,qBAAqB,EAAE,KACO,EAAE,KAAiC;CACjE,MAAM,iBAAiB,IAAI,IAAI,mBAAmB;AAClD,QAAO,OAAO,EAAE,MAAM,aAAa,UAAU;AAC3C,MAAI,CAAC,IAAI,QAAQ,CAAC,QAAQ,EAAE,iBAAiB,MAC3C,QAAO;EAGT,MAAM,OACJ,OAAO,aAAa,SAAS,WACzB,YAAY,OACZ,OAAO,KAAK,SAAS,WACnB,KAAK,OACL;AACR,MAAI,SAAS,UAAa,eAAe,IAAI,KAAK,CAChD,QAAO;EAGT,MAAM,WAAW,IAAI,IACnB,MAAM,QAAQ,aAAa,YAAY,GAAG,YAAY,cAAc,EAAE,CACvE;EACD,MAAM,SAAS,MAAM,QAAQ,KAAK,YAAY,GAAG,KAAK,cAAc,EAAE,EAAE,QACrE,eACC,OAAO,eAAe,YAAY,CAAC,SAAS,IAAI,WAAW,CAC9D;AACD,MAAI,MAAM,WAAW,EACnB,QAAO;EAIT,MAAM,UAAU,mBADA,MAAM,mBAAmB,IAAI,EACD,MAAM;AAClD,MAAI,QAAQ,SAAS,EACnB,OAAM,IAAI,SAAS,iDAAiD,QAAQ,KAAK,KAAK,IAAI,IAAI;AAGhG,SAAO"}
|