@voltro/plugin-governance 0.80.0 → 0.82.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 +1610 -6
- package/THIRD-PARTY-NOTICES.md +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +59 -49
- package/package.json +4 -4
package/THIRD-PARTY-NOTICES.md
CHANGED
package/dist/index.d.ts
CHANGED
|
@@ -221,6 +221,9 @@ export declare interface GovernancePluginOptions {
|
|
|
221
221
|
*/
|
|
222
222
|
readonly fieldEncryption?: boolean | {
|
|
223
223
|
readonly secretKey?: string;
|
|
224
|
+
/** Names of the secrets holding the keys a rotation retires — read, never
|
|
225
|
+
* written with. Default `['VOLTRO_FIELD_ENCRYPTION_KEY_PREVIOUS']`. */
|
|
226
|
+
readonly previousSecretKeys?: ReadonlyArray<string>;
|
|
224
227
|
};
|
|
225
228
|
/**
|
|
226
229
|
* Namespace for this plugin's rpc tags + inspect endpoints. Default `governance`.
|
package/dist/index.js
CHANGED
|
@@ -280,37 +280,42 @@ var A = (e) => {
|
|
|
280
280
|
}, S = () => x().paths;
|
|
281
281
|
for (let e of a.retention ?? []) (e.action ?? "delete") === "anonymize" && R(`retention policy '${e.table}'`, e.anonymizeFields);
|
|
282
282
|
a.erasure?.mode === "anonymize" && R("erasure defaults", a.erasure.anonymizeFields);
|
|
283
|
-
let w = typeof a.fieldEncryption == "object" ? a.fieldEncryption.secretKey ?? "VOLTRO_FIELD_ENCRYPTION_KEY" : "VOLTRO_FIELD_ENCRYPTION_KEY", T = a.fieldEncryption ? [{
|
|
283
|
+
let w = typeof a.fieldEncryption == "object" ? a.fieldEncryption.secretKey ?? "VOLTRO_FIELD_ENCRYPTION_KEY" : "VOLTRO_FIELD_ENCRYPTION_KEY", T = typeof a.fieldEncryption == "object" ? a.fieldEncryption.previousSecretKeys ?? ["VOLTRO_FIELD_ENCRYPTION_KEY_PREVIOUS"] : ["VOLTRO_FIELD_ENCRYPTION_KEY_PREVIOUS"], E = a.fieldEncryption ? [{
|
|
284
284
|
name: w,
|
|
285
285
|
required: !1,
|
|
286
286
|
secret: !0,
|
|
287
287
|
description: "AES-256-GCM key for `.encrypted()` columns (field encryption). Lose the key, lose the data — GCM fails closed."
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
288
|
+
}, ...T.map((e) => ({
|
|
289
|
+
name: e,
|
|
290
|
+
required: !1,
|
|
291
|
+
secret: !0,
|
|
292
|
+
description: "A retired field-encryption key, read during a rotation so values written under it stay readable. Remove once they are re-encrypted."
|
|
293
|
+
}))] : [], D = [], O, k, j = () => void 0, M = [], N = null, P = !1, I = () => {
|
|
294
|
+
if (!O) throw Error("governance: data store not bound yet");
|
|
295
|
+
return O;
|
|
296
|
+
}, B = {
|
|
292
297
|
subjectGraph: x,
|
|
293
|
-
exportSubject: (e) => L(
|
|
298
|
+
exportSubject: (e) => L(I(), S(), e),
|
|
294
299
|
eraseSubject: async (e, t) => {
|
|
295
300
|
let n = {
|
|
296
301
|
...a.erasure,
|
|
297
302
|
...t
|
|
298
|
-
}, r = await z(
|
|
299
|
-
return
|
|
303
|
+
}, r = await z(I(), S(), e, n);
|
|
304
|
+
return D.unshift(r), D.length > 1e3 && (D.length = Z), r;
|
|
300
305
|
},
|
|
301
306
|
recordConsent: (e) => v.record(e),
|
|
302
307
|
hasConsent: (e, t) => v.has(e, t),
|
|
303
308
|
runRetentionNow: async () => {
|
|
304
|
-
if (!
|
|
305
|
-
let e = await U(
|
|
306
|
-
|
|
309
|
+
if (!O || !a.retention) return;
|
|
310
|
+
let e = await U(O, a.retention);
|
|
311
|
+
M = e, N = (/* @__PURE__ */ new Date()).toISOString(), j("retention sweep", { report: e });
|
|
307
312
|
}
|
|
308
|
-
},
|
|
313
|
+
}, V = [
|
|
309
314
|
{
|
|
310
315
|
...n,
|
|
311
316
|
description: "GDPR data export for a subject (admin only).",
|
|
312
317
|
execute: (e, t) => o.gen(function* () {
|
|
313
|
-
return yield* d(t.request.subject, c), yield* o.promise(() =>
|
|
318
|
+
return yield* d(t.request.subject, c), yield* o.promise(() => B.exportSubject(e.subjectId));
|
|
314
319
|
})
|
|
315
320
|
},
|
|
316
321
|
{
|
|
@@ -319,7 +324,7 @@ var A = (e) => {
|
|
|
319
324
|
execute: (e, t) => o.gen(function* () {
|
|
320
325
|
yield* d(t.request.subject, c);
|
|
321
326
|
let { subjectId: n, mode: r } = e;
|
|
322
|
-
return yield* o.promise(() =>
|
|
327
|
+
return yield* o.promise(() => B.eraseSubject(n, r ? { mode: r } : void 0));
|
|
323
328
|
})
|
|
324
329
|
},
|
|
325
330
|
{
|
|
@@ -327,7 +332,7 @@ var A = (e) => {
|
|
|
327
332
|
description: "Record the calling subject's consent for a purpose.",
|
|
328
333
|
execute: (e, t) => o.promise(async () => {
|
|
329
334
|
let { purpose: n, granted: r } = e, i = t.request.subject;
|
|
330
|
-
return await
|
|
335
|
+
return await B.recordConsent({
|
|
331
336
|
subjectId: i.id ?? "anonymous",
|
|
332
337
|
purpose: n,
|
|
333
338
|
granted: r,
|
|
@@ -341,23 +346,23 @@ var A = (e) => {
|
|
|
341
346
|
description: "Whether the calling subject has granted consent for a purpose.",
|
|
342
347
|
execute: (e, t) => o.promise(async () => {
|
|
343
348
|
let n = t.request.subject;
|
|
344
|
-
return { granted: await
|
|
349
|
+
return { granted: await B.hasConsent(n.id ?? "anonymous", e.purpose) };
|
|
345
350
|
})
|
|
346
351
|
}
|
|
347
|
-
],
|
|
352
|
+
], H = (e) => ({
|
|
348
353
|
kind: "json",
|
|
349
354
|
data: e
|
|
350
|
-
}),
|
|
355
|
+
}), W = (e, t) => ({
|
|
351
356
|
kind: "json",
|
|
352
357
|
status: e,
|
|
353
358
|
data: { error: t }
|
|
354
|
-
}),
|
|
359
|
+
}), K = (e, t) => new URLSearchParams(new URL(e, "http://x").search).get(t), q = [
|
|
355
360
|
{
|
|
356
361
|
method: "GET",
|
|
357
362
|
path: "/status",
|
|
358
363
|
scope: "process",
|
|
359
364
|
description: "Retention policies, subject scopes, field-encryption state, last sweep.",
|
|
360
|
-
handler: () => o.succeed(
|
|
365
|
+
handler: () => o.succeed(H({
|
|
361
366
|
retention: (a.retention ?? []).map((e) => ({
|
|
362
367
|
table: e.table,
|
|
363
368
|
ttlMs: e.ttlMs,
|
|
@@ -367,11 +372,11 @@ var A = (e) => {
|
|
|
367
372
|
table: e.table,
|
|
368
373
|
subjectField: e.subjectField
|
|
369
374
|
})),
|
|
370
|
-
fieldEncryption:
|
|
375
|
+
fieldEncryption: P,
|
|
371
376
|
sweepIntervalMs: a.sweepIntervalMs ?? 36e5,
|
|
372
|
-
lastSweepAt:
|
|
373
|
-
lastSweep:
|
|
374
|
-
erasureCount:
|
|
377
|
+
lastSweepAt: N,
|
|
378
|
+
lastSweep: M,
|
|
379
|
+
erasureCount: D.length
|
|
375
380
|
}))
|
|
376
381
|
},
|
|
377
382
|
{
|
|
@@ -379,7 +384,7 @@ var A = (e) => {
|
|
|
379
384
|
path: "/erasures",
|
|
380
385
|
scope: "process",
|
|
381
386
|
description: "GDPR erasure log (most recent first).",
|
|
382
|
-
handler: () => o.succeed(
|
|
387
|
+
handler: () => o.succeed(H({ erasures: D.slice(0, 100) }))
|
|
383
388
|
},
|
|
384
389
|
{
|
|
385
390
|
method: "GET",
|
|
@@ -388,7 +393,7 @@ var A = (e) => {
|
|
|
388
393
|
description: "The routes a DSAR export/erasure walks — AND what the derivation cannot see.",
|
|
389
394
|
handler: () => o.sync(() => {
|
|
390
395
|
let e = x();
|
|
391
|
-
return
|
|
396
|
+
return H({
|
|
392
397
|
subjectTable: e.subjectTable,
|
|
393
398
|
derived: b !== void 0,
|
|
394
399
|
paths: e.paths.map((t) => ({
|
|
@@ -407,8 +412,8 @@ var A = (e) => {
|
|
|
407
412
|
scope: p ? "shared-store" : "process",
|
|
408
413
|
description: "Consent ledger for a subject (?subjectId=…).",
|
|
409
414
|
handler: (e) => {
|
|
410
|
-
let t =
|
|
411
|
-
return t ? o.promise(async () =>
|
|
415
|
+
let t = K(e.url, "subjectId");
|
|
416
|
+
return t ? o.promise(async () => H({ records: await v.list(t) })) : o.succeed(W(400, "subjectId query param required"));
|
|
412
417
|
}
|
|
413
418
|
},
|
|
414
419
|
{
|
|
@@ -420,13 +425,13 @@ var A = (e) => {
|
|
|
420
425
|
try {
|
|
421
426
|
t = JSON.parse(e.body || "{}");
|
|
422
427
|
} catch {
|
|
423
|
-
return
|
|
428
|
+
return W(400, "invalid JSON body");
|
|
424
429
|
}
|
|
425
|
-
if (typeof t != "object" || !t || Array.isArray(t) || !("subjectId" in t) || typeof t.subjectId != "string" || !t.subjectId.trim()) return
|
|
430
|
+
if (typeof t != "object" || !t || Array.isArray(t) || !("subjectId" in t) || typeof t.subjectId != "string" || !t.subjectId.trim()) return W(400, "subjectId must be a non-empty string");
|
|
426
431
|
try {
|
|
427
|
-
return
|
|
432
|
+
return H({ bundle: await B.exportSubject(t.subjectId) });
|
|
428
433
|
} catch (e) {
|
|
429
|
-
return
|
|
434
|
+
return W(500, e.message);
|
|
430
435
|
}
|
|
431
436
|
})
|
|
432
437
|
},
|
|
@@ -439,15 +444,15 @@ var A = (e) => {
|
|
|
439
444
|
try {
|
|
440
445
|
t = JSON.parse(e.body || "{}");
|
|
441
446
|
} catch {
|
|
442
|
-
return
|
|
447
|
+
return W(400, "invalid JSON body");
|
|
443
448
|
}
|
|
444
|
-
if (typeof t != "object" || !t || Array.isArray(t) || !("subjectId" in t) || typeof t.subjectId != "string" || !t.subjectId.trim()) return
|
|
449
|
+
if (typeof t != "object" || !t || Array.isArray(t) || !("subjectId" in t) || typeof t.subjectId != "string" || !t.subjectId.trim()) return W(400, "subjectId must be a non-empty string");
|
|
445
450
|
let n = "mode" in t ? t.mode : void 0;
|
|
446
|
-
if (n !== void 0 && n !== "delete" && n !== "anonymize") return
|
|
451
|
+
if (n !== void 0 && n !== "delete" && n !== "anonymize") return W(400, "mode must be delete or anonymize");
|
|
447
452
|
try {
|
|
448
|
-
return
|
|
453
|
+
return H({ entry: await B.eraseSubject(t.subjectId, n ? { mode: n } : void 0) });
|
|
449
454
|
} catch (e) {
|
|
450
|
-
return
|
|
455
|
+
return W(500, e.message);
|
|
451
456
|
}
|
|
452
457
|
})
|
|
453
458
|
},
|
|
@@ -455,9 +460,9 @@ var A = (e) => {
|
|
|
455
460
|
method: "POST",
|
|
456
461
|
path: "/sweep",
|
|
457
462
|
description: "Run the retention sweep now.",
|
|
458
|
-
handler: () => o.promise(async () => (await
|
|
459
|
-
lastSweepAt:
|
|
460
|
-
lastSweep:
|
|
463
|
+
handler: () => o.promise(async () => (await B.runRetentionNow(), H({
|
|
464
|
+
lastSweepAt: N,
|
|
465
|
+
lastSweep: M
|
|
461
466
|
})))
|
|
462
467
|
}
|
|
463
468
|
];
|
|
@@ -470,25 +475,30 @@ var A = (e) => {
|
|
|
470
475
|
"inspect:read",
|
|
471
476
|
"inspect:write"
|
|
472
477
|
],
|
|
473
|
-
declaredEnv:
|
|
474
|
-
services: s.succeed(Q,
|
|
475
|
-
routes:
|
|
478
|
+
declaredEnv: E,
|
|
479
|
+
services: s.succeed(Q, B),
|
|
480
|
+
routes: V,
|
|
476
481
|
rpcClientDescriptors: r,
|
|
477
|
-
inspectEndpoints:
|
|
482
|
+
inspectEndpoints: q,
|
|
478
483
|
...p ? { extendSchema: { tables: J } } : {},
|
|
479
484
|
bindDataStore: (e, t) => {
|
|
480
|
-
if (
|
|
485
|
+
if (O = A(e), p && (g = X(e)), a.retention && a.retention.length > 0) {
|
|
481
486
|
let e = a.sweepIntervalMs ?? 36e5;
|
|
482
|
-
|
|
487
|
+
k = t?.scheduleCoordinated("governance.retention.sweep", e, () => B.runRetentionNow());
|
|
483
488
|
}
|
|
484
489
|
},
|
|
485
490
|
onActivate: (e) => o.gen(function* () {
|
|
486
|
-
|
|
491
|
+
j = (t, n) => e.logger.warn(t, n);
|
|
487
492
|
let t = !1;
|
|
488
493
|
if (a.fieldEncryption) {
|
|
489
494
|
let e = w, n = yield* o.promise(() => h(e));
|
|
490
495
|
if (!n) return yield* o.die(/* @__PURE__ */ Error(`governance: fieldEncryption is enabled but secret "${e}" did not resolve — set it via the Secrets-Resolver / env`));
|
|
491
|
-
|
|
496
|
+
let r = [];
|
|
497
|
+
for (let e of T) {
|
|
498
|
+
let t = yield* o.promise(() => h(e));
|
|
499
|
+
t !== void 0 && t.trim() !== "" && r.push(t);
|
|
500
|
+
}
|
|
501
|
+
_(ee(n, r)), t = !0, P = !0;
|
|
492
502
|
}
|
|
493
503
|
!p && !a.consent && e.logger.warn("governance: consent ledger uses the in-memory store — per-process + restart-cleared. Under >1 replica a consent recorded on one replica is invisible to others. Pass consent: 'datastore' (durable, cross-instance) in production.");
|
|
494
504
|
let n = x();
|
|
@@ -505,7 +515,7 @@ var A = (e) => {
|
|
|
505
515
|
});
|
|
506
516
|
}),
|
|
507
517
|
onDeactivate: () => o.sync(() => {
|
|
508
|
-
|
|
518
|
+
k?.stop(), k = void 0, a.fieldEncryption && _(void 0);
|
|
509
519
|
})
|
|
510
520
|
});
|
|
511
521
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@voltro/plugin-governance",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.82.0",
|
|
4
4
|
"description": "Data governance — retention policies (TTL sweep), GDPR subject export + erasure (right-to-be-forgotten), and consent recording. Builds on audit + soft-delete.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"voltro",
|
|
@@ -38,9 +38,9 @@
|
|
|
38
38
|
"node": ">=24.0.0"
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"@voltro/database": "0.
|
|
42
|
-
"@voltro/protocol": "0.
|
|
43
|
-
"@voltro/runtime": "0.
|
|
41
|
+
"@voltro/database": "0.82.0",
|
|
42
|
+
"@voltro/protocol": "0.82.0",
|
|
43
|
+
"@voltro/runtime": "0.82.0"
|
|
44
44
|
},
|
|
45
45
|
"peerDependencies": {
|
|
46
46
|
"effect": "^3.22.2"
|