@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.
- package/CHANGELOG.md +76 -0
- package/README.md +3 -1
- package/docs/configuration.md +40 -5
- package/docs/getting-started.md +28 -0
- package/docs/production-source.md +43 -7
- package/docs/releasing.md +5 -3
- package/docs/roadmap.md +3 -2
- package/docs/sanitization.md +415 -14
- package/docs/standalone-workflow.md +12 -6
- package/docs/troubleshooting.md +5 -1
- package/docs/tutorial.md +5 -0
- package/package.json +1 -1
- package/scripts/install/create_root_config.mjs +5 -22
- package/src/baseline/privacy_engine.mjs +660 -213
- package/src/baseline/privacy_structured_json.mjs +554 -0
- package/src/cli/rehearsal.mjs +3 -1
- package/src/cli/renderers.mjs +12 -0
- package/src/cli/source_commands.mjs +11 -3
- package/src/identity/claim.mjs +23 -21
- package/src/project/install_scaffold.mjs +35 -1
- package/src/project/setup.mjs +9 -1
- package/src/source/access.mjs +50 -5
- package/src/source/asset_transfer.mjs +139 -87
- package/src/source/external_reader.mjs +136 -23
- package/src/source/postgresql_access.mjs +80 -23
|
@@ -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
|
|
72
|
-
|
|
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
|
|
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
|
|
93
|
-
|
|
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
|
|
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
|
-
|
|
136
|
+
join pg_roles granted on granted.oid = membership.roleid
|
|
137
|
+
where member.rolname = current_user
|
|
138
|
+
order by granted.rolname`,
|
|
102
139
|
);
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|
164
|
-
|
|
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
|
|
192
|
-
|
|
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 (
|
|
221
|
-
|
|
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
|
|
243
|
-
|
|
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 =
|
|
356
|
+
const { credentialPath, receiptPath } = resolvePostgresqlSourceStatePaths({
|
|
309
357
|
projectRoot,
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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 =
|
|
452
|
-
projectRoot,
|
|
453
|
-
"
|
|
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(
|
|
480
|
-
rm(
|
|
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(
|
|
578
|
-
rm(
|
|
634
|
+
rm(credentialPath, { force: true }),
|
|
635
|
+
rm(receiptPath, { force: true }),
|
|
579
636
|
]);
|
|
580
637
|
return { retired: true };
|
|
581
638
|
};
|