@rehearsal-db/core 0.1.0-beta.1
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/BENCHMARKS.md +61 -0
- package/CHANGELOG.md +59 -0
- package/COMPATIBILITY.md +22 -0
- package/LICENSE +21 -0
- package/README.md +375 -0
- package/SECURITY.md +19 -0
- package/SUPPORT.md +15 -0
- package/docs/adapters.md +23 -0
- package/docs/baselines.md +33 -0
- package/docs/commands.md +35 -0
- package/docs/configuration.md +98 -0
- package/docs/getting-started.md +247 -0
- package/docs/glossary.md +33 -0
- package/docs/production-source.md +33 -0
- package/docs/releasing.md +79 -0
- package/docs/sanitization.md +69 -0
- package/docs/security-model.md +40 -0
- package/docs/troubleshooting.md +50 -0
- package/docs/tutorial.md +96 -0
- package/package.json +77 -0
- package/scripts/lib/environment/local_supabase.mjs +197 -0
- package/scripts/lib/rehearsal/baseline_artifact.mjs +536 -0
- package/scripts/lib/rehearsal/baseline_builder.mjs +155 -0
- package/scripts/lib/rehearsal/configuration.d.mts +85 -0
- package/scripts/lib/rehearsal/configuration.mjs +559 -0
- package/scripts/lib/rehearsal/diagnostics.mjs +193 -0
- package/scripts/lib/rehearsal/migration_history.mjs +220 -0
- package/scripts/lib/rehearsal/plan.mjs +587 -0
- package/scripts/lib/rehearsal/process_environment.mjs +64 -0
- package/scripts/lib/rehearsal/runtime_restore.mjs +310 -0
- package/scripts/lib/rehearsal/sanitization_policy.mjs +169 -0
- package/scripts/lib/rehearsal/schema_snapshot.mjs +113 -0
- package/scripts/lib/rehearsal/service_environment.mjs +82 -0
- package/scripts/operations/database/manage_rehearsal_database.mjs +784 -0
- package/scripts/operations/rehearsal/rehearsal_cli.mjs +565 -0
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Purpose: Build the bounded PostgreSQL restore program for a verified sanitized
|
|
3
|
+
* Rehearsal baseline. Do not run directly; this module is reusable script infrastructure.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
|
|
8
|
+
const identifierPattern = /^[a-z][a-z0-9_]{0,62}$/u;
|
|
9
|
+
const constraintFailurePattern =
|
|
10
|
+
/^ERROR:\s+(?:[A-Z0-9]{5}:\s+)?new row for relation "([a-z][a-z0-9_]*)" violates check constraint "([a-z][a-z0-9_]*)"$/mu;
|
|
11
|
+
const sqlStatePattern = /^ERROR:\s+([A-Z0-9]{5}):/mu;
|
|
12
|
+
const notNullFailurePattern =
|
|
13
|
+
/^ERROR:\s+(?:23502:\s+)?null value in column "([a-z][a-z0-9_]*)" of relation "([a-z][a-z0-9_]*)" violates not-null constraint$/mu;
|
|
14
|
+
const quoteIdentifier = (value) => {
|
|
15
|
+
if (!identifierPattern.test(value)) {
|
|
16
|
+
throw new Error(`Unsafe Rehearsal restore identifier: ${value}`);
|
|
17
|
+
}
|
|
18
|
+
return `"${value}"`;
|
|
19
|
+
};
|
|
20
|
+
const quoteLiteral = (value) => `'${value.replaceAll("'", "''")}'`;
|
|
21
|
+
|
|
22
|
+
const includedPolicies = (manifest) =>
|
|
23
|
+
manifest.tables
|
|
24
|
+
.filter((table) => table.sourceRows !== "EXCLUDE")
|
|
25
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
26
|
+
|
|
27
|
+
export const summarizeRestoreError = (stderr) => {
|
|
28
|
+
const match = String(stderr).match(constraintFailurePattern);
|
|
29
|
+
if (match) return `Sanitized data violated ${match[1]}.${match[2]}.`;
|
|
30
|
+
const notNull = String(stderr).match(notNullFailurePattern);
|
|
31
|
+
if (notNull) {
|
|
32
|
+
return `Sanitized data omitted required column ${notNull[2]}.${notNull[1]}.`;
|
|
33
|
+
}
|
|
34
|
+
const sqlState = String(stderr).match(sqlStatePattern)?.[1];
|
|
35
|
+
return sqlState
|
|
36
|
+
? `PostgreSQL rejected the sanitized baseline stream with SQLSTATE ${sqlState}; row details were withheld.`
|
|
37
|
+
: "PostgreSQL rejected the sanitized baseline stream; row details were withheld.";
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const encodeBaselineRecordForCopy = (line) => {
|
|
41
|
+
let record;
|
|
42
|
+
try {
|
|
43
|
+
record = JSON.parse(line.trimEnd());
|
|
44
|
+
} catch (error) {
|
|
45
|
+
throw new Error("A Rehearsal baseline record is not valid JSON.", {
|
|
46
|
+
cause: error,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
if (
|
|
50
|
+
!identifierPattern.test(record?.table ?? "") ||
|
|
51
|
+
!record.row ||
|
|
52
|
+
typeof record.row !== "object" ||
|
|
53
|
+
Array.isArray(record.row)
|
|
54
|
+
) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
"A Rehearsal baseline record has an invalid restore shape.",
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
return `${record.table}\t${Buffer.from(JSON.stringify(record.row), "utf8").toString("base64")}`;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const buildRestoreSqlPrefix = () => `\\set ON_ERROR_STOP on
|
|
63
|
+
\\set VERBOSITY verbose
|
|
64
|
+
begin;
|
|
65
|
+
set local session_replication_role = replica;
|
|
66
|
+
create temporary table rehearsal_restore_rows (
|
|
67
|
+
sequence bigint generated always as identity primary key,
|
|
68
|
+
table_name text not null,
|
|
69
|
+
encoded text not null
|
|
70
|
+
) on commit drop;
|
|
71
|
+
copy rehearsal_restore_rows (table_name, encoded) from stdin;
|
|
72
|
+
`;
|
|
73
|
+
|
|
74
|
+
const decodedPayloadSql =
|
|
75
|
+
"convert_from(decode(source.encoded, 'base64'), 'UTF8')::jsonb";
|
|
76
|
+
|
|
77
|
+
const authReferenceSelects = (manifest) =>
|
|
78
|
+
includedPolicies(manifest).flatMap((table) =>
|
|
79
|
+
table.columns
|
|
80
|
+
.filter(
|
|
81
|
+
(column) =>
|
|
82
|
+
column.action !== "EXCLUDE" &&
|
|
83
|
+
column.foreignKey?.schema === "auth" &&
|
|
84
|
+
column.foreignKey?.table === "users" &&
|
|
85
|
+
column.foreignKey?.column === "id",
|
|
86
|
+
)
|
|
87
|
+
.map(
|
|
88
|
+
(
|
|
89
|
+
column,
|
|
90
|
+
) => `select nullif(${decodedPayloadSql}->>${quoteLiteral(column.name)}, '')::uuid as id
|
|
91
|
+
from rehearsal_restore_rows source
|
|
92
|
+
where source.table_name = ${quoteLiteral(table.name)}`,
|
|
93
|
+
),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const buildAuthRestoreSql = (manifest) => {
|
|
97
|
+
const selects = authReferenceSelects(manifest);
|
|
98
|
+
if (selects.length === 0) return "";
|
|
99
|
+
return `with referenced_auth_users as (
|
|
100
|
+
${selects.join("\n\tunion\n\t")}
|
|
101
|
+
)
|
|
102
|
+
insert into auth.users (
|
|
103
|
+
instance_id,
|
|
104
|
+
id,
|
|
105
|
+
aud,
|
|
106
|
+
role,
|
|
107
|
+
email,
|
|
108
|
+
confirmation_token,
|
|
109
|
+
recovery_token,
|
|
110
|
+
email_change_token_new,
|
|
111
|
+
email_change,
|
|
112
|
+
email_change_token_current,
|
|
113
|
+
phone_change,
|
|
114
|
+
phone_change_token,
|
|
115
|
+
reauthentication_token,
|
|
116
|
+
raw_app_meta_data,
|
|
117
|
+
raw_user_meta_data,
|
|
118
|
+
is_sso_user,
|
|
119
|
+
is_anonymous,
|
|
120
|
+
created_at,
|
|
121
|
+
updated_at
|
|
122
|
+
)
|
|
123
|
+
select distinct
|
|
124
|
+
'00000000-0000-0000-0000-000000000000'::uuid,
|
|
125
|
+
id,
|
|
126
|
+
'authenticated',
|
|
127
|
+
'authenticated',
|
|
128
|
+
'rehearsal-' || replace(id::text, '-', '') || '@rehearsal.invalid',
|
|
129
|
+
'',
|
|
130
|
+
'',
|
|
131
|
+
'',
|
|
132
|
+
'',
|
|
133
|
+
'',
|
|
134
|
+
'',
|
|
135
|
+
'',
|
|
136
|
+
'',
|
|
137
|
+
'{"provider":"email","providers":["email"]}'::jsonb,
|
|
138
|
+
'{"rehearsal":true}'::jsonb,
|
|
139
|
+
false,
|
|
140
|
+
false,
|
|
141
|
+
now(),
|
|
142
|
+
now()
|
|
143
|
+
from referenced_auth_users
|
|
144
|
+
where id is not null
|
|
145
|
+
on conflict (id) do nothing;
|
|
146
|
+
`;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const buildTableRestoreSql = (table) => {
|
|
150
|
+
const tableName = quoteIdentifier(table.name);
|
|
151
|
+
const columns = table.columns.filter(
|
|
152
|
+
(column) => column.action !== "EXCLUDE" && column.generated !== "ALWAYS",
|
|
153
|
+
);
|
|
154
|
+
if (columns.length === 0) return "";
|
|
155
|
+
const columnList = columns
|
|
156
|
+
.map((column) => quoteIdentifier(column.name))
|
|
157
|
+
.join(", ");
|
|
158
|
+
const selectedColumns = columns
|
|
159
|
+
.map((column) => `restored.${quoteIdentifier(column.name)}`)
|
|
160
|
+
.join(", ");
|
|
161
|
+
const overridesIdentity = columns.some((column) => column.identity === "YES")
|
|
162
|
+
? " overriding system value"
|
|
163
|
+
: "";
|
|
164
|
+
return `insert into public.${tableName} (${columnList})${overridesIdentity}
|
|
165
|
+
select ${selectedColumns}
|
|
166
|
+
from rehearsal_restore_rows source
|
|
167
|
+
cross join lateral jsonb_populate_record(
|
|
168
|
+
null::public.${tableName},
|
|
169
|
+
${decodedPayloadSql}
|
|
170
|
+
) restored
|
|
171
|
+
where source.table_name = ${quoteLiteral(table.name)}
|
|
172
|
+
order by source.sequence;
|
|
173
|
+
`;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const buildIdentitySequenceSql = (table) =>
|
|
177
|
+
table.columns
|
|
178
|
+
.filter(
|
|
179
|
+
(column) =>
|
|
180
|
+
column.action !== "EXCLUDE" &&
|
|
181
|
+
column.identity === "YES" &&
|
|
182
|
+
column.generated !== "ALWAYS",
|
|
183
|
+
)
|
|
184
|
+
.map((column) => {
|
|
185
|
+
const relation = `public.${quoteIdentifier(table.name)}`;
|
|
186
|
+
const columnName = quoteIdentifier(column.name);
|
|
187
|
+
return `select setval(
|
|
188
|
+
pg_get_serial_sequence(${quoteLiteral(`public.${table.name}`)}, ${quoteLiteral(column.name)}),
|
|
189
|
+
coalesce((select max(${columnName}) from ${relation}), 1),
|
|
190
|
+
exists(select 1 from ${relation})
|
|
191
|
+
);`;
|
|
192
|
+
})
|
|
193
|
+
.join("\n");
|
|
194
|
+
|
|
195
|
+
const buildRowCountChecks = (tableCounts) =>
|
|
196
|
+
Object.entries(tableCounts)
|
|
197
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
198
|
+
.map(([table, expected]) => {
|
|
199
|
+
quoteIdentifier(table);
|
|
200
|
+
if (!Number.isSafeInteger(expected) || expected < 0) {
|
|
201
|
+
throw new Error(`Invalid Rehearsal row count for ${table}.`);
|
|
202
|
+
}
|
|
203
|
+
return `if (select count(*) from public.${quoteIdentifier(table)}) <> ${expected} then
|
|
204
|
+
raise exception 'Rehearsal row-count mismatch for ${table}';
|
|
205
|
+
end if;`;
|
|
206
|
+
})
|
|
207
|
+
.join("\n\t");
|
|
208
|
+
|
|
209
|
+
const buildForeignKeyChecks = (manifest) =>
|
|
210
|
+
includedPolicies(manifest)
|
|
211
|
+
.flatMap((table) =>
|
|
212
|
+
table.columns
|
|
213
|
+
.filter((column) => column.action !== "EXCLUDE" && column.foreignKey)
|
|
214
|
+
.map((column) => ({ table, column })),
|
|
215
|
+
)
|
|
216
|
+
.map(({ table, column }) => {
|
|
217
|
+
const target = column.foreignKey;
|
|
218
|
+
for (const value of [
|
|
219
|
+
table.name,
|
|
220
|
+
column.name,
|
|
221
|
+
target.schema,
|
|
222
|
+
target.table,
|
|
223
|
+
target.column,
|
|
224
|
+
])
|
|
225
|
+
quoteIdentifier(value);
|
|
226
|
+
return `if exists (
|
|
227
|
+
select 1
|
|
228
|
+
from public.${quoteIdentifier(table.name)} child
|
|
229
|
+
left join ${quoteIdentifier(target.schema)}.${quoteIdentifier(target.table)} parent
|
|
230
|
+
on parent.${quoteIdentifier(target.column)} = child.${quoteIdentifier(column.name)}
|
|
231
|
+
where child.${quoteIdentifier(column.name)} is not null
|
|
232
|
+
and parent.${quoteIdentifier(target.column)} is null
|
|
233
|
+
) then
|
|
234
|
+
raise exception 'Rehearsal foreign-key mismatch for ${table.name}.${column.name}';
|
|
235
|
+
end if;`;
|
|
236
|
+
})
|
|
237
|
+
.join("\n\t");
|
|
238
|
+
|
|
239
|
+
export const buildRestoreSqlSuffix = ({ manifest, tableCounts }) => {
|
|
240
|
+
if (!Array.isArray(manifest?.tables) || !tableCounts) {
|
|
241
|
+
throw new Error("A reviewed manifest and exact row counts are required.");
|
|
242
|
+
}
|
|
243
|
+
const tables = includedPolicies(manifest);
|
|
244
|
+
const expectedNames = tables.map((table) => table.name).sort();
|
|
245
|
+
if (expectedNames.join("\0") !== Object.keys(tableCounts).sort().join("\0")) {
|
|
246
|
+
throw new Error("Rehearsal restore table counts do not match the policy.");
|
|
247
|
+
}
|
|
248
|
+
return `\\.
|
|
249
|
+
create index rehearsal_restore_rows_table_name_idx
|
|
250
|
+
on rehearsal_restore_rows (table_name, sequence);
|
|
251
|
+
truncate table ${manifest.tables
|
|
252
|
+
.map((table) => `public.${quoteIdentifier(table.name)}`)
|
|
253
|
+
.join(", ")}
|
|
254
|
+
restart identity cascade;
|
|
255
|
+
${buildAuthRestoreSql(manifest)}
|
|
256
|
+
${tables.map(buildTableRestoreSql).join("\n")}
|
|
257
|
+
${tables.map(buildIdentitySequenceSql).filter(Boolean).join("\n")}
|
|
258
|
+
set local session_replication_role = origin;
|
|
259
|
+
do $rehearsal_checks$
|
|
260
|
+
begin
|
|
261
|
+
${buildRowCountChecks(tableCounts)}
|
|
262
|
+
${buildForeignKeyChecks(manifest)}
|
|
263
|
+
end
|
|
264
|
+
$rehearsal_checks$;
|
|
265
|
+
commit;
|
|
266
|
+
`;
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
export const createCandidateMigrationReceipt = ({
|
|
270
|
+
baselineManifest,
|
|
271
|
+
currentFiles,
|
|
272
|
+
}) => {
|
|
273
|
+
const baselineEntries = Object.entries(
|
|
274
|
+
baselineManifest?.migrations ?? {},
|
|
275
|
+
).sort(([left], [right]) => left.localeCompare(right));
|
|
276
|
+
if (baselineEntries.length === 0) {
|
|
277
|
+
throw new Error("The active Rehearsal baseline has no migration bundle.");
|
|
278
|
+
}
|
|
279
|
+
if (baselineEntries.length > currentFiles.length) {
|
|
280
|
+
throw new Error(
|
|
281
|
+
"The active Rehearsal history is absent from local source.",
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
for (let index = 0; index < baselineEntries.length; index += 1) {
|
|
285
|
+
const [filename, receipt] = baselineEntries[index];
|
|
286
|
+
const current = currentFiles[index];
|
|
287
|
+
if (
|
|
288
|
+
current.filename !== filename ||
|
|
289
|
+
current.fileSha256 !== receipt.sha256
|
|
290
|
+
) {
|
|
291
|
+
throw new Error(
|
|
292
|
+
`Local migration history diverges from the active baseline at ${filename}.`,
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
const candidates = currentFiles.slice(baselineEntries.length);
|
|
297
|
+
const candidateSha256 = createHash("sha256")
|
|
298
|
+
.update(
|
|
299
|
+
`${JSON.stringify(
|
|
300
|
+
candidates.map(({ filename, fileSha256 }) => ({
|
|
301
|
+
filename,
|
|
302
|
+
fileSha256,
|
|
303
|
+
})),
|
|
304
|
+
null,
|
|
305
|
+
"\t",
|
|
306
|
+
)}\n`,
|
|
307
|
+
)
|
|
308
|
+
.digest("hex");
|
|
309
|
+
return { candidates, candidateSha256 };
|
|
310
|
+
};
|
|
@@ -0,0 +1,169 @@
|
|
|
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
|
+
export const SANITIZATION_ACTIONS = Object.freeze({
|
|
7
|
+
KEEP: "KEEP",
|
|
8
|
+
PSEUDONYMIZE: "PSEUDONYMIZE",
|
|
9
|
+
REPLACE: "REPLACE",
|
|
10
|
+
EXCLUDE: "EXCLUDE",
|
|
11
|
+
DERIVE: "DERIVE",
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
export const EXCLUDED_VALUE = Symbol("REHEARSAL_EXCLUDED_VALUE");
|
|
15
|
+
|
|
16
|
+
const actionAliases = new Map([
|
|
17
|
+
["KEEP", SANITIZATION_ACTIONS.KEEP],
|
|
18
|
+
["KEEP EXACTLY", SANITIZATION_ACTIONS.KEEP],
|
|
19
|
+
["PSEUDONYMIZE", SANITIZATION_ACTIONS.PSEUDONYMIZE],
|
|
20
|
+
["REPLACE", SANITIZATION_ACTIONS.REPLACE],
|
|
21
|
+
["REPLACE WITH SYNTHETIC", SANITIZATION_ACTIONS.REPLACE],
|
|
22
|
+
["EXCLUDE", SANITIZATION_ACTIONS.EXCLUDE],
|
|
23
|
+
["DERIVE", SANITIZATION_ACTIONS.DERIVE],
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
const identifierPattern = /^[a-z][a-z0-9_]{0,62}$/u;
|
|
27
|
+
|
|
28
|
+
const assertIdentifier = (value, label) => {
|
|
29
|
+
if (!identifierPattern.test(value ?? "")) {
|
|
30
|
+
throw new Error(`${label} must be a lowercase PostgreSQL identifier.`);
|
|
31
|
+
}
|
|
32
|
+
return value;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const normalizeSanitizationAction = (action) => {
|
|
36
|
+
const normalized = actionAliases.get(
|
|
37
|
+
String(action ?? "")
|
|
38
|
+
.trim()
|
|
39
|
+
.toUpperCase(),
|
|
40
|
+
);
|
|
41
|
+
if (!normalized) {
|
|
42
|
+
throw new Error(`Unknown Rehearsal sanitization action: ${action}.`);
|
|
43
|
+
}
|
|
44
|
+
return normalized;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const indexTables = (tables, label) => {
|
|
48
|
+
if (!Array.isArray(tables)) throw new Error(`${label} must be an array.`);
|
|
49
|
+
const index = new Map();
|
|
50
|
+
for (const table of tables) {
|
|
51
|
+
const name = assertIdentifier(table?.name, `${label} table name`);
|
|
52
|
+
if (index.has(name)) throw new Error(`${label} duplicates table ${name}.`);
|
|
53
|
+
if (!Array.isArray(table.columns)) {
|
|
54
|
+
throw new Error(`${label} table ${name} must list its columns.`);
|
|
55
|
+
}
|
|
56
|
+
const columns = new Map();
|
|
57
|
+
for (const column of table.columns) {
|
|
58
|
+
const columnName = assertIdentifier(
|
|
59
|
+
typeof column === "string" ? column : column?.name,
|
|
60
|
+
`${label} column name`,
|
|
61
|
+
);
|
|
62
|
+
if (columns.has(columnName)) {
|
|
63
|
+
throw new Error(`${label} duplicates column ${name}.${columnName}.`);
|
|
64
|
+
}
|
|
65
|
+
columns.set(columnName, column);
|
|
66
|
+
}
|
|
67
|
+
index.set(name, { table, columns });
|
|
68
|
+
}
|
|
69
|
+
return index;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export const validateSanitizationCoverage = ({ policy, schemaTables }) => {
|
|
73
|
+
if (!policy || typeof policy !== "object" || Array.isArray(policy)) {
|
|
74
|
+
throw new Error("A Rehearsal sanitization policy object is required.");
|
|
75
|
+
}
|
|
76
|
+
const schema = indexTables(schemaTables, "Schema inventory");
|
|
77
|
+
const classified = indexTables(policy.tables, "Sanitization policy");
|
|
78
|
+
const normalizedTables = [];
|
|
79
|
+
|
|
80
|
+
for (const [tableName, schemaEntry] of schema) {
|
|
81
|
+
const policyEntry = classified.get(tableName);
|
|
82
|
+
if (!policyEntry) {
|
|
83
|
+
throw new Error(
|
|
84
|
+
`Sanitization policy does not classify table ${tableName}.`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
const normalizedColumns = [];
|
|
88
|
+
for (const columnName of schemaEntry.columns.keys()) {
|
|
89
|
+
const column = policyEntry.columns.get(columnName);
|
|
90
|
+
if (!column) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
`Sanitization policy does not classify column ${tableName}.${columnName}.`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
normalizedColumns.push({
|
|
96
|
+
...column,
|
|
97
|
+
name: columnName,
|
|
98
|
+
action: normalizeSanitizationAction(column.action),
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
for (const columnName of policyEntry.columns.keys()) {
|
|
102
|
+
if (!schemaEntry.columns.has(columnName)) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
`Sanitization policy references unknown column ${tableName}.${columnName}.`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
normalizedTables.push({
|
|
109
|
+
...policyEntry.table,
|
|
110
|
+
name: tableName,
|
|
111
|
+
columns: normalizedColumns,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
for (const tableName of classified.keys()) {
|
|
116
|
+
if (!schema.has(tableName)) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
`Sanitization policy references unknown table ${tableName}.`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return Object.freeze({
|
|
124
|
+
policyVersion: policy.policyVersion,
|
|
125
|
+
tableCount: normalizedTables.length,
|
|
126
|
+
columnCount: normalizedTables.reduce(
|
|
127
|
+
(total, table) => total + table.columns.length,
|
|
128
|
+
0,
|
|
129
|
+
),
|
|
130
|
+
tables: Object.freeze(normalizedTables),
|
|
131
|
+
});
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
export const applySanitizationAction = ({
|
|
135
|
+
action,
|
|
136
|
+
value,
|
|
137
|
+
replace,
|
|
138
|
+
pseudonymize,
|
|
139
|
+
derive,
|
|
140
|
+
context = {},
|
|
141
|
+
}) => {
|
|
142
|
+
switch (normalizeSanitizationAction(action)) {
|
|
143
|
+
case SANITIZATION_ACTIONS.KEEP:
|
|
144
|
+
return value;
|
|
145
|
+
case SANITIZATION_ACTIONS.EXCLUDE:
|
|
146
|
+
return EXCLUDED_VALUE;
|
|
147
|
+
case SANITIZATION_ACTIONS.REPLACE:
|
|
148
|
+
if (typeof replace !== "function") {
|
|
149
|
+
throw new Error(
|
|
150
|
+
"REPLACE requires a project-owned replacement function.",
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
return replace(value, context);
|
|
154
|
+
case SANITIZATION_ACTIONS.PSEUDONYMIZE:
|
|
155
|
+
if (typeof pseudonymize !== "function") {
|
|
156
|
+
throw new Error(
|
|
157
|
+
"PSEUDONYMIZE requires a project-owned pseudonymization function.",
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
return pseudonymize(value, context);
|
|
161
|
+
case SANITIZATION_ACTIONS.DERIVE:
|
|
162
|
+
if (typeof derive !== "function") {
|
|
163
|
+
throw new Error("DERIVE requires a project-owned derivation function.");
|
|
164
|
+
}
|
|
165
|
+
return derive(value, context);
|
|
166
|
+
default:
|
|
167
|
+
throw new Error("Unreachable Rehearsal sanitization action.");
|
|
168
|
+
}
|
|
169
|
+
};
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Purpose: Capture and compare bounded, data-free PostgreSQL public-schema
|
|
3
|
+
* snapshots for a local Rehearsal runtime. Do not run directly.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import { chmod, mkdir, mkdtemp, readFile, rm } from "node:fs/promises";
|
|
9
|
+
import { basename, join, resolve, sep } from "node:path";
|
|
10
|
+
import { createCleanProcessEnvironment } from "./process_environment.mjs";
|
|
11
|
+
|
|
12
|
+
const maximumSchemaBytes = 64 * 1024 * 1024;
|
|
13
|
+
|
|
14
|
+
const assertArtifactRoot = (artifactRoot) => {
|
|
15
|
+
const resolved = resolve(artifactRoot);
|
|
16
|
+
if (basename(resolved) !== ".rehearsal" || resolved === sep) {
|
|
17
|
+
throw new Error(
|
|
18
|
+
`Refusing an unsafe Rehearsal artifact root: ${artifactRoot}`,
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
return resolved;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const assertProductionSchemaDump = (schemaSql) => {
|
|
25
|
+
if (typeof schemaSql !== "string" || schemaSql.length === 0) {
|
|
26
|
+
throw new Error("The Rehearsal production schema snapshot is empty.");
|
|
27
|
+
}
|
|
28
|
+
if (Buffer.byteLength(schemaSql) > maximumSchemaBytes) {
|
|
29
|
+
throw new Error("The Rehearsal production schema snapshot is too large.");
|
|
30
|
+
}
|
|
31
|
+
if (!schemaSql.includes('CREATE SCHEMA IF NOT EXISTS "public";')) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
"The Rehearsal production schema snapshot does not define public.",
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
if (/^COPY\s|^\\connect\s|^CREATE DATABASE\s/mu.test(schemaSql)) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
"The Rehearsal production schema snapshot contains data or database switching commands.",
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
return schemaSql;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const comparableSchemaDump = (schemaSql) =>
|
|
45
|
+
assertProductionSchemaDump(schemaSql)
|
|
46
|
+
.split("\n")
|
|
47
|
+
.map((line) => line.trimEnd())
|
|
48
|
+
.filter(Boolean)
|
|
49
|
+
.map((line) => {
|
|
50
|
+
const checkConstraint = line.match(
|
|
51
|
+
/^(\s*CONSTRAINT "[^"]+" CHECK ).*(,?)$/u,
|
|
52
|
+
);
|
|
53
|
+
return checkConstraint
|
|
54
|
+
? `${checkConstraint[1]}<database-normalized-expression>${checkConstraint[2]}`
|
|
55
|
+
: line;
|
|
56
|
+
})
|
|
57
|
+
.join("\n");
|
|
58
|
+
|
|
59
|
+
export const compareProductionSchemaDumps = ({ expected, actual }) => {
|
|
60
|
+
const expectedComparable = comparableSchemaDump(expected);
|
|
61
|
+
const actualComparable = comparableSchemaDump(actual);
|
|
62
|
+
if (expectedComparable !== actualComparable) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
"The local Rehearsal public schema does not structurally match the production snapshot.",
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
return createHash("sha256").update(actualComparable).digest("hex");
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export const captureSupabasePublicSchema = async ({
|
|
71
|
+
repositoryRoot,
|
|
72
|
+
artifactRoot,
|
|
73
|
+
arguments_,
|
|
74
|
+
}) => {
|
|
75
|
+
const root = assertArtifactRoot(artifactRoot);
|
|
76
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
77
|
+
const temporaryDirectory = await mkdtemp(join(root, ".schema-capture-"));
|
|
78
|
+
const destination = join(temporaryDirectory, "production-schema.sql");
|
|
79
|
+
try {
|
|
80
|
+
const result = spawnSync(
|
|
81
|
+
"supabase",
|
|
82
|
+
[...arguments_, "--schema", "public", "--file", destination],
|
|
83
|
+
{
|
|
84
|
+
cwd: repositoryRoot,
|
|
85
|
+
env: createCleanProcessEnvironment(),
|
|
86
|
+
encoding: "utf8",
|
|
87
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
88
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
89
|
+
},
|
|
90
|
+
);
|
|
91
|
+
if (result.error || result.status !== 0) {
|
|
92
|
+
throw new Error(
|
|
93
|
+
"The exact Rehearsal schema snapshot could not be captured.",
|
|
94
|
+
{ cause: result.error },
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
await chmod(destination, 0o600);
|
|
98
|
+
return assertProductionSchemaDump(await readFile(destination, "utf8"));
|
|
99
|
+
} finally {
|
|
100
|
+
await rm(temporaryDirectory, { recursive: true, force: true });
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export const captureLocalPublicSchema = ({
|
|
105
|
+
repositoryRoot,
|
|
106
|
+
artifactRoot,
|
|
107
|
+
workdir,
|
|
108
|
+
}) =>
|
|
109
|
+
captureSupabasePublicSchema({
|
|
110
|
+
repositoryRoot,
|
|
111
|
+
artifactRoot,
|
|
112
|
+
arguments_: ["db", "dump", "--local", "--workdir", workdir],
|
|
113
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Purpose: Load an exact, project-owned allowlist of local service credentials
|
|
3
|
+
* without exposing values or inheriting ambient hosted credentials.
|
|
4
|
+
* Do not run directly; this module is reusable Rehearsal infrastructure.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { readFile, stat } from "node:fs/promises";
|
|
8
|
+
import { parseEnv } from "node:util";
|
|
9
|
+
|
|
10
|
+
const ENVIRONMENT_KEY_PATTERN = /^[A-Z][A-Z0-9_]*$/u;
|
|
11
|
+
|
|
12
|
+
export const readRehearsalServiceEnvironment = async ({
|
|
13
|
+
path,
|
|
14
|
+
keys = [],
|
|
15
|
+
blockedKeys = [],
|
|
16
|
+
}) => {
|
|
17
|
+
if (!path) {
|
|
18
|
+
if (keys.length) {
|
|
19
|
+
throw new Error(
|
|
20
|
+
"Rehearsal service environment keys require an owned environment file.",
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
return {};
|
|
24
|
+
}
|
|
25
|
+
if (!keys.length) {
|
|
26
|
+
throw new Error(
|
|
27
|
+
"A Rehearsal service environment file requires an exact variable allowlist.",
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
for (const key of keys) {
|
|
31
|
+
if (!ENVIRONMENT_KEY_PATTERN.test(key)) {
|
|
32
|
+
throw new Error(`Invalid Rehearsal service environment key: ${key}.`);
|
|
33
|
+
}
|
|
34
|
+
if (blockedKeys.includes(key)) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
`Rehearsal service environment key ${key} is blocked by the safety policy.`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let source;
|
|
42
|
+
let details;
|
|
43
|
+
try {
|
|
44
|
+
[details, source] = await Promise.all([stat(path), readFile(path, "utf8")]);
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (error?.code === "ENOENT") {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`Rehearsal service environment file is missing: ${path}.`,
|
|
49
|
+
{ cause: error },
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
if (!details.isFile()) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
`Rehearsal service environment path is not a file: ${path}.`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
if ((details.mode & 0o077) !== 0) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
"Rehearsal service environment credentials must be owner-only (chmod 600).",
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const parsed = parseEnv(source);
|
|
66
|
+
const unexpectedKeys = Object.keys(parsed).filter(
|
|
67
|
+
(key) => !keys.includes(key),
|
|
68
|
+
);
|
|
69
|
+
if (unexpectedKeys.length) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
`Rehearsal service environment contains variables outside its allowlist: ${unexpectedKeys.join(", ")}.`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
const missingKeys = keys.filter((key) => !parsed[key]?.trim());
|
|
75
|
+
if (missingKeys.length) {
|
|
76
|
+
throw new Error(
|
|
77
|
+
`Rehearsal service environment is missing required variables: ${missingKeys.join(", ")}.`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return Object.fromEntries(keys.map((key) => [key, parsed[key]]));
|
|
82
|
+
};
|