@rehearsal-db/core 0.1.0-beta.12 → 0.1.0-beta.14

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.
Files changed (38) hide show
  1. package/CHANGELOG.md +116 -0
  2. package/COMPATIBILITY.md +6 -0
  3. package/README.md +5 -3
  4. package/docs/commands.md +15 -11
  5. package/docs/configuration.md +40 -5
  6. package/docs/getting-started.md +32 -3
  7. package/docs/production-source.md +175 -17
  8. package/docs/releasing.md +5 -3
  9. package/docs/roadmap.md +3 -2
  10. package/docs/sanitization.md +479 -12
  11. package/docs/security-model.md +8 -0
  12. package/docs/standalone-workflow.md +34 -13
  13. package/docs/troubleshooting.md +17 -6
  14. package/docs/tutorial.md +7 -1
  15. package/package.json +2 -2
  16. package/scripts/install/create_root_config.mjs +5 -22
  17. package/src/baseline/artifact.mjs +27 -4
  18. package/src/baseline/privacy_engine.mjs +865 -207
  19. package/src/baseline/privacy_structured_json.mjs +554 -0
  20. package/src/cli/arguments.mjs +3 -0
  21. package/src/cli/guided.mjs +8 -6
  22. package/src/cli/rehearsal.mjs +23 -3
  23. package/src/cli/renderers.mjs +29 -4
  24. package/src/cli/runtime_commands.mjs +92 -9
  25. package/src/cli/source_commands.mjs +37 -8
  26. package/src/identity/claim.mjs +23 -21
  27. package/src/project/install_scaffold.mjs +35 -1
  28. package/src/project/setup.mjs +14 -24
  29. package/src/runtime/plan.mjs +7 -3
  30. package/src/runtime/restore.mjs +13 -5
  31. package/src/shared/diagnostics.mjs +7 -0
  32. package/src/shared/node_runtime.mjs +31 -0
  33. package/src/source/access.mjs +271 -51
  34. package/src/source/asset_transfer.mjs +156 -74
  35. package/src/source/baseline.mjs +42 -6
  36. package/src/source/external_reader.mjs +368 -0
  37. package/src/source/postgresql_access.mjs +213 -55
  38. package/src/source/privacy_paths.mjs +38 -0
@@ -6,20 +6,23 @@
6
6
  import { createHash, randomBytes } from "node:crypto";
7
7
  import {
8
8
  chmod,
9
+ link,
10
+ lstat,
9
11
  mkdir,
10
12
  open,
11
13
  readFile,
12
- rename,
13
14
  rm,
14
15
  writeFile,
15
16
  } from "node:fs/promises";
16
- import { dirname, isAbsolute, relative, resolve } from "node:path";
17
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
17
18
  import pg from "pg";
18
19
  import {
19
20
  assertSourceAccessConfirmation,
20
21
  createSourceRetirementPlan,
22
+ sourceAccessPolicyFingerprint,
21
23
  sourceTargetFingerprint,
22
24
  } from "./access.mjs";
25
+ import { verifyExternalPostgresqlReader } from "./external_reader.mjs";
23
26
 
24
27
  const { Client } = pg;
25
28
  const IDENTIFIER = /^[a-z][a-z0-9_]{0,62}$/u;
@@ -41,6 +44,36 @@ const ensureOwnedPath = (projectRoot, relativePath) => {
41
44
  return path;
42
45
  };
43
46
 
