@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,480 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Apply and retire an exact PostgreSQL source-access plan. This module is used
|
|
3
|
+
* only by explicit source commands; ordinary runtime commands must not import it.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
7
|
+
import {
|
|
8
|
+
chmod,
|
|
9
|
+
mkdir,
|
|
10
|
+
open,
|
|
11
|
+
readFile,
|
|
12
|
+
rename,
|
|
13
|
+
rm,
|
|
14
|
+
writeFile,
|
|
15
|
+
} from "node:fs/promises";
|
|
16
|
+
import { dirname, isAbsolute, relative, resolve } from "node:path";
|
|
17
|
+
import pg from "pg";
|
|
18
|
+
import {
|
|
19
|
+
assertSourceAccessConfirmation,
|
|
20
|
+
createSourceRetirementPlan,
|
|
21
|
+
sourceTargetFingerprint,
|
|
22
|
+
} from "./access.mjs";
|
|
23
|
+
|
|
24
|
+
const { Client } = pg;
|
|
25
|
+
const IDENTIFIER = /^[a-z][a-z0-9_]{0,62}$/u;
|
|
26
|
+
|
|
27
|
+
const quoteIdentifier = (value) => {
|
|
28
|
+
if (!IDENTIFIER.test(value ?? ""))
|
|
29
|
+
throw new Error("Unsafe source-access identifier.");
|
|
30
|
+
return `"${value}"`;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const quoteLiteral = (value) => `'${String(value).replaceAll("'", "''")}'`;
|
|
34
|
+
|
|
35
|
+
const ensureOwnedPath = (projectRoot, relativePath) => {
|
|
36
|
+
const root = resolve(projectRoot);
|
|
37
|
+
const path = resolve(root, relativePath);
|
|
38
|
+
const child = relative(root, path);
|
|
39
|
+
if (!child || child.startsWith("..") || isAbsolute(child))
|
|
40
|
+
throw new Error("Source-access file escaped the project root.");
|
|
41
|
+
return path;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const readerConnectionString = (
|
|
45
|
+
administratorConnectionString,
|
|
46
|
+
role,
|
|
47
|
+
password,
|
|
48
|
+
) => {
|
|
49
|
+
const target = new URL(administratorConnectionString);
|
|
50
|
+
target.username = role;
|
|
51
|
+
target.password = password;
|
|
52
|
+
return target.toString();
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const writeExclusiveSecret = async (path, content) => {
|
|
56
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
57
|
+
const handle = await open(path, "wx", 0o600);
|
|
58
|
+
try {
|
|
59
|
+
await handle.writeFile(content);
|
|
60
|
+
await handle.sync();
|
|
61
|
+
} finally {
|
|
62
|
+
await handle.close();
|
|
63
|
+
}
|
|
64
|
+
await chmod(path, 0o600);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const connection = async (connectionString, clientFactory) => {
|
|
68
|
+
const client = clientFactory
|
|
69
|
+
? await clientFactory(connectionString)
|
|
70
|
+
: new Client({
|
|
71
|
+
connectionString,
|
|
72
|
+
application_name: "rehearsal-source-access",
|
|
73
|
+
});
|
|
74
|
+
if (typeof client.connect === "function") await client.connect();
|
|
75
|
+
return client;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const sourceRelation = (relation) =>
|
|
79
|
+
`${quoteIdentifier(relation.source.schema)}.${quoteIdentifier(relation.source.table)}`;
|
|
80
|
+
|
|
81
|
+
const viewRelation = (plan, relation) =>
|
|
82
|
+
`${quoteIdentifier(plan.policy.exportSchema)}.${quoteIdentifier(relation.view)}`;
|
|
83
|
+
|
|
84
|
+
const assertTarget = (plan, administratorConnectionString) => {
|
|
85
|
+
if (
|
|
86
|
+
sourceTargetFingerprint(administratorConnectionString) !==
|
|
87
|
+
plan.policy.targetFingerprint
|
|
88
|
+
) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
"Source administrator credential changed to a different target.",
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const assertUnusedObjects = async (client, plan) => {
|
|
96
|
+
const result = await client.query(
|
|
97
|
+
`select
|
|
98
|
+
exists(select 1 from pg_namespace where nspname = $1) as schema_exists,
|
|
99
|
+
exists(select 1 from pg_roles where rolname = $2) as reader_exists,
|
|
100
|
+
exists(select 1 from pg_roles where rolname = $3) as owner_exists`,
|
|
101
|
+
[
|
|
102
|
+
plan.policy.exportSchema,
|
|
103
|
+
plan.policy.reader.role,
|
|
104
|
+
plan.policy.reader.ownerRole,
|
|
105
|
+
],
|
|
106
|
+
);
|
|
107
|
+
const state = result.rows[0];
|
|
108
|
+
if (state.schema_exists || state.reader_exists || state.owner_exists) {
|
|
109
|
+
throw new Error(
|
|
110
|
+
"Source-access targets already exist. Rehearsal will not overwrite or adopt them.",
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const assertSourceColumns = async (client, relation) => {
|
|
116
|
+
const result = await client.query(
|
|
117
|
+
`select column_name
|
|
118
|
+
from information_schema.columns
|
|
119
|
+
where table_schema = $1 and table_name = $2
|
|
120
|
+
order by ordinal_position`,
|
|
121
|
+
[relation.source.schema, relation.source.table],
|
|
122
|
+
);
|
|
123
|
+
if (result.rows.length === 0) {
|
|
124
|
+
throw new Error(
|
|
125
|
+
`Declared source relation ${relation.source.schema}.${relation.source.table} does not exist.`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
const actual = new Set(result.rows.map((row) => row.column_name));
|
|
129
|
+
const missing = relation.columns.filter((column) => !actual.has(column));
|
|
130
|
+
if (missing.length) {
|
|
131
|
+
throw new Error(
|
|
132
|
+
`Declared source relation is missing ${missing.length} reviewed column(s).`,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const runDenyChecks = async (client, plan) => {
|
|
138
|
+
const role = plan.policy.reader.role;
|
|
139
|
+
for (const relation of plan.policy.relations) {
|
|
140
|
+
const raw = `${relation.source.schema}.${relation.source.table}`;
|
|
141
|
+
const permissions = await client.query(
|
|
142
|
+
`select
|
|
143
|
+
has_table_privilege($1, $2, 'SELECT') as can_select,
|
|
144
|
+
has_table_privilege($1, $2, 'INSERT') as can_insert,
|
|
145
|
+
has_table_privilege($1, $2, 'UPDATE') as can_update,
|
|
146
|
+
has_table_privilege($1, $2, 'DELETE') as can_delete`,
|
|
147
|
+
[role, raw],
|
|
148
|
+
);
|
|
149
|
+
if (Object.values(permissions.rows[0]).some(Boolean)) {
|
|
150
|
+
throw new Error(
|
|
151
|
+
"Source reader unexpectedly has raw-table or write privileges.",
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const escalation = await client.query(
|
|
156
|
+
`select
|
|
157
|
+
pg_has_role($1, $2, 'MEMBER') as owner_member,
|
|
158
|
+
(select rolsuper or rolcreaterole or rolcreatedb or rolreplication or rolbypassrls
|
|
159
|
+
from pg_roles where rolname = $1) as privileged`,
|
|
160
|
+
[role, plan.policy.reader.ownerRole],
|
|
161
|
+
);
|
|
162
|
+
if (escalation.rows[0].owner_member || escalation.rows[0].privileged) {
|
|
163
|
+
throw new Error(
|
|
164
|
+
"Source reader unexpectedly has role-escalation privileges.",
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
const network = await client.query(
|
|
168
|
+
`select count(*)::integer as exposed
|
|
169
|
+
from pg_proc p
|
|
170
|
+
join pg_namespace n on n.oid = p.pronamespace
|
|
171
|
+
where has_function_privilege($1, p.oid, 'EXECUTE')
|
|
172
|
+
and (n.nspname in ('net', 'http') or p.proname ~* '^(dblink|http|net_)')
|
|
173
|
+
and p.prosecdef`,
|
|
174
|
+
[role],
|
|
175
|
+
);
|
|
176
|
+
if (network.rows[0].exposed > 0) {
|
|
177
|
+
throw new Error(
|
|
178
|
+
"Source reader can execute a security-definer network function.",
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
export const applyPostgresqlSourceAccess = async ({
|
|
184
|
+
plan,
|
|
185
|
+
confirmation,
|
|
186
|
+
projectRoot = process.cwd(),
|
|
187
|
+
environment = process.env,
|
|
188
|
+
clientFactory,
|
|
189
|
+
now = new Date(),
|
|
190
|
+
}) => {
|
|
191
|
+
assertSourceAccessConfirmation(plan, confirmation);
|
|
192
|
+
const administratorConnectionString =
|
|
193
|
+
environment[plan.policy.administratorEnvironmentVariable];
|
|
194
|
+
if (!administratorConnectionString) {
|
|
195
|
+
throw new Error(`Missing ${plan.policy.administratorEnvironmentVariable}.`);
|
|
196
|
+
}
|
|
197
|
+
assertTarget(plan, administratorConnectionString);
|
|
198
|
+
for (const relation of plan.policy.relations) {
|
|
199
|
+
if (relation.rowScope.kind !== "approved-owner") continue;
|
|
200
|
+
const value = environment[relation.rowScope.valueEnvironmentVariable];
|
|
201
|
+
if (
|
|
202
|
+
!value ||
|
|
203
|
+
createHash("sha256").update(value).digest("hex") !==
|
|
204
|
+
plan.review.relations.find((entry) => entry.view === relation.view)
|
|
205
|
+
?.rowScope.valueSha256
|
|
206
|
+
) {
|
|
207
|
+
throw new Error(
|
|
208
|
+
"Approved owner scope changed after the reviewed source-access preview.",
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const password = randomBytes(32).toString("base64url");
|
|
213
|
+
const expiresAt = new Date(
|
|
214
|
+
now.valueOf() + plan.policy.reader.validForMinutes * 60_000,
|
|
215
|
+
).toISOString();
|
|
216
|
+
const credentialPath = ensureOwnedPath(
|
|
217
|
+
projectRoot,
|
|
218
|
+
plan.policy.reader.credentialFile,
|
|
219
|
+
);
|
|
220
|
+
const receiptPath = ensureOwnedPath(
|
|
221
|
+
projectRoot,
|
|
222
|
+
".rehearsal/source-access-receipt.json",
|
|
223
|
+
);
|
|
224
|
+
const temporaryCredentialPath = `${credentialPath}.building-${process.pid}`;
|
|
225
|
+
const readerUrl = readerConnectionString(
|
|
226
|
+
administratorConnectionString,
|
|
227
|
+
plan.policy.reader.role,
|
|
228
|
+
password,
|
|
229
|
+
);
|
|
230
|
+
await writeExclusiveSecret(
|
|
231
|
+
temporaryCredentialPath,
|
|
232
|
+
`REHEARSAL_SOURCE_DATABASE_URL=${readerUrl}\n`,
|
|
233
|
+
);
|
|
234
|
+
const client = await connection(administratorConnectionString, clientFactory);
|
|
235
|
+
let committed = false;
|
|
236
|
+
try {
|
|
237
|
+
await client.query("begin");
|
|
238
|
+
await client.query(
|
|
239
|
+
"select pg_advisory_xact_lock(hashtext('rehearsal-source-access'))",
|
|
240
|
+
);
|
|
241
|
+
await assertUnusedObjects(client, plan);
|
|
242
|
+
for (const relation of plan.policy.relations)
|
|
243
|
+
await assertSourceColumns(client, relation);
|
|
244
|
+
await client.query(
|
|
245
|
+
`create role ${quoteIdentifier(plan.policy.reader.ownerRole)}
|
|
246
|
+
nologin noinherit nosuperuser nocreatedb nocreaterole noreplication nobypassrls`,
|
|
247
|
+
);
|
|
248
|
+
await client.query(
|
|
249
|
+
`create role ${quoteIdentifier(plan.policy.reader.role)}
|
|
250
|
+
login password ${quoteLiteral(password)} valid until ${quoteLiteral(expiresAt)}
|
|
251
|
+
noinherit nosuperuser nocreatedb nocreaterole noreplication nobypassrls`,
|
|
252
|
+
);
|
|
253
|
+
await client.query(
|
|
254
|
+
`create schema ${quoteIdentifier(plan.policy.exportSchema)}
|
|
255
|
+
authorization ${quoteIdentifier(plan.policy.reader.ownerRole)}`,
|
|
256
|
+
);
|
|
257
|
+
await client.query(
|
|
258
|
+
`revoke all on schema ${quoteIdentifier(plan.policy.exportSchema)} from public`,
|
|
259
|
+
);
|
|
260
|
+
for (const schema of new Set(
|
|
261
|
+
plan.policy.relations.map((relation) => relation.source.schema),
|
|
262
|
+
)) {
|
|
263
|
+
await client.query(
|
|
264
|
+
`grant usage on schema ${quoteIdentifier(schema)}
|
|
265
|
+
to ${quoteIdentifier(plan.policy.reader.ownerRole)}`,
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
for (const relation of plan.policy.relations) {
|
|
269
|
+
const columns = relation.columns.map(quoteIdentifier).join(", ");
|
|
270
|
+
await client.query(
|
|
271
|
+
`grant select (${columns}) on ${sourceRelation(relation)}
|
|
272
|
+
to ${quoteIdentifier(plan.policy.reader.ownerRole)}`,
|
|
273
|
+
);
|
|
274
|
+
const filter =
|
|
275
|
+
relation.rowScope.kind === "approved-owner"
|
|
276
|
+
? ` where ${quoteIdentifier(relation.rowScope.column)} = ${quoteLiteral(environment[relation.rowScope.valueEnvironmentVariable])}`
|
|
277
|
+
: "";
|
|
278
|
+
await client.query(
|
|
279
|
+
`set local role ${quoteIdentifier(plan.policy.reader.ownerRole)}`,
|
|
280
|
+
);
|
|
281
|
+
await client.query(
|
|
282
|
+
`create view ${viewRelation(plan, relation)} with (security_barrier = true) as
|
|
283
|
+
select ${columns} from ${sourceRelation(relation)}${filter}`,
|
|
284
|
+
);
|
|
285
|
+
await client.query("reset role");
|
|
286
|
+
await client.query(
|
|
287
|
+
`revoke all on ${viewRelation(plan, relation)} from public`,
|
|
288
|
+
);
|
|
289
|
+
await client.query(
|
|
290
|
+
`grant select on ${viewRelation(plan, relation)} to ${quoteIdentifier(plan.policy.reader.role)}`,
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
await client.query(
|
|
294
|
+
`grant usage on schema ${quoteIdentifier(plan.policy.exportSchema)}
|
|
295
|
+
to ${quoteIdentifier(plan.policy.reader.role)}`,
|
|
296
|
+
);
|
|
297
|
+
const ledger = plan.policy.migrationLedger;
|
|
298
|
+
await client.query(
|
|
299
|
+
`grant usage on schema ${quoteIdentifier(ledger.schema)}
|
|
300
|
+
to ${quoteIdentifier(plan.policy.reader.role)}`,
|
|
301
|
+
);
|
|
302
|
+
await client.query(
|
|
303
|
+
`grant select (${[
|
|
304
|
+
ledger.versionColumn,
|
|
305
|
+
ledger.nameColumn,
|
|
306
|
+
ledger.statementsColumn,
|
|
307
|
+
]
|
|
308
|
+
.map(quoteIdentifier)
|
|
309
|
+
.join(
|
|
310
|
+
", ",
|
|
311
|
+
)}) on ${quoteIdentifier(ledger.schema)}.${quoteIdentifier(ledger.table)}
|
|
312
|
+
to ${quoteIdentifier(plan.policy.reader.role)}`,
|
|
313
|
+
);
|
|
314
|
+
await client.query(
|
|
315
|
+
`comment on schema ${quoteIdentifier(plan.policy.exportSchema)} is ${quoteLiteral(
|
|
316
|
+
`rehearsal-source-access:${plan.digest}`,
|
|
317
|
+
)}`,
|
|
318
|
+
);
|
|
319
|
+
await runDenyChecks(client, plan);
|
|
320
|
+
await client.query("commit");
|
|
321
|
+
committed = true;
|
|
322
|
+
} catch (error) {
|
|
323
|
+
await client.query("rollback").catch(() => undefined);
|
|
324
|
+
throw error;
|
|
325
|
+
} finally {
|
|
326
|
+
await client.end?.();
|
|
327
|
+
if (!committed) await rm(temporaryCredentialPath, { force: true });
|
|
328
|
+
}
|
|
329
|
+
await rename(temporaryCredentialPath, credentialPath);
|
|
330
|
+
const receipt = {
|
|
331
|
+
receiptVersion: 1,
|
|
332
|
+
targetFingerprint: plan.policy.targetFingerprint,
|
|
333
|
+
planDigest: plan.digest,
|
|
334
|
+
exportSchema: plan.policy.exportSchema,
|
|
335
|
+
views: plan.policy.relations.map((relation) => relation.view).sort(),
|
|
336
|
+
relations: plan.policy.relations.map((relation) => ({
|
|
337
|
+
...relation.source,
|
|
338
|
+
columns: relation.columns,
|
|
339
|
+
})),
|
|
340
|
+
migrationLedger: plan.policy.migrationLedger,
|
|
341
|
+
readerRole: plan.policy.reader.role,
|
|
342
|
+
ownerRole: plan.policy.reader.ownerRole,
|
|
343
|
+
expiresAt,
|
|
344
|
+
};
|
|
345
|
+
await writeFile(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`, {
|
|
346
|
+
flag: "wx",
|
|
347
|
+
mode: 0o600,
|
|
348
|
+
});
|
|
349
|
+
await chmod(receiptPath, 0o600);
|
|
350
|
+
return { receipt, credentialPath, receiptPath };
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
export const planPostgresqlSourceAccessRetirement = async ({
|
|
354
|
+
projectRoot = process.cwd(),
|
|
355
|
+
targetFingerprint,
|
|
356
|
+
}) => {
|
|
357
|
+
const receiptPath = ensureOwnedPath(
|
|
358
|
+
projectRoot,
|
|
359
|
+
".rehearsal/source-access-receipt.json",
|
|
360
|
+
);
|
|
361
|
+
const receipt = JSON.parse(await readFile(receiptPath, "utf8"));
|
|
362
|
+
return {
|
|
363
|
+
...createSourceRetirementPlan({ receipt, targetFingerprint }),
|
|
364
|
+
receipt,
|
|
365
|
+
receiptPath,
|
|
366
|
+
};
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
export const retirePostgresqlSourceAccess = async ({
|
|
370
|
+
plan,
|
|
371
|
+
confirmation,
|
|
372
|
+
administratorEnvironmentVariable,
|
|
373
|
+
projectRoot = process.cwd(),
|
|
374
|
+
environment = process.env,
|
|
375
|
+
credentialFile,
|
|
376
|
+
clientFactory,
|
|
377
|
+
}) => {
|
|
378
|
+
if (confirmation !== plan.digest) {
|
|
379
|
+
throw new Error(
|
|
380
|
+
`Source retirement confirmation does not match. Expected ${plan.digest}.`,
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
const administratorConnectionString =
|
|
384
|
+
environment[administratorEnvironmentVariable];
|
|
385
|
+
if (!administratorConnectionString)
|
|
386
|
+
throw new Error(`Missing ${administratorEnvironmentVariable}.`);
|
|
387
|
+
assertTarget(
|
|
388
|
+
{ policy: { targetFingerprint: plan.review.targetFingerprint } },
|
|
389
|
+
administratorConnectionString,
|
|
390
|
+
);
|
|
391
|
+
const client = await connection(administratorConnectionString, clientFactory);
|
|
392
|
+
try {
|
|
393
|
+
await client.query("begin");
|
|
394
|
+
await client.query(
|
|
395
|
+
"select pg_advisory_xact_lock(hashtext('rehearsal-source-access'))",
|
|
396
|
+
);
|
|
397
|
+
const inventory = await client.query(
|
|
398
|
+
`select c.relname,
|
|
399
|
+
obj_description(n.oid, 'pg_namespace') as schema_comment
|
|
400
|
+
from pg_class c join pg_namespace n on n.oid = c.relnamespace
|
|
401
|
+
where n.nspname = $1 and c.relkind in ('v', 'm')
|
|
402
|
+
order by c.relname`,
|
|
403
|
+
[plan.review.exportSchema],
|
|
404
|
+
);
|
|
405
|
+
if (
|
|
406
|
+
inventory.rows.map((row) => row.relname).join("\0") !==
|
|
407
|
+
plan.review.views.join("\0")
|
|
408
|
+
) {
|
|
409
|
+
throw new Error(
|
|
410
|
+
"Source export schema changed after preparation; retirement stopped.",
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
if (
|
|
414
|
+
inventory.rows.some(
|
|
415
|
+
(row) =>
|
|
416
|
+
row.schema_comment !==
|
|
417
|
+
`rehearsal-source-access:${plan.review.preparationDigest}`,
|
|
418
|
+
)
|
|
419
|
+
) {
|
|
420
|
+
throw new Error("Source export schema ownership receipt does not match.");
|
|
421
|
+
}
|
|
422
|
+
for (const view of plan.review.views) {
|
|
423
|
+
await client.query(
|
|
424
|
+
`drop view ${quoteIdentifier(plan.review.exportSchema)}.${quoteIdentifier(view)}`,
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
await client.query(
|
|
428
|
+
`drop schema ${quoteIdentifier(plan.review.exportSchema)} restrict`,
|
|
429
|
+
);
|
|
430
|
+
for (const relation of plan.receipt.relations) {
|
|
431
|
+
await client.query(
|
|
432
|
+
`revoke select (${relation.columns.map(quoteIdentifier).join(", ")})
|
|
433
|
+
on table ${quoteIdentifier(relation.schema)}.${quoteIdentifier(relation.table)}
|
|
434
|
+
from ${quoteIdentifier(plan.review.ownerRole)}`,
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
for (const schema of new Set(
|
|
438
|
+
plan.receipt.relations.map((relation) => relation.schema),
|
|
439
|
+
)) {
|
|
440
|
+
await client.query(
|
|
441
|
+
`revoke usage on schema ${quoteIdentifier(schema)}
|
|
442
|
+
from ${quoteIdentifier(plan.review.ownerRole)}`,
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
const ledger = plan.receipt.migrationLedger;
|
|
446
|
+
await client.query(
|
|
447
|
+
`revoke all privileges on table ${quoteIdentifier(ledger.schema)}.${quoteIdentifier(ledger.table)}
|
|
448
|
+
from ${quoteIdentifier(plan.review.readerRole)}`,
|
|
449
|
+
);
|
|
450
|
+
await client.query(
|
|
451
|
+
`revoke usage on schema ${quoteIdentifier(ledger.schema)}
|
|
452
|
+
from ${quoteIdentifier(plan.review.readerRole)}`,
|
|
453
|
+
);
|
|
454
|
+
await client.query(`drop role ${quoteIdentifier(plan.review.readerRole)}`);
|
|
455
|
+
await client.query(`drop role ${quoteIdentifier(plan.review.ownerRole)}`);
|
|
456
|
+
await client.query("commit");
|
|
457
|
+
const remaining = await client.query(
|
|
458
|
+
`select
|
|
459
|
+
exists(select 1 from pg_namespace where nspname = $1) as schema_exists,
|
|
460
|
+
exists(select 1 from pg_roles where rolname = $2) as reader_exists,
|
|
461
|
+
exists(select 1 from pg_roles where rolname = $3) as owner_exists`,
|
|
462
|
+
[plan.review.exportSchema, plan.review.readerRole, plan.review.ownerRole],
|
|
463
|
+
);
|
|
464
|
+
if (Object.values(remaining.rows[0]).some(Boolean)) {
|
|
465
|
+
throw new Error(
|
|
466
|
+
"Source access retirement could not be independently verified.",
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
} catch (error) {
|
|
470
|
+
await client.query("rollback").catch(() => undefined);
|
|
471
|
+
throw error;
|
|
472
|
+
} finally {
|
|
473
|
+
await client.end?.();
|
|
474
|
+
}
|
|
475
|
+
await Promise.all([
|
|
476
|
+
rm(ensureOwnedPath(projectRoot, credentialFile), { force: true }),
|
|
477
|
+
rm(plan.receiptPath, { force: true }),
|
|
478
|
+
]);
|
|
479
|
+
return { retired: true };
|
|
480
|
+
};
|
|
@@ -14,24 +14,34 @@ import { pathToFileURL } from "node:url";
|
|
|
14
14
|
import {
|
|
15
15
|
resolveActiveBaselinePaths,
|
|
16
16
|
verifyActiveBaseline,
|
|
17
|
-
} from "../
|
|
18
|
-
import { loadRehearsalConfig } from "../
|
|
19
|
-
import { formatCount } from "../
|
|
20
|
-
import { readMigrationFileInventory } from "../
|
|
21
|
-
import { createCleanProcessEnvironment } from "../
|
|
17
|
+
} from "../baseline/artifact.mjs";
|
|
18
|
+
import { loadRehearsalConfig } from "../project/configuration.mjs";
|
|
19
|
+
import { formatCount } from "../shared/human_output.mjs";
|
|
20
|
+
import { readMigrationFileInventory } from "../runtime/migration_history.mjs";
|
|
21
|
+
import { createCleanProcessEnvironment } from "../shared/process_environment.mjs";
|
|
22
22
|
import {
|
|
23
23
|
buildRestoreSqlPrefix,
|
|
24
24
|
buildRestoreSqlSuffix,
|
|
25
25
|
createCandidateMigrationReceipt,
|
|
26
26
|
encodeBaselineRecordForCopy,
|
|
27
27
|
summarizeRestoreError,
|
|
28
|
-
} from "../
|
|
29
|
-
import { readBoundRuntimeSanitizationPolicy } from "../
|
|
30
|
-
import { ensureLocalContainerRuntime } from "
|
|
28
|
+
} from "../runtime/restore.mjs";
|
|
29
|
+
import { readBoundRuntimeSanitizationPolicy } from "../baseline/sanitization_policy.mjs";
|
|
30
|
+
import { ensureLocalContainerRuntime } from "./supabase_environment.mjs";
|
|
31
|
+
import { parseRuntimeInvocation } from "./target.mjs";
|
|
32
|
+
import {
|
|
33
|
+
buildRuntimeFinalizationSql,
|
|
34
|
+
buildRuntimePostSchemaSql,
|
|
35
|
+
buildRuntimePrerequisiteSql,
|
|
36
|
+
buildRuntimeVerificationSql,
|
|
37
|
+
validateRuntimePolicy,
|
|
38
|
+
} from "../runtime/policy.mjs";
|
|
31
39
|
|
|
32
40
|
const repositoryRoot = process.cwd();
|
|
41
|
+
const invocation = parseRuntimeInvocation();
|
|
33
42
|
const loadedConfiguration = await loadRehearsalConfig({
|
|
34
43
|
projectRoot: repositoryRoot,
|
|
44
|
+
configPath: invocation.configPath,
|
|
35
45
|
});
|
|
36
46
|
const { config, paths: configuredPaths } = loadedConfiguration;
|
|
37
47
|
const artifactRoot = configuredPaths.artifactDirectory;
|
|
@@ -51,7 +61,7 @@ const image = config.postgresql.image;
|
|
|
51
61
|
const database = config.postgresql.database;
|
|
52
62
|
const databaseUser = config.postgresql.user;
|
|
53
63
|
const databasePort = config.runtime.ports.database;
|
|
54
|
-
const action =
|
|
64
|
+
const action = invocation.action;
|
|
55
65
|
const applicationMigrations = new URL(
|
|
56
66
|
"./",
|
|
57
67
|
pathToFileURL(`${configuredPaths.migrationDirectory}/`),
|
|
@@ -59,6 +69,11 @@ const applicationMigrations = new URL(
|
|
|
59
69
|
const runtimeAdapter = configuredPaths.runtimeAdapter
|
|
60
70
|
? await import(pathToFileURL(configuredPaths.runtimeAdapter).href)
|
|
61
71
|
: null;
|
|
72
|
+
const runtimePolicy = configuredPaths.runtimePolicy
|
|
73
|
+
? validateRuntimePolicy(
|
|
74
|
+
JSON.parse(await readFile(configuredPaths.runtimePolicy, "utf8")),
|
|
75
|
+
)
|
|
76
|
+
: null;
|
|
62
77
|
const environmentKeyPattern = /^[A-Z][A-Z0-9_]*$/u;
|
|
63
78
|
|
|
64
79
|
const runCommand = (
|
|
@@ -366,6 +381,13 @@ const restoreBaselineRows = async ({ baseline, paths, manifest }) => {
|
|
|
366
381
|
}
|
|
367
382
|
};
|
|
368
383
|
|
|
384
|
+
const prepareProjectPrerequisites = () => {
|
|
385
|
+
if (runtimePolicy) {
|
|
386
|
+
runPsql(buildRuntimePrerequisiteSql(runtimePolicy));
|
|
387
|
+
console.log("Applied reviewed runtime prerequisites.");
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
|
|
369
391
|
const prepareProjectSchema = ({ baseline, environment }) => {
|
|
370
392
|
if (!runtimeAdapter?.prepareSchema) return;
|
|
371
393
|
const preparation = runtimeAdapter.prepareSchema({
|
|
@@ -381,6 +403,18 @@ const prepareProjectSchema = ({ baseline, environment }) => {
|
|
|
381
403
|
console.log(preparation.message);
|
|
382
404
|
};
|
|
383
405
|
|
|
406
|
+
const finalizeProjectSchema = () => {
|
|
407
|
+
if (!runtimePolicy || runtimePolicy.triggers.length === 0) return;
|
|
408
|
+
runPsql(buildRuntimePostSchemaSql(runtimePolicy));
|
|
409
|
+
console.log("Applied reviewed post-schema triggers.");
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
const finalizeProjectRuntime = () => {
|
|
413
|
+
if (!runtimePolicy || runtimePolicy.localRows.length === 0) return;
|
|
414
|
+
runPsql(buildRuntimeFinalizationSql(runtimePolicy));
|
|
415
|
+
console.log("Applied reviewed local-only recipes.");
|
|
416
|
+
};
|
|
417
|
+
|
|
384
418
|
const configureProjectRuntime = ({ baseline, environment }) => {
|
|
385
419
|
if (!runtimeAdapter?.configureRuntime) {
|
|
386
420
|
return {
|
|
@@ -511,6 +545,8 @@ const resetRuntime = async () => {
|
|
|
511
545
|
try {
|
|
512
546
|
const password = randomBytes(24).toString("hex");
|
|
513
547
|
await createContainer(password);
|
|
548
|
+
const environment = databaseEnvironment(password);
|
|
549
|
+
prepareProjectPrerequisites();
|
|
514
550
|
initializeMigrationLedger();
|
|
515
551
|
const baselineMigrations = await readMigrationFileInventory(
|
|
516
552
|
new URL("./", pathToFileURL(`${active.paths.migrationsDirectory}/`)),
|
|
@@ -521,9 +557,10 @@ const resetRuntime = async () => {
|
|
|
521
557
|
directory: active.paths.migrationsDirectory,
|
|
522
558
|
});
|
|
523
559
|
}
|
|
524
|
-
const environment = databaseEnvironment(password);
|
|
525
560
|
prepareProjectSchema({ baseline: active.baseline, environment });
|
|
561
|
+
finalizeProjectSchema();
|
|
526
562
|
await restoreBaselineRows({ ...active });
|
|
563
|
+
finalizeProjectRuntime();
|
|
527
564
|
const projectRuntime = configureProjectRuntime({
|
|
528
565
|
baseline: active.baseline,
|
|
529
566
|
environment,
|
|
@@ -624,6 +661,7 @@ const verifyRuntime = async () => {
|
|
|
624
661
|
baseline,
|
|
625
662
|
});
|
|
626
663
|
let projectVerification;
|
|
664
|
+
if (runtimePolicy) runPsql(buildRuntimeVerificationSql(runtimePolicy));
|
|
627
665
|
if (runtimeAdapter?.verifyRuntime) {
|
|
628
666
|
projectVerification = runtimeAdapter.verifyRuntime({
|
|
629
667
|
baseline,
|
|
@@ -650,9 +688,7 @@ const migrateRuntime = async () => {
|
|
|
650
688
|
const { baseline } = await readActive();
|
|
651
689
|
await startRuntime();
|
|
652
690
|
const receipt = await readCandidateReceipt(baseline);
|
|
653
|
-
const confirmation =
|
|
654
|
-
.find((argument) => argument.startsWith("--confirm-candidates="))
|
|
655
|
-
?.slice("--confirm-candidates=".length);
|
|
691
|
+
const confirmation = invocation.confirmation;
|
|
656
692
|
if (confirmation !== receipt.candidateSha256) {
|
|
657
693
|
throw new Error(
|
|
658
694
|
"Candidate migration confirmation is missing or does not match the exact SHA-256.",
|