@rehearsal-db/core 0.1.0-beta.9 → 0.1.0-rc.2
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/CHANGELOG.md +319 -1
- package/COMPATIBILITY.md +15 -0
- package/README.md +73 -16
- package/dist/package.json +100 -0
- package/dist/scripts/install/create_root_config.d.mts +3 -0
- package/dist/scripts/install/create_root_config.mjs +13 -0
- package/dist/scripts/runtime/manage_database.d.mts +6 -0
- package/{scripts → dist/scripts}/runtime/manage_database.mjs +6 -8
- package/dist/src/application/interactive_task.d.mts +30 -0
- package/dist/src/application/interactive_task.mjs +83 -0
- package/dist/src/application/session.d.mts +97 -0
- package/dist/src/application/session.mjs +380 -0
- package/dist/src/baseline/artifact.d.mts +119 -0
- package/dist/src/baseline/artifact.mjs +522 -0
- package/dist/src/baseline/builder.d.mts +19 -0
- package/dist/src/baseline/builder.mjs +142 -0
- package/dist/src/baseline/input_discovery.d.mts +13 -0
- package/dist/src/baseline/input_discovery.mjs +149 -0
- package/dist/src/baseline/policy_review.d.mts +56 -0
- package/dist/src/baseline/policy_review.mjs +73 -0
- package/dist/src/baseline/preparation.d.mts +63 -0
- package/dist/src/baseline/preparation.mjs +295 -0
- package/dist/src/baseline/privacy_contract.d.mts +147 -0
- package/dist/src/baseline/privacy_contract.mjs +2 -0
- package/dist/src/baseline/privacy_dependency_graph.d.mts +7 -0
- package/dist/src/baseline/privacy_dependency_graph.mjs +110 -0
- package/dist/src/baseline/privacy_engine.d.mts +5 -0
- package/dist/src/baseline/privacy_engine.mjs +4 -0
- package/dist/src/baseline/privacy_key.d.mts +7 -0
- package/dist/src/baseline/privacy_key.mjs +42 -0
- package/dist/src/baseline/privacy_pipeline.d.mts +7 -0
- package/dist/src/baseline/privacy_pipeline.mjs +127 -0
- package/dist/src/baseline/privacy_policy.d.mts +6 -0
- package/dist/src/baseline/privacy_policy.mjs +237 -0
- package/dist/src/baseline/privacy_recipe_validation.d.mts +5 -0
- package/dist/src/baseline/privacy_recipe_validation.mjs +289 -0
- package/dist/src/baseline/privacy_rule_registry.d.mts +18 -0
- package/dist/src/baseline/privacy_rule_registry.mjs +143 -0
- package/dist/src/baseline/privacy_structured_json.d.mts +66 -0
- package/dist/src/baseline/privacy_structured_json.mjs +464 -0
- package/dist/src/baseline/privacy_transforms.d.mts +18 -0
- package/dist/src/baseline/privacy_transforms.mjs +142 -0
- package/dist/src/baseline/privacy_validation_shared.d.mts +31 -0
- package/dist/src/baseline/privacy_validation_shared.mjs +127 -0
- package/dist/src/baseline/sanitization_policy.d.mts +69 -0
- package/dist/src/baseline/sanitization_policy.mjs +227 -0
- package/dist/src/baseline/schema_snapshot.d.mts +19 -0
- package/dist/src/baseline/schema_snapshot.mjs +79 -0
- package/dist/src/cli/application_command_handlers.d.mts +8 -0
- package/dist/src/cli/application_command_handlers.mjs +52 -0
- package/dist/src/cli/arguments.d.mts +38 -0
- package/dist/src/cli/arguments.mjs +130 -0
- package/dist/src/cli/basic_command_handlers.d.mts +4 -0
- package/dist/src/cli/basic_command_handlers.mjs +57 -0
- package/dist/src/cli/cli_commands.d.mts +14 -0
- package/dist/src/cli/cli_commands.mjs +34 -0
- package/dist/src/cli/command_contract.d.mts +57 -0
- package/dist/src/cli/command_contract.mjs +39 -0
- package/dist/src/cli/command_output.d.mts +12 -0
- package/dist/src/cli/command_output.mjs +20 -0
- package/dist/src/cli/command_registry.d.mts +7 -0
- package/dist/src/cli/command_registry.mjs +14 -0
- package/dist/src/cli/guided.d.mts +53 -0
- package/dist/src/cli/guided.mjs +727 -0
- package/dist/src/cli/identity_command_handlers.d.mts +12 -0
- package/dist/src/cli/identity_command_handlers.mjs +239 -0
- package/dist/src/cli/identity_renderer.d.mts +8 -0
- package/dist/src/cli/identity_renderer.mjs +33 -0
- package/dist/src/cli/init_command.d.mts +21 -0
- package/dist/src/cli/init_command.mjs +90 -0
- package/dist/src/cli/inspection_command_handlers.d.mts +3 -0
- package/dist/src/cli/inspection_command_handlers.mjs +79 -0
- package/dist/src/cli/project_command_handlers.d.mts +11 -0
- package/dist/src/cli/project_command_handlers.mjs +92 -0
- package/dist/src/cli/project_commands.d.mts +283 -0
- package/dist/src/cli/project_commands.mjs +192 -0
- package/dist/src/cli/rehearsal.d.mts +7 -0
- package/dist/src/cli/rehearsal.mjs +190 -0
- package/dist/src/cli/renderers.d.mts +299 -0
- package/dist/src/cli/renderers.mjs +554 -0
- package/dist/src/cli/runtime_action_handlers.d.mts +12 -0
- package/dist/src/cli/runtime_action_handlers.mjs +201 -0
- package/dist/src/cli/runtime_commands.d.mts +294 -0
- package/dist/src/cli/runtime_commands.mjs +619 -0
- package/dist/src/cli/source_command_handlers.d.mts +8 -0
- package/dist/src/cli/source_command_handlers.mjs +62 -0
- package/dist/src/cli/source_commands.d.mts +436 -0
- package/dist/src/cli/source_commands.mjs +301 -0
- package/dist/src/cli/terminal.d.mts +59 -0
- package/dist/src/cli/terminal.mjs +230 -0
- package/dist/src/identity/claim.d.mts +5 -0
- package/dist/src/identity/claim.mjs +4 -0
- package/dist/src/identity/claim_contract.d.mts +135 -0
- package/dist/src/identity/claim_contract.mjs +6 -0
- package/dist/src/identity/claim_executor.d.mts +10 -0
- package/dist/src/identity/claim_executor.mjs +358 -0
- package/dist/src/identity/claim_identity_lookup.d.mts +23 -0
- package/dist/src/identity/claim_identity_lookup.mjs +134 -0
- package/dist/src/identity/claim_plan.d.mts +8 -0
- package/dist/src/identity/claim_plan.mjs +61 -0
- package/dist/src/identity/claim_policy.d.mts +3 -0
- package/dist/src/identity/claim_policy.mjs +377 -0
- package/dist/src/identity/claim_sql.d.mts +10 -0
- package/dist/src/identity/claim_sql.mjs +46 -0
- package/dist/src/identity/claim_verification.d.mts +13 -0
- package/dist/src/identity/claim_verification.mjs +35 -0
- package/dist/src/identity/connect.d.mts +30 -0
- package/dist/src/identity/connect.mjs +111 -0
- package/dist/src/identity/provider_configuration.d.mts +37 -0
- package/dist/src/identity/provider_configuration.mjs +132 -0
- package/dist/src/identity/storage.d.mts +21 -0
- package/dist/src/identity/storage.mjs +122 -0
- package/dist/src/index.d.mts +4 -0
- package/dist/src/index.mjs +3 -0
- package/dist/src/project/configuration.d.mts +13 -0
- package/dist/src/project/configuration.mjs +9 -0
- package/dist/src/project/configuration_contract.d.mts +125 -0
- package/dist/src/project/configuration_contract.mjs +27 -0
- package/dist/src/project/configuration_discovery.d.mts +7 -0
- package/dist/src/project/configuration_discovery.mjs +37 -0
- package/dist/src/project/configuration_normalization.d.mts +107 -0
- package/dist/src/project/configuration_normalization.mjs +302 -0
- package/dist/src/project/configuration_rendering.d.mts +14 -0
- package/dist/src/project/configuration_rendering.mjs +228 -0
- package/dist/src/project/configuration_resolution.d.mts +125 -0
- package/dist/src/project/configuration_resolution.mjs +81 -0
- package/dist/src/project/configuration_schema.d.mts +22 -0
- package/dist/src/project/configuration_schema.mjs +160 -0
- package/dist/src/project/configuration_validation.d.mts +22 -0
- package/dist/src/project/configuration_validation.mjs +136 -0
- package/dist/src/project/install_scaffold.d.mts +42 -0
- package/dist/src/project/install_scaffold.mjs +263 -0
- package/dist/src/project/project_detection.d.mts +14 -0
- package/dist/src/project/project_detection.mjs +47 -0
- package/dist/src/project/setup.d.mts +87 -0
- package/dist/src/project/setup.mjs +414 -0
- package/dist/src/project/support_report.d.mts +88 -0
- package/dist/src/project/support_report.mjs +83 -0
- package/dist/src/runtime/cleanup.d.mts +85 -0
- package/dist/src/runtime/cleanup.mjs +311 -0
- package/dist/src/runtime/lifecycle_engine.d.mts +63 -0
- package/dist/src/runtime/lifecycle_engine.mjs +114 -0
- package/dist/src/runtime/migration_history.d.mts +51 -0
- package/dist/src/runtime/migration_history.mjs +170 -0
- package/dist/src/runtime/plan.d.mts +134 -0
- package/dist/src/runtime/plan.mjs +640 -0
- package/dist/src/runtime/policy.d.mts +66 -0
- package/dist/src/runtime/policy.mjs +369 -0
- package/dist/src/runtime/restore.d.mts +22 -0
- package/dist/src/runtime/restore.mjs +276 -0
- package/dist/src/runtime/service_environment.d.mts +11 -0
- package/dist/src/runtime/service_environment.mjs +58 -0
- package/dist/src/runtime/topology.d.mts +51 -0
- package/dist/src/runtime/topology.mjs +128 -0
- package/dist/src/shared/diagnostics.d.mts +97 -0
- package/dist/src/shared/diagnostics.mjs +170 -0
- package/dist/src/shared/human_output.d.mts +2 -0
- package/{src → dist/src}/shared/human_output.mjs +1 -3
- package/dist/src/shared/node_runtime.d.mts +14 -0
- package/dist/src/shared/node_runtime.mjs +25 -0
- package/dist/src/shared/operation_guard.d.mts +22 -0
- package/dist/src/shared/operation_guard.mjs +150 -0
- package/dist/src/shared/process_environment.d.mts +15 -0
- package/dist/src/shared/process_environment.mjs +50 -0
- package/dist/src/source/access.d.mts +165 -0
- package/dist/src/source/access.mjs +602 -0
- package/dist/src/source/asset_transfer.d.mts +27 -0
- package/dist/src/source/asset_transfer.mjs +202 -0
- package/dist/src/source/baseline.d.mts +66 -0
- package/dist/src/source/baseline.mjs +372 -0
- package/dist/src/source/external_reader.d.mts +42 -0
- package/dist/src/source/external_reader.mjs +278 -0
- package/dist/src/source/postgresql_access.d.mts +86 -0
- package/dist/src/source/postgresql_access.mjs +454 -0
- package/dist/src/source/privacy_paths.d.mts +10 -0
- package/dist/src/source/privacy_paths.mjs +27 -0
- package/dist/src/targets/driver.d.mts +2 -0
- package/dist/src/targets/driver.mjs +13 -0
- package/dist/src/targets/postgresql.d.mts +5 -0
- package/dist/src/targets/postgresql.mjs +620 -0
- package/dist/src/targets/supabase.d.mts +8 -0
- package/dist/src/targets/supabase.mjs +629 -0
- package/dist/src/targets/supabase_environment.d.mts +54 -0
- package/dist/src/targets/supabase_environment.mjs +184 -0
- package/dist/src/targets/target.d.mts +33 -0
- package/dist/src/targets/target.mjs +39 -0
- package/docs/README.md +35 -16
- package/docs/architecture.md +135 -10
- package/docs/commands.md +53 -15
- package/docs/configuration.md +186 -31
- package/docs/getting-started.md +66 -9
- package/docs/index.md +67 -0
- package/docs/production-source.md +175 -17
- package/docs/releasing.md +24 -12
- package/docs/roadmap.md +3 -2
- package/docs/runtime-lifecycle.md +99 -0
- package/docs/runtime-policies.md +80 -7
- package/docs/sanitization.md +572 -4
- package/docs/security-model.md +8 -0
- package/docs/stability.md +70 -0
- package/docs/standalone-workflow.md +36 -13
- package/docs/troubleshooting.md +82 -10
- package/docs/tutorial.md +7 -1
- package/docs/typescript.md +134 -0
- package/package.json +44 -29
- package/types/configuration.d.mts +5 -0
- package/src/README.md +0 -17
- package/src/application/session.mjs +0 -313
- package/src/baseline/artifact.mjs +0 -661
- package/src/baseline/builder.mjs +0 -164
- package/src/baseline/input_discovery.mjs +0 -155
- package/src/baseline/policy_review.mjs +0 -92
- package/src/baseline/preparation.mjs +0 -323
- package/src/baseline/privacy_engine.mjs +0 -413
- package/src/baseline/sanitization_policy.mjs +0 -299
- package/src/baseline/schema_snapshot.mjs +0 -113
- package/src/cli/arguments.mjs +0 -120
- package/src/cli/guided.mjs +0 -807
- package/src/cli/rehearsal.mjs +0 -933
- package/src/cli/renderers.mjs +0 -584
- package/src/cli/runtime_commands.mjs +0 -553
- package/src/cli/source_commands.mjs +0 -326
- package/src/cli/terminal.mjs +0 -275
- package/src/identity/claim.mjs +0 -975
- package/src/identity/storage.mjs +0 -165
- package/src/project/configuration.d.mts +0 -153
- package/src/project/configuration.mjs +0 -1197
- package/src/project/setup.mjs +0 -379
- package/src/project/support_report.mjs +0 -105
- package/src/runtime/cleanup.mjs +0 -381
- package/src/runtime/migration_history.mjs +0 -220
- package/src/runtime/plan.mjs +0 -685
- package/src/runtime/policy.mjs +0 -438
- package/src/runtime/restore.mjs +0 -329
- package/src/runtime/service_environment.mjs +0 -82
- package/src/runtime/topology.mjs +0 -177
- package/src/shared/diagnostics.mjs +0 -199
- package/src/shared/operation_guard.mjs +0 -162
- package/src/shared/process_environment.mjs +0 -68
- package/src/source/access.mjs +0 -578
- package/src/source/asset_transfer.mjs +0 -177
- package/src/source/baseline.mjs +0 -446
- package/src/source/postgresql_access.mjs +0 -480
- package/src/targets/postgresql.mjs +0 -808
- package/src/targets/supabase.mjs +0 -830
- package/src/targets/supabase_environment.mjs +0 -229
- package/src/targets/target.mjs +0 -66
|
@@ -1,413 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Execute reviewed privacy policy version 2 without project-owned callbacks.
|
|
3
|
-
* The engine is intentionally small and fail-closed: unsupported shapes and
|
|
4
|
-
* recipes must be reviewed and added here instead of evaluated as code.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import { createHash, createHmac, randomBytes } from "node:crypto";
|
|
8
|
-
import { chmod, mkdir, open, readFile, stat } from "node:fs/promises";
|
|
9
|
-
import { dirname } from "node:path";
|
|
10
|
-
|
|
11
|
-
const IDENTIFIER = /^[a-z][a-z0-9_]{0,62}$/u;
|
|
12
|
-
const HEX_64 = /^[a-f0-9]{64}$/u;
|
|
13
|
-
const ACTIONS = new Set([
|
|
14
|
-
"KEEP",
|
|
15
|
-
"PSEUDONYMIZE",
|
|
16
|
-
"REPLACE",
|
|
17
|
-
"EXCLUDE",
|
|
18
|
-
"DERIVE",
|
|
19
|
-
]);
|
|
20
|
-
const PSEUDONYM_FORMATS = new Set(["uuid", "email", "text", "integer"]);
|
|
21
|
-
const DERIVATION_KINDS = new Set(["date-shift", "json-object"]);
|
|
22
|
-
const KEY_BYTES = 32;
|
|
23
|
-
|
|
24
|
-
const isObject = (value) =>
|
|
25
|
-
value !== null &&
|
|
26
|
-
typeof value === "object" &&
|
|
27
|
-
!Array.isArray(value) &&
|
|
28
|
-
Object.getPrototypeOf(value) === Object.prototype;
|
|
29
|
-
|
|
30
|
-
const knownKeys = (value, allowed, label) => {
|
|
31
|
-
for (const key of Object.keys(value)) {
|
|
32
|
-
if (!allowed.includes(key)) throw new Error(`${label}.${key} is unknown.`);
|
|
33
|
-
}
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
const identifier = (value, label) => {
|
|
37
|
-
if (!IDENTIFIER.test(value ?? "")) {
|
|
38
|
-
throw new Error(`${label} must be a lowercase PostgreSQL identifier.`);
|
|
39
|
-
}
|
|
40
|
-
return value;
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
const nonEmpty = (value, label) => {
|
|
44
|
-
if (typeof value !== "string" || !value.trim()) {
|
|
45
|
-
throw new Error(`${label} must be a non-empty string.`);
|
|
46
|
-
}
|
|
47
|
-
return value.trim();
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
const positiveInteger = (value, label, maximum = Number.MAX_SAFE_INTEGER) => {
|
|
51
|
-
if (!Number.isSafeInteger(value) || value < 1 || value > maximum) {
|
|
52
|
-
throw new Error(`${label} must be an integer from 1 through ${maximum}.`);
|
|
53
|
-
}
|
|
54
|
-
return value;
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
const validateRecipe = (action, recipe, label) => {
|
|
58
|
-
if (action === "KEEP" || action === "EXCLUDE") {
|
|
59
|
-
if (recipe !== undefined && recipe !== null) {
|
|
60
|
-
throw new Error(`${label}.recipe is not allowed for ${action}.`);
|
|
61
|
-
}
|
|
62
|
-
return null;
|
|
63
|
-
}
|
|
64
|
-
if (!isObject(recipe)) {
|
|
65
|
-
throw new Error(`${label}.recipe is required for ${action}.`);
|
|
66
|
-
}
|
|
67
|
-
if (action === "PSEUDONYMIZE") {
|
|
68
|
-
knownKeys(recipe, ["format", "namespace", "maxLength"], `${label}.recipe`);
|
|
69
|
-
if (!PSEUDONYM_FORMATS.has(recipe.format)) {
|
|
70
|
-
throw new Error(`${label}.recipe.format is unsupported.`);
|
|
71
|
-
}
|
|
72
|
-
const normalized = {
|
|
73
|
-
format: recipe.format,
|
|
74
|
-
namespace: nonEmpty(recipe.namespace, `${label}.recipe.namespace`),
|
|
75
|
-
};
|
|
76
|
-
if (recipe.format === "text") {
|
|
77
|
-
normalized.maxLength = positiveInteger(
|
|
78
|
-
recipe.maxLength,
|
|
79
|
-
`${label}.recipe.maxLength`,
|
|
80
|
-
1_024,
|
|
81
|
-
);
|
|
82
|
-
} else if (recipe.maxLength !== undefined) {
|
|
83
|
-
throw new Error(
|
|
84
|
-
`${label}.recipe.maxLength is supported only for text pseudonyms.`,
|
|
85
|
-
);
|
|
86
|
-
}
|
|
87
|
-
return Object.freeze(normalized);
|
|
88
|
-
}
|
|
89
|
-
if (action === "REPLACE") {
|
|
90
|
-
knownKeys(recipe, ["kind", "value"], `${label}.recipe`);
|
|
91
|
-
if (recipe.kind !== "constant" || !Object.hasOwn(recipe, "value")) {
|
|
92
|
-
throw new Error(`${label}.recipe must be an explicit constant.`);
|
|
93
|
-
}
|
|
94
|
-
if (typeof recipe.value === "function" || recipe.value === undefined) {
|
|
95
|
-
throw new Error(`${label}.recipe.value must be JSON-compatible.`);
|
|
96
|
-
}
|
|
97
|
-
JSON.stringify(recipe.value);
|
|
98
|
-
return Object.freeze({ kind: "constant", value: recipe.value });
|
|
99
|
-
}
|
|
100
|
-
knownKeys(recipe, ["kind", "days", "fields", "allowNull"], `${label}.recipe`);
|
|
101
|
-
if (!DERIVATION_KINDS.has(recipe.kind)) {
|
|
102
|
-
throw new Error(`${label}.recipe.kind is unsupported.`);
|
|
103
|
-
}
|
|
104
|
-
if (recipe.kind === "date-shift") {
|
|
105
|
-
const days = positiveInteger(recipe.days, `${label}.recipe.days`, 3_650);
|
|
106
|
-
if (recipe.fields !== undefined || recipe.allowNull !== undefined) {
|
|
107
|
-
throw new Error(
|
|
108
|
-
`${label}.recipe contains fields not used by date-shift.`,
|
|
109
|
-
);
|
|
110
|
-
}
|
|
111
|
-
return Object.freeze({ kind: "date-shift", days });
|
|
112
|
-
}
|
|
113
|
-
if (!isObject(recipe.fields) || Object.keys(recipe.fields).length === 0) {
|
|
114
|
-
throw new Error(`${label}.recipe.fields must classify JSON object keys.`);
|
|
115
|
-
}
|
|
116
|
-
if (recipe.allowNull !== undefined && typeof recipe.allowNull !== "boolean") {
|
|
117
|
-
throw new Error(`${label}.recipe.allowNull must be true or false.`);
|
|
118
|
-
}
|
|
119
|
-
const fields = Object.fromEntries(
|
|
120
|
-
Object.entries(recipe.fields).map(([key, declaration]) => {
|
|
121
|
-
if (!key || !isObject(declaration)) {
|
|
122
|
-
throw new Error(`${label}.recipe.fields contains an invalid key.`);
|
|
123
|
-
}
|
|
124
|
-
knownKeys(
|
|
125
|
-
declaration,
|
|
126
|
-
["action", "recipe"],
|
|
127
|
-
`${label}.recipe.fields.${key}`,
|
|
128
|
-
);
|
|
129
|
-
const nestedAction = String(declaration.action ?? "").toUpperCase();
|
|
130
|
-
if (!ACTIONS.has(nestedAction)) {
|
|
131
|
-
throw new Error(`${label}.recipe.fields.${key}.action is unsupported.`);
|
|
132
|
-
}
|
|
133
|
-
return [
|
|
134
|
-
key,
|
|
135
|
-
Object.freeze({
|
|
136
|
-
action: nestedAction,
|
|
137
|
-
recipe: validateRecipe(
|
|
138
|
-
nestedAction,
|
|
139
|
-
declaration.recipe,
|
|
140
|
-
`${label}.recipe.fields.${key}`,
|
|
141
|
-
),
|
|
142
|
-
}),
|
|
143
|
-
];
|
|
144
|
-
}),
|
|
145
|
-
);
|
|
146
|
-
return Object.freeze({
|
|
147
|
-
kind: "json-object",
|
|
148
|
-
allowNull: recipe.allowNull === true,
|
|
149
|
-
fields: Object.freeze(fields),
|
|
150
|
-
});
|
|
151
|
-
};
|
|
152
|
-
|
|
153
|
-
export const validateExecutablePrivacyPolicy = (policy) => {
|
|
154
|
-
if (!isObject(policy)) throw new Error("Privacy policy must be an object.");
|
|
155
|
-
knownKeys(
|
|
156
|
-
policy,
|
|
157
|
-
["policyVersion", "draft", "migrationCutoff", "tables"],
|
|
158
|
-
"policy",
|
|
159
|
-
);
|
|
160
|
-
if (policy.policyVersion !== 2) {
|
|
161
|
-
throw new Error("Executable privacy policies must use policyVersion 2.");
|
|
162
|
-
}
|
|
163
|
-
if (policy.draft === true) {
|
|
164
|
-
throw new Error("The privacy policy is still a REVIEW REQUIRED draft.");
|
|
165
|
-
}
|
|
166
|
-
if (!/^\d{14}$/u.test(policy.migrationCutoff ?? "")) {
|
|
167
|
-
throw new Error(
|
|
168
|
-
"Privacy policy migrationCutoff must be a timestamp version.",
|
|
169
|
-
);
|
|
170
|
-
}
|
|
171
|
-
if (!Array.isArray(policy.tables) || policy.tables.length === 0) {
|
|
172
|
-
throw new Error("Privacy policy must classify at least one table.");
|
|
173
|
-
}
|
|
174
|
-
const names = new Set();
|
|
175
|
-
const tables = policy.tables.map((table, tableIndex) => {
|
|
176
|
-
const label = `policy.tables[${tableIndex}]`;
|
|
177
|
-
if (!isObject(table)) throw new Error(`${label} must be an object.`);
|
|
178
|
-
knownKeys(table, ["schema", "name", "sourceRows", "columns"], label);
|
|
179
|
-
const schema = identifier(table.schema ?? "public", `${label}.schema`);
|
|
180
|
-
const name = identifier(table.name, `${label}.name`);
|
|
181
|
-
const key = schema === "public" ? name : `${schema}.${name}`;
|
|
182
|
-
if (names.has(key))
|
|
183
|
-
throw new Error(`Privacy policy duplicates table ${key}.`);
|
|
184
|
-
names.add(key);
|
|
185
|
-
if (!["STREAM AND SANITIZE", "EXCLUDE"].includes(table.sourceRows)) {
|
|
186
|
-
throw new Error(`${label}.sourceRows is unsupported.`);
|
|
187
|
-
}
|
|
188
|
-
if (!Array.isArray(table.columns) || table.columns.length === 0) {
|
|
189
|
-
throw new Error(`${label}.columns must not be empty.`);
|
|
190
|
-
}
|
|
191
|
-
const columnNames = new Set();
|
|
192
|
-
const columns = table.columns.map((column, columnIndex) => {
|
|
193
|
-
const columnLabel = `${label}.columns[${columnIndex}]`;
|
|
194
|
-
if (!isObject(column))
|
|
195
|
-
throw new Error(`${columnLabel} must be an object.`);
|
|
196
|
-
knownKeys(
|
|
197
|
-
column,
|
|
198
|
-
["name", "action", "recipe", "generated", "identity", "foreignKey"],
|
|
199
|
-
columnLabel,
|
|
200
|
-
);
|
|
201
|
-
const columnName = identifier(column.name, `${columnLabel}.name`);
|
|
202
|
-
if (columnNames.has(columnName)) {
|
|
203
|
-
throw new Error(
|
|
204
|
-
`Privacy policy duplicates column ${name}.${columnName}.`,
|
|
205
|
-
);
|
|
206
|
-
}
|
|
207
|
-
columnNames.add(columnName);
|
|
208
|
-
const action = String(column.action ?? "").toUpperCase();
|
|
209
|
-
if (!ACTIONS.has(action))
|
|
210
|
-
throw new Error(`${columnLabel}.action is unsupported.`);
|
|
211
|
-
if (!["ALWAYS", "NEVER"].includes(column.generated)) {
|
|
212
|
-
throw new Error(`${columnLabel}.generated must be ALWAYS or NEVER.`);
|
|
213
|
-
}
|
|
214
|
-
if (!["YES", "NO"].includes(column.identity)) {
|
|
215
|
-
throw new Error(`${columnLabel}.identity must be YES or NO.`);
|
|
216
|
-
}
|
|
217
|
-
if (column.foreignKey !== null) {
|
|
218
|
-
if (!isObject(column.foreignKey)) {
|
|
219
|
-
throw new Error(
|
|
220
|
-
`${columnLabel}.foreignKey must be null or an object.`,
|
|
221
|
-
);
|
|
222
|
-
}
|
|
223
|
-
knownKeys(
|
|
224
|
-
column.foreignKey,
|
|
225
|
-
["schema", "table", "column"],
|
|
226
|
-
`${columnLabel}.foreignKey`,
|
|
227
|
-
);
|
|
228
|
-
identifier(
|
|
229
|
-
column.foreignKey.schema,
|
|
230
|
-
`${columnLabel}.foreignKey.schema`,
|
|
231
|
-
);
|
|
232
|
-
identifier(column.foreignKey.table, `${columnLabel}.foreignKey.table`);
|
|
233
|
-
identifier(
|
|
234
|
-
column.foreignKey.column,
|
|
235
|
-
`${columnLabel}.foreignKey.column`,
|
|
236
|
-
);
|
|
237
|
-
}
|
|
238
|
-
return Object.freeze({
|
|
239
|
-
...column,
|
|
240
|
-
name: columnName,
|
|
241
|
-
action,
|
|
242
|
-
recipe: validateRecipe(action, column.recipe, columnLabel),
|
|
243
|
-
});
|
|
244
|
-
});
|
|
245
|
-
return Object.freeze({
|
|
246
|
-
schema,
|
|
247
|
-
name,
|
|
248
|
-
sourceRows: table.sourceRows,
|
|
249
|
-
columns: Object.freeze(columns),
|
|
250
|
-
});
|
|
251
|
-
});
|
|
252
|
-
return Object.freeze({
|
|
253
|
-
policyVersion: 2,
|
|
254
|
-
migrationCutoff: policy.migrationCutoff,
|
|
255
|
-
tables: Object.freeze(tables),
|
|
256
|
-
});
|
|
257
|
-
};
|
|
258
|
-
|
|
259
|
-
const digest = (key, namespace, value) =>
|
|
260
|
-
createHmac("sha256", key)
|
|
261
|
-
.update(namespace)
|
|
262
|
-
.update("\0")
|
|
263
|
-
.update(JSON.stringify(value))
|
|
264
|
-
.digest();
|
|
265
|
-
|
|
266
|
-
const pseudonym = (key, recipe, value) => {
|
|
267
|
-
if (value === null) return null;
|
|
268
|
-
const bytes = digest(key, recipe.namespace, value);
|
|
269
|
-
if (recipe.format === "uuid") {
|
|
270
|
-
const copy = Buffer.from(bytes.subarray(0, 16));
|
|
271
|
-
copy[6] = (copy[6] & 0x0f) | 0x40;
|
|
272
|
-
copy[8] = (copy[8] & 0x3f) | 0x80;
|
|
273
|
-
const hex = copy.toString("hex");
|
|
274
|
-
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
275
|
-
}
|
|
276
|
-
if (recipe.format === "email") {
|
|
277
|
-
return `rehearsal+${bytes.toString("hex").slice(0, 24)}@example.invalid`;
|
|
278
|
-
}
|
|
279
|
-
if (recipe.format === "integer") {
|
|
280
|
-
return Number(bytes.readBigUInt64BE(0) % 9_007_199_254_740_991n) + 1;
|
|
281
|
-
}
|
|
282
|
-
const token = `rehearsal_${bytes.toString("hex")}`;
|
|
283
|
-
return token.slice(0, recipe.maxLength);
|
|
284
|
-
};
|
|
285
|
-
|
|
286
|
-
const executeDeclaration = ({ declaration, value, key, path }) => {
|
|
287
|
-
if (declaration.action === "KEEP") return value;
|
|
288
|
-
if (declaration.action === "EXCLUDE") return undefined;
|
|
289
|
-
if (declaration.action === "REPLACE")
|
|
290
|
-
return structuredClone(declaration.recipe.value);
|
|
291
|
-
if (declaration.action === "PSEUDONYMIZE") {
|
|
292
|
-
return pseudonym(key, declaration.recipe, value);
|
|
293
|
-
}
|
|
294
|
-
if (declaration.recipe.kind === "date-shift") {
|
|
295
|
-
if (value === null) return null;
|
|
296
|
-
const date = new Date(value);
|
|
297
|
-
if (!Number.isFinite(date.valueOf()))
|
|
298
|
-
throw new Error(`${path} is not a valid date.`);
|
|
299
|
-
const direction = digest(key, path, value)[0] % 2 === 0 ? -1 : 1;
|
|
300
|
-
date.setUTCDate(date.getUTCDate() + direction * declaration.recipe.days);
|
|
301
|
-
return date.toISOString();
|
|
302
|
-
}
|
|
303
|
-
if (value === null && declaration.recipe.allowNull) return null;
|
|
304
|
-
if (!isObject(value)) throw new Error(`${path} must be a JSON object.`);
|
|
305
|
-
const actual = Object.keys(value).sort();
|
|
306
|
-
const expected = Object.keys(declaration.recipe.fields).sort();
|
|
307
|
-
if (actual.join("\0") !== expected.join("\0")) {
|
|
308
|
-
throw new Error(`${path} contains an unclassified JSON key.`);
|
|
309
|
-
}
|
|
310
|
-
return Object.fromEntries(
|
|
311
|
-
Object.entries(declaration.recipe.fields).flatMap(([field, nested]) => {
|
|
312
|
-
const transformed = executeDeclaration({
|
|
313
|
-
declaration: nested,
|
|
314
|
-
value: value[field],
|
|
315
|
-
key,
|
|
316
|
-
path: `${path}.${field}`,
|
|
317
|
-
});
|
|
318
|
-
return transformed === undefined ? [] : [[field, transformed]];
|
|
319
|
-
}),
|
|
320
|
-
);
|
|
321
|
-
};
|
|
322
|
-
|
|
323
|
-
export const createPrivacyEngine = ({ policy, key }) => {
|
|
324
|
-
const normalized = validateExecutablePrivacyPolicy(policy);
|
|
325
|
-
const secret = Buffer.isBuffer(key) ? key : Buffer.from(key ?? "");
|
|
326
|
-
if (secret.length < KEY_BYTES) {
|
|
327
|
-
throw new Error(`Privacy key must contain at least ${KEY_BYTES} bytes.`);
|
|
328
|
-
}
|
|
329
|
-
const tables = new Map(
|
|
330
|
-
normalized.tables.map((table) => [
|
|
331
|
-
table.schema === "public" ? table.name : `${table.schema}.${table.name}`,
|
|
332
|
-
table,
|
|
333
|
-
]),
|
|
334
|
-
);
|
|
335
|
-
return Object.freeze({
|
|
336
|
-
keyFingerprint: createHash("sha256").update(secret).digest("hex"),
|
|
337
|
-
sanitize(record) {
|
|
338
|
-
if (
|
|
339
|
-
!isObject(record) ||
|
|
340
|
-
!IDENTIFIER.test(record.schema ?? "public") ||
|
|
341
|
-
!IDENTIFIER.test(record.table ?? "") ||
|
|
342
|
-
!isObject(record.row)
|
|
343
|
-
) {
|
|
344
|
-
throw new Error("Source record has an invalid privacy-engine shape.");
|
|
345
|
-
}
|
|
346
|
-
const schema = record.schema ?? "public";
|
|
347
|
-
const relation =
|
|
348
|
-
schema === "public" ? record.table : `${schema}.${record.table}`;
|
|
349
|
-
const table = tables.get(relation);
|
|
350
|
-
if (!table)
|
|
351
|
-
throw new Error(`Privacy policy does not classify table ${relation}.`);
|
|
352
|
-
if (table.sourceRows === "EXCLUDE") return null;
|
|
353
|
-
const actual = Object.keys(record.row).sort();
|
|
354
|
-
const expected = table.columns.map((column) => column.name).sort();
|
|
355
|
-
if (actual.join("\0") !== expected.join("\0")) {
|
|
356
|
-
throw new Error(
|
|
357
|
-
`Source table ${relation} contains an unclassified or missing column.`,
|
|
358
|
-
);
|
|
359
|
-
}
|
|
360
|
-
const row = Object.fromEntries(
|
|
361
|
-
table.columns.flatMap((column) => {
|
|
362
|
-
const transformed = executeDeclaration({
|
|
363
|
-
declaration: column,
|
|
364
|
-
value: record.row[column.name],
|
|
365
|
-
key: secret,
|
|
366
|
-
path: `${relation}.${column.name}`,
|
|
367
|
-
});
|
|
368
|
-
return transformed === undefined ? [] : [[column.name, transformed]];
|
|
369
|
-
}),
|
|
370
|
-
);
|
|
371
|
-
return schema === "public"
|
|
372
|
-
? { table: record.table, row }
|
|
373
|
-
: { schema, table: record.table, row };
|
|
374
|
-
},
|
|
375
|
-
});
|
|
376
|
-
};
|
|
377
|
-
|
|
378
|
-
export const createPrivacyKey = async (path) => {
|
|
379
|
-
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
380
|
-
const key = randomBytes(KEY_BYTES);
|
|
381
|
-
const handle = await open(path, "wx", 0o600);
|
|
382
|
-
try {
|
|
383
|
-
await handle.writeFile(`${key.toString("base64")}\n`);
|
|
384
|
-
await handle.sync();
|
|
385
|
-
} finally {
|
|
386
|
-
await handle.close();
|
|
387
|
-
}
|
|
388
|
-
await chmod(path, 0o600);
|
|
389
|
-
return { fingerprint: createHash("sha256").update(key).digest("hex") };
|
|
390
|
-
};
|
|
391
|
-
|
|
392
|
-
export const readPrivacyKey = async (path) => {
|
|
393
|
-
const details = await stat(path);
|
|
394
|
-
if (!details.isFile() || (details.mode & 0o077) !== 0) {
|
|
395
|
-
throw new Error("Privacy key must be an owner-only regular file.");
|
|
396
|
-
}
|
|
397
|
-
const encoded = (await readFile(path, "utf8")).trim();
|
|
398
|
-
const key = Buffer.from(encoded, "base64");
|
|
399
|
-
if (key.length !== KEY_BYTES || !/^[A-Za-z0-9+/]+={0,2}$/u.test(encoded)) {
|
|
400
|
-
throw new Error("Privacy key file is invalid.");
|
|
401
|
-
}
|
|
402
|
-
return key;
|
|
403
|
-
};
|
|
404
|
-
|
|
405
|
-
export const assertPrivacyKeyFingerprint = (key, expected) => {
|
|
406
|
-
if (!HEX_64.test(expected ?? ""))
|
|
407
|
-
throw new Error("Privacy key fingerprint is invalid.");
|
|
408
|
-
const actual = createHash("sha256").update(key).digest("hex");
|
|
409
|
-
if (actual !== expected)
|
|
410
|
-
throw new Error(
|
|
411
|
-
"Privacy key does not match the reviewed baseline receipt.",
|
|
412
|
-
);
|
|
413
|
-
};
|
|
@@ -1,299 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Generic, fail-closed sanitization policy validation and value operations.
|
|
3
|
-
* Projects own the policy, schema inventory, pseudonym keys, and derivation logic.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { createHash } from "node:crypto";
|
|
7
|
-
import { validateExecutablePrivacyPolicy } from "./privacy_engine.mjs";
|
|
8
|
-
|
|
9
|
-
export const SANITIZATION_ACTIONS = Object.freeze({
|
|
10
|
-
KEEP: "KEEP",
|
|
11
|
-
PSEUDONYMIZE: "PSEUDONYMIZE",
|
|
12
|
-
REPLACE: "REPLACE",
|
|
13
|
-
EXCLUDE: "EXCLUDE",
|
|
14
|
-
DERIVE: "DERIVE",
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
export const EXCLUDED_VALUE = Symbol("REHEARSAL_EXCLUDED_VALUE");
|
|
18
|
-
|
|
19
|
-
const actionAliases = new Map([
|
|
20
|
-
["KEEP", SANITIZATION_ACTIONS.KEEP],
|
|
21
|
-
["KEEP EXACTLY", SANITIZATION_ACTIONS.KEEP],
|
|
22
|
-
["PSEUDONYMIZE", SANITIZATION_ACTIONS.PSEUDONYMIZE],
|
|
23
|
-
["REPLACE", SANITIZATION_ACTIONS.REPLACE],
|
|
24
|
-
["REPLACE WITH SYNTHETIC", SANITIZATION_ACTIONS.REPLACE],
|
|
25
|
-
["EXCLUDE", SANITIZATION_ACTIONS.EXCLUDE],
|
|
26
|
-
["DERIVE", SANITIZATION_ACTIONS.DERIVE],
|
|
27
|
-
]);
|
|
28
|
-
|
|
29
|
-
const identifierPattern = /^[a-z][a-z0-9_]{0,62}$/u;
|
|
30
|
-
|
|
31
|
-
const assertIdentifier = (value, label) => {
|
|
32
|
-
if (!identifierPattern.test(value ?? "")) {
|
|
33
|
-
throw new Error(`${label} must be a lowercase PostgreSQL identifier.`);
|
|
34
|
-
}
|
|
35
|
-
return value;
|
|
36
|
-
};
|
|
37
|
-
const tableSchema = (table) => table.schema ?? "public";
|
|
38
|
-
const tableKey = (table) =>
|
|
39
|
-
tableSchema(table) === "public"
|
|
40
|
-
? table.name
|
|
41
|
-
: `${tableSchema(table)}.${table.name}`;
|
|
42
|
-
|
|
43
|
-
export const normalizeSanitizationAction = (action) => {
|
|
44
|
-
const normalized = actionAliases.get(
|
|
45
|
-
String(action ?? "")
|
|
46
|
-
.trim()
|
|
47
|
-
.toUpperCase(),
|
|
48
|
-
);
|
|
49
|
-
if (!normalized) {
|
|
50
|
-
throw new Error(`Unknown Rehearsal sanitization action: ${action}.`);
|
|
51
|
-
}
|
|
52
|
-
return normalized;
|
|
53
|
-
};
|
|
54
|
-
|
|
55
|
-
export const validateRuntimeSanitizationPolicy = (policy) => {
|
|
56
|
-
if (!policy || typeof policy !== "object" || Array.isArray(policy)) {
|
|
57
|
-
throw new Error("A Rehearsal sanitization policy object is required.");
|
|
58
|
-
}
|
|
59
|
-
if (policy.draft === true) {
|
|
60
|
-
throw new Error(
|
|
61
|
-
"The sanitization policy is still a REVIEW REQUIRED draft.",
|
|
62
|
-
);
|
|
63
|
-
}
|
|
64
|
-
if (policy.policyVersion === 2) {
|
|
65
|
-
return validateExecutablePrivacyPolicy(policy);
|
|
66
|
-
}
|
|
67
|
-
if (policy.policyVersion !== 1) {
|
|
68
|
-
throw new Error("The sanitization policy must use policyVersion 1 or 2.");
|
|
69
|
-
}
|
|
70
|
-
if (!/^\d{14}$/u.test(policy.migrationCutoff ?? "")) {
|
|
71
|
-
throw new Error(
|
|
72
|
-
"The sanitization policy must declare a timestamp migrationCutoff.",
|
|
73
|
-
);
|
|
74
|
-
}
|
|
75
|
-
if (!Array.isArray(policy.tables) || policy.tables.length === 0) {
|
|
76
|
-
throw new Error("The sanitization policy does not list tables.");
|
|
77
|
-
}
|
|
78
|
-
const tables = new Set();
|
|
79
|
-
for (const table of policy.tables) {
|
|
80
|
-
const schemaName = assertIdentifier(
|
|
81
|
-
tableSchema(table),
|
|
82
|
-
"Sanitization policy table schema",
|
|
83
|
-
);
|
|
84
|
-
const tableName = assertIdentifier(
|
|
85
|
-
table?.name,
|
|
86
|
-
"Sanitization policy table name",
|
|
87
|
-
);
|
|
88
|
-
const key =
|
|
89
|
-
schemaName === "public" ? tableName : `${schemaName}.${tableName}`;
|
|
90
|
-
if (tables.has(key)) {
|
|
91
|
-
throw new Error(`Sanitization policy duplicates table ${key}.`);
|
|
92
|
-
}
|
|
93
|
-
tables.add(key);
|
|
94
|
-
if (!["STREAM AND SANITIZE", "EXCLUDE"].includes(table.sourceRows)) {
|
|
95
|
-
throw new Error(
|
|
96
|
-
`Sanitization policy table ${tableName} has an invalid sourceRows decision.`,
|
|
97
|
-
);
|
|
98
|
-
}
|
|
99
|
-
if (!Array.isArray(table.columns) || table.columns.length === 0) {
|
|
100
|
-
throw new Error(
|
|
101
|
-
`Sanitization policy table ${tableName} must list its columns.`,
|
|
102
|
-
);
|
|
103
|
-
}
|
|
104
|
-
const columns = new Set();
|
|
105
|
-
for (const column of table.columns) {
|
|
106
|
-
const columnName = assertIdentifier(
|
|
107
|
-
column?.name,
|
|
108
|
-
`Sanitization policy column in ${tableName}`,
|
|
109
|
-
);
|
|
110
|
-
if (columns.has(columnName)) {
|
|
111
|
-
throw new Error(
|
|
112
|
-
`Sanitization policy duplicates column ${tableName}.${columnName}.`,
|
|
113
|
-
);
|
|
114
|
-
}
|
|
115
|
-
columns.add(columnName);
|
|
116
|
-
normalizeSanitizationAction(column.action);
|
|
117
|
-
if (!["ALWAYS", "NEVER"].includes(column.generated)) {
|
|
118
|
-
throw new Error(
|
|
119
|
-
`Sanitization policy column ${tableName}.${columnName} must classify generated as ALWAYS or NEVER.`,
|
|
120
|
-
);
|
|
121
|
-
}
|
|
122
|
-
if (!["YES", "NO"].includes(column.identity)) {
|
|
123
|
-
throw new Error(
|
|
124
|
-
`Sanitization policy column ${tableName}.${columnName} must classify identity as YES or NO.`,
|
|
125
|
-
);
|
|
126
|
-
}
|
|
127
|
-
if (column.foreignKey !== null) {
|
|
128
|
-
if (
|
|
129
|
-
!column.foreignKey ||
|
|
130
|
-
typeof column.foreignKey !== "object" ||
|
|
131
|
-
Array.isArray(column.foreignKey)
|
|
132
|
-
) {
|
|
133
|
-
throw new Error(
|
|
134
|
-
`Sanitization policy column ${tableName}.${columnName} has an invalid foreignKey decision.`,
|
|
135
|
-
);
|
|
136
|
-
}
|
|
137
|
-
assertIdentifier(
|
|
138
|
-
column.foreignKey.schema,
|
|
139
|
-
`Foreign-key schema for ${tableName}.${columnName}`,
|
|
140
|
-
);
|
|
141
|
-
assertIdentifier(
|
|
142
|
-
column.foreignKey.table,
|
|
143
|
-
`Foreign-key table for ${tableName}.${columnName}`,
|
|
144
|
-
);
|
|
145
|
-
assertIdentifier(
|
|
146
|
-
column.foreignKey.column,
|
|
147
|
-
`Foreign-key column for ${tableName}.${columnName}`,
|
|
148
|
-
);
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
return policy;
|
|
153
|
-
};
|
|
154
|
-
|
|
155
|
-
export const readBoundRuntimeSanitizationPolicy = ({
|
|
156
|
-
bytes,
|
|
157
|
-
expectedSha256,
|
|
158
|
-
}) => {
|
|
159
|
-
const source = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes);
|
|
160
|
-
const policy = validateRuntimeSanitizationPolicy(
|
|
161
|
-
JSON.parse(source.toString("utf8")),
|
|
162
|
-
);
|
|
163
|
-
const actualSha256 = createHash("sha256").update(source).digest("hex");
|
|
164
|
-
if (actualSha256 !== expectedSha256) {
|
|
165
|
-
throw new Error(
|
|
166
|
-
"The active sanitization policy does not match the reviewed baseline policy checksum.",
|
|
167
|
-
);
|
|
168
|
-
}
|
|
169
|
-
return policy;
|
|
170
|
-
};
|
|
171
|
-
|
|
172
|
-
const indexTables = (tables, label) => {
|
|
173
|
-
if (!Array.isArray(tables)) throw new Error(`${label} must be an array.`);
|
|
174
|
-
const index = new Map();
|
|
175
|
-
for (const table of tables) {
|
|
176
|
-
const schema = assertIdentifier(
|
|
177
|
-
tableSchema(table),
|
|
178
|
-
`${label} table schema`,
|
|
179
|
-
);
|
|
180
|
-
const name = assertIdentifier(table?.name, `${label} table name`);
|
|
181
|
-
const key = schema === "public" ? name : `${schema}.${name}`;
|
|
182
|
-
if (index.has(key)) throw new Error(`${label} duplicates table ${key}.`);
|
|
183
|
-
if (!Array.isArray(table.columns)) {
|
|
184
|
-
throw new Error(`${label} table ${name} must list its columns.`);
|
|
185
|
-
}
|
|
186
|
-
const columns = new Map();
|
|
187
|
-
for (const column of table.columns) {
|
|
188
|
-
const columnName = assertIdentifier(
|
|
189
|
-
typeof column === "string" ? column : column?.name,
|
|
190
|
-
`${label} column name`,
|
|
191
|
-
);
|
|
192
|
-
if (columns.has(columnName)) {
|
|
193
|
-
throw new Error(`${label} duplicates column ${name}.${columnName}.`);
|
|
194
|
-
}
|
|
195
|
-
columns.set(columnName, column);
|
|
196
|
-
}
|
|
197
|
-
index.set(key, { table: { ...table, schema }, columns });
|
|
198
|
-
}
|
|
199
|
-
return index;
|
|
200
|
-
};
|
|
201
|
-
|
|
202
|
-
export const validateSanitizationCoverage = ({ policy, schemaTables }) => {
|
|
203
|
-
if (!policy || typeof policy !== "object" || Array.isArray(policy)) {
|
|
204
|
-
throw new Error("A Rehearsal sanitization policy object is required.");
|
|
205
|
-
}
|
|
206
|
-
const schema = indexTables(schemaTables, "Schema inventory");
|
|
207
|
-
const classified = indexTables(policy.tables, "Sanitization policy");
|
|
208
|
-
const normalizedTables = [];
|
|
209
|
-
|
|
210
|
-
for (const [tableName, schemaEntry] of schema) {
|
|
211
|
-
const policyEntry = classified.get(tableName);
|
|
212
|
-
if (!policyEntry) {
|
|
213
|
-
throw new Error(
|
|
214
|
-
`Sanitization policy does not classify table ${tableName}.`,
|
|
215
|
-
);
|
|
216
|
-
}
|
|
217
|
-
const normalizedColumns = [];
|
|
218
|
-
for (const columnName of schemaEntry.columns.keys()) {
|
|
219
|
-
const column = policyEntry.columns.get(columnName);
|
|
220
|
-
if (!column) {
|
|
221
|
-
throw new Error(
|
|
222
|
-
`Sanitization policy does not classify column ${tableName}.${columnName}.`,
|
|
223
|
-
);
|
|
224
|
-
}
|
|
225
|
-
normalizedColumns.push({
|
|
226
|
-
...column,
|
|
227
|
-
name: columnName,
|
|
228
|
-
action: normalizeSanitizationAction(column.action),
|
|
229
|
-
});
|
|
230
|
-
}
|
|
231
|
-
for (const columnName of policyEntry.columns.keys()) {
|
|
232
|
-
if (!schemaEntry.columns.has(columnName)) {
|
|
233
|
-
throw new Error(
|
|
234
|
-
`Sanitization policy references unknown column ${tableName}.${columnName}.`,
|
|
235
|
-
);
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
normalizedTables.push({
|
|
239
|
-
...policyEntry.table,
|
|
240
|
-
schema: tableSchema(policyEntry.table),
|
|
241
|
-
columns: normalizedColumns,
|
|
242
|
-
});
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
for (const tableName of classified.keys()) {
|
|
246
|
-
if (!schema.has(tableName)) {
|
|
247
|
-
throw new Error(
|
|
248
|
-
`Sanitization policy references unknown table ${tableName}.`,
|
|
249
|
-
);
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
return Object.freeze({
|
|
254
|
-
policyVersion: policy.policyVersion,
|
|
255
|
-
tableCount: normalizedTables.length,
|
|
256
|
-
columnCount: normalizedTables.reduce(
|
|
257
|
-
(total, table) => total + table.columns.length,
|
|
258
|
-
0,
|
|
259
|
-
),
|
|
260
|
-
tables: Object.freeze(normalizedTables),
|
|
261
|
-
});
|
|
262
|
-
};
|
|
263
|
-
|
|
264
|
-
export const applySanitizationAction = ({
|
|
265
|
-
action,
|
|
266
|
-
value,
|
|
267
|
-
replace,
|
|
268
|
-
pseudonymize,
|
|
269
|
-
derive,
|
|
270
|
-
context = {},
|
|
271
|
-
}) => {
|
|
272
|
-
switch (normalizeSanitizationAction(action)) {
|
|
273
|
-
case SANITIZATION_ACTIONS.KEEP:
|
|
274
|
-
return value;
|
|
275
|
-
case SANITIZATION_ACTIONS.EXCLUDE:
|
|
276
|
-
return EXCLUDED_VALUE;
|
|
277
|
-
case SANITIZATION_ACTIONS.REPLACE:
|
|
278
|
-
if (typeof replace !== "function") {
|
|
279
|
-
throw new Error(
|
|
280
|
-
"REPLACE requires a project-owned replacement function.",
|
|
281
|
-
);
|
|
282
|
-
}
|
|
283
|
-
return replace(value, context);
|
|
284
|
-
case SANITIZATION_ACTIONS.PSEUDONYMIZE:
|
|
285
|
-
if (typeof pseudonymize !== "function") {
|
|
286
|
-
throw new Error(
|
|
287
|
-
"PSEUDONYMIZE requires a project-owned pseudonymization function.",
|
|
288
|
-
);
|
|
289
|
-
}
|
|
290
|
-
return pseudonymize(value, context);
|
|
291
|
-
case SANITIZATION_ACTIONS.DERIVE:
|
|
292
|
-
if (typeof derive !== "function") {
|
|
293
|
-
throw new Error("DERIVE requires a project-owned derivation function.");
|
|
294
|
-
}
|
|
295
|
-
return derive(value, context);
|
|
296
|
-
default:
|
|
297
|
-
throw new Error("Unreachable Rehearsal sanitization action.");
|
|
298
|
-
}
|
|
299
|
-
};
|