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

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.
@@ -13,7 +13,10 @@ import {
13
13
  pruneBaselineGenerations,
14
14
  verifyActiveBaseline,
15
15
  } from "../baseline/artifact.mjs";
16
- import { readPrivacyKey } from "../baseline/privacy_engine.mjs";
16
+ import {
17
+ createPrivacyEngine,
18
+ readPrivacyKey,
19
+ } from "../baseline/privacy_engine.mjs";
17
20
  import { loadRehearsalConfig } from "../project/configuration.mjs";
18
21
  import {
19
22
  createSourceAccessPlan,
@@ -21,6 +24,7 @@ import {
21
24
  } from "../source/access.mjs";
22
25
  import { streamApprovedSupabaseAssets } from "../source/asset_transfer.mjs";
23
26
  import { refreshPostgresqlBaseline } from "../source/baseline.mjs";
27
+ import { resolvePrivacyMappedAssets } from "../source/privacy_paths.mjs";
24
28
  import {
25
29
  applyPostgresqlSourceAccess,
26
30
  planPostgresqlSourceAccessRetirement,
@@ -114,6 +118,15 @@ export const runSourceBaselineRefresh = async ({ planOptions }) => {
114
118
  join(loaded.projectRoot, sourcePolicy.reader.credentialFile),
115
119
  ),
116
120
  ]);
121
+ const privacyPolicy = JSON.parse(privacyPolicyBytes.toString("utf8"));
122
+ const assetPrivacyEngine = createPrivacyEngine({
123
+ policy: privacyPolicy,
124
+ key: privacyKey,
125
+ });
126
+ const assetDeclarations = resolvePrivacyMappedAssets({
127
+ sourcePolicy,
128
+ privacyPolicy,
129
+ });
117
130
  return refreshPostgresqlBaseline({
118
131
  connectionString,
119
132
  sourcePolicy,
@@ -133,10 +146,12 @@ export const runSourceBaselineRefresh = async ({ planOptions }) => {
133
146
  baseUrl:
134
147
  process.env[sourcePolicy.assetReader.baseUrlEnvironmentVariable],
135
148
  token: process.env[sourcePolicy.assetReader.tokenEnvironmentVariable],
136
- declarations: sourcePolicy.assets,
149
+ declarations: assetDeclarations,
137
150
  maximumObjects: sourcePolicy.assetReader.maximumObjects,
138
151
  maximumObjectBytes: sourcePolicy.assetReader.maximumObjectBytes,
139
152
  maximumTotalBytes: sourcePolicy.assetReader.maximumTotalBytes,
153
+ pathMapper: ({ mapping, value }) =>
154
+ assetPrivacyEngine.remapPath({ mapping, value }),
140
155
  })
141
156
  : [],
142
157
  });
@@ -244,9 +259,15 @@ export const buildRefreshWorkflowPlan = async (planOptions) => {
244
259
  };
245
260
 
246
261
  export const createRefreshWorkflow =
