@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
@@ -0,0 +1,368 @@
1
+ /** Verify an externally provisioned PostgreSQL reader without source writes. */
2
+
3
+ import pg from "pg";
4
+ import { RehearsalError } from "../shared/diagnostics.mjs";
5
+ import { externalViewDefinitionFingerprint } from "./access.mjs";
6
+
7
+ const { Client } = pg;
8
+
9
+ const connect = async (connectionString, clientFactory) => {
10
+ const client = clientFactory
11
+ ? await clientFactory(connectionString)
12
+ : new Client({
13
+ connectionString,
14
+ application_name: "rehearsal-external-source-verification",
15
+ });
16
+ if (typeof client.connect === "function") await client.connect();
17
+ return client;
18
+ };
19
+
20
+ const columnKey = (schema, relation, column) =>
21
+ `${schema}\0${relation}\0${column}`;
22
+
23
+ const expectedReadableColumns = (plan) =>
24
+ new Set([
25
+ ...plan.policy.relations.flatMap((relation) =>
26
+ relation.columns.map((column) =>
27
+ columnKey(plan.policy.exportSchema, relation.view, column),
28
+ ),
29
+ ),
30
+ ...[
31
+ plan.policy.migrationLedger.versionColumn,
32
+ plan.policy.migrationLedger.nameColumn,
33
+ plan.policy.migrationLedger.statementsColumn,
34
+ ].map((column) =>
35
+ columnKey(
36
+ plan.policy.migrationLedger.schema,
37
+ plan.policy.migrationLedger.table,
38
+ column,
39
+ ),
40
+ ),
41
+ ]);
42
+
43
+ const count = (result, label) => {
44
+ const value = Number(result.rows[0]?.count);
45
+ if (!Number.isSafeInteger(value) || value < 0) {
46
+ throw new Error(`External source reader ${label} check was inconclusive.`);
47
+ }
48
+ return value;
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
+
64
+ export const verifyExternalPostgresqlReader = async ({
65
+ plan,
66
+ connectionString,
67
+ clientFactory,
68
+ now = new Date(),
69
+ }) => {
70
+ if (plan.policy.reader.mode !== "external") {
71
+ throw new Error("External reader verification requires external mode.");
72
+ }
73
+ const client = await connect(connectionString, clientFactory);
74
+ let transactionOpen = false;
75
+ try {
76
+ await client.query("begin transaction read only");
77
+ transactionOpen = true;
78
+ const roleResult = await client.query(
79
+ `select rolname as role, rolcanlogin, rolvaliduntil,
80
+ rolsuper, rolcreaterole, rolcreatedb, rolreplication, rolbypassrls
81
+ from pg_roles where rolname = current_user`,
82
+ );
83
+ const role = roleResult.rows[0];
84
+ if (!role || role.role !== plan.policy.reader.role || !role.rolcanlogin) {
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
+ });
91
+ }
92
+ if (
93
+ role.rolsuper ||
94
+ role.rolcreaterole ||
95
+ role.rolcreatedb ||
96
+ role.rolreplication ||
97
+ role.rolbypassrls
98
+ ) {
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
+ });
104
+ }
105
+ const expiresAt = new Date(role.rolvaliduntil);
106
+ const maximumExpiration =
107
+ now.valueOf() + plan.policy.reader.maximumValidForMinutes * 60_000;
108
+ if (
109
+ !Number.isFinite(expiresAt.valueOf()) ||
110
+ expiresAt.valueOf() <= now.valueOf() ||
111
+ expiresAt.valueOf() > maximumExpiration
112
+ ) {
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
+ });
119
+ }
120
+
121
+ const memberships = await client.query(
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
134
+ from pg_auth_members membership
135
+ join pg_roles member on member.oid = membership.member
136
+ join pg_roles granted on granted.oid = membership.roleid
137
+ where member.rolname = current_user
138
+ order by granted.rolname`,
139
+ );
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],
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
+ }
201
+ }
202
+
203
+ const databaseWrites = await client.query(
204
+ `select count(*)::integer as count
205
+ where has_database_privilege(current_user, current_database(), 'CREATE')`,
206
+ );
207
+ const schemaWrites = await client.query(
208
+ `select count(*)::integer as count
209
+ from pg_namespace
210
+ where nspname not like 'pg_temp_%'
211
+ and has_schema_privilege(current_user, oid, 'CREATE')`,
212
+ );
213
+ const relationWrites = await client.query(
214
+ `select count(*)::integer as count
215
+ from pg_class relation
216
+ join pg_namespace namespace on namespace.oid = relation.relnamespace
217
+ where namespace.nspname not in ('pg_catalog', 'information_schema')
218
+ and relation.relkind in ('r', 'p', 'v', 'm', 'f')
219
+ and (has_table_privilege(current_user, relation.oid, 'INSERT')
220
+ or has_table_privilege(current_user, relation.oid, 'UPDATE')
221
+ or has_table_privilege(current_user, relation.oid, 'DELETE')
222
+ or has_table_privilege(current_user, relation.oid, 'TRUNCATE')
223
+ or has_table_privilege(current_user, relation.oid, 'REFERENCES')
224
+ or has_table_privilege(current_user, relation.oid, 'TRIGGER'))`,
225
+ );
226
+ const columnWrites = await client.query(
227
+ `select count(*)::integer as count
228
+ from pg_class relation
229
+ join pg_namespace namespace on namespace.oid = relation.relnamespace
230
+ join pg_attribute attribute on attribute.attrelid = relation.oid
231
+ where namespace.nspname not in ('pg_catalog', 'information_schema')
232
+ and relation.relkind in ('r', 'p', 'v', 'm', 'f')
233
+ and attribute.attnum > 0 and not attribute.attisdropped
234
+ and (has_column_privilege(current_user, relation.oid, attribute.attnum, 'INSERT')
235
+ or has_column_privilege(current_user, relation.oid, attribute.attnum, 'UPDATE')
236
+ or has_column_privilege(current_user, relation.oid, attribute.attnum, 'REFERENCES'))`,
237
+ );
238
+ const sequenceAccess = await client.query(
239
+ `select count(*)::integer as count
240
+ from pg_class sequence
241
+ join pg_namespace namespace on namespace.oid = sequence.relnamespace
242
+ where namespace.nspname not in ('pg_catalog', 'information_schema')
243
+ and sequence.relkind = 'S'
244
+ and (has_sequence_privilege(current_user, sequence.oid, 'SELECT')
245
+ or has_sequence_privilege(current_user, sequence.oid, 'USAGE')
246
+ or has_sequence_privilege(current_user, sequence.oid, 'UPDATE'))`,
247
+ );
248
+ if (
249
+ [
250
+ databaseWrites,
251
+ schemaWrites,
252
+ relationWrites,
253
+ columnWrites,
254
+ sequenceAccess,
255
+ ].some((result) => count(result, "write or sequence privilege") !== 0)
256
+ ) {
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
+ });
264
+ }
265
+
266
+ const readable = await client.query(
267
+ `select namespace.nspname as schema, relation.relname as relation,
268
+ attribute.attname as column
269
+ from pg_class relation
270
+ join pg_namespace namespace on namespace.oid = relation.relnamespace
271
+ join pg_attribute attribute on attribute.attrelid = relation.oid
272
+ where namespace.nspname not in ('pg_catalog', 'information_schema')
273
+ and namespace.nspname not like 'pg_toast%'
274
+ and relation.relkind in ('r', 'p', 'v', 'm', 'f')
275
+ and attribute.attnum > 0 and not attribute.attisdropped
276
+ and has_column_privilege(current_user, relation.oid, attribute.attnum, 'SELECT')
277
+ order by namespace.nspname, relation.relname, attribute.attnum`,
278
+ );
279
+ const expected = expectedReadableColumns(plan);
280
+ const actual = new Set(
281
+ readable.rows.map((row) =>
282
+ columnKey(row.schema, row.relation, row.column),
283
+ ),
284
+ );
285
+ if (
286
+ actual.size !== expected.size ||
287
+ [...actual].some((entry) => !expected.has(entry))
288
+ ) {
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
+ });
295
+ }
296
+
297
+ for (const relation of plan.policy.relations) {
298
+ const view = await client.query(
299
+ `select pg_get_viewdef(relation.oid, false) as definition,
300
+ relation.reloptions,
301
+ owner.rolname as owner
302
+ from pg_class relation
303
+ join pg_namespace namespace on namespace.oid = relation.relnamespace
304
+ join pg_roles owner on owner.oid = relation.relowner
305
+ where namespace.nspname = $1 and relation.relname = $2
306
+ and relation.relkind = 'v'`,
307
+ [plan.policy.exportSchema, relation.view],
308
+ );
309
+ const actual = view.rows[0];
310
+ if (!actual) {
311
+ throw new Error("An externally reviewed export view is missing.");
312
+ }
313
+ if (
314
+ !Array.isArray(actual.reloptions) ||
315
+ !actual.reloptions.includes("security_barrier=true")
316
+ ) {
317
+ throw new Error(
318
+ "External export views must enable PostgreSQL security_barrier.",
319
+ );
320
+ }
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
+ });
332
+ }
333
+ if (
334
+ externalViewDefinitionFingerprint(actual.definition) !==
335
+ relation.viewDefinitionSha256
336
+ ) {
337
+ throw new Error(
338
+ "External export view definition does not match its reviewed fingerprint.",
339
+ );
340
+ }
341
+ }
342
+
343
+ const securityDefiners = await client.query(
344
+ `select count(*)::integer as count
345
+ from pg_proc procedure
346
+ join pg_namespace namespace on namespace.oid = procedure.pronamespace
347
+ where namespace.nspname not in ('pg_catalog', 'information_schema')
348
+ and procedure.prosecdef
349
+ and has_function_privilege(current_user, procedure.oid, 'EXECUTE')`,
350
+ );
351
+ if (count(securityDefiners, "security-definer function") !== 0) {
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
+ });
358
+ }
359
+ await client.query("rollback");
360
+ transactionOpen = false;
361
+ return Object.freeze({ expiresAt: expiresAt.toISOString() });
362
+ } catch (error) {
363
+ if (transactionOpen) await client.query("rollback").catch(() => undefined);
364
+ throw error;
365
+ } finally {
366
+ await client.end?.();
367
+ }
368
+ };