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,44 @@
|
|
|
1
|
+
import type { BlobTarget } from "@modules/config";
|
|
2
|
+
import type { S3Client } from "@modules/network";
|
|
3
|
+
|
|
4
|
+
export type BlobAction = BlobTarget["action"];
|
|
5
|
+
|
|
6
|
+
export interface BlobProtectionResult {
|
|
7
|
+
sourceTable: string;
|
|
8
|
+
sourceColumn: string;
|
|
9
|
+
provider: "aws_s3";
|
|
10
|
+
action: BlobAction;
|
|
11
|
+
objectRefHash: string;
|
|
12
|
+
versionIdHash: string;
|
|
13
|
+
legalHoldApplied: boolean;
|
|
14
|
+
overwriteApplied: boolean;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export interface DiscoveredBlobObject {
|
|
18
|
+
target: BlobTarget;
|
|
19
|
+
sourceTable: string;
|
|
20
|
+
sourceColumn: string;
|
|
21
|
+
originalValue: string;
|
|
22
|
+
maskedValue: string;
|
|
23
|
+
bucket: string;
|
|
24
|
+
key: string;
|
|
25
|
+
versionId: string;
|
|
26
|
+
eTag: string | null;
|
|
27
|
+
overwriteETag: string | null;
|
|
28
|
+
overwriteVersionId: string | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface BlobShredReceipt {
|
|
32
|
+
provider: "aws_s3";
|
|
33
|
+
action: BlobAction;
|
|
34
|
+
objectRefHash: string;
|
|
35
|
+
versionCount: number;
|
|
36
|
+
deletedVersionIdHashes: string[];
|
|
37
|
+
retainedVersionIdHashes: string[];
|
|
38
|
+
status: "purged" | "captured_version_deleted" | "retained_by_policy";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface BlobWorkflowOptions {
|
|
42
|
+
s3Client?: S3Client;
|
|
43
|
+
}
|
|
44
|
+
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { bytesToBase64 } from "@/lib";
|
|
2
|
+
import { generateHMAC } from "@modules/crypto/hmac";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Produces a deterministic subject hash used as the worker's irreversible lookup key.
|
|
6
|
+
*
|
|
7
|
+
* @param rootId - Subject identifier in the source schema.
|
|
8
|
+
* @param appSchema - Source schema name.
|
|
9
|
+
* @param rootTable - Source root table name.
|
|
10
|
+
* @param hmacKey - HMAC key bytes.
|
|
11
|
+
* @param tenantId - Optional tenant discriminator.
|
|
12
|
+
* @returns Stable HMAC-SHA256 hex digest.
|
|
13
|
+
*/
|
|
14
|
+
export async function createUserHash(
|
|
15
|
+
rootId: string | number,
|
|
16
|
+
appSchema: string,
|
|
17
|
+
rootTable: string,
|
|
18
|
+
hmacKey: Uint8Array,
|
|
19
|
+
tenantId?: string
|
|
20
|
+
): Promise<string> {
|
|
21
|
+
return generateHMAC(
|
|
22
|
+
`${appSchema}:${rootTable}:${tenantId ?? ""}:${rootId}`,
|
|
23
|
+
bytesToBase64(hmacKey)
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Derives an irreversible synthetic email for downstream systems that still require an address.
|
|
29
|
+
*
|
|
30
|
+
* @param userId - Source subject identifier.
|
|
31
|
+
* @param email - Original email value from the root payload.
|
|
32
|
+
* @param salt - Per-row salt stored in vault metadata.
|
|
33
|
+
* @param hmacKey - HMAC key bytes.
|
|
34
|
+
* @returns Pseudonymous `dpdp_...@dpdp.invalid` address.
|
|
35
|
+
*/
|
|
36
|
+
export async function createPseudonym(
|
|
37
|
+
userId: string | number,
|
|
38
|
+
email: string,
|
|
39
|
+
salt: string,
|
|
40
|
+
hmacKey: Uint8Array
|
|
41
|
+
): Promise<string> {
|
|
42
|
+
const digest = await generateHMAC(
|
|
43
|
+
`${userId}:${email}`,
|
|
44
|
+
`${salt}:${bytesToBase64(hmacKey)}`
|
|
45
|
+
);
|
|
46
|
+
return `dpdp_${digest.slice(0, 24)}@dpdp.invalid`;
|
|
47
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { fail } from "@/errors";
|
|
2
|
+
import type { OutboxRow } from "@modules/network";
|
|
3
|
+
import type { SqlExecutor } from "@/types";
|
|
4
|
+
import { canonicalJsonStringify } from "@/utils";
|
|
5
|
+
import type { JSONValue } from "postgres";
|
|
6
|
+
|
|
7
|
+
const UNFINALIZED_PREVIOUS_HASH = "UNFINALIZED";
|
|
8
|
+
const UNFINALIZED_CURRENT_HASH = "0000000000000000000000000000000000000000000000000000000000000000";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Enqueues a tamper-evident outbox event inside the current transaction scope.
|
|
12
|
+
*
|
|
13
|
+
* The function is idempotent by `idempotency_key` and inserts an unfinalized chain record.
|
|
14
|
+
* Hash-chain finalization is intentionally performed by the relay in a short read-committed
|
|
15
|
+
* transaction after commit, avoiding repeatable-read snapshot forks under multi-worker load.
|
|
16
|
+
*
|
|
17
|
+
* @param sql - Postgres pool or transaction.
|
|
18
|
+
* @param engineSchema - Worker engine schema.
|
|
19
|
+
* @param userHash - Subject hash associated with the event.
|
|
20
|
+
* @param eventType - Outbox event type.
|
|
21
|
+
* @param payload - JSON-serializable event payload.
|
|
22
|
+
* @param idempotencyKey - Global idempotency key for replay safety.
|
|
23
|
+
* @param now - Event creation timestamp.
|
|
24
|
+
* @returns Existing or newly inserted outbox row.
|
|
25
|
+
* @throws {WorkerError} When payload is non-serializable or insert invariants are violated.
|
|
26
|
+
*/
|
|
27
|
+
export async function enqueueOutboxEvent(
|
|
28
|
+
sql: SqlExecutor,
|
|
29
|
+
engineSchema: string,
|
|
30
|
+
userHash: string,
|
|
31
|
+
eventType: string,
|
|
32
|
+
payload: unknown,
|
|
33
|
+
idempotencyKey: string,
|
|
34
|
+
now: Date
|
|
35
|
+
): Promise<OutboxRow> {
|
|
36
|
+
const jsonPayload = payload as JSONValue;
|
|
37
|
+
try {
|
|
38
|
+
canonicalJsonStringify(jsonPayload);
|
|
39
|
+
} catch {
|
|
40
|
+
fail({
|
|
41
|
+
code: "OUTBOX_PAYLOAD_INVALID",
|
|
42
|
+
title: "Invalid outbox payload",
|
|
43
|
+
detail: "Outbox payload must be JSON-serializable.",
|
|
44
|
+
category: "validation",
|
|
45
|
+
retryable: false,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const [existing] = await sql<OutboxRow[]>`
|
|
50
|
+
SELECT *
|
|
51
|
+
FROM ${sql(engineSchema)}.outbox
|
|
52
|
+
WHERE idempotency_key = ${idempotencyKey}
|
|
53
|
+
LIMIT 1
|
|
54
|
+
`;
|
|
55
|
+
|
|
56
|
+
if (existing) {
|
|
57
|
+
return existing;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const [inserted] = await sql<OutboxRow[]>`
|
|
61
|
+
INSERT INTO ${sql(engineSchema)}.outbox (
|
|
62
|
+
idempotency_key,
|
|
63
|
+
user_uuid_hash,
|
|
64
|
+
event_type,
|
|
65
|
+
payload,
|
|
66
|
+
previous_hash,
|
|
67
|
+
current_hash,
|
|
68
|
+
chain_status,
|
|
69
|
+
status,
|
|
70
|
+
attempt_count,
|
|
71
|
+
next_attempt_at,
|
|
72
|
+
created_at,
|
|
73
|
+
updated_at
|
|
74
|
+
)
|
|
75
|
+
VALUES (
|
|
76
|
+
${idempotencyKey},
|
|
77
|
+
${userHash},
|
|
78
|
+
${eventType},
|
|
79
|
+
${sql.json(jsonPayload)},
|
|
80
|
+
${UNFINALIZED_PREVIOUS_HASH},
|
|
81
|
+
${UNFINALIZED_CURRENT_HASH},
|
|
82
|
+
'pending',
|
|
83
|
+
'pending',
|
|
84
|
+
0,
|
|
85
|
+
${now},
|
|
86
|
+
${now},
|
|
87
|
+
${now}
|
|
88
|
+
)
|
|
89
|
+
ON CONFLICT (idempotency_key) DO NOTHING
|
|
90
|
+
RETURNING *
|
|
91
|
+
`;
|
|
92
|
+
|
|
93
|
+
if (inserted) {
|
|
94
|
+
return inserted;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const [stored] = await sql<OutboxRow[]>`
|
|
98
|
+
SELECT *
|
|
99
|
+
FROM ${sql(engineSchema)}.outbox
|
|
100
|
+
WHERE idempotency_key = ${idempotencyKey}
|
|
101
|
+
LIMIT 1
|
|
102
|
+
`;
|
|
103
|
+
|
|
104
|
+
if (!stored) {
|
|
105
|
+
fail({
|
|
106
|
+
code: "OUTBOX_INSERT_INVARIANT_BROKEN",
|
|
107
|
+
title: "Outbox insert invariant broken",
|
|
108
|
+
detail: `Outbox insert for ${idempotencyKey} completed without returning a row.`,
|
|
109
|
+
category: "database",
|
|
110
|
+
retryable: false,
|
|
111
|
+
fatal: true,
|
|
112
|
+
context: { idempotencyKey },
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return stored;
|
|
117
|
+
}
|
|
118
|
+
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { assertIdentifier } from "@/utils";
|
|
2
|
+
import { DEFAULT_APP_SCHEMA, DEFAULT_ENGINE_SCHEMA, DEFAULT_NOTICE_WINDOW_HOURS, DEFAULT_RETENTION_YEARS } from "./types";
|
|
3
|
+
import type { WorkerSchemas, WorkerSecrets } from "../types";
|
|
4
|
+
import { fail } from "@/errors";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Resolves and validates application and engine schema identifiers.
|
|
8
|
+
*
|
|
9
|
+
* @param input - Optional schema overrides from operation options.
|
|
10
|
+
* @returns Canonical schema names safe for dynamic identifier interpolation.
|
|
11
|
+
* @throws {WorkerError} When any schema name fails identifier validation.
|
|
12
|
+
*/
|
|
13
|
+
export function resolveSchemas(input: WorkerSchemas = {}) {
|
|
14
|
+
return {
|
|
15
|
+
appSchema: assertIdentifier(
|
|
16
|
+
input.appSchema ?? DEFAULT_APP_SCHEMA,
|
|
17
|
+
"application schema name"
|
|
18
|
+
),
|
|
19
|
+
engineSchema: assertIdentifier(
|
|
20
|
+
input.engineSchema ?? DEFAULT_ENGINE_SCHEMA,
|
|
21
|
+
"engine schema name"
|
|
22
|
+
),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Validates worker cryptographic material before any vaulting operation begins.
|
|
28
|
+
*
|
|
29
|
+
* `hmacKey` falls back to `kek` when not provided, preserving deterministic pseudonymization.
|
|
30
|
+
*
|
|
31
|
+
* @param secrets - Worker key material loaded from config or env.
|
|
32
|
+
* @returns Normalized key pair safe for downstream crypto helpers.
|
|
33
|
+
* @throws {WorkerError} When KEK length is not 32 bytes or HMAC key is empty.
|
|
34
|
+
*/
|
|
35
|
+
export function assertWorkerSecrets(
|
|
36
|
+
secrets: WorkerSecrets
|
|
37
|
+
): { kek: Uint8Array; hmacKey: Uint8Array } {
|
|
38
|
+
if (secrets.kek.length !== 32) {
|
|
39
|
+
fail({
|
|
40
|
+
code: "KEK_INVALID_LENGTH",
|
|
41
|
+
title: "Invalid KEK length",
|
|
42
|
+
detail: `Invalid KEK length. Expected 32 bytes, got ${secrets.kek.length}.`,
|
|
43
|
+
category: "configuration",
|
|
44
|
+
retryable: false,
|
|
45
|
+
fatal: true,
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const hmacKey = secrets.hmacKey ?? secrets.kek;
|
|
50
|
+
if (hmacKey.length === 0) {
|
|
51
|
+
fail({
|
|
52
|
+
code: "HMAC_KEY_EMPTY",
|
|
53
|
+
title: "Invalid HMAC key",
|
|
54
|
+
detail: "HMAC key must not be empty.",
|
|
55
|
+
category: "configuration",
|
|
56
|
+
retryable: false,
|
|
57
|
+
fatal: true,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
kek: secrets.kek,
|
|
63
|
+
hmacKey,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Normalizes retention years with strict non-negative validation.
|
|
69
|
+
*
|
|
70
|
+
* @param years - Optional retention duration in years.
|
|
71
|
+
* @returns Validated retention duration.
|
|
72
|
+
* @throws {WorkerError} When `years` is non-integer or negative.
|
|
73
|
+
*/
|
|
74
|
+
export function resolveRetentionYears(years?: number): number {
|
|
75
|
+
if (years === undefined) {
|
|
76
|
+
return DEFAULT_RETENTION_YEARS;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (!Number.isInteger(years) || years < 0) {
|
|
80
|
+
fail({
|
|
81
|
+
code: "RETENTION_YEARS_INVALID",
|
|
82
|
+
title: "Invalid retention period",
|
|
83
|
+
detail: "retentionYears must be an integer greater than or equal to 0.",
|
|
84
|
+
category: "validation",
|
|
85
|
+
retryable: false,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return years;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Normalizes notice-window configuration while enforcing the legal minimum of one hour.
|
|
94
|
+
*
|
|
95
|
+
* @param hours - Optional notice window in hours.
|
|
96
|
+
* @returns Validated notice window value.
|
|
97
|
+
* @throws {WorkerError} When `hours` is non-integer or less than 1.
|
|
98
|
+
*/
|
|
99
|
+
export function resolveNoticeWindowHours(hours?: number): number {
|
|
100
|
+
if (hours === undefined) {
|
|
101
|
+
return DEFAULT_NOTICE_WINDOW_HOURS;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!Number.isInteger(hours) || hours < 1) {
|
|
105
|
+
fail({
|
|
106
|
+
code: "NOTICE_WINDOW_INVALID",
|
|
107
|
+
title: "Invalid notice window",
|
|
108
|
+
detail: "noticeWindowHours must be an integer greater than 0.",
|
|
109
|
+
category: "validation",
|
|
110
|
+
retryable: false,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return hours;
|
|
115
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default application schema used by tests and local worker bootstrap.
|
|
3
|
+
*/
|
|
4
|
+
export const DEFAULT_APP_SCHEMA = "mock_app";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Default worker-owned engine schema.
|
|
8
|
+
*/
|
|
9
|
+
export const DEFAULT_ENGINE_SCHEMA = "dpdp_engine";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Default notice lead time in hours.
|
|
13
|
+
*/
|
|
14
|
+
export const DEFAULT_NOTICE_WINDOW_HOURS = 48;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Default retention period when no evidence rule matches.
|
|
18
|
+
*/
|
|
19
|
+
export const DEFAULT_RETENTION_YEARS = 0;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Default recursive graph traversal limit.
|
|
23
|
+
*/
|
|
24
|
+
export const DEFAULT_GRAPH_MAX_DEPTH = 32;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Sentinel payload stored after cryptographic shredding.
|
|
28
|
+
*/
|
|
29
|
+
export const DESTROYED_PII_SENTINEL = Object.freeze({ v: 1, destroyed: true });
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Durable record shape stored in `${engineSchema}.pii_vault`.
|
|
33
|
+
*
|
|
34
|
+
* Legal metadata is treated as append-only, while lifecycle timestamps and lease fields remain
|
|
35
|
+
* mutable as the state machine advances.
|
|
36
|
+
*/
|
|
37
|
+
export interface VaultRecord {
|
|
38
|
+
user_uuid_hash: string;
|
|
39
|
+
request_id: string | null;
|
|
40
|
+
tenant_id: string;
|
|
41
|
+
root_schema: string;
|
|
42
|
+
root_table: string;
|
|
43
|
+
root_id: string;
|
|
44
|
+
pseudonym: string;
|
|
45
|
+
encrypted_pii: { v?: number; data?: string; destroyed?: boolean };
|
|
46
|
+
salt: string;
|
|
47
|
+
dependency_count: number;
|
|
48
|
+
trigger_source: string | null;
|
|
49
|
+
legal_framework: string | null;
|
|
50
|
+
actor_opaque_id: string | null;
|
|
51
|
+
applied_rule_name: string | null;
|
|
52
|
+
applied_rule_citation: string | null;
|
|
53
|
+
retention_expiry: Date;
|
|
54
|
+
notification_due_at: Date;
|
|
55
|
+
notification_sent_at: Date | null;
|
|
56
|
+
notification_lock_id: string | null;
|
|
57
|
+
notification_lock_expires_at: Date | null;
|
|
58
|
+
shredded_at: Date | null;
|
|
59
|
+
created_at: Date;
|
|
60
|
+
updated_at: Date;
|
|
61
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { fail } from "@/errors";
|
|
2
|
+
import { assertIdentifier } from "@/utils";
|
|
3
|
+
import type { DispatchNoticeOptions } from "../types";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Explicit root payload columns used to build the pre-erasure notice.
|
|
7
|
+
*/
|
|
8
|
+
export interface NoticeColumns {
|
|
9
|
+
emailColumn: string;
|
|
10
|
+
nameColumn?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Validates the short-lived notification lease duration.
|
|
15
|
+
*
|
|
16
|
+
* @param value - Optional lease duration in seconds.
|
|
17
|
+
* @returns Lease duration in seconds.
|
|
18
|
+
* @throws {WorkerError} When the value is non-integer or less than one second.
|
|
19
|
+
*/
|
|
20
|
+
export function resolveNotificationLeaseSeconds(value?: number): number {
|
|
21
|
+
if (value === undefined) {
|
|
22
|
+
return 120;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
26
|
+
fail({
|
|
27
|
+
code: "NOTIFICATION_LEASE_INVALID",
|
|
28
|
+
title: "Invalid notification lease",
|
|
29
|
+
detail: "notificationLeaseSeconds must be an integer greater than 0.",
|
|
30
|
+
category: "validation",
|
|
31
|
+
retryable: false,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Builds the deterministic mail idempotency key used by the transport.
|
|
40
|
+
*
|
|
41
|
+
* @param vault - Reserved vault row.
|
|
42
|
+
* @returns Stable idempotency key for the notice delivery.
|
|
43
|
+
*/
|
|
44
|
+
export function buildNotificationIdempotencyKey(vault: {
|
|
45
|
+
request_id: string | null;
|
|
46
|
+
root_schema: string;
|
|
47
|
+
root_table: string;
|
|
48
|
+
root_id: string;
|
|
49
|
+
notification_due_at: Date;
|
|
50
|
+
}): string {
|
|
51
|
+
return vault.request_id
|
|
52
|
+
? `notice:${vault.request_id}:${vault.notification_due_at.toISOString()}`
|
|
53
|
+
: `notice:${vault.root_schema}:${vault.root_table}:${vault.root_id}:${vault.notification_due_at.toISOString()}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Resolves the root payload columns that should be used to build the legal notice.
|
|
58
|
+
*
|
|
59
|
+
* @param options - Runtime notice options.
|
|
60
|
+
* @returns Validated email/name column mapping.
|
|
61
|
+
* @throws {WorkerError} When no explicit or compatible email mapping can be derived.
|
|
62
|
+
*/
|
|
63
|
+
export function resolveNoticeColumns(options: DispatchNoticeOptions): NoticeColumns {
|
|
64
|
+
if (options.noticeEmailColumn) {
|
|
65
|
+
return {
|
|
66
|
+
emailColumn: assertIdentifier(
|
|
67
|
+
options.noticeEmailColumn,
|
|
68
|
+
"graph notice email column"
|
|
69
|
+
),
|
|
70
|
+
nameColumn: options.noticeNameColumn
|
|
71
|
+
? assertIdentifier(options.noticeNameColumn, "graph notice name column")
|
|
72
|
+
: undefined,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const configuredColumns = new Set(Object.keys(options.rootPiiColumns ?? {}));
|
|
77
|
+
if (configuredColumns.size === 0) {
|
|
78
|
+
return {
|
|
79
|
+
emailColumn: "email",
|
|
80
|
+
nameColumn: "full_name",
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (configuredColumns.has("email")) {
|
|
85
|
+
return {
|
|
86
|
+
emailColumn: "email",
|
|
87
|
+
nameColumn: configuredColumns.has("full_name") ? "full_name" : undefined,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
fail({
|
|
92
|
+
code: "NOTIFICATION_EMAIL_COLUMN_MISSING",
|
|
93
|
+
title: "Missing notice email column mapping",
|
|
94
|
+
detail:
|
|
95
|
+
"noticeEmailColumn is required when root_pii_columns does not contain 'email'. Configure graph.notice_email_column in compliance.worker.yml.",
|
|
96
|
+
category: "configuration",
|
|
97
|
+
retryable: false,
|
|
98
|
+
fatal: true,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Produces the human-readable dry-run plan for a pre-erasure notice.
|
|
104
|
+
*
|
|
105
|
+
* @param appSchema - Source application schema.
|
|
106
|
+
* @param engineSchema - Worker engine schema.
|
|
107
|
+
* @param rootTable - Root table name.
|
|
108
|
+
* @param subjectId - Root identifier.
|
|
109
|
+
* @param userHash - Worker-side subject hash.
|
|
110
|
+
* @param notificationDueAt - Notice eligibility timestamp.
|
|
111
|
+
* @param retentionExpiry - Retention expiry timestamp.
|
|
112
|
+
* @returns Dry-run plan describing the lease, decrypt, mail, and outbox flow.
|
|
113
|
+
*/
|
|
114
|
+
export function buildNoticeDryRunPlan(
|
|
115
|
+
appSchema: string,
|
|
116
|
+
engineSchema: string,
|
|
117
|
+
rootTable: string,
|
|
118
|
+
subjectId: string | number,
|
|
119
|
+
userHash: string,
|
|
120
|
+
notificationDueAt: Date,
|
|
121
|
+
retentionExpiry: Date
|
|
122
|
+
) {
|
|
123
|
+
return {
|
|
124
|
+
mode: "dry-run" as const,
|
|
125
|
+
summary: `Would attempt the pre-erasure notice for root row ${subjectId} (${userHash}).`,
|
|
126
|
+
checks: [
|
|
127
|
+
`Read ${engineSchema}.pii_vault using (${appSchema}, ${rootTable}, ${subjectId}) as the lookup key.`,
|
|
128
|
+
`Verify that now is between notification_due_at (${notificationDueAt.toISOString()}) and retention_expiry (${retentionExpiry.toISOString()}).`,
|
|
129
|
+
"Acquire a short notification lease before decrypting or sending mail.",
|
|
130
|
+
"Use a deterministic mail idempotency key so retries do not duplicate sends.",
|
|
131
|
+
"Write the outbox event only after the mailer succeeds.",
|
|
132
|
+
],
|
|
133
|
+
cryptoSteps: [
|
|
134
|
+
"Unwrap the stored DEK with the worker KEK.",
|
|
135
|
+
"Decrypt the vaulted JSON payload in memory only.",
|
|
136
|
+
"Null and overwrite temporary buffers after the email path completes.",
|
|
137
|
+
],
|
|
138
|
+
sqlSteps: [
|
|
139
|
+
`SELECT * FROM ${engineSchema}.pii_vault WHERE root_schema = '${appSchema}' AND root_table = '${rootTable}' AND root_id = '${subjectId}' FOR UPDATE;`,
|
|
140
|
+
"UPDATE pii_vault SET notification_lock_id = '<uuid>', notification_lock_expires_at = '<lease-expiry>';",
|
|
141
|
+
"SELECT encrypted_dek FROM user_keys WHERE user_uuid_hash = '<user-hash>';",
|
|
142
|
+
"UPDATE pii_vault SET notification_sent_at = '<timestamp>', notification_lock_id = NULL, notification_lock_expires_at = NULL;",
|
|
143
|
+
`INSERT INTO ${engineSchema}.outbox (...) VALUES (... 'NOTIFICATION_SENT' ...);`,
|
|
144
|
+
],
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|