247
- ({ runRuntimeStack, prepareDependentTargets, topologyCommandEnvironment }) =>
262
+ ({
263
+ runRuntimeStack,
264
+ prepareDependentTargets,
265
+ topologyCommandEnvironment,
266
+ buildPlan = buildRefreshWorkflowPlan,
267
+ refreshBaseline = runSourceBaselineRefresh,
268
+ }) =>
248
269
  async ({ flags, planOptions }) => {
249
- const context = await buildRefreshWorkflowPlan(planOptions);
270
+ const context = await buildPlan(planOptions);
250
271
  if (!flags.refreshConfirmation) {
251
272
  return { mode: "preview", plan: context.plan };
252
273
  }
@@ -259,7 +280,7 @@ export const createRefreshWorkflow =
259
280
  let runtime;
260
281
  let preparations = [];
261
282
  try {
262
- refreshed = await runSourceBaselineRefresh({ planOptions });
283
+ refreshed = await refreshBaseline({ planOptions });
263
284
  const stack = await runRuntimeStack({
264
285
  command: "reset",
265
286
  flags,
@@ -23,6 +23,11 @@ import {
23
23
  renderDetectedPostgresqlConfig,
24
24
  } from "./configuration.mjs";
25
25
  import { resolveRuntimeTarget } from "../targets/target.mjs";
26
+ export {
27
+ assertSupportedRehearsalNodeRuntime,
28
+ inspectRehearsalNodeRuntime,
29
+ } from "../shared/node_runtime.mjs";
30
+ import { assertSupportedRehearsalNodeRuntime } from "../shared/node_runtime.mjs";
26
31
 
27
32
  const CONFIG_PATH = "rehearsal.config.mjs";
28
33
  const LOCAL_SUPABASE_CONFIG_PATH =
@@ -82,29 +87,6 @@ export const isRehearsalPortAvailable = async (port) => {
82
87
  return canBindToPort(port);
83
88
  };
84
89
 
85
- export const inspectRehearsalNodeRuntime = (
86
- version = process.versions.node,
87
- ) => {
88
- const major = Number.parseInt(String(version).split(".")[0], 10);
89
- return {
90
- version: String(version),
91
- major,
92
- supported: major === 24,
93
- };
94
- };
95
-
96
- export const assertSupportedRehearsalNodeRuntime = (
97
- version = process.versions.node,
98
- ) => {
99
- const runtime = inspectRehearsalNodeRuntime(version);
100
- if (!runtime.supported) {
101
- throw new Error(
102
- `Rehearsal setup requires Node.js 24; this shell is using Node.js ${runtime.version}. Switch to Node.js 24, then rerun setup. With nvm: nvm install 24 && nvm use 24.`,
103
- );
104
- }
105
- return runtime;
106
- };
107
-
108
90
  export const findAvailableRehearsalPorts = async ({
109
91
  isAvailable = isRehearsalPortAvailable,
110
92
  } = {}) => {
@@ -22,6 +22,10 @@ import {
22
22
  import { createCandidateMigrationReceipt } from "./restore.mjs";
23
23
  import { readMigrationFileInventory } from "./migration_history.mjs";
24
24
  import { REHEARSAL_VERSION } from "../shared/diagnostics.mjs";
25
+ import {
26
+ inspectRehearsalNodeRuntime,
27
+ REHEARSAL_NODE_LABEL,
28
+ } from "../shared/node_runtime.mjs";
25
29
  import { readRehearsalServiceEnvironment } from "./service_environment.mjs";
26
30
  import {
27
31
  readBoundRuntimeSanitizationPolicy,
@@ -357,17 +361,17 @@ export const runRehearsalDoctor = async (options = {}) => {
357
361
  };
358
362
  }
359
363
  const { config, paths, projectRoot } = loaded;
360
- const nodeMajor = Number(process.versions.node.split(".")[0]);
364
+ const nodeRuntime = inspectRehearsalNodeRuntime();
361
365
  const checkDefinitions = [
362
366
  {
363
367
  id: "node",
364
368
  label: "Node.js compatibility",
365
369
  run: async () => {
366
- if (nodeMajor !== 24)
370
+ if (!nodeRuntime.supported)
367
371
  throw new Error(`Node ${process.versions.node} is unsupported.`);
368
372
  return `Node ${process.versions.node}`;
369
373
  },
370
- remediation: "Use Node.js 24.",
374
+ remediation: `Use ${REHEARSAL_NODE_LABEL}. Node.js 24 LTS is recommended.`,
371
375
  },
372
376
  {
373
377
  id: "operating-system",
@@ -194,11 +194,19 @@ const buildIdentitySequenceSql = (table) =>
194
194
  const schema = tableSchema(table);
195
195
  const relation = qualifiedRelation(table);
196
196
  const columnName = quoteIdentifier(column.name);
197
- return `select setval(
198
- pg_get_serial_sequence(${quoteLiteral(`${schema}.${table.name}`)}, ${quoteLiteral(column.name)}),
199
- coalesce((select max(${columnName}) from ${relation}), 1),
200
- exists(select 1 from ${relation})
201
- );`;
197
+ const sequenceValueQuery = `select max(${columnName})::bigint, exists(select 1 from ${relation}) from ${relation}`;
198
+ return `do $rehearsal_sequence$
199
+ declare
200
+ sequence_name text := pg_get_serial_sequence(${quoteLiteral(`${schema}.${table.name}`)}, ${quoteLiteral(column.name)});
201
+ maximum_value bigint;
202
+ has_rows boolean;
203
+ begin
204
+ if sequence_name is not null then
205
+ execute ${quoteLiteral(sequenceValueQuery)} into maximum_value, has_rows;
206
+ perform setval(sequence_name, coalesce(maximum_value, 1), has_rows);
207
+ end if;
208
+ end
209
+ $rehearsal_sequence$;`;
202
210
  })
203
211
  .join("\n");
204
212
 
@@ -94,6 +94,13 @@ export class RehearsalError extends Error {
94
94
  const inferCategory = (error) => {
95
95
  const message = String(error?.message ?? error);
96
96
  if (/checksum/iu.test(message)) return "baseline_checksum_mismatch";
97
+ if (
98
+ /container\b.*\b(?:is not ready|starting|unhealthy)|health(?:check)?\b.*\b(?:failed|starting|unhealthy)|out of memory|\boom\b|no space left|resource temporarily unavailable/iu.test(
99
+ message,
100
+ )
101
+ ) {
102
+ return "runtime_dependency_failure";
103
+ }
97
104
  if (/candidate|migration history diverges/iu.test(message)) {
98
105
  return "migration_candidate_failure";
99
106
  }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Supported Node.js release lines. Keep this aligned with package.json and the
3
+ * verification matrix.
4
+ */
5
+
6
+ export const REHEARSAL_NODE_MAJORS = Object.freeze([22, 24, 26]);
7
+ export const REHEARSAL_NODE_LABEL = "Node.js 22, 24, or 26";
8
+ export const REHEARSAL_RECOMMENDED_NODE_MAJOR = 24;
9
+
10
+ export const inspectRehearsalNodeRuntime = (
11
+ version = process.versions.node,
12
+ ) => {
13
+ const major = Number.parseInt(String(version).split(".")[0], 10);
14
+ return {
15
+ version: String(version),
16
+ major,
17
+ supported: REHEARSAL_NODE_MAJORS.includes(major),
18
+ };
19
+ };
20
+
21
+ export const assertSupportedRehearsalNodeRuntime = (
22
+ version = process.versions.node,
23
+ ) => {
24
+ const runtime = inspectRehearsalNodeRuntime(version);
25
+ if (!runtime.supported) {
26
+ throw new Error(
27
+ `Rehearsal requires ${REHEARSAL_NODE_LABEL}; this shell is using Node.js ${runtime.version}. Switch to a supported release, then rerun setup. With nvm: nvm install --lts && nvm use --lts.`,
28
+ );
29
+ }
30
+ return runtime;
31
+ };
@@ -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,24 +102,74 @@ 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
+ "connectionEnvironmentVariable",
119
+ "credentialFile",
120
+ "maximumValidForMinutes",
121
+ ],
107
122
  "sourcePolicy.reader",
108
123
  );
124
+ let administratorEnvironmentVariable;
125
+ if (readerMode === "managed") {
126
+ administratorEnvironmentVariable = envKey(
127
+ policy.administratorEnvironmentVariable,
128
+ "sourcePolicy.administratorEnvironmentVariable",
129
+ );
130
+ } else if (policy.administratorEnvironmentVariable !== undefined) {
131
+ throw new Error(
132
+ "sourcePolicy.administratorEnvironmentVariable is not allowed for an external reader.",
133
+ );
134
+ }
109
135
  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.");
136
+ let ownerRole;
137
+ let validForMinutes;
138
+ let connectionEnvironmentVariable;
139
+ let maximumValidForMinutes;
140
+ if (readerMode === "managed") {
141
+ ownerRole = identifier(
142
+ policy.reader.ownerRole,
143
+ "sourcePolicy.reader.ownerRole",
144
+ );
145
+ if (role === ownerRole)
146
+ throw new Error("Source reader and owner roles must differ.");
147
+ if (
148
+ !Number.isSafeInteger(policy.reader.validForMinutes) ||
149
+ policy.reader.validForMinutes < 5 ||
150
+ policy.reader.validForMinutes > 240
151
+ ) {
152
+ throw new Error(
153
+ "sourcePolicy.reader.validForMinutes must be from 5 through 240.",
154
+ );
155
+ }
156
+ validForMinutes = policy.reader.validForMinutes;
157
+ } else {
158
+ connectionEnvironmentVariable = envKey(
159
+ policy.reader.connectionEnvironmentVariable,
160
+ "sourcePolicy.reader.connectionEnvironmentVariable",
161
+ );
162
+ if (
163
+ !Number.isSafeInteger(policy.reader.maximumValidForMinutes) ||
164
+ policy.reader.maximumValidForMinutes < 5 ||
165
+ policy.reader.maximumValidForMinutes > 240
166
+ ) {
167
+ throw new Error(
168
+ "sourcePolicy.reader.maximumValidForMinutes must be from 5 through 240.",
169
+ );
170
+ }
171
+ maximumValidForMinutes = policy.reader.maximumValidForMinutes;
172
+ }
116
173
  if (
117
174
  typeof policy.reader.credentialFile !== "string" ||
118
175
  !policy.reader.credentialFile.startsWith(".rehearsal/") ||
@@ -122,15 +179,6 @@ export const validateSourceAccessPolicy = (policy) => {
122
179
  "sourcePolicy.reader.credentialFile must stay under .rehearsal/.",
123
180
  );
124
181
  }
125
- if (
126
- !Number.isSafeInteger(policy.reader.validForMinutes) ||
127
- policy.reader.validForMinutes < 5 ||
128
- policy.reader.validForMinutes > 240
129
- ) {
130
- throw new Error(
131
- "sourcePolicy.reader.validForMinutes must be from 5 through 240.",
132
- );
133
- }
134
182
  const exportSchema = identifier(
135
183
  policy.exportSchema,
136
184
  "sourcePolicy.exportSchema",
@@ -185,6 +233,7 @@ export const validateSourceAccessPolicy = (policy) => {
185
233
  "columns",
186
234
  "orderBy",
187
235
  "rowScope",
236
+ "viewDefinitionSha256",
188
237
  ],
189
238
  label,
190
239
  );
@@ -235,6 +284,17 @@ export const validateSourceAccessPolicy = (policy) => {
235
284
  throw new Error(`${label}.orderBy must name unique exported columns.`);
236
285
  }
237
286
  const orderBy = Object.freeze([...relation.orderBy]);
287
+ let viewDefinitionSha256;
288
+ if (readerMode === "external") {
289
+ viewDefinitionSha256 = fingerprint(
290
+ relation.viewDefinitionSha256,
291
+ `${label}.viewDefinitionSha256`,
292
+ );
293
+ } else if (relation.viewDefinitionSha256 !== undefined) {
294
+ throw new Error(
295
+ `${label}.viewDefinitionSha256 is supported only for an external reader.`,
296
+ );
297
+ }
238
298
  if (!isObject(relation.rowScope))
239
299
  throw new Error(`${label}.rowScope must be an object.`);
240
300
  knownKeys(
@@ -282,6 +342,7 @@ export const validateSourceAccessPolicy = (policy) => {
282
342
  columns,
283
343
  orderBy,
284
344
  rowScope,
345
+ ...(viewDefinitionSha256 ? { viewDefinitionSha256 } : {}),
285
346
  });
286
347
  });
287
348
  const assets = policy.assets ?? [];
@@ -290,29 +351,79 @@ export const validateSourceAccessPolicy = (policy) => {
290
351
  const normalizedAssets = assets.map((asset, index) => {
291
352
  const label = `sourcePolicy.assets[${index}]`;
292
353
  if (!isObject(asset)) throw new Error(`${label} must be an object.`);
293
- knownKeys(asset, ["bucket", "prefix", "rights"], label);
354
+ knownKeys(
355
+ asset,
356
+ [
357
+ "bucket",
358
+ "prefix",
359
+ "prefixEnvironmentVariable",
360
+ "rights",
361
+ "pathMapping",
362
+ ],
363
+ label,
364
+ );
294
365
  if (!/^[a-z0-9][a-z0-9.-]{0,99}$/u.test(asset.bucket ?? "")) {
295
366
  throw new Error(`${label}.bucket is invalid.`);
296
367
  }
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.`);
368
+ const hasFixedPrefix = asset.prefix !== undefined;
369
+ const hasPrivatePrefix = asset.prefixEnvironmentVariable !== undefined;
370
+ if (hasFixedPrefix === hasPrivatePrefix) {
371
+ throw new Error(
372
+ `${label} must declare exactly one of prefix or prefixEnvironmentVariable.`,
373
+ );
374
+ }
375
+ if (hasFixedPrefix) {
376
+ const prefixParts =
377
+ typeof asset.prefix === "string"
378
+ ? asset.prefix.replace(/\/$/u, "").split("/")
379
+ : [];
380
+ if (
381
+ typeof asset.prefix !== "string" ||
382
+ !asset.prefix ||
383
+ asset.prefix.startsWith("/") ||
384
+ prefixParts.some((part) => !part || part === "." || part === "..")
385
+ ) {
386
+ throw new Error(`${label}.prefix is unsafe.`);
387
+ }
388
+ } else {
389
+ envKey(
390
+ asset.prefixEnvironmentVariable,
391
+ `${label}.prefixEnvironmentVariable`,
392
+ );
308
393
  }
309
394
  if (!["approved-public", "approved-owner"].includes(asset.rights)) {
310
395
  throw new Error(`${label}.rights must be explicitly approved.`);
311
396
  }
397
+ if (
398
+ asset.pathMapping !== undefined &&
399
+ !/^[a-z][a-z0-9-]{0,62}$/u.test(asset.pathMapping)
400
+ ) {
401
+ throw new Error(`${label}.pathMapping is invalid.`);
402
+ }
403
+ if (hasPrivatePrefix && asset.rights !== "approved-owner") {
404
+ throw new Error(
405
+ `${label}.prefixEnvironmentVariable requires approved-owner rights.`,
406
+ );
407
+ }
408
+ if (hasPrivatePrefix && asset.pathMapping === undefined) {
409
+ throw new Error(
410
+ `${label}.prefixEnvironmentVariable requires pathMapping.`,
411
+ );
412
+ }
413
+ if (hasFixedPrefix && asset.pathMapping !== undefined) {
414
+ throw new Error(
415
+ `${label}.pathMapping requires prefixEnvironmentVariable so the raw identity stays out of tracked policy.`,
416
+ );
417
+ }
312
418
  return Object.freeze({
313
419
  bucket: asset.bucket,
314
- prefix: asset.prefix,
420
+ ...(hasFixedPrefix
421
+ ? { prefix: asset.prefix }
422
+ : { prefixEnvironmentVariable: asset.prefixEnvironmentVariable }),
315
423
  rights: asset.rights,
424
+ ...(asset.pathMapping === undefined
425
+ ? {}
426
+ : { pathMapping: asset.pathMapping }),
316
427
  });
317
428
  });
318
429
  let assetReader = null;
@@ -368,12 +479,16 @@ export const validateSourceAccessPolicy = (policy) => {
368
479
  return Object.freeze({
369
480
  accessVersion: 1,
370
481
  targetFingerprint: policy.targetFingerprint,
371
- administratorEnvironmentVariable: policy.administratorEnvironmentVariable,
482
+ ...(administratorEnvironmentVariable
483
+ ? { administratorEnvironmentVariable }
484
+ : {}),
372
485
  reader: Object.freeze({
486
+ mode: readerMode,
373
487
  role,
374
- ownerRole,
375
488
  credentialFile: policy.reader.credentialFile,
376
- validForMinutes: policy.reader.validForMinutes,
489
+ ...(readerMode === "managed"
490
+ ? { ownerRole, validForMinutes }
491
+ : { connectionEnvironmentVariable, maximumValidForMinutes }),
377
492
  }),
378
493
  exportSchema,
379
494
  migrationLedger,
@@ -389,10 +504,18 @@ export const createSourceAccessPlan = ({
389
504
  }) => {
390
505
  const normalized = validateSourceAccessPolicy(policy);
391
506
  const sourceCredential =
392
- environment[normalized.administratorEnvironmentVariable];
507
+ environment[
508
+ normalized.reader.mode === "managed"
509
+ ? normalized.administratorEnvironmentVariable
510
+ : normalized.reader.connectionEnvironmentVariable
511
+ ];
393
512
  if (!sourceCredential) {
394
513
  throw new Error(
395
- `Source preparation requires ${normalized.administratorEnvironmentVariable}; the value is read from the environment and never printed.`,
514
+ `Source preparation requires ${
515
+ normalized.reader.mode === "managed"
516
+ ? normalized.administratorEnvironmentVariable
517
+ : normalized.reader.connectionEnvironmentVariable
518
+ }; the value is read from the environment and never printed.`,
396
519
  );
397
520
  }
398
521
  const actualTarget = sourceTargetFingerprint(sourceCredential);
@@ -411,6 +534,16 @@ export const createSourceAccessPlan = ({
411
534
  return [relation.view, sha256(value)];
412
535
  }),
413
536
  );
537
+ const assetPrefixReceipts = Object.fromEntries(
538
+ normalized.assets
539
+ .filter((asset) => asset.prefixEnvironmentVariable)
540
+ .map((asset) => {
541
+ const key = asset.prefixEnvironmentVariable;
542
+ const value = environment[key];
543
+ if (!value) throw new Error(`Source preparation requires ${key}.`);
544
+ return [`${asset.bucket}:${asset.pathMapping}`, sha256(value)];
545
+ }),
546
+ );
414
547
  if (normalized.assetReader) {
415
548
  const baseUrl =
416
549
  environment[normalized.assetReader.baseUrlEnvironmentVariable];
@@ -435,10 +568,21 @@ export const createSourceAccessPlan = ({
435
568
  targetFingerprint: normalized.targetFingerprint,
436
569
  exportSchema: normalized.exportSchema,
437
570
  reader: {
571
+ mode: normalized.reader.mode,
438
572
  role: normalized.reader.role,
439
- ownerRole: normalized.reader.ownerRole,
440
- validForMinutes: normalized.reader.validForMinutes,
441
573
  credentialFile: normalized.reader.credentialFile,
574
+ ...(normalized.reader.mode === "managed"
575
+ ? {
576
+ ownerRole: normalized.reader.ownerRole,
577
+ validForMinutes: normalized.reader.validForMinutes,
578
+ sourceChanges: true,
579
+ }
580
+ : {
581
+ connectionEnvironmentVariable:
582
+ normalized.reader.connectionEnvironmentVariable,
583
+ maximumValidForMinutes: normalized.reader.maximumValidForMinutes,
584
+ sourceChanges: false,
585
+ }),
442
586
  },
443
587
  relations: normalized.relations.map((relation) => ({
444
588
  source: relation.source,
@@ -446,6 +590,9 @@ export const createSourceAccessPlan = ({
446
590
  targetTable: relation.targetTable,
447
591
  columns: relation.columns,
448
592
  orderBy: relation.orderBy,
593
+ ...(relation.viewDefinitionSha256
594
+ ? { viewDefinitionSha256: relation.viewDefinitionSha256 }
595
+ : {}),
449
596
  rowScope: {
450
597
  kind: relation.rowScope.kind,
451
598
  ...(relation.rowScope.kind === "approved-owner"
@@ -456,7 +603,15 @@ export const createSourceAccessPlan = ({
456
603
  : {}),
457
604
  },
458
605
  })),
459
- assets: normalized.assets,
606
+ assets: normalized.assets.map((asset) =>
607
+ asset.prefixEnvironmentVariable
608
+ ? {
609
+ ...asset,
610
+ prefixValueSha256:
611
+ assetPrefixReceipts[`${asset.bucket}:${asset.pathMapping}`],
612
+ }
613
+ : asset,
614
+ ),
460
615
  assetReader: normalized.assetReader
461
616
  ? {
462
617
  endpointFingerprint: normalized.assetReader.endpointFingerprint,
@@ -465,13 +620,23 @@ export const createSourceAccessPlan = ({
465
620
  maximumTotalBytes: normalized.assetReader.maximumTotalBytes,
466
621
  }
467
622
  : null,
468
- denyChecks: [
469
- "raw relation reads",
470
- "writes",
471
- "role creation and escalation",
472
- "network functions",
473
- "unrelated private assets",
474
- ],
623
+ denyChecks:
624
+ normalized.reader.mode === "managed"
625
+ ? [
626
+ "raw relation reads",
627
+ "writes",
628
+ "role creation and escalation",
629
+ "network functions",
630
+ "unrelated private assets",
631
+ ]
632
+ : [
633
+ "all non-system readable columns match the approved views and migration ledger",
634
+ "no relation, column, schema, or database writes",
635
+ "no inherited roles or privileged role attributes",
636
+ "no executable non-system security-definer functions",
637
+ "database-enforced credential expiration",
638
+ "unrelated private assets",
639
+ ],
475
640
  };
476
641
  return Object.freeze({
477
642
  policy: normalized,
@@ -480,6 +645,9 @@ export const createSourceAccessPlan = ({
480
645
  });
481
646
  };
482
647
 
648
+ export const sourceAccessPolicyFingerprint = (policy) =>
649
+ sha256(canonicalJson(validateSourceAccessPolicy(policy)));
650
+
483
651
  export const assetEndpointFingerprint = (value) => {
484
652
  let endpoint;
485
653
  try {
@@ -525,6 +693,10 @@ export const createSourceRetirementPlan = ({
525
693
  if (!Array.isArray(receipt.views) || !Array.isArray(receipt.relations)) {
526
694
  throw new Error("Source access receipt object inventory is invalid.");
527
695
  }
696
+ const accessMode = receipt.accessMode ?? "managed";
697
+ if (!["managed", "external"].includes(accessMode)) {
698
+ throw new Error("Source access receipt mode is invalid.");
699
+ }
528
700
  const relations = receipt.relations.map((relation, index) => {
529
701
  if (!isObject(relation))
530
702
  throw new Error(`receipt.relations[${index}] is invalid.`);
@@ -560,6 +732,7 @@ export const createSourceRetirementPlan = ({
560
732
  const review = {
561
733
  planVersion: 1,
562
734
  operation: "retire-source-access",
735
+ accessMode,
563
736
  targetFingerprint: target,
564
737
  preparationDigest: fingerprint(receipt.planDigest, "receipt.planDigest"),
565
738
  exportSchema: identifier(receipt.exportSchema, "receipt.exportSchema"),
@@ -569,7 +742,9 @@ export const createSourceRetirementPlan = ({
569
742
  relations,
570
743
  migrationLedger: ledger,
571
744
  readerRole: identifier(receipt.readerRole, "receipt.readerRole"),
572
- ownerRole: identifier(receipt.ownerRole, "receipt.ownerRole"),
745
+ ...(accessMode === "managed"
746
+ ? { ownerRole: identifier(receipt.ownerRole, "receipt.ownerRole") }
747
+ : {}),
573
748
  };
574
749
  return Object.freeze({
575
750
  review: Object.freeze(review),