dpdp-erasure-cli 1.0.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/.env.example +55 -0
- package/Dockerfile +33 -0
- package/compliance.worker.yaml +64 -0
- package/package.json +41 -0
- package/src/constants/index.ts +1 -0
- package/src/errors/fail.ts +110 -0
- package/src/errors/index.ts +4 -0
- package/src/errors/inferer.ts +166 -0
- package/src/errors/registry.ts +122 -0
- package/src/errors/types.ts +65 -0
- package/src/errors/worker.ts +161 -0
- package/src/index.ts +328 -0
- package/src/lib/crypto/digest.ts +22 -0
- package/src/lib/crypto/encoding.ts +78 -0
- package/src/lib/crypto/index.ts +2 -0
- package/src/lib/index.ts +1 -0
- package/src/modules/bootstrap/index.ts +2 -0
- package/src/modules/bootstrap/integrity.ts +38 -0
- package/src/modules/bootstrap/preflight.ts +296 -0
- package/src/modules/cli/check-integrity.ts +48 -0
- package/src/modules/cli/dry-run.ts +90 -0
- package/src/modules/cli/graph.ts +87 -0
- package/src/modules/cli/index.ts +184 -0
- package/src/modules/cli/init.ts +115 -0
- package/src/modules/cli/inspect.ts +86 -0
- package/src/modules/cli/introspector.ts +117 -0
- package/src/modules/cli/keygen.ts +38 -0
- package/src/modules/cli/scan.ts +126 -0
- package/src/modules/cli/sign.ts +50 -0
- package/src/modules/cli/ui.ts +61 -0
- package/src/modules/cli/verify-schema.ts +31 -0
- package/src/modules/cli/verify.ts +85 -0
- package/src/modules/config/compatibility.ts +271 -0
- package/src/modules/config/index.ts +4 -0
- package/src/modules/config/reader.ts +149 -0
- package/src/modules/config/signature.ts +69 -0
- package/src/modules/config/validation.ts +658 -0
- package/src/modules/crypto/aes.ts +158 -0
- package/src/modules/crypto/envelope.ts +48 -0
- package/src/modules/crypto/hmac.ts +60 -0
- package/src/modules/crypto/index.ts +3 -0
- package/src/modules/db/drift.ts +36 -0
- package/src/modules/db/graph.ts +203 -0
- package/src/modules/db/index.ts +4 -0
- package/src/modules/db/migrations.ts +254 -0
- package/src/modules/db/sql-debug.ts +61 -0
- package/src/modules/engine/blob/index.ts +3 -0
- package/src/modules/engine/blob/s3.ts +455 -0
- package/src/modules/engine/blob/store.ts +236 -0
- package/src/modules/engine/blob/types.ts +44 -0
- package/src/modules/engine/helpers/identity.ts +47 -0
- package/src/modules/engine/helpers/index.ts +4 -0
- package/src/modules/engine/helpers/outbox.ts +118 -0
- package/src/modules/engine/helpers/runtime.ts +115 -0
- package/src/modules/engine/helpers/types.ts +61 -0
- package/src/modules/engine/index.ts +6 -0
- package/src/modules/engine/notifier/config.ts +147 -0
- package/src/modules/engine/notifier/dispatcher.ts +300 -0
- package/src/modules/engine/notifier/index.ts +3 -0
- package/src/modules/engine/notifier/payload.ts +51 -0
- package/src/modules/engine/notifier/reservation.ts +153 -0
- package/src/modules/engine/notifier/types.ts +38 -0
- package/src/modules/engine/shredder.ts +254 -0
- package/src/modules/engine/types.ts +146 -0
- package/src/modules/engine/vault/compiled-targets.ts +562 -0
- package/src/modules/engine/vault/context.ts +254 -0
- package/src/modules/engine/vault/dry-run.ts +94 -0
- package/src/modules/engine/vault/execution.ts +485 -0
- package/src/modules/engine/vault/index.ts +3 -0
- package/src/modules/engine/vault/purge.ts +82 -0
- package/src/modules/engine/vault/retention.ts +124 -0
- package/src/modules/engine/vault/satellite-mutation.ts +193 -0
- package/src/modules/engine/vault/satellite.ts +103 -0
- package/src/modules/engine/vault/shadow.ts +36 -0
- package/src/modules/engine/vault/static-plan.ts +116 -0
- package/src/modules/engine/vault/store.ts +34 -0
- package/src/modules/engine/vault/vault.ts +84 -0
- package/src/modules/introspector/classifier.ts +502 -0
- package/src/modules/introspector/dag.ts +276 -0
- package/src/modules/introspector/index.ts +7 -0
- package/src/modules/introspector/naming.ts +75 -0
- package/src/modules/introspector/report.ts +153 -0
- package/src/modules/introspector/run.ts +123 -0
- package/src/modules/introspector/s3-sampler.ts +227 -0
- package/src/modules/introspector/types.ts +131 -0
- package/src/modules/introspector/yaml.ts +101 -0
- package/src/modules/network/api/control-plane.ts +275 -0
- package/src/modules/network/api/index.ts +1 -0
- package/src/modules/network/api/validation.ts +71 -0
- package/src/modules/network/index.ts +4 -0
- package/src/modules/network/object-store/aws/client.ts +444 -0
- package/src/modules/network/object-store/aws/credentials.ts +271 -0
- package/src/modules/network/object-store/aws/index.ts +2 -0
- package/src/modules/network/object-store/aws/sigv4.ts +190 -0
- package/src/modules/network/object-store/aws/type.ts +6 -0
- package/src/modules/network/object-store/index.ts +1 -0
- package/src/modules/network/outbox/dispatcher.ts +183 -0
- package/src/modules/network/outbox/index.ts +3 -0
- package/src/modules/network/outbox/process.ts +133 -0
- package/src/modules/network/outbox/shared.ts +56 -0
- package/src/modules/network/outbox/store.ts +346 -0
- package/src/modules/network/outbox/types.ts +54 -0
- package/src/modules/network/request-signing.ts +61 -0
- package/src/modules/worker/index.ts +2 -0
- package/src/modules/worker/tasks.ts +58 -0
- package/src/modules/worker/types.ts +89 -0
- package/src/modules/worker/worker.ts +243 -0
- package/src/secrets/index.ts +4 -0
- package/src/secrets/kms/index.ts +2 -0
- package/src/secrets/kms/signature.ts +82 -0
- package/src/secrets/kms/validation.ts +64 -0
- package/src/secrets/reader.ts +42 -0
- package/src/secrets/repository/crypto.ts +89 -0
- package/src/secrets/repository/index.ts +2 -0
- package/src/secrets/repository/methods.ts +37 -0
- package/src/secrets/resolvers.ts +247 -0
- package/src/secrets/signature.ts +78 -0
- package/src/types/index.ts +1 -0
- package/src/types/types.ts +23 -0
- package/src/utils/identifiers.ts +48 -0
- package/src/utils/index.ts +3 -0
- package/src/utils/json.ts +35 -0
- package/src/utils/logger.ts +161 -0
- package/src/validation/zod.ts +70 -0
- package/tests/adversarial.test.ts +464 -0
- package/tests/blob-s3.test.ts +216 -0
- package/tests/config.test.ts +395 -0
- package/tests/control-plane-client.test.ts +108 -0
- package/tests/crypto.test.ts +106 -0
- package/tests/errors.test.ts +69 -0
- package/tests/fetch-dispatcher.test.ts +213 -0
- package/tests/graph.test.ts +84 -0
- package/tests/helpers/index.ts +101 -0
- package/tests/index-preflight.test.ts +168 -0
- package/tests/introspector-classifier.test.ts +62 -0
- package/tests/introspector-report.test.ts +85 -0
- package/tests/introspector.test.ts +394 -0
- package/tests/kms.test.ts +124 -0
- package/tests/logger.test.ts +61 -0
- package/tests/notifier.test.ts +303 -0
- package/tests/outbox.test.ts +478 -0
- package/tests/purge-policy.test.ts +124 -0
- package/tests/retention.test.ts +103 -0
- package/tests/s3-client.test.ts +110 -0
- package/tests/satellite.test.ts +119 -0
- package/tests/schema-compatibility.test.ts +237 -0
- package/tests/schema-integrity.test.ts +64 -0
- package/tests/shredder.test.ts +163 -0
- package/tests/vault.compiled-targets.test.ts +243 -0
- package/tests/vault.replica.test.ts +59 -0
- package/tests/vault.test.ts +279 -0
- package/tests/worker.retry.test.ts +291 -0
- package/tests/worker.test.ts +200 -0
- package/tsconfig.json +19 -0
- package/vitest.config.ts +13 -0
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
import type { Sql } from "@/types";
|
|
2
|
+
import { bytesToBase64, bytesToHex } from "@/lib";
|
|
3
|
+
import { fail } from "@/errors";
|
|
4
|
+
import { importHmacKey, generateDEK, wrapKey, encryptGCMBytes } from "@modules/crypto";
|
|
5
|
+
import type { VaultUserOptions, VaultUserResult } from "../types";
|
|
6
|
+
import { createPseudonym, enqueueOutboxEvent } from "../helpers";
|
|
7
|
+
import {
|
|
8
|
+
buildHardDeleteEventIdempotencyKey,
|
|
9
|
+
buildVaultEventIdempotencyKey,
|
|
10
|
+
computeMutationValue,
|
|
11
|
+
normalizeRootRowValue,
|
|
12
|
+
type RootMutationContext
|
|
13
|
+
} from "./context";
|
|
14
|
+
import { resolveStaticExecutionPlan } from "./static-plan";
|
|
15
|
+
import { finalizeVaultResult, ShadowModeRollback } from "./shadow";
|
|
16
|
+
import { getVaultRecordByUserId } from "./store";
|
|
17
|
+
import { evaluateRetention, resolveRetentionWindow } from "./retention";
|
|
18
|
+
import { mutateCompiledTargets } from "./compiled-targets";
|
|
19
|
+
import { mutateSatelliteTargets } from "./satellite-mutation";
|
|
20
|
+
import { hasBlobTargetValues, protectBlobTargets } from "../blob";
|
|
21
|
+
|
|
22
|
+
const textEncoder = new TextEncoder();
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Prepared inputs shared across the live vault transaction.
|
|
26
|
+
*/
|
|
27
|
+
export interface PreparedVaultExecutionContext {
|
|
28
|
+
appSchema: string;
|
|
29
|
+
engineSchema: string;
|
|
30
|
+
rootContext: RootMutationContext;
|
|
31
|
+
defaultRetentionYears: number;
|
|
32
|
+
noticeWindowHours: number;
|
|
33
|
+
now: Date;
|
|
34
|
+
tenantId?: string;
|
|
35
|
+
normalizedSubjectId: string;
|
|
36
|
+
userHash: string;
|
|
37
|
+
kek: Uint8Array;
|
|
38
|
+
hmacKey: Uint8Array;
|
|
39
|
+
options: VaultUserOptions;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Executes the live repeatable-read vault or hard-delete transaction.
|
|
44
|
+
*
|
|
45
|
+
* @param sql - Primary Postgres pool used for transactional writes.
|
|
46
|
+
* @param subjectId - Root subject identifier.
|
|
47
|
+
* @param context - Prepared vault execution context.
|
|
48
|
+
* @returns Final vault result, or the rolled-back shadow result when shadow mode is enabled.
|
|
49
|
+
*/
|
|
50
|
+
export async function runVaultMutation(
|
|
51
|
+
sql: Sql,
|
|
52
|
+
subjectId: string | number,
|
|
53
|
+
context: PreparedVaultExecutionContext
|
|
54
|
+
): Promise<VaultUserResult> {
|
|
55
|
+
let dek: Uint8Array = new Uint8Array(0);
|
|
56
|
+
let plainTextPiiBuffer: Uint8Array = new Uint8Array(0);
|
|
57
|
+
let encryptedPiiBuffer: Uint8Array = new Uint8Array(0);
|
|
58
|
+
const mutationHmacKey = await importHmacKey(context.hmacKey);
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
try {
|
|
62
|
+
return await sql.begin("ISOLATION LEVEL REPEATABLE READ", async (tx) => {
|
|
63
|
+
await tx.unsafe("SET LOCAL lock_timeout = '5s'");
|
|
64
|
+
const staticPlan = resolveStaticExecutionPlan(
|
|
65
|
+
context.appSchema,
|
|
66
|
+
context.rootContext,
|
|
67
|
+
context.options,
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const columnsToSelect = [
|
|
71
|
+
context.rootContext.rootIdColumn,
|
|
72
|
+
...new Set([
|
|
73
|
+
...Object.keys(context.rootContext.rootPiiColumns),
|
|
74
|
+
...(staticPlan.source === "legacy_config"
|
|
75
|
+
? context.rootContext.satelliteTargets.map((target) => target.lookup_column)
|
|
76
|
+
: []),
|
|
77
|
+
...context.rootContext.blobTargets
|
|
78
|
+
.filter((target) => target.table === context.rootContext.rootTable)
|
|
79
|
+
.map((target) => target.column),
|
|
80
|
+
]),
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
const tenantFilter = context.tenantId ? tx`AND tenant_id = ${context.tenantId}` : tx``;
|
|
84
|
+
|
|
85
|
+
const [lockedRootRow] = await tx<Record<string, unknown>[]>`
|
|
86
|
+
SELECT ${tx(columnsToSelect)}
|
|
87
|
+
FROM ${tx(context.appSchema)}.${tx(context.rootContext.rootTable)}
|
|
88
|
+
WHERE ${tx(context.rootContext.rootIdColumn)} = ${subjectId}
|
|
89
|
+
${tenantFilter}
|
|
90
|
+
FOR UPDATE
|
|
91
|
+
`;
|
|
92
|
+
|
|
93
|
+
const lockedVault = await getVaultRecordByUserId(
|
|
94
|
+
tx,
|
|
95
|
+
context.engineSchema,
|
|
96
|
+
context.appSchema,
|
|
97
|
+
subjectId,
|
|
98
|
+
context.rootContext.rootTable,
|
|
99
|
+
context.tenantId
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
if (lockedVault) {
|
|
103
|
+
return finalizeVaultResult(
|
|
104
|
+
{
|
|
105
|
+
action: "already_vaulted",
|
|
106
|
+
userHash: lockedVault.user_uuid_hash,
|
|
107
|
+
dryRun: false,
|
|
108
|
+
dependencyCount: lockedVault.dependency_count,
|
|
109
|
+
retentionYears: null,
|
|
110
|
+
appliedRuleName: lockedVault.applied_rule_name,
|
|
111
|
+
appliedRuleCitation: lockedVault.applied_rule_citation,
|
|
112
|
+
retentionExpiry: lockedVault.retention_expiry.toISOString(),
|
|
113
|
+
notificationDueAt: lockedVault.notification_due_at.toISOString(),
|
|
114
|
+
pseudonym: lockedVault.pseudonym,
|
|
115
|
+
outboxEventType: null
|
|
116
|
+
},
|
|
117
|
+
context.options.shadowMode ?? false
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (!lockedRootRow) {
|
|
122
|
+
const hardDeleteIdempotencyKey = buildHardDeleteEventIdempotencyKey(
|
|
123
|
+
context.options,
|
|
124
|
+
context.appSchema,
|
|
125
|
+
context.rootContext.rootTable,
|
|
126
|
+
context.rootContext.rootIdColumn,
|
|
127
|
+
subjectId
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
const hardDeleteEvents = await tx<{ id: string }[]>`
|
|
131
|
+
SELECT id
|
|
132
|
+
FROM ${tx(context.engineSchema)}.outbox
|
|
133
|
+
WHERE idempotency_key = ${hardDeleteIdempotencyKey}
|
|
134
|
+
LIMIT 1
|
|
135
|
+
`;
|
|
136
|
+
|
|
137
|
+
if (hardDeleteEvents.length > 0) {
|
|
138
|
+
return finalizeVaultResult(
|
|
139
|
+
{
|
|
140
|
+
action: "already_hard_deleted",
|
|
141
|
+
userHash: context.userHash,
|
|
142
|
+
dryRun: false,
|
|
143
|
+
dependencyCount: 0,
|
|
144
|
+
retentionYears: null,
|
|
145
|
+
appliedRuleName: null,
|
|
146
|
+
appliedRuleCitation: null,
|
|
147
|
+
retentionExpiry: null,
|
|
148
|
+
notificationDueAt: null,
|
|
149
|
+
pseudonym: null,
|
|
150
|
+
outboxEventType: null,
|
|
151
|
+
},
|
|
152
|
+
context.options.shadowMode ?? false
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
fail({
|
|
157
|
+
code: "VAULT_ROOT_ROW_NOT_FOUND",
|
|
158
|
+
title: "Root row not found",
|
|
159
|
+
detail: `Root row ${context.appSchema}.${context.rootContext.rootTable}#${context.normalizedSubjectId} disappeared before vaulting began.`,
|
|
160
|
+
category: "validation",
|
|
161
|
+
retryable: false,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const dependencyCount = staticPlan.dependencyCount
|
|
166
|
+
const retention = await evaluateRetention(
|
|
167
|
+
tx,
|
|
168
|
+
subjectId,
|
|
169
|
+
{
|
|
170
|
+
default_retention_years: context.defaultRetentionYears,
|
|
171
|
+
root_id_column: context.rootContext.rootIdColumn,
|
|
172
|
+
retention_rules: context.options.retentionRules ?? [],
|
|
173
|
+
app_schema: context.appSchema
|
|
174
|
+
},
|
|
175
|
+
context.tenantId
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
const { retentionExpiry, notificationDueAt } = await resolveRetentionWindow(
|
|
179
|
+
tx,
|
|
180
|
+
context.now,
|
|
181
|
+
retention.retentionYears,
|
|
182
|
+
context.noticeWindowHours
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
const compiledMutations = await mutateCompiledTargets(
|
|
186
|
+
tx,
|
|
187
|
+
context.appSchema,
|
|
188
|
+
context.rootContext.rootTable,
|
|
189
|
+
context.rootContext.rootIdColumn,
|
|
190
|
+
subjectId,
|
|
191
|
+
staticPlan.targets,
|
|
192
|
+
mutationHmacKey,
|
|
193
|
+
context.tenantId
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
const manualSatelliteMutations = staticPlan.source === "legacy_config"
|
|
197
|
+
? await mutateSatelliteTargets(
|
|
198
|
+
tx,
|
|
199
|
+
context.appSchema,
|
|
200
|
+
context.rootContext,
|
|
201
|
+
lockedRootRow,
|
|
202
|
+
mutationHmacKey,
|
|
203
|
+
context.tenantId
|
|
204
|
+
)
|
|
205
|
+
: [];
|
|
206
|
+
|
|
207
|
+
const satelliteMutations = [...compiledMutations, ...manualSatelliteMutations];
|
|
208
|
+
const hasBlobObjects = await hasBlobTargetValues({
|
|
209
|
+
tx,
|
|
210
|
+
appSchema: context.appSchema,
|
|
211
|
+
engineSchema: context.engineSchema,
|
|
212
|
+
rootTable: context.rootContext.rootTable,
|
|
213
|
+
rootIdColumn: context.rootContext.rootIdColumn,
|
|
214
|
+
rootId: subjectId,
|
|
215
|
+
userHash: context.userHash,
|
|
216
|
+
requestId: context.options.requestId,
|
|
217
|
+
tenantId: context.tenantId,
|
|
218
|
+
targets: context.rootContext.blobTargets,
|
|
219
|
+
lockedRootRow,
|
|
220
|
+
hmacKey: context.hmacKey,
|
|
221
|
+
s3Client: context.options.s3Client,
|
|
222
|
+
shadowMode: context.options.shadowMode,
|
|
223
|
+
now: context.now,
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
if (dependencyCount === 0 && !hasBlobObjects) {
|
|
227
|
+
const deleted = await tx`
|
|
228
|
+
DELETE FROM ${tx(context.appSchema)}.${tx(context.rootContext.rootTable)}
|
|
229
|
+
WHERE ${tx(context.rootContext.rootIdColumn)} = ${subjectId}
|
|
230
|
+
${tenantFilter}
|
|
231
|
+
RETURNING ${tx(context.rootContext.rootIdColumn)}
|
|
232
|
+
`;
|
|
233
|
+
|
|
234
|
+
if (deleted.length === 0) {
|
|
235
|
+
fail({
|
|
236
|
+
code: "VAULT_ROOT_DELETE_FAILED",
|
|
237
|
+
title: "Root row delete invariant failed",
|
|
238
|
+
detail: `Root row ${context.appSchema}.${context.rootContext.rootTable}#${context.normalizedSubjectId} could not be deleted.`,
|
|
239
|
+
category: "concurrency",
|
|
240
|
+
retryable: true,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
await enqueueOutboxEvent(
|
|
245
|
+
tx,
|
|
246
|
+
context.engineSchema,
|
|
247
|
+
context.userHash,
|
|
248
|
+
"USER_HARD_DELETED",
|
|
249
|
+
{
|
|
250
|
+
request_id: context.options.requestId ?? null,
|
|
251
|
+
subject_opaque_id: context.options.subjectOpaqueId ?? context.normalizedSubjectId,
|
|
252
|
+
tenant_id: context.tenantId ?? null,
|
|
253
|
+
trigger_source: context.options.triggerSource ?? null,
|
|
254
|
+
legal_framework: context.options.legalFramework ?? null,
|
|
255
|
+
actor_opaque_id: context.options.actorOpaqueId ?? null,
|
|
256
|
+
applied_rule_name: retention.appliedRuleName,
|
|
257
|
+
applied_rule_citation: retention.appliedRuleCitation,
|
|
258
|
+
event_timestamp: context.now.toISOString(),
|
|
259
|
+
root_schema: context.appSchema,
|
|
260
|
+
root_table: context.rootContext.rootTable,
|
|
261
|
+
root_id_column: context.rootContext.rootIdColumn,
|
|
262
|
+
root_id: context.normalizedSubjectId,
|
|
263
|
+
deleted_at: context.now.toISOString(),
|
|
264
|
+
dependency_count: 0,
|
|
265
|
+
execution_plan_source: staticPlan.source,
|
|
266
|
+
satellite_mutations: satelliteMutations,
|
|
267
|
+
},
|
|
268
|
+
buildHardDeleteEventIdempotencyKey(
|
|
269
|
+
context.options,
|
|
270
|
+
context.appSchema,
|
|
271
|
+
context.rootContext.rootTable,
|
|
272
|
+
context.rootContext.rootIdColumn,
|
|
273
|
+
subjectId
|
|
274
|
+
),
|
|
275
|
+
context.now
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
return finalizeVaultResult(
|
|
279
|
+
{
|
|
280
|
+
action: "hard_deleted",
|
|
281
|
+
userHash: context.userHash,
|
|
282
|
+
dryRun: false,
|
|
283
|
+
dependencyCount: 0,
|
|
284
|
+
retentionYears: retention.retentionYears,
|
|
285
|
+
appliedRuleName: retention.appliedRuleName,
|
|
286
|
+
appliedRuleCitation: retention.appliedRuleCitation,
|
|
287
|
+
retentionExpiry: null,
|
|
288
|
+
notificationDueAt: null,
|
|
289
|
+
pseudonym: null,
|
|
290
|
+
outboxEventType: "USER_HARD_DELETED",
|
|
291
|
+
},
|
|
292
|
+
context.options.shadowMode ?? false
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const rootPiiPayload: Record<string, unknown> = {};
|
|
297
|
+
const payloadColumns = new Set([
|
|
298
|
+
...Object.keys(context.rootContext.rootPiiColumns),
|
|
299
|
+
...context.rootContext.blobTargets
|
|
300
|
+
.filter((target) => target.table === context.rootContext.rootTable)
|
|
301
|
+
.map((target) => target.column),
|
|
302
|
+
]);
|
|
303
|
+
for (const column of payloadColumns) {
|
|
304
|
+
rootPiiPayload[column] = lockedRootRow[column] ?? null;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const salt = bytesToHex(globalThis.crypto.getRandomValues(new Uint8Array(16)));
|
|
308
|
+
const pseudonymSource =
|
|
309
|
+
normalizeRootRowValue(
|
|
310
|
+
rootPiiPayload[Object.keys(context.rootContext.rootPiiColumns)[0] ?? ""]
|
|
311
|
+
) ?? JSON.stringify(rootPiiPayload);
|
|
312
|
+
const pseudonym = await createPseudonym(
|
|
313
|
+
subjectId,
|
|
314
|
+
pseudonymSource,
|
|
315
|
+
salt,
|
|
316
|
+
context.hmacKey
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
dek = generateDEK();
|
|
320
|
+
const wrappedDEK = await wrapKey(dek, context.kek);
|
|
321
|
+
plainTextPiiBuffer = textEncoder.encode(JSON.stringify(rootPiiPayload));
|
|
322
|
+
encryptedPiiBuffer = await encryptGCMBytes(plainTextPiiBuffer, dek);
|
|
323
|
+
const encryptedPiiPayload = {
|
|
324
|
+
v: 1,
|
|
325
|
+
data: bytesToBase64(encryptedPiiBuffer),
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
await tx`
|
|
329
|
+
INSERT INTO ${tx(context.engineSchema)}.pii_vault (
|
|
330
|
+
user_uuid_hash,
|
|
331
|
+
request_id,
|
|
332
|
+
tenant_id,
|
|
333
|
+
root_schema,
|
|
334
|
+
root_table,
|
|
335
|
+
root_id,
|
|
336
|
+
pseudonym,
|
|
337
|
+
encrypted_pii,
|
|
338
|
+
salt,
|
|
339
|
+
dependency_count,
|
|
340
|
+
trigger_source,
|
|
341
|
+
legal_framework,
|
|
342
|
+
actor_opaque_id,
|
|
343
|
+
applied_rule_name,
|
|
344
|
+
applied_rule_citation,
|
|
345
|
+
retention_expiry,
|
|
346
|
+
notification_due_at,
|
|
347
|
+
created_at,
|
|
348
|
+
updated_at
|
|
349
|
+
)
|
|
350
|
+
VALUES (
|
|
351
|
+
${context.userHash},
|
|
352
|
+
${context.options.requestId ?? null},
|
|
353
|
+
${context.tenantId ?? ""},
|
|
354
|
+
${context.appSchema},
|
|
355
|
+
${context.rootContext.rootTable},
|
|
356
|
+
${context.normalizedSubjectId},
|
|
357
|
+
${pseudonym},
|
|
358
|
+
${tx.json(encryptedPiiPayload)},
|
|
359
|
+
${salt},
|
|
360
|
+
${dependencyCount},
|
|
361
|
+
${context.options.triggerSource ?? null},
|
|
362
|
+
${context.options.legalFramework ?? null},
|
|
363
|
+
${context.options.actorOpaqueId ?? null},
|
|
364
|
+
${retention.appliedRuleName},
|
|
365
|
+
${retention.appliedRuleCitation},
|
|
366
|
+
${retentionExpiry},
|
|
367
|
+
${notificationDueAt},
|
|
368
|
+
${context.now},
|
|
369
|
+
${context.now}
|
|
370
|
+
)
|
|
371
|
+
`;
|
|
372
|
+
|
|
373
|
+
await tx`
|
|
374
|
+
INSERT INTO ${tx(context.engineSchema)}.user_keys (user_uuid_hash, encrypted_dek, created_at)
|
|
375
|
+
VALUES (${context.userHash}, ${wrappedDEK}, ${context.now})
|
|
376
|
+
`;
|
|
377
|
+
|
|
378
|
+
const blobProtection = await protectBlobTargets({
|
|
379
|
+
tx,
|
|
380
|
+
appSchema: context.appSchema,
|
|
381
|
+
engineSchema: context.engineSchema,
|
|
382
|
+
rootTable: context.rootContext.rootTable,
|
|
383
|
+
rootIdColumn: context.rootContext.rootIdColumn,
|
|
384
|
+
rootId: subjectId,
|
|
385
|
+
userHash: context.userHash,
|
|
386
|
+
requestId: context.options.requestId,
|
|
387
|
+
tenantId: context.tenantId,
|
|
388
|
+
targets: context.rootContext.blobTargets,
|
|
389
|
+
lockedRootRow,
|
|
390
|
+
hmacKey: context.hmacKey,
|
|
391
|
+
s3Client: context.options.s3Client,
|
|
392
|
+
shadowMode: context.options.shadowMode,
|
|
393
|
+
now: context.now,
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
const rootMutationValues: Record<string, string | null> = {};
|
|
397
|
+
for (const [column, mutation] of Object.entries(context.rootContext.rootPiiColumns)) {
|
|
398
|
+
rootMutationValues[column] = await computeMutationValue(
|
|
399
|
+
mutation,
|
|
400
|
+
lockedRootRow[column],
|
|
401
|
+
context.appSchema,
|
|
402
|
+
context.rootContext.rootTable,
|
|
403
|
+
column,
|
|
404
|
+
mutationHmacKey
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
Object.assign(rootMutationValues, blobProtection.rootColumnMasks);
|
|
408
|
+
|
|
409
|
+
await tx`
|
|
410
|
+
UPDATE ${tx(context.appSchema)}.${tx(context.rootContext.rootTable)}
|
|
411
|
+
SET ${tx(rootMutationValues)}
|
|
412
|
+
WHERE ${tx(context.rootContext.rootIdColumn)} = ${subjectId}
|
|
413
|
+
${tenantFilter}
|
|
414
|
+
`;
|
|
415
|
+
|
|
416
|
+
await enqueueOutboxEvent(
|
|
417
|
+
tx,
|
|
418
|
+
context.engineSchema,
|
|
419
|
+
context.userHash,
|
|
420
|
+
"USER_VAULTED",
|
|
421
|
+
{
|
|
422
|
+
request_id: context.options.requestId ?? null,
|
|
423
|
+
subject_opaque_id: context.options.subjectOpaqueId ?? context.normalizedSubjectId,
|
|
424
|
+
tenant_id: context.tenantId ?? null,
|
|
425
|
+
trigger_source: context.options.triggerSource ?? null,
|
|
426
|
+
legal_framework: context.options.legalFramework ?? null,
|
|
427
|
+
actor_opaque_id: context.options.actorOpaqueId ?? null,
|
|
428
|
+
applied_rule_name: retention.appliedRuleName,
|
|
429
|
+
applied_rule_citation: retention.appliedRuleCitation,
|
|
430
|
+
event_timestamp: context.now.toISOString(),
|
|
431
|
+
root_schema: context.appSchema,
|
|
432
|
+
root_table: context.rootContext.rootTable,
|
|
433
|
+
root_id_column: context.rootContext.rootIdColumn,
|
|
434
|
+
root_id: context.normalizedSubjectId,
|
|
435
|
+
pseudonym,
|
|
436
|
+
dependency_count: dependencyCount,
|
|
437
|
+
retention_years: retention.retentionYears,
|
|
438
|
+
retention_expiry: retentionExpiry.toISOString(),
|
|
439
|
+
notification_due_at: notificationDueAt.toISOString(),
|
|
440
|
+
vaulted_at: context.now.toISOString(),
|
|
441
|
+
execution_plan_source: staticPlan.source,
|
|
442
|
+
satellite_mutations: satelliteMutations,
|
|
443
|
+
blob_protections: blobProtection.receipts,
|
|
444
|
+
},
|
|
445
|
+
buildVaultEventIdempotencyKey(
|
|
446
|
+
context.options,
|
|
447
|
+
context.appSchema,
|
|
448
|
+
context.rootContext.rootTable,
|
|
449
|
+
context.rootContext.rootIdColumn,
|
|
450
|
+
subjectId
|
|
451
|
+
),
|
|
452
|
+
context.now
|
|
453
|
+
);
|
|
454
|
+
|
|
455
|
+
return finalizeVaultResult(
|
|
456
|
+
{
|
|
457
|
+
action: "vaulted",
|
|
458
|
+
userHash: context.userHash,
|
|
459
|
+
dryRun: false,
|
|
460
|
+
dependencyCount,
|
|
461
|
+
retentionYears: retention.retentionYears,
|
|
462
|
+
appliedRuleName: retention.appliedRuleName,
|
|
463
|
+
appliedRuleCitation: retention.appliedRuleCitation,
|
|
464
|
+
retentionExpiry: retentionExpiry.toISOString(),
|
|
465
|
+
notificationDueAt: notificationDueAt.toISOString(),
|
|
466
|
+
pseudonym,
|
|
467
|
+
outboxEventType: "USER_VAULTED",
|
|
468
|
+
blobProtectionCount: blobProtection.receipts.length,
|
|
469
|
+
},
|
|
470
|
+
context.options.shadowMode ?? false
|
|
471
|
+
);
|
|
472
|
+
});
|
|
473
|
+
} catch (error) {
|
|
474
|
+
if (error instanceof ShadowModeRollback) {
|
|
475
|
+
return error.result;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
throw error;
|
|
479
|
+
}
|
|
480
|
+
} finally {
|
|
481
|
+
dek.fill(0);
|
|
482
|
+
plainTextPiiBuffer.fill(0);
|
|
483
|
+
encryptedPiiBuffer.fill(0);
|
|
484
|
+
}
|
|
485
|
+
};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { type PurgePolicy } from "@modules/config";
|
|
2
|
+
import { fail } from "@/errors";
|
|
3
|
+
import { assertIdentifier } from "@/utils";
|
|
4
|
+
import type { SqlExecutor } from "@/types";
|
|
5
|
+
|
|
6
|
+
export interface PurgeCandidateQuery {
|
|
7
|
+
appSchema: string;
|
|
8
|
+
rootTable: string;
|
|
9
|
+
rootIdColumn: string;
|
|
10
|
+
purgePolicy: PurgePolicy;
|
|
11
|
+
limit?: number;
|
|
12
|
+
now?: Date;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Selects root subjects that are legally eligible for an administrator-triggered purge.
|
|
17
|
+
*
|
|
18
|
+
* The selector is intentionally limited to simple indexed predicates so large-tenant purge
|
|
19
|
+
* discovery remains bounded, explainable, and safe to run before submitting jobs to the
|
|
20
|
+
* Control Plane.
|
|
21
|
+
*
|
|
22
|
+
* @param sql - postgres.js connection or transaction.
|
|
23
|
+
* @param input - DPO-attested purge selector and root-table metadata.
|
|
24
|
+
* @returns Opaque subject identifiers to submit as `ADMIN_PURGE` erasure jobs.
|
|
25
|
+
* @throws {WorkerError} When purge automation is disabled or identifiers are unsafe.
|
|
26
|
+
*/
|
|
27
|
+
export async function selectPurgeCandidates(
|
|
28
|
+
sql: SqlExecutor,
|
|
29
|
+
input: PurgeCandidateQuery
|
|
30
|
+
): Promise<string[]> {
|
|
31
|
+
if (!input.purgePolicy.enabled || !input.purgePolicy.selector) {
|
|
32
|
+
fail({
|
|
33
|
+
code: "PURGE_POLICY_DISABLED",
|
|
34
|
+
title: "Purge policy disabled",
|
|
35
|
+
detail: "Refusing to discover purge candidates without an enabled purge_policy selector.",
|
|
36
|
+
category: "configuration",
|
|
37
|
+
retryable: false,
|
|
38
|
+
fatal: false,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const appSchema = assertIdentifier(input.appSchema, "application schema name");
|
|
43
|
+
const rootTable = assertIdentifier(input.rootTable, "purge root table");
|
|
44
|
+
const rootIdColumn = assertIdentifier(input.rootIdColumn, "purge root id column");
|
|
45
|
+
const selector = input.purgePolicy.selector;
|
|
46
|
+
const selectorColumn = assertIdentifier(selector.column, "purge selector column");
|
|
47
|
+
const effectiveLimit = Math.min(input.limit ?? input.purgePolicy.max_batch_size, input.purgePolicy.max_batch_size);
|
|
48
|
+
|
|
49
|
+
if (selector.kind === "boolean_column") {
|
|
50
|
+
const rows = await sql<{ subject_opaque_id: string }[]>`
|
|
51
|
+
SELECT ${sql(rootIdColumn)}::text AS subject_opaque_id
|
|
52
|
+
FROM ${sql(appSchema)}.${sql(rootTable)}
|
|
53
|
+
WHERE ${sql(selectorColumn)} = ${selector.value}
|
|
54
|
+
ORDER BY ${sql(rootIdColumn)}
|
|
55
|
+
LIMIT ${effectiveLimit}
|
|
56
|
+
`;
|
|
57
|
+
return rows.map((row) => row.subject_opaque_id);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (selector.kind === "enum_column") {
|
|
61
|
+
const rows = await sql<{ subject_opaque_id: string }[]>`
|
|
62
|
+
SELECT ${sql(rootIdColumn)}::text AS subject_opaque_id
|
|
63
|
+
FROM ${sql(appSchema)}.${sql(rootTable)}
|
|
64
|
+
WHERE ${sql(selectorColumn)} = ANY(${selector.values}::text[])
|
|
65
|
+
ORDER BY ${sql(rootIdColumn)}
|
|
66
|
+
LIMIT ${effectiveLimit}
|
|
67
|
+
`;
|
|
68
|
+
return rows.map((row) => row.subject_opaque_id);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const cutoff = selector.before
|
|
72
|
+
? new Date(selector.before)
|
|
73
|
+
: new Date((input.now ?? new Date()).getTime() - selector.older_than_days! * 24 * 60 * 60 * 1000);
|
|
74
|
+
const rows = await sql<{ subject_opaque_id: string }[]>`
|
|
75
|
+
SELECT ${sql(rootIdColumn)}::text AS subject_opaque_id
|
|
76
|
+
FROM ${sql(appSchema)}.${sql(rootTable)}
|
|
77
|
+
WHERE ${sql(selectorColumn)} < ${cutoff}
|
|
78
|
+
ORDER BY ${sql(rootIdColumn)}
|
|
79
|
+
LIMIT ${effectiveLimit}
|
|
80
|
+
`;
|
|
81
|
+
return rows.map((row) => row.subject_opaque_id);
|
|
82
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { RetentionRule } from "@modules/config";
|
|
2
|
+
import type { SqlExecutor, Tsql } from "@/types";
|
|
3
|
+
import { assertIdentifier } from "@/utils";
|
|
4
|
+
import { resolveRetentionYears } from "../helpers";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Inputs required to evaluate the dynamic legal retention policy.
|
|
8
|
+
*/
|
|
9
|
+
export interface RetentionEvaluationConfig {
|
|
10
|
+
default_retention_years: number;
|
|
11
|
+
root_id_column: string;
|
|
12
|
+
retention_rules: readonly RetentionRule[];
|
|
13
|
+
app_schema: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Longest applicable retention window derived from physical evidence.
|
|
18
|
+
*/
|
|
19
|
+
export interface RetentionEvaluationResult {
|
|
20
|
+
retentionYears: number;
|
|
21
|
+
appliedRuleName: string;
|
|
22
|
+
appliedRuleCitation: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface ReturnTypeResolveRetentionWindow {
|
|
26
|
+
retentionExpiry: Date;
|
|
27
|
+
notificationDueAt: Date;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Resolves retention and notice timestamps with Postgres time math when a live SQL handle exists.
|
|
32
|
+
*
|
|
33
|
+
* @param sql - SQL handle used to evaluate interval math.
|
|
34
|
+
* @param now - Clock anchor for the lifecycle.
|
|
35
|
+
* @param retentionYears - Selected retention period in years.
|
|
36
|
+
* @param noticeWindowHours - Hours before shred when the notice should become due.
|
|
37
|
+
* @returns Retention expiry and notification due timestamps.
|
|
38
|
+
*/
|
|
39
|
+
export async function resolveRetentionWindow(
|
|
40
|
+
sql: SqlExecutor,
|
|
41
|
+
now: Date,
|
|
42
|
+
retentionYears: number,
|
|
43
|
+
noticeWindowHours: number,
|
|
44
|
+
): Promise<ReturnTypeResolveRetentionWindow> {
|
|
45
|
+
if (typeof sql !== "function") {
|
|
46
|
+
const retentionExpiry = new Date(now);
|
|
47
|
+
retentionExpiry.setUTCFullYear(retentionExpiry.getUTCFullYear() + retentionYears);
|
|
48
|
+
|
|
49
|
+
const notificationDueAt = new Date(
|
|
50
|
+
Math.max(
|
|
51
|
+
now.getTime(),
|
|
52
|
+
retentionExpiry.getTime() - noticeWindowHours * 60 * 60 * 1000
|
|
53
|
+
)
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
retentionExpiry,
|
|
58
|
+
notificationDueAt,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const [window] = await sql<{ retention_expiry: Date; notification_due_at: Date }[]>`
|
|
63
|
+
SELECT
|
|
64
|
+
${now}::timestamptz + MAKE_INTERVAL(years := ${retentionYears}) AS retention_expiry,
|
|
65
|
+
GREATEST(
|
|
66
|
+
${now}::timestamptz,
|
|
67
|
+
${now}::timestamptz + MAKE_INTERVAL(years := ${retentionYears}) - MAKE_INTERVAL(hours := ${noticeWindowHours})
|
|
68
|
+
) AS notification_due_at
|
|
69
|
+
`;
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
retentionExpiry: window!.retention_expiry,
|
|
73
|
+
notificationDueAt: window!.notification_due_at,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Evaluates configured evidence rules and selects the longest applicable retention window.
|
|
79
|
+
*
|
|
80
|
+
* @param tx - Active transaction used for consistent evidence reads.
|
|
81
|
+
* @param subjectId - Root subject identifier being vaulted.
|
|
82
|
+
* @param rules - Retention rules and default fallback parsed from worker config.
|
|
83
|
+
* @param tenantId - Optional tenant discriminator for multi-tenant datasets.
|
|
84
|
+
* @returns Highest retention duration and the rule that produced it.
|
|
85
|
+
* @throws {WorkerError} When evidence table or column identifiers are unsafe.
|
|
86
|
+
*/
|
|
87
|
+
export async function evaluateRetention(
|
|
88
|
+
tx: Tsql,
|
|
89
|
+
subjectId: string | number,
|
|
90
|
+
rules: RetentionEvaluationConfig,
|
|
91
|
+
tenantId?: string
|
|
92
|
+
): Promise<RetentionEvaluationResult> {
|
|
93
|
+
const rootIdColumn = assertIdentifier(rules.root_id_column, "graph root id column");
|
|
94
|
+
let selectedYears = resolveRetentionYears(rules.default_retention_years);
|
|
95
|
+
let selectedRuleName = "DEFAULT";
|
|
96
|
+
let selectedRuleCitation = "Configured default_retention_years policy";
|
|
97
|
+
|
|
98
|
+
for (const rule of rules.retention_rules) {
|
|
99
|
+
for (const tableName of rule.if_has_data_in) {
|
|
100
|
+
const safeTable = assertIdentifier(tableName, "retention rule evidence table");
|
|
101
|
+
const tenantFilter = tenantId ? tx` AND tenant_id = ${tenantId}` : tx``;
|
|
102
|
+
const [match] = await tx<{ exists: boolean }[]>`
|
|
103
|
+
SELECT EXISTS(
|
|
104
|
+
SELECT 1
|
|
105
|
+
FROM ${tx(rules.app_schema)}.${tx(safeTable)}
|
|
106
|
+
WHERE ${tx(rootIdColumn)} = ${subjectId}
|
|
107
|
+
${tenantFilter}
|
|
108
|
+
) AS exists
|
|
109
|
+
`;
|
|
110
|
+
|
|
111
|
+
if (match?.exists && rule.retention_years > selectedYears) {
|
|
112
|
+
selectedYears = rule.retention_years;
|
|
113
|
+
selectedRuleName = rule.rule_name;
|
|
114
|
+
selectedRuleCitation = rule.legal_citation;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
retentionYears: selectedYears,
|
|
121
|
+
appliedRuleName: selectedRuleName,
|
|
122
|
+
appliedRuleCitation: selectedRuleCitation,
|
|
123
|
+
};
|
|
124
|
+
}
|