@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
@@ -50,6 +50,13 @@ const positiveInteger = (value, label) => {
50
50
  const canonicalJson = (value) => `${JSON.stringify(value, null, "\t")}\n`;
51
51
  const sha256 = (value) => createHash("sha256").update(value).digest("hex");
52
52
 
53
+ export const externalViewDefinitionFingerprint = (definition) => {
54
+ if (typeof definition !== "string" || !definition.trim()) {
55
+ throw new Error("External view definition must be a non-empty string.");
56
+ }
57
+ return sha256(definition.trim());
58
+ };
59
+
53
60
  export const sourceTargetFingerprint = (connectionString) => {
54
61
  let target;
55
62
  try {
@@ -95,40 +102,119 @@ export const validateSourceAccessPolicy = (policy) => {
95
102
  throw new Error("Source access policy must use accessVersion 1.");
96
103
  }
97
104
  fingerprint(policy.targetFingerprint, "sourcePolicy.targetFingerprint");
98
- envKey(
99
- policy.administratorEnvironmentVariable,
100
- "sourcePolicy.administratorEnvironmentVariable",
101
- );
102
105
  if (!isObject(policy.reader))
103
106
  throw new Error("sourcePolicy.reader must be an object.");
107
+ const readerMode = policy.reader.mode ?? "managed";
108
+ if (!["managed", "external"].includes(readerMode)) {
109
+ throw new Error("sourcePolicy.reader.mode must be managed or external.");
110
+ }
104
111
  knownKeys(
105
112
  policy.reader,
106
- ["role", "ownerRole", "credentialFile", "validForMinutes"],
113
+ readerMode === "managed"
114
+ ? ["mode", "role", "ownerRole", "credentialFile", "validForMinutes"]
115
+ : [
116
+ "mode",
117
+ "role",
118
+ "allowedMemberships",
119
+ "connectionEnvironmentVariable",
120
+ "credentialFile",
121
+ "maximumValidForMinutes",
122
+ ],
107
123
  "sourcePolicy.reader",
108
124
  );
109
- const role = identifier(policy.reader.role, "sourcePolicy.reader.role");
110
- const ownerRole = identifier(
111
- policy.reader.ownerRole,
112
- "sourcePolicy.reader.ownerRole",
113
- );
114
- if (role === ownerRole)
115
- throw new Error("Source reader and owner roles must differ.");
116
- if (
117
- typeof policy.reader.credentialFile !== "string" ||
118
- !policy.reader.credentialFile.startsWith(".rehearsal/") ||
119
- policy.reader.credentialFile.split(/[\\/]/u).includes("..")
120
- ) {
125
+ let administratorEnvironmentVariable;
126
+ if (readerMode === "managed") {
127
+ administratorEnvironmentVariable = envKey(
128
+ policy.administratorEnvironmentVariable,
129
+ "sourcePolicy.administratorEnvironmentVariable",
130
+ );
131
+ } else if (policy.administratorEnvironmentVariable !== undefined) {
121
132
  throw new Error(
122
- "sourcePolicy.reader.credentialFile must stay under .rehearsal/.",
133
+ "sourcePolicy.administratorEnvironmentVariable is not allowed for an external reader.",
134
+ );
135
+ }
136
+ const role = identifier(policy.reader.role, "sourcePolicy.reader.role");
137
+ let ownerRole;
138
+ let validForMinutes;
139
+ let connectionEnvironmentVariable;
140
+ let maximumValidForMinutes;
141
+ let allowedMemberships = Object.freeze([]);
142
+ if (readerMode === "managed") {
143
+ ownerRole = identifier(
144
+ policy.reader.ownerRole,
145
+ "sourcePolicy.reader.ownerRole",
146
+ );
147
+ if (role === ownerRole)
148
+ throw new Error("Source reader and owner roles must differ.");
149
+ if (
150
+ !Number.isSafeInteger(policy.reader.validForMinutes) ||
151
+ policy.reader.validForMinutes < 5 ||
152
+ policy.reader.validForMinutes > 240
153
+ ) {
154
+ throw new Error(
155
+ "sourcePolicy.reader.validForMinutes must be from 5 through 240.",
156
+ );
157
+ }
158
+ validForMinutes = policy.reader.validForMinutes;
159
+ } else {
160
+ if (
161
+ policy.reader.allowedMemberships !== undefined &&
162
+ (!Array.isArray(policy.reader.allowedMemberships) ||
163
+ policy.reader.allowedMemberships.length > 8)
164
+ ) {
165
+ throw new Error(
166
+ "sourcePolicy.reader.allowedMemberships must contain at most 8 role names.",
167
+ );
168
+ }
169
+ allowedMemberships = Object.freeze(
170
+ (policy.reader.allowedMemberships ?? []).map((membership, index) =>
171
+ identifier(
172
+ membership,
173
+ `sourcePolicy.reader.allowedMemberships[${index}]`,
174
+ ),
175
+ ),
176
+ );
177
+ if (new Set(allowedMemberships).size !== allowedMemberships.length) {
178
+ throw new Error(
179
+ "sourcePolicy.reader.allowedMemberships must not contain duplicates.",
180
+ );
181
+ }
182
+ if (allowedMemberships.includes(role)) {
183
+ throw new Error(
184
+ "sourcePolicy.reader.allowedMemberships must not include the login role.",
185
+ );
186
+ }
187
+ connectionEnvironmentVariable = envKey(
188
+ policy.reader.connectionEnvironmentVariable,
189
+ "sourcePolicy.reader.connectionEnvironmentVariable",
123
190
  );
191
+ if (
192
+ !Number.isSafeInteger(policy.reader.maximumValidForMinutes) ||
193
+ policy.reader.maximumValidForMinutes < 5 ||
194
+ policy.reader.maximumValidForMinutes > 240
195
+ ) {
196
+ throw new Error(
197
+ "sourcePolicy.reader.maximumValidForMinutes must be from 5 through 240.",
198
+ );
199
+ }
200
+ maximumValidForMinutes = policy.reader.maximumValidForMinutes;
124
201
  }
202
+ const credentialParts =
203
+ typeof policy.reader.credentialFile === "string"
204
+ ? policy.reader.credentialFile.split(/[\\/]/u)
205
+ : [];
206
+ const rehearsalIndex = credentialParts.lastIndexOf(".rehearsal");
125
207
  if (
126
- !Number.isSafeInteger(policy.reader.validForMinutes) ||
127
- policy.reader.validForMinutes < 5 ||
128
- policy.reader.validForMinutes > 240
208
+ typeof policy.reader.credentialFile !== "string" ||
209
+ policy.reader.credentialFile.startsWith("/") ||
210
+ /^[A-Za-z]:[\\/]/u.test(policy.reader.credentialFile) ||
211
+ credentialParts.some((part) => !part || part === "." || part === "..") ||
212
+ rehearsalIndex < 0 ||
213
+ credentialParts[rehearsalIndex + 1] !== "secrets" ||
214
+ rehearsalIndex + 2 >= credentialParts.length
129
215
  ) {
130
216
  throw new Error(
131
- "sourcePolicy.reader.validForMinutes must be from 5 through 240.",
217
+ "sourcePolicy.reader.credentialFile must be a project-relative file under the configured .rehearsal/secrets directory.",
132
218
  );
133
219
  }
134
220
  const exportSchema = identifier(
@@ -185,6 +271,7 @@ export const validateSourceAccessPolicy = (policy) => {
185
271
  "columns",
186
272
  "orderBy",
187
273
  "rowScope",
274
+ "viewDefinitionSha256",
188
275
  ],
189
276
  label,
190
277
  );
@@ -235,6 +322,17 @@ export const validateSourceAccessPolicy = (policy) => {
235
322
  throw new Error(`${label}.orderBy must name unique exported columns.`);
236
323
  }
237
324
  const orderBy = Object.freeze([...relation.orderBy]);
325
+ let viewDefinitionSha256;
326
+ if (readerMode === "external") {
327
+ viewDefinitionSha256 = fingerprint(
328
+ relation.viewDefinitionSha256,
329
+ `${label}.viewDefinitionSha256`,
330
+ );
331
+ } else if (relation.viewDefinitionSha256 !== undefined) {
332
+ throw new Error(
333
+ `${label}.viewDefinitionSha256 is supported only for an external reader.`,
334
+ );
335
+ }
238
336
  if (!isObject(relation.rowScope))
239
337
  throw new Error(`${label}.rowScope must be an object.`);
240
338
  knownKeys(
@@ -282,6 +380,7 @@ export const validateSourceAccessPolicy = (policy) => {
282
380
  columns,
283
381
  orderBy,
284
382
  rowScope,
383
+ ...(viewDefinitionSha256 ? { viewDefinitionSha256 } : {}),
285
384
  });
286
385
  });
287
386
  const assets = policy.assets ?? [];
@@ -290,29 +389,79 @@ export const validateSourceAccessPolicy = (policy) => {
290
389
  const normalizedAssets = assets.map((asset, index) => {
291
390
  const label = `sourcePolicy.assets[${index}]`;
292
391
  if (!isObject(asset)) throw new Error(`${label} must be an object.`);
293
- knownKeys(asset, ["bucket", "prefix", "rights"], label);
392
+ knownKeys(
393
+ asset,
394
+ [
395
+ "bucket",
396
+ "prefix",
397
+ "prefixEnvironmentVariable",
398
+ "rights",
399
+ "pathMapping",
400
+ ],
401
+ label,
402
+ );
294
403
  if (!/^[a-z0-9][a-z0-9.-]{0,99}$/u.test(asset.bucket ?? "")) {
295
404
  throw new Error(`${label}.bucket is invalid.`);
296
405
  }
297
- const prefixParts =
298
- typeof asset.prefix === "string"
299
- ? asset.prefix.replace(/\/$/u, "").split("/")
300
- : [];
301
- if (
302
- typeof asset.prefix !== "string" ||
303
- !asset.prefix ||
304
- asset.prefix.startsWith("/") ||
305
- prefixParts.some((part) => !part || part === "." || part === "..")
306
- ) {
307
- throw new Error(`${label}.prefix is unsafe.`);
406
+ const hasFixedPrefix = asset.prefix !== undefined;
407
+ const hasPrivatePrefix = asset.prefixEnvironmentVariable !== undefined;
408
+ if (hasFixedPrefix === hasPrivatePrefix) {
409
+ throw new Error(
410
+ `${label} must declare exactly one of prefix or prefixEnvironmentVariable.`,
411
+ );
412
+ }
413
+ if (hasFixedPrefix) {
414
+ const prefixParts =
415
+ typeof asset.prefix === "string"
416
+ ? asset.prefix.replace(/\/$/u, "").split("/")
417
+ : [];
418
+ if (
419
+ typeof asset.prefix !== "string" ||
420
+ !asset.prefix ||
421
+ asset.prefix.startsWith("/") ||
422
+ prefixParts.some((part) => !part || part === "." || part === "..")
423
+ ) {
424
+ throw new Error(`${label}.prefix is unsafe.`);
425
+ }
426
+ } else {
427
+ envKey(
428
+ asset.prefixEnvironmentVariable,
429
+ `${label}.prefixEnvironmentVariable`,
430
+ );
308
431
  }
309
432
  if (!["approved-public", "approved-owner"].includes(asset.rights)) {
310
433
  throw new Error(`${label}.rights must be explicitly approved.`);
311
434
  }
435
+ if (
436
+ asset.pathMapping !== undefined &&
437
+ !/^[a-z][a-z0-9-]{0,62}$/u.test(asset.pathMapping)
438
+ ) {
439
+ throw new Error(`${label}.pathMapping is invalid.`);
440
+ }
441
+ if (hasPrivatePrefix && asset.rights !== "approved-owner") {
442
+ throw new Error(
443
+ `${label}.prefixEnvironmentVariable requires approved-owner rights.`,
444
+ );
445
+ }
446
+ if (hasPrivatePrefix && asset.pathMapping === undefined) {
447
+ throw new Error(
448
+ `${label}.prefixEnvironmentVariable requires pathMapping.`,
449
+ );
450
+ }
451
+ if (hasFixedPrefix && asset.pathMapping !== undefined) {
452
+ throw new Error(
453
+ `${label}.pathMapping requires prefixEnvironmentVariable so the raw identity stays out of tracked policy.`,
454
+ );
455
+ }
312
456
  return Object.freeze({
313
457
  bucket: asset.bucket,
314
- prefix: asset.prefix,
458
+ ...(hasFixedPrefix
459
+ ? { prefix: asset.prefix }
460
+ : { prefixEnvironmentVariable: asset.prefixEnvironmentVariable }),
315
461
  rights: asset.rights,
462
+ ...(asset.pathMapping === undefined
463
+ ? {}
464
+ : { pathMapping: asset.pathMapping }),
316
465
  });
317
466
  });
318
467
  let assetReader = null;
@@ -368,12 +517,20 @@ export const validateSourceAccessPolicy = (policy) => {
368
517
  return Object.freeze({
369
518
  accessVersion: 1,
370
519
  targetFingerprint: policy.targetFingerprint,
371
- administratorEnvironmentVariable: policy.administratorEnvironmentVariable,
520
+ ...(administratorEnvironmentVariable
521
+ ? { administratorEnvironmentVariable }
522
+ : {}),
372
523
  reader: Object.freeze({
524
+ mode: readerMode,
373
525
  role,
374
- ownerRole,
375
526
  credentialFile: policy.reader.credentialFile,
376
- validForMinutes: policy.reader.validForMinutes,
527
+ ...(readerMode === "managed"
528
+ ? { ownerRole, validForMinutes }
529
+ : {
530
+ allowedMemberships,
531
+ connectionEnvironmentVariable,
532
+ maximumValidForMinutes,
533
+ }),
377
534
  }),
378
535
  exportSchema,
379
536
  migrationLedger,
@@ -389,10 +546,18 @@ export const createSourceAccessPlan = ({
389
546
  }) => {
390
547
  const normalized = validateSourceAccessPolicy(policy);
391
548
  const sourceCredential =
392
- environment[normalized.administratorEnvironmentVariable];
549
+ environment[
550
+ normalized.reader.mode === "managed"
551
+ ? normalized.administratorEnvironmentVariable
552
+ : normalized.reader.connectionEnvironmentVariable
553
+ ];
393
554
  if (!sourceCredential) {
394
555
  throw new Error(
395
- `Source preparation requires ${normalized.administratorEnvironmentVariable}; the value is read from the environment and never printed.`,
556
+ `Source preparation requires ${
557
+ normalized.reader.mode === "managed"
558
+ ? normalized.administratorEnvironmentVariable
559
+ : normalized.reader.connectionEnvironmentVariable
560
+ }; the value is read from the environment and never printed.`,
396
561
  );
397
562
  }
398
563
  const actualTarget = sourceTargetFingerprint(sourceCredential);
@@ -411,6 +576,16 @@ export const createSourceAccessPlan = ({
411
576
  return [relation.view, sha256(value)];
412
577
  }),
413
578
  );
579
+ const assetPrefixReceipts = Object.fromEntries(
580
+ normalized.assets
581
+ .filter((asset) => asset.prefixEnvironmentVariable)
582
+ .map((asset) => {
583
+ const key = asset.prefixEnvironmentVariable;
584
+ const value = environment[key];
585
+ if (!value) throw new Error(`Source preparation requires ${key}.`);
586
+ return [`${asset.bucket}:${asset.pathMapping}`, sha256(value)];
587
+ }),
588
+ );
414
589
  if (normalized.assetReader) {
415
590
  const baseUrl =
416
591
  environment[normalized.assetReader.baseUrlEnvironmentVariable];
@@ -435,10 +610,22 @@ export const createSourceAccessPlan = ({
435
610
  targetFingerprint: normalized.targetFingerprint,
436
611
  exportSchema: normalized.exportSchema,
437
612
  reader: {
613
+ mode: normalized.reader.mode,
438
614
  role: normalized.reader.role,
439
- ownerRole: normalized.reader.ownerRole,
440
- validForMinutes: normalized.reader.validForMinutes,
441
615
  credentialFile: normalized.reader.credentialFile,
616
+ ...(normalized.reader.mode === "managed"
617
+ ? {
618
+ ownerRole: normalized.reader.ownerRole,
619
+ validForMinutes: normalized.reader.validForMinutes,
620
+ sourceChanges: true,
621
+ }
622
+ : {
623
+ connectionEnvironmentVariable:
624
+ normalized.reader.connectionEnvironmentVariable,
625
+ allowedMemberships: normalized.reader.allowedMemberships,
626
+ maximumValidForMinutes: normalized.reader.maximumValidForMinutes,
627
+ sourceChanges: false,
628
+ }),
442
629
  },
443
630
  relations: normalized.relations.map((relation) => ({
444
631
  source: relation.source,
@@ -446,6 +633,9 @@ export const createSourceAccessPlan = ({
446
633
  targetTable: relation.targetTable,
447
634
  columns: relation.columns,
448
635
  orderBy: relation.orderBy,
636
+ ...(relation.viewDefinitionSha256
637
+ ? { viewDefinitionSha256: relation.viewDefinitionSha256 }
638
+ : {}),
449
639
  rowScope: {
450
640
  kind: relation.rowScope.kind,
451
641
  ...(relation.rowScope.kind === "approved-owner"
@@ -456,7 +646,15 @@ export const createSourceAccessPlan = ({
456
646
  : {}),
457
647
  },
458
648
  })),
459
- assets: normalized.assets,
649
+ assets: normalized.assets.map((asset) =>
650
+ asset.prefixEnvironmentVariable
651
+ ? {
652
+ ...asset,
653
+ prefixValueSha256:
654
+ assetPrefixReceipts[`${asset.bucket}:${asset.pathMapping}`],
655
+ }
656
+ : asset,
657
+ ),
460
658
  assetReader: normalized.assetReader
461
659
  ? {
462
660
  endpointFingerprint: normalized.assetReader.endpointFingerprint,
@@ -465,13 +663,25 @@ export const createSourceAccessPlan = ({
465
663
  maximumTotalBytes: normalized.assetReader.maximumTotalBytes,
466
664
  }
467
665
  : null,
468
- denyChecks: [
469
- "raw relation reads",
470
- "writes",
471
- "role creation and escalation",
472
- "network functions",
473
- "unrelated private assets",
474
- ],
666
+ denyChecks:
667
+ normalized.reader.mode === "managed"
668
+ ? [
669
+ "raw relation reads",
670
+ "writes",
671
+ "role creation and escalation",
672
+ "network functions",
673
+ "unrelated private assets",
674
+ ]
675
+ : [
676
+ "all non-system readable columns match the approved views and migration ledger",
677
+ "no relation, column, schema, or database writes",
678
+ "role memberships exactly match the reviewed allowlist",
679
+ "allowed groups cannot log in, escalate, switch roles, delegate membership, or inherit other roles",
680
+ "no privileged role attributes",
681
+ "no executable non-system security-definer functions",
682
+ "database-enforced credential expiration",
683
+ "unrelated private assets",
684
+ ],
475
685
  };
476
686
  return Object.freeze({
477
687
  policy: normalized,
@@ -480,6 +690,9 @@ export const createSourceAccessPlan = ({
480
690
  });
481
691
  };
482
692
 
693
+ export const sourceAccessPolicyFingerprint = (policy) =>
694
+ sha256(canonicalJson(validateSourceAccessPolicy(policy)));
695
+
483
696
  export const assetEndpointFingerprint = (value) => {
484
697
  let endpoint;
485
698
  try {
@@ -525,6 +738,10 @@ export const createSourceRetirementPlan = ({
525
738
  if (!Array.isArray(receipt.views) || !Array.isArray(receipt.relations)) {
526
739
  throw new Error("Source access receipt object inventory is invalid.");
527
740
  }
741
+ const accessMode = receipt.accessMode ?? "managed";
742
+ if (!["managed", "external"].includes(accessMode)) {
743
+ throw new Error("Source access receipt mode is invalid.");
744
+ }
528
745
  const relations = receipt.relations.map((relation, index) => {
529
746
  if (!isObject(relation))
530
747
  throw new Error(`receipt.relations[${index}] is invalid.`);
@@ -560,6 +777,7 @@ export const createSourceRetirementPlan = ({
560
777
  const review = {
561
778
  planVersion: 1,
562
779
  operation: "retire-source-access",
780
+ accessMode,
563
781
  targetFingerprint: target,
564
782
  preparationDigest: fingerprint(receipt.planDigest, "receipt.planDigest"),
565
783
  exportSchema: identifier(receipt.exportSchema, "receipt.exportSchema"),
@@ -569,7 +787,9 @@ export const createSourceRetirementPlan = ({
569
787
  relations,
570
788
  migrationLedger: ledger,
571
789
  readerRole: identifier(receipt.readerRole, "receipt.readerRole"),
572
- ownerRole: identifier(receipt.ownerRole, "receipt.ownerRole"),
790
+ ...(accessMode === "managed"
791
+ ? { ownerRole: identifier(receipt.ownerRole, "receipt.ownerRole") }
792
+ : {}),
573
793
  };
574
794
  return Object.freeze({
575
795
  review: Object.freeze(review),