drupal-mcp-connector 2.19.0 → 2.20.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/.agents/commands/drupal-config-set.md +2 -2
- package/.agents/commands/drupal-create-node.md +2 -2
- package/.agents/commands/drupal-create-translation.md +1 -1
- package/.agents/commands/drupal-delete-node.md +1 -1
- package/.agents/commands/drupal-describe-fields.md +2 -2
- package/.agents/commands/drupal-drush-config-import.md +2 -2
- package/.agents/commands/drupal-drush-module-disable.md +2 -2
- package/.agents/commands/drupal-drush-module-list.md +1 -1
- package/.agents/commands/drupal-drush-user-list.md +1 -1
- package/.agents/commands/drupal-drush-watchdog.md +1 -1
- package/.agents/commands/drupal-entity-create.md +2 -2
- package/.agents/commands/drupal-entity-delete.md +1 -1
- package/.agents/commands/drupal-entity-update.md +4 -4
- package/.agents/commands/drupal-report-field-completeness.md +2 -2
- package/.agents/commands/drupal-report-missing-field.md +2 -2
- package/.agents/commands/drupal-report-seo-meta-coverage.md +2 -2
- package/.agents/commands/drupal-report-status-report.md +1 -1
- package/.agents/commands/drupal-update-node.md +4 -4
- package/CHANGELOG.md +254 -0
- package/README.md +10 -3
- package/bin/drupal-mcp-verify.js +4 -3
- package/config/config.example.json +52 -2
- package/package.json +1 -1
- package/scripts/generate-commands.js +40 -5
- package/scripts/install-commands.js +148 -11
- package/src/index.js +9 -12
- package/src/lib/backends/graphql-schema.js +9 -1
- package/src/lib/backends/graphql.js +4 -2
- package/src/lib/backends/index.js +9 -3
- package/src/lib/backends/jsonapi.js +2 -0
- package/src/lib/dispatch.js +6 -6
- package/src/lib/drupal-fetch.js +97 -26
- package/src/lib/dry-run-checks.js +78 -0
- package/src/lib/error-body.js +448 -0
- package/src/lib/error-status.js +38 -0
- package/src/lib/errors.js +0 -11
- package/src/lib/evidence.js +0 -6
- package/src/lib/governance.js +2 -8
- package/src/lib/link-checker.js +3 -3
- package/src/lib/mcp-server.js +7 -1
- package/src/lib/metatag-audit.js +2 -1
- package/src/lib/module-tools.js +23 -2
- package/src/lib/operations.js +2 -2
- package/src/lib/patch-preflight.js +23 -5
- package/src/lib/policy-enforcement.js +4 -4
- package/src/lib/principal.js +3 -3
- package/src/lib/relay/edge.js +2 -2
- package/src/lib/reports-support.js +75 -0
- package/src/lib/security.js +234 -18
- package/src/lib/sentinel-draft.js +3 -2
- package/src/lib/server-tools.js +188 -27
- package/src/lib/tool-prompts.js +139 -8
- package/src/lib/usage.js +0 -9
- package/src/lib/verify.js +164 -61
- package/src/tools/config.js +70 -5
- package/src/tools/drush.js +191 -17
- package/src/tools/entities.js +18 -6
- package/src/tools/fields.js +40 -4
- package/src/tools/graphql.js +10 -5
- package/src/tools/nodes.js +16 -6
- package/src/tools/paragraphs.js +1 -1
- package/src/tools/reports-config.js +3 -3
- package/src/tools/reports-content.js +34 -26
- package/src/tools/reports-extra.js +46 -38
- package/src/tools/reports.js +50 -25
- package/src/tools/scheduler.js +1 -1
- package/src/tools/structure.js +12 -2
- package/src/tools/translations.js +5 -1
- package/src/lib/draft-write.js +0 -19
- package/src/lib/node-draft-inventory.js +0 -5
|
@@ -24,7 +24,9 @@
|
|
|
24
24
|
|
|
25
25
|
import { entityLooksModerated, hasExplicitModerationState } from "./moderation-default.js";
|
|
26
26
|
import { entityRevisionId } from "./write-revision.js";
|
|
27
|
+
import { httpStatusOf } from "./error-status.js";
|
|
27
28
|
import { writeDraft, readNodeDraftInventory, assertInventoryDraftLanguage } from "./sentinel-draft.js";
|
|
29
|
+
import { PREFLIGHT_NONE, PREFLIGHT_CORE_GUARD, PREFLIGHT_SENTINEL_DRAFT } from "./dry-run-checks.js";
|
|
28
30
|
|
|
29
31
|
/** Stable error code for a core working-copy / not-latest-revision block. */
|
|
30
32
|
export const PATCH_BLOCKED_CODE = "PATCH_BLOCKED";
|
|
@@ -300,7 +302,7 @@ const ID_MISMATCH_RE = /does not match the ID in the payload/i;
|
|
|
300
302
|
*/
|
|
301
303
|
export function isProbePassedWithoutSave(err) {
|
|
302
304
|
const msg = String(err?.message || "");
|
|
303
|
-
return ID_MISMATCH_RE.test(msg) ||
|
|
305
|
+
return ID_MISMATCH_RE.test(msg) || httpStatusOf(err) === 422;
|
|
304
306
|
}
|
|
305
307
|
|
|
306
308
|
/**
|
|
@@ -315,6 +317,12 @@ export function isProbePassedWithoutSave(err) {
|
|
|
315
317
|
* When `resourceVersion` is `rel:working-copy`, the probe hits that same
|
|
316
318
|
* URL so dryRun cannot succeed when the real write would 400 (#166).
|
|
317
319
|
*
|
|
320
|
+
* The probe body carries no attributes, and core rejects the id before it
|
|
321
|
+
* evaluates field access or validation. A passing probe therefore proves the
|
|
322
|
+
* route's entity update access and the working-copy guard, and nothing about
|
|
323
|
+
* the fields the real write will submit (#336). The result says so
|
|
324
|
+
* (`payloadEvaluated: false`) rather than claiming the target is writable.
|
|
325
|
+
*
|
|
318
326
|
* @param {object} args
|
|
319
327
|
* @param {object} args.backend Backend with `rawQuery` + `resourcePath`.
|
|
320
328
|
* @param {string} args.entityType
|
|
@@ -323,7 +331,7 @@ export function isProbePassedWithoutSave(err) {
|
|
|
323
331
|
* @param {?object} [args.existing]
|
|
324
332
|
* @param {object} [args.attributes]
|
|
325
333
|
* @param {?string} [args.resourceVersion]
|
|
326
|
-
* @returns {Promise<{probed: boolean,
|
|
334
|
+
* @returns {Promise<{probed: boolean, revisionGuardPassed?: boolean, payloadEvaluated?: boolean, skipped?: string}>}
|
|
327
335
|
* @throws {PatchBlockedError|WorkingCopyStaleError} When the guard rejects the probe.
|
|
328
336
|
*/
|
|
329
337
|
export async function preflightPatchWritable({
|
|
@@ -368,7 +376,7 @@ export async function preflightPatchWritable({
|
|
|
368
376
|
throw new PatchBlockedError(cause);
|
|
369
377
|
}
|
|
370
378
|
if (isProbePassedWithoutSave(err)) {
|
|
371
|
-
return { probed: true,
|
|
379
|
+
return { probed: true, revisionGuardPassed: true, payloadEvaluated: false };
|
|
372
380
|
}
|
|
373
381
|
throw err;
|
|
374
382
|
}
|
|
@@ -380,9 +388,14 @@ export async function preflightPatchWritable({
|
|
|
380
388
|
* `langcode` always resolves Sentinel inventory and draft-preflights, even
|
|
381
389
|
* when the entity is unmoderated (media translations).
|
|
382
390
|
*
|
|
391
|
+
* The returned `preflight` names the server-side check that ran, so a dryRun
|
|
392
|
+
* preview can state what it covered (#336; see dry-run-checks.js):
|
|
393
|
+
* `sentinel_draft` evaluated the submitted fields, `core_patch_guard` carried
|
|
394
|
+
* no fields, `none` means Drupal evaluated nothing.
|
|
395
|
+
*
|
|
383
396
|
* @param {object} backend
|
|
384
397
|
* @param {{entityType: string, bundle: string, id: string, existing?: ?object, attributes?: object, relationships?: object, langcode?: string}} args
|
|
385
|
-
* @returns {Promise<{resourceVersion: ?string, workingCopy: ?object, liveVid: ?number|string, workingVid: ?number|string}>}
|
|
398
|
+
* @returns {Promise<{resourceVersion: ?string, workingCopy: ?object, liveVid: ?number|string, workingVid: ?number|string, preflight: string}>}
|
|
386
399
|
*/
|
|
387
400
|
export async function prepareGuardedPatch(backend, {
|
|
388
401
|
entityType, bundle, id, existing, attributes, relationships, langcode,
|
|
@@ -391,6 +404,8 @@ export async function prepareGuardedPatch(backend, {
|
|
|
391
404
|
const target = (needsPreflight || langcode)
|
|
392
405
|
? await resolveWorkingCopyPatchTarget(backend, { entityType, bundle, id, existing })
|
|
393
406
|
: { resourceVersion: undefined, workingCopy: null, liveVid: null, workingVid: null };
|
|
407
|
+
// Set only after a check has run and passed; a refusal throws first.
|
|
408
|
+
target.preflight = PREFLIGHT_NONE;
|
|
394
409
|
if (needsPreflight && !target.resourceVersion) {
|
|
395
410
|
// Canonical path (no distinct working copy). The id-mismatch probe never
|
|
396
411
|
// reaches Sentinel's save-time stale-default check; refuse here when the
|
|
@@ -427,6 +442,7 @@ export async function prepareGuardedPatch(backend, {
|
|
|
427
442
|
} catch (err) {
|
|
428
443
|
throw rewriteStaleCopyError(err);
|
|
429
444
|
}
|
|
445
|
+
target.preflight = PREFLIGHT_SENTINEL_DRAFT;
|
|
430
446
|
return target;
|
|
431
447
|
}
|
|
432
448
|
if (target.resourceVersion) {
|
|
@@ -438,13 +454,15 @@ export async function prepareGuardedPatch(backend, {
|
|
|
438
454
|
} catch (err) {
|
|
439
455
|
throw rewriteStaleCopyError(err);
|
|
440
456
|
}
|
|
457
|
+
target.preflight = PREFLIGHT_SENTINEL_DRAFT;
|
|
441
458
|
return target;
|
|
442
459
|
}
|
|
443
460
|
try {
|
|
444
|
-
await preflightPatchWritable({
|
|
461
|
+
const probe = await preflightPatchWritable({
|
|
445
462
|
backend, entityType, bundle, id, existing, attributes,
|
|
446
463
|
resourceVersion: target.resourceVersion,
|
|
447
464
|
});
|
|
465
|
+
if (probe.probed === true) target.preflight = PREFLIGHT_CORE_GUARD;
|
|
448
466
|
} catch (err) {
|
|
449
467
|
throw rewriteStaleCopyError(err);
|
|
450
468
|
}
|
|
@@ -11,11 +11,11 @@
|
|
|
11
11
|
import { createHash, createHmac, randomUUID, timingSafeEqual } from "node:crypto";
|
|
12
12
|
import { SEAL_PREFIX } from "./policy-promotion.js";
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
const POLICY_BUNDLE_VERSION = 1;
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
const DEFAULT_BUNDLE_TTL = 86400 * 30;
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
const EMERGENCY_DENY = "*";
|
|
19
19
|
|
|
20
20
|
export const EMERGENCY_DIGEST = "emergency-deny";
|
|
21
21
|
|
|
@@ -43,7 +43,7 @@ function normalize(value) {
|
|
|
43
43
|
* @param {object} claims
|
|
44
44
|
* @returns {string}
|
|
45
45
|
*/
|
|
46
|
-
|
|
46
|
+
function canonicalJson(claims) {
|
|
47
47
|
return JSON.stringify(normalize(claims));
|
|
48
48
|
}
|
|
49
49
|
|
package/src/lib/principal.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
16
16
|
import { getDefaultSiteName, getInboundGrants } from "./config.js";
|
|
17
17
|
import { inferOperation } from "./operations.js";
|
|
18
|
+
import { POLICY_DIGEST } from "./policy-promotion.js";
|
|
18
19
|
import { resolveSecurityConfig, SecurityError } from "./security.js";
|
|
19
20
|
|
|
20
21
|
const identityStore = new AsyncLocalStorage();
|
|
@@ -23,7 +24,6 @@ const identityStore = new AsyncLocalStorage();
|
|
|
23
24
|
export const TARGET_HINT_KEYS = Object.freeze(["site", "environment", "tenant", "target"]);
|
|
24
25
|
|
|
25
26
|
const ACTOR_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
26
|
-
const POLICY_DIGEST = /^[0-9a-f]{64}$/i;
|
|
27
27
|
|
|
28
28
|
function tableHasKeys(table) {
|
|
29
29
|
return Object.keys(table).some((key) => {
|
|
@@ -129,7 +129,7 @@ function grantNameList(values) {
|
|
|
129
129
|
* @param {object|null} actors
|
|
130
130
|
* @returns {object|null}
|
|
131
131
|
*/
|
|
132
|
-
|
|
132
|
+
function normalizeActors(actors) {
|
|
133
133
|
if (!actors || typeof actors !== "object" || Array.isArray(actors)) return null;
|
|
134
134
|
const entries = [];
|
|
135
135
|
for (const [rawKey, value] of Object.entries(actors)) {
|
|
@@ -201,7 +201,7 @@ export function resolveActor({ identity = null, actors = null } = {}) {
|
|
|
201
201
|
* @param {object|null} policies
|
|
202
202
|
* @returns {object|null}
|
|
203
203
|
*/
|
|
204
|
-
|
|
204
|
+
function normalizePolicies(policies) {
|
|
205
205
|
if (!policies || typeof policies !== "object" || Array.isArray(policies)) return null;
|
|
206
206
|
const entries = [];
|
|
207
207
|
for (const [rawKey, value] of Object.entries(policies)) {
|
package/src/lib/relay/edge.js
CHANGED
|
@@ -348,7 +348,7 @@ function callerApprovalHint(args = {}) {
|
|
|
348
348
|
* @param {unknown} raw
|
|
349
349
|
* @returns {string[]|{invalid: true, reason: string}}
|
|
350
350
|
*/
|
|
351
|
-
|
|
351
|
+
function normalizeApprovalRequiredTools(raw) {
|
|
352
352
|
if (raw === null || raw === undefined) return [];
|
|
353
353
|
if (Array.isArray(raw)) {
|
|
354
354
|
return [...new Set(
|
|
@@ -415,7 +415,7 @@ export class EdgeStartupError extends Error {
|
|
|
415
415
|
* @param {Record<string, string|string[]>} [headers]
|
|
416
416
|
* @returns {Record<string, string>}
|
|
417
417
|
*/
|
|
418
|
-
|
|
418
|
+
function fanDownHeaders(headers = {}) {
|
|
419
419
|
const entries = [];
|
|
420
420
|
for (const [name, value] of Object.entries(forwardHeaders(headers))) {
|
|
421
421
|
const key = String(name).toLowerCase();
|
|
@@ -64,6 +64,35 @@ export function fieldValue(entity, candidates) {
|
|
|
64
64
|
return undefined;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Read a field off a canonical entity and say whether its KEY was there.
|
|
69
|
+
*
|
|
70
|
+
* JSON:API keeps the key of an empty field (value `null`, `[]`, or
|
|
71
|
+
* `data: null`) and leaves out the key of a field the account may not view.
|
|
72
|
+
* So an absent key and an empty value are different facts, and callers that
|
|
73
|
+
* count "missing" values must keep them apart (#337).
|
|
74
|
+
*
|
|
75
|
+
* Looks in `fields`, then `relationships`. Promoted base attributes (`title`,
|
|
76
|
+
* `status`, `langcode`, `created`, `changed`, and `path` as `url`) are always
|
|
77
|
+
* carried by the canonical shape, so they always read as present: a denied
|
|
78
|
+
* base attribute cannot be told from an empty one after promotion.
|
|
79
|
+
*
|
|
80
|
+
* @param {object} entity Canonical entity.
|
|
81
|
+
* @param {string} field Field machine name.
|
|
82
|
+
* @returns {{present: boolean, value: *}} `present` is about the key, not the value.
|
|
83
|
+
*/
|
|
84
|
+
export function fieldPresence(entity, field) {
|
|
85
|
+
const name = field === "path" ? "url" : field;
|
|
86
|
+
if (BASE_KEYS.has(name)) {
|
|
87
|
+
return { present: true, value: new Map(Object.entries(entity ?? {})).get(name) };
|
|
88
|
+
}
|
|
89
|
+
const fields = new Map(Object.entries(entity?.fields ?? {}));
|
|
90
|
+
if (fields.has(name)) return { present: true, value: fields.get(name) };
|
|
91
|
+
const relationships = new Map(Object.entries(entity?.relationships ?? {}));
|
|
92
|
+
if (relationships.has(name)) return { present: true, value: relationships.get(name) };
|
|
93
|
+
return { present: false, value: undefined };
|
|
94
|
+
}
|
|
95
|
+
|
|
67
96
|
/**
|
|
68
97
|
* Whole days elapsed between a date and now.
|
|
69
98
|
* @param {?(string|number|Date)} dateValue A date parseable by `new Date()`.
|
|
@@ -73,3 +102,49 @@ export function daysSince(dateValue) {
|
|
|
73
102
|
if (!dateValue) return null;
|
|
74
103
|
return Math.floor((Date.now() - new Date(dateValue).getTime()) / MS_PER_DAY);
|
|
75
104
|
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Whether a canonical field or relationship value carries no content.
|
|
108
|
+
*
|
|
109
|
+
* Handles scalars, `{value}` text objects, arrays, relationship refs
|
|
110
|
+
* (`{id}` / `[{id}, …]` / `null`), and keyed objects such as a link
|
|
111
|
+
* (`{uri, title}`). A keyed object with at least one key and none of the
|
|
112
|
+
* known value keys counts as populated.
|
|
113
|
+
*
|
|
114
|
+
* @param {*} value A value read off an entity's base props, `fields`, or `relationships`.
|
|
115
|
+
* @returns {boolean} True when the value is absent or carries no content.
|
|
116
|
+
*/
|
|
117
|
+
export function isEmptyFieldValue(value) {
|
|
118
|
+
if (value === undefined || value === null || value === "") return true;
|
|
119
|
+
if (Array.isArray(value)) return value.every((item) => isEmptyFieldValue(item));
|
|
120
|
+
if (typeof value === "object") {
|
|
121
|
+
if ("id" in value) return !value.id;
|
|
122
|
+
if ("value" in value) return value.value === undefined || value.value === null || value.value === "";
|
|
123
|
+
if ("target_id" in value) return value.target_id === undefined || value.target_id === null;
|
|
124
|
+
if ("uri" in value) return !value.uri;
|
|
125
|
+
return Object.keys(value).length === 0;
|
|
126
|
+
}
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Note attached to a report's `notVisible` list: requested fields whose key is
|
|
132
|
+
* absent from every sampled entity, so the report could not score them (#341).
|
|
133
|
+
*/
|
|
134
|
+
export const FIELDS_NOT_VISIBLE_NOTE =
|
|
135
|
+
"These fields are absent from every sampled entity (the key is missing, not empty). " +
|
|
136
|
+
"Each may be denied to this account or not exist on this bundle, or the name may be wrong. " +
|
|
137
|
+
"Drupal leaves a field the account may not view out of the response with no marker, so this report cannot tell whether any value is missing. " +
|
|
138
|
+
"They were not scored.";
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Normalize a caller's `fields` argument to distinct, non-empty field names.
|
|
142
|
+
* Arguments are not schema-validated before they reach a handler, so a single
|
|
143
|
+
* string is read as one name and non-string entries are dropped.
|
|
144
|
+
* @param {*} fields The raw `fields` argument.
|
|
145
|
+
* @returns {string[]} Field names, or [] when the caller named none.
|
|
146
|
+
*/
|
|
147
|
+
export function requestedFieldNames(fields) {
|
|
148
|
+
const list = typeof fields === "string" ? [fields] : (Array.isArray(fields) ? fields : []);
|
|
149
|
+
return [...new Set(list.filter((f) => typeof f === "string" && f.trim()).map((f) => f.trim()))];
|
|
150
|
+
}
|
package/src/lib/security.js
CHANGED
|
@@ -53,6 +53,19 @@ import { parse } from "graphql";
|
|
|
53
53
|
*
|
|
54
54
|
* globalRedactedFields string[] stripped from every response, every type
|
|
55
55
|
*
|
|
56
|
+
* protectedModules string[] module machine names ADDED to the default
|
|
57
|
+
* protected list (DEFAULT_PROTECTED_MODULES).
|
|
58
|
+
* drupal_drush_module_disable refuses them (#346).
|
|
59
|
+
* allowProtectedModuleUninstall string[] explicit opt-out: names REMOVED from the
|
|
60
|
+
* protected list. The only way to shrink it.
|
|
61
|
+
* A malformed value in either key refuses
|
|
62
|
+
* every uninstall rather than being ignored.
|
|
63
|
+
* allowCoreExtensionChange boolean false on every preset. true lets
|
|
64
|
+
* drupal_config_set write core.extension and
|
|
65
|
+
* lets drupal_drush_config_import run when
|
|
66
|
+
* core.extension differs (#349). Anything but
|
|
67
|
+
* true or false keeps both refused.
|
|
68
|
+
*
|
|
56
69
|
* declaredCeiling string narrow-only X-MCP-Declared-Ceiling
|
|
57
70
|
* (public|internal|restricted). Invalid
|
|
58
71
|
* values are dropped, never widened.
|
|
@@ -244,6 +257,98 @@ const PRESETS = {
|
|
|
244
257
|
* @param {object} site Site config (reads site.security).
|
|
245
258
|
* @returns {object} Effective security config used by the assert/redact helpers.
|
|
246
259
|
*/
|
|
260
|
+
/**
|
|
261
|
+
* Modules drupal_drush_module_disable refuses to uninstall, on every preset
|
|
262
|
+
* (#346). Uninstalling one removes a control the connector relies on, and
|
|
263
|
+
* Drupal drops the module's hook_schema tables on uninstall, so an audit log or
|
|
264
|
+
* stored keys go with it. Operators extend the list with
|
|
265
|
+
* `security.protectedModules` and shrink it only with the explicit
|
|
266
|
+
* `security.allowProtectedModuleUninstall`.
|
|
267
|
+
*/
|
|
268
|
+
export const DEFAULT_PROTECTED_MODULES = Object.freeze([
|
|
269
|
+
// Governance and integrity.
|
|
270
|
+
"mcp_sentinel", "audit_chain", "field_guard", "file_gate",
|
|
271
|
+
// Secrets and authentication.
|
|
272
|
+
"key", "encrypt", "simple_oauth", "consumers",
|
|
273
|
+
// The API and governed tool surface the connector talks to.
|
|
274
|
+
"jsonapi", "serialization", "mcp_server", "mcp_server_tool_bridge", "tool",
|
|
275
|
+
// The editorial gate that decides publication.
|
|
276
|
+
"content_moderation", "workflows",
|
|
277
|
+
]);
|
|
278
|
+
|
|
279
|
+
/** Drupal module machine name. Same rule as validateMachineName(), kept local to avoid an import cycle. */
|
|
280
|
+
const MODULE_NAME_RE = /^[a-z][a-z0-9_]{0,127}$/;
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Read one module-list key from raw site security config.
|
|
284
|
+
* @param {object} raw site.security.
|
|
285
|
+
* @param {string} key Config key.
|
|
286
|
+
* @param {string[]} problems Collects a message per malformed key.
|
|
287
|
+
* @returns {string[]} The valid names (empty when the key is absent or malformed).
|
|
288
|
+
*/
|
|
289
|
+
function readModuleList(raw, key, problems) {
|
|
290
|
+
const value = new Map(Object.entries(raw)).get(key);
|
|
291
|
+
if (value === undefined || value === null) return [];
|
|
292
|
+
if (!Array.isArray(value)) {
|
|
293
|
+
problems.push(`security.${key} must be an array of module machine names.`);
|
|
294
|
+
return [];
|
|
295
|
+
}
|
|
296
|
+
const valid = value.filter((name) => typeof name === "string" && MODULE_NAME_RE.test(name));
|
|
297
|
+
if (valid.length !== value.length) {
|
|
298
|
+
problems.push(
|
|
299
|
+
`security.${key} has ${value.length - valid.length} entr${value.length - valid.length === 1 ? "y that is" : "ies that are"} ` +
|
|
300
|
+
"not a module machine name (lowercase letters, digits and underscores)."
|
|
301
|
+
);
|
|
302
|
+
// A malformed opt-out never removes anything; a malformed extension still adds its valid names.
|
|
303
|
+
return key === "allowProtectedModuleUninstall" ? [] : valid;
|
|
304
|
+
}
|
|
305
|
+
return valid;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Resolve the effective protected-module list for a site.
|
|
310
|
+
* @param {object} raw site.security.
|
|
311
|
+
* @returns {{modules: string[], optOuts: string[], error: ?string}} `error` is
|
|
312
|
+
* set when either key is malformed; callers must then refuse every uninstall.
|
|
313
|
+
*/
|
|
314
|
+
function resolveProtectedModules(raw) {
|
|
315
|
+
const problems = [];
|
|
316
|
+
const added = readModuleList(raw, "protectedModules", problems);
|
|
317
|
+
const optOuts = readModuleList(raw, "allowProtectedModuleUninstall", problems);
|
|
318
|
+
const removed = new Set(optOuts);
|
|
319
|
+
const modules = [...new Set([...DEFAULT_PROTECTED_MODULES, ...added])].filter((name) => !removed.has(name)).sort();
|
|
320
|
+
return { modules, optOuts: [...removed].sort(), error: problems.length ? problems.join(" ") : null };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** The config object that lists installed modules and themes. */
|
|
324
|
+
export const CORE_EXTENSION_CONFIG = "core.extension";
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Whether a config name addresses core.extension. Surrounding space and case
|
|
328
|
+
* are ignored: a source that trims the name would otherwise be reached by a
|
|
329
|
+
* padded one.
|
|
330
|
+
* @param {*} name Config object name.
|
|
331
|
+
* @returns {boolean}
|
|
332
|
+
*/
|
|
333
|
+
export function isCoreExtensionConfig(name) {
|
|
334
|
+
return typeof name === "string" && name.trim().toLowerCase() === CORE_EXTENSION_CONFIG;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Resolve the explicit opt-in for changing core.extension (#349).
|
|
339
|
+
* @param {object} raw site.security.
|
|
340
|
+
* @returns {{allowed: boolean, error: ?string}} `error` is set when the value
|
|
341
|
+
* is neither true nor false; the change then stays refused.
|
|
342
|
+
*/
|
|
343
|
+
function resolveCoreExtensionOptIn(raw) {
|
|
344
|
+
const value = new Map(Object.entries(raw)).get("allowCoreExtensionChange");
|
|
345
|
+
if (value === undefined || value === null) return { allowed: false, error: null };
|
|
346
|
+
if (typeof value !== "boolean") {
|
|
347
|
+
return { allowed: false, error: "security.allowCoreExtensionChange must be true or false." };
|
|
348
|
+
}
|
|
349
|
+
return { allowed: value, error: null };
|
|
350
|
+
}
|
|
351
|
+
|
|
247
352
|
/** Default when `security.preset` is omitted — least privilege, not open mode (#140). */
|
|
248
353
|
export const DEFAULT_SECURITY_PRESET = "production-strict";
|
|
249
354
|
|
|
@@ -254,6 +359,9 @@ export function resolveSecurityConfig(site) {
|
|
|
254
359
|
const presetName = raw.preset ?? DEFAULT_SECURITY_PRESET;
|
|
255
360
|
const preset = PRESETS[presetName] ?? PRESETS[DEFAULT_SECURITY_PRESET];
|
|
256
361
|
|
|
362
|
+
const protectedModules = resolveProtectedModules(raw);
|
|
363
|
+
const coreExtension = resolveCoreExtensionOptIn(raw);
|
|
364
|
+
|
|
257
365
|
// Merge: explicit keys in site.security override the preset
|
|
258
366
|
return {
|
|
259
367
|
readOnly: raw.readOnly ?? preset.readOnly,
|
|
@@ -272,6 +380,13 @@ export function resolveSecurityConfig(site) {
|
|
|
272
380
|
],
|
|
273
381
|
declaredCeiling: raw.declaredCeiling ?? preset.declaredCeiling,
|
|
274
382
|
readBudgets: raw.readBudgets ?? preset.readBudgets ?? null,
|
|
383
|
+
// Not a preset value: the default list applies on every preset (#346).
|
|
384
|
+
protectedModules: protectedModules.modules,
|
|
385
|
+
protectedModuleOptOuts: protectedModules.optOuts,
|
|
386
|
+
protectedModulesError: protectedModules.error,
|
|
387
|
+
// Not a preset value either: strict on every preset (#349).
|
|
388
|
+
allowCoreExtensionChange: coreExtension.allowed,
|
|
389
|
+
coreExtensionChangeError: coreExtension.error,
|
|
275
390
|
};
|
|
276
391
|
}
|
|
277
392
|
|
|
@@ -387,7 +502,7 @@ export function hasScope(site, scope) {
|
|
|
387
502
|
* @param {object} site Resolved site config.
|
|
388
503
|
* @returns {boolean}
|
|
389
504
|
*/
|
|
390
|
-
|
|
505
|
+
function isGovernedSetup(site) {
|
|
391
506
|
return site?.requireGovernance === true || Boolean(site?.oauth);
|
|
392
507
|
}
|
|
393
508
|
|
|
@@ -430,6 +545,119 @@ export function assertDestructiveAllowed(secConfig, entityType, id) {
|
|
|
430
545
|
}
|
|
431
546
|
}
|
|
432
547
|
|
|
548
|
+
/**
|
|
549
|
+
* Gate a module uninstall (drupal_drush_module_disable) against the site's
|
|
550
|
+
* protected-module list (#346). Fails closed: a security config that is
|
|
551
|
+
* malformed, or that never went through resolveSecurityConfig(), refuses every
|
|
552
|
+
* uninstall.
|
|
553
|
+
* @param {object} secConfig Resolved security config.
|
|
554
|
+
* @param {string} moduleName Module machine name, already validated.
|
|
555
|
+
* @returns {void}
|
|
556
|
+
* @throws {SecurityError} if the module is protected or the list cannot be trusted.
|
|
557
|
+
*/
|
|
558
|
+
export function assertModuleUninstallAllowed(secConfig, moduleName) {
|
|
559
|
+
if (secConfig?.protectedModulesError || !Array.isArray(secConfig?.protectedModules)) {
|
|
560
|
+
throw new SecurityError(
|
|
561
|
+
"Module uninstall is blocked because this site's protected-module config cannot be read. " +
|
|
562
|
+
`${secConfig?.protectedModulesError ?? "The protected-module list is missing."} ` +
|
|
563
|
+
"No module can be uninstalled through the connector until an operator fixes it."
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
if (secConfig.protectedModules.includes(moduleName)) {
|
|
567
|
+
throw new SecurityError(
|
|
568
|
+
`Module "${moduleName}" is protected and cannot be uninstalled through the connector. ` +
|
|
569
|
+
"Uninstalling it removes a control the connector relies on, and Drupal drops the module's tables on uninstall. " +
|
|
570
|
+
`To allow it, an operator must add "${moduleName}" to security.allowProtectedModuleUninstall for this site.`
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Gate a change to core.extension (#349). The object lists installed modules
|
|
577
|
+
* and themes, so a write to it, or a config import that changes it, can
|
|
578
|
+
* uninstall a protected module without going through
|
|
579
|
+
* assertModuleUninstallAllowed(). Refused on every preset unless the operator
|
|
580
|
+
* set `security.allowCoreExtensionChange: true`. Fails closed: a malformed
|
|
581
|
+
* value, or a config that never went through resolveSecurityConfig(), refuses.
|
|
582
|
+
* @param {object} secConfig Resolved security config.
|
|
583
|
+
* @param {string} refusal What is being refused and why, as full sentences.
|
|
584
|
+
* @returns {void}
|
|
585
|
+
* @throws {SecurityError} unless the operator opted in.
|
|
586
|
+
*/
|
|
587
|
+
export function assertCoreExtensionChangeAllowed(secConfig, refusal) {
|
|
588
|
+
if (secConfig?.allowCoreExtensionChange === true && !secConfig.coreExtensionChangeError) return;
|
|
589
|
+
throw new SecurityError(
|
|
590
|
+
`${refusal} ` +
|
|
591
|
+
"To install or uninstall a module use drupal_drush_module_enable or drupal_drush_module_disable, " +
|
|
592
|
+
"which check the protected-module list. " +
|
|
593
|
+
(secConfig?.coreExtensionChangeError
|
|
594
|
+
? `This site's opt-in cannot be read: ${secConfig.coreExtensionChangeError} It stays refused until an operator fixes it.`
|
|
595
|
+
: "To allow it, an operator must set security.allowCoreExtensionChange = true for this site.")
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Check a drupal_config_set value for core.extension against the protected
|
|
601
|
+
* module list (#349). Runs only after assertCoreExtensionChangeAllowed().
|
|
602
|
+
*
|
|
603
|
+
* The value is a map of top-level keys, and the source also accepts a dotted
|
|
604
|
+
* key such as `module.devel`. A `module` key replaces the whole module map, so
|
|
605
|
+
* every protected module in `currentModules` must still be in it. A dotted key
|
|
606
|
+
* under `module.` that names a protected module is refused outright.
|
|
607
|
+
*
|
|
608
|
+
* @param {object} secConfig Resolved security config.
|
|
609
|
+
* @param {object} value The submitted map of config keys to values.
|
|
610
|
+
* @param {?string[]} currentModules Machine names installed now, or null when
|
|
611
|
+
* the caller has not read them. Needed only when `value` has a `module` key.
|
|
612
|
+
* @returns {{needsCurrentModules: boolean}} `needsCurrentModules` is true when
|
|
613
|
+
* the value replaces the module map and `currentModules` was not given; the
|
|
614
|
+
* caller reads the list and calls again.
|
|
615
|
+
* @throws {SecurityError} if the write would remove or alter a protected
|
|
616
|
+
* module, or the protected-module list cannot be trusted.
|
|
617
|
+
*/
|
|
618
|
+
export function assertCoreExtensionValueKeepsProtected(secConfig, value, currentModules = null) {
|
|
619
|
+
if (secConfig?.protectedModulesError || !Array.isArray(secConfig?.protectedModules)) {
|
|
620
|
+
throw new SecurityError(
|
|
621
|
+
"The write to core.extension is blocked because this site's protected-module config cannot be read. " +
|
|
622
|
+
`${secConfig?.protectedModulesError ?? "The protected-module list is missing."}`
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
const entries = value && typeof value === "object" && !Array.isArray(value) ? Object.entries(value) : [];
|
|
626
|
+
const protectedSet = new Set(secConfig.protectedModules);
|
|
627
|
+
|
|
628
|
+
const dotted = entries
|
|
629
|
+
.map(([key]) => /^module\.([^.]+)/.exec(key)?.[1])
|
|
630
|
+
.filter((name) => name !== undefined && protectedSet.has(name));
|
|
631
|
+
if (dotted.length) {
|
|
632
|
+
throw new SecurityError(
|
|
633
|
+
`The write to core.extension changes the entry of protected module${dotted.length === 1 ? "" : "s"} ` +
|
|
634
|
+
`${[...new Set(dotted)].sort().join(", ")}. Nothing was written. ` +
|
|
635
|
+
"To allow it, an operator must name the module in security.allowProtectedModuleUninstall for this site."
|
|
636
|
+
);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
const moduleEntry = entries.find(([key]) => key === "module");
|
|
640
|
+
if (!moduleEntry) return { needsCurrentModules: false };
|
|
641
|
+
const next = moduleEntry[1];
|
|
642
|
+
if (!next || typeof next !== "object" || Array.isArray(next)) {
|
|
643
|
+
throw new SecurityError(
|
|
644
|
+
"The write to core.extension is refused: `module` must be a map of module machine names to weights. Nothing was written."
|
|
645
|
+
);
|
|
646
|
+
}
|
|
647
|
+
if (!Array.isArray(currentModules)) return { needsCurrentModules: true };
|
|
648
|
+
|
|
649
|
+
const kept = new Set(Object.keys(next));
|
|
650
|
+
const removed = currentModules.filter((name) => protectedSet.has(name) && !kept.has(name)).sort();
|
|
651
|
+
if (removed.length) {
|
|
652
|
+
throw new SecurityError(
|
|
653
|
+
`The write to core.extension would remove protected module${removed.length === 1 ? "" : "s"} ${removed.join(", ")}. ` +
|
|
654
|
+
"Nothing was written. " +
|
|
655
|
+
"To allow it, an operator must name the module in security.allowProtectedModuleUninstall for this site."
|
|
656
|
+
);
|
|
657
|
+
}
|
|
658
|
+
return { needsCurrentModules: false };
|
|
659
|
+
}
|
|
660
|
+
|
|
433
661
|
/**
|
|
434
662
|
* Whether a set of write attributes carries a publish action.
|
|
435
663
|
*
|
|
@@ -723,23 +951,6 @@ export function redactCanonicalEntity(entity, secConfig, entityType) {
|
|
|
723
951
|
return { ...entity, fields: redactedFields, ...baseOverrides };
|
|
724
952
|
}
|
|
725
953
|
|
|
726
|
-
/**
|
|
727
|
-
* Redact a full JSON:API response by redacting each item under `.data`.
|
|
728
|
-
* @param {?object} response JSON:API response with a `data` object or array.
|
|
729
|
-
* @param {object} secConfig Resolved security config.
|
|
730
|
-
* @param {string} entityType Entity type for the response data.
|
|
731
|
-
* @returns {?object} New response; original is not mutated.
|
|
732
|
-
*/
|
|
733
|
-
export function redactResponse(response, secConfig, entityType) {
|
|
734
|
-
if (!response?.data) return response;
|
|
735
|
-
return {
|
|
736
|
-
...response,
|
|
737
|
-
data: Array.isArray(response.data)
|
|
738
|
-
? response.data.map((r) => redactResource(r, secConfig, entityType))
|
|
739
|
-
: redactResource(response.data, secConfig, entityType),
|
|
740
|
-
};
|
|
741
|
-
}
|
|
742
|
-
|
|
743
954
|
// ---------------------------------------------------------------------------
|
|
744
955
|
// Security summary tool (exposed as drupal_security_info)
|
|
745
956
|
// ---------------------------------------------------------------------------
|
|
@@ -768,5 +979,10 @@ export function getSecuritySummary(site) {
|
|
|
768
979
|
entityRules: cfg.entityRules,
|
|
769
980
|
globalRedactedFields: cfg.globalRedactedFields,
|
|
770
981
|
declaredCeiling: cfg.declaredCeiling ?? null,
|
|
982
|
+
protectedModules: cfg.protectedModules,
|
|
983
|
+
protectedModuleOptOuts: cfg.protectedModuleOptOuts,
|
|
984
|
+
...(cfg.protectedModulesError ? { protectedModulesError: cfg.protectedModulesError } : {}),
|
|
985
|
+
allowCoreExtensionChange: cfg.allowCoreExtensionChange,
|
|
986
|
+
...(cfg.coreExtensionChangeError ? { coreExtensionChangeError: cfg.coreExtensionChangeError } : {}),
|
|
771
987
|
};
|
|
772
988
|
}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { entityRevisionId } from "./write-revision.js";
|
|
12
|
+
import { httpStatusOf } from "./error-status.js";
|
|
12
13
|
|
|
13
14
|
const LANGCODE_RE = /^[a-z][a-z0-9_-]{0,11}$/;
|
|
14
15
|
const MISSING_DRAFT_ENDPOINT =
|
|
@@ -19,7 +20,6 @@ const MISSING_TRANSLATION_ENDPOINT =
|
|
|
19
20
|
"Update MCP Sentinel; no canonical langcode PATCH was attempted.";
|
|
20
21
|
const MISSING_DRAFT_RE = /does not provide Sentinel's governed draft endpoint/;
|
|
21
22
|
const MISSING_TRANSLATION_RE = /does not provide Sentinel's governed draft-translation endpoint/;
|
|
22
|
-
const DRUPAL_ABSENCE_RE = /Drupal (404|405)\b/;
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
25
|
* Whether a backend can issue Sentinel's JSON:API draft/translation routes.
|
|
@@ -70,7 +70,8 @@ export function isMissingTranslationEndpoint(error) {
|
|
|
70
70
|
* @returns {Error}
|
|
71
71
|
*/
|
|
72
72
|
function missingEndpointError(error, message) {
|
|
73
|
-
|
|
73
|
+
const status = httpStatusOf(error);
|
|
74
|
+
if (status === 404 || status === 405) {
|
|
74
75
|
return new Error(message, { cause: error });
|
|
75
76
|
}
|
|
76
77
|
return error instanceof Error ? error : new Error(String(error));
|