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,395 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { readWorkerConfig } from "@modules/config";
|
|
6
|
+
|
|
7
|
+
const masterKeyHex = "42".repeat(32);
|
|
8
|
+
const hmacKeyBase64 = Buffer.from(new Uint8Array(32).fill(0x24)).toString("base64");
|
|
9
|
+
|
|
10
|
+
async function writeYaml(contents: string): Promise<string> {
|
|
11
|
+
const directory = await mkdtemp(join(tmpdir(), "worker-config-"));
|
|
12
|
+
const path = join(directory, "compliance.worker.yml");
|
|
13
|
+
await writeFile(path, contents, "utf8");
|
|
14
|
+
return path;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function removeYaml(path: string) {
|
|
18
|
+
await rm(path, { force: true });
|
|
19
|
+
await rm(dirname(path), { recursive: true, force: true });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("Worker configuration", () => {
|
|
23
|
+
const pathsToDelete: string[] = [];
|
|
24
|
+
|
|
25
|
+
afterEach(async () => {
|
|
26
|
+
for (const path of pathsToDelete.splice(0, pathsToDelete.length)) {
|
|
27
|
+
await removeYaml(path);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("parses strict YAML config with strongly typed graph and satellite definitions", async () => {
|
|
32
|
+
const path = await writeYaml(`
|
|
33
|
+
version: "1.0"
|
|
34
|
+
database:
|
|
35
|
+
app_schema: tenant_app
|
|
36
|
+
engine_schema: tenant_engine
|
|
37
|
+
replica_db_url: postgres://replica:replica@replica-host:5432/postgres
|
|
38
|
+
compliance_policy:
|
|
39
|
+
default_retention_years: 0
|
|
40
|
+
notice_window_hours: 72
|
|
41
|
+
retention_rules:
|
|
42
|
+
- rule_name: PMLA_FINANCIAL
|
|
43
|
+
legal_citation: "Prevention of Money Laundering Act, 2002, Sec 12"
|
|
44
|
+
if_has_data_in:
|
|
45
|
+
- transactions
|
|
46
|
+
retention_years: 10
|
|
47
|
+
- rule_name: RBI_KYC
|
|
48
|
+
legal_citation: "RBI KYC Directions, 2016, Sec 38"
|
|
49
|
+
if_has_data_in:
|
|
50
|
+
- kyc_documents
|
|
51
|
+
retention_years: 5
|
|
52
|
+
graph:
|
|
53
|
+
root_table: users
|
|
54
|
+
root_id_column: id
|
|
55
|
+
max_depth: 32
|
|
56
|
+
root_pii_columns:
|
|
57
|
+
email: HMAC
|
|
58
|
+
full_name: STATIC_MASK
|
|
59
|
+
satellite_targets:
|
|
60
|
+
- table: marketing_leads
|
|
61
|
+
lookup_column: email
|
|
62
|
+
action: redact
|
|
63
|
+
masking_rules:
|
|
64
|
+
email: HMAC
|
|
65
|
+
- table: audit_logs
|
|
66
|
+
lookup_column: user_identifier
|
|
67
|
+
action: hard_delete
|
|
68
|
+
blob_targets:
|
|
69
|
+
- table: users
|
|
70
|
+
column: kyc_document_url
|
|
71
|
+
provider: aws_s3
|
|
72
|
+
region: ap-south-1
|
|
73
|
+
action: versioned_hard_delete
|
|
74
|
+
retention_mode: governance
|
|
75
|
+
expected_bucket_owner: "123456789012"
|
|
76
|
+
purge_policy:
|
|
77
|
+
enabled: true
|
|
78
|
+
selector:
|
|
79
|
+
kind: boolean_column
|
|
80
|
+
column: purge_eligible
|
|
81
|
+
value: true
|
|
82
|
+
max_batch_size: 50000
|
|
83
|
+
actor_opaque_id: system:dpo-purge
|
|
84
|
+
legal_framework: DPDP_2023
|
|
85
|
+
legal_citation: "DPDP Act, 2023 Sec 12; client-approved purge schedule"
|
|
86
|
+
outbox:
|
|
87
|
+
batch_size: 20
|
|
88
|
+
lease_seconds: 90
|
|
89
|
+
max_attempts: 12
|
|
90
|
+
base_backoff_ms: 1500
|
|
91
|
+
security:
|
|
92
|
+
notification_lease_seconds: 180
|
|
93
|
+
master_key_env: DPDP_MASTER_KEY
|
|
94
|
+
hmac_key_env: DPDP_HMAC_KEY
|
|
95
|
+
integrity:
|
|
96
|
+
expected_schema_hash: "${"1".repeat(64)}"
|
|
97
|
+
legal_attestation:
|
|
98
|
+
dpo_identifier: "dpo-name@client.com"
|
|
99
|
+
configuration_version: "v1.2.0"
|
|
100
|
+
legal_review_date: "2026-04-20"
|
|
101
|
+
acknowledgment: "I confirm this configuration accurately reflects our obligations."
|
|
102
|
+
`);
|
|
103
|
+
pathsToDelete.push(path);
|
|
104
|
+
|
|
105
|
+
const config = await readWorkerConfig(
|
|
106
|
+
{
|
|
107
|
+
DPDP_MASTER_KEY: masterKeyHex,
|
|
108
|
+
DPDP_HMAC_KEY: `base64:${hmacKeyBase64}`,
|
|
109
|
+
},
|
|
110
|
+
path
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
expect(config.database.app_schema).toBe("tenant_app");
|
|
114
|
+
expect(config.database.engine_schema).toBe("tenant_engine");
|
|
115
|
+
expect(config.database.replica_db_url).toBe("postgres://replica:replica@replica-host:5432/postgres");
|
|
116
|
+
expect(config.compliance_policy.default_retention_years).toBe(0);
|
|
117
|
+
expect(config.compliance_policy.notice_window_hours).toBe(72);
|
|
118
|
+
expect(config.compliance_policy.retention_rules).toEqual([
|
|
119
|
+
{
|
|
120
|
+
rule_name: "PMLA_FINANCIAL",
|
|
121
|
+
legal_citation: "Prevention of Money Laundering Act, 2002, Sec 12",
|
|
122
|
+
if_has_data_in: ["transactions"],
|
|
123
|
+
retention_years: 10,
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
rule_name: "RBI_KYC",
|
|
127
|
+
legal_citation: "RBI KYC Directions, 2016, Sec 38",
|
|
128
|
+
if_has_data_in: ["kyc_documents"],
|
|
129
|
+
retention_years: 5,
|
|
130
|
+
},
|
|
131
|
+
]);
|
|
132
|
+
expect(config.graph.root_table).toBe("users");
|
|
133
|
+
expect(config.graph.root_id_column).toBe("id");
|
|
134
|
+
expect(config.graph.root_pii_columns).toEqual({
|
|
135
|
+
email: "HMAC",
|
|
136
|
+
full_name: "STATIC_MASK",
|
|
137
|
+
});
|
|
138
|
+
expect(config.satellite_targets).toHaveLength(2);
|
|
139
|
+
expect(config.blob_targets).toEqual([
|
|
140
|
+
{
|
|
141
|
+
table: "users",
|
|
142
|
+
column: "kyc_document_url",
|
|
143
|
+
lookup_column: undefined,
|
|
144
|
+
provider: "aws_s3",
|
|
145
|
+
region: "ap-south-1",
|
|
146
|
+
action: "versioned_hard_delete",
|
|
147
|
+
retention_mode: "governance",
|
|
148
|
+
expected_bucket_owner: "123456789012",
|
|
149
|
+
require_version_id: true,
|
|
150
|
+
masking_blob_path: undefined,
|
|
151
|
+
},
|
|
152
|
+
]);
|
|
153
|
+
expect(config.purge_policy).toEqual({
|
|
154
|
+
enabled: true,
|
|
155
|
+
selector: {
|
|
156
|
+
kind: "boolean_column",
|
|
157
|
+
column: "purge_eligible",
|
|
158
|
+
value: true,
|
|
159
|
+
},
|
|
160
|
+
max_batch_size: 50000,
|
|
161
|
+
actor_opaque_id: "system:dpo-purge",
|
|
162
|
+
legal_framework: "DPDP_2023",
|
|
163
|
+
legal_citation: "DPDP Act, 2023 Sec 12; client-approved purge schedule",
|
|
164
|
+
});
|
|
165
|
+
expect(config.outbox.batch_size).toBe(20);
|
|
166
|
+
expect(config.security.notification_lease_seconds).toBe(180);
|
|
167
|
+
expect(config.legal_attestation).toEqual({
|
|
168
|
+
dpo_identifier: "dpo-name@client.com",
|
|
169
|
+
configuration_version: "v1.2.0",
|
|
170
|
+
legal_review_date: "2026-04-20",
|
|
171
|
+
acknowledgment: "I confirm this configuration accurately reflects our obligations.",
|
|
172
|
+
});
|
|
173
|
+
expect(Buffer.from(config.masterKey).toString("hex")).toBe(masterKeyHex);
|
|
174
|
+
expect(Buffer.from(config.hmacKey).toString("base64")).toBe(hmacKeyBase64);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("fails closed when required compliance fields are null", async () => {
|
|
178
|
+
const path = await writeYaml(`
|
|
179
|
+
version: "1.0"
|
|
180
|
+
database:
|
|
181
|
+
app_schema: tenant_app
|
|
182
|
+
engine_schema: tenant_engine
|
|
183
|
+
compliance_policy:
|
|
184
|
+
default_retention_years: null
|
|
185
|
+
notice_window_hours: 48
|
|
186
|
+
retention_rules:
|
|
187
|
+
- rule_name: RBI_KYC
|
|
188
|
+
legal_citation: "RBI KYC Directions, 2016, Sec 38"
|
|
189
|
+
if_has_data_in:
|
|
190
|
+
- kyc_documents
|
|
191
|
+
retention_years: 5
|
|
192
|
+
graph:
|
|
193
|
+
root_table: users
|
|
194
|
+
root_id_column: id
|
|
195
|
+
max_depth: 32
|
|
196
|
+
root_pii_columns:
|
|
197
|
+
email: HMAC
|
|
198
|
+
satellite_targets:
|
|
199
|
+
- table: marketing_leads
|
|
200
|
+
lookup_column: email
|
|
201
|
+
action: redact
|
|
202
|
+
masking_rules:
|
|
203
|
+
email: HMAC
|
|
204
|
+
outbox:
|
|
205
|
+
batch_size: 10
|
|
206
|
+
lease_seconds: 60
|
|
207
|
+
max_attempts: 10
|
|
208
|
+
base_backoff_ms: 1000
|
|
209
|
+
security:
|
|
210
|
+
notification_lease_seconds: 120
|
|
211
|
+
master_key_env: DPDP_MASTER_KEY
|
|
212
|
+
hmac_key_env: DPDP_HMAC_KEY
|
|
213
|
+
integrity:
|
|
214
|
+
expected_schema_hash: "${"1".repeat(64)}"
|
|
215
|
+
legal_attestation:
|
|
216
|
+
dpo_identifier: "dpo-name@client.com"
|
|
217
|
+
configuration_version: "v1.2.0"
|
|
218
|
+
legal_review_date: "2026-04-20"
|
|
219
|
+
acknowledgment: "I confirm this configuration accurately reflects our obligations."
|
|
220
|
+
`);
|
|
221
|
+
pathsToDelete.push(path);
|
|
222
|
+
|
|
223
|
+
await expect(
|
|
224
|
+
readWorkerConfig(
|
|
225
|
+
{
|
|
226
|
+
DPDP_MASTER_KEY: masterKeyHex,
|
|
227
|
+
DPDP_HMAC_KEY: `base64:${hmacKeyBase64}`,
|
|
228
|
+
},
|
|
229
|
+
path
|
|
230
|
+
)
|
|
231
|
+
).rejects.toThrow(/default_retention_years/i);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("rejects malicious identifier injection in root_table", async () => {
|
|
235
|
+
const path = await writeYaml(`
|
|
236
|
+
version: "1.0"
|
|
237
|
+
database:
|
|
238
|
+
app_schema: tenant_app
|
|
239
|
+
engine_schema: tenant_engine
|
|
240
|
+
compliance_policy:
|
|
241
|
+
default_retention_years: 0
|
|
242
|
+
notice_window_hours: 48
|
|
243
|
+
retention_rules:
|
|
244
|
+
- rule_name: RBI_KYC
|
|
245
|
+
legal_citation: "RBI KYC Directions, 2016, Sec 38"
|
|
246
|
+
if_has_data_in:
|
|
247
|
+
- kyc_documents
|
|
248
|
+
retention_years: 5
|
|
249
|
+
graph:
|
|
250
|
+
root_table: "users; DROP TABLE clients;--"
|
|
251
|
+
root_id_column: id
|
|
252
|
+
max_depth: 32
|
|
253
|
+
root_pii_columns:
|
|
254
|
+
email: HMAC
|
|
255
|
+
satellite_targets:
|
|
256
|
+
- table: marketing_leads
|
|
257
|
+
lookup_column: email
|
|
258
|
+
action: redact
|
|
259
|
+
masking_rules:
|
|
260
|
+
email: HMAC
|
|
261
|
+
outbox:
|
|
262
|
+
batch_size: 10
|
|
263
|
+
lease_seconds: 60
|
|
264
|
+
max_attempts: 10
|
|
265
|
+
base_backoff_ms: 1000
|
|
266
|
+
security:
|
|
267
|
+
notification_lease_seconds: 120
|
|
268
|
+
master_key_env: DPDP_MASTER_KEY
|
|
269
|
+
hmac_key_env: DPDP_HMAC_KEY
|
|
270
|
+
integrity:
|
|
271
|
+
expected_schema_hash: "${"1".repeat(64)}"
|
|
272
|
+
legal_attestation:
|
|
273
|
+
dpo_identifier: "dpo-name@client.com"
|
|
274
|
+
configuration_version: "v1.2.0"
|
|
275
|
+
legal_review_date: "2026-04-20"
|
|
276
|
+
acknowledgment: "I confirm this configuration accurately reflects our obligations."
|
|
277
|
+
`);
|
|
278
|
+
pathsToDelete.push(path);
|
|
279
|
+
|
|
280
|
+
await expect(
|
|
281
|
+
readWorkerConfig(
|
|
282
|
+
{
|
|
283
|
+
DPDP_MASTER_KEY: masterKeyHex,
|
|
284
|
+
DPDP_HMAC_KEY: `base64:${hmacKeyBase64}`,
|
|
285
|
+
},
|
|
286
|
+
path
|
|
287
|
+
)
|
|
288
|
+
).rejects.toThrow(/invalid graph root table/i);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("fails closed when legal_attestation is missing", async () => {
|
|
292
|
+
const path = await writeYaml(`
|
|
293
|
+
version: "1.0"
|
|
294
|
+
database:
|
|
295
|
+
app_schema: tenant_app
|
|
296
|
+
engine_schema: tenant_engine
|
|
297
|
+
compliance_policy:
|
|
298
|
+
default_retention_years: 0
|
|
299
|
+
notice_window_hours: 48
|
|
300
|
+
retention_rules:
|
|
301
|
+
- rule_name: RBI_KYC
|
|
302
|
+
legal_citation: "RBI KYC Directions, 2016, Sec 38"
|
|
303
|
+
if_has_data_in:
|
|
304
|
+
- kyc_documents
|
|
305
|
+
retention_years: 5
|
|
306
|
+
graph:
|
|
307
|
+
root_table: users
|
|
308
|
+
root_id_column: id
|
|
309
|
+
max_depth: 32
|
|
310
|
+
root_pii_columns:
|
|
311
|
+
email: HMAC
|
|
312
|
+
satellite_targets:
|
|
313
|
+
- table: marketing_leads
|
|
314
|
+
lookup_column: email
|
|
315
|
+
action: redact
|
|
316
|
+
masking_rules:
|
|
317
|
+
email: HMAC
|
|
318
|
+
outbox:
|
|
319
|
+
batch_size: 10
|
|
320
|
+
lease_seconds: 60
|
|
321
|
+
max_attempts: 10
|
|
322
|
+
base_backoff_ms: 1000
|
|
323
|
+
security:
|
|
324
|
+
notification_lease_seconds: 120
|
|
325
|
+
master_key_env: DPDP_MASTER_KEY
|
|
326
|
+
hmac_key_env: DPDP_HMAC_KEY
|
|
327
|
+
integrity:
|
|
328
|
+
expected_schema_hash: "${"1".repeat(64)}"
|
|
329
|
+
`);
|
|
330
|
+
pathsToDelete.push(path);
|
|
331
|
+
|
|
332
|
+
await expect(
|
|
333
|
+
readWorkerConfig(
|
|
334
|
+
{
|
|
335
|
+
DPDP_MASTER_KEY: masterKeyHex,
|
|
336
|
+
DPDP_HMAC_KEY: `base64:${hmacKeyBase64}`,
|
|
337
|
+
},
|
|
338
|
+
path
|
|
339
|
+
)
|
|
340
|
+
).rejects.toThrow(/legal_attestation/i);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("fails closed when purge automation is enabled without an attested selector", async () => {
|
|
344
|
+
const path = await writeYaml(`
|
|
345
|
+
version: "1.0"
|
|
346
|
+
database:
|
|
347
|
+
app_schema: tenant_app
|
|
348
|
+
engine_schema: tenant_engine
|
|
349
|
+
compliance_policy:
|
|
350
|
+
default_retention_years: 0
|
|
351
|
+
notice_window_hours: 48
|
|
352
|
+
retention_rules:
|
|
353
|
+
- rule_name: RBI_KYC
|
|
354
|
+
legal_citation: "RBI KYC Directions, 2016, Sec 38"
|
|
355
|
+
if_has_data_in:
|
|
356
|
+
- kyc_documents
|
|
357
|
+
retention_years: 5
|
|
358
|
+
graph:
|
|
359
|
+
root_table: users
|
|
360
|
+
root_id_column: id
|
|
361
|
+
max_depth: 32
|
|
362
|
+
root_pii_columns:
|
|
363
|
+
email: HMAC
|
|
364
|
+
purge_policy:
|
|
365
|
+
enabled: true
|
|
366
|
+
outbox:
|
|
367
|
+
batch_size: 10
|
|
368
|
+
lease_seconds: 60
|
|
369
|
+
max_attempts: 10
|
|
370
|
+
base_backoff_ms: 1000
|
|
371
|
+
security:
|
|
372
|
+
notification_lease_seconds: 120
|
|
373
|
+
master_key_env: DPDP_MASTER_KEY
|
|
374
|
+
hmac_key_env: DPDP_HMAC_KEY
|
|
375
|
+
integrity:
|
|
376
|
+
expected_schema_hash: "${"1".repeat(64)}"
|
|
377
|
+
legal_attestation:
|
|
378
|
+
dpo_identifier: "dpo-name@client.com"
|
|
379
|
+
configuration_version: "v1.2.0"
|
|
380
|
+
legal_review_date: "2026-04-20"
|
|
381
|
+
acknowledgment: "I confirm this configuration accurately reflects our obligations."
|
|
382
|
+
`);
|
|
383
|
+
pathsToDelete.push(path);
|
|
384
|
+
|
|
385
|
+
await expect(
|
|
386
|
+
readWorkerConfig(
|
|
387
|
+
{
|
|
388
|
+
DPDP_MASTER_KEY: masterKeyHex,
|
|
389
|
+
DPDP_HMAC_KEY: `base64:${hmacKeyBase64}`,
|
|
390
|
+
},
|
|
391
|
+
path
|
|
392
|
+
)
|
|
393
|
+
).rejects.toThrow(/purge_policy\.selector/i);
|
|
394
|
+
});
|
|
395
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createControlPlaneApiClient } from "@modules/network";
|
|
3
|
+
|
|
4
|
+
describe("Control Plane API client", () => {
|
|
5
|
+
it("accepts offset-form ISO timestamps in worker sync payloads", async () => {
|
|
6
|
+
const fetchMock = vi.fn(async () => ({
|
|
7
|
+
ok: true,
|
|
8
|
+
status: 200,
|
|
9
|
+
json: async () => ({
|
|
10
|
+
pending: true,
|
|
11
|
+
task: {
|
|
12
|
+
id: "task-notify-1",
|
|
13
|
+
task_type: "NOTIFY_USER",
|
|
14
|
+
payload: {
|
|
15
|
+
request_id: "01ce9849-189c-4c3d-ab91-b35eff852b9f",
|
|
16
|
+
subject_opaque_id: "usr_local_zero",
|
|
17
|
+
idempotency_key: "9943912a-1897-4860-ad9c-d32e9b3c2876",
|
|
18
|
+
trigger_source: "USER_CONSENT_WITHDRAWAL",
|
|
19
|
+
actor_opaque_id: "usr_local_zero",
|
|
20
|
+
legal_framework: "DPDP_2023",
|
|
21
|
+
request_timestamp: "2026-04-20T14:49:04.477+00:00",
|
|
22
|
+
cooldown_days: 0,
|
|
23
|
+
shadow_mode: false,
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
}),
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
vi.spyOn(globalThis, "fetch").mockImplementation(fetchMock as any);
|
|
30
|
+
|
|
31
|
+
const client = createControlPlaneApiClient({
|
|
32
|
+
syncUrl: "https://control-plane.example/api/v1/worker/sync",
|
|
33
|
+
ackBaseUrl: "https://control-plane.example/api/v1/worker/tasks",
|
|
34
|
+
workerAuthHeaders: {
|
|
35
|
+
"x-client-id": "worker-1",
|
|
36
|
+
authorization: "Bearer worker-secret",
|
|
37
|
+
},
|
|
38
|
+
workerConfigHash: "ab".repeat(32),
|
|
39
|
+
workerConfigVersion: "v-test",
|
|
40
|
+
workerDpoIdentifier: "dpo@example.com",
|
|
41
|
+
pushOutboxEvent: async () => true,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const response = await client.syncTask();
|
|
45
|
+
expect(response).toEqual({
|
|
46
|
+
pending: true,
|
|
47
|
+
task: {
|
|
48
|
+
id: "task-notify-1",
|
|
49
|
+
task_type: "NOTIFY_USER",
|
|
50
|
+
payload: {
|
|
51
|
+
request_id: "01ce9849-189c-4c3d-ab91-b35eff852b9f",
|
|
52
|
+
subject_opaque_id: "usr_local_zero",
|
|
53
|
+
idempotency_key: "9943912a-1897-4860-ad9c-d32e9b3c2876",
|
|
54
|
+
trigger_source: "USER_CONSENT_WITHDRAWAL",
|
|
55
|
+
actor_opaque_id: "usr_local_zero",
|
|
56
|
+
legal_framework: "DPDP_2023",
|
|
57
|
+
request_timestamp: "2026-04-20T14:49:04.477+00:00",
|
|
58
|
+
cooldown_days: 0,
|
|
59
|
+
shadow_mode: false,
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
expect(globalThis.fetch).toHaveBeenCalledWith(
|
|
65
|
+
"https://control-plane.example/api/v1/worker/sync",
|
|
66
|
+
expect.objectContaining({
|
|
67
|
+
headers: expect.objectContaining({
|
|
68
|
+
"x-worker-config-hash": "ab".repeat(32),
|
|
69
|
+
"x-worker-config-version": "v-test",
|
|
70
|
+
"x-worker-dpo-identifier": "dpo@example.com",
|
|
71
|
+
}),
|
|
72
|
+
})
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("sends authenticated task heartbeat requests to extend long-running leases", async () => {
|
|
77
|
+
const fetchMock = vi.fn(async () => ({
|
|
78
|
+
ok: true,
|
|
79
|
+
status: 200,
|
|
80
|
+
json: async () => ({ ok: true }),
|
|
81
|
+
}));
|
|
82
|
+
|
|
83
|
+
vi.spyOn(globalThis, "fetch").mockImplementation(fetchMock as any);
|
|
84
|
+
|
|
85
|
+
const client = createControlPlaneApiClient({
|
|
86
|
+
syncUrl: "https://control-plane.example/api/v1/worker/sync",
|
|
87
|
+
ackBaseUrl: "https://control-plane.example/api/v1/worker/tasks",
|
|
88
|
+
workerAuthHeaders: {
|
|
89
|
+
"x-client-id": "worker-1",
|
|
90
|
+
authorization: "Bearer worker-secret",
|
|
91
|
+
},
|
|
92
|
+
workerConfigHash: "ab".repeat(32),
|
|
93
|
+
pushOutboxEvent: async () => true,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
await expect(client.heartbeatTask?.("task-long-1")).resolves.toBe(true);
|
|
97
|
+
expect(globalThis.fetch).toHaveBeenCalledWith(
|
|
98
|
+
"https://control-plane.example/api/v1/worker/tasks/task-long-1/heartbeat",
|
|
99
|
+
expect.objectContaining({
|
|
100
|
+
method: "POST",
|
|
101
|
+
headers: expect.objectContaining({
|
|
102
|
+
"x-client-id": "worker-1",
|
|
103
|
+
authorization: "Bearer worker-secret",
|
|
104
|
+
}),
|
|
105
|
+
})
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { decryptGCM, decryptGCMBytes, encryptGCM, encryptGCMBytes, generateDEK, generateHMAC, unwrapKey, wrapKey } from "@modules/crypto";
|
|
2
|
+
import { describe, it, expect } from "vitest";
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
describe("Cryptographic Core (AES-256-GCM + Envelope + HMAC)", () => {
|
|
6
|
+
const KEK = new Uint8Array(32).fill(0x42); // Dummy Master Key
|
|
7
|
+
const rawPII = "User Email: john.doe@example.com, Phone: +91 9876543210";
|
|
8
|
+
|
|
9
|
+
describe("AES-256-GCM & Envelope Encryption", () => {
|
|
10
|
+
it("should successfully encrypt and decrypt PII using a unique DEK", async () => {
|
|
11
|
+
const userDEK = generateDEK();
|
|
12
|
+
|
|
13
|
+
// 1. Encrypt
|
|
14
|
+
const encryptedPII = await encryptGCM(rawPII, userDEK);
|
|
15
|
+
expect(encryptedPII.length).toBeGreaterThan(12 + 16); // IV (12) + Tag (16) + min 1 byte data
|
|
16
|
+
|
|
17
|
+
// 2. Decrypt
|
|
18
|
+
const decryptedPII = await decryptGCM(encryptedPII, userDEK);
|
|
19
|
+
expect(decryptedPII).toBe(rawPII);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("should expose decrypted bytes for callers that need explicit memory wiping", async () => {
|
|
23
|
+
const userDEK = generateDEK();
|
|
24
|
+
const encryptedPII = await encryptGCM(rawPII, userDEK);
|
|
25
|
+
|
|
26
|
+
const decryptedBytes = await decryptGCMBytes(encryptedPII, userDEK);
|
|
27
|
+
expect(new TextDecoder().decode(decryptedBytes)).toBe(rawPII);
|
|
28
|
+
|
|
29
|
+
decryptedBytes.fill(0);
|
|
30
|
+
expect(Array.from(decryptedBytes).every((byte) => byte === 0)).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("should successfully wrap and unwrap a DEK using a Master KEK", async () => {
|
|
34
|
+
const originalDEK = generateDEK();
|
|
35
|
+
|
|
36
|
+
// 1. Wrap
|
|
37
|
+
const wrappedDEK = await wrapKey(originalDEK, KEK);
|
|
38
|
+
|
|
39
|
+
// 2. Unwrap
|
|
40
|
+
const recoveredDEK = await unwrapKey(wrappedDEK, KEK);
|
|
41
|
+
|
|
42
|
+
expect(recoveredDEK).toEqual(originalDEK);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("should fail decryption if the wrong KEK is used", async () => {
|
|
46
|
+
const originalDEK = generateDEK();
|
|
47
|
+
const wrappedDEK = await wrapKey(originalDEK, KEK);
|
|
48
|
+
const wrongKEK = new Uint8Array(32).fill(0x99);
|
|
49
|
+
|
|
50
|
+
await expect(unwrapKey(wrappedDEK, wrongKEK)).rejects.toThrow();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("should fail decryption if the ciphertext is tampered with", async () => {
|
|
54
|
+
const userDEK = generateDEK();
|
|
55
|
+
const encryptedPII = await encryptGCM(rawPII, userDEK);
|
|
56
|
+
|
|
57
|
+
// Tamper with one byte in the middle of the ciphertext
|
|
58
|
+
if (encryptedPII[20] !== undefined) {
|
|
59
|
+
encryptedPII[20] ^= 0xFF;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
await expect(decryptGCM(encryptedPII, userDEK)).rejects.toThrow();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("should throw an error if encryptGCM is given an invalid key length", async () => {
|
|
66
|
+
const invalidKey = new Uint8Array(16); // 128-bit instead of 256-bit
|
|
67
|
+
await expect(encryptGCM(rawPII, invalidKey)).rejects.toThrow(/Invalid key length/);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("should throw an error if decryptGCM is given an invalid key length", async () => {
|
|
71
|
+
const userDEK = generateDEK();
|
|
72
|
+
const encryptedPII = await encryptGCM(rawPII, userDEK);
|
|
73
|
+
const invalidKey = new Uint8Array(16);
|
|
74
|
+
await expect(decryptGCM(encryptedPII, invalidKey)).rejects.toThrow(/Invalid key length/);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("should throw an error if decryptGCM is given a payload too short to be valid", async () => {
|
|
78
|
+
const userDEK = generateDEK();
|
|
79
|
+
const shortCiphertext = new Uint8Array(10); // Less than IV + Tag length
|
|
80
|
+
await expect(decryptGCM(shortCiphertext, userDEK)).rejects.toThrow(/Invalid ciphertext/);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("HMAC Pseudonymization", () => {
|
|
85
|
+
it("should generate a consistent hash for the same input and salt", async () => {
|
|
86
|
+
const salt = "somesalt123";
|
|
87
|
+
const hash1 = await generateHMAC(rawPII, salt);
|
|
88
|
+
const hash2 = await generateHMAC(rawPII, salt);
|
|
89
|
+
expect(hash1).toBe(hash2);
|
|
90
|
+
expect(hash1).toHaveLength(64); // SHA-256 hex length
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("should generate a different hash for the same input but different salt", async () => {
|
|
94
|
+
const hash1 = await generateHMAC(rawPII, "saltA");
|
|
95
|
+
const hash2 = await generateHMAC(rawPII, "saltB");
|
|
96
|
+
expect(hash1).not.toBe(hash2);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("should generate a different hash for different inputs with the same salt", async () => {
|
|
100
|
+
const salt = "somesalt123";
|
|
101
|
+
const hash1 = await generateHMAC("user1@example.com", salt);
|
|
102
|
+
const hash2 = await generateHMAC("user2@example.com", salt);
|
|
103
|
+
expect(hash1).not.toBe(hash2);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { asWorkerError, serializeWorkerError, workerError } from "@/errors";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { ZodError, z } from "zod";
|
|
4
|
+
|
|
5
|
+
describe("WorkerError normalization", () => {
|
|
6
|
+
it("preserves explicit worker error metadata in RFC-9457-style problem details", () => {
|
|
7
|
+
const error = workerError({
|
|
8
|
+
code: "TEST_EXPLICIT",
|
|
9
|
+
title: "Explicit worker error",
|
|
10
|
+
detail: "The worker emitted a classified error.",
|
|
11
|
+
category: "internal",
|
|
12
|
+
retryable: false,
|
|
13
|
+
fatal: true,
|
|
14
|
+
context: { component: "test" },
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
expect(serializeWorkerError(error, "worker:test")).toEqual({
|
|
18
|
+
type: "urn:dpdp:worker:error:test_explicit",
|
|
19
|
+
title: "Explicit worker error",
|
|
20
|
+
detail: "The worker emitted a classified error.",
|
|
21
|
+
code: "TEST_EXPLICIT",
|
|
22
|
+
category: "internal",
|
|
23
|
+
retryable: false,
|
|
24
|
+
fatal: true,
|
|
25
|
+
instance: "worker:test",
|
|
26
|
+
context: { component: "test" },
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("classifies transient postgres failures as retryable concurrency/database errors", () => {
|
|
31
|
+
const postgresError = Object.assign(new Error("deadlock detected"), {
|
|
32
|
+
code: "40P01",
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const normalized = asWorkerError(postgresError);
|
|
36
|
+
|
|
37
|
+
expect(normalized.code).toBe("DB_DEADLOCK_DETECTED");
|
|
38
|
+
expect(normalized.category).toBe("concurrency");
|
|
39
|
+
expect(normalized.retryable).toBe(true);
|
|
40
|
+
expect(normalized.fatal).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("converts zod validation failures into structured validation errors", () => {
|
|
44
|
+
let validationError: ZodError | null = null;
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
z.object({ default_retention_years: z.number().int().min(0) }).parse({
|
|
48
|
+
default_retention_years: null,
|
|
49
|
+
});
|
|
50
|
+
} catch (error) {
|
|
51
|
+
validationError = error as ZodError;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const normalized = asWorkerError(validationError);
|
|
55
|
+
|
|
56
|
+
expect(normalized.code).toBe("VALIDATION_FAILED");
|
|
57
|
+
expect(normalized.category).toBe("validation");
|
|
58
|
+
expect(normalized.detail).toContain("default_retention_years");
|
|
59
|
+
expect(normalized.toProblem("worker:test").issues).toEqual([
|
|
60
|
+
{
|
|
61
|
+
path: "default_retention_years",
|
|
62
|
+
param: "default_retention_years",
|
|
63
|
+
code: "invalid_type",
|
|
64
|
+
message: "Invalid input: expected number, received null",
|
|
65
|
+
},
|
|
66
|
+
]);
|
|
67
|
+
expect(normalized.retryable).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
});
|