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,455 @@
|
|
|
1
|
+
import type { BlobTarget } from "@modules/config";
|
|
2
|
+
import { createS3Client, parseS3ObjectUrl, type S3Client, type S3ObjectVersion } from "@modules/network";
|
|
3
|
+
import type { Tsql } from "@/types";
|
|
4
|
+
import type { BlobProtectionResult, BlobShredReceipt, DiscoveredBlobObject } from "./types";
|
|
5
|
+
import { fail } from "@/errors";
|
|
6
|
+
import { yieldWorkerEventLoop } from "../vault/satellite-mutation";
|
|
7
|
+
import { generateHMAC } from "@modules/crypto";
|
|
8
|
+
import { bytesToBase64 } from "@/lib";
|
|
9
|
+
import { countOtherActiveBlobReferences, getPendingBlobObjectsForUser, insertBlobObject, markBlobObjectShredded, type BlobObjectRow } from "./store";
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
export interface ProtectBlobTargetsInput {
|
|
13
|
+
tx: Tsql;
|
|
14
|
+
appSchema: string;
|
|
15
|
+
engineSchema: string;
|
|
16
|
+
rootTable: string;
|
|
17
|
+
rootIdColumn: string;
|
|
18
|
+
rootId: string | number;
|
|
19
|
+
userHash: string;
|
|
20
|
+
requestId?: string | null;
|
|
21
|
+
tenantId?: string;
|
|
22
|
+
targets: readonly BlobTarget[];
|
|
23
|
+
lockedRootRow: Record<string, unknown>;
|
|
24
|
+
hmacKey: Uint8Array;
|
|
25
|
+
s3Client?: S3Client;
|
|
26
|
+
shadowMode?: boolean;
|
|
27
|
+
now: Date;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface ProtectedBlobTargetsResult {
|
|
31
|
+
rootColumnMasks: Record<string, string>;
|
|
32
|
+
receipts: BlobProtectionResult[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface BlobCandidate {
|
|
36
|
+
target: BlobTarget;
|
|
37
|
+
sourceTable: string;
|
|
38
|
+
sourceColumn: string;
|
|
39
|
+
originalValue: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface ProtectedBlobTargetsResult {
|
|
43
|
+
rootColumnMasks: Record<string, string>;
|
|
44
|
+
receipts: BlobProtectionResult[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async function hmacBlobField(prefix: string, value: string, hmacKey: Uint8Array): Promise<string> {
|
|
49
|
+
return generateHMAC(`${prefix}:${value}`, bytesToBase64(hmacKey));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function buildObjectRefHash(
|
|
53
|
+
bucket: string,
|
|
54
|
+
key: string,
|
|
55
|
+
versionId: string,
|
|
56
|
+
hmacKey: Uint8Array
|
|
57
|
+
): Promise<string> {
|
|
58
|
+
return hmacBlobField("s3-object-ref", `${bucket}\n${key}\n${versionId}`, hmacKey);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function buildVersionHash(versionId: string, hmacKey: Uint8Array): Promise<string> {
|
|
62
|
+
return hmacBlobField("s3-version-id", versionId, hmacKey);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async function updateBlobSourceColumn(
|
|
67
|
+
tx: Tsql,
|
|
68
|
+
appSchema: string,
|
|
69
|
+
rootId: string | number,
|
|
70
|
+
tenantId: string | undefined,
|
|
71
|
+
candidate: BlobCandidate,
|
|
72
|
+
maskedValue: string
|
|
73
|
+
): Promise<void> {
|
|
74
|
+
if (!candidate.target.lookup_column) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const tenantFilter = tenantId ? tx` AND tenant_id = ${tenantId}` : tx``;
|
|
79
|
+
await tx`
|
|
80
|
+
UPDATE ${tx(appSchema)}.${tx(candidate.target.table)}
|
|
81
|
+
SET ${tx(candidate.target.column)} = ${maskedValue}
|
|
82
|
+
WHERE ${tx(candidate.target.lookup_column)} = ${rootId}
|
|
83
|
+
AND ${tx(candidate.target.column)} = ${candidate.originalValue}
|
|
84
|
+
${tenantFilter}
|
|
85
|
+
`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function loadMaskingBlob(target: BlobTarget): Promise<Uint8Array> {
|
|
89
|
+
if (!target.masking_blob_path) {
|
|
90
|
+
fail({
|
|
91
|
+
code: "BLOB_MASKING_ASSET_MISSING",
|
|
92
|
+
title: "Blob masking asset missing",
|
|
93
|
+
detail: `Blob target ${target.table}.${target.column} requires masking_blob_path for overwrite mode.`,
|
|
94
|
+
category: "configuration",
|
|
95
|
+
retryable: false,
|
|
96
|
+
fatal: true,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return new Uint8Array(await Bun.file(target.masking_blob_path).arrayBuffer());
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
function normalizeBlobCell(value: unknown): string | null {
|
|
105
|
+
if (typeof value !== "string") {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const trimmed = value.trim();
|
|
110
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Checks whether configured blob targets contain any S3 URL values for the locked subject.
|
|
115
|
+
*
|
|
116
|
+
* @param input - Active vault transaction and target metadata.
|
|
117
|
+
* @returns `true` when at least one configured blob cell contains a non-empty URL.
|
|
118
|
+
*/
|
|
119
|
+
export async function hasBlobTargetValues(input: ProtectBlobTargetsInput): Promise<boolean> {
|
|
120
|
+
return (await collectBlobCandidates(input)).length > 0;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function collectBlobCandidates(input: ProtectBlobTargetsInput): Promise<BlobCandidate[]> {
|
|
124
|
+
const candidates: BlobCandidate[] = [];
|
|
125
|
+
|
|
126
|
+
for (const target of input.targets) {
|
|
127
|
+
if (target.table === input.rootTable) {
|
|
128
|
+
const originalValue = normalizeBlobCell(input.lockedRootRow[target.column]);
|
|
129
|
+
if (originalValue) {
|
|
130
|
+
candidates.push({
|
|
131
|
+
target,
|
|
132
|
+
sourceTable: target.table,
|
|
133
|
+
sourceColumn: target.column,
|
|
134
|
+
originalValue,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (!target.lookup_column) {
|
|
141
|
+
fail({
|
|
142
|
+
code: "BLOB_LOOKUP_COLUMN_MISSING",
|
|
143
|
+
title: "Blob target lookup column missing",
|
|
144
|
+
detail: `Blob target ${target.table}.${target.column} requires lookup_column because it is not the root table.`,
|
|
145
|
+
category: "configuration",
|
|
146
|
+
retryable: false,
|
|
147
|
+
fatal: true,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const tenantFilter = input.tenantId ? input.tx` AND tenant_id = ${input.tenantId}` : input.tx``;
|
|
152
|
+
const rows = await input.tx<{ value: string | null }[]>`
|
|
153
|
+
SELECT ${input.tx(target.column)}::text AS value
|
|
154
|
+
FROM ${input.tx(input.appSchema)}.${input.tx(target.table)}
|
|
155
|
+
WHERE ${input.tx(target.lookup_column)} = ${input.rootId}
|
|
156
|
+
${tenantFilter}
|
|
157
|
+
FOR UPDATE
|
|
158
|
+
`;
|
|
159
|
+
|
|
160
|
+
for (const row of rows) {
|
|
161
|
+
const originalValue = normalizeBlobCell(row.value);
|
|
162
|
+
if (!originalValue) {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
candidates.push({
|
|
167
|
+
target,
|
|
168
|
+
sourceTable: target.table,
|
|
169
|
+
sourceColumn: target.column,
|
|
170
|
+
originalValue,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return candidates;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Applies S3 legal holds, optional sanitized overwrites, and local DB URL masking for configured blob targets.
|
|
180
|
+
*
|
|
181
|
+
* External S3 side effects are skipped in shadow mode so a shadow task cannot mutate object storage
|
|
182
|
+
* while its Postgres transaction rolls back.
|
|
183
|
+
*
|
|
184
|
+
* @param input - Active vault transaction, config, locked root row, S3 client, and crypto key.
|
|
185
|
+
* @returns Root-column masks to merge into the root update plus sanitized outbox receipts.
|
|
186
|
+
*/
|
|
187
|
+
export async function protectBlobTargets(input: ProtectBlobTargetsInput): Promise<ProtectedBlobTargetsResult> {
|
|
188
|
+
if (input.targets.length === 0) {
|
|
189
|
+
return { rootColumnMasks: {}, receipts: [] };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const candidates = await collectBlobCandidates(input);
|
|
193
|
+
if (candidates.length === 0) {
|
|
194
|
+
return { rootColumnMasks: {}, receipts: [] };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const s3Client = input.shadowMode ? undefined : input.s3Client ?? createS3Client();
|
|
198
|
+
const rootColumnMasks: Record<string, string> = {};
|
|
199
|
+
const receipts: BlobProtectionResult[] = [];
|
|
200
|
+
|
|
201
|
+
for (const candidate of candidates) {
|
|
202
|
+
const pointer = parseS3ObjectUrl(candidate.originalValue);
|
|
203
|
+
const maskedValue = await hmacBlobField(
|
|
204
|
+
`blob-url:${input.appSchema}:${candidate.sourceTable}:${candidate.sourceColumn}`,
|
|
205
|
+
candidate.originalValue,
|
|
206
|
+
input.hmacKey
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
let versionId = pointer.versionId;
|
|
210
|
+
let eTag: string | null = null;
|
|
211
|
+
let overwriteETag: string | null = null;
|
|
212
|
+
let overwriteVersionId: string | null = null;
|
|
213
|
+
|
|
214
|
+
if (!input.shadowMode) {
|
|
215
|
+
const head = await s3Client!.headObject({
|
|
216
|
+
bucket: pointer.bucket,
|
|
217
|
+
key: pointer.key,
|
|
218
|
+
versionId: pointer.versionId,
|
|
219
|
+
region: candidate.target.region,
|
|
220
|
+
expectedBucketOwner: candidate.target.expected_bucket_owner,
|
|
221
|
+
});
|
|
222
|
+
versionId = versionId ?? head.versionId ?? undefined;
|
|
223
|
+
eTag = head.eTag;
|
|
224
|
+
|
|
225
|
+
if (candidate.target.require_version_id && !versionId) {
|
|
226
|
+
fail({
|
|
227
|
+
code: "BLOB_VERSION_ID_MISSING",
|
|
228
|
+
title: "S3 object version id missing",
|
|
229
|
+
detail: `Blob target ${candidate.sourceTable}.${candidate.sourceColumn} resolved to an S3 object without a version id.`,
|
|
230
|
+
category: "integrity",
|
|
231
|
+
retryable: false,
|
|
232
|
+
fatal: true,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
await s3Client!.putObjectLegalHold({
|
|
237
|
+
bucket: pointer.bucket,
|
|
238
|
+
key: pointer.key,
|
|
239
|
+
versionId,
|
|
240
|
+
region: candidate.target.region,
|
|
241
|
+
expectedBucketOwner: candidate.target.expected_bucket_owner,
|
|
242
|
+
status: "ON",
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
if (candidate.target.action === "overwrite") {
|
|
246
|
+
const overwriteReceipt = await s3Client!.putObject({
|
|
247
|
+
bucket: pointer.bucket,
|
|
248
|
+
key: pointer.key,
|
|
249
|
+
region: candidate.target.region,
|
|
250
|
+
expectedBucketOwner: candidate.target.expected_bucket_owner,
|
|
251
|
+
body: await loadMaskingBlob(candidate.target),
|
|
252
|
+
contentType: candidate.target.masking_blob_path?.endsWith(".pdf")
|
|
253
|
+
? "application/pdf"
|
|
254
|
+
: "application/octet-stream",
|
|
255
|
+
});
|
|
256
|
+
overwriteETag = overwriteReceipt.eTag;
|
|
257
|
+
overwriteVersionId = overwriteReceipt.versionId;
|
|
258
|
+
}
|
|
259
|
+
} else {
|
|
260
|
+
versionId = versionId ?? "SHADOW_VERSION";
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const resolvedVersionId = versionId ?? "null";
|
|
264
|
+
const discovered: DiscoveredBlobObject = {
|
|
265
|
+
target: candidate.target,
|
|
266
|
+
sourceTable: candidate.sourceTable,
|
|
267
|
+
sourceColumn: candidate.sourceColumn,
|
|
268
|
+
originalValue: candidate.originalValue,
|
|
269
|
+
maskedValue,
|
|
270
|
+
bucket: pointer.bucket,
|
|
271
|
+
key: pointer.key,
|
|
272
|
+
versionId: resolvedVersionId,
|
|
273
|
+
eTag,
|
|
274
|
+
overwriteETag,
|
|
275
|
+
overwriteVersionId,
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
if (candidate.target.table === input.rootTable) {
|
|
279
|
+
rootColumnMasks[candidate.target.column] = maskedValue;
|
|
280
|
+
} else {
|
|
281
|
+
await updateBlobSourceColumn(
|
|
282
|
+
input.tx,
|
|
283
|
+
input.appSchema,
|
|
284
|
+
input.rootId,
|
|
285
|
+
input.tenantId,
|
|
286
|
+
candidate,
|
|
287
|
+
maskedValue
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
await insertBlobObject(input.tx, {
|
|
292
|
+
engineSchema: input.engineSchema,
|
|
293
|
+
userHash: input.userHash,
|
|
294
|
+
requestId: input.requestId,
|
|
295
|
+
tenantId: input.tenantId,
|
|
296
|
+
rootSchema: input.appSchema,
|
|
297
|
+
rootTable: input.rootTable,
|
|
298
|
+
rootId: String(input.rootId),
|
|
299
|
+
discovered,
|
|
300
|
+
now: input.now,
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
receipts.push({
|
|
304
|
+
sourceTable: candidate.sourceTable,
|
|
305
|
+
sourceColumn: candidate.sourceColumn,
|
|
306
|
+
provider: "aws_s3",
|
|
307
|
+
action: candidate.target.action,
|
|
308
|
+
objectRefHash: await buildObjectRefHash(pointer.bucket, pointer.key, resolvedVersionId, input.hmacKey),
|
|
309
|
+
versionIdHash: await buildVersionHash(resolvedVersionId, input.hmacKey),
|
|
310
|
+
legalHoldApplied: !input.shadowMode,
|
|
311
|
+
overwriteApplied: Boolean(overwriteVersionId),
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
await yieldWorkerEventLoop();
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return { rootColumnMasks, receipts };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function filterVersionsForDeletion(row: BlobObjectRow, versions: S3ObjectVersion[]): S3ObjectVersion[] {
|
|
321
|
+
if (row.action !== "overwrite" || !row.overwrite_version_id) {
|
|
322
|
+
return versions;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return versions.filter((version) => version.versionId !== row.overwrite_version_id);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function buildReceipt(
|
|
329
|
+
row: BlobObjectRow,
|
|
330
|
+
deletedVersions: readonly string[],
|
|
331
|
+
retainedVersions: readonly string[],
|
|
332
|
+
hmacKey: Uint8Array,
|
|
333
|
+
status: BlobShredReceipt["status"]
|
|
334
|
+
): Promise<BlobShredReceipt> {
|
|
335
|
+
return {
|
|
336
|
+
provider: "aws_s3",
|
|
337
|
+
action: row.action,
|
|
338
|
+
objectRefHash: await buildObjectRefHash(row.bucket, row.object_key, row.version_id, hmacKey),
|
|
339
|
+
versionCount: deletedVersions.length + retainedVersions.length,
|
|
340
|
+
deletedVersionIdHashes: await Promise.all(deletedVersions.map((versionId) => buildVersionHash(versionId, hmacKey))),
|
|
341
|
+
retainedVersionIdHashes: await Promise.all(retainedVersions.map((versionId) => buildVersionHash(versionId, hmacKey))),
|
|
342
|
+
status,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Removes legal holds and purges all configured S3 object versions for a shredded subject.
|
|
348
|
+
*
|
|
349
|
+
* Raw bucket names and keys remain only in the worker-local database. Returned receipts are HMACed
|
|
350
|
+
* so Control Plane CoEs can prove deletion without receiving object paths that may contain PII.
|
|
351
|
+
*
|
|
352
|
+
* @param tx - Active shred transaction.
|
|
353
|
+
* @param engineSchema - Worker engine schema.
|
|
354
|
+
* @param userHash - Subject hash in the local vault.
|
|
355
|
+
* @param hmacKey - Worker HMAC key used to sanitize receipt identifiers.
|
|
356
|
+
* @param now - Shred timestamp.
|
|
357
|
+
* @param s3Client - Optional S3 client override for tests.
|
|
358
|
+
* @returns Sanitized deletion receipts safe for the Control Plane outbox.
|
|
359
|
+
*/
|
|
360
|
+
export async function shredBlobObjects(
|
|
361
|
+
tx: Tsql,
|
|
362
|
+
engineSchema: string,
|
|
363
|
+
userHash: string,
|
|
364
|
+
hmacKey: Uint8Array,
|
|
365
|
+
now: Date,
|
|
366
|
+
s3Client: S3Client = createS3Client()
|
|
367
|
+
): Promise<BlobShredReceipt[]> {
|
|
368
|
+
const rows = await getPendingBlobObjectsForUser(tx, engineSchema, userHash);
|
|
369
|
+
const receipts: BlobShredReceipt[] = [];
|
|
370
|
+
|
|
371
|
+
for (const row of rows) {
|
|
372
|
+
if (row.action === "legal_hold_only") {
|
|
373
|
+
const receipt = await buildReceipt(row, [], [row.version_id], hmacKey, "retained_by_policy");
|
|
374
|
+
await markBlobObjectShredded(tx, engineSchema, row.id, receipt, now);
|
|
375
|
+
receipts.push(receipt);
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const otherReferences = await countOtherActiveBlobReferences(tx, engineSchema, row);
|
|
380
|
+
if (otherReferences > 0) {
|
|
381
|
+
fail({
|
|
382
|
+
code: "BLOB_SHARED_OBJECT_CONFLICT",
|
|
383
|
+
title: "Shared S3 object deletion refused",
|
|
384
|
+
detail: "The worker refuses to delete an S3 object that is still referenced by another unshredded subject.",
|
|
385
|
+
category: "integrity",
|
|
386
|
+
retryable: false,
|
|
387
|
+
fatal: true,
|
|
388
|
+
context: {
|
|
389
|
+
provider: row.provider,
|
|
390
|
+
bucket: row.bucket,
|
|
391
|
+
objectKeyHash: await buildObjectRefHash(row.bucket, row.object_key, row.version_id, hmacKey),
|
|
392
|
+
otherReferences,
|
|
393
|
+
},
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (row.legal_hold_status === "ON") {
|
|
398
|
+
await s3Client.putObjectLegalHold({
|
|
399
|
+
bucket: row.bucket,
|
|
400
|
+
key: row.object_key,
|
|
401
|
+
versionId: row.version_id,
|
|
402
|
+
region: row.region,
|
|
403
|
+
expectedBucketOwner: row.expected_bucket_owner ?? undefined,
|
|
404
|
+
status: "OFF",
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (row.action === "hard_delete") {
|
|
409
|
+
const deleted = await s3Client.deleteObjectVersion({
|
|
410
|
+
bucket: row.bucket,
|
|
411
|
+
key: row.object_key,
|
|
412
|
+
versionId: row.version_id === "null" ? undefined : row.version_id,
|
|
413
|
+
region: row.region,
|
|
414
|
+
expectedBucketOwner: row.expected_bucket_owner ?? undefined,
|
|
415
|
+
bypassGovernanceRetention: row.retention_mode === "governance",
|
|
416
|
+
});
|
|
417
|
+
const deletedVersionId = deleted.versionId ?? row.version_id;
|
|
418
|
+
const receipt = await buildReceipt(row, [deletedVersionId], [], hmacKey, "captured_version_deleted");
|
|
419
|
+
await markBlobObjectShredded(tx, engineSchema, row.id, receipt, now);
|
|
420
|
+
receipts.push(receipt);
|
|
421
|
+
await yieldWorkerEventLoop();
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const versions = await s3Client.listObjectVersions({
|
|
426
|
+
bucket: row.bucket,
|
|
427
|
+
key: row.object_key,
|
|
428
|
+
region: row.region,
|
|
429
|
+
expectedBucketOwner: row.expected_bucket_owner ?? undefined,
|
|
430
|
+
});
|
|
431
|
+
const versionsToDelete = filterVersionsForDeletion(row, versions);
|
|
432
|
+
const deletedVersionIds: string[] = [];
|
|
433
|
+
|
|
434
|
+
for (const version of versionsToDelete) {
|
|
435
|
+
await s3Client.deleteObjectVersion({
|
|
436
|
+
bucket: row.bucket,
|
|
437
|
+
key: row.object_key,
|
|
438
|
+
versionId: version.versionId,
|
|
439
|
+
region: row.region,
|
|
440
|
+
expectedBucketOwner: row.expected_bucket_owner ?? undefined,
|
|
441
|
+
bypassGovernanceRetention: row.retention_mode === "governance",
|
|
442
|
+
});
|
|
443
|
+
deletedVersionIds.push(version.versionId);
|
|
444
|
+
await yieldWorkerEventLoop();
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const retainedVersionIds =
|
|
448
|
+
row.action === "overwrite" && row.overwrite_version_id ? [row.overwrite_version_id] : [];
|
|
449
|
+
const receipt = await buildReceipt(row, deletedVersionIds, retainedVersionIds, hmacKey, "purged");
|
|
450
|
+
await markBlobObjectShredded(tx, engineSchema, row.id, receipt, now);
|
|
451
|
+
receipts.push(receipt);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
return receipts;
|
|
455
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import type { JSONValue } from "postgres";
|
|
2
|
+
import type { BlobAction, DiscoveredBlobObject, BlobShredReceipt } from "./types";
|
|
3
|
+
import type { Tsql } from "@/types";
|
|
4
|
+
|
|
5
|
+
export interface BlobObjectRow {
|
|
6
|
+
id: string;
|
|
7
|
+
user_uuid_hash: string;
|
|
8
|
+
request_id: string | null;
|
|
9
|
+
tenant_id: string;
|
|
10
|
+
root_schema: string;
|
|
11
|
+
root_table: string;
|
|
12
|
+
root_id: string;
|
|
13
|
+
source_table: string;
|
|
14
|
+
source_column: string;
|
|
15
|
+
provider: "aws_s3";
|
|
16
|
+
action: BlobAction;
|
|
17
|
+
retention_mode: "governance" | "compliance";
|
|
18
|
+
region: string;
|
|
19
|
+
expected_bucket_owner: string | null;
|
|
20
|
+
bucket: string;
|
|
21
|
+
object_key: string;
|
|
22
|
+
version_id: string;
|
|
23
|
+
e_tag: string | null;
|
|
24
|
+
masked_value: string;
|
|
25
|
+
legal_hold_status: "ON" | "OFF" | "not_supported";
|
|
26
|
+
legal_hold_applied_at: Date | null;
|
|
27
|
+
overwrite_status: "not_requested" | "applied";
|
|
28
|
+
overwrite_e_tag: string | null;
|
|
29
|
+
overwrite_version_id: string | null;
|
|
30
|
+
overwrite_applied_at: Date | null;
|
|
31
|
+
shred_status: "pending" | "purged" | "captured_version_deleted" | "retained_by_policy";
|
|
32
|
+
shred_receipt: BlobShredReceipt | null;
|
|
33
|
+
shredded_at: Date | null;
|
|
34
|
+
created_at: Date;
|
|
35
|
+
updated_at: Date;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface InsertBlobObjectInput {
|
|
39
|
+
engineSchema: string;
|
|
40
|
+
userHash: string;
|
|
41
|
+
requestId?: string | null;
|
|
42
|
+
tenantId?: string;
|
|
43
|
+
rootSchema: string;
|
|
44
|
+
rootTable: string;
|
|
45
|
+
rootId: string;
|
|
46
|
+
discovered: DiscoveredBlobObject;
|
|
47
|
+
now: Date;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Persists one protected S3 object receipt in the worker-local vault ledger.
|
|
52
|
+
*
|
|
53
|
+
* @param tx - Active vault transaction.
|
|
54
|
+
* @param input - Blob metadata discovered from the local database and S3.
|
|
55
|
+
* @returns Inserted or replayed blob object row.
|
|
56
|
+
*/
|
|
57
|
+
export async function insertBlobObject(
|
|
58
|
+
tx: Tsql,
|
|
59
|
+
input: InsertBlobObjectInput
|
|
60
|
+
): Promise<BlobObjectRow> {
|
|
61
|
+
const [row] = await tx<BlobObjectRow[]>`
|
|
62
|
+
INSERT INTO ${tx(input.engineSchema)}.blob_objects (
|
|
63
|
+
user_uuid_hash,
|
|
64
|
+
request_id,
|
|
65
|
+
tenant_id,
|
|
66
|
+
root_schema,
|
|
67
|
+
root_table,
|
|
68
|
+
root_id,
|
|
69
|
+
source_table,
|
|
70
|
+
source_column,
|
|
71
|
+
provider,
|
|
72
|
+
action,
|
|
73
|
+
retention_mode,
|
|
74
|
+
region,
|
|
75
|
+
expected_bucket_owner,
|
|
76
|
+
bucket,
|
|
77
|
+
object_key,
|
|
78
|
+
version_id,
|
|
79
|
+
e_tag,
|
|
80
|
+
masked_value,
|
|
81
|
+
legal_hold_status,
|
|
82
|
+
legal_hold_applied_at,
|
|
83
|
+
overwrite_status,
|
|
84
|
+
overwrite_e_tag,
|
|
85
|
+
overwrite_version_id,
|
|
86
|
+
overwrite_applied_at,
|
|
87
|
+
shred_status,
|
|
88
|
+
created_at,
|
|
89
|
+
updated_at
|
|
90
|
+
)
|
|
91
|
+
VALUES (
|
|
92
|
+
${input.userHash},
|
|
93
|
+
${input.requestId ?? null},
|
|
94
|
+
${input.tenantId ?? ""},
|
|
95
|
+
${input.rootSchema},
|
|
96
|
+
${input.rootTable},
|
|
97
|
+
${input.rootId},
|
|
98
|
+
${input.discovered.sourceTable},
|
|
99
|
+
${input.discovered.sourceColumn},
|
|
100
|
+
'aws_s3',
|
|
101
|
+
${input.discovered.target.action},
|
|
102
|
+
${input.discovered.target.retention_mode},
|
|
103
|
+
${input.discovered.target.region},
|
|
104
|
+
${input.discovered.target.expected_bucket_owner ?? null},
|
|
105
|
+
${input.discovered.bucket},
|
|
106
|
+
${input.discovered.key},
|
|
107
|
+
${input.discovered.versionId},
|
|
108
|
+
${input.discovered.eTag},
|
|
109
|
+
${input.discovered.maskedValue},
|
|
110
|
+
'ON',
|
|
111
|
+
${input.now},
|
|
112
|
+
${input.discovered.overwriteVersionId ? "applied" : "not_requested"},
|
|
113
|
+
${input.discovered.overwriteETag},
|
|
114
|
+
${input.discovered.overwriteVersionId},
|
|
115
|
+
${input.discovered.overwriteVersionId ? input.now : null},
|
|
116
|
+
'pending',
|
|
117
|
+
${input.now},
|
|
118
|
+
${input.now}
|
|
119
|
+
)
|
|
120
|
+
ON CONFLICT (user_uuid_hash, source_table, source_column, bucket, object_key, version_id)
|
|
121
|
+
DO UPDATE
|
|
122
|
+
SET legal_hold_status = EXCLUDED.legal_hold_status,
|
|
123
|
+
expected_bucket_owner = EXCLUDED.expected_bucket_owner,
|
|
124
|
+
legal_hold_applied_at = EXCLUDED.legal_hold_applied_at,
|
|
125
|
+
overwrite_status = EXCLUDED.overwrite_status,
|
|
126
|
+
overwrite_e_tag = EXCLUDED.overwrite_e_tag,
|
|
127
|
+
overwrite_version_id = EXCLUDED.overwrite_version_id,
|
|
128
|
+
overwrite_applied_at = EXCLUDED.overwrite_applied_at,
|
|
129
|
+
updated_at = EXCLUDED.updated_at
|
|
130
|
+
RETURNING *
|
|
131
|
+
`;
|
|
132
|
+
|
|
133
|
+
return row!;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Lists pending blob objects attached to one vaulted subject.
|
|
138
|
+
*
|
|
139
|
+
* @param tx - Active shred transaction.
|
|
140
|
+
* @param engineSchema - Worker engine schema.
|
|
141
|
+
* @param userHash - Vault subject hash.
|
|
142
|
+
* @returns Pending blob object rows.
|
|
143
|
+
*/
|
|
144
|
+
export async function getPendingBlobObjectsForUser(
|
|
145
|
+
tx: Tsql,
|
|
146
|
+
engineSchema: string,
|
|
147
|
+
userHash: string
|
|
148
|
+
): Promise<BlobObjectRow[]> {
|
|
149
|
+
return tx<BlobObjectRow[]>`
|
|
150
|
+
SELECT *
|
|
151
|
+
FROM ${tx(engineSchema)}.blob_objects
|
|
152
|
+
WHERE user_uuid_hash = ${userHash}
|
|
153
|
+
AND shred_status = 'pending'
|
|
154
|
+
ORDER BY created_at ASC, id ASC
|
|
155
|
+
FOR UPDATE
|
|
156
|
+
`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Counts pending blob objects for a subject without claiming them.
|
|
161
|
+
*
|
|
162
|
+
* @param tx - Active shred transaction.
|
|
163
|
+
* @param engineSchema - Worker engine schema.
|
|
164
|
+
* @param userHash - Vault subject hash.
|
|
165
|
+
* @returns Number of pending blob object rows.
|
|
166
|
+
*/
|
|
167
|
+
export async function countPendingBlobObjectsForUser(
|
|
168
|
+
tx: Tsql,
|
|
169
|
+
engineSchema: string,
|
|
170
|
+
userHash: string
|
|
171
|
+
): Promise<number> {
|
|
172
|
+
const [row] = await tx<{ total: number }[]>`
|
|
173
|
+
SELECT COUNT(*)::int AS total
|
|
174
|
+
FROM ${tx(engineSchema)}.blob_objects
|
|
175
|
+
WHERE user_uuid_hash = ${userHash}
|
|
176
|
+
AND shred_status = 'pending'
|
|
177
|
+
`;
|
|
178
|
+
|
|
179
|
+
return row?.total ?? 0;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Counts active references to the same physical S3 object held by other subjects.
|
|
184
|
+
*
|
|
185
|
+
* @param tx - Active shred transaction.
|
|
186
|
+
* @param engineSchema - Worker engine schema.
|
|
187
|
+
* @param row - Blob object being considered for destructive S3 deletion.
|
|
188
|
+
* @returns Number of non-purged references owned by other subjects.
|
|
189
|
+
*/
|
|
190
|
+
export async function countOtherActiveBlobReferences(
|
|
191
|
+
tx: Tsql,
|
|
192
|
+
engineSchema: string,
|
|
193
|
+
row: BlobObjectRow
|
|
194
|
+
): Promise<number> {
|
|
195
|
+
const [result] = await tx<{ total: number }[]>`
|
|
196
|
+
SELECT COUNT(*)::int AS total
|
|
197
|
+
FROM ${tx(engineSchema)}.blob_objects
|
|
198
|
+
WHERE provider = ${row.provider}
|
|
199
|
+
AND bucket = ${row.bucket}
|
|
200
|
+
AND object_key = ${row.object_key}
|
|
201
|
+
AND user_uuid_hash <> ${row.user_uuid_hash}
|
|
202
|
+
AND shred_status IN ('pending', 'retained_by_policy')
|
|
203
|
+
`;
|
|
204
|
+
|
|
205
|
+
return result?.total ?? 0;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Marks a blob object as shredded or explicitly retained by policy.
|
|
210
|
+
*
|
|
211
|
+
* @param tx - Active shred transaction.
|
|
212
|
+
* @param engineSchema - Worker engine schema.
|
|
213
|
+
* @param rowId - Blob object row id.
|
|
214
|
+
* @param receipt - Sanitized non-PII deletion receipt.
|
|
215
|
+
* @param now - Completion timestamp.
|
|
216
|
+
*/
|
|
217
|
+
export async function markBlobObjectShredded(
|
|
218
|
+
tx: Tsql,
|
|
219
|
+
engineSchema: string,
|
|
220
|
+
rowId: string,
|
|
221
|
+
receipt: BlobShredReceipt,
|
|
222
|
+
now: Date
|
|
223
|
+
): Promise<void> {
|
|
224
|
+
await tx`
|
|
225
|
+
UPDATE ${tx(engineSchema)}.blob_objects
|
|
226
|
+
SET shred_status = ${receipt.status},
|
|
227
|
+
shred_receipt = ${tx.json(receipt as unknown as JSONValue)},
|
|
228
|
+
legal_hold_status = CASE
|
|
229
|
+
WHEN ${receipt.status === "retained_by_policy"} THEN legal_hold_status
|
|
230
|
+
ELSE 'OFF'
|
|
231
|
+
END,
|
|
232
|
+
shredded_at = ${now},
|
|
233
|
+
updated_at = ${now}
|
|
234
|
+
WHERE id = ${rowId}
|
|
235
|
+
`;
|
|
236
|
+
}
|