47
+ const isInside = (root, path) => {
48
+ const child = relative(root, path);
49
+ return Boolean(child) && !child.startsWith("..") && !isAbsolute(child);
50
+ };
51
+
52
+ const resolveSourceStateRoot = ({
53
+ projectRoot,
54
+ artifactRoot = join(projectRoot, ".rehearsal"),
55
+ }) => ensureOwnedPath(projectRoot, artifactRoot);
56
+
57
+ export const resolvePostgresqlSourceStatePaths = ({
58
+ projectRoot = process.cwd(),
59
+ artifactRoot = join(projectRoot, ".rehearsal"),
60
+ credentialFile,
61
+ }) => {
62
+ const stateRoot = resolveSourceStateRoot({ projectRoot, artifactRoot });
63
+ const credentialPath = ensureOwnedPath(projectRoot, credentialFile);
64
+ const secretsRoot = join(stateRoot, "secrets");
65
+ if (!isInside(secretsRoot, credentialPath)) {
66
+ throw new Error(
67
+ "Source reader credential must stay inside this target's configured .rehearsal/secrets directory.",
68
+ );
69
+ }
70
+ return Object.freeze({
71
+ stateRoot,
72
+ credentialPath,
73
+ receiptPath: join(stateRoot, "source-access-receipt.json"),
74
+ });
75
+ };
76
+
44
77
  const readerConnectionString = (
45
78
  administratorConnectionString,
46
79
  role,
@@ -64,6 +97,22 @@ const writeExclusiveSecret = async (path, content) => {
64
97
  await chmod(path, 0o600);
65
98
  };
66
99
 
100
+ const assertSourceStateAvailable = async (...paths) => {
101
+ for (const path of paths) {
102
+ try {
103
+ await lstat(path);
104
+ } catch (error) {
105
+ if (error?.code === "ENOENT") continue;
106
+ throw error;
107
+ }
108
+ const error = new Error(
109
+ "Source-access state already exists and will not be overwritten.",
110
+ );
111
+ error.code = "EEXIST";
112
+ throw error;
113
+ }
114
+ };
115
+
67
116
  const connection = async (connectionString, clientFactory) => {
68
117
  const client = clientFactory
69
118
  ? await clientFactory(connectionString)
@@ -180,60 +229,163 @@ const runDenyChecks = async (client, plan) => {
180
229
  }
181
230
  };
182
231
 
232
+ const assertApprovedOwnerScopes = (plan, environment) => {
233
+ for (const relation of plan.policy.relations) {
234
+ if (relation.rowScope.kind !== "approved-owner") continue;
235
+ const value = environment[relation.rowScope.valueEnvironmentVariable];
236
+ if (
237
+ !value ||
238
+ createHash("sha256").update(value).digest("hex") !==
239
+ plan.review.relations.find((entry) => entry.view === relation.view)
240
+ ?.rowScope.valueSha256
241
+ ) {
242
+ throw new Error(
243
+ "Approved owner scope changed after the reviewed source-access preview.",
244
+ );
245
+ }
246
+ }
247
+ };
248
+
249
+ const buildSourceAccessReceipt = ({ plan, expiresAt }) => ({
250
+ receiptVersion: 1,
251
+ accessMode: plan.policy.reader.mode,
252
+ policyFingerprint: sourceAccessPolicyFingerprint(plan.policy),
253
+ targetFingerprint: plan.policy.targetFingerprint,
254
+ planDigest: plan.digest,
255
+ exportSchema: plan.policy.exportSchema,
256
+ views: plan.policy.relations.map((relation) => relation.view).sort(),
257
+ relations: plan.policy.relations.map((relation) => ({
258
+ ...relation.source,
259
+ columns: relation.columns,
260
+ })),
261
+ migrationLedger: plan.policy.migrationLedger,
262
+ readerRole: plan.policy.reader.role,
263
+ ...(plan.policy.reader.mode === "managed"
264
+ ? { ownerRole: plan.policy.reader.ownerRole }
265
+ : {}),
266
+ expiresAt,
267
+ });
268
+
269
+ const activateExternalPostgresqlSourceAccess = async ({
270
+ plan,
271
+ projectRoot,
272
+ artifactRoot,
273
+ environment,
274
+ clientFactory,
275
+ now,
276
+ }) => {
277
+ const connectionString =
278
+ environment[plan.policy.reader.connectionEnvironmentVariable];
279
+ if (!connectionString) {
280
+ throw new Error(
281
+ `Missing ${plan.policy.reader.connectionEnvironmentVariable}.`,
282
+ );
283
+ }
284
+ assertTarget(plan, connectionString);
285
+ assertApprovedOwnerScopes(plan, environment);
286
+ const { credentialPath, receiptPath } = resolvePostgresqlSourceStatePaths({
287
+ projectRoot,
288
+ artifactRoot,
289
+ credentialFile: plan.policy.reader.credentialFile,
290
+ });
291
+ await assertSourceStateAvailable(credentialPath, receiptPath);
292
+ const verified = await verifyExternalPostgresqlReader({
293
+ plan,
294
+ connectionString,
295
+ clientFactory,
296
+ now,
297
+ });
298
+ const temporaryCredentialPath = `${credentialPath}.building-${process.pid}`;
299
+ const receipt = buildSourceAccessReceipt({
300
+ plan,
301
+ expiresAt: verified.expiresAt,
302
+ });
303
+ let receiptWritten = false;
304
+ try {
305
+ await writeExclusiveSecret(
306
+ temporaryCredentialPath,
307
+ `REHEARSAL_SOURCE_DATABASE_URL=${connectionString}\n`,
308
+ );
309
+ await writeFile(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`, {
310
+ flag: "wx",
311
+ mode: 0o600,
312
+ });
313
+ receiptWritten = true;
314
+ await chmod(receiptPath, 0o600);
315
+ await link(temporaryCredentialPath, credentialPath);
316
+ await rm(temporaryCredentialPath, { force: true });
317
+ } catch (error) {
318
+ await rm(temporaryCredentialPath, { force: true });
319
+ if (receiptWritten) await rm(receiptPath, { force: true });
320
+ throw error;
321
+ }
322
+ return { receipt, credentialPath, receiptPath };
323
+ };
324
+
183
325
  export const applyPostgresqlSourceAccess = async ({
184
326
  plan,
185
327
  confirmation,
186
328
  projectRoot = process.cwd(),
329
+ artifactRoot = join(projectRoot, ".rehearsal"),
187
330
  environment = process.env,
188
331
  clientFactory,
189
332
  now = new Date(),
190
333
  }) => {
191
334
  assertSourceAccessConfirmation(plan, confirmation);
335
+ if (plan.policy.reader.mode === "external") {
336
+ return activateExternalPostgresqlSourceAccess({
337
+ plan,
338
+ projectRoot,
339
+ artifactRoot,
340
+ environment,
341
+ clientFactory,
342
+ now,
343
+ });
344
+ }
192
345
  const administratorConnectionString =
193
346
  environment[plan.policy.administratorEnvironmentVariable];
194
347
  if (!administratorConnectionString) {
195
348
  throw new Error(`Missing ${plan.policy.administratorEnvironmentVariable}.`);
196
349
  }
197
350
  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
- }
351
+ assertApprovedOwnerScopes(plan, environment);
212
352
  const password = randomBytes(32).toString("base64url");
213
353
  const expiresAt = new Date(
214
354
  now.valueOf() + plan.policy.reader.validForMinutes * 60_000,
215
355
  ).toISOString();
216
- const credentialPath = ensureOwnedPath(
217
- projectRoot,
218
- plan.policy.reader.credentialFile,
219
- );
220
- const receiptPath = ensureOwnedPath(
356
+ const { credentialPath, receiptPath } = resolvePostgresqlSourceStatePaths({
221
357
  projectRoot,
222
- ".rehearsal/source-access-receipt.json",
223
- );
358
+ artifactRoot,
359
+ credentialFile: plan.policy.reader.credentialFile,
360
+ });
224
361
  const temporaryCredentialPath = `${credentialPath}.building-${process.pid}`;
225
362
  const readerUrl = readerConnectionString(
226
363
  administratorConnectionString,
227
364
  plan.policy.reader.role,
228
365
  password,
229
366
  );
230
- await writeExclusiveSecret(
231
- temporaryCredentialPath,
232
- `REHEARSAL_SOURCE_DATABASE_URL=${readerUrl}\n`,
233
- );
234
- const client = await connection(administratorConnectionString, clientFactory);
235
- let committed = false;
367
+ let receiptReserved = false;
368
+ let credentialLinked = false;
369
+ try {
370
+ await writeExclusiveSecret(
371
+ temporaryCredentialPath,
372
+ `REHEARSAL_SOURCE_DATABASE_URL=${readerUrl}\n`,
373
+ );
374
+ const receiptReservation = await open(receiptPath, "wx", 0o600);
375
+ receiptReserved = true;
376
+ await receiptReservation.close();
377
+ await link(temporaryCredentialPath, credentialPath);
378
+ credentialLinked = true;
379
+ await rm(temporaryCredentialPath, { force: true });
380
+ } catch (error) {
381
+ await rm(temporaryCredentialPath, { force: true });
382
+ if (credentialLinked) await rm(credentialPath, { force: true });
383
+ if (receiptReserved) await rm(receiptPath, { force: true });
384
+ throw error;
385
+ }
386
+ let client;
236
387
  try {
388
+ client = await connection(administratorConnectionString, clientFactory);
237
389
  await client.query("begin");
238
390
  await client.query(
239
391
  "select pg_advisory_xact_lock(hashtext('rehearsal-source-access'))",
@@ -318,32 +470,19 @@ export const applyPostgresqlSourceAccess = async ({
318
470
  );
319
471
  await runDenyChecks(client, plan);
320
472
  await client.query("commit");
321
- committed = true;
322
473
  } catch (error) {
323
- await client.query("rollback").catch(() => undefined);
474
+ await client?.query("rollback").catch(() => undefined);
475
+ await Promise.all([
476
+ rm(credentialPath, { force: true }),
477
+ rm(receiptPath, { force: true }),
478
+ ]);
324
479
  throw error;
325
480
  } finally {
326
- await client.end?.();
327
- if (!committed) await rm(temporaryCredentialPath, { force: true });
481
+ await client?.end?.();
328
482
  }
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
- };
483
+ const receipt = buildSourceAccessReceipt({ plan, expiresAt });
345
484
  await writeFile(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`, {
346
- flag: "wx",
485
+ flag: "w",
347
486
  mode: 0o600,
348
487
  });
349
488
  await chmod(receiptPath, 0o600);
@@ -352,11 +491,12 @@ export const applyPostgresqlSourceAccess = async ({
352
491
 
353
492
  export const planPostgresqlSourceAccessRetirement = async ({
354
493
  projectRoot = process.cwd(),
494
+ artifactRoot = join(projectRoot, ".rehearsal"),
355
495
  targetFingerprint,
356
496
  }) => {
357
- const receiptPath = ensureOwnedPath(
358
- projectRoot,
359
- ".rehearsal/source-access-receipt.json",
497
+ const receiptPath = join(
498
+ resolveSourceStateRoot({ projectRoot, artifactRoot }),
499
+ "source-access-receipt.json",
360
500
  );
361
501
  const receipt = JSON.parse(await readFile(receiptPath, "utf8"));
362
502
  return {
@@ -371,6 +511,7 @@ export const retirePostgresqlSourceAccess = async ({
371
511
  confirmation,
372
512
  administratorEnvironmentVariable,
373
513
  projectRoot = process.cwd(),
514
+ artifactRoot = join(projectRoot, ".rehearsal"),
374
515
  environment = process.env,
375
516
  credentialFile,
376
517
  clientFactory,
@@ -380,6 +521,23 @@ export const retirePostgresqlSourceAccess = async ({
380
521
  `Source retirement confirmation does not match. Expected ${plan.digest}.`,
381
522
  );
382
523
  }
524
+ const { credentialPath, receiptPath } = resolvePostgresqlSourceStatePaths({
525
+ projectRoot,
526
+ artifactRoot,
527
+ credentialFile,
528
+ });
529
+ if (resolve(plan.receiptPath) !== receiptPath) {
530
+ throw new Error(
531
+ "Source retirement receipt does not belong to this target's configured state root.",
532
+ );
533
+ }
534
+ if (plan.review.accessMode === "external") {
535
+ await Promise.all([
536
+ rm(credentialPath, { force: true }),
537
+ rm(receiptPath, { force: true }),
538
+ ]);
539
+ return { retired: true, providerResourcesPreserved: true };
540
+ }
383
541
  const administratorConnectionString =
384
542
  environment[administratorEnvironmentVariable];
385
543
  if (!administratorConnectionString)
@@ -473,8 +631,8 @@ export const retirePostgresqlSourceAccess = async ({
473
631
  await client.end?.();
474
632
  }
475
633
  await Promise.all([
476
- rm(ensureOwnedPath(projectRoot, credentialFile), { force: true }),
477
- rm(plan.receiptPath, { force: true }),
634
+ rm(credentialPath, { force: true }),
635
+ rm(receiptPath, { force: true }),
478
636
  ]);
479
637
  return { retired: true };
480
638
  };
@@ -0,0 +1,38 @@
1
+ /** Coordinate private source prefixes with reviewed privacy path mappings. */
2
+
3
+ import { validateExecutablePrivacyPolicy } from "../baseline/privacy_engine.mjs";
4
+ import { validateSourceAccessPolicy } from "./access.mjs";
5
+
6
+ export const resolvePrivacyMappedAssets = ({
7
+ sourcePolicy,
8
+ privacyPolicy,
9
+ environment = process.env,
10
+ }) => {
11
+ const source = validateSourceAccessPolicy(sourcePolicy);
12
+ const privacy = validateExecutablePrivacyPolicy(privacyPolicy);
13
+
14
+ return Object.freeze(
15
+ source.assets.map((asset) => {
16
+ if (!asset.pathMapping) return asset;
17
+ const mapping = privacy.pathMappings[asset.pathMapping];
18
+ if (!mapping) {
19
+ throw new Error(
20
+ `Storage asset references unknown privacy path mapping ${asset.pathMapping}.`,
21
+ );
22
+ }
23
+ const binding = privacy.bindings[mapping.binding];
24
+ if (binding.environmentVariable !== asset.prefixEnvironmentVariable) {
25
+ throw new Error(
26
+ `Storage asset ${asset.bucket} must use the environment variable reviewed by privacy path mapping ${asset.pathMapping}.`,
27
+ );
28
+ }
29
+ const prefix = environment[asset.prefixEnvironmentVariable];
30
+ if (typeof prefix !== "string" || !prefix) {
31
+ throw new Error(
32
+ `Storage asset ${asset.bucket} requires ${asset.prefixEnvironmentVariable}.`,
33
+ );
34
+ }
35
+ return Object.freeze({ ...asset, prefix });
36
+ }),
37
+ );
38
+ };