@rehearsal-db/core 0.1.0-beta.13 → 0.1.0-beta.15
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 +93 -0
- package/README.md +4 -1
- package/dist/package.json +100 -0
- package/dist/scripts/install/create_root_config.d.mts +3 -0
- package/dist/scripts/install/create_root_config.mjs +13 -0
- package/dist/scripts/runtime/manage_database.d.mts +6 -0
- package/{scripts → dist/scripts}/runtime/manage_database.mjs +6 -8
- package/dist/src/application/session.d.mts +96 -0
- package/dist/src/application/session.mjs +380 -0
- package/dist/src/baseline/artifact.d.mts +119 -0
- package/dist/src/baseline/artifact.mjs +522 -0
- package/dist/src/baseline/builder.d.mts +19 -0
- package/dist/src/baseline/builder.mjs +142 -0
- package/dist/src/baseline/input_discovery.d.mts +13 -0
- package/dist/src/baseline/input_discovery.mjs +149 -0
- package/dist/src/baseline/policy_review.d.mts +56 -0
- package/dist/src/baseline/policy_review.mjs +73 -0
- package/dist/src/baseline/preparation.d.mts +63 -0
- package/dist/src/baseline/preparation.mjs +295 -0
- package/dist/src/baseline/privacy_engine.d.mts +162 -0
- package/dist/src/baseline/privacy_engine.mjs +1162 -0
- package/dist/src/baseline/privacy_structured_json.d.mts +66 -0
- package/dist/src/baseline/privacy_structured_json.mjs +464 -0
- package/dist/src/baseline/sanitization_policy.d.mts +69 -0
- package/dist/src/baseline/sanitization_policy.mjs +227 -0
- package/dist/src/baseline/schema_snapshot.d.mts +19 -0
- package/dist/src/baseline/schema_snapshot.mjs +79 -0
- package/dist/src/cli/arguments.d.mts +43 -0
- package/dist/src/cli/arguments.mjs +135 -0
- package/dist/src/cli/guided.d.mts +51 -0
- package/dist/src/cli/guided.mjs +713 -0
- package/dist/src/cli/init_command.d.mts +21 -0
- package/dist/src/cli/init_command.mjs +90 -0
- package/dist/src/cli/project_commands.d.mts +157 -0
- package/dist/src/cli/project_commands.mjs +183 -0
- package/dist/src/cli/rehearsal.d.mts +7 -0
- package/dist/src/cli/rehearsal.mjs +706 -0
- package/dist/src/cli/renderers.d.mts +299 -0
- package/dist/src/cli/renderers.mjs +554 -0
- package/dist/src/cli/runtime_commands.d.mts +265 -0
- package/dist/src/cli/runtime_commands.mjs +599 -0
- package/dist/src/cli/source_commands.d.mts +432 -0
- package/dist/src/cli/source_commands.mjs +299 -0
- package/dist/src/cli/terminal.d.mts +59 -0
- package/dist/src/cli/terminal.mjs +230 -0
- package/dist/src/identity/claim.d.mts +144 -0
- package/dist/src/identity/claim.mjs +947 -0
- package/dist/src/identity/provider_configuration.d.mts +37 -0
- package/dist/src/identity/provider_configuration.mjs +132 -0
- package/dist/src/identity/storage.d.mts +21 -0
- package/dist/src/identity/storage.mjs +122 -0
- package/dist/src/index.d.mts +4 -0
- package/dist/src/index.mjs +3 -0
- package/dist/src/project/configuration.d.mts +181 -0
- package/dist/src/project/configuration.mjs +958 -0
- package/dist/src/project/install_scaffold.d.mts +42 -0
- package/dist/src/project/install_scaffold.mjs +263 -0
- package/dist/src/project/setup.d.mts +87 -0
- package/dist/src/project/setup.mjs +414 -0
- package/dist/src/project/support_report.d.mts +88 -0
- package/dist/src/project/support_report.mjs +83 -0
- package/dist/src/runtime/cleanup.d.mts +85 -0
- package/dist/src/runtime/cleanup.mjs +311 -0
- package/dist/src/runtime/migration_history.d.mts +51 -0
- package/dist/src/runtime/migration_history.mjs +170 -0
- package/dist/src/runtime/plan.d.mts +134 -0
- package/dist/src/runtime/plan.mjs +636 -0
- package/dist/src/runtime/policy.d.mts +66 -0
- package/dist/src/runtime/policy.mjs +369 -0
- package/dist/src/runtime/restore.d.mts +22 -0
- package/dist/src/runtime/restore.mjs +276 -0
- package/dist/src/runtime/service_environment.d.mts +11 -0
- package/dist/src/runtime/service_environment.mjs +58 -0
- package/dist/src/runtime/topology.d.mts +51 -0
- package/dist/src/runtime/topology.mjs +128 -0
- package/dist/src/shared/diagnostics.d.mts +97 -0
- package/dist/src/shared/diagnostics.mjs +170 -0
- package/dist/src/shared/human_output.d.mts +2 -0
- package/{src → dist/src}/shared/human_output.mjs +1 -3
- package/dist/src/shared/node_runtime.d.mts +14 -0
- package/dist/src/shared/node_runtime.mjs +25 -0
- package/dist/src/shared/operation_guard.d.mts +22 -0
- package/dist/src/shared/operation_guard.mjs +150 -0
- package/dist/src/shared/process_environment.d.mts +15 -0
- package/dist/src/shared/process_environment.mjs +50 -0
- package/dist/src/source/access.d.mts +165 -0
- package/dist/src/source/access.mjs +602 -0
- package/dist/src/source/asset_transfer.d.mts +27 -0
- package/dist/src/source/asset_transfer.mjs +202 -0
- package/dist/src/source/baseline.d.mts +66 -0
- package/dist/src/source/baseline.mjs +372 -0
- package/dist/src/source/external_reader.d.mts +42 -0
- package/dist/src/source/external_reader.mjs +278 -0
- package/dist/src/source/postgresql_access.d.mts +86 -0
- package/dist/src/source/postgresql_access.mjs +454 -0
- package/dist/src/source/privacy_paths.d.mts +10 -0
- package/dist/src/source/privacy_paths.mjs +27 -0
- package/dist/src/targets/driver.d.mts +2 -0
- package/dist/src/targets/driver.mjs +13 -0
- package/dist/src/targets/postgresql.d.mts +5 -0
- package/dist/src/targets/postgresql.mjs +641 -0
- package/dist/src/targets/supabase.d.mts +8 -0
- package/dist/src/targets/supabase.mjs +659 -0
- package/dist/src/targets/supabase_environment.d.mts +49 -0
- package/dist/src/targets/supabase_environment.mjs +162 -0
- package/dist/src/targets/target.d.mts +33 -0
- package/dist/src/targets/target.mjs +39 -0
- package/docs/README.md +1 -0
- package/docs/architecture.md +17 -9
- package/docs/configuration.md +49 -8
- package/docs/getting-started.md +28 -0
- package/docs/index.md +1 -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/docs/typescript.md +87 -0
- package/package.json +35 -29
- package/scripts/install/create_root_config.mjs +0 -36
- package/src/README.md +0 -17
- package/src/application/session.mjs +0 -435
- package/src/baseline/artifact.mjs +0 -684
- package/src/baseline/builder.mjs +0 -164
- package/src/baseline/input_discovery.mjs +0 -155
- package/src/baseline/policy_review.mjs +0 -92
- package/src/baseline/preparation.mjs +0 -323
- package/src/baseline/privacy_engine.mjs +0 -952
- package/src/baseline/sanitization_policy.mjs +0 -299
- package/src/baseline/schema_snapshot.mjs +0 -113
- package/src/cli/arguments.mjs +0 -126
- package/src/cli/guided.mjs +0 -814
- package/src/cli/init_command.mjs +0 -94
- package/src/cli/rehearsal.mjs +0 -970
- package/src/cli/renderers.mjs +0 -626
- package/src/cli/runtime_commands.mjs +0 -707
- package/src/cli/source_commands.mjs +0 -347
- package/src/cli/terminal.mjs +0 -275
- package/src/identity/claim.mjs +0 -1163
- package/src/identity/provider_configuration.mjs +0 -177
- package/src/identity/storage.mjs +0 -165
- package/src/project/configuration.mjs +0 -1337
- package/src/project/install_scaffold.mjs +0 -291
- package/src/project/setup.mjs +0 -466
- package/src/project/support_report.mjs +0 -105
- package/src/runtime/cleanup.mjs +0 -381
- package/src/runtime/migration_history.mjs +0 -220
- package/src/runtime/plan.mjs +0 -722
- package/src/runtime/policy.mjs +0 -438
- package/src/runtime/restore.mjs +0 -337
- package/src/runtime/service_environment.mjs +0 -82
- package/src/runtime/topology.mjs +0 -177
- package/src/shared/diagnostics.mjs +0 -207
- package/src/shared/node_runtime.mjs +0 -31
- package/src/shared/operation_guard.mjs +0 -162
- package/src/shared/process_environment.mjs +0 -68
- package/src/source/access.mjs +0 -753
- package/src/source/asset_transfer.mjs +0 -207
- package/src/source/baseline.mjs +0 -482
- package/src/source/external_reader.mjs +0 -255
- package/src/source/postgresql_access.mjs +0 -581
- package/src/source/privacy_paths.mjs +0 -38
- package/src/targets/postgresql.mjs +0 -808
- package/src/targets/supabase.mjs +0 -840
- package/src/targets/supabase_environment.mjs +0 -229
- package/src/targets/target.mjs +0 -66
- /package/{src/project → types}/configuration.d.mts +0 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/** Verify an externally provisioned PostgreSQL reader without source writes. */
|
|
2
|
+
import pg from "pg";
|
|
3
|
+
import { RehearsalError } from "../shared/diagnostics.mjs";
|
|
4
|
+
import { externalViewDefinitionFingerprint } from "./access.mjs";
|
|
5
|
+
const { Client } = pg;
|
|
6
|
+
const connect = async (connectionString, clientFactory) => {
|
|
7
|
+
const client = clientFactory
|
|
8
|
+
? await clientFactory(connectionString)
|
|
9
|
+
: new Client({
|
|
10
|
+
connectionString,
|
|
11
|
+
application_name: "rehearsal-external-source-verification",
|
|
12
|
+
});
|
|
13
|
+
if (typeof client.connect === "function")
|
|
14
|
+
await client.connect();
|
|
15
|
+
return client;
|
|
16
|
+
};
|
|
17
|
+
const columnKey = (schema, relation, column) => `${schema}\0${relation}\0${column}`;
|
|
18
|
+
const expectedReadableColumns = (plan) => new Set([
|
|
19
|
+
...plan.policy.relations.flatMap((relation) => relation.columns.map((column) => columnKey(plan.policy.exportSchema, relation.view, column))),
|
|
20
|
+
...[
|
|
21
|
+
plan.policy.migrationLedger.versionColumn,
|
|
22
|
+
plan.policy.migrationLedger.nameColumn,
|
|
23
|
+
plan.policy.migrationLedger.statementsColumn,
|
|
24
|
+
].map((column) => columnKey(plan.policy.migrationLedger.schema, plan.policy.migrationLedger.table, column)),
|
|
25
|
+
]);
|
|
26
|
+
const count = (result, label) => {
|
|
27
|
+
const value = Number(result.rows[0]?.count);
|
|
28
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
29
|
+
throw new Error(`External source reader ${label} check was inconclusive.`);
|
|
30
|
+
}
|
|
31
|
+
return value;
|
|
32
|
+
};
|
|
33
|
+
const sourceAuthorizationRefusal = ({ message, expected, actual, }) => new RehearsalError({
|
|
34
|
+
category: "unsafe_environment",
|
|
35
|
+
code: "SOURCE_AUTHORIZATION_REFUSED",
|
|
36
|
+
message,
|
|
37
|
+
expected,
|
|
38
|
+
actual,
|
|
39
|
+
refused: "The external source credential was not activated or saved.",
|
|
40
|
+
suggestions: [
|
|
41
|
+
"Review sourcePolicy.reader and the database role grants, then preview source plan again.",
|
|
42
|
+
],
|
|
43
|
+
});
|
|
44
|
+
export const verifyExternalPostgresqlReader = async ({ plan, connectionString, clientFactory, now = new Date(), }) => {
|
|
45
|
+
if (plan.policy.reader.mode !== "external") {
|
|
46
|
+
throw new Error("External reader verification requires external mode.");
|
|
47
|
+
}
|
|
48
|
+
const client = await connect(connectionString, clientFactory);
|
|
49
|
+
let transactionOpen = false;
|
|
50
|
+
try {
|
|
51
|
+
await client.query("begin transaction read only");
|
|
52
|
+
transactionOpen = true;
|
|
53
|
+
const roleResult = await client.query(`select rolname as role, rolcanlogin, rolvaliduntil,
|
|
54
|
+
rolsuper, rolcreaterole, rolcreatedb, rolreplication, rolbypassrls
|
|
55
|
+
from pg_roles where rolname = current_user`);
|
|
56
|
+
const role = roleResult.rows[0];
|
|
57
|
+
if (!role || role.role !== plan.policy.reader.role || !role.rolcanlogin) {
|
|
58
|
+
throw sourceAuthorizationRefusal({
|
|
59
|
+
message: "External source credential does not authenticate as the reviewed login role.",
|
|
60
|
+
expected: "the reviewed login role",
|
|
61
|
+
actual: "a missing, different, or non-login role",
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
if (role.rolsuper ||
|
|
65
|
+
role.rolcreaterole ||
|
|
66
|
+
role.rolcreatedb ||
|
|
67
|
+
role.rolreplication ||
|
|
68
|
+
role.rolbypassrls) {
|
|
69
|
+
throw sourceAuthorizationRefusal({
|
|
70
|
+
message: "External source reader has privileged role attributes.",
|
|
71
|
+
expected: "a non-privileged login role",
|
|
72
|
+
actual: "one or more privileged role attributes",
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
const expiresAt = new Date(String(role.rolvaliduntil));
|
|
76
|
+
const maximumExpiration = now.valueOf() + plan.policy.reader.maximumValidForMinutes * 60_000;
|
|
77
|
+
if (!Number.isFinite(expiresAt.valueOf()) ||
|
|
78
|
+
expiresAt.valueOf() <= now.valueOf() ||
|
|
79
|
+
expiresAt.valueOf() > maximumExpiration) {
|
|
80
|
+
throw sourceAuthorizationRefusal({
|
|
81
|
+
message: "External source reader must have a future database-enforced expiration within the reviewed limit.",
|
|
82
|
+
expected: `a future expiration within ${plan.policy.reader.maximumValidForMinutes} minutes`,
|
|
83
|
+
actual: "an absent, expired, or overly long expiration",
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
const memberships = await client.query(`select granted.rolname as role, granted.rolcanlogin,
|
|
87
|
+
granted.rolsuper, granted.rolcreaterole, granted.rolcreatedb,
|
|
88
|
+
granted.rolreplication, granted.rolbypassrls,
|
|
89
|
+
membership.admin_option,
|
|
90
|
+
coalesce(
|
|
91
|
+
(to_jsonb(membership) ->> 'inherit_option')::boolean,
|
|
92
|
+
member.rolinherit
|
|
93
|
+
) as inherit_option,
|
|
94
|
+
coalesce(
|
|
95
|
+
(to_jsonb(membership) ->> 'set_option')::boolean,
|
|
96
|
+
true
|
|
97
|
+
) as set_option
|
|
98
|
+
from pg_auth_members membership
|
|
99
|
+
join pg_roles member on member.oid = membership.member
|
|
100
|
+
join pg_roles granted on granted.oid = membership.roleid
|
|
101
|
+
where member.rolname = current_user
|
|
102
|
+
order by granted.rolname`);
|
|
103
|
+
const expectedMemberships = [
|
|
104
|
+
...plan.policy.reader.allowedMemberships,
|
|
105
|
+
].sort();
|
|
106
|
+
const actualMemberships = memberships.rows
|
|
107
|
+
.map((membership) => String(membership.role))
|
|
108
|
+
.sort();
|
|
109
|
+
if (actualMemberships.length !== expectedMemberships.length ||
|
|
110
|
+
actualMemberships.some((membership, index) => membership !== expectedMemberships[index])) {
|
|
111
|
+
throw sourceAuthorizationRefusal({
|
|
112
|
+
message: "External source reader role memberships do not exactly match the reviewed allowlist.",
|
|
113
|
+
expected: expectedMemberships,
|
|
114
|
+
actual: actualMemberships,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
for (const membership of memberships.rows) {
|
|
118
|
+
if (membership.rolcanlogin ||
|
|
119
|
+
membership.rolsuper ||
|
|
120
|
+
membership.rolcreaterole ||
|
|
121
|
+
membership.rolcreatedb ||
|
|
122
|
+
membership.rolreplication ||
|
|
123
|
+
membership.rolbypassrls ||
|
|
124
|
+
membership.admin_option ||
|
|
125
|
+
membership.inherit_option !== true ||
|
|
126
|
+
membership.set_option !== false) {
|
|
127
|
+
throw sourceAuthorizationRefusal({
|
|
128
|
+
message: "External source reader has an unsafe reviewed role membership.",
|
|
129
|
+
expected: "a non-login, non-privileged group with inheritance enabled and role switching and delegation disabled",
|
|
130
|
+
actual: `unsafe membership options for ${String(membership.role)}`,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (expectedMemberships.length > 0) {
|
|
135
|
+
const nestedMemberships = await client.query(`select count(*)::integer as count
|
|
136
|
+
from pg_auth_members direct_membership
|
|
137
|
+
join pg_roles direct_role
|
|
138
|
+
on direct_role.oid = direct_membership.roleid
|
|
139
|
+
join pg_auth_members nested_membership
|
|
140
|
+
on nested_membership.member = direct_membership.roleid
|
|
141
|
+
join pg_roles member on member.oid = direct_membership.member
|
|
142
|
+
where member.rolname = current_user
|
|
143
|
+
and direct_role.rolname = any($1::name[])`, [expectedMemberships]);
|
|
144
|
+
if (count(nestedMemberships, "nested role membership") !== 0) {
|
|
145
|
+
throw sourceAuthorizationRefusal({
|
|
146
|
+
message: "An allowed external source group inherits another database role.",
|
|
147
|
+
expected: "no nested role memberships",
|
|
148
|
+
actual: "one or more nested memberships",
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const databaseWrites = await client.query(`select count(*)::integer as count
|
|
153
|
+
where has_database_privilege(current_user, current_database(), 'CREATE')`);
|
|
154
|
+
const schemaWrites = await client.query(`select count(*)::integer as count
|
|
155
|
+
from pg_namespace
|
|
156
|
+
where nspname not like 'pg_temp_%'
|
|
157
|
+
and has_schema_privilege(current_user, oid, 'CREATE')`);
|
|
158
|
+
const relationWrites = await client.query(`select count(*)::integer as count
|
|
159
|
+
from pg_class relation
|
|
160
|
+
join pg_namespace namespace on namespace.oid = relation.relnamespace
|
|
161
|
+
where namespace.nspname not in ('pg_catalog', 'information_schema')
|
|
162
|
+
and relation.relkind in ('r', 'p', 'v', 'm', 'f')
|
|
163
|
+
and (has_table_privilege(current_user, relation.oid, 'INSERT')
|
|
164
|
+
or has_table_privilege(current_user, relation.oid, 'UPDATE')
|
|
165
|
+
or has_table_privilege(current_user, relation.oid, 'DELETE')
|
|
166
|
+
or has_table_privilege(current_user, relation.oid, 'TRUNCATE')
|
|
167
|
+
or has_table_privilege(current_user, relation.oid, 'REFERENCES')
|
|
168
|
+
or has_table_privilege(current_user, relation.oid, 'TRIGGER'))`);
|
|
169
|
+
const columnWrites = await client.query(`select count(*)::integer as count
|
|
170
|
+
from pg_class relation
|
|
171
|
+
join pg_namespace namespace on namespace.oid = relation.relnamespace
|
|
172
|
+
join pg_attribute attribute on attribute.attrelid = relation.oid
|
|
173
|
+
where namespace.nspname not in ('pg_catalog', 'information_schema')
|
|
174
|
+
and relation.relkind in ('r', 'p', 'v', 'm', 'f')
|
|
175
|
+
and attribute.attnum > 0 and not attribute.attisdropped
|
|
176
|
+
and (has_column_privilege(current_user, relation.oid, attribute.attnum, 'INSERT')
|
|
177
|
+
or has_column_privilege(current_user, relation.oid, attribute.attnum, 'UPDATE')
|
|
178
|
+
or has_column_privilege(current_user, relation.oid, attribute.attnum, 'REFERENCES'))`);
|
|
179
|
+
const sequenceAccess = await client.query(`select count(*)::integer as count
|
|
180
|
+
from pg_class sequence
|
|
181
|
+
join pg_namespace namespace on namespace.oid = sequence.relnamespace
|
|
182
|
+
where namespace.nspname not in ('pg_catalog', 'information_schema')
|
|
183
|
+
and sequence.relkind = 'S'
|
|
184
|
+
and (has_sequence_privilege(current_user, sequence.oid, 'SELECT')
|
|
185
|
+
or has_sequence_privilege(current_user, sequence.oid, 'USAGE')
|
|
186
|
+
or has_sequence_privilege(current_user, sequence.oid, 'UPDATE'))`);
|
|
187
|
+
if ([
|
|
188
|
+
databaseWrites,
|
|
189
|
+
schemaWrites,
|
|
190
|
+
relationWrites,
|
|
191
|
+
columnWrites,
|
|
192
|
+
sequenceAccess,
|
|
193
|
+
].some((result) => count(result, "write or sequence privilege") !== 0)) {
|
|
194
|
+
throw sourceAuthorizationRefusal({
|
|
195
|
+
message: "External source reader has database write or sequence privileges.",
|
|
196
|
+
expected: "no database, schema, relation, column, or sequence write access",
|
|
197
|
+
actual: "one or more disallowed effective privileges",
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
const readable = await client.query(`select namespace.nspname as schema, relation.relname as relation,
|
|
201
|
+
attribute.attname as column
|
|
202
|
+
from pg_class relation
|
|
203
|
+
join pg_namespace namespace on namespace.oid = relation.relnamespace
|
|
204
|
+
join pg_attribute attribute on attribute.attrelid = relation.oid
|
|
205
|
+
where namespace.nspname not in ('pg_catalog', 'information_schema')
|
|
206
|
+
and namespace.nspname not like 'pg_toast%'
|
|
207
|
+
and relation.relkind in ('r', 'p', 'v', 'm', 'f')
|
|
208
|
+
and attribute.attnum > 0 and not attribute.attisdropped
|
|
209
|
+
and has_column_privilege(current_user, relation.oid, attribute.attnum, 'SELECT')
|
|
210
|
+
order by namespace.nspname, relation.relname, attribute.attnum`);
|
|
211
|
+
const expected = expectedReadableColumns(plan);
|
|
212
|
+
const actual = new Set(readable.rows.map((row) => columnKey(String(row.schema), String(row.relation), String(row.column))));
|
|
213
|
+
if (actual.size !== expected.size ||
|
|
214
|
+
[...actual].some((entry) => !expected.has(entry))) {
|
|
215
|
+
throw sourceAuthorizationRefusal({
|
|
216
|
+
message: "External source reader's readable columns do not exactly match the reviewed export surface.",
|
|
217
|
+
expected: `${expected.size} reviewed readable columns`,
|
|
218
|
+
actual: `${actual.size} effective readable columns`,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
for (const relation of plan.policy.relations) {
|
|
222
|
+
const view = await client.query(`select pg_get_viewdef(relation.oid, false) as definition,
|
|
223
|
+
relation.reloptions,
|
|
224
|
+
owner.rolname as owner
|
|
225
|
+
from pg_class relation
|
|
226
|
+
join pg_namespace namespace on namespace.oid = relation.relnamespace
|
|
227
|
+
join pg_roles owner on owner.oid = relation.relowner
|
|
228
|
+
where namespace.nspname = $1 and relation.relname = $2
|
|
229
|
+
and relation.relkind = 'v'`, [plan.policy.exportSchema, relation.view]);
|
|
230
|
+
const actual = view.rows[0];
|
|
231
|
+
if (!actual) {
|
|
232
|
+
throw new Error("An externally reviewed export view is missing.");
|
|
233
|
+
}
|
|
234
|
+
if (!Array.isArray(actual.reloptions) ||
|
|
235
|
+
!actual.reloptions.includes("security_barrier=true")) {
|
|
236
|
+
throw new Error("External export views must enable PostgreSQL security_barrier.");
|
|
237
|
+
}
|
|
238
|
+
if ([
|
|
239
|
+
plan.policy.reader.role,
|
|
240
|
+
...plan.policy.reader.allowedMemberships,
|
|
241
|
+
].includes(String(actual.owner))) {
|
|
242
|
+
throw sourceAuthorizationRefusal({
|
|
243
|
+
message: "External source reader roles must not own an export view.",
|
|
244
|
+
expected: "a separate non-reader view owner",
|
|
245
|
+
actual: "the login or an allowed membership owns a reviewed view",
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
if (externalViewDefinitionFingerprint(String(actual.definition)) !==
|
|
249
|
+
relation.viewDefinitionSha256) {
|
|
250
|
+
throw new Error("External export view definition does not match its reviewed fingerprint.");
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
const securityDefiners = await client.query(`select count(*)::integer as count
|
|
254
|
+
from pg_proc procedure
|
|
255
|
+
join pg_namespace namespace on namespace.oid = procedure.pronamespace
|
|
256
|
+
where namespace.nspname not in ('pg_catalog', 'information_schema')
|
|
257
|
+
and procedure.prosecdef
|
|
258
|
+
and has_function_privilege(current_user, procedure.oid, 'EXECUTE')`);
|
|
259
|
+
if (count(securityDefiners, "security-definer function") !== 0) {
|
|
260
|
+
throw sourceAuthorizationRefusal({
|
|
261
|
+
message: "External source reader can execute a non-system security-definer function.",
|
|
262
|
+
expected: "no executable non-system security-definer functions",
|
|
263
|
+
actual: "one or more executable security-definer functions",
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
await client.query("rollback");
|
|
267
|
+
transactionOpen = false;
|
|
268
|
+
return Object.freeze({ expiresAt: expiresAt.toISOString() });
|
|
269
|
+
}
|
|
270
|
+
catch (error) {
|
|
271
|
+
if (transactionOpen)
|
|
272
|
+
await client.query("rollback").catch(() => undefined);
|
|
273
|
+
throw error;
|
|
274
|
+
}
|
|
275
|
+
finally {
|
|
276
|
+
await client.end?.();
|
|
277
|
+
}
|
|
278
|
+
};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Apply and retire an exact PostgreSQL source-access plan. This module is used
|
|
3
|
+
* only by explicit source commands; ordinary runtime commands must not import it.
|
|
4
|
+
*/
|
|
5
|
+
import type { SourceAccessPlan, SourceRetirementPlan } from "./access.mjs";
|
|
6
|
+
interface QueryResultLike {
|
|
7
|
+
readonly rows: Record<string, unknown>[];
|
|
8
|
+
}
|
|
9
|
+
interface SourceClient {
|
|
10
|
+
connect?(): Promise<void> | void;
|
|
11
|
+
query(query: string, values?: readonly unknown[]): Promise<QueryResultLike>;
|
|
12
|
+
end?(): Promise<void> | void;
|
|
13
|
+
}
|
|
14
|
+
type SourceClientFactory = (connectionString: string) => SourceClient | Promise<SourceClient>;
|
|
15
|
+
export interface SourceAccessReceipt {
|
|
16
|
+
readonly receiptVersion: 1;
|
|
17
|
+
readonly accessMode: "managed" | "external";
|
|
18
|
+
readonly policyFingerprint: string;
|
|
19
|
+
readonly targetFingerprint: string;
|
|
20
|
+
readonly planDigest: string;
|
|
21
|
+
readonly exportSchema: string;
|
|
22
|
+
readonly views: readonly string[];
|
|
23
|
+
readonly relations: readonly Readonly<{
|
|
24
|
+
schema: string;
|
|
25
|
+
table: string;
|
|
26
|
+
columns: readonly string[];
|
|
27
|
+
}>[];
|
|
28
|
+
readonly migrationLedger: Readonly<{
|
|
29
|
+
schema: string;
|
|
30
|
+
table: string;
|
|
31
|
+
versionColumn: string;
|
|
32
|
+
nameColumn: string;
|
|
33
|
+
statementsColumn: string;
|
|
34
|
+
}>;
|
|
35
|
+
readonly readerRole: string;
|
|
36
|
+
readonly ownerRole?: string;
|
|
37
|
+
readonly expiresAt: string;
|
|
38
|
+
}
|
|
39
|
+
export interface RetirementExecutionPlan extends SourceRetirementPlan {
|
|
40
|
+
readonly receipt: SourceAccessReceipt;
|
|
41
|
+
readonly receiptPath: string;
|
|
42
|
+
}
|
|
43
|
+
export declare const resolvePostgresqlSourceStatePaths: ({ projectRoot, artifactRoot, credentialFile, }: {
|
|
44
|
+
projectRoot?: string;
|
|
45
|
+
artifactRoot?: string;
|
|
46
|
+
credentialFile: string;
|
|
47
|
+
}) => Readonly<{
|
|
48
|
+
stateRoot: string;
|
|
49
|
+
credentialPath: string;
|
|
50
|
+
receiptPath: string;
|
|
51
|
+
}>;
|
|
52
|
+
export declare const applyPostgresqlSourceAccess: ({ plan, confirmation, projectRoot, artifactRoot, environment, clientFactory, now, }: {
|
|
53
|
+
plan: SourceAccessPlan;
|
|
54
|
+
confirmation: string;
|
|
55
|
+
projectRoot?: string;
|
|
56
|
+
artifactRoot?: string;
|
|
57
|
+
environment?: NodeJS.ProcessEnv;
|
|
58
|
+
clientFactory?: SourceClientFactory;
|
|
59
|
+
now?: Date;
|
|
60
|
+
}) => Promise<{
|
|
61
|
+
receipt: SourceAccessReceipt;
|
|
62
|
+
credentialPath: string;
|
|
63
|
+
receiptPath: string;
|
|
64
|
+
}>;
|
|
65
|
+
export declare const planPostgresqlSourceAccessRetirement: ({ projectRoot, artifactRoot, targetFingerprint, }: {
|
|
66
|
+
projectRoot?: string;
|
|
67
|
+
artifactRoot?: string;
|
|
68
|
+
targetFingerprint: string;
|
|
69
|
+
}) => Promise<RetirementExecutionPlan>;
|
|
70
|
+
export declare const retirePostgresqlSourceAccess: ({ plan, confirmation, administratorEnvironmentVariable, projectRoot, artifactRoot, environment, credentialFile, clientFactory, }: {
|
|
71
|
+
plan: RetirementExecutionPlan;
|
|
72
|
+
confirmation: string;
|
|
73
|
+
administratorEnvironmentVariable?: string;
|
|
74
|
+
projectRoot?: string;
|
|
75
|
+
artifactRoot?: string;
|
|
76
|
+
environment?: NodeJS.ProcessEnv;
|
|
77
|
+
credentialFile: string;
|
|
78
|
+
clientFactory?: SourceClientFactory;
|
|
79
|
+
}) => Promise<{
|
|
80
|
+
retired: boolean;
|
|
81
|
+
providerResourcesPreserved: boolean;
|
|
82
|
+
} | {
|
|
83
|
+
providerResourcesPreserved?: never;
|
|
84
|
+
retired: boolean;
|
|
85
|
+
}>;
|
|
86
|
+
export {};
|