@rehearsal-db/core 0.1.0-beta.7 → 0.1.0-beta.9
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 +77 -1
- package/COMPATIBILITY.md +15 -3
- package/README.md +31 -13
- package/docs/README.md +29 -0
- package/docs/adapters.md +15 -6
- package/docs/architecture.md +58 -0
- package/docs/baselines.md +22 -1
- package/docs/commands.md +81 -14
- package/docs/configuration.md +144 -11
- package/docs/getting-started.md +12 -2
- package/docs/glossary.md +4 -0
- package/docs/production-source.md +176 -33
- package/docs/roadmap.md +30 -28
- package/docs/runtime-policies.md +191 -0
- package/docs/sanitization.md +24 -8
- package/docs/security-model.md +29 -8
- package/docs/standalone-workflow.md +107 -0
- package/docs/troubleshooting.md +8 -0
- package/package.json +25 -22
- package/scripts/runtime/manage_database.mjs +18 -0
- package/src/README.md +17 -0
- package/src/application/session.mjs +313 -0
- package/{scripts/lib/rehearsal/baseline_artifact.mjs → src/baseline/artifact.mjs} +110 -14
- package/{scripts/lib/rehearsal/baseline_builder.mjs → src/baseline/builder.mjs} +8 -4
- package/{scripts/lib/rehearsal/baseline_preparation.mjs → src/baseline/preparation.mjs} +11 -4
- package/src/baseline/privacy_engine.mjs +413 -0
- package/{scripts/lib/rehearsal → src/baseline}/sanitization_policy.mjs +27 -7
- package/{scripts/lib/rehearsal → src/baseline}/schema_snapshot.mjs +1 -1
- package/src/cli/arguments.mjs +120 -0
- package/src/cli/guided.mjs +807 -0
- package/src/cli/rehearsal.mjs +933 -0
- package/src/cli/renderers.mjs +584 -0
- package/src/cli/runtime_commands.mjs +553 -0
- package/src/cli/source_commands.mjs +326 -0
- package/src/cli/terminal.mjs +275 -0
- package/src/identity/claim.mjs +975 -0
- package/src/identity/storage.mjs +165 -0
- package/{scripts/lib/rehearsal → src/project}/configuration.d.mts +34 -0
- package/{scripts/lib/rehearsal → src/project}/configuration.mjs +353 -3
- package/{scripts/lib/rehearsal → src/project}/setup.mjs +1 -1
- package/{scripts/lib/rehearsal → src/project}/support_report.mjs +5 -2
- package/{scripts/lib/rehearsal → src/runtime}/cleanup.mjs +3 -3
- package/{scripts/lib/rehearsal → src/runtime}/plan.mjs +5 -5
- package/src/runtime/policy.mjs +438 -0
- package/{scripts/lib/rehearsal/runtime_restore.mjs → src/runtime/restore.mjs} +40 -25
- package/src/runtime/topology.mjs +177 -0
- package/{scripts/lib/rehearsal → src/shared}/diagnostics.mjs +1 -1
- package/src/shared/operation_guard.mjs +162 -0
- package/{scripts/lib/rehearsal → src/shared}/process_environment.mjs +5 -1
- package/src/source/access.mjs +578 -0
- package/src/source/asset_transfer.mjs +177 -0
- package/src/source/baseline.mjs +446 -0
- package/src/source/postgresql_access.mjs +480 -0
- package/{scripts/lib/runtime/postgresql_runtime.mjs → src/targets/postgresql.mjs} +49 -13
- package/{scripts/lib/runtime/supabase_runtime.mjs → src/targets/supabase.mjs} +54 -16
- package/{scripts/lib/environment/local_supabase.mjs → src/targets/supabase_environment.mjs} +52 -20
- package/{scripts/lib/runtime/runtime_target.mjs → src/targets/target.mjs} +27 -2
- package/scripts/operations/database/manage_rehearsal_database.mjs +0 -11
- package/scripts/operations/rehearsal/rehearsal_cli.mjs +0 -2166
- /package/{scripts/lib/rehearsal → src/baseline}/input_discovery.mjs +0 -0
- /package/{scripts/lib/rehearsal → src/baseline}/policy_review.mjs +0 -0
- /package/{scripts/lib/rehearsal → src/runtime}/migration_history.mjs +0 -0
- /package/{scripts/lib/rehearsal → src/runtime}/service_environment.mjs +0 -0
- /package/{scripts/lib/rehearsal → src/shared}/human_output.mjs +0 -0
|
@@ -0,0 +1,413 @@
|
|
|
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
|
+
};
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { createHash } from "node:crypto";
|
|
7
|
+
import { validateExecutablePrivacyPolicy } from "./privacy_engine.mjs";
|
|
7
8
|
|
|
8
9
|
export const SANITIZATION_ACTIONS = Object.freeze({
|
|
9
10
|
KEEP: "KEEP",
|
|
@@ -33,6 +34,11 @@ const assertIdentifier = (value, label) => {
|
|
|
33
34
|
}
|
|
34
35
|
return value;
|
|
35
36
|
};
|
|
37
|
+
const tableSchema = (table) => table.schema ?? "public";
|
|
38
|
+
const tableKey = (table) =>
|
|
39
|
+
tableSchema(table) === "public"
|
|
40
|
+
? table.name
|
|
41
|
+
: `${tableSchema(table)}.${table.name}`;
|
|
36
42
|
|
|
37
43
|
export const normalizeSanitizationAction = (action) => {
|
|
38
44
|
const normalized = actionAliases.get(
|
|
@@ -55,8 +61,11 @@ export const validateRuntimeSanitizationPolicy = (policy) => {
|
|
|
55
61
|
"The sanitization policy is still a REVIEW REQUIRED draft.",
|
|
56
62
|
);
|
|
57
63
|
}
|
|
64
|
+
if (policy.policyVersion === 2) {
|
|
65
|
+
return validateExecutablePrivacyPolicy(policy);
|
|
66
|
+
}
|
|
58
67
|
if (policy.policyVersion !== 1) {
|
|
59
|
-
throw new Error("The sanitization policy must use policyVersion 1.");
|
|
68
|
+
throw new Error("The sanitization policy must use policyVersion 1 or 2.");
|
|
60
69
|
}
|
|
61
70
|
if (!/^\d{14}$/u.test(policy.migrationCutoff ?? "")) {
|
|
62
71
|
throw new Error(
|
|
@@ -68,14 +77,20 @@ export const validateRuntimeSanitizationPolicy = (policy) => {
|
|
|
68
77
|
}
|
|
69
78
|
const tables = new Set();
|
|
70
79
|
for (const table of policy.tables) {
|
|
80
|
+
const schemaName = assertIdentifier(
|
|
81
|
+
tableSchema(table),
|
|
82
|
+
"Sanitization policy table schema",
|
|
83
|
+
);
|
|
71
84
|
const tableName = assertIdentifier(
|
|
72
85
|
table?.name,
|
|
73
86
|
"Sanitization policy table name",
|
|
74
87
|
);
|
|
75
|
-
|
|
76
|
-
|
|
88
|
+
const key =
|
|
89
|
+
schemaName === "public" ? tableName : `${schemaName}.${tableName}`;
|
|
90
|
+
if (tables.has(key)) {
|
|
91
|
+
throw new Error(`Sanitization policy duplicates table ${key}.`);
|
|
77
92
|
}
|
|
78
|
-
tables.add(
|
|
93
|
+
tables.add(key);
|
|
79
94
|
if (!["STREAM AND SANITIZE", "EXCLUDE"].includes(table.sourceRows)) {
|
|
80
95
|
throw new Error(
|
|
81
96
|
`Sanitization policy table ${tableName} has an invalid sourceRows decision.`,
|
|
@@ -158,8 +173,13 @@ const indexTables = (tables, label) => {
|
|
|
158
173
|
if (!Array.isArray(tables)) throw new Error(`${label} must be an array.`);
|
|
159
174
|
const index = new Map();
|
|
160
175
|
for (const table of tables) {
|
|
176
|
+
const schema = assertIdentifier(
|
|
177
|
+
tableSchema(table),
|
|
178
|
+
`${label} table schema`,
|
|
179
|
+
);
|
|
161
180
|
const name = assertIdentifier(table?.name, `${label} table name`);
|
|
162
|
-
|
|
181
|
+
const key = schema === "public" ? name : `${schema}.${name}`;
|
|
182
|
+
if (index.has(key)) throw new Error(`${label} duplicates table ${key}.`);
|
|
163
183
|
if (!Array.isArray(table.columns)) {
|
|
164
184
|
throw new Error(`${label} table ${name} must list its columns.`);
|
|
165
185
|
}
|
|
@@ -174,7 +194,7 @@ const indexTables = (tables, label) => {
|
|
|
174
194
|
}
|
|
175
195
|
columns.set(columnName, column);
|
|
176
196
|
}
|
|
177
|
-
index.set(
|
|
197
|
+
index.set(key, { table: { ...table, schema }, columns });
|
|
178
198
|
}
|
|
179
199
|
return index;
|
|
180
200
|
};
|
|
@@ -217,7 +237,7 @@ export const validateSanitizationCoverage = ({ policy, schemaTables }) => {
|
|
|
217
237
|
}
|
|
218
238
|
normalizedTables.push({
|
|
219
239
|
...policyEntry.table,
|
|
220
|
-
|
|
240
|
+
schema: tableSchema(policyEntry.table),
|
|
221
241
|
columns: normalizedColumns,
|
|
222
242
|
});
|
|
223
243
|
}
|
|
@@ -7,7 +7,7 @@ import { spawnSync } from "node:child_process";
|
|
|
7
7
|
import { createHash } from "node:crypto";
|
|
8
8
|
import { chmod, mkdir, mkdtemp, readFile, rm } from "node:fs/promises";
|
|
9
9
|
import { basename, join, resolve, sep } from "node:path";
|
|
10
|
-
import { createCleanProcessEnvironment } from "
|
|
10
|
+
import { createCleanProcessEnvironment } from "../shared/process_environment.mjs";
|
|
11
11
|
|
|
12
12
|
const maximumSchemaBytes = 64 * 1024 * 1024;
|
|
13
13
|
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/** Parse and classify the stable Rehearsal command-line contract. */
|
|
2
|
+
|
|
3
|
+
export const parseArguments = (arguments_) => {
|
|
4
|
+
const flags = {
|
|
5
|
+
json: false,
|
|
6
|
+
help: false,
|
|
7
|
+
verbosity: "normal",
|
|
8
|
+
dryRun: false,
|
|
9
|
+
write: false,
|
|
10
|
+
plain: false,
|
|
11
|
+
configPath: undefined,
|
|
12
|
+
confirmation: undefined,
|
|
13
|
+
recordsPath: undefined,
|
|
14
|
+
ledgerPath: undefined,
|
|
15
|
+
assetsPath: undefined,
|
|
16
|
+
target: undefined,
|
|
17
|
+
includeRuntime: false,
|
|
18
|
+
includeImages: false,
|
|
19
|
+
cleanupConfirmation: undefined,
|
|
20
|
+
sourceAccessConfirmation: undefined,
|
|
21
|
+
sourceRetirementConfirmation: undefined,
|
|
22
|
+
refreshConfirmation: undefined,
|
|
23
|
+
identityConfirmation: undefined,
|
|
24
|
+
identityName: undefined,
|
|
25
|
+
runtimeTopology: undefined,
|
|
26
|
+
targetConfirmations: undefined,
|
|
27
|
+
};
|
|
28
|
+
const positionals = [];
|
|
29
|
+
for (const argument of arguments_) {
|
|
30
|
+
if (argument === "--json") flags.json = true;
|
|
31
|
+
else if (argument === "--help" || argument === "-h") flags.help = true;
|
|
32
|
+
else if (argument === "--verbose") flags.verbosity = "verbose";
|
|
33
|
+
else if (argument === "--debug") flags.verbosity = "debug";
|
|
34
|
+
else if (argument === "--dry-run") flags.dryRun = true;
|
|
35
|
+
else if (argument === "--write") flags.write = true;
|
|
36
|
+
else if (argument === "--plain") flags.plain = true;
|
|
37
|
+
else if (argument.startsWith("--config="))
|
|
38
|
+
flags.configPath = argument.slice("--config=".length);
|
|
39
|
+
else if (argument.startsWith("--confirm-candidates="))
|
|
40
|
+
flags.confirmation = argument.slice("--confirm-candidates=".length);
|
|
41
|
+
else if (argument.startsWith("--confirm-cleanup="))
|
|
42
|
+
flags.cleanupConfirmation = argument.slice("--confirm-cleanup=".length);
|
|
43
|
+
else if (argument.startsWith("--confirm-source-access="))
|
|
44
|
+
flags.sourceAccessConfirmation = argument.slice(
|
|
45
|
+
"--confirm-source-access=".length,
|
|
46
|
+
);
|
|
47
|
+
else if (argument.startsWith("--confirm-source-retirement="))
|
|
48
|
+
flags.sourceRetirementConfirmation = argument.slice(
|
|
49
|
+
"--confirm-source-retirement=".length,
|
|
50
|
+
);
|
|
51
|
+
else if (argument.startsWith("--confirm-refresh="))
|
|
52
|
+
flags.refreshConfirmation = argument.slice("--confirm-refresh=".length);
|
|
53
|
+
else if (argument.startsWith("--confirm-identity="))
|
|
54
|
+
flags.identityConfirmation = argument.slice("--confirm-identity=".length);
|
|
55
|
+
else if (argument.startsWith("--identity="))
|
|
56
|
+
flags.identityName = argument.slice("--identity=".length);
|
|
57
|
+
else if (argument.startsWith("--records="))
|
|
58
|
+
flags.recordsPath = argument.slice("--records=".length);
|
|
59
|
+
else if (argument.startsWith("--ledger="))
|
|
60
|
+
flags.ledgerPath = argument.slice("--ledger=".length);
|
|
61
|
+
else if (argument.startsWith("--assets="))
|
|
62
|
+
flags.assetsPath = argument.slice("--assets=".length);
|
|
63
|
+
else if (argument.startsWith("--target="))
|
|
64
|
+
flags.target = argument.slice("--target=".length);
|
|
65
|
+
else if (argument === "--include-runtime") flags.includeRuntime = true;
|
|
66
|
+
else if (argument === "--include-images") flags.includeImages = true;
|
|
67
|
+
else if (argument.startsWith("--"))
|
|
68
|
+
throw new Error(`Unknown Rehearsal option: ${argument}.`);
|
|
69
|
+
else positionals.push(argument);
|
|
70
|
+
}
|
|
71
|
+
return { flags, positionals };
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export const resetGuidedFlags = (flags) => {
|
|
75
|
+
Object.assign(flags, {
|
|
76
|
+
dryRun: false,
|
|
77
|
+
write: false,
|
|
78
|
+
confirmation: undefined,
|
|
79
|
+
recordsPath: undefined,
|
|
80
|
+
ledgerPath: undefined,
|
|
81
|
+
assetsPath: undefined,
|
|
82
|
+
target: undefined,
|
|
83
|
+
includeRuntime: false,
|
|
84
|
+
includeImages: false,
|
|
85
|
+
cleanupConfirmation: undefined,
|
|
86
|
+
sourceAccessConfirmation: undefined,
|
|
87
|
+
sourceRetirementConfirmation: undefined,
|
|
88
|
+
refreshConfirmation: undefined,
|
|
89
|
+
identityConfirmation: undefined,
|
|
90
|
+
identityName: undefined,
|
|
91
|
+
runtimeTopology: undefined,
|
|
92
|
+
targetConfirmations: undefined,
|
|
93
|
+
setupPlan: undefined,
|
|
94
|
+
preparationPlan: undefined,
|
|
95
|
+
cleanupPlan: undefined,
|
|
96
|
+
});
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export const commandMutatesProject = ({ command, flags, guided }) => {
|
|
100
|
+
if (guided) return true;
|
|
101
|
+
if (["setup", "init", "baseline prepare", "privacy key"].includes(command))
|
|
102
|
+
return flags.write;
|
|
103
|
+
if (command === "cleanup") return flags.write;
|
|
104
|
+
if (command === "source apply")
|
|
105
|
+
return Boolean(flags.sourceAccessConfirmation);
|
|
106
|
+
if (command === "source retire")
|
|
107
|
+
return Boolean(flags.sourceRetirementConfirmation);
|
|
108
|
+
if (command === "refresh") return Boolean(flags.refreshConfirmation);
|
|
109
|
+
if (command === "identity claim") return Boolean(flags.identityConfirmation);
|
|
110
|
+
if (command === "run") return !flags.dryRun;
|
|
111
|
+
return [
|
|
112
|
+
"baseline create",
|
|
113
|
+
"baseline refresh",
|
|
114
|
+
"start",
|
|
115
|
+
"migrate",
|
|
116
|
+
"reset",
|
|
117
|
+
"stop",
|
|
118
|
+
"discard",
|
|
119
|
+
].includes(command);
|
|
120
|
+
};
|