drupal-mcp-connector 2.13.1 → 2.14.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/CHANGELOG.md +40 -0
- package/bin/drupal-mcp-anchor.js +52 -0
- package/bin/drupal-mcp-edge.js +17 -1
- package/config/config.example.json +6 -0
- package/package.json +3 -2
- package/src/lib/anchor.js +338 -0
- package/src/lib/config.js +39 -0
- package/src/lib/contracts/approval.js +5 -0
- package/src/lib/evidence.js +607 -0
- package/src/lib/http-auth.js +65 -3
- package/src/lib/relay/edge.js +551 -7
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Independently verifiable evidence (#261).
|
|
3
|
+
*
|
|
4
|
+
* Reconciles one governed execution by stable identifiers, submits the
|
|
5
|
+
* minimized digest to an independent notary, and exports a tenant-scoped
|
|
6
|
+
* assessor pack bound to the live policy digest. Caller tenant / identity
|
|
7
|
+
* fields are never authority. Prompts, payloads, and secrets never enter
|
|
8
|
+
* the ledger or the export.
|
|
9
|
+
*
|
|
10
|
+
* Lab/loopback only. Not a hosted-service or design-partner admission claim.
|
|
11
|
+
* Audit Chain NDJSON is the off-system stream, not this anchor.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { digestPayload } from "./contracts/types.js";
|
|
15
|
+
import { loadPinnedPublicKey, verifyInclusion } from "./anchor.js";
|
|
16
|
+
import { POLICY_DIGEST } from "./policy-promotion.js";
|
|
17
|
+
|
|
18
|
+
/** Explicit absences are identifiers, not missing fields. */
|
|
19
|
+
export const ABSENT = Object.freeze({
|
|
20
|
+
delegation: "none:no_delegation",
|
|
21
|
+
obligations: "none:no_obligations",
|
|
22
|
+
approval: "none:not_required",
|
|
23
|
+
targetRevision: "none:not_observed",
|
|
24
|
+
localExecution: "none:not_dispatched",
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
/** The eight identifiers a governed execution must carry. */
|
|
28
|
+
export const EXECUTION_IDS = Object.freeze([
|
|
29
|
+
"identityId",
|
|
30
|
+
"delegationId",
|
|
31
|
+
"decisionId",
|
|
32
|
+
"obligationId",
|
|
33
|
+
"approvalId",
|
|
34
|
+
"localExecutionId",
|
|
35
|
+
"targetRevision",
|
|
36
|
+
"receiptId",
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
export const RECONCILE_STATES = Object.freeze([
|
|
40
|
+
"settled",
|
|
41
|
+
"incomplete",
|
|
42
|
+
"mismatched",
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
const DEFAULT_MAX_RECORDS = 10_000;
|
|
46
|
+
|
|
47
|
+
const FORBIDDEN_EXPORT_KEYS = new Set([
|
|
48
|
+
"prompt",
|
|
49
|
+
"payload",
|
|
50
|
+
"email",
|
|
51
|
+
"mail",
|
|
52
|
+
"ip",
|
|
53
|
+
"useragent",
|
|
54
|
+
"user-agent",
|
|
55
|
+
"body",
|
|
56
|
+
"arguments",
|
|
57
|
+
"secret",
|
|
58
|
+
"token",
|
|
59
|
+
"password",
|
|
60
|
+
"authorization",
|
|
61
|
+
"cookie",
|
|
62
|
+
"clientsecret",
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Controls the assessor may cite. Prior labs are residual in this export
|
|
67
|
+
* unless this pack carries evidence for them. Never "passed".
|
|
68
|
+
*/
|
|
69
|
+
export const ASSESSOR_CONTROL_CATALOG = Object.freeze([
|
|
70
|
+
Object.freeze({ id: "P5.3", title: "Tenant secret isolation", source: "prior_lab" }),
|
|
71
|
+
Object.freeze({ id: "P5.5", title: "Token-resolved routing", source: "prior_lab" }),
|
|
72
|
+
Object.freeze({ id: "P5.4", title: "Signed policy lifecycle", source: "prior_lab" }),
|
|
73
|
+
Object.freeze({ id: "P8.5", title: "Local policy continuity", source: "prior_lab" }),
|
|
74
|
+
Object.freeze({ id: "P5.6", title: "Attributable usage", source: "prior_lab" }),
|
|
75
|
+
Object.freeze({ id: "P8.7", title: "Independent evidence anchoring", source: "anchor" }),
|
|
76
|
+
Object.freeze({ id: "P8.8", title: "Data-minimized provenance", source: "anchor" }),
|
|
77
|
+
Object.freeze({ id: "P8.10", title: "Execution reconciliation", source: "anchor" }),
|
|
78
|
+
Object.freeze({ id: "P9.8", title: "Evidence-producing onboarding", source: "onboard" }),
|
|
79
|
+
Object.freeze({ id: "P9.9", title: "Clean offboarding", source: "offboard" }),
|
|
80
|
+
]);
|
|
81
|
+
|
|
82
|
+
function isRecord(value) {
|
|
83
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function cleanKey(value) {
|
|
87
|
+
const key = typeof value === "string" ? value.trim() : "";
|
|
88
|
+
return key && !key.startsWith("_") ? key : "";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function grantIds(values) {
|
|
92
|
+
if (!Array.isArray(values)) return [];
|
|
93
|
+
return [...new Set(values.map((value) => cleanKey(value)).filter(Boolean))];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function identifier(value, fallback = "") {
|
|
97
|
+
const id = typeof value === "string" ? value.trim() : "";
|
|
98
|
+
return id || fallback;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function invalid(reason) {
|
|
102
|
+
return Object.freeze({ invalid: true, reason });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function isLoopbackUrl(url) {
|
|
106
|
+
try {
|
|
107
|
+
const parsed = new URL(url);
|
|
108
|
+
return parsed.hostname === "127.0.0.1"
|
|
109
|
+
|| parsed.hostname === "localhost"
|
|
110
|
+
|| parsed.hostname === "::1";
|
|
111
|
+
} catch {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Digest of the inbound principal. Tokens and secrets are not inputs.
|
|
118
|
+
*
|
|
119
|
+
* @param {object|null} identity
|
|
120
|
+
* @returns {string}
|
|
121
|
+
*/
|
|
122
|
+
export function identityId(identity) {
|
|
123
|
+
if (!isRecord(identity)) return "";
|
|
124
|
+
const issuer = typeof identity.iss === "string"
|
|
125
|
+
? identity.iss
|
|
126
|
+
: (typeof identity.issuer === "string" ? identity.issuer : "");
|
|
127
|
+
const subject = typeof identity.sub === "string" && identity.sub
|
|
128
|
+
? identity.sub
|
|
129
|
+
: (typeof identity.subject === "string" ? identity.subject : "");
|
|
130
|
+
const clientId = typeof identity.clientId === "string" ? identity.clientId : "";
|
|
131
|
+
if (!subject && !clientId) return "";
|
|
132
|
+
return digestPayload({ issuer, subject, clientId });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* @param {string|null|undefined} delegator
|
|
137
|
+
* @returns {string}
|
|
138
|
+
*/
|
|
139
|
+
export function delegationId(delegator) {
|
|
140
|
+
return identifier(delegator, ABSENT.delegation);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* @param {unknown} obligations
|
|
145
|
+
* @returns {string}
|
|
146
|
+
*/
|
|
147
|
+
export function obligationId(obligations) {
|
|
148
|
+
if (!Array.isArray(obligations) || obligations.length === 0) return ABSENT.obligations;
|
|
149
|
+
return digestPayload(obligations);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* @param {string|null|undefined} approval
|
|
154
|
+
* @returns {string}
|
|
155
|
+
*/
|
|
156
|
+
export function approvalId(approval) {
|
|
157
|
+
return identifier(approval, ABSENT.approval);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Normalize `auth.evidenceAnchor`. Absent / comment-only is omitted.
|
|
162
|
+
* Anything else that is not `{ url, publicKey }` (or a test `{ submit,
|
|
163
|
+
* publicKey }`) is invalid and refuses startup.
|
|
164
|
+
*
|
|
165
|
+
* @param {object|null|undefined} raw
|
|
166
|
+
* @returns {object|null}
|
|
167
|
+
*/
|
|
168
|
+
export function normalizeEvidenceAnchor(raw) {
|
|
169
|
+
if (raw === null || raw === undefined) return null;
|
|
170
|
+
if (!isRecord(raw)) return invalid("evidenceAnchor");
|
|
171
|
+
const entries = Object.entries(raw)
|
|
172
|
+
.filter(([key]) => cleanKey(key));
|
|
173
|
+
if (!entries.length) return null;
|
|
174
|
+
const bag = new Map(entries);
|
|
175
|
+
const publicKey = typeof bag.get("publicKey") === "string" ? bag.get("publicKey").trim() : "";
|
|
176
|
+
if (!publicKey) return invalid("evidenceAnchor.publicKey");
|
|
177
|
+
try {
|
|
178
|
+
loadPinnedPublicKey(publicKey);
|
|
179
|
+
} catch {
|
|
180
|
+
return invalid("evidenceAnchor.publicKey");
|
|
181
|
+
}
|
|
182
|
+
const submit = bag.get("submit");
|
|
183
|
+
if (typeof submit === "function") {
|
|
184
|
+
return Object.freeze({
|
|
185
|
+
publicKey,
|
|
186
|
+
submit,
|
|
187
|
+
timeoutMs: 2000,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
const url = typeof bag.get("url") === "string" ? bag.get("url").trim() : "";
|
|
191
|
+
if (!url) return invalid("evidenceAnchor.url");
|
|
192
|
+
let parsed;
|
|
193
|
+
try {
|
|
194
|
+
parsed = new URL(url);
|
|
195
|
+
} catch {
|
|
196
|
+
return invalid("evidenceAnchor.url");
|
|
197
|
+
}
|
|
198
|
+
if (parsed.protocol === "http:") {
|
|
199
|
+
if (!isLoopbackUrl(url)) return invalid("evidenceAnchor.url");
|
|
200
|
+
} else if (parsed.protocol !== "https:") {
|
|
201
|
+
return invalid("evidenceAnchor.url");
|
|
202
|
+
}
|
|
203
|
+
const timeoutRaw = bag.get("timeoutMs");
|
|
204
|
+
const timeoutMs = timeoutRaw === undefined
|
|
205
|
+
? 2000
|
|
206
|
+
: (Number.isInteger(timeoutRaw) && timeoutRaw > 0 ? timeoutRaw : null);
|
|
207
|
+
if (timeoutMs === null) return invalid("evidenceAnchor.timeoutMs");
|
|
208
|
+
return Object.freeze({ url, publicKey, timeoutMs });
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* @param {object} [raw]
|
|
213
|
+
* @returns {object}
|
|
214
|
+
*/
|
|
215
|
+
export function createExecutionChain(raw = {}) {
|
|
216
|
+
const tenant = identifier(raw.tenant);
|
|
217
|
+
const requestId = identifier(raw.requestId);
|
|
218
|
+
const localExecutionId = identifier(raw.localExecutionId, requestId || ABSENT.localExecution);
|
|
219
|
+
return Object.freeze({
|
|
220
|
+
schema: "sentinel-execution-v1",
|
|
221
|
+
tenant,
|
|
222
|
+
identityId: identifier(raw.identityId),
|
|
223
|
+
delegationId: identifier(raw.delegationId, ABSENT.delegation),
|
|
224
|
+
decisionId: identifier(raw.decisionId),
|
|
225
|
+
obligationId: identifier(raw.obligationId, ABSENT.obligations),
|
|
226
|
+
approvalId: identifier(raw.approvalId, ABSENT.approval),
|
|
227
|
+
localExecutionId,
|
|
228
|
+
targetRevision: identifier(raw.targetRevision, ABSENT.targetRevision),
|
|
229
|
+
receiptId: identifier(raw.receiptId),
|
|
230
|
+
policyDigest: identifier(raw.policyDigest),
|
|
231
|
+
outcome: typeof raw.outcome === "string" ? raw.outcome : null,
|
|
232
|
+
requestId: requestId || (localExecutionId.startsWith("none:") ? "" : localExecutionId),
|
|
233
|
+
receiptDecisionId: identifier(raw.receiptDecisionId),
|
|
234
|
+
receiptTenant: identifier(raw.receiptTenant),
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* @param {object} chain
|
|
240
|
+
* @returns {{state: string, reason: string|null}}
|
|
241
|
+
*/
|
|
242
|
+
export function reconcileExecution(chain) {
|
|
243
|
+
if (!isRecord(chain)) {
|
|
244
|
+
return { state: "incomplete", reason: "missing_identifier" };
|
|
245
|
+
}
|
|
246
|
+
const required = ["tenant", "identityId", "decisionId", "receiptId", "localExecutionId", "delegationId", "obligationId", "approvalId", "targetRevision"];
|
|
247
|
+
const values = new Map(Object.entries(chain));
|
|
248
|
+
if (required.some((key) => !identifier(values.get(key)))) {
|
|
249
|
+
return { state: "incomplete", reason: "missing_identifier" };
|
|
250
|
+
}
|
|
251
|
+
if (chain.policyDigest && !POLICY_DIGEST.test(chain.policyDigest)) {
|
|
252
|
+
return { state: "mismatched", reason: "policy_digest" };
|
|
253
|
+
}
|
|
254
|
+
if (chain.requestId && chain.localExecutionId && !chain.localExecutionId.startsWith("none:")
|
|
255
|
+
&& chain.requestId !== chain.localExecutionId) {
|
|
256
|
+
return { state: "mismatched", reason: "execution_mismatch" };
|
|
257
|
+
}
|
|
258
|
+
if (chain.receiptDecisionId && chain.receiptDecisionId !== chain.decisionId) {
|
|
259
|
+
return { state: "mismatched", reason: "decision_mismatch" };
|
|
260
|
+
}
|
|
261
|
+
if (chain.receiptTenant && chain.receiptTenant !== chain.tenant) {
|
|
262
|
+
return { state: "mismatched", reason: "tenant_mismatch" };
|
|
263
|
+
}
|
|
264
|
+
return { state: "settled", reason: null };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Minimized digest: the eight ids, tenant, policy digest, and outcome.
|
|
269
|
+
* @param {object} chain
|
|
270
|
+
* @returns {string}
|
|
271
|
+
*/
|
|
272
|
+
export function digestExecution(chain) {
|
|
273
|
+
return digestPayload({
|
|
274
|
+
identityId: chain.identityId,
|
|
275
|
+
delegationId: chain.delegationId,
|
|
276
|
+
decisionId: chain.decisionId,
|
|
277
|
+
obligationId: chain.obligationId,
|
|
278
|
+
approvalId: chain.approvalId,
|
|
279
|
+
localExecutionId: chain.localExecutionId,
|
|
280
|
+
targetRevision: chain.targetRevision,
|
|
281
|
+
receiptId: chain.receiptId,
|
|
282
|
+
tenant: chain.tenant,
|
|
283
|
+
policyDigest: chain.policyDigest || "",
|
|
284
|
+
outcome: chain.outcome || "",
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function positiveInt(value) {
|
|
289
|
+
return Number.isInteger(value) && value > 0 ? value : null;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Bounded in-process evidence ledger. Tenant-partitioned. Restart clears it.
|
|
294
|
+
*
|
|
295
|
+
* @param {object} [options]
|
|
296
|
+
* @param {() => number} [options.now]
|
|
297
|
+
* @param {number} [options.maxRecords]
|
|
298
|
+
* @returns {object}
|
|
299
|
+
*/
|
|
300
|
+
export function createEvidenceLedger({
|
|
301
|
+
now = () => Date.now(),
|
|
302
|
+
maxRecords = DEFAULT_MAX_RECORDS,
|
|
303
|
+
} = {}) {
|
|
304
|
+
const bound = positiveInt(maxRecords);
|
|
305
|
+
if (bound === null) {
|
|
306
|
+
throw new TypeError("createEvidenceLedger requires maxRecords to be a positive integer.");
|
|
307
|
+
}
|
|
308
|
+
const rows = [];
|
|
309
|
+
let dropped = 0;
|
|
310
|
+
|
|
311
|
+
return {
|
|
312
|
+
get size() {
|
|
313
|
+
return rows.length;
|
|
314
|
+
},
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* @param {object} chain
|
|
318
|
+
* @param {object|null} [inclusion]
|
|
319
|
+
* @param {string|null} [publicPin]
|
|
320
|
+
* @returns {object}
|
|
321
|
+
*/
|
|
322
|
+
record(chain, inclusion = null, publicPin = null) {
|
|
323
|
+
const frozenChain = createExecutionChain(chain);
|
|
324
|
+
const reconciliation = reconcileExecution(frozenChain);
|
|
325
|
+
const digest = digestExecution(frozenChain);
|
|
326
|
+
let anchored = false;
|
|
327
|
+
let verifiedInclusion = null;
|
|
328
|
+
if (inclusion && publicPin) {
|
|
329
|
+
const checked = verifyInclusion(publicPin, inclusion);
|
|
330
|
+
anchored = checked.ok && inclusion.receiptDigest === digest;
|
|
331
|
+
verifiedInclusion = anchored ? inclusion : null;
|
|
332
|
+
}
|
|
333
|
+
const row = Object.freeze({
|
|
334
|
+
...frozenChain,
|
|
335
|
+
digest,
|
|
336
|
+
reconciliation: Object.freeze(reconciliation),
|
|
337
|
+
inclusion: verifiedInclusion,
|
|
338
|
+
anchored,
|
|
339
|
+
at: new Date(now()).toISOString(),
|
|
340
|
+
});
|
|
341
|
+
rows.push(row);
|
|
342
|
+
if (rows.length > bound) {
|
|
343
|
+
rows.shift();
|
|
344
|
+
dropped += 1;
|
|
345
|
+
}
|
|
346
|
+
return row;
|
|
347
|
+
},
|
|
348
|
+
|
|
349
|
+
/** @returns {object[]} */
|
|
350
|
+
records() {
|
|
351
|
+
return rows.slice();
|
|
352
|
+
},
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* @param {{tenant?: string|null}} [params]
|
|
356
|
+
* @returns {object[]}
|
|
357
|
+
*/
|
|
358
|
+
query({ tenant = null } = {}) {
|
|
359
|
+
const tenantId = typeof tenant === "string" ? tenant.trim() : "";
|
|
360
|
+
if (!tenantId) return [];
|
|
361
|
+
return rows.filter((row) => row.tenant === tenantId);
|
|
362
|
+
},
|
|
363
|
+
|
|
364
|
+
/** @returns {{size: number, dropped: number, maxRecords: number}} */
|
|
365
|
+
stats() {
|
|
366
|
+
return { size: rows.length, dropped, maxRecords: bound };
|
|
367
|
+
},
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const READ_DENIED = Object.freeze({ ok: false, reason: "not_entitled" });
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Tenant-scoped evidence read. Same grant rule as usage: tenant from
|
|
375
|
+
* `auth.tenantGrants`; a `tenant` argument is a confirming hint.
|
|
376
|
+
*
|
|
377
|
+
* @param {object} params
|
|
378
|
+
* @returns {{ok: true, tenant: string, records: object[]}|{ok: false, reason: "not_entitled"}}
|
|
379
|
+
*/
|
|
380
|
+
export function readEvidence({
|
|
381
|
+
identity = null,
|
|
382
|
+
tenantGrants = null,
|
|
383
|
+
tenant = null,
|
|
384
|
+
ledger = null,
|
|
385
|
+
} = {}) {
|
|
386
|
+
if (!ledger || typeof ledger.query !== "function") return READ_DENIED;
|
|
387
|
+
if (!isRecord(identity) || typeof identity.clientId !== "string" || !identity.clientId) {
|
|
388
|
+
return READ_DENIED;
|
|
389
|
+
}
|
|
390
|
+
if (!isRecord(tenantGrants)) return READ_DENIED;
|
|
391
|
+
const granted = grantIds(new Map(Object.entries(tenantGrants)).get(identity.clientId));
|
|
392
|
+
if (!granted.length) return READ_DENIED;
|
|
393
|
+
const hint = typeof tenant === "string" && tenant.trim() ? tenant.trim() : null;
|
|
394
|
+
if (hint && !granted.includes(hint)) return READ_DENIED;
|
|
395
|
+
if (!hint && granted.length > 1) return READ_DENIED;
|
|
396
|
+
const resolved = hint ?? granted[0];
|
|
397
|
+
return { ok: true, tenant: resolved, records: ledger.query({ tenant: resolved }) };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function forbiddenKey(key) {
|
|
401
|
+
return FORBIDDEN_EXPORT_KEYS.has(String(key).toLowerCase());
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Walk a value and fail if a forbidden key is present.
|
|
406
|
+
* @param {*} value
|
|
407
|
+
* @returns {boolean}
|
|
408
|
+
*/
|
|
409
|
+
export function isDataMinimized(value) {
|
|
410
|
+
if (Array.isArray(value)) return value.every(isDataMinimized);
|
|
411
|
+
if (!isRecord(value)) return true;
|
|
412
|
+
return Object.entries(value).every(([key, child]) => !forbiddenKey(key) && isDataMinimized(child));
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function minimizeInclusion(inclusion) {
|
|
416
|
+
if (!isRecord(inclusion)) return null;
|
|
417
|
+
return Object.freeze({
|
|
418
|
+
schema: inclusion.schema,
|
|
419
|
+
algorithm: inclusion.algorithm,
|
|
420
|
+
anchorId: inclusion.anchorId,
|
|
421
|
+
receiptDigest: inclusion.receiptDigest,
|
|
422
|
+
signedAt: inclusion.signedAt,
|
|
423
|
+
keyId: inclusion.keyId,
|
|
424
|
+
signature: inclusion.signature,
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function minimizeExecution(row) {
|
|
429
|
+
return Object.freeze({
|
|
430
|
+
identityId: row.identityId,
|
|
431
|
+
delegationId: row.delegationId,
|
|
432
|
+
decisionId: row.decisionId,
|
|
433
|
+
obligationId: row.obligationId,
|
|
434
|
+
approvalId: row.approvalId,
|
|
435
|
+
localExecutionId: row.localExecutionId,
|
|
436
|
+
targetRevision: row.targetRevision,
|
|
437
|
+
receiptId: row.receiptId,
|
|
438
|
+
policyDigest: row.policyDigest || null,
|
|
439
|
+
outcome: row.outcome,
|
|
440
|
+
reconcileState: row.reconciliation?.state ?? "incomplete",
|
|
441
|
+
digest: row.digest,
|
|
442
|
+
anchorId: row.inclusion?.anchorId ?? null,
|
|
443
|
+
inclusion: minimizeInclusion(row.inclusion),
|
|
444
|
+
anchored: row.anchored === true,
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function citeRow(row) {
|
|
449
|
+
return Object.freeze({
|
|
450
|
+
receiptId: row.receiptId,
|
|
451
|
+
anchorId: row.anchorId,
|
|
452
|
+
digest: row.digest,
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function liveAnchored(row, liveDigest) {
|
|
457
|
+
return row.anchored
|
|
458
|
+
&& row.reconcileState === "settled"
|
|
459
|
+
&& row.policyDigest === liveDigest;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function findOnboardOutcomes(executions, liveDigest) {
|
|
463
|
+
const settled = executions.filter((row) => liveAnchored(row, liveDigest));
|
|
464
|
+
const allowed = settled.find((row) => (
|
|
465
|
+
row.outcome === "ok"
|
|
466
|
+
&& (!row.approvalId || row.approvalId === ABSENT.approval)
|
|
467
|
+
)) ?? null;
|
|
468
|
+
const denied = settled.find((row) => row.outcome === "denied") ?? null;
|
|
469
|
+
const gated = settled.find((row) => (
|
|
470
|
+
row.outcome === "require_approval"
|
|
471
|
+
|| (row.outcome === "ok" && row.approvalId && row.approvalId !== ABSENT.approval)
|
|
472
|
+
)) ?? null;
|
|
473
|
+
return { allowed, denied, gated, any: settled[0] ?? null };
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function findOffboardOutcomes(executions, liveDigest) {
|
|
477
|
+
const settled = executions.filter((row) => liveAnchored(row, liveDigest));
|
|
478
|
+
const revoked = settled.find((row) => row.outcome === "revoked") ?? null;
|
|
479
|
+
const destroyed = settled.find((row) => row.outcome === "destroyed") ?? null;
|
|
480
|
+
return { revoked, destroyed, any: settled[0] ?? null };
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Data-minimized assessor export bound to the live policy digest.
|
|
485
|
+
*
|
|
486
|
+
* @param {object} params
|
|
487
|
+
* @param {object|null} params.identity
|
|
488
|
+
* @param {object|null} params.tenantGrants
|
|
489
|
+
* @param {string|null} [params.tenant]
|
|
490
|
+
* @param {object|null} params.ledger
|
|
491
|
+
* @param {string|null} [params.policyDigest] Live bound digest (server-resolved).
|
|
492
|
+
* @param {boolean} [params.attested]
|
|
493
|
+
* @param {() => Date} [params.now]
|
|
494
|
+
* @returns {{ok: true, tenant: string, policyDigest: string|null, executions: object[], controls: object[]}|{ok: false, reason: string}}
|
|
495
|
+
*/
|
|
496
|
+
export function exportAssessor({
|
|
497
|
+
identity = null,
|
|
498
|
+
tenantGrants = null,
|
|
499
|
+
tenant = null,
|
|
500
|
+
ledger = null,
|
|
501
|
+
policyDigest = null,
|
|
502
|
+
attested = false,
|
|
503
|
+
now = () => new Date(),
|
|
504
|
+
} = {}) {
|
|
505
|
+
const read = readEvidence({ identity, tenantGrants, tenant, ledger });
|
|
506
|
+
if (!read.ok) return read;
|
|
507
|
+
const liveDigest = typeof policyDigest === "string" && POLICY_DIGEST.test(policyDigest)
|
|
508
|
+
? policyDigest.toLowerCase()
|
|
509
|
+
: null;
|
|
510
|
+
const executions = read.records.map(minimizeExecution);
|
|
511
|
+
const evidenced = liveDigest
|
|
512
|
+
? executions.find((row) => (
|
|
513
|
+
row.anchored
|
|
514
|
+
&& row.reconcileState === "settled"
|
|
515
|
+
&& row.policyDigest === liveDigest
|
|
516
|
+
)) ?? null
|
|
517
|
+
: null;
|
|
518
|
+
|
|
519
|
+
const onboard = liveDigest ? findOnboardOutcomes(executions, liveDigest) : null;
|
|
520
|
+
const offboard = liveDigest ? findOffboardOutcomes(executions, liveDigest) : null;
|
|
521
|
+
const controls = ASSESSOR_CONTROL_CATALOG.map((control) => {
|
|
522
|
+
if (control.source === "offboard") {
|
|
523
|
+
const complete = Boolean(offboard?.revoked && offboard?.destroyed);
|
|
524
|
+
return Object.freeze({
|
|
525
|
+
id: control.id,
|
|
526
|
+
title: control.title,
|
|
527
|
+
policyDigest: liveDigest,
|
|
528
|
+
evidence: complete
|
|
529
|
+
? Object.freeze({
|
|
530
|
+
revoked: citeRow(offboard.revoked),
|
|
531
|
+
destroyed: citeRow(offboard.destroyed),
|
|
532
|
+
})
|
|
533
|
+
: null,
|
|
534
|
+
state: complete ? "evidenced" : "residual",
|
|
535
|
+
reason: complete
|
|
536
|
+
? null
|
|
537
|
+
: (!liveDigest
|
|
538
|
+
? "no_live_policy_digest"
|
|
539
|
+
: (offboard?.any ? "incomplete_offboard_outcomes" : "no_anchored_execution")),
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
if (control.source === "onboard") {
|
|
543
|
+
const complete = Boolean(onboard?.allowed && onboard?.denied && onboard?.gated);
|
|
544
|
+
return Object.freeze({
|
|
545
|
+
id: control.id,
|
|
546
|
+
title: control.title,
|
|
547
|
+
policyDigest: liveDigest,
|
|
548
|
+
evidence: complete
|
|
549
|
+
? Object.freeze({
|
|
550
|
+
allowed: citeRow(onboard.allowed),
|
|
551
|
+
denied: citeRow(onboard.denied),
|
|
552
|
+
approvalGated: citeRow(onboard.gated),
|
|
553
|
+
})
|
|
554
|
+
: null,
|
|
555
|
+
state: complete ? "evidenced" : "residual",
|
|
556
|
+
reason: complete
|
|
557
|
+
? null
|
|
558
|
+
: (!liveDigest
|
|
559
|
+
? "no_live_policy_digest"
|
|
560
|
+
: (onboard?.any ? "incomplete_onboard_outcomes" : "no_anchored_execution")),
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
const cites = Boolean(evidenced && control.source === "anchor");
|
|
564
|
+
return Object.freeze({
|
|
565
|
+
id: control.id,
|
|
566
|
+
title: control.title,
|
|
567
|
+
policyDigest: liveDigest,
|
|
568
|
+
evidence: cites ? citeRow(evidenced) : null,
|
|
569
|
+
state: cites ? "evidenced" : "residual",
|
|
570
|
+
reason: cites
|
|
571
|
+
? null
|
|
572
|
+
: (!liveDigest
|
|
573
|
+
? "no_live_policy_digest"
|
|
574
|
+
: (evidenced ? "not_in_this_export" : "no_anchored_execution")),
|
|
575
|
+
});
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
const pack = {
|
|
579
|
+
ok: true,
|
|
580
|
+
tenant: read.tenant,
|
|
581
|
+
generatedAt: now().toISOString(),
|
|
582
|
+
policyDigest: liveDigest,
|
|
583
|
+
attested: attested === true && Boolean(liveDigest),
|
|
584
|
+
executions,
|
|
585
|
+
controls,
|
|
586
|
+
residuals: Object.freeze([
|
|
587
|
+
Object.freeze({
|
|
588
|
+
id: "shared_host_notary",
|
|
589
|
+
status: "managed",
|
|
590
|
+
detail:
|
|
591
|
+
"The lab notary may share a host with the edge. Production placement "
|
|
592
|
+
+ "must be a separately administered sink; this export does not choose one.",
|
|
593
|
+
}),
|
|
594
|
+
Object.freeze({
|
|
595
|
+
id: "not_a_hosted_admission",
|
|
596
|
+
status: "managed",
|
|
597
|
+
detail:
|
|
598
|
+
"Loopback evidence is not hosted design-partner admission. Laboratory "
|
|
599
|
+
+ "onboard and offboard may be cited here; hosted admission remains a later cut.",
|
|
600
|
+
}),
|
|
601
|
+
]),
|
|
602
|
+
};
|
|
603
|
+
if (!isDataMinimized(pack)) {
|
|
604
|
+
throw new Error("Assessor export carried a forbidden key; the pack was not emitted.");
|
|
605
|
+
}
|
|
606
|
+
return pack;
|
|
607
|
+
}
|
package/src/lib/http-auth.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { timingSafeEqual } from "crypto";
|
|
13
|
-
import { readFileSync, statSync } from "fs";
|
|
13
|
+
import { readFileSync, statSync, writeFileSync } from "fs";
|
|
14
14
|
import { createRemoteJWKSet, customFetch, jwtVerify } from "jose";
|
|
15
15
|
|
|
16
16
|
/** Header names that must never become identity. */
|
|
@@ -248,11 +248,15 @@ export function authorizationServerDiscoveryUrls(issuer) {
|
|
|
248
248
|
*
|
|
249
249
|
* @param {object} options
|
|
250
250
|
* @param {string} [options.filePath]
|
|
251
|
-
* @returns {{
|
|
251
|
+
* @returns {{
|
|
252
|
+
* isRevoked: (identity: {jti?: ?string, sub?: ?string}) => boolean,
|
|
253
|
+
* revoke: (identity: {jti?: ?string}) => {ok: true}|{ok: false, reason: string},
|
|
254
|
+
* }}
|
|
252
255
|
*/
|
|
253
256
|
export function createRevocationStore({
|
|
254
257
|
filePath,
|
|
255
258
|
readFile = readFileSync,
|
|
259
|
+
writeFile = writeFileSync,
|
|
256
260
|
stat = statSync,
|
|
257
261
|
} = {}) {
|
|
258
262
|
let cache = { mtimeMs: Number.NaN, jti: new Set(), sub: new Set(), denyAll: false };
|
|
@@ -290,6 +294,56 @@ export function createRevocationStore({
|
|
|
290
294
|
if (identity.sub && sub.has(identity.sub)) return true;
|
|
291
295
|
return false;
|
|
292
296
|
},
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Persist one jti and apply it in-process. The next authenticate does
|
|
300
|
+
* not wait on mtime; a leftover file still denies after restart.
|
|
301
|
+
*
|
|
302
|
+
* @param {{jti?: ?string}} identity
|
|
303
|
+
* @returns {{ok: true}|{ok: false, reason: string}}
|
|
304
|
+
*/
|
|
305
|
+
revoke(identity) {
|
|
306
|
+
const tokenId = typeof identity?.jti === "string" ? identity.jti.trim() : "";
|
|
307
|
+
if (!tokenId || !filePath) {
|
|
308
|
+
return { ok: false, reason: "unreadable" };
|
|
309
|
+
}
|
|
310
|
+
let exists = false;
|
|
311
|
+
try {
|
|
312
|
+
stat(filePath);
|
|
313
|
+
exists = true;
|
|
314
|
+
} catch (err) {
|
|
315
|
+
if (err?.code !== "ENOENT" && err?.code !== "ENOTDIR") {
|
|
316
|
+
return { ok: false, reason: "unreadable" };
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
let raw;
|
|
320
|
+
if (exists) {
|
|
321
|
+
try {
|
|
322
|
+
raw = JSON.parse(readFile(filePath, "utf8"));
|
|
323
|
+
} catch {
|
|
324
|
+
return { ok: false, reason: "unreadable" };
|
|
325
|
+
}
|
|
326
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
327
|
+
return { ok: false, reason: "unreadable" };
|
|
328
|
+
}
|
|
329
|
+
} else {
|
|
330
|
+
raw = { jti: [], sub: [] };
|
|
331
|
+
}
|
|
332
|
+
const nextJti = new Set([...(raw.jti ?? []).map(String), tokenId]);
|
|
333
|
+
const nextSub = new Set((raw.sub ?? []).map(String));
|
|
334
|
+
try {
|
|
335
|
+
writeFile(filePath, JSON.stringify({ jti: [...nextJti], sub: [...nextSub] }));
|
|
336
|
+
} catch {
|
|
337
|
+
return { ok: false, reason: "unreadable" };
|
|
338
|
+
}
|
|
339
|
+
cache = {
|
|
340
|
+
mtimeMs: Number.NaN,
|
|
341
|
+
jti: nextJti,
|
|
342
|
+
sub: nextSub,
|
|
343
|
+
denyAll: false,
|
|
344
|
+
};
|
|
345
|
+
return { ok: true };
|
|
346
|
+
},
|
|
293
347
|
};
|
|
294
348
|
}
|
|
295
349
|
|
|
@@ -529,7 +583,12 @@ export function resolveInboundAuthConfig(cfg = {}, env = process.env) {
|
|
|
529
583
|
/**
|
|
530
584
|
* Build the live inbound authenticator for HTTPS.
|
|
531
585
|
* @param {object} options
|
|
532
|
-
* @returns {Promise<{
|
|
586
|
+
* @returns {Promise<{
|
|
587
|
+
* authenticate: Function,
|
|
588
|
+
* protectedResource: object,
|
|
589
|
+
* resourceMetadataUrl: string,
|
|
590
|
+
* revoke?: (jti: string) => {ok: true}|{ok: false, reason: string},
|
|
591
|
+
* }>}
|
|
533
592
|
*/
|
|
534
593
|
export async function createInboundHttpsAuth({ inboundCfg, fetchFn = fetch }) {
|
|
535
594
|
if (!inboundCfg.issuer || !inboundCfg.audience) {
|
|
@@ -587,5 +646,8 @@ export async function createInboundHttpsAuth({ inboundCfg, fetchFn = fetch }) {
|
|
|
587
646
|
scopesSupported: inboundCfg.requiredScopes,
|
|
588
647
|
}),
|
|
589
648
|
resourceMetadataUrl,
|
|
649
|
+
...(revocationStore ? {
|
|
650
|
+
revoke: (jti) => revocationStore.revoke({ jti }),
|
|
651
|
+
} : {}),
|
|
590
652
|
};
|
|
591
653
|
}
|