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,296 @@
|
|
|
1
|
+
import type { Sql } from "@/types";
|
|
2
|
+
import { assertIdentifier } from "@/utils";
|
|
3
|
+
import type { CompiledExecutionTarget, WorkerConfig } from "@modules/config/validation";
|
|
4
|
+
import { fail } from "@/errors";
|
|
5
|
+
|
|
6
|
+
export interface IndexRequirement {
|
|
7
|
+
schema: string;
|
|
8
|
+
table: string;
|
|
9
|
+
columns: string[];
|
|
10
|
+
reason: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface IndexPreflightResult {
|
|
14
|
+
checked: number,
|
|
15
|
+
missing: IndexRequirement[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface IndexPrefixRow {
|
|
19
|
+
exists: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface ColumnExistsRow {
|
|
23
|
+
exists: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function qualifiedTable(value: string, defaultSchema: string): { schema: string; table: string } {
|
|
27
|
+
const parts = value.split(".");
|
|
28
|
+
if (parts.length === 1) {
|
|
29
|
+
return {
|
|
30
|
+
schema: defaultSchema,
|
|
31
|
+
table: assertIdentifier(parts[0]!, "table name"),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (parts.length === 2) {
|
|
36
|
+
return {
|
|
37
|
+
schema: assertIdentifier(parts[0]!, "table schema"),
|
|
38
|
+
table: assertIdentifier(parts[1]!, "table name"),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
fail({
|
|
43
|
+
code: "INDEX_PREFLIGHT_TABLE_INVALID",
|
|
44
|
+
title: "Invalid index preflight table reference",
|
|
45
|
+
detail: `Expected table or schema.table, received "${value}".`,
|
|
46
|
+
category: "configuration",
|
|
47
|
+
retryable: false,
|
|
48
|
+
fatal: true,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function addRequirement(
|
|
53
|
+
requirements: Map<string, IndexRequirement>,
|
|
54
|
+
requirement: IndexRequirement
|
|
55
|
+
): void {
|
|
56
|
+
const safeRequirement: IndexRequirement = {
|
|
57
|
+
schema: assertIdentifier(requirement.schema, "index requirement schema"),
|
|
58
|
+
table: assertIdentifier(requirement.table, "index requirement schema"),
|
|
59
|
+
columns: requirement.columns.map((column) => assertIdentifier(column, "index requirement schema")),
|
|
60
|
+
reason: requirement.reason,
|
|
61
|
+
}
|
|
62
|
+
const key = `${safeRequirement.schema}.${safeRequirement.table}:${safeRequirement.columns.join(",")}`;
|
|
63
|
+
requirements.set(key, safeRequirement);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function addCompiledTargetRequirements(
|
|
67
|
+
requirements: Map<string, IndexRequirement>,
|
|
68
|
+
target: CompiledExecutionTarget,
|
|
69
|
+
defaultSchema: string
|
|
70
|
+
): void {
|
|
71
|
+
const child = qualifiedTable(target.table, defaultSchema);
|
|
72
|
+
if (target.child_columns.length > 0) {
|
|
73
|
+
addRequirement(requirements, {
|
|
74
|
+
...child,
|
|
75
|
+
columns: target.child_columns,
|
|
76
|
+
reason: `compiled DAG child join for ${target.table}`,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const primaryKeyColumns = target.primary_key_columns.length > 0 ? target.primary_key_columns : ["id"];
|
|
81
|
+
addRequirement(requirements, {
|
|
82
|
+
...child,
|
|
83
|
+
columns: primaryKeyColumns,
|
|
84
|
+
reason: `compiled DAG bounded mutation identity for ${target.table}`,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
if (!target.parent || target.parent_columns.length === 0) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const parent = qualifiedTable(target.parent, defaultSchema);
|
|
92
|
+
addRequirement(requirements, {
|
|
93
|
+
...parent,
|
|
94
|
+
columns: target.parent_columns,
|
|
95
|
+
reason: `compiled DAG parent join for ${target.table}`,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Builds the exact lookup/index contract required for safe runtime execution.
|
|
101
|
+
*
|
|
102
|
+
* @param config - DPO-attested worker configuration.
|
|
103
|
+
* @returns Deduplicated index requirements for root, evidence, satellite, blob, and compiled-DAG lookups.
|
|
104
|
+
*/
|
|
105
|
+
export function collectIndexRequirements(config: WorkerConfig): IndexRequirement[] {
|
|
106
|
+
const requirements = new Map<string, IndexRequirement>();
|
|
107
|
+
const appSchema = config.database.app_schema;
|
|
108
|
+
|
|
109
|
+
addRequirement(requirements, {
|
|
110
|
+
schema: appSchema,
|
|
111
|
+
table: config.graph.root_table,
|
|
112
|
+
columns: [config.graph.root_id_column],
|
|
113
|
+
reason: "root row lock and task subject lookup",
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
for (const rule of config.compliance_policy.retention_rules) {
|
|
117
|
+
for (const table of rule.if_has_data_in) {
|
|
118
|
+
addRequirement(requirements, {
|
|
119
|
+
schema: appSchema,
|
|
120
|
+
table,
|
|
121
|
+
columns: [config.graph.root_id_column],
|
|
122
|
+
reason: `retention evidence rule ${rule.rule_name}`,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
for (const target of config.satellite_targets) {
|
|
128
|
+
addRequirement(requirements, {
|
|
129
|
+
schema: appSchema,
|
|
130
|
+
table: target.table,
|
|
131
|
+
columns: [target.lookup_column],
|
|
132
|
+
reason: `satellite ${target.action} lookup`,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
for (const target of config.blob_targets) {
|
|
137
|
+
if (target.lookup_column) {
|
|
138
|
+
addRequirement(requirements, {
|
|
139
|
+
schema: appSchema,
|
|
140
|
+
table: target.table,
|
|
141
|
+
columns: [target.lookup_column],
|
|
142
|
+
reason: `blob target lookup for ${target.table}.${target.column}`,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
for (const rule of config.rules ?? []) {
|
|
148
|
+
for (const target of rule.targets) {
|
|
149
|
+
addCompiledTargetRequirements(requirements, target, appSchema);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return Array.from(requirements.values()).sort((left, right) =>
|
|
154
|
+
`${left.schema}.${left.table}.${left.columns.join(".")}`.localeCompare(
|
|
155
|
+
`${right.schema}.${right.table}.${right.columns.join(".")}`
|
|
156
|
+
)
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function hasIndexPrefix(
|
|
161
|
+
sql: Sql,
|
|
162
|
+
requirement: IndexRequirement
|
|
163
|
+
): Promise<boolean> {
|
|
164
|
+
const [row] = await sql<IndexPrefixRow[]>`
|
|
165
|
+
SELECT EXISTS (
|
|
166
|
+
SELECT 1
|
|
167
|
+
FROM pg_index AS idx
|
|
168
|
+
JOIN pg_class AS table_class
|
|
169
|
+
ON table_class.oid = idx.indrelid
|
|
170
|
+
JOIN pg_namespace AS table_namespace
|
|
171
|
+
ON table_namespace.oid = table_class.relnamespace
|
|
172
|
+
JOIN LATERAL unnest(idx.indkey) WITH ORDINALITY AS indexed(attnum, ordinality)
|
|
173
|
+
ON indexed.ordinality <= ${requirement.columns.length}
|
|
174
|
+
JOIN pg_attribute AS attribute
|
|
175
|
+
ON attribute.attrelid = table_class.oid
|
|
176
|
+
AND attribute.attnum = indexed.attnum
|
|
177
|
+
WHERE table_namespace.nspname = ${requirement.schema}
|
|
178
|
+
AND table_class.relname = ${requirement.table}
|
|
179
|
+
AND idx.indisvalid
|
|
180
|
+
AND idx.indisready
|
|
181
|
+
AND idx.indpred IS NULL
|
|
182
|
+
GROUP BY idx.indexrelid
|
|
183
|
+
HAVING array_agg(attribute.attname ORDER BY indexed.ordinality) = ${requirement.columns}
|
|
184
|
+
OR array_agg(attribute.attname ORDER BY attribute.attname) = ${[...requirement.columns].sort()}
|
|
185
|
+
) AS exists
|
|
186
|
+
`;
|
|
187
|
+
|
|
188
|
+
return row?.exists ?? false;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function tableHasColumn(
|
|
192
|
+
sql: Sql,
|
|
193
|
+
schema: string,
|
|
194
|
+
table: string,
|
|
195
|
+
column: string
|
|
196
|
+
): Promise<boolean> {
|
|
197
|
+
const [row] = await sql<ColumnExistsRow[]>`
|
|
198
|
+
SELECT EXISTS (
|
|
199
|
+
SELECT 1
|
|
200
|
+
FROM information_schema.columns
|
|
201
|
+
WHERE table_schema = ${schema}
|
|
202
|
+
AND table_name = ${table}
|
|
203
|
+
AND column_name = ${column}
|
|
204
|
+
) AS exists
|
|
205
|
+
`;
|
|
206
|
+
return row?.exists ?? false;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function collectTenantScopedRequirements(
|
|
210
|
+
sql: Sql,
|
|
211
|
+
requirements: IndexRequirement[]
|
|
212
|
+
): Promise<IndexRequirement[]> {
|
|
213
|
+
const tenantRequirements: IndexRequirement[] = [];
|
|
214
|
+
const seenTables = new Map<string, boolean>();
|
|
215
|
+
|
|
216
|
+
for (const requirement of requirements) {
|
|
217
|
+
if (requirement.columns.includes("tenant_id")) {
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (
|
|
221
|
+
requirement.reason.startsWith("root row lock") ||
|
|
222
|
+
requirement.reason.startsWith("compiled DAG parent join")
|
|
223
|
+
) {
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const key = `${requirement.schema}.${requirement.table}`;
|
|
228
|
+
let hasTenantId = seenTables.get(key);
|
|
229
|
+
if (hasTenantId === undefined) {
|
|
230
|
+
hasTenantId = await tableHasColumn(sql, requirement.schema, requirement.table, "tenant_id");
|
|
231
|
+
seenTables.set(key, hasTenantId);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (hasTenantId) {
|
|
235
|
+
tenantRequirements.push({
|
|
236
|
+
...requirement,
|
|
237
|
+
columns: [...requirement.columns, "tenant_id"],
|
|
238
|
+
reason: `${requirement.reason} with tenant isolation`,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return tenantRequirements;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Verifies that configured runtime lookups are backed by usable non-partial indexes.
|
|
248
|
+
*
|
|
249
|
+
* The worker can mutate safely only when root locks, retention evidence probes, satellite
|
|
250
|
+
* batches, and compiled-DAG joins are index-backed. This preflight prevents a bad YAML from
|
|
251
|
+
* creating table scans or broad lock pressure against very large tenant databases.
|
|
252
|
+
*
|
|
253
|
+
* @param sql - Postgres pool used for catalog inspection.
|
|
254
|
+
* @param config - Parsed worker configuration.
|
|
255
|
+
* @returns Requirement summary when every lookup is index-backed.
|
|
256
|
+
* @throws {WorkerError} When one or more required indexes are missing.
|
|
257
|
+
*/
|
|
258
|
+
export async function assertIndexPreflight(
|
|
259
|
+
sql: Sql,
|
|
260
|
+
config: WorkerConfig
|
|
261
|
+
): Promise<IndexPreflightResult> {
|
|
262
|
+
const baseRequirements = collectIndexRequirements(config);
|
|
263
|
+
const requirements = [
|
|
264
|
+
...baseRequirements,
|
|
265
|
+
...await collectTenantScopedRequirements(sql, baseRequirements),
|
|
266
|
+
];
|
|
267
|
+
const missing: IndexRequirement[] = [];
|
|
268
|
+
|
|
269
|
+
for (const requirement of requirements) {
|
|
270
|
+
if (!await hasIndexPrefix(sql, requirement)) {
|
|
271
|
+
missing.push(requirement);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (missing.length > 0) {
|
|
276
|
+
fail({
|
|
277
|
+
code: "INDEX_PREFLIGHT_FAILED",
|
|
278
|
+
title: "Required lookup indexes are missing",
|
|
279
|
+
detail: `Detected ${missing.length} missing index requirement(s): ${missing
|
|
280
|
+
.map((item) => `${item.schema}.${item.table}(${item.columns.join(", ")}) for ${item.reason}`)
|
|
281
|
+
.join("; ")}`,
|
|
282
|
+
category: "configuration",
|
|
283
|
+
retryable: false,
|
|
284
|
+
fatal: true,
|
|
285
|
+
context: {
|
|
286
|
+
checked: requirements.length,
|
|
287
|
+
missing,
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
checked: requirements.length,
|
|
294
|
+
missing,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import postgres from "postgres";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { UI, exitWithError } from "./ui";
|
|
4
|
+
import { assertSchemaIntegrity } from "@modules/bootstrap";
|
|
5
|
+
import { assertConfigSchemaCompatibility, readWorkerConfig } from "@modules/config";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Runs fail-closed integrity validation for CI/CD and worker boot gates.
|
|
9
|
+
*
|
|
10
|
+
* @param options - Manifest path and database URL.
|
|
11
|
+
* @returns Resolves only when schema hash and compiled DAG checks pass.
|
|
12
|
+
*/
|
|
13
|
+
export async function checkIntegrityAction(options: { config: string; url?: string }) {
|
|
14
|
+
UI.header("Fail-Closed Integrity Check");
|
|
15
|
+
|
|
16
|
+
const dbUrl = options.url || process.env.DATABASE_URL;
|
|
17
|
+
if (!dbUrl) {
|
|
18
|
+
exitWithError("Database URL required.", "Provide --url or set DATABASE_URL env.");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const configPath = path.resolve(options.config);
|
|
22
|
+
const mockEnv = {
|
|
23
|
+
...process.env,
|
|
24
|
+
DPDP_MASTER_KEY: process.env.DPDP_MASTER_KEY || "0".repeat(64),
|
|
25
|
+
DPDP_HMAC_KEY: process.env.DPDP_HMAC_KEY || "0".repeat(64),
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const config = await readWorkerConfig(mockEnv, configPath);
|
|
29
|
+
const expectedSchemaHash =
|
|
30
|
+
config.legal_attestation.schema_hash ?? config.integrity.expected_schema_hash;
|
|
31
|
+
const sql = postgres(dbUrl);
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
const detectedHash = await assertSchemaIntegrity(
|
|
35
|
+
sql,
|
|
36
|
+
config.database.app_schema,
|
|
37
|
+
expectedSchemaHash
|
|
38
|
+
);
|
|
39
|
+
await assertConfigSchemaCompatibility(sql, config);
|
|
40
|
+
|
|
41
|
+
UI.keyValue("Live Schema Hash", detectedHash);
|
|
42
|
+
UI.success("Schema hash, legal attestation, and compiled DAG are current.");
|
|
43
|
+
} catch (error) {
|
|
44
|
+
exitWithError("Integrity check failed", error instanceof Error ? error.message : String(error));
|
|
45
|
+
} finally {
|
|
46
|
+
await sql.end();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import postgres from "postgres";
|
|
2
|
+
import pc from "picocolors";
|
|
3
|
+
import { UI, exitWithError } from "./ui";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { readWorkerConfig } from "../config";
|
|
6
|
+
import { vaultUser } from "../engine";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Preview the results of a vault operation for a specific subject ID.
|
|
10
|
+
*/
|
|
11
|
+
export async function dryRunAction(options: {
|
|
12
|
+
id: string,
|
|
13
|
+
config: string,
|
|
14
|
+
url?: string,
|
|
15
|
+
}) {
|
|
16
|
+
UI.header("Vault Simulation");
|
|
17
|
+
|
|
18
|
+
const dbUrl = options.url || process.env.DATABASE_URL;
|
|
19
|
+
if (!dbUrl) exitWithError("Database URL required.", "Provider --url or set DATABASE_URL env.");
|
|
20
|
+
|
|
21
|
+
const configPath = path.resolve(options.config);
|
|
22
|
+
UI.info(`Subject : ${pc.bold(options.id)}`);
|
|
23
|
+
UI.info(`Manifest : ${pc.bold(options.config)}`);
|
|
24
|
+
|
|
25
|
+
const mockEnv = {
|
|
26
|
+
...process.env,
|
|
27
|
+
DPDP_MASTER_KEY: process.env.DPDP_MASTER_KEY || "0".repeat(64),
|
|
28
|
+
DPDP_HMAC_KEY: process.env.DPDP_HMAC_KEY || "0".repeat(64),
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
let config;
|
|
32
|
+
try {
|
|
33
|
+
config = await readWorkerConfig(mockEnv, configPath);
|
|
34
|
+
} catch (err) {
|
|
35
|
+
exitWithError("Config load failed", err instanceof Error ? err.message : String(err));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const spinner = UI.spinner(`Executing dry-run pipeline for ${options.id}`);
|
|
39
|
+
const sql = postgres(dbUrl);
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const result = await vaultUser(sql, options.id, {
|
|
43
|
+
kek: config.masterKey,
|
|
44
|
+
hmacKey: config.hmacKey,
|
|
45
|
+
}, {
|
|
46
|
+
appSchema: config.database.app_schema,
|
|
47
|
+
engineSchema: config.database.engine_schema,
|
|
48
|
+
defaultRetentionYears: config.compliance_policy.default_retention_years,
|
|
49
|
+
noticeWindowHours: config.compliance_policy.notice_window_hours,
|
|
50
|
+
graphMaxDepth: config.graph.max_depth,
|
|
51
|
+
rootTable: config.graph.root_table,
|
|
52
|
+
rootIdColumn: config.graph.root_id_column,
|
|
53
|
+
rootPiiColumns: config.graph.root_pii_columns,
|
|
54
|
+
satelliteTargets: config.satellite_targets,
|
|
55
|
+
blobTargets: config.blob_targets,
|
|
56
|
+
retentionRules: config.compliance_policy.retention_rules,
|
|
57
|
+
dryRun: true,
|
|
58
|
+
now: new Date(),
|
|
59
|
+
});
|
|
60
|
+
spinner.stop();
|
|
61
|
+
|
|
62
|
+
if (!result.plan) exitWithError("Internal Error", "Vault engine failed to return a plan.");
|
|
63
|
+
|
|
64
|
+
UI.step(1, "Executive Summary");
|
|
65
|
+
UI.subStep(result.plan.summary);
|
|
66
|
+
|
|
67
|
+
UI.step(2, "Integrity Checks");
|
|
68
|
+
result.plan.checks.forEach(c => UI.subStep(c));
|
|
69
|
+
|
|
70
|
+
UI.step(3, "Cryptographic Pipeline");
|
|
71
|
+
result.plan.cryptoSteps.forEach(s => UI.subStep(pc.yellow(s)));
|
|
72
|
+
|
|
73
|
+
UI.step(4, "Proposed SQL Transactions");
|
|
74
|
+
result.plan.sqlSteps.forEach(s => UI.subStep(pc.magenta(s)));
|
|
75
|
+
|
|
76
|
+
UI.divider();
|
|
77
|
+
UI.info("FINAL PREVIEW:");
|
|
78
|
+
UI.keyValue("Planned Action", result.action);
|
|
79
|
+
UI.keyValue("Worker Hash", result.userHash || "N/A");
|
|
80
|
+
UI.keyValue("Dependencies", result.dependencyCount.toString());
|
|
81
|
+
UI.keyValue("Retention", `${result.retentionYears ?? 0} years (${result.appliedRuleName})`);
|
|
82
|
+
|
|
83
|
+
UI.success("Dry-run complete. No production data was modified.");
|
|
84
|
+
} catch (err) {
|
|
85
|
+
spinner.fail("Simulation failed");
|
|
86
|
+
exitWithError("Dry-run error", err instanceof Error ? err.message : String(err));
|
|
87
|
+
} finally {
|
|
88
|
+
await sql.end();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { type DependencyNode, getDependencyGraph } from "@modules/db";
|
|
2
|
+
import { readWorkerConfig } from "@modules/config";
|
|
3
|
+
import pc from "picocolors";
|
|
4
|
+
import { UI, exitWithError } from "./ui";
|
|
5
|
+
import postgres from "postgres";
|
|
6
|
+
|
|
7
|
+
function renderTree(rootName: string, nodes: DependencyNode[]) {
|
|
8
|
+
const nodesByParent: Record<string, DependencyNode[]> = {};
|
|
9
|
+
nodes.forEach(n => {
|
|
10
|
+
if (!nodesByParent[n.parent_table]) nodesByParent[n.parent_table] = [];
|
|
11
|
+
nodesByParent[n.parent_table]?.push(n);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
function print(tableName: string, prefix: string, isLast: boolean) {
|
|
15
|
+
const children = nodesByParent[tableName] || [];
|
|
16
|
+
children.forEach((child, i) => {
|
|
17
|
+
const isChildLast = i === children.length - 1;
|
|
18
|
+
const marker = isChildLast ? "└── " : "├── ";
|
|
19
|
+
const unsafe = ["CASCADE", "SET_NULL", "SET_DEFAULT"].includes(child.delete_action);
|
|
20
|
+
const color = unsafe ? pc.red : pc.cyan;
|
|
21
|
+
|
|
22
|
+
console.log(`${prefix}${marker}${color(child.table_name)} ${pc.gray(`(${child.column_name})`)}`);
|
|
23
|
+
print(child.table_name, prefix + (isChildLast ? " " : "│ "), isChildLast);
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
print(rootName, "", true);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Visualizes the recursive foreign-key graph.
|
|
32
|
+
*/
|
|
33
|
+
export async function graphAction(options: {
|
|
34
|
+
table: string,
|
|
35
|
+
url?: string,
|
|
36
|
+
schema?: string,
|
|
37
|
+
maxDepth: string,
|
|
38
|
+
}): Promise<void> {
|
|
39
|
+
UI.header("Dependecy Graph Audit");
|
|
40
|
+
|
|
41
|
+
const dbUrl = options.url || process.env.DATABASE_URL
|
|
42
|
+
if (!dbUrl) exitWithError("Database URL required.", "Pass --url or set DATABASE_URL env.");
|
|
43
|
+
|
|
44
|
+
let appSchema = options.schema;
|
|
45
|
+
if (!appSchema) {
|
|
46
|
+
try {
|
|
47
|
+
const config = await readWorkerConfig(process.env);
|
|
48
|
+
appSchema = config.database.app_schema;
|
|
49
|
+
} catch (err) {
|
|
50
|
+
exitWithError("Schema missing.", "Provide --schema or ensure compliance.worker.yml exists.");
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const maxDepth = parseInt(options.maxDepth, 10);
|
|
55
|
+
const spinner = UI.spinner(`Crawling relationships for ${appSchema}.${options.table}...`);
|
|
56
|
+
const sql = postgres(dbUrl);
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
const nodes = await getDependencyGraph(sql, appSchema, options.table, {
|
|
60
|
+
maxDepth,
|
|
61
|
+
failOnUnsafeDeleteAction: false,
|
|
62
|
+
});
|
|
63
|
+
spinner.stop();
|
|
64
|
+
|
|
65
|
+
if (nodes.length === 0) {
|
|
66
|
+
UI.success("Leaf table detected: 0 downstream dependencies found.");
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
console.log(`\n${pc.bold(pc.white(options.table))}`);
|
|
71
|
+
renderTree(options.table, nodes);
|
|
72
|
+
|
|
73
|
+
const unsafe = nodes.filter((n) => ["CASCADE", "SET_NULL", "SET_DEFAULT"].includes(n.delete_action));
|
|
74
|
+
if (unsafe.length > 0) {
|
|
75
|
+
UI.error(`${unsafe.length} UNSAFE CONSTRAINTS FOUND`);
|
|
76
|
+
unsafe.forEach(n => console.log(` ${pc.red("✖")} ${n.table_name}.${n.column_name} — ON DELETE ${n.delete_action}`));
|
|
77
|
+
UI.warn("Live execution will fail until these are converted to NO ACTION or RESTRICT.");
|
|
78
|
+
} else {
|
|
79
|
+
UI.success("Graph validation passed: All constraints are safe for anonymization.");
|
|
80
|
+
}
|
|
81
|
+
} catch (err) {
|
|
82
|
+
spinner.fail("Graph crawl failed");
|
|
83
|
+
exitWithError("Analysis error", err instanceof Error ? err.message : String(err));
|
|
84
|
+
} finally {
|
|
85
|
+
sql.end();
|
|
86
|
+
}
|
|
87
|
+
}
|