@rehearsal-db/core 0.1.0-beta.13 → 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.
@@ -1,6 +1,7 @@
1
1
  /** Verify an externally provisioned PostgreSQL reader without source writes. */
2
2
 
3
3
  import pg from "pg";
4
+ import { RehearsalError } from "../shared/diagnostics.mjs";
4
5
  import { externalViewDefinitionFingerprint } from "./access.mjs";
5
6
 
6
7
  const { Client } = pg;
@@ -47,6 +48,19 @@ const count = (result, label) => {
47
48
  return value;
48
49
  };
49
50
 
51
+ const sourceAuthorizationRefusal = ({ message, expected, actual }) =>
52
+ new RehearsalError({
53
+ category: "unsafe_environment",
54
+ code: "SOURCE_AUTHORIZATION_REFUSED",
55
+ message,
56
+ expected,
57
+ actual,
58
+ refused: "The external source credential was not activated or saved.",
59
+ suggestions: [
60
+ "Review sourcePolicy.reader and the database role grants, then preview source plan again.",
61
+ ],
62
+ });
63
+
50
64
  export const verifyExternalPostgresqlReader = async ({
51
65
  plan,
52
66
  connectionString,
@@ -68,9 +82,12 @@ export const verifyExternalPostgresqlReader = async ({
68
82
  );
69
83
  const role = roleResult.rows[0];
70
84
  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
- );
85
+ throw sourceAuthorizationRefusal({
86
+ message:
87
+ "External source credential does not authenticate as the reviewed login role.",
88
+ expected: "the reviewed login role",
89
+ actual: "a missing, different, or non-login role",
90
+ });
74
91
  }
75
92
  if (
76
93
  role.rolsuper ||
@@ -79,7 +96,11 @@ export const verifyExternalPostgresqlReader = async ({
79
96
  role.rolreplication ||
80
97
  role.rolbypassrls
81
98
  ) {
82
- throw new Error("External source reader has privileged role attributes.");
99
+ throw sourceAuthorizationRefusal({
100
+ message: "External source reader has privileged role attributes.",
101
+ expected: "a non-privileged login role",
102
+ actual: "one or more privileged role attributes",
103
+ });
83
104
  }
84
105
  const expiresAt = new Date(role.rolvaliduntil);
85
106
  const maximumExpiration =
@@ -89,21 +110,94 @@ export const verifyExternalPostgresqlReader = async ({
89
110
  expiresAt.valueOf() <= now.valueOf() ||
90
111
  expiresAt.valueOf() > maximumExpiration
91
112
  ) {
92
- throw new Error(
93
- "External source reader must have a future database-enforced expiration within the reviewed limit.",
94
- );
113
+ throw sourceAuthorizationRefusal({
114
+ message:
115
+ "External source reader must have a future database-enforced expiration within the reviewed limit.",
116
+ expected: `a future expiration within ${plan.policy.reader.maximumValidForMinutes} minutes`,
117
+ actual: "an absent, expired, or overly long expiration",
118
+ });
95
119
  }
96
120
 
97
121
  const memberships = await client.query(
98
- `select count(*)::integer as count
122
+ `select granted.rolname as role, granted.rolcanlogin,
123
+ granted.rolsuper, granted.rolcreaterole, granted.rolcreatedb,
124
+ granted.rolreplication, granted.rolbypassrls,
125
+ membership.admin_option,
126
+ coalesce(
127
+ (to_jsonb(membership) ->> 'inherit_option')::boolean,
128
+ member.rolinherit
129
+ ) as inherit_option,
130
+ coalesce(
131
+ (to_jsonb(membership) ->> 'set_option')::boolean,
132
+ true
133
+ ) as set_option
99
134
  from pg_auth_members membership
100
135
  join pg_roles member on member.oid = membership.member
101
- where member.rolname = current_user`,
136
+ join pg_roles granted on granted.oid = membership.roleid
137
+ where member.rolname = current_user
138
+ order by granted.rolname`,
102
139
  );
103
- if (count(memberships, "role membership") !== 0) {
104
- throw new Error(
105
- "External source reader belongs to another database role.",
140
+ const expectedMemberships = [
141
+ ...plan.policy.reader.allowedMemberships,
142
+ ].sort();
143
+ const actualMemberships = memberships.rows
144
+ .map((membership) => membership.role)
145
+ .sort();
146
+ if (
147
+ actualMemberships.length !== expectedMemberships.length ||
148
+ actualMemberships.some(
149
+ (membership, index) => membership !== expectedMemberships[index],
150
+ )
151
+ ) {
152
+ throw sourceAuthorizationRefusal({
153
+ message:
154
+ "External source reader role memberships do not exactly match the reviewed allowlist.",
155
+ expected: expectedMemberships,
156
+ actual: actualMemberships,
157
+ });
158
+ }
159
+ for (const membership of memberships.rows) {
160
+ if (
161
+ membership.rolcanlogin ||
162
+ membership.rolsuper ||
163
+ membership.rolcreaterole ||
164
+ membership.rolcreatedb ||
165
+ membership.rolreplication ||
166
+ membership.rolbypassrls ||
167
+ membership.admin_option ||
168
+ membership.inherit_option !== true ||
169
+ membership.set_option !== false
170
+ ) {
171
+ throw sourceAuthorizationRefusal({
172
+ message:
173
+ "External source reader has an unsafe reviewed role membership.",
174
+ expected:
175
+ "a non-login, non-privileged group with inheritance enabled and role switching and delegation disabled",
176
+ actual: `unsafe membership options for ${membership.role}`,
177
+ });
178
+ }
179
+ }
180
+ if (expectedMemberships.length > 0) {
181
+ const nestedMemberships = await client.query(
182
+ `select count(*)::integer as count
183
+ from pg_auth_members direct_membership
184
+ join pg_roles direct_role
185
+ on direct_role.oid = direct_membership.roleid
186
+ join pg_auth_members nested_membership
187
+ on nested_membership.member = direct_membership.roleid
188
+ join pg_roles member on member.oid = direct_membership.member
189
+ where member.rolname = current_user
190
+ and direct_role.rolname = any($1::name[])`,
191
+ [expectedMemberships],
106
192
  );
193
+ if (count(nestedMemberships, "nested role membership") !== 0) {
194
+ throw sourceAuthorizationRefusal({
195
+ message:
196
+ "An allowed external source group inherits another database role.",
197
+ expected: "no nested role memberships",
198
+ actual: "one or more nested memberships",
199
+ });
200
+ }
107
201
  }
108
202
 
109
203
  const databaseWrites = await client.query(
@@ -160,9 +254,13 @@ export const verifyExternalPostgresqlReader = async ({
160
254
  sequenceAccess,
161
255
  ].some((result) => count(result, "write or sequence privilege") !== 0)
162
256
  ) {
163
- throw new Error(
164
- "External source reader has database write or sequence privileges.",
165
- );
257
+ throw sourceAuthorizationRefusal({
258
+ message:
259
+ "External source reader has database write or sequence privileges.",
260
+ expected:
261
+ "no database, schema, relation, column, or sequence write access",
262
+ actual: "one or more disallowed effective privileges",
263
+ });
166
264
  }
167
265
 
168
266
  const readable = await client.query(
@@ -188,9 +286,12 @@ export const verifyExternalPostgresqlReader = async ({
188
286
  actual.size !== expected.size ||
189
287
  [...actual].some((entry) => !expected.has(entry))
190
288
  ) {
191
- throw new Error(
192
- "External source reader's readable columns do not exactly match the reviewed export surface.",
193
- );
289
+ throw sourceAuthorizationRefusal({
290
+ message:
291
+ "External source reader's readable columns do not exactly match the reviewed export surface.",
292
+ expected: `${expected.size} reviewed readable columns`,
293
+ actual: `${actual.size} effective readable columns`,
294
+ });
194
295
  }
195
296
 
196
297
  for (const relation of plan.policy.relations) {
@@ -217,8 +318,17 @@ export const verifyExternalPostgresqlReader = async ({
217
318
  "External export views must enable PostgreSQL security_barrier.",
218
319
  );
219
320
  }
220
- if (actual.owner === plan.policy.reader.role) {
221
- throw new Error("External source reader must not own an export view.");
321
+ if (
322
+ [
323
+ plan.policy.reader.role,
324
+ ...plan.policy.reader.allowedMemberships,
325
+ ].includes(actual.owner)
326
+ ) {
327
+ throw sourceAuthorizationRefusal({
328
+ message: "External source reader roles must not own an export view.",
329
+ expected: "a separate non-reader view owner",
330
+ actual: "the login or an allowed membership owns a reviewed view",
331
+ });
222
332
  }
223
333
  if (
224
334
  externalViewDefinitionFingerprint(actual.definition) !==
@@ -239,9 +349,12 @@ export const verifyExternalPostgresqlReader = async ({
239
349
  and has_function_privilege(current_user, procedure.oid, 'EXECUTE')`,
240
350
  );
241
351
  if (count(securityDefiners, "security-definer function") !== 0) {
242
- throw new Error(
243
- "External source reader can execute a non-system security-definer function.",
244
- );
352
+ throw sourceAuthorizationRefusal({
353
+ message:
354
+ "External source reader can execute a non-system security-definer function.",
355
+ expected: "no executable non-system security-definer functions",
356
+ actual: "one or more executable security-definer functions",
357
+ });
245
358
  }
246
359
  await client.query("rollback");
247
360
  transactionOpen = false;
@@ -7,13 +7,14 @@ import { createHash, randomBytes } from "node:crypto";
7
7
  import {
8
8
  chmod,
9
9
  link,
10
+ lstat,
10
11
  mkdir,
11
12
  open,
12
13
  readFile,
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,
@@ -43,6 +44,36 @@ const ensureOwnedPath = (projectRoot, relativePath) => {
43
44
  return path;
44
45
  };
45
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
+
46
77
  const readerConnectionString = (
47
78
  administratorConnectionString,
48
79
  role,
@@ -66,6 +97,22 @@ const writeExclusiveSecret = async (path, content) => {
66
97
  await chmod(path, 0o600);
67
98
  };
68
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
+
69
116
  const connection = async (connectionString, clientFactory) => {
70
117
  const client = clientFactory
71
118
  ? await clientFactory(connectionString)
@@ -222,6 +269,7 @@ const buildSourceAccessReceipt = ({ plan, expiresAt }) => ({
222
269
  const activateExternalPostgresqlSourceAccess = async ({
223
270
  plan,
224
271
  projectRoot,
272
+ artifactRoot,
225
273
  environment,
226
274
  clientFactory,
227
275
  now,
@@ -235,20 +283,18 @@ const activateExternalPostgresqlSourceAccess = async ({
235
283
  }
236
284
  assertTarget(plan, connectionString);
237
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);
238
292
  const verified = await verifyExternalPostgresqlReader({
239
293
  plan,
240
294
  connectionString,
241
295
  clientFactory,
242
296
  now,
243
297
  });
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
298
  const temporaryCredentialPath = `${credentialPath}.building-${process.pid}`;
253
299
  const receipt = buildSourceAccessReceipt({
254
300
  plan,
@@ -280,6 +326,7 @@ export const applyPostgresqlSourceAccess = async ({
280
326
  plan,
281
327
  confirmation,
282
328
  projectRoot = process.cwd(),
329
+ artifactRoot = join(projectRoot, ".rehearsal"),
283
330
  environment = process.env,
284
331
  clientFactory,
285
332
  now = new Date(),
@@ -289,6 +336,7 @@ export const applyPostgresqlSourceAccess = async ({
289
336
  return activateExternalPostgresqlSourceAccess({
290
337
  plan,
291
338
  projectRoot,
339
+ artifactRoot,
292
340
  environment,
293
341
  clientFactory,
294
342
  now,
@@ -305,14 +353,11 @@ export const applyPostgresqlSourceAccess = async ({
305
353
  const expiresAt = new Date(
306
354
  now.valueOf() + plan.policy.reader.validForMinutes * 60_000,
307
355
  ).toISOString();
308
- const credentialPath = ensureOwnedPath(
356
+ const { credentialPath, receiptPath } = resolvePostgresqlSourceStatePaths({
309
357
  projectRoot,
310
- plan.policy.reader.credentialFile,
311
- );
312
- const receiptPath = ensureOwnedPath(
313
- projectRoot,
314
- ".rehearsal/source-access-receipt.json",
315
- );
358
+ artifactRoot,
359
+ credentialFile: plan.policy.reader.credentialFile,
360
+ });
316
361
  const temporaryCredentialPath = `${credentialPath}.building-${process.pid}`;
317
362
  const readerUrl = readerConnectionString(
318
363
  administratorConnectionString,
@@ -446,11 +491,12 @@ export const applyPostgresqlSourceAccess = async ({
446
491
 
447
492
  export const planPostgresqlSourceAccessRetirement = async ({
448
493
  projectRoot = process.cwd(),
494
+ artifactRoot = join(projectRoot, ".rehearsal"),
449
495
  targetFingerprint,
450
496
  }) => {
451
- const receiptPath = ensureOwnedPath(
452
- projectRoot,
453
- ".rehearsal/source-access-receipt.json",
497
+ const receiptPath = join(
498
+ resolveSourceStateRoot({ projectRoot, artifactRoot }),
499
+ "source-access-receipt.json",
454
500
  );
455
501
  const receipt = JSON.parse(await readFile(receiptPath, "utf8"));
456
502
  return {
@@ -465,6 +511,7 @@ export const retirePostgresqlSourceAccess = async ({
465
511
  confirmation,
466
512
  administratorEnvironmentVariable,
467
513
  projectRoot = process.cwd(),
514
+ artifactRoot = join(projectRoot, ".rehearsal"),
468
515
  environment = process.env,
469
516
  credentialFile,
470
517
  clientFactory,
@@ -474,10 +521,20 @@ export const retirePostgresqlSourceAccess = async ({
474
521
  `Source retirement confirmation does not match. Expected ${plan.digest}.`,
475
522
  );
476
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
+ }
477
534
  if (plan.review.accessMode === "external") {
478
535
  await Promise.all([
479
- rm(ensureOwnedPath(projectRoot, credentialFile), { force: true }),
480
- rm(plan.receiptPath, { force: true }),
536
+ rm(credentialPath, { force: true }),
537
+ rm(receiptPath, { force: true }),
481
538
  ]);
482
539
  return { retired: true, providerResourcesPreserved: true };
483
540
  }
@@ -574,8 +631,8 @@ export const retirePostgresqlSourceAccess = async ({
574
631
  await client.end?.();
575
632
  }
576
633
  await Promise.all([
577
- rm(ensureOwnedPath(projectRoot, credentialFile), { force: true }),
578
- rm(plan.receiptPath, { force: true }),
634
+ rm(credentialPath, { force: true }),
635
+ rm(receiptPath, { force: true }),
579
636
  ]);
580
637
  return { retired: true };
581
638
  };