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,243 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
2
|
+
import { 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("Vault Engine compiled DAG execution", () => {
|
|
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
|
+
it("mutates a deep compiled target through precompiled joins instead of same-column satellite lookup", async () => {
|
|
27
|
+
const appSchema = uniqueSchema("vault_compiled_app");
|
|
28
|
+
const engineSchema = uniqueSchema("vault_compiled_engine");
|
|
29
|
+
schemasToDrop.push(appSchema, engineSchema);
|
|
30
|
+
await prepareWorkerSchemas(sql, appSchema, engineSchema, {
|
|
31
|
+
withDependencies: true,
|
|
32
|
+
withDeepDependencies: true,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const userId = await insertUser(sql, appSchema, "compiled@example.com", "Compiled User");
|
|
36
|
+
const [order] = await sql<{ id: number }[]>`
|
|
37
|
+
INSERT INTO ${sql(appSchema)}.orders (user_id, amount)
|
|
38
|
+
VALUES (${userId}, 500)
|
|
39
|
+
RETURNING id
|
|
40
|
+
`;
|
|
41
|
+
await sql`
|
|
42
|
+
INSERT INTO ${sql(appSchema)}.shipping_addresses (order_id, street, city)
|
|
43
|
+
VALUES (${order!.id}, '221B Baker Street', 'Mumbai')
|
|
44
|
+
`;
|
|
45
|
+
|
|
46
|
+
const result = await vaultUser(sql, userId, TEST_SECRETS, {
|
|
47
|
+
appSchema,
|
|
48
|
+
engineSchema,
|
|
49
|
+
now: new Date("2026-01-10T00:00:00.000Z"),
|
|
50
|
+
rootTable: "users",
|
|
51
|
+
rootIdColumn: "id",
|
|
52
|
+
rootPiiColumns: {
|
|
53
|
+
email: "HMAC",
|
|
54
|
+
full_name: "STATIC_MASK",
|
|
55
|
+
},
|
|
56
|
+
satelliteTargets: [],
|
|
57
|
+
compiledTargets: [
|
|
58
|
+
{ table: `${appSchema}.users`, pii_columns: ["email", "full_name"] },
|
|
59
|
+
{
|
|
60
|
+
table: `${appSchema}.orders`,
|
|
61
|
+
parent: `${appSchema}.users`,
|
|
62
|
+
parent_columns: ["id"],
|
|
63
|
+
child_columns: ["user_id"],
|
|
64
|
+
pii_columns: [],
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
table: `${appSchema}.shipping_addresses`,
|
|
68
|
+
parent: `${appSchema}.orders`,
|
|
69
|
+
parent_columns: ["id"],
|
|
70
|
+
child_columns: ["order_id"],
|
|
71
|
+
pii_columns: ["street", "city"],
|
|
72
|
+
action: "redact",
|
|
73
|
+
mutation_rules: {
|
|
74
|
+
street: "STATIC_MASK",
|
|
75
|
+
city: "STATIC_MASK",
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
],
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
expect(result.action).toBe("vaulted");
|
|
82
|
+
expect(result.dependencyCount).toBe(2);
|
|
83
|
+
|
|
84
|
+
const [address] = await sql<{ street: string | null; city: string | null }[]>`
|
|
85
|
+
SELECT street, city
|
|
86
|
+
FROM ${sql(appSchema)}.shipping_addresses
|
|
87
|
+
WHERE order_id = ${order!.id}
|
|
88
|
+
`;
|
|
89
|
+
expect(address).toEqual({
|
|
90
|
+
street: "[REDACTED]",
|
|
91
|
+
city: "[REDACTED]",
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const [outbox] = await sql<{ payload: { satellite_mutations: Array<{ table: string; affectedRows: number }> } }[]>`
|
|
95
|
+
SELECT payload
|
|
96
|
+
FROM ${sql(engineSchema)}.outbox
|
|
97
|
+
WHERE event_type = 'USER_VAULTED'
|
|
98
|
+
`;
|
|
99
|
+
expect(outbox?.payload.satellite_mutations).toContainEqual({
|
|
100
|
+
table: `${appSchema}.shipping_addresses`,
|
|
101
|
+
action: "redact",
|
|
102
|
+
affectedRows: 1,
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("uses all configured primary key columns when mutating compiled targets", async () => {
|
|
107
|
+
const appSchema = uniqueSchema("vault_compiled_pk_app");
|
|
108
|
+
const engineSchema = uniqueSchema("vault_compiled_pk_engine");
|
|
109
|
+
schemasToDrop.push(appSchema, engineSchema);
|
|
110
|
+
await prepareWorkerSchemas(sql, appSchema, engineSchema);
|
|
111
|
+
|
|
112
|
+
await sql`
|
|
113
|
+
CREATE TABLE ${sql(appSchema)}.devices (
|
|
114
|
+
id SERIAL PRIMARY KEY,
|
|
115
|
+
user_id INTEGER NOT NULL REFERENCES ${sql(appSchema)}.users(id)
|
|
116
|
+
)
|
|
117
|
+
`;
|
|
118
|
+
await sql`
|
|
119
|
+
CREATE TABLE ${sql(appSchema)}.device_events (
|
|
120
|
+
device_id INTEGER NOT NULL REFERENCES ${sql(appSchema)}.devices(id),
|
|
121
|
+
event_seq INTEGER NOT NULL,
|
|
122
|
+
payload TEXT NOT NULL,
|
|
123
|
+
PRIMARY KEY (device_id, event_seq)
|
|
124
|
+
)
|
|
125
|
+
`;
|
|
126
|
+
|
|
127
|
+
const userId = await insertUser(sql, appSchema, "compiled-pk@example.com", "Compiled PK User");
|
|
128
|
+
const [device] = await sql<{ id: number }[]>`
|
|
129
|
+
INSERT INTO ${sql(appSchema)}.devices (user_id)
|
|
130
|
+
VALUES (${userId})
|
|
131
|
+
RETURNING id
|
|
132
|
+
`;
|
|
133
|
+
await sql`
|
|
134
|
+
INSERT INTO ${sql(appSchema)}.device_events (device_id, event_seq, payload)
|
|
135
|
+
VALUES
|
|
136
|
+
(${device!.id}, 1, 'first pii payload'),
|
|
137
|
+
(${device!.id}, 2, 'second pii payload')
|
|
138
|
+
`;
|
|
139
|
+
|
|
140
|
+
const result = await vaultUser(sql, userId, TEST_SECRETS, {
|
|
141
|
+
appSchema,
|
|
142
|
+
engineSchema,
|
|
143
|
+
now: new Date("2026-01-10T00:00:00.000Z"),
|
|
144
|
+
rootTable: "users",
|
|
145
|
+
rootIdColumn: "id",
|
|
146
|
+
rootPiiColumns: {
|
|
147
|
+
email: "HMAC",
|
|
148
|
+
full_name: "STATIC_MASK",
|
|
149
|
+
},
|
|
150
|
+
satelliteTargets: [],
|
|
151
|
+
compiledTargets: [
|
|
152
|
+
{ table: `${appSchema}.users`, pii_columns: ["email", "full_name"] },
|
|
153
|
+
{
|
|
154
|
+
table: `${appSchema}.devices`,
|
|
155
|
+
parent: `${appSchema}.users`,
|
|
156
|
+
parent_columns: ["id"],
|
|
157
|
+
child_columns: ["user_id"],
|
|
158
|
+
pii_columns: [],
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
table: `${appSchema}.device_events`,
|
|
162
|
+
parent: `${appSchema}.devices`,
|
|
163
|
+
parent_columns: ["id"],
|
|
164
|
+
child_columns: ["device_id"],
|
|
165
|
+
primary_key_columns: ["device_id", "event_seq"],
|
|
166
|
+
pii_columns: ["payload"],
|
|
167
|
+
action: "redact",
|
|
168
|
+
mutation_rules: {
|
|
169
|
+
payload: "STATIC_MASK",
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
],
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
expect(result.action).toBe("vaulted");
|
|
176
|
+
const rows = await sql<{ event_seq: number; payload: string }[]>`
|
|
177
|
+
SELECT event_seq, payload
|
|
178
|
+
FROM ${sql(appSchema)}.device_events
|
|
179
|
+
WHERE device_id = ${device!.id}
|
|
180
|
+
ORDER BY event_seq ASC
|
|
181
|
+
`;
|
|
182
|
+
expect(rows).toEqual([
|
|
183
|
+
{ event_seq: 1, payload: "[REDACTED]" },
|
|
184
|
+
{ event_seq: 2, payload: "[REDACTED]" },
|
|
185
|
+
]);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("treats compiled DAG targets as authoritative when legacy satellite targets are also present", async () => {
|
|
189
|
+
const appSchema = uniqueSchema("vault_compiled_authority_app");
|
|
190
|
+
const engineSchema = uniqueSchema("vault_compiled_authority_engine");
|
|
191
|
+
schemasToDrop.push(appSchema, engineSchema);
|
|
192
|
+
await prepareWorkerSchemas(sql, appSchema, engineSchema, { withDependencies: true });
|
|
193
|
+
|
|
194
|
+
const userId = await insertUser(sql, appSchema, "authority@example.com", "Authority User");
|
|
195
|
+
await sql`
|
|
196
|
+
INSERT INTO ${sql(appSchema)}.orders (user_id, amount)
|
|
197
|
+
VALUES (${userId}, 700)
|
|
198
|
+
`;
|
|
199
|
+
|
|
200
|
+
const result = await vaultUser(sql, userId, TEST_SECRETS, {
|
|
201
|
+
appSchema,
|
|
202
|
+
engineSchema,
|
|
203
|
+
now: new Date("2026-01-10T00:00:00.000Z"),
|
|
204
|
+
rootTable: "users",
|
|
205
|
+
rootIdColumn: "id",
|
|
206
|
+
rootPiiColumns: {
|
|
207
|
+
email: "HMAC",
|
|
208
|
+
full_name: "STATIC_MASK",
|
|
209
|
+
},
|
|
210
|
+
satelliteTargets: [
|
|
211
|
+
{
|
|
212
|
+
table: "legacy_target_that_must_not_run",
|
|
213
|
+
lookup_column: "user_id",
|
|
214
|
+
action: "redact",
|
|
215
|
+
masking_rules: {
|
|
216
|
+
payload: "STATIC_MASK",
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
],
|
|
220
|
+
compiledTargets: [
|
|
221
|
+
{ table: `${appSchema}.users`, pii_columns: ["email", "full_name"] },
|
|
222
|
+
{
|
|
223
|
+
table: `${appSchema}.orders`,
|
|
224
|
+
parent: `${appSchema}.users`,
|
|
225
|
+
parent_columns: ["id"],
|
|
226
|
+
child_columns: ["user_id"],
|
|
227
|
+
pii_columns: [],
|
|
228
|
+
},
|
|
229
|
+
],
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
expect(result.action).toBe("vaulted");
|
|
233
|
+
expect(result.dependencyCount).toBe(1);
|
|
234
|
+
|
|
235
|
+
const [outbox] = await sql<{ payload: { execution_plan_source: string; satellite_mutations: unknown[] } }[]>`
|
|
236
|
+
SELECT payload
|
|
237
|
+
FROM ${sql(engineSchema)}.outbox
|
|
238
|
+
WHERE event_type = 'USER_VAULTED'
|
|
239
|
+
`;
|
|
240
|
+
expect(outbox?.payload.execution_plan_source).toBe("compiled");
|
|
241
|
+
expect(outbox?.payload.satellite_mutations).toEqual([]);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Sql } from "@/types";
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
const getDependencyGraphMock = vi.fn();
|
|
5
|
+
|
|
6
|
+
vi.mock("../src/db/graph", () => ({
|
|
7
|
+
getDependencyGraph: getDependencyGraphMock,
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
const { vaultUser } = await import("@modules/engine");
|
|
11
|
+
|
|
12
|
+
describe("Vault Engine Static Plan Routing", () => {
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
getDependencyGraphMock.mockReset();
|
|
15
|
+
getDependencyGraphMock.mockResolvedValue([]);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("does not run recursive graph traversal during dry-run when a static plan is provided", async () => {
|
|
19
|
+
const primary = {
|
|
20
|
+
unsafe: vi.fn().mockResolvedValue([]),
|
|
21
|
+
} as unknown as Sql;
|
|
22
|
+
const replica = {
|
|
23
|
+
tag: "replica",
|
|
24
|
+
} as unknown as Sql;
|
|
25
|
+
|
|
26
|
+
const result = await vaultUser(
|
|
27
|
+
primary,
|
|
28
|
+
42,
|
|
29
|
+
{
|
|
30
|
+
kek: new Uint8Array(32).fill(0x42),
|
|
31
|
+
hmacKey: new Uint8Array(32).fill(0x24),
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
appSchema: "tenant_app",
|
|
35
|
+
engineSchema: "tenant_engine",
|
|
36
|
+
rootTable: "users",
|
|
37
|
+
rootIdColumn: "id",
|
|
38
|
+
rootPiiColumns: { email: "HMAC", full_name: "STATIC_MASK" },
|
|
39
|
+
satelliteTargets: [],
|
|
40
|
+
compiledTargets: [
|
|
41
|
+
{ table: "tenant_app.users", pii_columns: ["email", "full_name"] },
|
|
42
|
+
{
|
|
43
|
+
table: "tenant_app.orders",
|
|
44
|
+
parent: "tenant_app.users",
|
|
45
|
+
join: "tenant_app.users.id = tenant_app.orders.user_id",
|
|
46
|
+
pii_columns: [],
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
dryRun: true,
|
|
50
|
+
sqlReplica: replica,
|
|
51
|
+
now: new Date("2026-01-10T00:00:00.000Z"),
|
|
52
|
+
}
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
expect(result.action).toBe("dry_run");
|
|
56
|
+
expect(result.dependencyCount).toBe(1);
|
|
57
|
+
expect(getDependencyGraphMock).not.toHaveBeenCalled();
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
TEST_SECRETS,
|
|
4
|
+
createTestSql,
|
|
5
|
+
dropSchemas,
|
|
6
|
+
insertUser,
|
|
7
|
+
prepareWorkerSchemas,
|
|
8
|
+
uniqueSchema,
|
|
9
|
+
} from "./helpers";
|
|
10
|
+
import type { Sql } from "@/types";
|
|
11
|
+
import { createUserHash, vaultUser } from "@modules/engine";
|
|
12
|
+
import { decryptGCM, unwrapKey } from "@modules/crypto";
|
|
13
|
+
|
|
14
|
+
describe("Vault Engine (Atomic State Machine)", () => {
|
|
15
|
+
let sql: Sql;
|
|
16
|
+
const schemasToDrop: string[] = [];
|
|
17
|
+
|
|
18
|
+
beforeAll(() => {
|
|
19
|
+
sql = createTestSql();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterAll(async () => {
|
|
23
|
+
await dropSchemas(sql, ...schemasToDrop);
|
|
24
|
+
await sql.end();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
async function prepare(options: { withDependencies?: boolean } = {}) {
|
|
28
|
+
const appSchema = uniqueSchema("vault_app");
|
|
29
|
+
const engineSchema = uniqueSchema("vault_engine");
|
|
30
|
+
schemasToDrop.push(appSchema, engineSchema);
|
|
31
|
+
|
|
32
|
+
await prepareWorkerSchemas(sql, appSchema, engineSchema, options);
|
|
33
|
+
return { appSchema, engineSchema };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function buildVaultOptions(
|
|
37
|
+
appSchema: string,
|
|
38
|
+
engineSchema: string,
|
|
39
|
+
now?: Date,
|
|
40
|
+
withCompiledDependencies = true
|
|
41
|
+
) {
|
|
42
|
+
return {
|
|
43
|
+
appSchema,
|
|
44
|
+
engineSchema,
|
|
45
|
+
now,
|
|
46
|
+
rootTable: "users",
|
|
47
|
+
rootIdColumn: "id",
|
|
48
|
+
rootPiiColumns: {
|
|
49
|
+
email: "HMAC" as const,
|
|
50
|
+
full_name: "STATIC_MASK" as const,
|
|
51
|
+
},
|
|
52
|
+
satelliteTargets: [],
|
|
53
|
+
compiledTargets: withCompiledDependencies
|
|
54
|
+
? [
|
|
55
|
+
{ table: `${appSchema}.users`, pii_columns: ["email", "full_name"] },
|
|
56
|
+
{
|
|
57
|
+
table: `${appSchema}.orders`,
|
|
58
|
+
parent: `${appSchema}.users`,
|
|
59
|
+
join: `${appSchema}.users.id = ${appSchema}.orders.user_id`,
|
|
60
|
+
pii_columns: [],
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
table: `${appSchema}.profiles`,
|
|
64
|
+
parent: `${appSchema}.users`,
|
|
65
|
+
join: `${appSchema}.users.id = ${appSchema}.profiles.user_id`,
|
|
66
|
+
pii_columns: [],
|
|
67
|
+
},
|
|
68
|
+
]
|
|
69
|
+
: [],
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
it("vaults, pseudonymizes, and keeps the original PII decryptable with the KEK", async () => {
|
|
74
|
+
const { appSchema, engineSchema } = await prepare({ withDependencies: true });
|
|
75
|
+
const now = new Date("2026-01-10T00:00:00.000Z");
|
|
76
|
+
const userId = await insertUser(sql, appSchema, "john.doe@example.com", "John Doe");
|
|
77
|
+
|
|
78
|
+
const result = await vaultUser(sql, userId, TEST_SECRETS, buildVaultOptions(appSchema, engineSchema, now));
|
|
79
|
+
expect(result.action).toBe("vaulted");
|
|
80
|
+
expect(result.userHash).toHaveLength(64);
|
|
81
|
+
expect(result.pseudonym).toMatch(/@dpdp\.invalid$/);
|
|
82
|
+
expect(result.dependencyCount).toBe(2);
|
|
83
|
+
|
|
84
|
+
const [publicUser] = await sql<{ email: string; full_name: string }[]>`
|
|
85
|
+
SELECT email, full_name
|
|
86
|
+
FROM ${sql(appSchema)}.users
|
|
87
|
+
WHERE id = ${userId}
|
|
88
|
+
`;
|
|
89
|
+
|
|
90
|
+
expect(publicUser?.email).toMatch(/^[0-9a-f]{64}$/);
|
|
91
|
+
expect(publicUser?.full_name).toBe("[REDACTED]");
|
|
92
|
+
|
|
93
|
+
const [vaultRow] = await sql`
|
|
94
|
+
SELECT *
|
|
95
|
+
FROM ${sql(engineSchema)}.pii_vault
|
|
96
|
+
WHERE root_schema = ${appSchema}
|
|
97
|
+
AND root_table = 'users'
|
|
98
|
+
AND root_id = ${userId.toString()}
|
|
99
|
+
`;
|
|
100
|
+
const [keyRow] = await sql`
|
|
101
|
+
SELECT *
|
|
102
|
+
FROM ${sql(engineSchema)}.user_keys
|
|
103
|
+
WHERE user_uuid_hash = ${result.userHash}
|
|
104
|
+
`;
|
|
105
|
+
const [outboxRow] = await sql`
|
|
106
|
+
SELECT *
|
|
107
|
+
FROM ${sql(engineSchema)}.outbox
|
|
108
|
+
WHERE idempotency_key = ${`vault:${appSchema}:users:id:${userId}`}
|
|
109
|
+
`;
|
|
110
|
+
|
|
111
|
+
expect(vaultRow?.user_uuid_hash).toBe(result.userHash);
|
|
112
|
+
expect(vaultRow?.dependency_count).toBe(2);
|
|
113
|
+
expect(vaultRow?.pseudonym).toBe(result.pseudonym);
|
|
114
|
+
expect(vaultRow?.notification_due_at).toBeDefined();
|
|
115
|
+
expect(keyRow?.encrypted_dek).toBeDefined();
|
|
116
|
+
expect(outboxRow?.event_type).toBe("USER_VAULTED");
|
|
117
|
+
expect(outboxRow?.status).toBe("pending");
|
|
118
|
+
|
|
119
|
+
expect(keyRow).toBeDefined();
|
|
120
|
+
expect(vaultRow).toBeDefined();
|
|
121
|
+
const dek = await unwrapKey(new Uint8Array(keyRow!.encrypted_dek), TEST_SECRETS.kek);
|
|
122
|
+
const encryptedPayload = new Uint8Array(Buffer.from((vaultRow!.encrypted_pii as { data: string }).data, "base64"));
|
|
123
|
+
const decryptedPii = await decryptGCM(encryptedPayload, dek);
|
|
124
|
+
|
|
125
|
+
expect(JSON.parse(decryptedPii)).toEqual({
|
|
126
|
+
email: "john.doe@example.com",
|
|
127
|
+
full_name: "John Doe",
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("hard deletes the user when the root table has no dependent tables", async () => {
|
|
132
|
+
const { appSchema, engineSchema } = await prepare({ withDependencies: false });
|
|
133
|
+
const userId = await insertUser(sql, appSchema, "delete.me@example.com", "Delete Me");
|
|
134
|
+
|
|
135
|
+
const result = await vaultUser(sql, userId, TEST_SECRETS, buildVaultOptions(appSchema, engineSchema, undefined, false));
|
|
136
|
+
expect(result.action).toBe("hard_deleted");
|
|
137
|
+
expect(result.dependencyCount).toBe(0);
|
|
138
|
+
|
|
139
|
+
const remainingUsers = await sql`SELECT * FROM ${sql(appSchema)}.users WHERE id = ${userId}`;
|
|
140
|
+
const vaultRows = await sql`SELECT * FROM ${sql(engineSchema)}.pii_vault WHERE root_id = ${userId.toString()}`;
|
|
141
|
+
const [outboxRow] = await sql`
|
|
142
|
+
SELECT *
|
|
143
|
+
FROM ${sql(engineSchema)}.outbox
|
|
144
|
+
WHERE idempotency_key = ${`hard-delete:${appSchema}:users:id:${userId}`}
|
|
145
|
+
`;
|
|
146
|
+
|
|
147
|
+
expect(remainingUsers).toHaveLength(0);
|
|
148
|
+
expect(vaultRows).toHaveLength(0);
|
|
149
|
+
expect(outboxRow?.event_type).toBe("USER_HARD_DELETED");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("supports dry-run mode without mutating any state", async () => {
|
|
153
|
+
const { appSchema, engineSchema } = await prepare({ withDependencies: true });
|
|
154
|
+
const userId = await insertUser(sql, appSchema, "preview@example.com", "Preview User");
|
|
155
|
+
|
|
156
|
+
const result = await vaultUser(sql, userId, TEST_SECRETS, {
|
|
157
|
+
...buildVaultOptions(appSchema, engineSchema, new Date("2026-01-10T00:00:00.000Z")),
|
|
158
|
+
dryRun: true,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
expect(result.action).toBe("dry_run");
|
|
162
|
+
expect(result.plan?.summary).toContain(`root row ${userId}`);
|
|
163
|
+
|
|
164
|
+
const [publicUser] = await sql`SELECT email, full_name FROM ${sql(appSchema)}.users WHERE id = ${userId}`;
|
|
165
|
+
const vaultRows = await sql`SELECT * FROM ${sql(engineSchema)}.pii_vault WHERE root_id = ${userId.toString()}`;
|
|
166
|
+
const outboxRows = await sql`SELECT * FROM ${sql(engineSchema)}.outbox`;
|
|
167
|
+
|
|
168
|
+
expect(publicUser?.email).toBe("preview@example.com");
|
|
169
|
+
expect(vaultRows).toHaveLength(0);
|
|
170
|
+
expect(outboxRows).toHaveLength(0);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("supports shadow mode by validating the full pipeline and rolling back all writes", async () => {
|
|
174
|
+
const { appSchema, engineSchema } = await prepare({ withDependencies: true });
|
|
175
|
+
const userId = await insertUser(sql, appSchema, "shadow@example.com", "Shadow User");
|
|
176
|
+
|
|
177
|
+
const result = await vaultUser(sql, userId, TEST_SECRETS, {
|
|
178
|
+
...buildVaultOptions(appSchema, engineSchema, new Date("2026-01-10T00:00:00.000Z")),
|
|
179
|
+
shadowMode: true,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
expect(result.action).toBe("vaulted");
|
|
183
|
+
expect(result.dryRun).toBe(false);
|
|
184
|
+
|
|
185
|
+
const [publicUser] = await sql<{ email: string; full_name: string }[]>`
|
|
186
|
+
SELECT email, full_name
|
|
187
|
+
FROM ${sql(appSchema)}.users
|
|
188
|
+
WHERE id = ${userId}
|
|
189
|
+
`;
|
|
190
|
+
const vaultRows = await sql`
|
|
191
|
+
SELECT *
|
|
192
|
+
FROM ${sql(engineSchema)}.pii_vault
|
|
193
|
+
WHERE root_schema = ${appSchema}
|
|
194
|
+
AND root_table = 'users'
|
|
195
|
+
AND root_id = ${userId.toString()}
|
|
196
|
+
`;
|
|
197
|
+
const outboxRows = await sql`
|
|
198
|
+
SELECT *
|
|
199
|
+
FROM ${sql(engineSchema)}.outbox
|
|
200
|
+
WHERE idempotency_key = ${`vault:${appSchema}:users:id:${userId}`}
|
|
201
|
+
`;
|
|
202
|
+
|
|
203
|
+
expect(publicUser?.email).toBe("shadow@example.com");
|
|
204
|
+
expect(publicUser?.full_name).toBe("Shadow User");
|
|
205
|
+
expect(vaultRows).toHaveLength(0);
|
|
206
|
+
expect(outboxRows).toHaveLength(0);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("rolls back cleanly when the vault insert collides with an existing hash", async () => {
|
|
210
|
+
const { appSchema, engineSchema } = await prepare({ withDependencies: true });
|
|
211
|
+
const userId = await insertUser(sql, appSchema, "jane.smith@example.com", "Jane Smith");
|
|
212
|
+
const conflictingHash = await createUserHash(userId, appSchema, "users", TEST_SECRETS.hmacKey);
|
|
213
|
+
|
|
214
|
+
await sql`
|
|
215
|
+
INSERT INTO ${sql(engineSchema)}.pii_vault (
|
|
216
|
+
user_uuid_hash,
|
|
217
|
+
root_schema,
|
|
218
|
+
root_table,
|
|
219
|
+
root_id,
|
|
220
|
+
pseudonym,
|
|
221
|
+
encrypted_pii,
|
|
222
|
+
salt,
|
|
223
|
+
dependency_count,
|
|
224
|
+
retention_expiry,
|
|
225
|
+
notification_due_at,
|
|
226
|
+
created_at,
|
|
227
|
+
updated_at
|
|
228
|
+
)
|
|
229
|
+
VALUES (
|
|
230
|
+
${conflictingHash},
|
|
231
|
+
${appSchema},
|
|
232
|
+
'users',
|
|
233
|
+
'999999',
|
|
234
|
+
'conflict@dpdp.invalid',
|
|
235
|
+
${sql.json({ v: 1, data: "AA==" })},
|
|
236
|
+
'conflictsalt',
|
|
237
|
+
1,
|
|
238
|
+
NOW(),
|
|
239
|
+
NOW(),
|
|
240
|
+
NOW(),
|
|
241
|
+
NOW()
|
|
242
|
+
)
|
|
243
|
+
`;
|
|
244
|
+
|
|
245
|
+
await expect(vaultUser(sql, userId, TEST_SECRETS, buildVaultOptions(appSchema, engineSchema))).rejects.toThrow();
|
|
246
|
+
|
|
247
|
+
const [publicUser] = await sql`SELECT email, full_name FROM ${sql(appSchema)}.users WHERE id = ${userId}`;
|
|
248
|
+
const keyRows = await sql`SELECT * FROM ${sql(engineSchema)}.user_keys WHERE user_uuid_hash = ${conflictingHash}`;
|
|
249
|
+
const outboxRows = await sql`
|
|
250
|
+
SELECT *
|
|
251
|
+
FROM ${sql(engineSchema)}.outbox
|
|
252
|
+
WHERE idempotency_key = ${`vault:${appSchema}:users:id:${userId}`}
|
|
253
|
+
`;
|
|
254
|
+
|
|
255
|
+
expect(publicUser?.email).toBe("jane.smith@example.com");
|
|
256
|
+
expect(publicUser?.full_name).toBe("Jane Smith");
|
|
257
|
+
expect(keyRows).toHaveLength(0);
|
|
258
|
+
expect(outboxRows).toHaveLength(0);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("returns an idempotent already_vaulted result when the same user is processed twice", async () => {
|
|
262
|
+
const { appSchema, engineSchema } = await prepare({ withDependencies: true });
|
|
263
|
+
const userId = await insertUser(sql, appSchema, "repeat@example.com", "Repeat User");
|
|
264
|
+
|
|
265
|
+
const first = await vaultUser(sql, userId, TEST_SECRETS, buildVaultOptions(appSchema, engineSchema));
|
|
266
|
+
const second = await vaultUser(sql, userId, TEST_SECRETS, buildVaultOptions(appSchema, engineSchema));
|
|
267
|
+
|
|
268
|
+
expect(first.action).toBe("vaulted");
|
|
269
|
+
expect(second.action).toBe("already_vaulted");
|
|
270
|
+
expect(second.userHash).toBe(first.userHash);
|
|
271
|
+
|
|
272
|
+
const outboxRows = await sql`
|
|
273
|
+
SELECT *
|
|
274
|
+
FROM ${sql(engineSchema)}.outbox
|
|
275
|
+
WHERE idempotency_key = ${`vault:${appSchema}:users:id:${userId}`}
|
|
276
|
+
`;
|
|
277
|
+
expect(outboxRows).toHaveLength(1);
|
|
278
|
+
});
|
|
279
|
+
});
|