drupal-mcp-connector 0.6.1
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/CHANGELOG.md +92 -0
- package/LICENSE +21 -0
- package/README.md +193 -0
- package/config/config.example.json +122 -0
- package/package.json +70 -0
- package/src/index.js +499 -0
- package/src/lib/backends/backend-interface.js +164 -0
- package/src/lib/backends/errors.js +31 -0
- package/src/lib/backends/graphql-filter.js +99 -0
- package/src/lib/backends/graphql-names.js +63 -0
- package/src/lib/backends/graphql-normalize.js +73 -0
- package/src/lib/backends/graphql-query.js +129 -0
- package/src/lib/backends/graphql-schema.js +226 -0
- package/src/lib/backends/graphql.js +391 -0
- package/src/lib/backends/index.js +128 -0
- package/src/lib/backends/jsonapi.js +403 -0
- package/src/lib/canonical.js +68 -0
- package/src/lib/config.js +257 -0
- package/src/lib/drupal-fetch.js +144 -0
- package/src/lib/errors.js +38 -0
- package/src/lib/http-auth.js +27 -0
- package/src/lib/oauth.js +177 -0
- package/src/lib/reports-support.js +75 -0
- package/src/lib/security.js +475 -0
- package/src/lib/validate.js +225 -0
- package/src/tools/drush.js +463 -0
- package/src/tools/entities.js +262 -0
- package/src/tools/graphql.js +175 -0
- package/src/tools/media.js +297 -0
- package/src/tools/nodes.js +247 -0
- package/src/tools/reports.js +609 -0
- package/src/tools/site.js +87 -0
- package/src/tools/taxonomy.js +202 -0
- package/src/tools/users.js +250 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers for the reporting tools — backend-neutral.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** Canonical base property names (read directly off the entity, not from `fields`). */
|
|
6
|
+
const BASE_KEYS = new Set(["id", "title", "status", "langcode", "created", "changed", "url"]);
|
|
7
|
+
|
|
8
|
+
/** Whole-day length in milliseconds, used by daysSince(). */
|
|
9
|
+
const MS_PER_DAY = 86400000;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Collect up to maxItems canonical entities by paging listEntities via offset.
|
|
13
|
+
* Stops early once a short page or a falsy hasNext signals the end of results.
|
|
14
|
+
* @param {{listEntities: Function}} backend Resolved backend with listEntities().
|
|
15
|
+
* @param {object} descriptor Query descriptor passed through to listEntities.
|
|
16
|
+
* @param {number} [maxItems] Hard cap on total entities returned.
|
|
17
|
+
* @param {number} [chunk] Per-request page size (default 50, matching JSON:API's cap).
|
|
18
|
+
* @returns {Promise<object[]>} Up to maxItems canonical entities.
|
|
19
|
+
*/
|
|
20
|
+
export async function collectEntities(backend, descriptor, maxItems = 100, chunk = 50) {
|
|
21
|
+
const out = [];
|
|
22
|
+
let offset = 0;
|
|
23
|
+
for (;;) {
|
|
24
|
+
const limit = Math.min(chunk, maxItems - out.length);
|
|
25
|
+
if (limit <= 0) break;
|
|
26
|
+
const res = await backend.listEntities({ ...descriptor, page: { limit, offset } });
|
|
27
|
+
const batch = res.entities ?? [];
|
|
28
|
+
out.push(...batch);
|
|
29
|
+
if (batch.length < limit || !res.page?.hasNext) break;
|
|
30
|
+
offset += batch.length;
|
|
31
|
+
}
|
|
32
|
+
return out.slice(0, maxItems);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Build a structured "report unavailable" result (returned, not thrown, so a
|
|
37
|
+
* caller can report a gated feature without aborting a batch of reports).
|
|
38
|
+
* @param {string} report Report identifier.
|
|
39
|
+
* @param {string} backend Backend the report was attempted against.
|
|
40
|
+
* @param {string} reason Why the report cannot run here.
|
|
41
|
+
* @returns {{unavailable: true, report: string, backend: string, reason: string}}
|
|
42
|
+
*/
|
|
43
|
+
export function gatedReport(report, backend, reason) {
|
|
44
|
+
return { unavailable: true, report, backend, reason };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Read a value from a canonical entity by trying base props then `fields`,
|
|
49
|
+
* returning the first candidate name that resolves to a defined value.
|
|
50
|
+
* @param {object} entity Canonical entity.
|
|
51
|
+
* @param {string[]} candidates Field/prop names to try, in priority order.
|
|
52
|
+
* @returns {*} The first matching value, or undefined if none match.
|
|
53
|
+
*/
|
|
54
|
+
export function fieldValue(entity, candidates) {
|
|
55
|
+
const base = new Map(Object.entries(entity));
|
|
56
|
+
const fields = entity.fields ? new Map(Object.entries(entity.fields)) : new Map();
|
|
57
|
+
for (const name of candidates) {
|
|
58
|
+
if (BASE_KEYS.has(name)) {
|
|
59
|
+
if (base.has(name) && base.get(name) !== undefined) return base.get(name);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (fields.has(name)) return fields.get(name);
|
|
63
|
+
}
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Whole days elapsed between a date and now.
|
|
69
|
+
* @param {?(string|number|Date)} dateValue A date parseable by `new Date()`.
|
|
70
|
+
* @returns {?number} Whole days since the date, or null when no date is given.
|
|
71
|
+
*/
|
|
72
|
+
export function daysSince(dateValue) {
|
|
73
|
+
if (!dateValue) return null;
|
|
74
|
+
return Math.floor((Date.now() - new Date(dateValue).getTime()) / MS_PER_DAY);
|
|
75
|
+
}
|
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
import { parse } from "graphql";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Security layer — connector-level access control.
|
|
5
|
+
*
|
|
6
|
+
* This is a SECOND layer of defense on top of Drupal's own permission system.
|
|
7
|
+
* It lets you restrict what the MCP server will even attempt, regardless of
|
|
8
|
+
* what the API credential is capable of doing.
|
|
9
|
+
*
|
|
10
|
+
* Configuration lives in config.json under each site's "security" key.
|
|
11
|
+
* See config/config.example.json for annotated examples.
|
|
12
|
+
*
|
|
13
|
+
* ─── Quick presets ────────────────────────────────────────────────────────
|
|
14
|
+
*
|
|
15
|
+
* "preset": "development" Everything allowed. Default if no security key.
|
|
16
|
+
* "preset": "content-editor" Create/edit nodes+media. No user mgmt, no deletes.
|
|
17
|
+
* "preset": "auditor" Read-only. All entity types. User fields redacted.
|
|
18
|
+
* "preset": "production-strict" Read-only. Explicit allowlist required. Redacts PII.
|
|
19
|
+
* "preset": "write-plane" Governed writes (no delete/mutations) on node, term, media.
|
|
20
|
+
*
|
|
21
|
+
* Presets can be overridden by adding explicit keys alongside them.
|
|
22
|
+
*
|
|
23
|
+
* ─── Explicit config keys ─────────────────────────────────────────────────
|
|
24
|
+
*
|
|
25
|
+
* readOnly true → reject all create/update/delete/graphql-mutation calls
|
|
26
|
+
* allowDestructive false → reject all delete operations
|
|
27
|
+
* allowGraphqlMutations false → reject drupal_graphql when mutation is detected
|
|
28
|
+
*
|
|
29
|
+
* allowedEntityTypes string[] | null null = allow all; array = allowlist
|
|
30
|
+
* deniedEntityTypes string[] always-blocked entity types
|
|
31
|
+
*
|
|
32
|
+
* entityRules object per-entity-type overrides:
|
|
33
|
+
* [entityType]:
|
|
34
|
+
* allowedOperations ["read","create","update","delete"] (or subset)
|
|
35
|
+
* allowedBundles string[] | null
|
|
36
|
+
* deniedBundles string[]
|
|
37
|
+
* redactedFields string[] stripped from ALL responses for this type
|
|
38
|
+
*
|
|
39
|
+
* globalRedactedFields string[] stripped from every response, every type
|
|
40
|
+
*
|
|
41
|
+
* ─── Field redaction ──────────────────────────────────────────────────────
|
|
42
|
+
*
|
|
43
|
+
* Redacted fields are replaced with "[REDACTED]" in response attributes.
|
|
44
|
+
* Recommended to include for user entities: "pass", "mail" (if PII concern).
|
|
45
|
+
* The connector never writes these fields either when redaction is active.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Preset definitions
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
const PRESETS = {
|
|
53
|
+
development: {
|
|
54
|
+
readOnly: false,
|
|
55
|
+
allowDestructive: true,
|
|
56
|
+
allowGraphqlMutations: true,
|
|
57
|
+
allowedEntityTypes: null,
|
|
58
|
+
deniedEntityTypes: [],
|
|
59
|
+
entityRules: {},
|
|
60
|
+
globalRedactedFields: [],
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
"content-editor": {
|
|
64
|
+
readOnly: false,
|
|
65
|
+
allowDestructive: false, // no deletes
|
|
66
|
+
allowGraphqlMutations: false,
|
|
67
|
+
allowedEntityTypes: ["node", "media", "file", "taxonomy_term", "menu_link_content"],
|
|
68
|
+
deniedEntityTypes: ["user"],
|
|
69
|
+
entityRules: {
|
|
70
|
+
node: { allowedOperations: ["read", "create", "update"] },
|
|
71
|
+
media: { allowedOperations: ["read", "create", "update"] },
|
|
72
|
+
file: { allowedOperations: ["read", "create"] },
|
|
73
|
+
taxonomy_term: { allowedOperations: ["read", "create", "update"] },
|
|
74
|
+
},
|
|
75
|
+
globalRedactedFields: [],
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
auditor: {
|
|
79
|
+
readOnly: true,
|
|
80
|
+
allowDestructive: false,
|
|
81
|
+
allowGraphqlMutations: false,
|
|
82
|
+
allowedEntityTypes: null, // read any entity type
|
|
83
|
+
deniedEntityTypes: [],
|
|
84
|
+
entityRules: {
|
|
85
|
+
user: {
|
|
86
|
+
allowedOperations: ["read"],
|
|
87
|
+
redactedFields: ["pass", "mail"],
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
globalRedactedFields: [],
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
"production-strict": {
|
|
94
|
+
readOnly: true,
|
|
95
|
+
allowDestructive: false,
|
|
96
|
+
allowGraphqlMutations: false,
|
|
97
|
+
allowedEntityTypes: null, // set an explicit allowlist in your config
|
|
98
|
+
deniedEntityTypes: ["user"], // no user data at all
|
|
99
|
+
entityRules: {},
|
|
100
|
+
globalRedactedFields: ["pass", "mail", "field_private", "field_api_key", "field_token"],
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
"write-plane": {
|
|
104
|
+
// Mirrors the server-side governance profile for agent writes. The
|
|
105
|
+
// Drupal-side governance layer remains authoritative; this is defence in depth.
|
|
106
|
+
readOnly: false,
|
|
107
|
+
allowDestructive: false, // no deletes
|
|
108
|
+
allowGraphqlMutations: false, // writes go through the JSON:API plane
|
|
109
|
+
allowedEntityTypes: ["node", "taxonomy_term", "media"],
|
|
110
|
+
deniedEntityTypes: ["user"],
|
|
111
|
+
entityRules: {},
|
|
112
|
+
globalRedactedFields: ["pass", "mail"],
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// Resolve effective security config for a site
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Resolve a site's effective security config by layering explicit `security`
|
|
122
|
+
* keys over the selected preset (explicit keys win; redacted-field lists merge).
|
|
123
|
+
* @param {object} site Site config (reads site.security).
|
|
124
|
+
* @returns {object} Effective security config used by the assert/redact helpers.
|
|
125
|
+
*/
|
|
126
|
+
export function resolveSecurityConfig(site) {
|
|
127
|
+
const raw = site.security ?? {};
|
|
128
|
+
const preset = PRESETS[raw.preset ?? "development"] ?? PRESETS.development;
|
|
129
|
+
|
|
130
|
+
// Merge: explicit keys in site.security override the preset
|
|
131
|
+
return {
|
|
132
|
+
readOnly: raw.readOnly ?? preset.readOnly,
|
|
133
|
+
allowDestructive: raw.allowDestructive ?? preset.allowDestructive,
|
|
134
|
+
allowGraphqlMutations: raw.allowGraphqlMutations ?? preset.allowGraphqlMutations,
|
|
135
|
+
allowedEntityTypes: raw.allowedEntityTypes ?? preset.allowedEntityTypes,
|
|
136
|
+
deniedEntityTypes: raw.deniedEntityTypes ?? preset.deniedEntityTypes,
|
|
137
|
+
entityRules: mergeEntityRules(preset.entityRules, raw.entityRules ?? {}),
|
|
138
|
+
globalRedactedFields: [
|
|
139
|
+
...(preset.globalRedactedFields ?? []),
|
|
140
|
+
...(raw.globalRedactedFields ?? []),
|
|
141
|
+
],
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Shallow-merge per-entity-type rule objects, with override rules taking
|
|
147
|
+
* priority over preset rules for each entity type.
|
|
148
|
+
* @param {object} base Preset entityRules.
|
|
149
|
+
* @param {object} override Site-supplied entityRules.
|
|
150
|
+
* @returns {object} Merged entityRules.
|
|
151
|
+
*/
|
|
152
|
+
function mergeEntityRules(base, override) {
|
|
153
|
+
const entries = Object.entries(override).map(([entityType, rules]) => {
|
|
154
|
+
const baseRules = new Map(Object.entries(base)).get(entityType) ?? {};
|
|
155
|
+
return [entityType, { ...baseRules, ...rules }];
|
|
156
|
+
});
|
|
157
|
+
return { ...base, ...Object.fromEntries(entries) };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
// SecurityError
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
/** Error thrown when a connector-level security policy blocks an operation. */
|
|
165
|
+
export class SecurityError extends Error {
|
|
166
|
+
/** @param {string} message Human-readable reason the operation was blocked. */
|
|
167
|
+
constructor(message) {
|
|
168
|
+
super(message);
|
|
169
|
+
this.name = "SecurityError";
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
// Assertion helpers — throw SecurityError if a check fails
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* @param {object} secConfig Resolved security config.
|
|
179
|
+
* @param {string} operationLabel Label used in the error message.
|
|
180
|
+
* @returns {void}
|
|
181
|
+
* @throws {SecurityError} if the site is configured read-only.
|
|
182
|
+
*/
|
|
183
|
+
export function assertNotReadOnly(secConfig, operationLabel) {
|
|
184
|
+
if (secConfig.readOnly) {
|
|
185
|
+
throw new SecurityError(
|
|
186
|
+
`This site is configured as read-only. Operation blocked: ${operationLabel}. ` +
|
|
187
|
+
"To enable writes, set security.readOnly = false in your config."
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* @param {object} secConfig Resolved security config.
|
|
194
|
+
* @param {string} entityType Entity type targeted by the delete.
|
|
195
|
+
* @param {string} id Entity id targeted by the delete.
|
|
196
|
+
* @returns {void}
|
|
197
|
+
* @throws {SecurityError} if destructive (delete) operations are disabled.
|
|
198
|
+
*/
|
|
199
|
+
export function assertDestructiveAllowed(secConfig, entityType, id) {
|
|
200
|
+
if (!secConfig.allowDestructive) {
|
|
201
|
+
throw new SecurityError(
|
|
202
|
+
"Destructive operations (delete) are disabled for this site. " +
|
|
203
|
+
`Blocked: delete ${entityType} ${id}. ` +
|
|
204
|
+
"To enable, set security.allowDestructive = true in your config."
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Detect whether a GraphQL document contains a mutation operation.
|
|
211
|
+
* Uses a real parser (robust against multi-operation docs, comments, and
|
|
212
|
+
* leading whitespace); falls back to a token-aware regex if the document
|
|
213
|
+
* does not parse.
|
|
214
|
+
* @param {string} query GraphQL document text.
|
|
215
|
+
* @returns {boolean} True if any operation is a mutation.
|
|
216
|
+
*/
|
|
217
|
+
function graphqlHasMutation(query) {
|
|
218
|
+
try {
|
|
219
|
+
const doc = parse(query);
|
|
220
|
+
return doc.definitions.some(
|
|
221
|
+
(d) => d.kind === "OperationDefinition" && d.operation === "mutation"
|
|
222
|
+
);
|
|
223
|
+
} catch {
|
|
224
|
+
// Unparseable — be conservative. Whole-string, token-aware (no ^/m anchors).
|
|
225
|
+
return /(^|[^A-Za-z0-9_])mutation\s+[A-Za-z_{]/.test(query);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* @param {object} secConfig Resolved security config.
|
|
231
|
+
* @param {string} query GraphQL document text.
|
|
232
|
+
* @returns {void} No-op for read-only (query) documents.
|
|
233
|
+
* @throws {SecurityError} if the document is a mutation and writes/mutations are disabled.
|
|
234
|
+
*/
|
|
235
|
+
export function assertGraphqlMutationAllowed(secConfig, query) {
|
|
236
|
+
const isMutation = graphqlHasMutation(query);
|
|
237
|
+
if (!isMutation) return;
|
|
238
|
+
|
|
239
|
+
// A read-only site blocks GraphQL mutations with the same switch as JSON:API writes.
|
|
240
|
+
if (secConfig.readOnly) {
|
|
241
|
+
assertNotReadOnly(secConfig, "graphql mutation");
|
|
242
|
+
}
|
|
243
|
+
if (!secConfig.allowGraphqlMutations) {
|
|
244
|
+
throw new SecurityError(
|
|
245
|
+
"GraphQL mutations are disabled for this site. " +
|
|
246
|
+
"Set security.allowGraphqlMutations = true to enable."
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* @param {object} secConfig Resolved security config.
|
|
253
|
+
* @param {string} entityType Entity type to check.
|
|
254
|
+
* @returns {void}
|
|
255
|
+
* @throws {SecurityError} if the type is denied, or not in a configured allowlist.
|
|
256
|
+
*/
|
|
257
|
+
export function assertEntityTypeAllowed(secConfig, entityType) {
|
|
258
|
+
// Check denylist first (takes priority over allowlist)
|
|
259
|
+
if (secConfig.deniedEntityTypes.includes(entityType)) {
|
|
260
|
+
throw new SecurityError(
|
|
261
|
+
`Access to entity type "${entityType}" is denied by security config (deniedEntityTypes).`
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Check allowlist
|
|
266
|
+
if (secConfig.allowedEntityTypes !== null) {
|
|
267
|
+
if (!secConfig.allowedEntityTypes.includes(entityType)) {
|
|
268
|
+
const allowed = secConfig.allowedEntityTypes.join(", ");
|
|
269
|
+
throw new SecurityError(
|
|
270
|
+
`Entity type "${entityType}" is not in the allowedEntityTypes list. ` +
|
|
271
|
+
`Allowed: ${allowed}`
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* @param {object} secConfig Resolved security config.
|
|
279
|
+
* @param {string} entityType Entity type owning the bundle.
|
|
280
|
+
* @param {string} bundle Bundle to check.
|
|
281
|
+
* @returns {void} No-op when the entity type has no bundle rules.
|
|
282
|
+
* @throws {SecurityError} if the bundle is denied, or not in a configured allowlist.
|
|
283
|
+
*/
|
|
284
|
+
export function assertBundleAllowed(secConfig, entityType, bundle) {
|
|
285
|
+
const rules = new Map(Object.entries(secConfig.entityRules)).get(entityType);
|
|
286
|
+
if (!rules) return; // no rules = allowed
|
|
287
|
+
|
|
288
|
+
if (rules.deniedBundles?.includes(bundle)) {
|
|
289
|
+
throw new SecurityError(
|
|
290
|
+
`Bundle "${bundle}" of entity type "${entityType}" is in the deniedBundles list.`
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
if (rules.allowedBundles !== null && rules.allowedBundles !== undefined) {
|
|
294
|
+
if (!rules.allowedBundles.includes(bundle)) {
|
|
295
|
+
throw new SecurityError(
|
|
296
|
+
`Bundle "${bundle}" is not in allowedBundles for "${entityType}". ` +
|
|
297
|
+
`Allowed: ${rules.allowedBundles.join(", ")}`
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* @param {object} secConfig Resolved security config.
|
|
305
|
+
* @param {"read"|"create"|"update"|"delete"} operation Operation to check.
|
|
306
|
+
* @param {string} entityType Entity type the operation targets.
|
|
307
|
+
* @returns {void} No-op when the type has no allowedOperations restriction.
|
|
308
|
+
* @throws {SecurityError} if the operation is not in the type's allowedOperations.
|
|
309
|
+
*/
|
|
310
|
+
export function assertOperationAllowed(secConfig, operation, entityType) {
|
|
311
|
+
const rules = new Map(Object.entries(secConfig.entityRules)).get(entityType);
|
|
312
|
+
if (!rules?.allowedOperations) return; // no restriction
|
|
313
|
+
|
|
314
|
+
if (!rules.allowedOperations.includes(operation)) {
|
|
315
|
+
throw new SecurityError(
|
|
316
|
+
`Operation "${operation}" is not allowed on entity type "${entityType}". ` +
|
|
317
|
+
`Allowed operations: ${rules.allowedOperations.join(", ")}`
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ---------------------------------------------------------------------------
|
|
323
|
+
// Composite assertion for read operations (most common)
|
|
324
|
+
// ---------------------------------------------------------------------------
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Composite read gate: entity type, bundle (if given), and the "read" operation.
|
|
328
|
+
* @param {object} secConfig Resolved security config.
|
|
329
|
+
* @param {string} entityType Entity type to read.
|
|
330
|
+
* @param {string} [bundle] Bundle to read.
|
|
331
|
+
* @returns {void}
|
|
332
|
+
* @throws {SecurityError} if any underlying check fails.
|
|
333
|
+
*/
|
|
334
|
+
export function assertReadAllowed(secConfig, entityType, bundle) {
|
|
335
|
+
assertEntityTypeAllowed(secConfig, entityType);
|
|
336
|
+
if (bundle) assertBundleAllowed(secConfig, entityType, bundle);
|
|
337
|
+
assertOperationAllowed(secConfig, "read", entityType);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Composite write gate: read-only switch, entity type, bundle (if given), and op.
|
|
342
|
+
* @param {object} secConfig Resolved security config.
|
|
343
|
+
* @param {"create"|"update"|"delete"} operation Write operation.
|
|
344
|
+
* @param {string} entityType Entity type to write.
|
|
345
|
+
* @param {string} [bundle] Bundle to write.
|
|
346
|
+
* @returns {void}
|
|
347
|
+
* @throws {SecurityError} if any underlying check fails.
|
|
348
|
+
*/
|
|
349
|
+
export function assertWriteAllowed(secConfig, operation, entityType, bundle) {
|
|
350
|
+
assertNotReadOnly(secConfig, `${operation} ${entityType}`);
|
|
351
|
+
assertEntityTypeAllowed(secConfig, entityType);
|
|
352
|
+
if (bundle) assertBundleAllowed(secConfig, entityType, bundle);
|
|
353
|
+
assertOperationAllowed(secConfig, operation, entityType);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Composite delete gate: destructive switch plus the full write gate.
|
|
358
|
+
* @param {object} secConfig Resolved security config.
|
|
359
|
+
* @param {string} entityType Entity type to delete.
|
|
360
|
+
* @param {string} [bundle] Bundle to delete.
|
|
361
|
+
* @param {string} id Entity id to delete (used in the error message).
|
|
362
|
+
* @returns {void}
|
|
363
|
+
* @throws {SecurityError} if deletes are disabled or any write check fails.
|
|
364
|
+
*/
|
|
365
|
+
export function assertDeleteAllowed(secConfig, entityType, bundle, id) {
|
|
366
|
+
assertDestructiveAllowed(secConfig, entityType, id);
|
|
367
|
+
assertWriteAllowed(secConfig, "delete", entityType, bundle);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ---------------------------------------------------------------------------
|
|
371
|
+
// Field redaction
|
|
372
|
+
// ---------------------------------------------------------------------------
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Redact sensitive fields from a JSON:API resource object (or array of them),
|
|
376
|
+
* replacing their `attributes` values with "[REDACTED]".
|
|
377
|
+
* @param {?(object|object[])} resource JSON:API resource(s) to redact.
|
|
378
|
+
* @param {object} secConfig Resolved security config (supplies the field lists).
|
|
379
|
+
* @param {string} entityType Entity type, used to pick per-type redacted fields.
|
|
380
|
+
* @returns {?(object|object[])} New resource object(s); originals are not mutated.
|
|
381
|
+
*/
|
|
382
|
+
export function redactResource(resource, secConfig, entityType) {
|
|
383
|
+
if (!resource) return resource;
|
|
384
|
+
|
|
385
|
+
// Collect fields to redact for this entity type
|
|
386
|
+
const entityRules = new Map(Object.entries(secConfig.entityRules)).get(entityType) ?? {};
|
|
387
|
+
const fieldsToRedact = new Set([
|
|
388
|
+
...(secConfig.globalRedactedFields ?? []),
|
|
389
|
+
...(entityRules.redactedFields ?? []),
|
|
390
|
+
]);
|
|
391
|
+
|
|
392
|
+
if (fieldsToRedact.size === 0) return resource;
|
|
393
|
+
|
|
394
|
+
function redactAttrs(obj) {
|
|
395
|
+
if (!obj?.attributes) return obj;
|
|
396
|
+
const attrs = Object.fromEntries(
|
|
397
|
+
Object.entries(obj.attributes).map(([k, v]) => [k, fieldsToRedact.has(k) ? "[REDACTED]" : v])
|
|
398
|
+
);
|
|
399
|
+
return { ...obj, attributes: attrs };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (Array.isArray(resource)) return resource.map(redactAttrs);
|
|
403
|
+
return redactAttrs(resource);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Redact sensitive fields from a CANONICAL entity (base props + `fields`).
|
|
408
|
+
* Mirrors redactResource but for the API-neutral canonical shape.
|
|
409
|
+
* @param {?object} entity Canonical entity to redact.
|
|
410
|
+
* @param {object} secConfig Resolved security config (supplies the field lists).
|
|
411
|
+
* @param {string} entityType Entity type, used to pick per-type redacted fields.
|
|
412
|
+
* @returns {?object} New entity; original is not mutated.
|
|
413
|
+
*/
|
|
414
|
+
export function redactCanonicalEntity(entity, secConfig, entityType) {
|
|
415
|
+
if (!entity) return entity;
|
|
416
|
+
const entityRulesMap = secConfig.entityRules ? new Map(Object.entries(secConfig.entityRules)) : new Map();
|
|
417
|
+
const entityRules = entityRulesMap.get(entityType) ?? {};
|
|
418
|
+
const fieldsToRedact = new Set([
|
|
419
|
+
...(secConfig.globalRedactedFields ?? []),
|
|
420
|
+
...(entityRules.redactedFields ?? []),
|
|
421
|
+
]);
|
|
422
|
+
if (fieldsToRedact.size === 0) return entity;
|
|
423
|
+
|
|
424
|
+
const redactedFields = Object.fromEntries(
|
|
425
|
+
Object.entries(entity.fields ?? {}).map(([k, v]) => [k, fieldsToRedact.has(k) ? "[REDACTED]" : v])
|
|
426
|
+
);
|
|
427
|
+
const BASE_PROPS = ["title", "status", "langcode", "created", "changed", "url"];
|
|
428
|
+
const baseOverrides = Object.fromEntries(
|
|
429
|
+
BASE_PROPS.filter((p) => fieldsToRedact.has(p)).map((p) => [p, "[REDACTED]"])
|
|
430
|
+
);
|
|
431
|
+
return { ...entity, fields: redactedFields, ...baseOverrides };
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Redact a full JSON:API response by redacting each item under `.data`.
|
|
436
|
+
* @param {?object} response JSON:API response with a `data` object or array.
|
|
437
|
+
* @param {object} secConfig Resolved security config.
|
|
438
|
+
* @param {string} entityType Entity type for the response data.
|
|
439
|
+
* @returns {?object} New response; original is not mutated.
|
|
440
|
+
*/
|
|
441
|
+
export function redactResponse(response, secConfig, entityType) {
|
|
442
|
+
if (!response?.data) return response;
|
|
443
|
+
return {
|
|
444
|
+
...response,
|
|
445
|
+
data: Array.isArray(response.data)
|
|
446
|
+
? response.data.map((r) => redactResource(r, secConfig, entityType))
|
|
447
|
+
: redactResource(response.data, secConfig, entityType),
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// ---------------------------------------------------------------------------
|
|
452
|
+
// Security summary tool (exposed as drupal_security_info)
|
|
453
|
+
// ---------------------------------------------------------------------------
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Build a human-readable summary of a site's effective security policy
|
|
457
|
+
* (exposed via the drupal_security_info tool). Lists only policy settings —
|
|
458
|
+
* no credentials.
|
|
459
|
+
* @param {object} site Site config.
|
|
460
|
+
* @returns {object} Flat summary of the resolved security settings.
|
|
461
|
+
*/
|
|
462
|
+
export function getSecuritySummary(site) {
|
|
463
|
+
const cfg = resolveSecurityConfig(site);
|
|
464
|
+
return {
|
|
465
|
+
site: site._name,
|
|
466
|
+
preset: site.security?.preset ?? "development (default)",
|
|
467
|
+
readOnly: cfg.readOnly,
|
|
468
|
+
allowDestructive: cfg.allowDestructive,
|
|
469
|
+
allowGraphqlMutations: cfg.allowGraphqlMutations,
|
|
470
|
+
allowedEntityTypes: cfg.allowedEntityTypes ?? "all",
|
|
471
|
+
deniedEntityTypes: cfg.deniedEntityTypes,
|
|
472
|
+
entityRules: cfg.entityRules,
|
|
473
|
+
globalRedactedFields: cfg.globalRedactedFields,
|
|
474
|
+
};
|
|
475
|
+
}
|