@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.
@@ -42,6 +42,21 @@ const readError = (label, response) =>
42
42
  `${label} failed with HTTP ${response.status}; response content was withheld.`,
43
43
  );
44
44
 
45
+ const canonicalContentType = (...values) => {
46
+ for (const value of values) {
47
+ if (typeof value !== "string") continue;
48
+ const mediaType = value.split(";", 1)[0].trim().toLowerCase();
49
+ if (
50
+ /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/u.test(
51
+ mediaType,
52
+ )
53
+ ) {
54
+ return mediaType;
55
+ }
56
+ }
57
+ return "application/octet-stream";
58
+ };
59
+
45
60
  const responseBody = async function* ({
46
61
  response,
47
62
  expectedBytes,
@@ -69,6 +84,7 @@ export const streamApprovedSupabaseAssets = async function* ({
69
84
  maximumObjects = 10_000,
70
85
  maximumObjectBytes = 50 * 1024 * 1024,
71
86
  maximumTotalBytes = 2 * 1024 * 1024 * 1024,
87
+ pathMapper,
72
88
  }) {
73
89
  const endpoint = safeBaseUrl(baseUrl);
74
90
  if (typeof token !== "string" || token.length < 16) {
@@ -156,13 +172,27 @@ export const streamApprovedSupabaseAssets = async function* ({
156
172
  );
157
173
  }
158
174
  }
175
+ if (declaration.pathMapping && typeof pathMapper !== "function") {
176
+ throw new Error(
177
+ "Storage path mapping requires the package-owned privacy mapper.",
178
+ );
179
+ }
180
+ const objectPath = declaration.pathMapping
181
+ ? safePath(
182
+ pathMapper({
183
+ mapping: declaration.pathMapping,
184
+ value: name,
185
+ }),
186
+ "Mapped Storage object path",
187
+ )
188
+ : name;
159
189
  yield {
160
190
  bucket: declaration.bucket,
161
- objectPath: name,
162
- contentType:
163
- download.headers.get("content-type") ??
164
- object.metadata?.mimetype ??
165
- "application/octet-stream",
191
+ objectPath,
192
+ contentType: canonicalContentType(
193
+ object.metadata?.mimetype,
194
+ download.headers.get("content-type"),
195
+ ),
166
196
  content: responseBody({
167
197
  response: download,
168
198
  expectedBytes,
@@ -24,6 +24,7 @@ import {
24
24
  validateExecutablePrivacyPolicy,
25
25
  } from "../baseline/privacy_engine.mjs";
26
26
  import {
27
+ sourceAccessPolicyFingerprint,
27
28
  sourceTargetFingerprint,
28
29
  validateSourceAccessPolicy,
29
30
  } from "./access.mjs";
@@ -45,6 +46,15 @@ const quoteIdentifier = (value) => {
45
46
 
46
47
  const canonicalJson = (value) => `${JSON.stringify(value, null, "\t")}\n`;
47
48
  const sha256 = (value) => createHash("sha256").update(value).digest("hex");
49
+ const parseEstimateValue = (value) => {
50
+ if (
51
+ (typeof value !== "string" && typeof value !== "number") ||
52
+ (typeof value === "string" && !/^(?:0|[1-9]\d*)$/u.test(value))
53
+ ) {
54
+ return Number.NaN;
55
+ }
56
+ return Number(value);
57
+ };
48
58
 
49
59
  const normalizeLimits = (limits = {}) => {
50
60
  const normalized = { ...DEFAULT_LIMITS, ...limits };
@@ -132,10 +142,20 @@ const estimateExport = async (client, policy) => {
132
142
  coalesce(sum(pg_column_size(export_row)), 0)::bigint as bytes
133
143
  from ${quoteIdentifier(policy.exportSchema)}.${quoteIdentifier(relation.view)} export_row`,
134
144
  );
135
- const tableRows = Number(result.rows[0].rows);
136
- const tableBytes = Number(result.rows[0].bytes);
137
- if (!Number.isSafeInteger(tableRows) || !Number.isSafeInteger(tableBytes)) {
138
- throw new Error("Source export estimate exceeds safe numeric limits.");
145
+ const estimate = result.rows?.[0];
146
+ const tableRows = parseEstimateValue(estimate?.rows);
147
+ const tableBytes = parseEstimateValue(estimate?.bytes);
148
+ if (
149
+ !Number.isSafeInteger(tableRows) ||
150
+ !Number.isSafeInteger(tableBytes) ||
151
+ tableRows < 0 ||
152
+ tableBytes < 0 ||
153
+ !Number.isSafeInteger(rows + tableRows) ||
154
+ !Number.isSafeInteger(bytes + tableBytes)
155
+ ) {
156
+ throw new Error(
157
+ "Source export estimate is missing, negative, or exceeds safe numeric limits.",
158
+ );
139
159
  }
140
160
  rows += tableRows;
141
161
  bytes += tableBytes;
@@ -238,6 +258,14 @@ const streamSanitizedRecords = async function* ({
238
258
  const batch = await client.query(
239
259
  `fetch forward ${limits.batchRows} from ${quoteIdentifier(cursor)}`,
240
260
  );
261
+ if (
262
+ !Array.isArray(batch?.rows) ||
263
+ batch.rows.length > limits.batchRows
264
+ ) {
265
+ throw new Error(
266
+ "Source reader returned a malformed or oversized cursor batch.",
267
+ );
268
+ }
241
269
  if (batch.rows.length === 0) break;
242
270
  for (const row of batch.rows) {
243
271
  const sanitized = privacyEngine.sanitize({
@@ -295,6 +323,7 @@ export const refreshPostgresqlBaseline = async ({
295
323
  );
296
324
  }
297
325
  const expectedReceiptShape = {
326
+ accessMode: sourcePolicy.reader.mode,
298
327
  exportSchema: sourcePolicy.exportSchema,
299
328
  views: sourcePolicy.relations.map((relation) => relation.view).sort(),
300
329
  relations: sourcePolicy.relations.map((relation) => ({
@@ -303,19 +332,25 @@ export const refreshPostgresqlBaseline = async ({
303
332
  })),
304
333
  migrationLedger: sourcePolicy.migrationLedger,
305
334
  readerRole: sourcePolicy.reader.role,
306
- ownerRole: sourcePolicy.reader.ownerRole,
335
+ ownerRole: sourcePolicy.reader.ownerRole ?? null,
307
336
  };
308
337
  const actualReceiptShape = {
338
+ accessMode: sourceAccessReceipt?.accessMode ?? "managed",
309
339
  exportSchema: sourceAccessReceipt?.exportSchema,
310
340
  views: [...(sourceAccessReceipt?.views ?? [])].sort(),
311
341
  relations: sourceAccessReceipt?.relations,
312
342
  migrationLedger: sourceAccessReceipt?.migrationLedger,
313
343
  readerRole: sourceAccessReceipt?.readerRole,
314
- ownerRole: sourceAccessReceipt?.ownerRole,
344
+ ownerRole: sourceAccessReceipt?.ownerRole ?? null,
315
345
  };
346
+ const policyFingerprint = sourceAccessPolicyFingerprint(sourcePolicy);
316
347
  if (
317
348
  sourceAccessReceipt?.targetFingerprint !== sourcePolicy.targetFingerprint ||
318
349
  !/^[a-f0-9]{64}$/u.test(sourceAccessReceipt?.planDigest ?? "") ||
350
+ (sourcePolicy.reader.mode === "external" &&
351
+ sourceAccessReceipt?.policyFingerprint !== policyFingerprint) ||
352
+ (sourceAccessReceipt?.policyFingerprint !== undefined &&
353
+ sourceAccessReceipt.policyFingerprint !== policyFingerprint) ||
319
354
  new Date(sourceAccessReceipt.expiresAt).valueOf() <= Date.now() ||
320
355
  JSON.stringify(actualReceiptShape) !== JSON.stringify(expectedReceiptShape)
321
356
  ) {
@@ -388,6 +423,7 @@ export const refreshPostgresqlBaseline = async ({
388
423
  canonicalJson({
389
424
  targetFingerprint: sourceAccessReceipt.targetFingerprint,
390
425
  planDigest: sourceAccessReceipt.planDigest,
426
+ policyFingerprint: sourceAccessReceipt.policyFingerprint ?? null,
391
427
  expiresAt: sourceAccessReceipt.expiresAt,
392
428
  }),
393
429
  ),
@@ -0,0 +1,255 @@
1
+ /** Verify an externally provisioned PostgreSQL reader without source writes. */
2
+
3
+ import pg from "pg";
4
+ import { externalViewDefinitionFingerprint } from "./access.mjs";
5
+
6
+ const { Client } = pg;
7
+
8
+ const connect = async (connectionString, clientFactory) => {
9
+ const client = clientFactory
10
+ ? await clientFactory(connectionString)
11
+ : new Client({
12
+ connectionString,
13
+ application_name: "rehearsal-external-source-verification",
14
+ });
15
+ if (typeof client.connect === "function") await client.connect();
16
+ return client;
17
+ };
18
+
19
+ const columnKey = (schema, relation, column) =>
20
+ `${schema}\0${relation}\0${column}`;
21
+
22
+ const expectedReadableColumns = (plan) =>
23
+ new Set([
24
+ ...plan.policy.relations.flatMap((relation) =>
25
+ relation.columns.map((column) =>
26
+ columnKey(plan.policy.exportSchema, relation.view, column),
27
+ ),
28
+ ),
29
+ ...[
30
+ plan.policy.migrationLedger.versionColumn,
31
+ plan.policy.migrationLedger.nameColumn,
32
+ plan.policy.migrationLedger.statementsColumn,
33
+ ].map((column) =>
34
+ columnKey(
35
+ plan.policy.migrationLedger.schema,
36
+ plan.policy.migrationLedger.table,
37
+ column,
38
+ ),
39
+ ),
40
+ ]);
41
+
42
+ const count = (result, label) => {
43
+ const value = Number(result.rows[0]?.count);
44
+ if (!Number.isSafeInteger(value) || value < 0) {
45
+ throw new Error(`External source reader ${label} check was inconclusive.`);
46
+ }
47
+ return value;
48
+ };
49
+
50
+ export const verifyExternalPostgresqlReader = async ({
51
+ plan,
52
+ connectionString,
53
+ clientFactory,
54
+ now = new Date(),
55
+ }) => {
56
+ if (plan.policy.reader.mode !== "external") {
57
+ throw new Error("External reader verification requires external mode.");
58
+ }
59
+ const client = await connect(connectionString, clientFactory);
60
+ let transactionOpen = false;
61
+ try {
62
+ await client.query("begin transaction read only");
63
+ transactionOpen = true;
64
+ const roleResult = await client.query(
65
+ `select rolname as role, rolcanlogin, rolvaliduntil,
66
+ rolsuper, rolcreaterole, rolcreatedb, rolreplication, rolbypassrls
67
+ from pg_roles where rolname = current_user`,
68
+ );
69
+ const role = roleResult.rows[0];
70
+ if (!role || role.role !== plan.policy.reader.role || !role.rolcanlogin) {
71
+ throw new Error(
72
+ "External source credential does not authenticate as the reviewed login role.",
73
+ );
74
+ }
75
+ if (
76
+ role.rolsuper ||
77
+ role.rolcreaterole ||
78
+ role.rolcreatedb ||
79
+ role.rolreplication ||
80
+ role.rolbypassrls
81
+ ) {
82
+ throw new Error("External source reader has privileged role attributes.");
83
+ }
84
+ const expiresAt = new Date(role.rolvaliduntil);
85
+ const maximumExpiration =
86
+ now.valueOf() + plan.policy.reader.maximumValidForMinutes * 60_000;
87
+ if (
88
+ !Number.isFinite(expiresAt.valueOf()) ||
89
+ expiresAt.valueOf() <= now.valueOf() ||
90
+ expiresAt.valueOf() > maximumExpiration
91
+ ) {
92
+ throw new Error(
93
+ "External source reader must have a future database-enforced expiration within the reviewed limit.",
94
+ );
95
+ }
96
+
97
+ const memberships = await client.query(
98
+ `select count(*)::integer as count
99
+ from pg_auth_members membership
100
+ join pg_roles member on member.oid = membership.member
101
+ where member.rolname = current_user`,
102
+ );
103
+ if (count(memberships, "role membership") !== 0) {
104
+ throw new Error(
105
+ "External source reader belongs to another database role.",
106
+ );
107
+ }
108
+
109
+ const databaseWrites = await client.query(
110
+ `select count(*)::integer as count
111
+ where has_database_privilege(current_user, current_database(), 'CREATE')`,
112
+ );
113
+ const schemaWrites = await client.query(
114
+ `select count(*)::integer as count
115
+ from pg_namespace
116
+ where nspname not like 'pg_temp_%'
117
+ and has_schema_privilege(current_user, oid, 'CREATE')`,
118
+ );
119
+ const relationWrites = await client.query(
120
+ `select count(*)::integer as count
121
+ from pg_class relation
122
+ join pg_namespace namespace on namespace.oid = relation.relnamespace
123
+ where namespace.nspname not in ('pg_catalog', 'information_schema')
124
+ and relation.relkind in ('r', 'p', 'v', 'm', 'f')
125
+ and (has_table_privilege(current_user, relation.oid, 'INSERT')
126
+ or has_table_privilege(current_user, relation.oid, 'UPDATE')
127
+ or has_table_privilege(current_user, relation.oid, 'DELETE')
128
+ or has_table_privilege(current_user, relation.oid, 'TRUNCATE')
129
+ or has_table_privilege(current_user, relation.oid, 'REFERENCES')
130
+ or has_table_privilege(current_user, relation.oid, 'TRIGGER'))`,
131
+ );
132
+ const columnWrites = await client.query(
133
+ `select count(*)::integer as count
134
+ from pg_class relation
135
+ join pg_namespace namespace on namespace.oid = relation.relnamespace
136
+ join pg_attribute attribute on attribute.attrelid = relation.oid
137
+ where namespace.nspname not in ('pg_catalog', 'information_schema')
138
+ and relation.relkind in ('r', 'p', 'v', 'm', 'f')
139
+ and attribute.attnum > 0 and not attribute.attisdropped
140
+ and (has_column_privilege(current_user, relation.oid, attribute.attnum, 'INSERT')
141
+ or has_column_privilege(current_user, relation.oid, attribute.attnum, 'UPDATE')
142
+ or has_column_privilege(current_user, relation.oid, attribute.attnum, 'REFERENCES'))`,
143
+ );
144
+ const sequenceAccess = await client.query(
145
+ `select count(*)::integer as count
146
+ from pg_class sequence
147
+ join pg_namespace namespace on namespace.oid = sequence.relnamespace
148
+ where namespace.nspname not in ('pg_catalog', 'information_schema')
149
+ and sequence.relkind = 'S'
150
+ and (has_sequence_privilege(current_user, sequence.oid, 'SELECT')
151
+ or has_sequence_privilege(current_user, sequence.oid, 'USAGE')
152
+ or has_sequence_privilege(current_user, sequence.oid, 'UPDATE'))`,
153
+ );
154
+ if (
155
+ [
156
+ databaseWrites,
157
+ schemaWrites,
158
+ relationWrites,
159
+ columnWrites,
160
+ sequenceAccess,
161
+ ].some((result) => count(result, "write or sequence privilege") !== 0)
162
+ ) {
163
+ throw new Error(
164
+ "External source reader has database write or sequence privileges.",
165
+ );
166
+ }
167
+
168
+ const readable = await client.query(
169
+ `select namespace.nspname as schema, relation.relname as relation,
170
+ attribute.attname as column
171
+ from pg_class relation
172
+ join pg_namespace namespace on namespace.oid = relation.relnamespace
173
+ join pg_attribute attribute on attribute.attrelid = relation.oid
174
+ where namespace.nspname not in ('pg_catalog', 'information_schema')
175
+ and namespace.nspname not like 'pg_toast%'
176
+ and relation.relkind in ('r', 'p', 'v', 'm', 'f')
177
+ and attribute.attnum > 0 and not attribute.attisdropped
178
+ and has_column_privilege(current_user, relation.oid, attribute.attnum, 'SELECT')
179
+ order by namespace.nspname, relation.relname, attribute.attnum`,
180
+ );
181
+ const expected = expectedReadableColumns(plan);
182
+ const actual = new Set(
183
+ readable.rows.map((row) =>
184
+ columnKey(row.schema, row.relation, row.column),
185
+ ),
186
+ );
187
+ if (
188
+ actual.size !== expected.size ||
189
+ [...actual].some((entry) => !expected.has(entry))
190
+ ) {
191
+ throw new Error(
192
+ "External source reader's readable columns do not exactly match the reviewed export surface.",
193
+ );
194
+ }
195
+
196
+ for (const relation of plan.policy.relations) {
197
+ const view = await client.query(
198
+ `select pg_get_viewdef(relation.oid, false) as definition,
199
+ relation.reloptions,
200
+ owner.rolname as owner
201
+ from pg_class relation
202
+ join pg_namespace namespace on namespace.oid = relation.relnamespace
203
+ join pg_roles owner on owner.oid = relation.relowner
204
+ where namespace.nspname = $1 and relation.relname = $2
205
+ and relation.relkind = 'v'`,
206
+ [plan.policy.exportSchema, relation.view],
207
+ );
208
+ const actual = view.rows[0];
209
+ if (!actual) {
210
+ throw new Error("An externally reviewed export view is missing.");
211
+ }
212
+ if (
213
+ !Array.isArray(actual.reloptions) ||
214
+ !actual.reloptions.includes("security_barrier=true")
215
+ ) {
216
+ throw new Error(
217
+ "External export views must enable PostgreSQL security_barrier.",
218
+ );
219
+ }
220
+ if (actual.owner === plan.policy.reader.role) {
221
+ throw new Error("External source reader must not own an export view.");
222
+ }
223
+ if (
224
+ externalViewDefinitionFingerprint(actual.definition) !==
225
+ relation.viewDefinitionSha256
226
+ ) {
227
+ throw new Error(
228
+ "External export view definition does not match its reviewed fingerprint.",
229
+ );
230
+ }
231
+ }
232
+
233
+ const securityDefiners = await client.query(
234
+ `select count(*)::integer as count
235
+ from pg_proc procedure
236
+ join pg_namespace namespace on namespace.oid = procedure.pronamespace
237
+ where namespace.nspname not in ('pg_catalog', 'information_schema')
238
+ and procedure.prosecdef
239
+ and has_function_privilege(current_user, procedure.oid, 'EXECUTE')`,
240
+ );
241
+ if (count(securityDefiners, "security-definer function") !== 0) {
242
+ throw new Error(
243
+ "External source reader can execute a non-system security-definer function.",
244
+ );
245
+ }
246
+ await client.query("rollback");
247
+ transactionOpen = false;
248
+ return Object.freeze({ expiresAt: expiresAt.toISOString() });
249
+ } catch (error) {
250
+ if (transactionOpen) await client.query("rollback").catch(() => undefined);
251
+ throw error;
252
+ } finally {
253
+ await client.end?.();
254
+ }
255
+ };
@@ -6,10 +6,10 @@
6
6
  import { createHash, randomBytes } from "node:crypto";
7
7
  import {
8
8
  chmod,
9
+ link,
9
10
  mkdir,
10
11
  open,
11
12
  readFile,
12
- rename,
13
13
  rm,
14
14
  writeFile,
15
15
  } from "node:fs/promises";
@@ -18,8 +18,10 @@ import pg from "pg";
18
18
  import {
19
19
  assertSourceAccessConfirmation,
20
20
  createSourceRetirementPlan,
21
+ sourceAccessPolicyFingerprint,
21
22
  sourceTargetFingerprint,
22
23
  } from "./access.mjs";
24
+ import { verifyExternalPostgresqlReader } from "./external_reader.mjs";
23
25
 
24
26
  const { Client } = pg;
25
27
  const IDENTIFIER = /^[a-z][a-z0-9_]{0,62}$/u;
@@ -180,6 +182,100 @@ const runDenyChecks = async (client, plan) => {
180
182
  }
181
183
  };
182
184
 
185
+ const assertApprovedOwnerScopes = (plan, environment) => {
186
+ for (const relation of plan.policy.relations) {
187
+ if (relation.rowScope.kind !== "approved-owner") continue;
188
+ const value = environment[relation.rowScope.valueEnvironmentVariable];
189
+ if (
190
+ !value ||
191
+ createHash("sha256").update(value).digest("hex") !==
192
+ plan.review.relations.find((entry) => entry.view === relation.view)
193
+ ?.rowScope.valueSha256
194
+ ) {
195
+ throw new Error(
196
+ "Approved owner scope changed after the reviewed source-access preview.",
197
+ );
198
+ }
199
+ }
200
+ };
201
+
202
+ const buildSourceAccessReceipt = ({ plan, expiresAt }) => ({
203
+ receiptVersion: 1,
204
+ accessMode: plan.policy.reader.mode,
205
+ policyFingerprint: sourceAccessPolicyFingerprint(plan.policy),
206
+ targetFingerprint: plan.policy.targetFingerprint,
207
+ planDigest: plan.digest,
208
+ exportSchema: plan.policy.exportSchema,
209
+ views: plan.policy.relations.map((relation) => relation.view).sort(),
210
+ relations: plan.policy.relations.map((relation) => ({
211
+ ...relation.source,
212
+ columns: relation.columns,
213
+ })),
214
+ migrationLedger: plan.policy.migrationLedger,
215
+ readerRole: plan.policy.reader.role,
216
+ ...(plan.policy.reader.mode === "managed"
217
+ ? { ownerRole: plan.policy.reader.ownerRole }
218
+ : {}),
219
+ expiresAt,
220
+ });
221
+
222
+ const activateExternalPostgresqlSourceAccess = async ({
223
+ plan,
224
+ projectRoot,
225
+ environment,
226
+ clientFactory,
227
+ now,
228
+ }) => {
229
+ const connectionString =
230
+ environment[plan.policy.reader.connectionEnvironmentVariable];
231
+ if (!connectionString) {
232
+ throw new Error(
233
+ `Missing ${plan.policy.reader.connectionEnvironmentVariable}.`,
234
+ );
235
+ }
236
+ assertTarget(plan, connectionString);
237
+ assertApprovedOwnerScopes(plan, environment);
238
+ const verified = await verifyExternalPostgresqlReader({
239
+ plan,
240
+ connectionString,
241
+ clientFactory,
242
+ now,
243
+ });
244
+ const credentialPath = ensureOwnedPath(
245
+ projectRoot,
246
+ plan.policy.reader.credentialFile,
247
+ );
248
+ const receiptPath = ensureOwnedPath(
249
+ projectRoot,
250
+ ".rehearsal/source-access-receipt.json",
251
+ );
252
+ const temporaryCredentialPath = `${credentialPath}.building-${process.pid}`;
253
+ const receipt = buildSourceAccessReceipt({
254
+ plan,
255
+ expiresAt: verified.expiresAt,
256
+ });
257
+ let receiptWritten = false;
258
+ try {
259
+ await writeExclusiveSecret(
260
+ temporaryCredentialPath,
261
+ `REHEARSAL_SOURCE_DATABASE_URL=${connectionString}\n`,
262
+ );
263
+ await writeFile(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`, {
264
+ flag: "wx",
265
+ mode: 0o600,
266
+ });
267
+ receiptWritten = true;
268
+ await chmod(receiptPath, 0o600);
269
+ await link(temporaryCredentialPath, credentialPath);
270
+ await rm(temporaryCredentialPath, { force: true });
271
+ } catch (error) {
272
+ await rm(temporaryCredentialPath, { force: true });
273
+ if (receiptWritten) await rm(receiptPath, { force: true });
274
+ throw error;
275
+ }
276
+ return { receipt, credentialPath, receiptPath };
277
+ };
278
+
183
279
  export const applyPostgresqlSourceAccess = async ({
184
280
  plan,
185
281
  confirmation,
@@ -189,26 +285,22 @@ export const applyPostgresqlSourceAccess = async ({
189
285
  now = new Date(),
190
286
  }) => {
191
287
  assertSourceAccessConfirmation(plan, confirmation);
288
+ if (plan.policy.reader.mode === "external") {
289
+ return activateExternalPostgresqlSourceAccess({
290
+ plan,
291
+ projectRoot,
292
+ environment,
293
+ clientFactory,
294
+ now,
295
+ });
296
+ }
192
297
  const administratorConnectionString =
193
298
  environment[plan.policy.administratorEnvironmentVariable];
194
299
  if (!administratorConnectionString) {
195
300
  throw new Error(`Missing ${plan.policy.administratorEnvironmentVariable}.`);
196
301
  }
197
302
  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
- }
303
+ assertApprovedOwnerScopes(plan, environment);
212
304
  const password = randomBytes(32).toString("base64url");
213
305
  const expiresAt = new Date(
214
306
  now.valueOf() + plan.policy.reader.validForMinutes * 60_000,
@@ -227,13 +319,28 @@ export const applyPostgresqlSourceAccess = async ({
227
319
  plan.policy.reader.role,
228
320
  password,
229
321
  );
230
- await writeExclusiveSecret(
231
- temporaryCredentialPath,
232
- `REHEARSAL_SOURCE_DATABASE_URL=${readerUrl}\n`,
233
- );
234
- const client = await connection(administratorConnectionString, clientFactory);
235
- let committed = false;
322
+ let receiptReserved = false;
323
+ let credentialLinked = false;
236
324
  try {
325
+ await writeExclusiveSecret(
326
+ temporaryCredentialPath,
327
+ `REHEARSAL_SOURCE_DATABASE_URL=${readerUrl}\n`,
328
+ );
329
+ const receiptReservation = await open(receiptPath, "wx", 0o600);
330
+ receiptReserved = true;
331
+ await receiptReservation.close();
332
+ await link(temporaryCredentialPath, credentialPath);
333
+ credentialLinked = true;
334
+ await rm(temporaryCredentialPath, { force: true });
335
+ } catch (error) {
336
+ await rm(temporaryCredentialPath, { force: true });
337
+ if (credentialLinked) await rm(credentialPath, { force: true });
338
+ if (receiptReserved) await rm(receiptPath, { force: true });
339
+ throw error;
340
+ }
341
+ let client;
342
+ try {
343
+ client = await connection(administratorConnectionString, clientFactory);
237
344
  await client.query("begin");
238
345
  await client.query(
239
346
  "select pg_advisory_xact_lock(hashtext('rehearsal-source-access'))",
@@ -318,32 +425,19 @@ export const applyPostgresqlSourceAccess = async ({
318
425
  );
319
426
  await runDenyChecks(client, plan);
320
427
  await client.query("commit");
321
- committed = true;
322
428
  } catch (error) {
323
- await client.query("rollback").catch(() => undefined);
429
+ await client?.query("rollback").catch(() => undefined);
430
+ await Promise.all([
431
+ rm(credentialPath, { force: true }),
432
+ rm(receiptPath, { force: true }),
433
+ ]);
324
434
  throw error;
325
435
  } finally {
326
- await client.end?.();
327
- if (!committed) await rm(temporaryCredentialPath, { force: true });
436
+ await client?.end?.();
328
437
  }
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
- };
438
+ const receipt = buildSourceAccessReceipt({ plan, expiresAt });
345
439
  await writeFile(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`, {
346
- flag: "wx",
440
+ flag: "w",
347
441
  mode: 0o600,
348
442
  });
349
443
  await chmod(receiptPath, 0o600);
@@ -380,6 +474,13 @@ export const retirePostgresqlSourceAccess = async ({
380
474
  `Source retirement confirmation does not match. Expected ${plan.digest}.`,
381
475
  );
382
476
  }
477
+ if (plan.review.accessMode === "external") {
478
+ await Promise.all([
479
+ rm(ensureOwnedPath(projectRoot, credentialFile), { force: true }),
480
+ rm(plan.receiptPath, { force: true }),
481
+ ]);
482
+ return { retired: true, providerResourcesPreserved: true };
483
+ }
383
484
  const administratorConnectionString =
384
485
  environment[administratorEnvironmentVariable];
385
486
  if (!administratorConnectionString)