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,158 @@
|
|
|
1
|
+
import { fail } from "@/errors";
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
const IV_LENGTH = 12; // 96-bit IV is the industry standard for GCM.
|
|
5
|
+
const KEY_LENGTH = 32; // 256-bit key for AES-256.
|
|
6
|
+
const textEncoder = new TextEncoder();
|
|
7
|
+
const textDecoder = new TextDecoder();
|
|
8
|
+
|
|
9
|
+
function assertAesKeyLength(rawKey: Uint8Array) {
|
|
10
|
+
if (rawKey.length !== KEY_LENGTH) {
|
|
11
|
+
fail({
|
|
12
|
+
code: "CRYPTO_INVALID_KEY_LENGTH",
|
|
13
|
+
title: "Invalid AES key length",
|
|
14
|
+
detail: `Invalid key length. Expected ${KEY_LENGTH} bytes for AES-256, got ${rawKey.length} bytes.`,
|
|
15
|
+
category: "crypto",
|
|
16
|
+
retryable: false,
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function toOwnedArrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
|
22
|
+
const copy = new Uint8Array(bytes.byteLength);
|
|
23
|
+
copy.set(bytes);
|
|
24
|
+
return copy.buffer as ArrayBuffer;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function importAesKey(rawKey: Uint8Array, usages: readonly ("encrypt" | "decrypt")[]): Promise<CryptoKey> {
|
|
28
|
+
assertAesKeyLength(rawKey);
|
|
29
|
+
|
|
30
|
+
const keyBytes = rawKey.slice();
|
|
31
|
+
try {
|
|
32
|
+
return await globalThis.crypto.subtle.importKey(
|
|
33
|
+
"raw",
|
|
34
|
+
toOwnedArrayBuffer(keyBytes),
|
|
35
|
+
"AES-GCM",
|
|
36
|
+
false,
|
|
37
|
+
[...usages]
|
|
38
|
+
);
|
|
39
|
+
} finally {
|
|
40
|
+
keyBytes.fill(0);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Encrypts raw bytes using AES-256-GCM.
|
|
46
|
+
*
|
|
47
|
+
* This overload exists for sensitive call sites that need direct control over plaintext buffer
|
|
48
|
+
* lifecycle so the caller can explicitly wipe the source bytes after encryption.
|
|
49
|
+
*
|
|
50
|
+
* @param plaintext - Raw plaintext bytes to encrypt.
|
|
51
|
+
* @param rawKey - 32-byte symmetric key.
|
|
52
|
+
* @returns Combined buffer in `IV || ciphertext+tag` format.
|
|
53
|
+
* @throws {WorkerError} When key length is invalid.
|
|
54
|
+
*/
|
|
55
|
+
export async function encryptGCMBytes(plaintext: Uint8Array, rawKey: Uint8Array): Promise<Uint8Array> {
|
|
56
|
+
const key = await importAesKey(rawKey, ["encrypt"]);
|
|
57
|
+
const iv = globalThis.crypto.getRandomValues(new Uint8Array(IV_LENGTH));
|
|
58
|
+
const ciphertextBuffer = await globalThis.crypto.subtle.encrypt(
|
|
59
|
+
{ name: "AES-GCM", iv },
|
|
60
|
+
key,
|
|
61
|
+
toOwnedArrayBuffer(plaintext)
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const combined = new Uint8Array(iv.length + ciphertextBuffer.byteLength);
|
|
65
|
+
combined.set(iv);
|
|
66
|
+
combined.set(new Uint8Array(ciphertextBuffer), iv.length);
|
|
67
|
+
|
|
68
|
+
return combined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Encrypts UTF-8 plaintext using AES-256-GCM.
|
|
73
|
+
*
|
|
74
|
+
* @param plaintext - Text payload to encrypt.
|
|
75
|
+
* @param rawKey - 32-byte symmetric key.
|
|
76
|
+
* @returns Combined buffer in `IV || ciphertext+tag` format.
|
|
77
|
+
* @throws {WorkerError} When key length is invalid.
|
|
78
|
+
*/
|
|
79
|
+
export async function encryptGCM(plaintext: string, rawKey: Uint8Array): Promise<Uint8Array> {
|
|
80
|
+
const plaintextBytes = textEncoder.encode(plaintext);
|
|
81
|
+
try {
|
|
82
|
+
return await encryptGCMBytes(plaintextBytes, rawKey);
|
|
83
|
+
} finally {
|
|
84
|
+
plaintextBytes.fill(0);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Decrypts a buffer in `IV || ciphertext+tag` format.
|
|
90
|
+
*
|
|
91
|
+
* Returns raw bytes so high-sensitivity callers can zero the decrypted buffer immediately after
|
|
92
|
+
* parsing, instead of leaving plaintext in immutable JS string storage.
|
|
93
|
+
*
|
|
94
|
+
* @param combined - Combined encrypted payload produced by `encryptGCM`.
|
|
95
|
+
* @param rawKey - 32-byte symmetric key.
|
|
96
|
+
* @returns Decrypted plaintext bytes.
|
|
97
|
+
* @throws {WorkerError} When key/ciphertext is invalid or integrity verification fails.
|
|
98
|
+
*/
|
|
99
|
+
export async function decryptGCMBytes(combined: Uint8Array, rawKey: Uint8Array): Promise<Uint8Array> {
|
|
100
|
+
assertAesKeyLength(rawKey);
|
|
101
|
+
|
|
102
|
+
if (combined.length < IV_LENGTH + 16) {
|
|
103
|
+
fail({
|
|
104
|
+
code: "CRYPTO_INVALID_CIPHERTEXT",
|
|
105
|
+
title: "Invalid ciphertext",
|
|
106
|
+
detail: "Invalid ciphertext. Too short to be a valid AES-GCM payload.",
|
|
107
|
+
category: "crypto",
|
|
108
|
+
retryable: false,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const crypto = globalThis.crypto;
|
|
113
|
+
|
|
114
|
+
const iv = combined.slice(0, IV_LENGTH);
|
|
115
|
+
const ciphertext = combined.slice(IV_LENGTH);
|
|
116
|
+
|
|
117
|
+
const key = await importAesKey(rawKey, ["decrypt"]);
|
|
118
|
+
|
|
119
|
+
let decryptBuffer: ArrayBuffer;
|
|
120
|
+
try {
|
|
121
|
+
decryptBuffer = await crypto.subtle.decrypt(
|
|
122
|
+
{ name: "AES-GCM", iv },
|
|
123
|
+
key,
|
|
124
|
+
toOwnedArrayBuffer(ciphertext)
|
|
125
|
+
);
|
|
126
|
+
} catch (error) {
|
|
127
|
+
fail({
|
|
128
|
+
code: "CRYPTO_INTEGRITY_FAILURE",
|
|
129
|
+
title: "AES-GCM integrity verification failed",
|
|
130
|
+
detail: "Decryption failed because the ciphertext or auth tag was corrupted.",
|
|
131
|
+
category: "crypto",
|
|
132
|
+
retryable: false,
|
|
133
|
+
cause: error,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return new Uint8Array(decryptBuffer);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Decrypts a buffer in `IV || ciphertext+tag` format` and decodes it as UTF-8 text.
|
|
142
|
+
*
|
|
143
|
+
* Prefer `decryptGCMBytes` when handling raw PII so the caller can explicitly wipe the plaintext
|
|
144
|
+
* buffer after use.
|
|
145
|
+
*
|
|
146
|
+
* @param combined - Combined encrypted payload produced by `encryptGCM`.
|
|
147
|
+
* @param rawKey - 32-byte symmetric key.
|
|
148
|
+
* @returns Decrypted UTF-8 plaintext.
|
|
149
|
+
* @throws {WorkerError} When key/ciphertext is invalid or integrity verification fails.
|
|
150
|
+
*/
|
|
151
|
+
export async function decryptGCM(combined: Uint8Array, rawKey: Uint8Array): Promise<string> {
|
|
152
|
+
const decryptedBytes = await decryptGCMBytes(combined, rawKey);
|
|
153
|
+
try {
|
|
154
|
+
return textDecoder.decode(decryptedBytes);
|
|
155
|
+
} finally {
|
|
156
|
+
decryptedBytes.fill(0);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { base64ToBytes } from "@/lib";
|
|
2
|
+
import { decryptGCMBytes, encryptGCMBytes } from "./aes";
|
|
3
|
+
|
|
4
|
+
const KEY_SIZE = 32;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Generates a new random 32-byte data-encryption key.
|
|
8
|
+
*
|
|
9
|
+
* @returns Cryptographically secure DEK bytes.
|
|
10
|
+
*/
|
|
11
|
+
export function generateDEK(): Uint8Array {
|
|
12
|
+
const crypto = globalThis.crypto;
|
|
13
|
+
return crypto.getRandomValues(new Uint8Array(KEY_SIZE));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Wraps a DEK with the worker KEK.
|
|
18
|
+
*
|
|
19
|
+
* @param dek - Plain DEK bytes.
|
|
20
|
+
* @param kek - 32-byte KEK bytes.
|
|
21
|
+
* @returns Encrypted DEK blob.
|
|
22
|
+
*/
|
|
23
|
+
export async function wrapKey(dek: Uint8Array, kek: Uint8Array): Promise<Uint8Array> {
|
|
24
|
+
return encryptGCMBytes(dek, kek);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Unwraps a previously wrapped DEK with the worker KEK.
|
|
29
|
+
*
|
|
30
|
+
* @param wrappedKey - Encrypted DEK blob.
|
|
31
|
+
* @param kek - 32-byte KEK bytes.
|
|
32
|
+
* @returns Plain DEK bytes.
|
|
33
|
+
*/
|
|
34
|
+
export async function unwrapKey(wrappedKey: Uint8Array, kek: Uint8Array): Promise<Uint8Array> {
|
|
35
|
+
const decrypted = await decryptGCMBytes(wrappedKey, kek);
|
|
36
|
+
if (decrypted.length === KEY_SIZE) {
|
|
37
|
+
const dek = decrypted.slice();
|
|
38
|
+
decrypted.fill(0);
|
|
39
|
+
return dek;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const legacyText = new TextDecoder().decode(decrypted);
|
|
44
|
+
return base64ToBytes(legacyText);
|
|
45
|
+
} finally {
|
|
46
|
+
decrypted.fill(0);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { bytesToHex } from "@/lib";
|
|
2
|
+
|
|
3
|
+
const textEncoder = new TextEncoder();
|
|
4
|
+
|
|
5
|
+
function copyToOwnedBytes(bytes: Uint8Array): Uint8Array {
|
|
6
|
+
const owned = new Uint8Array(bytes.byteLength);
|
|
7
|
+
owned.set(bytes);
|
|
8
|
+
return owned;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Imports HMAC-SHA256 key material once for repeated pseudonymization operations.
|
|
13
|
+
*
|
|
14
|
+
* @param keyMaterial - Raw key bytes or a legacy string secret.
|
|
15
|
+
* @returns Web Crypto HMAC key suitable for repeated `sign` calls.
|
|
16
|
+
*/
|
|
17
|
+
export async function importHmacKey(keyMaterial: Uint8Array | string): Promise<CryptoKey> {
|
|
18
|
+
const rawKey = typeof keyMaterial === "string"
|
|
19
|
+
? copyToOwnedBytes(textEncoder.encode(keyMaterial))
|
|
20
|
+
: copyToOwnedBytes(keyMaterial);
|
|
21
|
+
const keyBuffer = rawKey.buffer.slice(rawKey.byteOffset, rawKey.byteOffset + rawKey.byteLength) as ArrayBuffer;
|
|
22
|
+
return globalThis.crypto.subtle.importKey(
|
|
23
|
+
"raw",
|
|
24
|
+
keyBuffer,
|
|
25
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
26
|
+
false,
|
|
27
|
+
["sign"]
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Computes deterministic HMAC-SHA256 with a pre-imported Web Crypto key.
|
|
33
|
+
*
|
|
34
|
+
* @param input - Plain input string to sign.
|
|
35
|
+
* @param key - Pre-imported HMAC-SHA256 key.
|
|
36
|
+
* @returns Lowercase hex digest.
|
|
37
|
+
*/
|
|
38
|
+
export async function generateHMACWithKey(input: string, key: CryptoKey): Promise<string> {
|
|
39
|
+
const signature = await globalThis.crypto.subtle.sign(
|
|
40
|
+
"HMAC",
|
|
41
|
+
key,
|
|
42
|
+
textEncoder.encode(input)
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
return bytesToHex(new Uint8Array(signature));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Computes deterministic HMAC-SHA256 for worker pseudonymization and lookup keys.
|
|
50
|
+
*
|
|
51
|
+
* Prefer `importHmacKey` + `generateHMACWithKey` when signing many values in one request.
|
|
52
|
+
*
|
|
53
|
+
* @param input - Plain input string to sign.
|
|
54
|
+
* @param salt - HMAC key material (salt/secret).
|
|
55
|
+
* @returns Lowercase hex digest.
|
|
56
|
+
*/
|
|
57
|
+
export async function generateHMAC(input: string, salt: string): Promise<string> {
|
|
58
|
+
const key = await importHmacKey(salt);
|
|
59
|
+
return generateHMACWithKey(input, key);
|
|
60
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Sql } from "@/types";
|
|
2
|
+
import { assertIdentifier } from "@/utils";
|
|
3
|
+
import { sha256HexDigest } from "@/lib";
|
|
4
|
+
|
|
5
|
+
interface SchemaColumnRow {
|
|
6
|
+
table_name: string;
|
|
7
|
+
column_name: string;
|
|
8
|
+
data_type: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Computes a deterministic SHA-256 fingerprint of the live application schema.
|
|
13
|
+
*
|
|
14
|
+
* The signature is built from ordered `table_name + column_name + data_type` tuples.
|
|
15
|
+
*
|
|
16
|
+
* @param sql - Postgres pool or transaction used for metadata query.
|
|
17
|
+
* @param appSchema - Application schema to fingerprint.
|
|
18
|
+
* @returns Hex-encoded SHA-256 schema hash.
|
|
19
|
+
* @throws {WorkerError} When `appSchema` is not a safe SQL identifier.
|
|
20
|
+
*/
|
|
21
|
+
export async function detectSchemaDrift(sql: Sql, appSchema: string): Promise<string> {
|
|
22
|
+
const safeAppSchema = assertIdentifier(appSchema, "application schema name");
|
|
23
|
+
|
|
24
|
+
const columns = await sql<SchemaColumnRow[]>`
|
|
25
|
+
SELECT table_name, column_name, data_type
|
|
26
|
+
FROM information_schema.columns
|
|
27
|
+
WHERE table_schema = ${safeAppSchema}
|
|
28
|
+
ORDER BY table_name ASC, ordinal_position ASC, column_name ASC
|
|
29
|
+
`;
|
|
30
|
+
|
|
31
|
+
const signature = columns.map(
|
|
32
|
+
(column) => `${column.table_name}${column.column_name}${column.data_type}`)
|
|
33
|
+
.join("");
|
|
34
|
+
|
|
35
|
+
return sha256HexDigest(signature)
|
|
36
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dependency-graph discovery using PostgreSQL catalog metadata and recursive CTE traversal.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { assertIdentifier, quoteQualifiedIdentifier } from "@/utils";
|
|
6
|
+
import { fail } from "@/errors";
|
|
7
|
+
import type { SqlExecutor } from "@/types";
|
|
8
|
+
|
|
9
|
+
export interface DependencyNode {
|
|
10
|
+
table_schema: string;
|
|
11
|
+
table_name: string;
|
|
12
|
+
column_name: string;
|
|
13
|
+
parent_table: string;
|
|
14
|
+
delete_action: "NO_ACTION" | "RESTRICT" | "CASCADE" | "SET_NULL" | "SET_DEFAULT" | "UNKNOWN";
|
|
15
|
+
depth: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface DependencyGraphOptions {
|
|
19
|
+
maxDepth?: number;
|
|
20
|
+
failOnUnsafeDeleteAction?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const DEFAULT_MAX_DEPTH = 32;
|
|
24
|
+
const UNSAFE_DELETE_ACTIONS = new Set(["CASCADE", "SET_NULL", "SET_DEFAULT"]);
|
|
25
|
+
|
|
26
|
+
function normalizeDeleteAction(value: string): DependencyNode["delete_action"] {
|
|
27
|
+
switch (value) {
|
|
28
|
+
case "a":
|
|
29
|
+
return "NO_ACTION";
|
|
30
|
+
case "r":
|
|
31
|
+
return "RESTRICT";
|
|
32
|
+
case "c":
|
|
33
|
+
return "CASCADE";
|
|
34
|
+
case "n":
|
|
35
|
+
return "SET_NULL";
|
|
36
|
+
case "d":
|
|
37
|
+
return "SET_DEFAULT";
|
|
38
|
+
default:
|
|
39
|
+
return "UNKNOWN";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function resolveMaxDepth(input?: number): number {
|
|
44
|
+
if (input === undefined) {
|
|
45
|
+
return DEFAULT_MAX_DEPTH;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (!Number.isInteger(input) || input < 1) {
|
|
49
|
+
fail({
|
|
50
|
+
code: "GRAPH_MAX_DEPTH_INVALID",
|
|
51
|
+
title: "Invalid graph max depth",
|
|
52
|
+
detail: "maxDepth must be an integer greater than 0.",
|
|
53
|
+
category: "validation",
|
|
54
|
+
retryable: false,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return input;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Discovers the transitive foreign-key dependency graph for a root table.
|
|
63
|
+
*
|
|
64
|
+
* The recursive CTE tracks visited OIDs to prevent cyclic loops, fails closed when the configured
|
|
65
|
+
* depth limit is reached, and rejects FK actions that would silently delete or rewrite dependent
|
|
66
|
+
* records outside the worker's explicit vault/redaction logic.
|
|
67
|
+
*
|
|
68
|
+
* @param sql - Postgres pool or active transaction.
|
|
69
|
+
* @param schema - Root table schema.
|
|
70
|
+
* @param rootTable - Root table name.
|
|
71
|
+
* @param options - Optional traversal controls.
|
|
72
|
+
* @returns Ordered dependency nodes containing table/column lineage metadata.
|
|
73
|
+
* @throws {WorkerError} When root table is missing, depth is invalid, unsafe FK actions are present,
|
|
74
|
+
* or the traversal depth limit is reached.
|
|
75
|
+
*/
|
|
76
|
+
export async function getDependencyGraph(
|
|
77
|
+
sql: SqlExecutor,
|
|
78
|
+
schema: string,
|
|
79
|
+
rootTable: string,
|
|
80
|
+
options: DependencyGraphOptions = {}
|
|
81
|
+
): Promise<DependencyNode[]> {
|
|
82
|
+
const safeSchema = assertIdentifier(schema, "schema name");
|
|
83
|
+
const safeRootTable = assertIdentifier(rootTable, "table name");
|
|
84
|
+
const maxDepth = resolveMaxDepth(options.maxDepth);
|
|
85
|
+
const qualifiedRoot = quoteQualifiedIdentifier(safeSchema, safeRootTable);
|
|
86
|
+
|
|
87
|
+
const [rootExists] = await sql<{ oid: string | null }[]>`
|
|
88
|
+
SELECT to_regclass(${qualifiedRoot})::text AS oid
|
|
89
|
+
`;
|
|
90
|
+
|
|
91
|
+
if (!rootExists?.oid) {
|
|
92
|
+
fail({
|
|
93
|
+
code: "GRAPH_ROOT_TABLE_MISSING",
|
|
94
|
+
title: "Root table not found",
|
|
95
|
+
detail: `Root table ${safeSchema}.${safeRootTable} does not exist.`,
|
|
96
|
+
category: "validation",
|
|
97
|
+
retryable: false,
|
|
98
|
+
context: { schema: safeSchema, rootTable: safeRootTable },
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const result = await sql<
|
|
103
|
+
Array<
|
|
104
|
+
Omit<DependencyNode, "delete_action"> & {
|
|
105
|
+
delete_action_code: string;
|
|
106
|
+
table_oid: number;
|
|
107
|
+
reached_limit: boolean;
|
|
108
|
+
}
|
|
109
|
+
>
|
|
110
|
+
>`
|
|
111
|
+
WITH RECURSIVE dependency_tree AS (
|
|
112
|
+
SELECT
|
|
113
|
+
connamespace::regnamespace::text AS table_schema,
|
|
114
|
+
conrelid::regclass::text AS table_name,
|
|
115
|
+
a.attname AS column_name,
|
|
116
|
+
confrelid::regclass::text AS parent_table,
|
|
117
|
+
c.confdeltype::text AS delete_action_code,
|
|
118
|
+
conrelid::oid AS table_oid,
|
|
119
|
+
ARRAY[confrelid::oid, conrelid::oid] AS path,
|
|
120
|
+
1 AS depth,
|
|
121
|
+
FALSE AS reached_limit
|
|
122
|
+
FROM pg_constraint c
|
|
123
|
+
JOIN pg_attribute a
|
|
124
|
+
ON a.attrelid = c.conrelid
|
|
125
|
+
AND a.attnum = ANY(c.conkey)
|
|
126
|
+
WHERE c.contype = 'f'
|
|
127
|
+
AND c.confrelid = to_regclass(${qualifiedRoot})
|
|
128
|
+
|
|
129
|
+
UNION ALL
|
|
130
|
+
|
|
131
|
+
SELECT
|
|
132
|
+
child.connamespace::regnamespace::text AS table_schema,
|
|
133
|
+
child.conrelid::regclass::text AS table_name,
|
|
134
|
+
a.attname AS column_name,
|
|
135
|
+
child.confrelid::regclass::text AS parent_table,
|
|
136
|
+
child.confdeltype::text AS delete_action_code,
|
|
137
|
+
child.conrelid::oid AS table_oid,
|
|
138
|
+
dt.path || child.conrelid::oid AS path,
|
|
139
|
+
dt.depth + 1 AS depth,
|
|
140
|
+
dt.depth + 1 >= ${maxDepth} AS reached_limit
|
|
141
|
+
FROM pg_constraint child
|
|
142
|
+
JOIN pg_attribute a
|
|
143
|
+
ON a.attrelid = child.conrelid
|
|
144
|
+
AND a.attnum = ANY(child.conkey)
|
|
145
|
+
JOIN dependency_tree dt
|
|
146
|
+
ON child.confrelid = dt.table_oid
|
|
147
|
+
WHERE child.contype = 'f'
|
|
148
|
+
AND dt.depth < ${maxDepth}
|
|
149
|
+
AND NOT child.conrelid::oid = ANY(dt.path)
|
|
150
|
+
)
|
|
151
|
+
SELECT DISTINCT ON (table_name, column_name)
|
|
152
|
+
table_schema,
|
|
153
|
+
table_name,
|
|
154
|
+
column_name,
|
|
155
|
+
parent_table,
|
|
156
|
+
delete_action_code,
|
|
157
|
+
depth,
|
|
158
|
+
table_oid,
|
|
159
|
+
reached_limit
|
|
160
|
+
FROM dependency_tree
|
|
161
|
+
ORDER BY table_name, column_name, depth ASC
|
|
162
|
+
`;
|
|
163
|
+
|
|
164
|
+
const graph = result.map(({ table_oid: _tableOid, reached_limit: _reachedLimit, delete_action_code, ...node }) => ({
|
|
165
|
+
...node,
|
|
166
|
+
delete_action: normalizeDeleteAction(delete_action_code),
|
|
167
|
+
}));
|
|
168
|
+
|
|
169
|
+
if (options.failOnUnsafeDeleteAction !== false) {
|
|
170
|
+
const unsafe = graph.find((node) => UNSAFE_DELETE_ACTIONS.has(node.delete_action));
|
|
171
|
+
if (unsafe) {
|
|
172
|
+
fail({
|
|
173
|
+
code: "GRAPH_UNSAFE_DELETE_ACTION",
|
|
174
|
+
title: "Unsafe foreign-key delete action detected",
|
|
175
|
+
detail: `Foreign key ${unsafe.table_name}.${unsafe.column_name} uses ON DELETE ${unsafe.delete_action}; the worker refuses to run because Postgres could mutate dependent data outside the explicit erasure plan.`,
|
|
176
|
+
category: "integrity",
|
|
177
|
+
retryable: false,
|
|
178
|
+
fatal: true,
|
|
179
|
+
context: {
|
|
180
|
+
schema: safeSchema,
|
|
181
|
+
rootTable: safeRootTable,
|
|
182
|
+
table: unsafe.table_name,
|
|
183
|
+
column: unsafe.column_name,
|
|
184
|
+
deleteAction: unsafe.delete_action,
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (result.some((row) => row.depth >= maxDepth || row.reached_limit)) {
|
|
191
|
+
fail({
|
|
192
|
+
code: "GRAPH_DEPTH_LIMIT_REACHED",
|
|
193
|
+
title: "Dependency graph depth limit reached",
|
|
194
|
+
detail: `Dependency graph for ${safeSchema}.${safeRootTable} reached the safety limit of ${maxDepth}. Increase maxDepth before running destructive operations.`,
|
|
195
|
+
category: "integrity",
|
|
196
|
+
retryable: false,
|
|
197
|
+
fatal: true,
|
|
198
|
+
context: { schema: safeSchema, rootTable: safeRootTable, maxDepth },
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return graph;
|
|
203
|
+
}
|