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,658 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { assertIdentifier } from "@/utils";
|
|
3
|
+
import { keySourceSchema } from "@/secrets";
|
|
4
|
+
|
|
5
|
+
const isError = (err: unknown, msg: string): string => {
|
|
6
|
+
if (err instanceof Error) {
|
|
7
|
+
return err.message;
|
|
8
|
+
}
|
|
9
|
+
return msg;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const mutationRuleSchema = z.enum(["HMAC", "STATIC_MASK", "NULLIFY"]);
|
|
13
|
+
|
|
14
|
+
export type MutationRule = z.infer<typeof mutationRuleSchema>;
|
|
15
|
+
export type RootPiiColumns = Record<string, MutationRule>;
|
|
16
|
+
|
|
17
|
+
const rootPiiColumnsSchema = z
|
|
18
|
+
.record(z.string().min(1), mutationRuleSchema)
|
|
19
|
+
.superRefine((value, ctx) => {
|
|
20
|
+
const entries = Object.entries(value);
|
|
21
|
+
if (entries.length === 0) {
|
|
22
|
+
ctx.addIssue({
|
|
23
|
+
code: "custom",
|
|
24
|
+
message: "graph.root_pii_columns must contain at least one column mapping.",
|
|
25
|
+
});
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
for (const [column] of entries) {
|
|
30
|
+
try {
|
|
31
|
+
assertIdentifier(column, "graph root pii column");
|
|
32
|
+
} catch (error) {
|
|
33
|
+
ctx.addIssue({
|
|
34
|
+
code: "custom",
|
|
35
|
+
message: isError(error, "Invalid graph root pii column."),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const satelliteTargetSchema = z
|
|
42
|
+
.object({
|
|
43
|
+
table: z.string().min(1),
|
|
44
|
+
lookup_column: z.string().min(1),
|
|
45
|
+
action: z.enum(["redact", "hard_delete"]),
|
|
46
|
+
masking_rules: z.record(z.string().min(1), mutationRuleSchema).optional(),
|
|
47
|
+
})
|
|
48
|
+
.strict()
|
|
49
|
+
.superRefine((value, ctx) => {
|
|
50
|
+
try {
|
|
51
|
+
assertIdentifier(value.table, "satellite table name");
|
|
52
|
+
} catch (error) {
|
|
53
|
+
ctx.addIssue({
|
|
54
|
+
code: "custom",
|
|
55
|
+
message: isError(error, "Invalid satellite table name."),
|
|
56
|
+
path: ["table"],
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
assertIdentifier(value.lookup_column, "satellite lookup column");
|
|
62
|
+
} catch (error) {
|
|
63
|
+
ctx.addIssue({
|
|
64
|
+
code: "custom",
|
|
65
|
+
message: isError(error, "Invalid satellite lookup column."),
|
|
66
|
+
path: ["lookup_column"],
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (value.action === "redact" && (!value.masking_rules || Object.keys(value.masking_rules).length === 0)) {
|
|
71
|
+
ctx.addIssue({
|
|
72
|
+
code: "custom",
|
|
73
|
+
message: "satellite target masking_rules is required for redact actions.",
|
|
74
|
+
path: ["masking_rules"],
|
|
75
|
+
});
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (!value.masking_rules) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
for (const column of Object.keys(value.masking_rules)) {
|
|
84
|
+
try {
|
|
85
|
+
assertIdentifier(column, "satellite masking rule column");
|
|
86
|
+
} catch (error) {
|
|
87
|
+
ctx.addIssue({
|
|
88
|
+
code: "custom",
|
|
89
|
+
message: isError(error, "Invalid satellite masking rule column."),
|
|
90
|
+
path: ["masking_rules", column],
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
export type SatelliteTarget = z.infer<typeof satelliteTargetSchema>;
|
|
97
|
+
|
|
98
|
+
const blobTargetSchema = z
|
|
99
|
+
.object({
|
|
100
|
+
table: z.string().min(1),
|
|
101
|
+
column: z.string().min(1),
|
|
102
|
+
lookup_column: z.string().min(1).optional(),
|
|
103
|
+
provider: z.literal("aws_s3"),
|
|
104
|
+
region: z.string().min(1),
|
|
105
|
+
action: z.enum(["versioned_hard_delete", "hard_delete", "overwrite", "legal_hold_only"]),
|
|
106
|
+
retention_mode: z.enum(["governance", "compliance"]).default("governance"),
|
|
107
|
+
expected_bucket_owner: z.string().regex(/^\d{12}$/).optional(),
|
|
108
|
+
require_version_id: z.boolean().default(true),
|
|
109
|
+
masking_blob_path: z.string().min(1).optional(),
|
|
110
|
+
})
|
|
111
|
+
.strict()
|
|
112
|
+
.superRefine((value, ctx) => {
|
|
113
|
+
try {
|
|
114
|
+
assertIdentifier(value.table, "blob target table name");
|
|
115
|
+
} catch (error) {
|
|
116
|
+
ctx.addIssue({
|
|
117
|
+
code: "custom",
|
|
118
|
+
message: isError(error, "Invalid blob target table name."),
|
|
119
|
+
path: ["table"],
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
assertIdentifier(value.column, "blob target column name");
|
|
125
|
+
} catch (error) {
|
|
126
|
+
ctx.addIssue({
|
|
127
|
+
code: "custom",
|
|
128
|
+
message: isError(error, "Invalid blob target column name."),
|
|
129
|
+
path: ["column"],
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (value.lookup_column) {
|
|
134
|
+
try {
|
|
135
|
+
assertIdentifier(value.lookup_column, "blob target lookup column");
|
|
136
|
+
} catch (error) {
|
|
137
|
+
ctx.addIssue({
|
|
138
|
+
code: "custom",
|
|
139
|
+
message: isError(error, "Invalid blob target lookup column."),
|
|
140
|
+
path: ["lookup_column"],
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (value.action === "overwrite" && !value.masking_blob_path) {
|
|
146
|
+
ctx.addIssue({
|
|
147
|
+
code: "custom",
|
|
148
|
+
message: "blob target masking_blob_path is required for overwrite actions.",
|
|
149
|
+
path: ["masking_blob_path"],
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
export type BlobTarget = z.infer<typeof blobTargetSchema>;
|
|
155
|
+
|
|
156
|
+
const qualifiedIdentifierSchema = z.string().min(1).superRefine((value, ctx) => {
|
|
157
|
+
const parts = value.split(".");
|
|
158
|
+
if (parts.length > 2 || parts.some((part) => part.trim().length === 0)) {
|
|
159
|
+
ctx.addIssue({
|
|
160
|
+
code: "custom",
|
|
161
|
+
message: `Invalid qualified table reference: "${value}". Use table or schema.table.`,
|
|
162
|
+
});
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
for (const part of parts) {
|
|
167
|
+
try {
|
|
168
|
+
assertIdentifier(part, "compiled DAG table reference");
|
|
169
|
+
} catch (error) {
|
|
170
|
+
ctx.addIssue({
|
|
171
|
+
code: "custom",
|
|
172
|
+
message: isError(error, "Invalid compiled DAG table reference."),
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const compiledExecutionTargetSchema = z
|
|
179
|
+
.object({
|
|
180
|
+
table: qualifiedIdentifierSchema,
|
|
181
|
+
parent: qualifiedIdentifierSchema.optional(),
|
|
182
|
+
join: z.string().min(1).optional(),
|
|
183
|
+
fk_condition: z.string().min(1).optional(),
|
|
184
|
+
parent_columns: z.array(z.string().min(1)).default([]),
|
|
185
|
+
child_columns: z.array(z.string().min(1)).default([]),
|
|
186
|
+
primary_key_columns: z.array(z.string().min(1)).default([]),
|
|
187
|
+
action: z.enum(["redact", "hard_delete"]).optional(),
|
|
188
|
+
mutation_rules: z.record(z.string().min(1), mutationRuleSchema).optional(),
|
|
189
|
+
pii_columns: z.array(z.string().min(1)).default([]),
|
|
190
|
+
})
|
|
191
|
+
.strict()
|
|
192
|
+
.superRefine((value, ctx) => {
|
|
193
|
+
for (const [index, column] of value.parent_columns.entries()) {
|
|
194
|
+
try {
|
|
195
|
+
assertIdentifier(column, "compiled DAG parent column");
|
|
196
|
+
} catch (error) {
|
|
197
|
+
ctx.addIssue({
|
|
198
|
+
code: "custom",
|
|
199
|
+
message: isError(error, "Invalid compiled DAG parent column."),
|
|
200
|
+
path: ["parent_columns", index],
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
for (const [index, column] of value.child_columns.entries()) {
|
|
206
|
+
try {
|
|
207
|
+
assertIdentifier(column, "compiled DAG child column");
|
|
208
|
+
} catch (error) {
|
|
209
|
+
ctx.addIssue({
|
|
210
|
+
code: "custom",
|
|
211
|
+
message: isError(error, "Invalid compiled DAG child column."),
|
|
212
|
+
path: ["child_columns", index],
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
for (const [index, column] of value.primary_key_columns.entries()) {
|
|
218
|
+
try {
|
|
219
|
+
assertIdentifier(column, "compiled DAG primary key column");
|
|
220
|
+
} catch (error) {
|
|
221
|
+
ctx.addIssue({
|
|
222
|
+
code: "custom",
|
|
223
|
+
message: isError(error, "Invalid compiled DAG primary key column."),
|
|
224
|
+
path: ["primary_key_columns", index],
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
for (const [index, column] of value.pii_columns.entries()) {
|
|
230
|
+
try {
|
|
231
|
+
assertIdentifier(column, "compiled DAG PII column");
|
|
232
|
+
} catch (error) {
|
|
233
|
+
ctx.addIssue({
|
|
234
|
+
code: "custom",
|
|
235
|
+
message: isError(error, "Invalid compiled DAG PII column."),
|
|
236
|
+
path: ["pii_columns", index],
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
for (const column of Object.keys(value.mutation_rules ?? {})) {
|
|
242
|
+
try {
|
|
243
|
+
assertIdentifier(column, "compiled DAG mutation column");
|
|
244
|
+
} catch (error) {
|
|
245
|
+
ctx.addIssue({
|
|
246
|
+
code: "custom",
|
|
247
|
+
message: isError(error, "Invalid compiled DAG mutation column."),
|
|
248
|
+
path: ["mutation_rules", column],
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (
|
|
254
|
+
value.action === "redact"
|
|
255
|
+
&& (!value.mutation_rules || Object.keys(value.mutation_rules).length === 0)
|
|
256
|
+
) {
|
|
257
|
+
ctx.addIssue({
|
|
258
|
+
code: "custom",
|
|
259
|
+
message: "compiled DAG mutation_rules is required for redact actions.",
|
|
260
|
+
path: ["mutation_rules"],
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
const compiledExecutionRuleSchema = z
|
|
266
|
+
.object({
|
|
267
|
+
id: z.string().min(1),
|
|
268
|
+
root_table: qualifiedIdentifierSchema.optional(),
|
|
269
|
+
max_depth: z.number().int().min(1).max(32).optional(),
|
|
270
|
+
targets: z.array(compiledExecutionTargetSchema).min(1),
|
|
271
|
+
})
|
|
272
|
+
.strict();
|
|
273
|
+
|
|
274
|
+
export type CompiledExecutionTarget = z.output<typeof compiledExecutionTargetSchema>;
|
|
275
|
+
export type CompiledExecutionTargetInput = z.input<typeof compiledExecutionTargetSchema>;
|
|
276
|
+
export type CompiledExecutionRule = z.infer<typeof compiledExecutionRuleSchema>;
|
|
277
|
+
|
|
278
|
+
const retentionRuleSchema = z
|
|
279
|
+
.object({
|
|
280
|
+
rule_name: z.string().min(1),
|
|
281
|
+
legal_citation: z.string().min(1),
|
|
282
|
+
if_has_data_in: z.array(z.string().min(1)),
|
|
283
|
+
retention_years: z.number().int().min(0),
|
|
284
|
+
})
|
|
285
|
+
.strict()
|
|
286
|
+
.superRefine((value, ctx) => {
|
|
287
|
+
if (value.if_has_data_in.length === 0) {
|
|
288
|
+
ctx.addIssue({
|
|
289
|
+
code: "custom",
|
|
290
|
+
message: "retention rule must reference at least one evidence table.",
|
|
291
|
+
path: ["if_has_data_in"],
|
|
292
|
+
});
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
for (const table of value.if_has_data_in) {
|
|
297
|
+
try {
|
|
298
|
+
assertIdentifier(table, "retention rule evidence table");
|
|
299
|
+
} catch (error) {
|
|
300
|
+
ctx.addIssue({
|
|
301
|
+
code: "custom",
|
|
302
|
+
message: isError(error, "Invalid retention rule evidence table."),
|
|
303
|
+
path: ["if_has_data_in"],
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
export type RetentionRule = z.infer<typeof retentionRuleSchema>;
|
|
310
|
+
|
|
311
|
+
const purgeSelectorSchema = z
|
|
312
|
+
.discriminatedUnion("kind", [
|
|
313
|
+
z
|
|
314
|
+
.object({
|
|
315
|
+
kind: z.literal("boolean_column"),
|
|
316
|
+
column: z.string().min(1),
|
|
317
|
+
value: z.boolean().default(true),
|
|
318
|
+
})
|
|
319
|
+
.strict(),
|
|
320
|
+
z
|
|
321
|
+
.object({
|
|
322
|
+
kind: z.literal("enum_column"),
|
|
323
|
+
column: z.string().min(1),
|
|
324
|
+
values: z.array(z.string().min(1)).min(1),
|
|
325
|
+
})
|
|
326
|
+
.strict(),
|
|
327
|
+
z
|
|
328
|
+
.object({
|
|
329
|
+
kind: z.literal("timestamp_before"),
|
|
330
|
+
column: z.string().min(1),
|
|
331
|
+
older_than_days: z.number().int().min(0).optional(),
|
|
332
|
+
before: z.iso.datetime().optional(),
|
|
333
|
+
})
|
|
334
|
+
.strict()
|
|
335
|
+
.superRefine((value, ctx) => {
|
|
336
|
+
if (value.older_than_days === undefined && value.before === undefined) {
|
|
337
|
+
ctx.addIssue({
|
|
338
|
+
code: "custom",
|
|
339
|
+
message: "timestamp_before purge selector requires older_than_days or before.",
|
|
340
|
+
path: ["older_than_days"],
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
}),
|
|
344
|
+
])
|
|
345
|
+
.superRefine((value, ctx) => {
|
|
346
|
+
try {
|
|
347
|
+
assertIdentifier(value.column, "purge selector column");
|
|
348
|
+
} catch (error) {
|
|
349
|
+
ctx.addIssue({
|
|
350
|
+
code: "custom",
|
|
351
|
+
message: error instanceof Error ? error.message : "Invalid purge selector column.",
|
|
352
|
+
path: ["column"],
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
export type PurgeSelector = z.infer<typeof purgeSelectorSchema>;
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
const purgePolicySchema = z
|
|
361
|
+
.object({
|
|
362
|
+
enabled: z.boolean().default(false),
|
|
363
|
+
selector: purgeSelectorSchema.optional(),
|
|
364
|
+
max_batch_size: z.number().int().min(1).max(100_000).default(10_000),
|
|
365
|
+
actor_opaque_id: z.string().min(1).default("system:purge"),
|
|
366
|
+
legal_framework: z.string().min(1).default("DPDP_2023"),
|
|
367
|
+
legal_citation: z.string().min(1).optional(),
|
|
368
|
+
})
|
|
369
|
+
.strict()
|
|
370
|
+
.superRefine((value, ctx) => {
|
|
371
|
+
if (value.enabled && !value.selector) {
|
|
372
|
+
ctx.addIssue({
|
|
373
|
+
code: "custom",
|
|
374
|
+
message: "purge_policy.selector is required when purge_policy.enabled is true.",
|
|
375
|
+
path: ["selector"],
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
export type PurgePolicy = z.infer<typeof purgePolicySchema>;
|
|
381
|
+
|
|
382
|
+
const legalAttestationSchema = z
|
|
383
|
+
.object({
|
|
384
|
+
dpo_identifier: z.string().min(1),
|
|
385
|
+
configuration_version: z.string().min(1),
|
|
386
|
+
legal_review_date: z.iso.date(),
|
|
387
|
+
schema_hash: z.string().regex(/^[0-9a-fA-F]{64}$/).optional(),
|
|
388
|
+
generated_by: z.string().min(1).optional(),
|
|
389
|
+
acknowledgment: z.string().min(1),
|
|
390
|
+
})
|
|
391
|
+
.strict();
|
|
392
|
+
|
|
393
|
+
export type LegalAttestation = z.infer<typeof legalAttestationSchema>;
|
|
394
|
+
|
|
395
|
+
export const workerYamlSchema = z
|
|
396
|
+
.object({
|
|
397
|
+
version: z.string().min(1),
|
|
398
|
+
database: z
|
|
399
|
+
.object({
|
|
400
|
+
app_schema: z.string().min(1),
|
|
401
|
+
engine_schema: z.string().min(1),
|
|
402
|
+
replica_db_url: z.string().min(1).optional(),
|
|
403
|
+
})
|
|
404
|
+
.strict(),
|
|
405
|
+
compliance_policy: z
|
|
406
|
+
.object({
|
|
407
|
+
default_retention_years: z.number().int().min(0),
|
|
408
|
+
notice_window_hours: z.number().int().min(1),
|
|
409
|
+
retention_rules: z.array(retentionRuleSchema),
|
|
410
|
+
})
|
|
411
|
+
.strict(),
|
|
412
|
+
graph: z
|
|
413
|
+
.object({
|
|
414
|
+
root_table: z.string().min(1),
|
|
415
|
+
root_id_column: z.string().min(1),
|
|
416
|
+
max_depth: z.number().int().min(1).max(32),
|
|
417
|
+
root_pii_columns: rootPiiColumnsSchema,
|
|
418
|
+
notice_email_column: z.string().min(1).optional(),
|
|
419
|
+
notice_name_column: z.string().min(1).optional(),
|
|
420
|
+
})
|
|
421
|
+
.strict(),
|
|
422
|
+
satellite_targets: z.array(satelliteTargetSchema).default([]),
|
|
423
|
+
blob_targets: z.array(blobTargetSchema).default([]),
|
|
424
|
+
purge_policy: purgePolicySchema.default({
|
|
425
|
+
enabled: false,
|
|
426
|
+
max_batch_size: 10_000,
|
|
427
|
+
actor_opaque_id: "system:purge",
|
|
428
|
+
legal_framework: "DPDP_2023",
|
|
429
|
+
}),
|
|
430
|
+
rules: z.array(compiledExecutionRuleSchema).default([]),
|
|
431
|
+
outbox: z
|
|
432
|
+
.object({
|
|
433
|
+
batch_size: z.number().int().min(1),
|
|
434
|
+
lease_seconds: z.number().int().min(1),
|
|
435
|
+
max_attempts: z.number().int().min(1),
|
|
436
|
+
base_backoff_ms: z.number().int().min(1).default(1000),
|
|
437
|
+
})
|
|
438
|
+
.strict(),
|
|
439
|
+
security: z
|
|
440
|
+
.object({
|
|
441
|
+
notification_lease_seconds: z.number().int().min(1).default(120),
|
|
442
|
+
master_key_env: z.string().min(1).default("MASTER_KEY"),
|
|
443
|
+
hmac_key_env: z.string().min(1).default("HMAC_KEY"),
|
|
444
|
+
master_key_source: keySourceSchema.optional(),
|
|
445
|
+
hmac_key_source: keySourceSchema.optional(),
|
|
446
|
+
})
|
|
447
|
+
.strict(),
|
|
448
|
+
integrity: z
|
|
449
|
+
.object({
|
|
450
|
+
expected_schema_hash: z.string().regex(/^[0-9a-fA-F]{64}$/),
|
|
451
|
+
})
|
|
452
|
+
.strict(),
|
|
453
|
+
legal_attestation: legalAttestationSchema,
|
|
454
|
+
legal_disclaimer: z
|
|
455
|
+
.object({
|
|
456
|
+
text: z.string().min(1),
|
|
457
|
+
})
|
|
458
|
+
.strict()
|
|
459
|
+
.optional(),
|
|
460
|
+
})
|
|
461
|
+
.strict()
|
|
462
|
+
.superRefine((value, ctx) => {
|
|
463
|
+
try {
|
|
464
|
+
assertIdentifier(value.database.app_schema, "application schema name");
|
|
465
|
+
} catch (error) {
|
|
466
|
+
ctx.addIssue({
|
|
467
|
+
code: "custom",
|
|
468
|
+
message: isError(error, "Invalid application schema name."),
|
|
469
|
+
path: ["database", "app_schema"],
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
try {
|
|
474
|
+
assertIdentifier(value.database.engine_schema, "engine schema name");
|
|
475
|
+
} catch (error) {
|
|
476
|
+
ctx.addIssue({
|
|
477
|
+
code: "custom",
|
|
478
|
+
message: isError(error, "Invalid engine schema name."),
|
|
479
|
+
path: ["database", "engine_schema"],
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
try {
|
|
484
|
+
assertIdentifier(value.graph.root_table, "graph root table");
|
|
485
|
+
} catch (error) {
|
|
486
|
+
ctx.addIssue({
|
|
487
|
+
code: "custom",
|
|
488
|
+
message: isError(error, "Invalid graph root table."),
|
|
489
|
+
path: ["graph", "root_table"],
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
try {
|
|
494
|
+
assertIdentifier(value.graph.root_id_column, "graph root id column");
|
|
495
|
+
} catch (error) {
|
|
496
|
+
ctx.addIssue({
|
|
497
|
+
code: "custom",
|
|
498
|
+
message: isError(error, "Invalid graph root id column."),
|
|
499
|
+
path: ["graph", "root_id_column"],
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
if (value.graph.notice_email_column) {
|
|
504
|
+
try {
|
|
505
|
+
assertIdentifier(value.graph.notice_email_column, "graph notice email column");
|
|
506
|
+
} catch (error) {
|
|
507
|
+
ctx.addIssue({
|
|
508
|
+
code: "custom",
|
|
509
|
+
message: isError(error, "Invalid graph notice email column."),
|
|
510
|
+
path: ["graph", "notice_email_column"],
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
if (value.graph.notice_name_column) {
|
|
516
|
+
try {
|
|
517
|
+
assertIdentifier(value.graph.notice_name_column, "graph notice name column");
|
|
518
|
+
} catch (error) {
|
|
519
|
+
ctx.addIssue({
|
|
520
|
+
code: "custom",
|
|
521
|
+
message: isError(error, "Invalid graph notice name column."),
|
|
522
|
+
path: ["graph", "notice_name_column"],
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
for (const [index, target] of value.blob_targets.entries()) {
|
|
528
|
+
if (target.table !== value.graph.root_table && !target.lookup_column) {
|
|
529
|
+
ctx.addIssue({
|
|
530
|
+
code: "custom",
|
|
531
|
+
message: "blob target lookup_column is required when table is not the graph root table.",
|
|
532
|
+
path: ["blob_targets", index, "lookup_column"],
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
export type WorkerYamlConfig = z.infer<typeof workerYamlSchema>;
|
|
539
|
+
|
|
540
|
+
export interface WorkerConfig extends Omit<WorkerYamlConfig, "rules" | "purge_policy"> {
|
|
541
|
+
purge_policy?: PurgePolicy;
|
|
542
|
+
rules?: CompiledExecutionRule[];
|
|
543
|
+
masterKey: Uint8Array;
|
|
544
|
+
hmacKey: Uint8Array;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Sanitizes and validates the worker configuration for database safety.
|
|
549
|
+
*
|
|
550
|
+
* Validates: Ensures all table and column names are safe identifiers.
|
|
551
|
+
* Normalizes: Trims whitespace and standardizes string casing.
|
|
552
|
+
* Verifies: Recursively checks the DAG rules and compliance policies.
|
|
553
|
+
*
|
|
554
|
+
* @param config - The raw YAML-parsed configuration.
|
|
555
|
+
* @returns Sanitized configuration object.
|
|
556
|
+
* @throws {WorkerError} If any identifier fails security validation.
|
|
557
|
+
*/
|
|
558
|
+
export function normalizeWorkerYaml(config: WorkerYamlConfig): WorkerYamlConfig {
|
|
559
|
+
return {
|
|
560
|
+
...config,
|
|
561
|
+
database: {
|
|
562
|
+
...config.database,
|
|
563
|
+
app_schema: assertIdentifier(config.database.app_schema, "application schema name"),
|
|
564
|
+
engine_schema: assertIdentifier(config.database.engine_schema, "engine schema name"),
|
|
565
|
+
replica_db_url: config.database.replica_db_url?.trim() || undefined,
|
|
566
|
+
},
|
|
567
|
+
graph: {
|
|
568
|
+
...config.graph,
|
|
569
|
+
root_table: assertIdentifier(config.graph.root_table, "graph root table"),
|
|
570
|
+
root_id_column: assertIdentifier(config.graph.root_id_column, "graph root id column"),
|
|
571
|
+
notice_email_column: config.graph.notice_email_column
|
|
572
|
+
? assertIdentifier(config.graph.notice_email_column, "graph notice email column")
|
|
573
|
+
: undefined,
|
|
574
|
+
notice_name_column: config.graph.notice_name_column
|
|
575
|
+
? assertIdentifier(config.graph.notice_name_column, "graph notice name column")
|
|
576
|
+
: undefined,
|
|
577
|
+
root_pii_columns: Object.fromEntries(
|
|
578
|
+
Object.entries(config.graph.root_pii_columns).map(([column, rule]) => [
|
|
579
|
+
assertIdentifier(column, "graph root pii column"),
|
|
580
|
+
rule,
|
|
581
|
+
])
|
|
582
|
+
),
|
|
583
|
+
},
|
|
584
|
+
satellite_targets: config.satellite_targets.map((target) => ({
|
|
585
|
+
...target,
|
|
586
|
+
table: assertIdentifier(target.table, "satellite table name"),
|
|
587
|
+
lookup_column: assertIdentifier(target.lookup_column, "satellite lookup column"),
|
|
588
|
+
masking_rules: target.masking_rules
|
|
589
|
+
? Object.fromEntries(
|
|
590
|
+
Object.entries(target.masking_rules).map(([column, rule]) => [
|
|
591
|
+
assertIdentifier(column, "satellite masking rule column"),
|
|
592
|
+
rule,
|
|
593
|
+
])
|
|
594
|
+
)
|
|
595
|
+
: undefined,
|
|
596
|
+
})),
|
|
597
|
+
blob_targets: config.blob_targets.map((target) => ({
|
|
598
|
+
...target,
|
|
599
|
+
table: assertIdentifier(target.table, "blob target table name"),
|
|
600
|
+
column: assertIdentifier(target.column, "blob target column name"),
|
|
601
|
+
lookup_column: target.lookup_column
|
|
602
|
+
? assertIdentifier(target.lookup_column, "blob target lookup column")
|
|
603
|
+
: undefined,
|
|
604
|
+
region: target.region.trim(),
|
|
605
|
+
masking_blob_path: target.masking_blob_path?.trim(),
|
|
606
|
+
})),
|
|
607
|
+
rules: config.rules.map((rule) => ({
|
|
608
|
+
...rule,
|
|
609
|
+
root_table: rule.root_table?.trim(),
|
|
610
|
+
targets: rule.targets.map((target) => ({
|
|
611
|
+
...target,
|
|
612
|
+
table: target.table.trim(),
|
|
613
|
+
parent: target.parent?.trim(),
|
|
614
|
+
join: target.join?.trim(),
|
|
615
|
+
fk_condition: target.fk_condition?.trim(),
|
|
616
|
+
parent_columns: target.parent_columns.map((column) =>
|
|
617
|
+
assertIdentifier(column, "compiled DAG parent column")
|
|
618
|
+
),
|
|
619
|
+
child_columns: target.child_columns.map((column) =>
|
|
620
|
+
assertIdentifier(column, "compiled DAG child column")
|
|
621
|
+
),
|
|
622
|
+
primary_key_columns: target.primary_key_columns.map((column) =>
|
|
623
|
+
assertIdentifier(column, "compiled DAG primary key column")
|
|
624
|
+
),
|
|
625
|
+
mutation_rules: target.mutation_rules
|
|
626
|
+
? Object.fromEntries(
|
|
627
|
+
Object.entries(target.mutation_rules).map(([column, rule]) => [
|
|
628
|
+
assertIdentifier(column, "compiled DAG mutation column"),
|
|
629
|
+
rule,
|
|
630
|
+
])
|
|
631
|
+
)
|
|
632
|
+
: undefined,
|
|
633
|
+
pii_columns: target.pii_columns.map((column) =>
|
|
634
|
+
assertIdentifier(column, "compiled DAG PII column")
|
|
635
|
+
),
|
|
636
|
+
})),
|
|
637
|
+
})),
|
|
638
|
+
compliance_policy: {
|
|
639
|
+
...config.compliance_policy,
|
|
640
|
+
retention_rules: config.compliance_policy.retention_rules.map((rule) => ({
|
|
641
|
+
...rule,
|
|
642
|
+
legal_citation: rule.legal_citation.trim(),
|
|
643
|
+
if_has_data_in: rule.if_has_data_in.map((table) =>
|
|
644
|
+
assertIdentifier(table, "retention rule evidence table")
|
|
645
|
+
),
|
|
646
|
+
})),
|
|
647
|
+
},
|
|
648
|
+
legal_attestation: {
|
|
649
|
+
...config.legal_attestation,
|
|
650
|
+
dpo_identifier: config.legal_attestation.dpo_identifier.trim(),
|
|
651
|
+
configuration_version: config.legal_attestation.configuration_version.trim(),
|
|
652
|
+
legal_review_date: config.legal_attestation.legal_review_date,
|
|
653
|
+
acknowledgment: config.legal_attestation.acknowledgment.trim(),
|
|
654
|
+
schema_hash: config.legal_attestation.schema_hash?.toLowerCase(),
|
|
655
|
+
generated_by: config.legal_attestation.generated_by?.trim(),
|
|
656
|
+
},
|
|
657
|
+
};
|
|
658
|
+
}
|