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,110 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createS3Client, parseS3ObjectUrl } from "@modules/network";
|
|
3
|
+
|
|
4
|
+
describe("S3 client", () => {
|
|
5
|
+
it("parses S3 URLs without losing version ids", () => {
|
|
6
|
+
expect(parseS3ObjectUrl("s3://kyc-bucket/kyc/john-doe-aadhar.pdf?versionId=v123")).toEqual({
|
|
7
|
+
bucket: "kyc-bucket",
|
|
8
|
+
key: "kyc/john-doe-aadhar.pdf",
|
|
9
|
+
versionId: "v123",
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
expect(parseS3ObjectUrl("https://kyc-bucket.s3.ap-south-1.amazonaws.com/kyc/doc.pdf?versionId=v9")).toEqual({
|
|
13
|
+
bucket: "kyc-bucket",
|
|
14
|
+
key: "kyc/doc.pdf",
|
|
15
|
+
versionId: "v9",
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("signs native S3 requests and crawls object versions", async () => {
|
|
20
|
+
const calls: Array<{ url: string; method: string; authorization: string | null }> = [];
|
|
21
|
+
const fetchFn = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
|
|
22
|
+
const url = String(input);
|
|
23
|
+
const headers = new Headers(init?.headers);
|
|
24
|
+
calls.push({
|
|
25
|
+
url,
|
|
26
|
+
method: init?.method ?? "GET",
|
|
27
|
+
authorization: headers.get("authorization"),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
if (url.includes("169.254.170.2")) {
|
|
31
|
+
return new Response(JSON.stringify({
|
|
32
|
+
AccessKeyId: "AKIATEST",
|
|
33
|
+
SecretAccessKey: "secret",
|
|
34
|
+
Token: "session",
|
|
35
|
+
}), { status: 200 });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (url.includes("versions")) {
|
|
39
|
+
return new Response(`<?xml version="1.0" encoding="UTF-8"?>
|
|
40
|
+
<ListVersionsResult>
|
|
41
|
+
<IsTruncated>false</IsTruncated>
|
|
42
|
+
<Version>
|
|
43
|
+
<Key>kyc/doc.pdf</Key>
|
|
44
|
+
<VersionId>v1</VersionId>
|
|
45
|
+
<ETag>"etag-1"</ETag>
|
|
46
|
+
</Version>
|
|
47
|
+
<DeleteMarker>
|
|
48
|
+
<Key>kyc/doc.pdf</Key>
|
|
49
|
+
<VersionId>marker-1</VersionId>
|
|
50
|
+
</DeleteMarker>
|
|
51
|
+
</ListVersionsResult>`, { status: 200 });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return new Response(null, {
|
|
55
|
+
status: 200,
|
|
56
|
+
headers: {
|
|
57
|
+
"x-amz-version-id": "v1",
|
|
58
|
+
etag: "\"etag-1\"",
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const client = createS3Client({
|
|
64
|
+
env: {
|
|
65
|
+
AWS_CONTAINER_CREDENTIALS_RELATIVE_URI: "/v2/credentials/task",
|
|
66
|
+
AWS_EC2_METADATA_DISABLED: "true",
|
|
67
|
+
},
|
|
68
|
+
fetchFn: fetchFn as unknown as typeof fetch,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const head = await client.headObject({
|
|
72
|
+
bucket: "kyc-bucket",
|
|
73
|
+
key: "kyc/doc.pdf",
|
|
74
|
+
region: "ap-south-1",
|
|
75
|
+
});
|
|
76
|
+
const versions = await client.listObjectVersions({
|
|
77
|
+
bucket: "kyc-bucket",
|
|
78
|
+
key: "kyc/doc.pdf",
|
|
79
|
+
region: "ap-south-1",
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
expect(head).toMatchObject({ versionId: "v1", eTag: "etag-1" });
|
|
83
|
+
expect(versions).toEqual([
|
|
84
|
+
{ key: "kyc/doc.pdf", versionId: "v1", eTag: "etag-1", isDeleteMarker: false },
|
|
85
|
+
{ key: "kyc/doc.pdf", versionId: "marker-1", eTag: null, isDeleteMarker: true },
|
|
86
|
+
]);
|
|
87
|
+
expect(calls.some((call) => call.authorization?.startsWith("AWS4-HMAC-SHA256"))).toBe(true);
|
|
88
|
+
expect(calls.some((call) => call.url.includes("169.254.170.2/v2/credentials/task"))).toBe(true);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("rejects unsafe HTTP container credential endpoints before any S3 request is sent", async () => {
|
|
92
|
+
const fetchFn = vi.fn(async () => new Response(null, { status: 500 }));
|
|
93
|
+
const client = createS3Client({
|
|
94
|
+
env: {
|
|
95
|
+
AWS_CONTAINER_CREDENTIALS_FULL_URI: "http://metadata.evil.local/creds",
|
|
96
|
+
AWS_EC2_METADATA_DISABLED: "true",
|
|
97
|
+
},
|
|
98
|
+
fetchFn: fetchFn as unknown as typeof fetch,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
await expect(client.headObject({
|
|
102
|
+
bucket: "kyc-bucket",
|
|
103
|
+
key: "kyc/doc.pdf",
|
|
104
|
+
region: "ap-south-1",
|
|
105
|
+
})).rejects.toMatchObject({
|
|
106
|
+
code: "AWS_CREDENTIALS_URI_REJECTED",
|
|
107
|
+
});
|
|
108
|
+
expect(fetchFn).not.toHaveBeenCalled();
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { redactSatelliteTable } from "@modules/engine/vault/satellite";
|
|
3
|
+
import { createTestSql, dropSchemas, uniqueSchema } from "./helpers";
|
|
4
|
+
import type { Sql } from "@/types";
|
|
5
|
+
|
|
6
|
+
describe("Satellite Table Chunking", () => {
|
|
7
|
+
let sql: Sql;
|
|
8
|
+
const schemasToDrop: string[] = [];
|
|
9
|
+
|
|
10
|
+
beforeAll(() => {
|
|
11
|
+
sql = createTestSql();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
afterAll(async () => {
|
|
15
|
+
await dropSchemas(sql, ...schemasToDrop);
|
|
16
|
+
await sql.end();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
async function prepare() {
|
|
20
|
+
const schema = uniqueSchema("satellite_app");
|
|
21
|
+
schemasToDrop.push(schema);
|
|
22
|
+
|
|
23
|
+
await dropSchemas(sql, schema);
|
|
24
|
+
await sql`CREATE SCHEMA ${sql(schema)}`;
|
|
25
|
+
await sql`
|
|
26
|
+
CREATE TABLE ${sql(schema)}.orders (
|
|
27
|
+
id SERIAL PRIMARY KEY,
|
|
28
|
+
user_ref TEXT NOT NULL,
|
|
29
|
+
amount NUMERIC NOT NULL
|
|
30
|
+
)
|
|
31
|
+
`;
|
|
32
|
+
|
|
33
|
+
return { schema };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
it("redacts matching satellite rows in batches until exhaustion", async () => {
|
|
37
|
+
const { schema } = await prepare();
|
|
38
|
+
|
|
39
|
+
for (let index = 0; index < 5; index += 1) {
|
|
40
|
+
await sql`
|
|
41
|
+
INSERT INTO ${sql(schema)}.orders (user_ref, amount)
|
|
42
|
+
VALUES ('legacy-user', ${index + 1})
|
|
43
|
+
`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
await sql`
|
|
47
|
+
INSERT INTO ${sql(schema)}.orders (user_ref, amount)
|
|
48
|
+
VALUES ('other-user', 99)
|
|
49
|
+
`;
|
|
50
|
+
|
|
51
|
+
const redacted = await sql.begin((tx) =>
|
|
52
|
+
redactSatelliteTable(tx, `${schema}.orders`, "user_ref", "legacy-user", "hmac-user", 2)
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
expect(redacted).toBe(5);
|
|
56
|
+
|
|
57
|
+
const rows = await sql`
|
|
58
|
+
SELECT user_ref
|
|
59
|
+
FROM ${sql(schema)}.orders
|
|
60
|
+
ORDER BY id ASC
|
|
61
|
+
`;
|
|
62
|
+
|
|
63
|
+
expect(rows.map((row) => row.user_ref)).toEqual([
|
|
64
|
+
"hmac-user",
|
|
65
|
+
"hmac-user",
|
|
66
|
+
"hmac-user",
|
|
67
|
+
"hmac-user",
|
|
68
|
+
"hmac-user",
|
|
69
|
+
"other-user",
|
|
70
|
+
]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("returns zero when no rows match the lookup value", async () => {
|
|
74
|
+
const { schema } = await prepare();
|
|
75
|
+
|
|
76
|
+
const redacted = await sql.begin((tx) =>
|
|
77
|
+
redactSatelliteTable(tx, `${schema}.orders`, "user_ref", "missing-user", "hmac-user", 100)
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
expect(redacted).toBe(0);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("yields to the Bun event loop between satellite batches", async () => {
|
|
84
|
+
const { schema } = await prepare();
|
|
85
|
+
|
|
86
|
+
for (let index = 0; index < 3; index += 1) {
|
|
87
|
+
await sql`
|
|
88
|
+
INSERT INTO ${sql(schema)}.orders (user_ref, amount)
|
|
89
|
+
VALUES ('yield-user', ${index + 1})
|
|
90
|
+
`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const runtime = globalThis as typeof globalThis & {
|
|
94
|
+
Bun?: { sleep?: (ms: number) => Promise<void> };
|
|
95
|
+
};
|
|
96
|
+
const originalBun = runtime.Bun;
|
|
97
|
+
const originalSleep = runtime.Bun?.sleep;
|
|
98
|
+
const sleepMock = vi.fn(async () => { });
|
|
99
|
+
runtime.Bun = {
|
|
100
|
+
...(runtime.Bun ?? {}),
|
|
101
|
+
sleep: sleepMock,
|
|
102
|
+
};
|
|
103
|
+
try {
|
|
104
|
+
const redacted = await sql.begin((tx) =>
|
|
105
|
+
redactSatelliteTable(tx, `${schema}.orders`, "user_ref", "yield-user", "hmac-user", 1)
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
expect(redacted).toBe(3);
|
|
109
|
+
expect(sleepMock).toHaveBeenCalledTimes(3);
|
|
110
|
+
expect(sleepMock).toHaveBeenCalledWith(0);
|
|
111
|
+
} finally {
|
|
112
|
+
if (originalBun) {
|
|
113
|
+
runtime.Bun = { ...originalBun, sleep: originalSleep };
|
|
114
|
+
} else {
|
|
115
|
+
Reflect.deleteProperty(runtime, "Bun");
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { afterAll, 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 { assertConfigSchemaCompatibility, readWorkerConfig } from "@modules/config";
|
|
6
|
+
import { createTestSql, dropSchemas, uniqueSchema } from "./helpers";
|
|
7
|
+
import type { Sql } from "@/types";
|
|
8
|
+
|
|
9
|
+
const masterKeyHex = "42".repeat(32);
|
|
10
|
+
const hmacKeyBase64 = Buffer.from(new Uint8Array(32).fill(0x24)).toString("base64");
|
|
11
|
+
|
|
12
|
+
async function writeYaml(contents: string): Promise<string> {
|
|
13
|
+
const directory = await mkdtemp(join(tmpdir(), "worker-compat-"));
|
|
14
|
+
const path = join(directory, "compliance.worker.yml");
|
|
15
|
+
await writeFile(path, contents, "utf8");
|
|
16
|
+
return path;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function removeYaml(path: string) {
|
|
20
|
+
await rm(path, { force: true });
|
|
21
|
+
await rm(dirname(path), { recursive: true, force: true });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("Worker config schema compatibility", () => {
|
|
25
|
+
const sql: Sql = createTestSql();
|
|
26
|
+
const schemasToDrop: string[] = [];
|
|
27
|
+
const pathsToDelete: string[] = [];
|
|
28
|
+
|
|
29
|
+
afterAll(async () => {
|
|
30
|
+
for (const path of pathsToDelete.splice(0, pathsToDelete.length)) {
|
|
31
|
+
await removeYaml(path);
|
|
32
|
+
}
|
|
33
|
+
await dropSchemas(sql, ...schemasToDrop);
|
|
34
|
+
await sql.end();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
async function createCompatibleSchema(schema: string, includeRootLookupColumn: boolean) {
|
|
38
|
+
await dropSchemas(sql, schema);
|
|
39
|
+
await sql`CREATE SCHEMA ${sql(schema)}`;
|
|
40
|
+
await sql`
|
|
41
|
+
CREATE TABLE ${sql(schema)}.users (
|
|
42
|
+
id TEXT PRIMARY KEY,
|
|
43
|
+
${includeRootLookupColumn ? sql`user_identifier TEXT NOT NULL,` : sql``}
|
|
44
|
+
email TEXT NOT NULL,
|
|
45
|
+
full_name TEXT NOT NULL
|
|
46
|
+
)
|
|
47
|
+
`;
|
|
48
|
+
await sql`
|
|
49
|
+
CREATE TABLE ${sql(schema)}.marketing_leads (
|
|
50
|
+
id BIGSERIAL PRIMARY KEY,
|
|
51
|
+
email TEXT NOT NULL,
|
|
52
|
+
name TEXT NOT NULL
|
|
53
|
+
)
|
|
54
|
+
`;
|
|
55
|
+
await sql`
|
|
56
|
+
CREATE TABLE ${sql(schema)}.system_audit_logs (
|
|
57
|
+
id BIGSERIAL PRIMARY KEY,
|
|
58
|
+
user_identifier TEXT NOT NULL,
|
|
59
|
+
message TEXT NOT NULL
|
|
60
|
+
)
|
|
61
|
+
`;
|
|
62
|
+
await sql`
|
|
63
|
+
CREATE TABLE ${sql(schema)}.transactions (
|
|
64
|
+
id TEXT NOT NULL,
|
|
65
|
+
transaction_ref TEXT PRIMARY KEY,
|
|
66
|
+
amount NUMERIC(18,2) NOT NULL
|
|
67
|
+
)
|
|
68
|
+
`;
|
|
69
|
+
await sql`
|
|
70
|
+
CREATE TABLE ${sql(schema)}.invoices (
|
|
71
|
+
id TEXT NOT NULL,
|
|
72
|
+
invoice_ref TEXT PRIMARY KEY,
|
|
73
|
+
total NUMERIC(18,2) NOT NULL
|
|
74
|
+
)
|
|
75
|
+
`;
|
|
76
|
+
await sql`
|
|
77
|
+
CREATE TABLE ${sql(schema)}.kyc_documents (
|
|
78
|
+
id TEXT NOT NULL,
|
|
79
|
+
document_ref TEXT PRIMARY KEY
|
|
80
|
+
)
|
|
81
|
+
`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function loadConfig(appSchema: string, engineSchema: string) {
|
|
85
|
+
const path = await writeYaml(`
|
|
86
|
+
version: "1.0"
|
|
87
|
+
database:
|
|
88
|
+
app_schema: ${appSchema}
|
|
89
|
+
engine_schema: ${engineSchema}
|
|
90
|
+
compliance_policy:
|
|
91
|
+
default_retention_years: 0
|
|
92
|
+
notice_window_hours: 48
|
|
93
|
+
retention_rules:
|
|
94
|
+
- rule_name: PMLA_FINANCIAL
|
|
95
|
+
legal_citation: "Prevention of Money Laundering Act, 2002, Sec 12"
|
|
96
|
+
if_has_data_in:
|
|
97
|
+
- transactions
|
|
98
|
+
- invoices
|
|
99
|
+
retention_years: 10
|
|
100
|
+
- rule_name: RBI_KYC
|
|
101
|
+
legal_citation: "RBI KYC Directions, 2016, Sec 38"
|
|
102
|
+
if_has_data_in:
|
|
103
|
+
- kyc_documents
|
|
104
|
+
retention_years: 5
|
|
105
|
+
graph:
|
|
106
|
+
root_table: users
|
|
107
|
+
root_id_column: id
|
|
108
|
+
max_depth: 32
|
|
109
|
+
notice_email_column: email
|
|
110
|
+
notice_name_column: full_name
|
|
111
|
+
root_pii_columns:
|
|
112
|
+
email: HMAC
|
|
113
|
+
full_name: STATIC_MASK
|
|
114
|
+
satellite_targets:
|
|
115
|
+
- table: marketing_leads
|
|
116
|
+
lookup_column: email
|
|
117
|
+
action: redact
|
|
118
|
+
masking_rules:
|
|
119
|
+
email: HMAC
|
|
120
|
+
name: STATIC_MASK
|
|
121
|
+
- table: system_audit_logs
|
|
122
|
+
lookup_column: user_identifier
|
|
123
|
+
action: hard_delete
|
|
124
|
+
outbox:
|
|
125
|
+
batch_size: 10
|
|
126
|
+
lease_seconds: 60
|
|
127
|
+
max_attempts: 10
|
|
128
|
+
base_backoff_ms: 1000
|
|
129
|
+
security:
|
|
130
|
+
notification_lease_seconds: 120
|
|
131
|
+
master_key_env: DPDP_MASTER_KEY
|
|
132
|
+
hmac_key_env: DPDP_HMAC_KEY
|
|
133
|
+
integrity:
|
|
134
|
+
expected_schema_hash: "${"1".repeat(64)}"
|
|
135
|
+
legal_attestation:
|
|
136
|
+
dpo_identifier: "dpo-name@client.com"
|
|
137
|
+
configuration_version: "v1.2.0"
|
|
138
|
+
legal_review_date: "2026-04-20"
|
|
139
|
+
acknowledgment: "I confirm this configuration accurately reflects our obligations."
|
|
140
|
+
`);
|
|
141
|
+
pathsToDelete.push(path);
|
|
142
|
+
|
|
143
|
+
return await readWorkerConfig(
|
|
144
|
+
{
|
|
145
|
+
DPDP_MASTER_KEY: masterKeyHex,
|
|
146
|
+
DPDP_HMAC_KEY: `base64:${hmacKeyBase64}`,
|
|
147
|
+
},
|
|
148
|
+
path
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
it("accepts a schema that satisfies every configured root, satellite, and evidence reference", async () => {
|
|
153
|
+
const appSchema = uniqueSchema("compat_ok_app");
|
|
154
|
+
const engineSchema = uniqueSchema("compat_ok_engine");
|
|
155
|
+
schemasToDrop.push(appSchema, engineSchema);
|
|
156
|
+
await createCompatibleSchema(appSchema, true);
|
|
157
|
+
|
|
158
|
+
const config = await loadConfig(appSchema, engineSchema);
|
|
159
|
+
await expect(assertConfigSchemaCompatibility(sql, config)).resolves.toBeUndefined();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("fails closed when a satellite lookup column is missing from the root table", async () => {
|
|
163
|
+
const appSchema = uniqueSchema("compat_bad_app");
|
|
164
|
+
const engineSchema = uniqueSchema("compat_bad_engine");
|
|
165
|
+
schemasToDrop.push(appSchema, engineSchema);
|
|
166
|
+
await createCompatibleSchema(appSchema, false);
|
|
167
|
+
|
|
168
|
+
const config = await loadConfig(appSchema, engineSchema);
|
|
169
|
+
await expect(assertConfigSchemaCompatibility(sql, config)).rejects.toThrow(
|
|
170
|
+
new RegExp(`missing root column ${appSchema}\\.users\\.user_identifier`, "i")
|
|
171
|
+
);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("fails closed when enabled purge policy references a missing root column", async () => {
|
|
175
|
+
const appSchema = uniqueSchema("compat_purge_bad_app");
|
|
176
|
+
const engineSchema = uniqueSchema("compat_purge_bad_engine");
|
|
177
|
+
schemasToDrop.push(appSchema, engineSchema);
|
|
178
|
+
await createCompatibleSchema(appSchema, true);
|
|
179
|
+
|
|
180
|
+
const path = await writeYaml(`
|
|
181
|
+
version: "1.0"
|
|
182
|
+
database:
|
|
183
|
+
app_schema: ${appSchema}
|
|
184
|
+
engine_schema: ${engineSchema}
|
|
185
|
+
compliance_policy:
|
|
186
|
+
default_retention_years: 0
|
|
187
|
+
notice_window_hours: 48
|
|
188
|
+
retention_rules:
|
|
189
|
+
- rule_name: RBI_KYC
|
|
190
|
+
legal_citation: "RBI KYC Directions, 2016, Sec 38"
|
|
191
|
+
if_has_data_in:
|
|
192
|
+
- kyc_documents
|
|
193
|
+
retention_years: 5
|
|
194
|
+
graph:
|
|
195
|
+
root_table: users
|
|
196
|
+
root_id_column: id
|
|
197
|
+
max_depth: 32
|
|
198
|
+
root_pii_columns:
|
|
199
|
+
email: HMAC
|
|
200
|
+
full_name: STATIC_MASK
|
|
201
|
+
purge_policy:
|
|
202
|
+
enabled: true
|
|
203
|
+
selector:
|
|
204
|
+
kind: boolean_column
|
|
205
|
+
column: purge_eligible
|
|
206
|
+
value: true
|
|
207
|
+
outbox:
|
|
208
|
+
batch_size: 10
|
|
209
|
+
lease_seconds: 60
|
|
210
|
+
max_attempts: 10
|
|
211
|
+
base_backoff_ms: 1000
|
|
212
|
+
security:
|
|
213
|
+
notification_lease_seconds: 120
|
|
214
|
+
master_key_env: DPDP_MASTER_KEY
|
|
215
|
+
hmac_key_env: DPDP_HMAC_KEY
|
|
216
|
+
integrity:
|
|
217
|
+
expected_schema_hash: "${"1".repeat(64)}"
|
|
218
|
+
legal_attestation:
|
|
219
|
+
dpo_identifier: "dpo-name@client.com"
|
|
220
|
+
configuration_version: "v1.2.0"
|
|
221
|
+
legal_review_date: "2026-04-20"
|
|
222
|
+
acknowledgment: "I confirm this configuration accurately reflects our obligations."
|
|
223
|
+
`);
|
|
224
|
+
pathsToDelete.push(path);
|
|
225
|
+
|
|
226
|
+
const config = await readWorkerConfig(
|
|
227
|
+
{
|
|
228
|
+
DPDP_MASTER_KEY: masterKeyHex,
|
|
229
|
+
DPDP_HMAC_KEY: `base64:${hmacKeyBase64}`,
|
|
230
|
+
},
|
|
231
|
+
path
|
|
232
|
+
);
|
|
233
|
+
await expect(assertConfigSchemaCompatibility(sql, config)).rejects.toThrow(
|
|
234
|
+
new RegExp(`missing root column ${appSchema}\\.users\\.purge_eligible`, "i")
|
|
235
|
+
);
|
|
236
|
+
});
|
|
237
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
2
|
+
import type { Sql } from "@/types";
|
|
3
|
+
import { createTestSql, dropSchemas, uniqueSchema } from "./helpers";
|
|
4
|
+
import { detectSchemaDrift } from "@modules/db";
|
|
5
|
+
import { assertSchemaIntegrity } from "@modules/bootstrap";
|
|
6
|
+
|
|
7
|
+
describe("Schema Drift Detection", () => {
|
|
8
|
+
let sql: Sql;
|
|
9
|
+
const schemasToDrop: string[] = [];
|
|
10
|
+
|
|
11
|
+
beforeAll(() => {
|
|
12
|
+
sql = createTestSql();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
afterAll(async () => {
|
|
16
|
+
await dropSchemas(sql, ...schemasToDrop);
|
|
17
|
+
await sql.end();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
async function createSchema() {
|
|
21
|
+
const schema = uniqueSchema("drift_app");
|
|
22
|
+
schemasToDrop.push(schema);
|
|
23
|
+
|
|
24
|
+
await dropSchemas(sql, schema);
|
|
25
|
+
await sql`CREATE SCHEMA ${sql(schema)}`;
|
|
26
|
+
await sql`
|
|
27
|
+
CREATE TABLE ${sql(schema)}.users (
|
|
28
|
+
id SERIAL PRIMARY KEY,
|
|
29
|
+
email TEXT NOT NULL,
|
|
30
|
+
full_name TEXT NOT NULL
|
|
31
|
+
)
|
|
32
|
+
`;
|
|
33
|
+
await sql`
|
|
34
|
+
CREATE TABLE ${sql(schema)}.orders (
|
|
35
|
+
id SERIAL PRIMARY KEY,
|
|
36
|
+
user_id INTEGER NOT NULL REFERENCES ${sql(schema)}.users(id),
|
|
37
|
+
amount NUMERIC NOT NULL
|
|
38
|
+
)
|
|
39
|
+
`;
|
|
40
|
+
|
|
41
|
+
return schema;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
it("returns a deterministic digest and changes when the schema changes", async () => {
|
|
45
|
+
const schema = await createSchema();
|
|
46
|
+
|
|
47
|
+
const first = await detectSchemaDrift(sql, schema);
|
|
48
|
+
const second = await detectSchemaDrift(sql, schema);
|
|
49
|
+
expect(second).toBe(first);
|
|
50
|
+
|
|
51
|
+
await sql`ALTER TABLE ${sql(schema)}.users ADD COLUMN phone TEXT`;
|
|
52
|
+
|
|
53
|
+
const third = await detectSchemaDrift(sql, schema);
|
|
54
|
+
expect(third).not.toBe(first);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("fails closed when the manifest hash does not match the live schema digest", async () => {
|
|
58
|
+
const schema = await createSchema();
|
|
59
|
+
const liveHash = await detectSchemaDrift(sql, schema);
|
|
60
|
+
|
|
61
|
+
await expect(assertSchemaIntegrity(sql, schema, liveHash)).resolves.toBe(liveHash);
|
|
62
|
+
await expect(assertSchemaIntegrity(sql, schema, "0".repeat(64))).rejects.toThrow(/schema drift detected/i);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { dispatchPreErasureNotice, shredUser, vaultUser } from "@modules/engine";
|
|
3
|
+
import {
|
|
4
|
+
TEST_SECRETS,
|
|
5
|
+
createTestSql,
|
|
6
|
+
dropSchemas,
|
|
7
|
+
insertUser,
|
|
8
|
+
prepareWorkerSchemas,
|
|
9
|
+
uniqueSchema,
|
|
10
|
+
} from "./helpers";
|
|
11
|
+
import type { Sql } from "@/types";
|
|
12
|
+
|
|
13
|
+
describe("Crypto-Shredder Engine", () => {
|
|
14
|
+
let sql: Sql;
|
|
15
|
+
const schemasToDrop: string[] = [];
|
|
16
|
+
|
|
17
|
+
beforeAll(() => {
|
|
18
|
+
sql = createTestSql();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterAll(async () => {
|
|
22
|
+
await dropSchemas(sql, ...schemasToDrop);
|
|
23
|
+
await sql.end();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
async function prepare() {
|
|
27
|
+
const appSchema = uniqueSchema("shred_app");
|
|
28
|
+
const engineSchema = uniqueSchema("shred_engine");
|
|
29
|
+
schemasToDrop.push(appSchema, engineSchema);
|
|
30
|
+
await prepareWorkerSchemas(sql, appSchema, engineSchema, { withDependencies: true });
|
|
31
|
+
return { appSchema, engineSchema };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function seedUser(appSchema: string, engineSchema: string) {
|
|
35
|
+
const userId = await insertUser(sql, appSchema, "shred.me@example.com", "Shred Me");
|
|
36
|
+
await vaultUser(sql, userId, TEST_SECRETS, {
|
|
37
|
+
appSchema,
|
|
38
|
+
engineSchema,
|
|
39
|
+
now: new Date("2020-01-01T00:00:00.000Z"),
|
|
40
|
+
defaultRetentionYears: 1,
|
|
41
|
+
noticeWindowHours: 48,
|
|
42
|
+
rootTable: "users",
|
|
43
|
+
rootIdColumn: "id",
|
|
44
|
+
rootPiiColumns: {
|
|
45
|
+
email: "HMAC",
|
|
46
|
+
full_name: "STATIC_MASK",
|
|
47
|
+
},
|
|
48
|
+
satelliteTargets: [],
|
|
49
|
+
compiledTargets: [
|
|
50
|
+
{ table: `${appSchema}.users`, pii_columns: ["email", "full_name"] },
|
|
51
|
+
{
|
|
52
|
+
table: `${appSchema}.orders`,
|
|
53
|
+
parent: `${appSchema}.users`,
|
|
54
|
+
join: `${appSchema}.users.id = ${appSchema}.orders.user_id`,
|
|
55
|
+
pii_columns: [],
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
});
|
|
59
|
+
return userId;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function sendNotice(appSchema: string, engineSchema: string, userId: number) {
|
|
63
|
+
await dispatchPreErasureNotice(
|
|
64
|
+
sql,
|
|
65
|
+
userId,
|
|
66
|
+
TEST_SECRETS,
|
|
67
|
+
{
|
|
68
|
+
sendEmail: vi.fn().mockResolvedValue(undefined),
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
appSchema,
|
|
72
|
+
engineSchema,
|
|
73
|
+
now: new Date("2020-12-30T00:00:00.000Z"),
|
|
74
|
+
}
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
it("shreds the key after retention expiry and replaces the vault payload with a destroyed sentinel", async () => {
|
|
79
|
+
const { appSchema, engineSchema } = await prepare();
|
|
80
|
+
const userId = await seedUser(appSchema, engineSchema);
|
|
81
|
+
await sendNotice(appSchema, engineSchema, userId);
|
|
82
|
+
|
|
83
|
+
const result = await shredUser(sql, userId, {
|
|
84
|
+
appSchema,
|
|
85
|
+
engineSchema,
|
|
86
|
+
now: new Date("2021-01-02T00:00:00.000Z"),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
expect(result.action).toBe("shredded");
|
|
90
|
+
|
|
91
|
+
const keysAfter = await sql`
|
|
92
|
+
SELECT *
|
|
93
|
+
FROM ${sql(engineSchema)}.user_keys
|
|
94
|
+
WHERE user_uuid_hash = ${result.userHash}
|
|
95
|
+
`;
|
|
96
|
+
const [vaultAfter] = await sql`
|
|
97
|
+
SELECT encrypted_pii, shredded_at
|
|
98
|
+
FROM ${sql(engineSchema)}.pii_vault
|
|
99
|
+
WHERE root_schema = ${appSchema}
|
|
100
|
+
AND root_table = 'users'
|
|
101
|
+
AND root_id = ${userId.toString()}
|
|
102
|
+
`;
|
|
103
|
+
const outboxRows = await sql`
|
|
104
|
+
SELECT *
|
|
105
|
+
FROM ${sql(engineSchema)}.outbox
|
|
106
|
+
WHERE idempotency_key = ${`shred:${appSchema}:users:${userId}`}
|
|
107
|
+
`;
|
|
108
|
+
|
|
109
|
+
expect(keysAfter).toHaveLength(0);
|
|
110
|
+
expect(vaultAfter?.encrypted_pii).toEqual({ v: 1, destroyed: true });
|
|
111
|
+
expect(vaultAfter?.shredded_at).toBeDefined();
|
|
112
|
+
expect(outboxRows).toHaveLength(1);
|
|
113
|
+
expect(outboxRows[0]?.event_type).toBe("SHRED_SUCCESS");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("refuses to shred before the retention expiry", async () => {
|
|
117
|
+
const { appSchema, engineSchema } = await prepare();
|
|
118
|
+
const userId = await seedUser(appSchema, engineSchema);
|
|
119
|
+
await sendNotice(appSchema, engineSchema, userId);
|
|
120
|
+
|
|
121
|
+
await expect(
|
|
122
|
+
shredUser(sql, userId, {
|
|
123
|
+
appSchema,
|
|
124
|
+
engineSchema,
|
|
125
|
+
now: new Date("2020-12-31T00:00:00.000Z"),
|
|
126
|
+
})
|
|
127
|
+
).rejects.toThrow(/before retention expiry/i);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("refuses to shred if the pre-erasure notice has not been sent", async () => {
|
|
131
|
+
const { appSchema, engineSchema } = await prepare();
|
|
132
|
+
const userId = await seedUser(appSchema, engineSchema);
|
|
133
|
+
|
|
134
|
+
await expect(
|
|
135
|
+
shredUser(sql, userId, {
|
|
136
|
+
appSchema,
|
|
137
|
+
engineSchema,
|
|
138
|
+
now: new Date("2021-01-02T00:00:00.000Z"),
|
|
139
|
+
})
|
|
140
|
+
).rejects.toThrow(/notice has been sent/i);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("is idempotent when the same shred request is replayed", async () => {
|
|
144
|
+
const { appSchema, engineSchema } = await prepare();
|
|
145
|
+
const userId = await seedUser(appSchema, engineSchema);
|
|
146
|
+
await sendNotice(appSchema, engineSchema, userId);
|
|
147
|
+
|
|
148
|
+
const first = await shredUser(sql, userId, {
|
|
149
|
+
appSchema,
|
|
150
|
+
engineSchema,
|
|
151
|
+
now: new Date("2021-01-02T00:00:00.000Z"),
|
|
152
|
+
});
|
|
153
|
+
const second = await shredUser(sql, userId, {
|
|
154
|
+
appSchema,
|
|
155
|
+
engineSchema,
|
|
156
|
+
now: new Date("2021-01-03T00:00:00.000Z"),
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
expect(first.action).toBe("shredded");
|
|
160
|
+
expect(second.action).toBe("already_shredded");
|
|
161
|
+
expect(second.userHash).toBe(first.userHash);
|
|
162
|
+
});
|
|
163
|
+
});
|