@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.
- package/CHANGELOG.md +40 -0
- package/COMPATIBILITY.md +6 -0
- package/README.md +2 -2
- package/docs/commands.md +15 -11
- package/docs/getting-started.md +4 -3
- package/docs/production-source.md +138 -16
- package/docs/sanitization.md +73 -7
- package/docs/security-model.md +8 -0
- package/docs/standalone-workflow.md +24 -9
- package/docs/troubleshooting.md +12 -5
- package/docs/tutorial.md +2 -1
- package/package.json +2 -2
- package/src/baseline/artifact.mjs +27 -4
- package/src/baseline/privacy_engine.mjs +220 -9
- package/src/cli/arguments.mjs +3 -0
- package/src/cli/guided.mjs +8 -6
- package/src/cli/rehearsal.mjs +20 -2
- package/src/cli/renderers.mjs +17 -4
- package/src/cli/runtime_commands.mjs +92 -9
- package/src/cli/source_commands.mjs +26 -5
- package/src/project/setup.mjs +5 -23
- package/src/runtime/plan.mjs +7 -3
- package/src/runtime/restore.mjs +13 -5
- package/src/shared/diagnostics.mjs +7 -0
- package/src/shared/node_runtime.mjs +31 -0
- package/src/source/access.mjs +224 -49
- package/src/source/asset_transfer.mjs +35 -5
- package/src/source/baseline.mjs +42 -6
- package/src/source/external_reader.mjs +255 -0
- package/src/source/postgresql_access.mjs +143 -42
- package/src/source/privacy_paths.mjs +38 -0
|
@@ -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
|
|
162
|
-
contentType:
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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,
|
package/src/source/baseline.mjs
CHANGED
|
@@ -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
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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
|
-
|
|
231
|
-
|
|
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
|
|
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
|
|
327
|
-
if (!committed) await rm(temporaryCredentialPath, { force: true });
|
|
436
|
+
await client?.end?.();
|
|
328
437
|
}
|
|
329
|
-
|
|
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: "
|
|
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)
|