@xemahq/biome-database-nest 0.7.0 → 0.9.0
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/README.md +21 -3
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -2
- package/dist/index.js.map +1 -1
- package/dist/lib/bootstrap.d.ts +1 -0
- package/dist/lib/bootstrap.d.ts.map +1 -1
- package/dist/lib/bootstrap.js +21 -7
- package/dist/lib/bootstrap.js.map +1 -1
- package/dist/lib/config.d.ts +1 -0
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +12 -2
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/control-plane-client.d.ts +1 -0
- package/dist/lib/control-plane-client.d.ts.map +1 -1
- package/dist/lib/control-plane-client.js +10 -0
- package/dist/lib/control-plane-client.js.map +1 -1
- package/dist/lib/credential-generation.d.ts +4 -0
- package/dist/lib/credential-generation.d.ts.map +1 -0
- package/dist/lib/credential-generation.js +24 -0
- package/dist/lib/credential-generation.js.map +1 -0
- package/dist/lib/external-runtime-verifier.d.ts.map +1 -1
- package/dist/lib/external-runtime-verifier.js +722 -87
- package/dist/lib/external-runtime-verifier.js.map +1 -1
- package/dist/lib/migration-mode.d.ts +62 -0
- package/dist/lib/migration-mode.d.ts.map +1 -1
- package/dist/lib/migration-mode.js +29 -1
- package/dist/lib/migration-mode.js.map +1 -1
- package/dist/lib/nest/biome-database.module.d.ts +1 -1
- package/dist/lib/nest/biome-database.module.d.ts.map +1 -1
- package/dist/lib/nest/biome-database.module.js +3 -9
- package/dist/lib/nest/biome-database.module.js.map +1 -1
- package/package.json +1 -1
|
@@ -3,7 +3,21 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.verifyExternallyManagedRuntime = verifyExternallyManagedRuntime;
|
|
4
4
|
const node_crypto_1 = require("node:crypto");
|
|
5
5
|
const pg_1 = require("pg");
|
|
6
|
+
const credential_generation_1 = require("./credential-generation");
|
|
7
|
+
const migration_mode_1 = require("./migration-mode");
|
|
6
8
|
const LOG_PREFIX = '[biome-database]';
|
|
9
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
10
|
+
const SHA_256_PATTERN = /^[0-9a-f]{64}$/;
|
|
11
|
+
const ALL_TABLE_DML = Object.values(migration_mode_1.RuntimeTablePrivilege);
|
|
12
|
+
const ALL_TABLE_PRIVILEGES = [
|
|
13
|
+
...ALL_TABLE_DML,
|
|
14
|
+
'TRUNCATE',
|
|
15
|
+
'REFERENCES',
|
|
16
|
+
'TRIGGER',
|
|
17
|
+
];
|
|
18
|
+
const ALL_SEQUENCE_PRIVILEGES = Object.values(migration_mode_1.RuntimeSequencePrivilege);
|
|
19
|
+
const TABLE_DML_SET = new Set(ALL_TABLE_DML);
|
|
20
|
+
const SEQUENCE_PRIVILEGE_SET = new Set(ALL_SEQUENCE_PRIVILEGES);
|
|
7
21
|
function quoteIdentifier(identifier) {
|
|
8
22
|
return `"${identifier.replaceAll('"', '""')}"`;
|
|
9
23
|
}
|
|
@@ -19,60 +33,499 @@ function assertNonEmpty(value, field) {
|
|
|
19
33
|
}
|
|
20
34
|
}
|
|
21
35
|
function assertExpectation(expectation) {
|
|
22
|
-
if (expectation.tablePrivileges
|
|
36
|
+
if (!Array.isArray(expectation.tablePrivileges) ||
|
|
37
|
+
expectation.tablePrivileges.length === 0) {
|
|
23
38
|
throw new Error(`${LOG_PREFIX} externally-managed runtime access must require at least one table privilege.`);
|
|
24
39
|
}
|
|
40
|
+
if (!Array.isArray(expectation.writableRelations)) {
|
|
41
|
+
throw new Error(`${LOG_PREFIX} externally-managed runtime access must explicitly declare writableRelations.`);
|
|
42
|
+
}
|
|
43
|
+
const writableRelationKeys = new Set();
|
|
44
|
+
for (const relation of expectation.writableRelations) {
|
|
45
|
+
assertNonEmpty(relation.name, 'writable relation name');
|
|
46
|
+
const relationPrivileges = relation.privileges;
|
|
47
|
+
if (!Object.values(migration_mode_1.RuntimeWritableRelationKind).includes(relation.kind)) {
|
|
48
|
+
throw new Error(`${LOG_PREFIX} writable relation kind is invalid.`);
|
|
49
|
+
}
|
|
50
|
+
if (!Array.isArray(relationPrivileges) ||
|
|
51
|
+
!relationPrivileges.includes(migration_mode_1.RuntimeTablePrivilege.Select) ||
|
|
52
|
+
!relationPrivileges.some((privilege) => privilege !== migration_mode_1.RuntimeTablePrivilege.Select) ||
|
|
53
|
+
relationPrivileges.some((privilege) => !TABLE_DML_SET.has(privilege)) ||
|
|
54
|
+
new Set(relationPrivileges).size !== relationPrivileges.length) {
|
|
55
|
+
throw new Error(`${LOG_PREFIX} writable relation ${relation.name} must declare unique SELECT plus at least one DML privilege.`);
|
|
56
|
+
}
|
|
57
|
+
const key = `${relation.kind}\u0000${relation.name}`;
|
|
58
|
+
if (writableRelationKeys.has(key)) {
|
|
59
|
+
throw new Error(`${LOG_PREFIX} writableRelations contains duplicate ${relation.name}.`);
|
|
60
|
+
}
|
|
61
|
+
writableRelationKeys.add(key);
|
|
62
|
+
}
|
|
63
|
+
if (expectation.tablePrivileges.some((privilege) => !TABLE_DML_SET.has(privilege)) ||
|
|
64
|
+
new Set(expectation.tablePrivileges).size !==
|
|
65
|
+
expectation.tablePrivileges.length) {
|
|
66
|
+
throw new Error(`${LOG_PREFIX} runtime table privileges must be unique RuntimeTablePrivilege values.`);
|
|
67
|
+
}
|
|
68
|
+
if (!Array.isArray(expectation.sequencePrivileges) ||
|
|
69
|
+
expectation.sequencePrivileges.some((privilege) => !SEQUENCE_PRIVILEGE_SET.has(privilege)) ||
|
|
70
|
+
new Set(expectation.sequencePrivileges).size !==
|
|
71
|
+
expectation.sequencePrivileges.length) {
|
|
72
|
+
throw new Error(`${LOG_PREFIX} runtime sequence privileges must be unique RuntimeSequencePrivilege values.`);
|
|
73
|
+
}
|
|
74
|
+
const memberships = expectation.authorizedRoleMemberships;
|
|
75
|
+
if (!Array.isArray(memberships) || memberships.length !== 0) {
|
|
76
|
+
throw new Error(`${LOG_PREFIX} externally-managed runtime access forbids all role memberships; authorizedRoleMemberships must be an empty tuple.`);
|
|
77
|
+
}
|
|
78
|
+
if (!Array.isArray(expectation.targetFunctions)) {
|
|
79
|
+
throw new Error(`${LOG_PREFIX} externally-managed runtime access must explicitly declare targetFunctions.`);
|
|
80
|
+
}
|
|
81
|
+
const functionKeys = new Set();
|
|
82
|
+
for (const fn of expectation.targetFunctions) {
|
|
83
|
+
assertNonEmpty(fn.name, 'target function name');
|
|
84
|
+
if (typeof fn.identityArguments !== 'string') {
|
|
85
|
+
throw new Error(`${LOG_PREFIX} target function identityArguments must be a string.`);
|
|
86
|
+
}
|
|
87
|
+
if (!Array.isArray(fn.privileges) ||
|
|
88
|
+
fn.privileges.length !== 1 ||
|
|
89
|
+
fn.privileges[0] !== migration_mode_1.RuntimeExtensionFunctionPrivilege.Execute) {
|
|
90
|
+
throw new Error(`${LOG_PREFIX} target function ${fn.name} must declare exactly EXECUTE.`);
|
|
91
|
+
}
|
|
92
|
+
const key = `${fn.name}\u0000${fn.identityArguments}`;
|
|
93
|
+
if (functionKeys.has(key)) {
|
|
94
|
+
throw new Error(`${LOG_PREFIX} targetFunctions contains duplicate ${fn.name}.`);
|
|
95
|
+
}
|
|
96
|
+
functionKeys.add(key);
|
|
97
|
+
}
|
|
98
|
+
assertExtensionDependencies(expectation.extensionDependencies);
|
|
25
99
|
}
|
|
26
|
-
function
|
|
27
|
-
const
|
|
28
|
-
.
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
100
|
+
function extensionObjectKey(extensionName, schema, object) {
|
|
101
|
+
const identityArguments = object.kind === migration_mode_1.RuntimeExtensionObjectKind.Function
|
|
102
|
+
? object.identityArguments
|
|
103
|
+
: '';
|
|
104
|
+
return [
|
|
105
|
+
extensionName,
|
|
106
|
+
object.kind,
|
|
107
|
+
schema,
|
|
108
|
+
object.name,
|
|
109
|
+
identityArguments,
|
|
110
|
+
].join('\u0000');
|
|
111
|
+
}
|
|
112
|
+
function assertExtensionDependencies(dependencies) {
|
|
113
|
+
if (!Array.isArray(dependencies)) {
|
|
114
|
+
throw new Error(`${LOG_PREFIX} externally-managed runtime access must explicitly declare extensionDependencies.`);
|
|
115
|
+
}
|
|
116
|
+
const names = new Set();
|
|
117
|
+
const objectKeys = new Set();
|
|
118
|
+
for (const dependency of dependencies) {
|
|
119
|
+
assertNonEmpty(dependency.name, 'extension dependency name');
|
|
120
|
+
assertNonEmpty(dependency.schema, 'extension dependency schema');
|
|
121
|
+
assertNonEmpty(dependency.version, 'extension dependency version');
|
|
122
|
+
if (names.has(dependency.name)) {
|
|
123
|
+
throw new Error(`${LOG_PREFIX} extensionDependencies contains duplicate extension ${dependency.name}.`);
|
|
124
|
+
}
|
|
125
|
+
names.add(dependency.name);
|
|
126
|
+
if (!Array.isArray(dependency.objects) || dependency.objects.length === 0) {
|
|
127
|
+
throw new Error(`${LOG_PREFIX} extension dependency ${dependency.name} must declare at least one object.`);
|
|
128
|
+
}
|
|
129
|
+
for (const object of dependency.objects) {
|
|
130
|
+
assertNonEmpty(object.name, `${dependency.name} extension object name`);
|
|
131
|
+
const expectedPrivilege = (() => {
|
|
132
|
+
switch (object.kind) {
|
|
133
|
+
case migration_mode_1.RuntimeExtensionObjectKind.Type:
|
|
134
|
+
return migration_mode_1.RuntimeExtensionTypePrivilege.Usage;
|
|
135
|
+
case migration_mode_1.RuntimeExtensionObjectKind.Function:
|
|
136
|
+
if (typeof object.identityArguments !== 'string') {
|
|
137
|
+
throw new Error(`${LOG_PREFIX} ${dependency.name}.${object.name} function identityArguments must be a string.`);
|
|
138
|
+
}
|
|
139
|
+
return migration_mode_1.RuntimeExtensionFunctionPrivilege.Execute;
|
|
140
|
+
case migration_mode_1.RuntimeExtensionObjectKind.Table:
|
|
141
|
+
return migration_mode_1.RuntimeExtensionTablePrivilege.Select;
|
|
142
|
+
case migration_mode_1.RuntimeExtensionObjectKind.Sequence:
|
|
143
|
+
return migration_mode_1.RuntimeExtensionSequencePrivilege.Select;
|
|
144
|
+
default:
|
|
145
|
+
throw new Error(`${LOG_PREFIX} extension dependency ${dependency.name} contains an unsupported object kind.`);
|
|
146
|
+
}
|
|
147
|
+
})();
|
|
148
|
+
if (!Array.isArray(object.privileges) ||
|
|
149
|
+
object.privileges.length !== 1 ||
|
|
150
|
+
object.privileges[0] !== expectedPrivilege) {
|
|
151
|
+
throw new Error(`${LOG_PREFIX} extension object ${dependency.schema}.${object.name} must declare exactly ${expectedPrivilege}.`);
|
|
152
|
+
}
|
|
153
|
+
const key = extensionObjectKey(dependency.name, dependency.schema, object);
|
|
154
|
+
if (objectKeys.has(key)) {
|
|
155
|
+
throw new Error(`${LOG_PREFIX} extensionDependencies contains duplicate object ${dependency.schema}.${object.name}.`);
|
|
156
|
+
}
|
|
157
|
+
objectKeys.add(key);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
function buildTablePrivilegeQuery(crossSchema) {
|
|
162
|
+
const projections = ALL_TABLE_PRIVILEGES.map((privilege) => `CASE WHEN has_table_privilege(current_user, c.oid, '${privilege}') ` +
|
|
163
|
+
`THEN '${privilege}' END`).join(', ');
|
|
164
|
+
const publicProjections = ALL_TABLE_PRIVILEGES.map((privilege) => `CASE WHEN EXISTS (` +
|
|
165
|
+
`SELECT 1 FROM aclexplode(COALESCE(c.relacl, acldefault('r', c.relowner))) acl ` +
|
|
166
|
+
`WHERE acl.grantee = 0 AND acl.privilege_type = '${privilege}'` +
|
|
167
|
+
`) THEN '${privilege}' END`).join(', ');
|
|
32
168
|
return `
|
|
33
|
-
SELECT
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
.
|
|
44
|
-
|
|
45
|
-
|
|
169
|
+
SELECT n.nspname AS schema_name,
|
|
170
|
+
c.relname AS object_name,
|
|
171
|
+
c.relkind AS relation_kind,
|
|
172
|
+
array_remove(ARRAY[${projections}], NULL) AS privileges,
|
|
173
|
+
e.extname AS extension_name,
|
|
174
|
+
array_remove(ARRAY[${publicProjections}], NULL) AS public_privileges,
|
|
175
|
+
pg_has_role(current_user, c.relowner, 'USAGE') AS owns_object
|
|
176
|
+
FROM pg_class c
|
|
177
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
178
|
+
LEFT JOIN pg_depend d
|
|
179
|
+
ON d.classid = 'pg_class'::regclass
|
|
180
|
+
AND d.objid = c.oid
|
|
181
|
+
AND d.objsubid = 0
|
|
182
|
+
AND d.refclassid = 'pg_extension'::regclass
|
|
183
|
+
AND d.deptype = 'e'
|
|
184
|
+
LEFT JOIN pg_extension e ON e.oid = d.refobjid
|
|
185
|
+
WHERE n.nspname ${crossSchema ? '<>' : '='} $1
|
|
186
|
+
AND n.nspname NOT IN ('pg_catalog', 'information_schema')
|
|
187
|
+
AND n.nspname NOT LIKE 'pg_toast%'
|
|
188
|
+
AND n.nspname NOT LIKE 'pg_temp_%'
|
|
189
|
+
AND c.relkind IN ('r', 'p', 'v', 'm', 'f')
|
|
190
|
+
AND c.relname <> '_prisma_migrations'
|
|
191
|
+
${crossSchema
|
|
192
|
+
? `AND (${ALL_TABLE_PRIVILEGES.map((privilege) => `has_table_privilege(current_user, c.oid, '${privilege}')`).join(' OR ')})`
|
|
193
|
+
: ''}
|
|
194
|
+
ORDER BY n.nspname, c.relname
|
|
46
195
|
`;
|
|
47
196
|
}
|
|
48
|
-
function
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
.
|
|
54
|
-
`
|
|
55
|
-
`THEN '${privilege}' END`)
|
|
56
|
-
.join(', ');
|
|
197
|
+
function buildSequencePrivilegeQuery(crossSchema) {
|
|
198
|
+
const projections = ALL_SEQUENCE_PRIVILEGES.map((privilege) => `CASE WHEN has_sequence_privilege(current_user, c.oid, '${privilege}') ` +
|
|
199
|
+
`THEN '${privilege}' END`).join(', ');
|
|
200
|
+
const publicProjections = ALL_SEQUENCE_PRIVILEGES.map((privilege) => `CASE WHEN EXISTS (` +
|
|
201
|
+
`SELECT 1 FROM aclexplode(COALESCE(c.relacl, acldefault('s', c.relowner))) acl ` +
|
|
202
|
+
`WHERE acl.grantee = 0 AND acl.privilege_type = '${privilege}'` +
|
|
203
|
+
`) THEN '${privilege}' END`).join(', ');
|
|
57
204
|
return `
|
|
58
|
-
SELECT
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
205
|
+
SELECT n.nspname AS schema_name,
|
|
206
|
+
c.relname AS object_name,
|
|
207
|
+
array_remove(ARRAY[${projections}], NULL) AS privileges,
|
|
208
|
+
e.extname AS extension_name,
|
|
209
|
+
array_remove(ARRAY[${publicProjections}], NULL) AS public_privileges,
|
|
210
|
+
pg_has_role(current_user, c.relowner, 'USAGE') AS owns_object
|
|
211
|
+
FROM pg_class c
|
|
212
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
213
|
+
LEFT JOIN pg_depend d
|
|
214
|
+
ON d.classid = 'pg_class'::regclass
|
|
215
|
+
AND d.objid = c.oid
|
|
216
|
+
AND d.objsubid = 0
|
|
217
|
+
AND d.refclassid = 'pg_extension'::regclass
|
|
218
|
+
AND d.deptype = 'e'
|
|
219
|
+
LEFT JOIN pg_extension e ON e.oid = d.refobjid
|
|
220
|
+
WHERE n.nspname ${crossSchema ? '<>' : '='} $1
|
|
221
|
+
AND n.nspname NOT IN ('pg_catalog', 'information_schema')
|
|
222
|
+
AND n.nspname NOT LIKE 'pg_toast%'
|
|
223
|
+
AND n.nspname NOT LIKE 'pg_temp_%'
|
|
224
|
+
AND c.relkind = 'S'
|
|
225
|
+
${crossSchema
|
|
226
|
+
? `AND (${ALL_SEQUENCE_PRIVILEGES.map((privilege) => `has_sequence_privilege(current_user, c.oid, '${privilege}')`).join(' OR ')})`
|
|
227
|
+
: ''}
|
|
228
|
+
ORDER BY n.nspname, c.relname
|
|
69
229
|
`;
|
|
70
230
|
}
|
|
71
|
-
function
|
|
231
|
+
function formatCrossSchemaPrivileges(kind, rows) {
|
|
72
232
|
return rows
|
|
73
|
-
.map((row) => `${kind} ${row.
|
|
233
|
+
.map((row) => `${kind} ${row.schema_name}.${row.object_name} ` +
|
|
234
|
+
`[${row.privileges.join('/')}]`)
|
|
74
235
|
.join(', ');
|
|
75
236
|
}
|
|
237
|
+
function samePrivileges(observed, expected) {
|
|
238
|
+
const sortedObserved = [...observed].sort();
|
|
239
|
+
const sortedExpected = [...expected].sort();
|
|
240
|
+
return (sortedObserved.length === sortedExpected.length &&
|
|
241
|
+
sortedObserved.every((privilege, index) => privilege === sortedExpected[index]));
|
|
242
|
+
}
|
|
243
|
+
function expectedRelationPrivileges(row, tablePrivileges, writableRelations) {
|
|
244
|
+
if (row.relation_kind === 'r' || row.relation_kind === 'p') {
|
|
245
|
+
return tablePrivileges;
|
|
246
|
+
}
|
|
247
|
+
if (row.relation_kind === 'm') {
|
|
248
|
+
return [migration_mode_1.RuntimeTablePrivilege.Select];
|
|
249
|
+
}
|
|
250
|
+
const writableKind = row.relation_kind === 'v'
|
|
251
|
+
? migration_mode_1.RuntimeWritableRelationKind.View
|
|
252
|
+
: migration_mode_1.RuntimeWritableRelationKind.ForeignTable;
|
|
253
|
+
return (writableRelations.find((relation) => relation.kind === writableKind && relation.name === row.object_name)?.privileges ?? [migration_mode_1.RuntimeTablePrivilege.Select]);
|
|
254
|
+
}
|
|
255
|
+
function assertExactTargetPrivileges(kind, rows, expected, writableRelations) {
|
|
256
|
+
if (kind === 'table') {
|
|
257
|
+
const observedWritableRelations = new Set(rows.flatMap((row) => {
|
|
258
|
+
if (!('relation_kind' in row))
|
|
259
|
+
return [];
|
|
260
|
+
const writableKind = row.relation_kind === 'v'
|
|
261
|
+
? migration_mode_1.RuntimeWritableRelationKind.View
|
|
262
|
+
: row.relation_kind === 'f'
|
|
263
|
+
? migration_mode_1.RuntimeWritableRelationKind.ForeignTable
|
|
264
|
+
: null;
|
|
265
|
+
return writableKind === null
|
|
266
|
+
? []
|
|
267
|
+
: [`${writableKind}\u0000${row.object_name}`];
|
|
268
|
+
}));
|
|
269
|
+
const missingDeclarations = writableRelations.filter((relation) => !observedWritableRelations.has(`${relation.kind}\u0000${relation.name}`));
|
|
270
|
+
if (missingDeclarations.length > 0) {
|
|
271
|
+
throw new Error(`${LOG_PREFIX} writableRelations names missing target view/foreign table: ` +
|
|
272
|
+
missingDeclarations.map((relation) => relation.name).join(', '));
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
const mismatches = rows.filter((row) => {
|
|
276
|
+
const applicableExpected = kind === 'table' && 'relation_kind' in row
|
|
277
|
+
? expectedRelationPrivileges(row, expected, writableRelations)
|
|
278
|
+
: expected;
|
|
279
|
+
return (!samePrivileges(row.privileges, applicableExpected) ||
|
|
280
|
+
row.public_privileges.length > 0 ||
|
|
281
|
+
row.owns_object);
|
|
282
|
+
});
|
|
283
|
+
if (mismatches.length > 0) {
|
|
284
|
+
throw new Error(`${LOG_PREFIX} runtime ${kind} privilege contract failed: ${mismatches
|
|
285
|
+
.map((row) => {
|
|
286
|
+
const applicableExpected = kind === 'table' && 'relation_kind' in row
|
|
287
|
+
? expectedRelationPrivileges(row, expected, writableRelations)
|
|
288
|
+
: expected;
|
|
289
|
+
return (`${row.schema_name}.${row.object_name} expected [${applicableExpected.join('/')}], observed [${row.privileges.join('/')}], PUBLIC [` +
|
|
290
|
+
`${row.public_privileges.join('/')}], owns=${row.owns_object}`);
|
|
291
|
+
})
|
|
292
|
+
.join(', ')}.`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
function assertTargetTypes(rows) {
|
|
296
|
+
const violations = rows.filter((row) => !row.can_use || row.public_has_privilege || row.owns_object);
|
|
297
|
+
if (violations.length > 0) {
|
|
298
|
+
throw new Error(`${LOG_PREFIX} target type privilege contract failed: ${violations
|
|
299
|
+
.map((row) => `${row.object_name} kind=${row.type_kind} USAGE=${row.can_use} ` +
|
|
300
|
+
`PUBLIC=${row.public_has_privilege} owns=${row.owns_object}`)
|
|
301
|
+
.join(', ')}.`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
function functionIdentityKey(name, identityArguments) {
|
|
305
|
+
return `${name}\u0000${identityArguments}`;
|
|
306
|
+
}
|
|
307
|
+
function hasSafeTargetFunctionConfiguration(configuration, targetSchema) {
|
|
308
|
+
if (configuration === null || configuration.length !== 1) {
|
|
309
|
+
return false;
|
|
310
|
+
}
|
|
311
|
+
const prefix = 'search_path=';
|
|
312
|
+
const setting = configuration[0];
|
|
313
|
+
if (!setting.startsWith(prefix)) {
|
|
314
|
+
return false;
|
|
315
|
+
}
|
|
316
|
+
const path = setting
|
|
317
|
+
.slice(prefix.length)
|
|
318
|
+
.split(',')
|
|
319
|
+
.map((schema) => schema.trim());
|
|
320
|
+
return (path.length === 2 && path[0] === 'pg_catalog' && path[1] === targetSchema);
|
|
321
|
+
}
|
|
322
|
+
function assertTargetFunctions(rows, expectedFunctions, targetSchema) {
|
|
323
|
+
const ownership = rows.filter((row) => row.owns_object);
|
|
324
|
+
const publicExecute = rows.filter((row) => row.public_has_privilege);
|
|
325
|
+
const executable = rows.filter((row) => row.can_execute);
|
|
326
|
+
const expected = new Set(expectedFunctions.map((fn) => functionIdentityKey(fn.name, fn.identityArguments)));
|
|
327
|
+
const observed = new Set(executable.map((row) => functionIdentityKey(row.object_name, row.identity_arguments)));
|
|
328
|
+
const missing = [...expected].filter((key) => !observed.has(key));
|
|
329
|
+
const extra = [...observed].filter((key) => !expected.has(key));
|
|
330
|
+
const unsafe = executable.filter((row) => row.security_definer ||
|
|
331
|
+
!hasSafeTargetFunctionConfiguration(row.configuration, targetSchema));
|
|
332
|
+
if (ownership.length > 0 ||
|
|
333
|
+
publicExecute.length > 0 ||
|
|
334
|
+
missing.length > 0 ||
|
|
335
|
+
extra.length > 0 ||
|
|
336
|
+
unsafe.length > 0) {
|
|
337
|
+
throw new Error(`${LOG_PREFIX} target function privilege contract failed: ` +
|
|
338
|
+
`owned=${JSON.stringify(ownership.map((row) => functionIdentityKey(row.object_name, row.identity_arguments)))}, PUBLIC=${JSON.stringify(publicExecute.map((row) => functionIdentityKey(row.object_name, row.identity_arguments)))}, missing=${JSON.stringify(missing)}, extra=${JSON.stringify(extra)}, ` +
|
|
339
|
+
`unsafe=${JSON.stringify(unsafe.map((row) => ({
|
|
340
|
+
function: functionIdentityKey(row.object_name, row.identity_arguments),
|
|
341
|
+
securityDefiner: row.security_definer,
|
|
342
|
+
configuration: row.configuration ?? [],
|
|
343
|
+
})))}.`);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
function findDependency(dependencies, extensionName, schema) {
|
|
347
|
+
if (extensionName === null) {
|
|
348
|
+
return undefined;
|
|
349
|
+
}
|
|
350
|
+
return dependencies.find((dependency) => dependency.name === extensionName && dependency.schema === schema);
|
|
351
|
+
}
|
|
352
|
+
function expectedExtensionObjectKeys(dependencies) {
|
|
353
|
+
return new Set(dependencies.flatMap((dependency) => dependency.objects.map((object) => extensionObjectKey(dependency.name, dependency.schema, object))));
|
|
354
|
+
}
|
|
355
|
+
function assertExtensionMetadata(expected, observed) {
|
|
356
|
+
const actualByName = new Map(observed.map((row) => [row.extension_name, row]));
|
|
357
|
+
const mismatches = expected.filter((dependency) => {
|
|
358
|
+
const actual = actualByName.get(dependency.name);
|
|
359
|
+
return (actual === undefined ||
|
|
360
|
+
actual.schema_name !== dependency.schema ||
|
|
361
|
+
actual.extension_version !== dependency.version ||
|
|
362
|
+
actual.owns_extension);
|
|
363
|
+
});
|
|
364
|
+
if (observed.length !== expected.length || mismatches.length > 0) {
|
|
365
|
+
throw new Error(`${LOG_PREFIX} extension dependency mismatch: expected ${expected
|
|
366
|
+
.map((dependency) => `${dependency.name}@${dependency.version} in ${dependency.schema}`)
|
|
367
|
+
.join(', ')}, observed ${observed
|
|
368
|
+
.map((row) => `${row.extension_name}@${row.extension_version} in ${row.schema_name} owns=${row.owns_extension}`)
|
|
369
|
+
.join(', ')}.`);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
function assertExactExtensionObjects(dependencies, types, functions, tables, sequences) {
|
|
373
|
+
const expected = expectedExtensionObjectKeys(dependencies);
|
|
374
|
+
const observed = new Set();
|
|
375
|
+
const publicObjects = [];
|
|
376
|
+
const invalidMembership = [];
|
|
377
|
+
for (const row of types) {
|
|
378
|
+
const dependency = findDependency(dependencies, row.extension_name, row.schema_name);
|
|
379
|
+
const key = extensionObjectKey(row.extension_name ?? '<none>', row.schema_name, {
|
|
380
|
+
kind: migration_mode_1.RuntimeExtensionObjectKind.Type,
|
|
381
|
+
name: row.object_name,
|
|
382
|
+
privileges: [migration_mode_1.RuntimeExtensionTypePrivilege.Usage],
|
|
383
|
+
});
|
|
384
|
+
observed.add(key);
|
|
385
|
+
if (dependency === undefined) {
|
|
386
|
+
invalidMembership.push(`${row.schema_name}.${row.object_name}`);
|
|
387
|
+
}
|
|
388
|
+
if (row.owns_object) {
|
|
389
|
+
invalidMembership.push(`${row.schema_name}.${row.object_name} owned by runtime`);
|
|
390
|
+
}
|
|
391
|
+
if (row.public_has_privilege) {
|
|
392
|
+
publicObjects.push(`${row.schema_name}.${row.object_name} TYPE USAGE`);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
for (const row of functions) {
|
|
396
|
+
const dependency = findDependency(dependencies, row.extension_name, row.schema_name);
|
|
397
|
+
const key = extensionObjectKey(row.extension_name ?? '<none>', row.schema_name, {
|
|
398
|
+
kind: migration_mode_1.RuntimeExtensionObjectKind.Function,
|
|
399
|
+
name: row.object_name,
|
|
400
|
+
identityArguments: row.identity_arguments,
|
|
401
|
+
privileges: [migration_mode_1.RuntimeExtensionFunctionPrivilege.Execute],
|
|
402
|
+
});
|
|
403
|
+
observed.add(key);
|
|
404
|
+
if (dependency === undefined) {
|
|
405
|
+
invalidMembership.push(`${row.schema_name}.${row.object_name}(${row.identity_arguments})`);
|
|
406
|
+
}
|
|
407
|
+
if (row.owns_object ||
|
|
408
|
+
row.security_definer ||
|
|
409
|
+
(row.configuration !== null && row.configuration.length > 0)) {
|
|
410
|
+
invalidMembership.push(`${row.schema_name}.${row.object_name}(${row.identity_arguments}) ` +
|
|
411
|
+
`owner=${row.owns_object} securityDefiner=${row.security_definer} ` +
|
|
412
|
+
`configuration=${JSON.stringify(row.configuration ?? [])}`);
|
|
413
|
+
}
|
|
414
|
+
if (row.public_has_privilege) {
|
|
415
|
+
publicObjects.push(`${row.schema_name}.${row.object_name}(${row.identity_arguments}) EXECUTE`);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
for (const row of tables) {
|
|
419
|
+
const dependency = findDependency(dependencies, row.extension_name, row.schema_name);
|
|
420
|
+
if (dependency === undefined ||
|
|
421
|
+
!samePrivileges(row.privileges, [
|
|
422
|
+
migration_mode_1.RuntimeExtensionTablePrivilege.Select,
|
|
423
|
+
]) ||
|
|
424
|
+
row.owns_object) {
|
|
425
|
+
invalidMembership.push(`${row.schema_name}.${row.object_name}`);
|
|
426
|
+
}
|
|
427
|
+
observed.add(extensionObjectKey(row.extension_name ?? '<none>', row.schema_name, {
|
|
428
|
+
kind: migration_mode_1.RuntimeExtensionObjectKind.Table,
|
|
429
|
+
name: row.object_name,
|
|
430
|
+
privileges: [migration_mode_1.RuntimeExtensionTablePrivilege.Select],
|
|
431
|
+
}));
|
|
432
|
+
if (row.public_privileges.length > 0) {
|
|
433
|
+
publicObjects.push(`${row.schema_name}.${row.object_name} [${row.public_privileges.join('/')}]`);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
for (const row of sequences) {
|
|
437
|
+
const dependency = findDependency(dependencies, row.extension_name, row.schema_name);
|
|
438
|
+
if (dependency === undefined ||
|
|
439
|
+
!samePrivileges(row.privileges, [
|
|
440
|
+
migration_mode_1.RuntimeExtensionSequencePrivilege.Select,
|
|
441
|
+
]) ||
|
|
442
|
+
row.owns_object) {
|
|
443
|
+
invalidMembership.push(`${row.schema_name}.${row.object_name}`);
|
|
444
|
+
}
|
|
445
|
+
observed.add(extensionObjectKey(row.extension_name ?? '<none>', row.schema_name, {
|
|
446
|
+
kind: migration_mode_1.RuntimeExtensionObjectKind.Sequence,
|
|
447
|
+
name: row.object_name,
|
|
448
|
+
privileges: [migration_mode_1.RuntimeExtensionSequencePrivilege.Select],
|
|
449
|
+
}));
|
|
450
|
+
if (row.public_privileges.length > 0) {
|
|
451
|
+
publicObjects.push(`${row.schema_name}.${row.object_name} [${row.public_privileges.join('/')}]`);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
const missing = [...expected].filter((key) => !observed.has(key));
|
|
455
|
+
const extra = [...observed].filter((key) => !expected.has(key));
|
|
456
|
+
if (missing.length > 0 ||
|
|
457
|
+
extra.length > 0 ||
|
|
458
|
+
invalidMembership.length > 0 ||
|
|
459
|
+
publicObjects.length > 0) {
|
|
460
|
+
throw new Error(`${LOG_PREFIX} extension object privilege contract failed: ` +
|
|
461
|
+
`missing=${JSON.stringify(missing)}, extra=${JSON.stringify(extra)}, ` +
|
|
462
|
+
`invalidExtensionMembership=${JSON.stringify(invalidMembership)}, ` +
|
|
463
|
+
`PUBLIC=${JSON.stringify(publicObjects)}.`);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
function assertNoRoleMemberships(observedRows) {
|
|
467
|
+
if (observedRows.length > 0) {
|
|
468
|
+
throw new Error(`${LOG_PREFIX} runtime role has forbidden direct or transitive membership(s): ` +
|
|
469
|
+
observedRows
|
|
470
|
+
.map((row) => row.role_name)
|
|
471
|
+
.sort()
|
|
472
|
+
.join(', '));
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
function parseLedgerTimestamp(value, migrationName, field) {
|
|
476
|
+
const milliseconds = Date.parse(value);
|
|
477
|
+
if (!Number.isFinite(milliseconds)) {
|
|
478
|
+
throw new Error(`${LOG_PREFIX} migration ${migrationName} has invalid ${field} "${value}".`);
|
|
479
|
+
}
|
|
480
|
+
return milliseconds;
|
|
481
|
+
}
|
|
482
|
+
function validateMigrationLedger(rows) {
|
|
483
|
+
const ids = new Set();
|
|
484
|
+
const names = new Set();
|
|
485
|
+
return rows.map((row) => {
|
|
486
|
+
if (!UUID_PATTERN.test(row.id)) {
|
|
487
|
+
throw new Error(`${LOG_PREFIX} migration ledger row has invalid id "${row.id}".`);
|
|
488
|
+
}
|
|
489
|
+
assertNonEmpty(row.migration_name, 'migration name');
|
|
490
|
+
if (ids.has(row.id) || names.has(row.migration_name)) {
|
|
491
|
+
throw new Error(`${LOG_PREFIX} migration ledger contains a duplicate id or migration name: ${row.migration_name}.`);
|
|
492
|
+
}
|
|
493
|
+
ids.add(row.id);
|
|
494
|
+
names.add(row.migration_name);
|
|
495
|
+
if (!SHA_256_PATTERN.test(row.checksum)) {
|
|
496
|
+
throw new Error(`${LOG_PREFIX} migration ${row.migration_name} has an invalid SHA-256 checksum.`);
|
|
497
|
+
}
|
|
498
|
+
if (row.started_at === null) {
|
|
499
|
+
throw new Error(`${LOG_PREFIX} migration ${row.migration_name} has no started_at timestamp.`);
|
|
500
|
+
}
|
|
501
|
+
const startedAt = parseLedgerTimestamp(row.started_at, row.migration_name, 'started_at');
|
|
502
|
+
if (row.rolled_back_at !== null) {
|
|
503
|
+
throw new Error(`${LOG_PREFIX} migration ${row.migration_name} is rolled back.`);
|
|
504
|
+
}
|
|
505
|
+
if (row.finished_at === null) {
|
|
506
|
+
throw new Error(`${LOG_PREFIX} migration ${row.migration_name} is unfinished or failed.`);
|
|
507
|
+
}
|
|
508
|
+
if (row.logs !== null) {
|
|
509
|
+
throw new Error(`${LOG_PREFIX} migration ${row.migration_name} contains failure logs.`);
|
|
510
|
+
}
|
|
511
|
+
const finishedAt = parseLedgerTimestamp(row.finished_at, row.migration_name, 'finished_at');
|
|
512
|
+
if (finishedAt < startedAt) {
|
|
513
|
+
throw new Error(`${LOG_PREFIX} migration ${row.migration_name} finished before it started.`);
|
|
514
|
+
}
|
|
515
|
+
if (!Number.isInteger(row.applied_steps_count) ||
|
|
516
|
+
row.applied_steps_count <= 0) {
|
|
517
|
+
throw new Error(`${LOG_PREFIX} migration ${row.migration_name} has invalid applied_steps_count ${row.applied_steps_count}.`);
|
|
518
|
+
}
|
|
519
|
+
return {
|
|
520
|
+
id: row.id,
|
|
521
|
+
name: row.migration_name,
|
|
522
|
+
checksum: row.checksum,
|
|
523
|
+
startedAt: row.started_at,
|
|
524
|
+
finishedAt: row.finished_at,
|
|
525
|
+
appliedStepsCount: row.applied_steps_count,
|
|
526
|
+
};
|
|
527
|
+
});
|
|
528
|
+
}
|
|
76
529
|
function digestLedger(appliedMigrations) {
|
|
77
530
|
return (0, node_crypto_1.createHash)('sha256')
|
|
78
531
|
.update(JSON.stringify(appliedMigrations))
|
|
@@ -82,6 +535,8 @@ function assertTarget(target) {
|
|
|
82
535
|
assertNonEmpty(target.database, 'expected target database');
|
|
83
536
|
assertNonEmpty(target.schema, 'expected target schema');
|
|
84
537
|
assertNonEmpty(target.applicationName, 'expected application_name');
|
|
538
|
+
assertNonEmpty(target.expectedRole, 'expected runtime role');
|
|
539
|
+
(0, credential_generation_1.assertDatabaseCredentialGeneration)(target.credentialGeneration, `${LOG_PREFIX} expected credential generation`);
|
|
85
540
|
}
|
|
86
541
|
async function verifyExternallyManagedRuntime(runtimeUrl, target, access, compatibility) {
|
|
87
542
|
assertTarget(target);
|
|
@@ -98,17 +553,21 @@ async function verifyExternallyManagedRuntime(runtimeUrl, target, access, compat
|
|
|
98
553
|
SELECT current_database() AS database_name,
|
|
99
554
|
current_schema() AS schema_name,
|
|
100
555
|
current_setting('application_name') AS application_name,
|
|
101
|
-
current_user AS role_name
|
|
556
|
+
current_user AS role_name,
|
|
557
|
+
session_user AS session_role_name
|
|
102
558
|
`)).rows, 'connection identity');
|
|
103
559
|
if (identity.database_name !== target.database ||
|
|
104
560
|
identity.schema_name !== target.schema ||
|
|
105
|
-
identity.application_name !== target.applicationName
|
|
561
|
+
identity.application_name !== target.applicationName ||
|
|
562
|
+
identity.role_name !== target.expectedRole ||
|
|
563
|
+
identity.session_role_name !== target.expectedRole) {
|
|
106
564
|
throw new Error(`${LOG_PREFIX} runtime target mismatch: expected ` +
|
|
107
|
-
`${target.database}/${target.schema}/${target.applicationName}, observed ` +
|
|
108
|
-
`${identity.database_name}/${identity.schema_name ?? '<none>'}/${identity.application_name}
|
|
565
|
+
`${target.database}/${target.schema}/${target.applicationName}/${target.expectedRole}, observed ` +
|
|
566
|
+
`${identity.database_name}/${identity.schema_name ?? '<none>'}/${identity.application_name}/` +
|
|
567
|
+
`${identity.role_name} (session=${identity.session_role_name}).`);
|
|
109
568
|
}
|
|
110
569
|
const role = requireSingleRow((await client.query(`
|
|
111
|
-
SELECT rolsuper, rolcreatedb, rolcreaterole, rolbypassrls
|
|
570
|
+
SELECT rolsuper, rolcreatedb, rolcreaterole, rolbypassrls, rolreplication
|
|
112
571
|
FROM pg_roles
|
|
113
572
|
WHERE rolname = current_user
|
|
114
573
|
`)).rows, 'runtime role');
|
|
@@ -117,11 +576,20 @@ async function verifyExternallyManagedRuntime(runtimeUrl, target, access, compat
|
|
|
117
576
|
role.rolcreatedb ? 'CREATEDB' : null,
|
|
118
577
|
role.rolcreaterole ? 'CREATEROLE' : null,
|
|
119
578
|
role.rolbypassrls ? 'BYPASSRLS' : null,
|
|
579
|
+
role.rolreplication ? 'REPLICATION' : null,
|
|
120
580
|
].filter((value) => value !== null);
|
|
121
581
|
if (forbiddenRoleAttributes.length > 0) {
|
|
122
582
|
throw new Error(`${LOG_PREFIX} runtime role ${identity.role_name} has forbidden attribute(s): ` +
|
|
123
583
|
forbiddenRoleAttributes.join(', '));
|
|
124
584
|
}
|
|
585
|
+
const roleMemberships = (await client.query(`
|
|
586
|
+
SELECT rolname AS role_name
|
|
587
|
+
FROM pg_roles
|
|
588
|
+
WHERE rolname <> current_user
|
|
589
|
+
AND pg_has_role(current_user, oid, 'MEMBER')
|
|
590
|
+
ORDER BY rolname
|
|
591
|
+
`)).rows;
|
|
592
|
+
assertNoRoleMemberships(roleMemberships);
|
|
125
593
|
const databasePrivileges = requireSingleRow((await client.query(`
|
|
126
594
|
SELECT has_database_privilege(current_user, current_database(), 'CONNECT') AS can_connect,
|
|
127
595
|
has_database_privilege(current_user, current_database(), 'CREATE') AS can_create,
|
|
@@ -136,48 +604,222 @@ async function verifyExternallyManagedRuntime(runtimeUrl, target, access, compat
|
|
|
136
604
|
throw new Error(`${LOG_PREFIX} runtime role ${identity.role_name} has forbidden database ` +
|
|
137
605
|
`${databasePrivileges.owns_database ? 'ownership' : 'CREATE privilege'}.`);
|
|
138
606
|
}
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
throw new Error(`${LOG_PREFIX} runtime role ${identity.role_name} has forbidden CREATE/ownership ` +
|
|
151
|
-
`on schema ${target.schema}.`);
|
|
152
|
-
}
|
|
153
|
-
const persistentDdlSchemas = (await client.query(`
|
|
154
|
-
SELECT nspname AS schema_name
|
|
607
|
+
const persistentSchemaPrivileges = (await client.query(`
|
|
608
|
+
SELECT nspname AS schema_name,
|
|
609
|
+
has_schema_privilege(current_user, oid, 'USAGE') AS can_use,
|
|
610
|
+
has_schema_privilege(current_user, oid, 'CREATE') AS can_create,
|
|
611
|
+
pg_has_role(current_user, nspowner, 'USAGE') AS owns_schema,
|
|
612
|
+
EXISTS (
|
|
613
|
+
SELECT 1
|
|
614
|
+
FROM aclexplode(COALESCE(nspacl, acldefault('n', nspowner))) acl
|
|
615
|
+
WHERE acl.grantee = 0
|
|
616
|
+
AND acl.privilege_type = 'USAGE'
|
|
617
|
+
) AS public_can_use
|
|
155
618
|
FROM pg_namespace
|
|
156
619
|
WHERE nspname NOT IN ('pg_catalog', 'information_schema')
|
|
157
620
|
AND nspname NOT LIKE 'pg_toast%'
|
|
158
621
|
AND nspname NOT LIKE 'pg_temp_%'
|
|
159
|
-
AND (
|
|
160
|
-
has_schema_privilege(current_user, oid, 'CREATE')
|
|
161
|
-
OR pg_has_role(current_user, nspowner, 'USAGE')
|
|
162
|
-
)
|
|
163
622
|
ORDER BY nspname
|
|
164
623
|
`)).rows;
|
|
624
|
+
const targetSchemaPrivileges = requireSingleRow(persistentSchemaPrivileges.filter((row) => row.schema_name === target.schema), 'target schema privilege');
|
|
625
|
+
if (!targetSchemaPrivileges.can_use) {
|
|
626
|
+
throw new Error(`${LOG_PREFIX} runtime role ${identity.role_name} lacks USAGE on schema ${target.schema}.`);
|
|
627
|
+
}
|
|
628
|
+
if (targetSchemaPrivileges.public_can_use) {
|
|
629
|
+
throw new Error(`${LOG_PREFIX} target schema ${target.schema} grants forbidden USAGE to PUBLIC.`);
|
|
630
|
+
}
|
|
631
|
+
const persistentDdlSchemas = persistentSchemaPrivileges.filter((row) => row.can_create || row.owns_schema);
|
|
165
632
|
if (persistentDdlSchemas.length > 0) {
|
|
166
633
|
throw new Error(`${LOG_PREFIX} runtime role ${identity.role_name} has forbidden persistent ` +
|
|
167
634
|
`schema CREATE/ownership on: ${persistentDdlSchemas
|
|
168
635
|
.map((row) => row.schema_name)
|
|
169
636
|
.join(', ')}.`);
|
|
170
637
|
}
|
|
171
|
-
const
|
|
172
|
-
if (
|
|
173
|
-
throw new Error(`${LOG_PREFIX}
|
|
638
|
+
const extensionSchemas = new Set(access.extensionDependencies.map((dependency) => dependency.schema));
|
|
639
|
+
if (extensionSchemas.has(target.schema)) {
|
|
640
|
+
throw new Error(`${LOG_PREFIX} extension dependency schema must differ from target schema ${target.schema}.`);
|
|
174
641
|
}
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
642
|
+
const crossSchemaUsage = persistentSchemaPrivileges.filter((row) => row.schema_name !== target.schema &&
|
|
643
|
+
row.can_use &&
|
|
644
|
+
!extensionSchemas.has(row.schema_name));
|
|
645
|
+
if (crossSchemaUsage.length > 0) {
|
|
646
|
+
throw new Error(`${LOG_PREFIX} runtime role ${identity.role_name} has forbidden cross-schema ` +
|
|
647
|
+
`USAGE on: ${crossSchemaUsage
|
|
648
|
+
.map((row) => row.schema_name)
|
|
649
|
+
.join(', ')}.`);
|
|
650
|
+
}
|
|
651
|
+
const missingOrPublicExtensionSchemas = [...extensionSchemas].filter((schema) => {
|
|
652
|
+
const row = persistentSchemaPrivileges.find((candidate) => candidate.schema_name === schema);
|
|
653
|
+
return row === undefined || !row.can_use || row.public_can_use;
|
|
654
|
+
});
|
|
655
|
+
if (missingOrPublicExtensionSchemas.length > 0) {
|
|
656
|
+
throw new Error(`${LOG_PREFIX} extension schemas must grant direct runtime USAGE and revoke PUBLIC USAGE: ` +
|
|
657
|
+
missingOrPublicExtensionSchemas.join(', '));
|
|
658
|
+
}
|
|
659
|
+
const crossSchemaTablePrivileges = (await client.query(buildTablePrivilegeQuery(true), [
|
|
660
|
+
target.schema,
|
|
661
|
+
])).rows;
|
|
662
|
+
const crossSchemaSequencePrivileges = (await client.query(buildSequencePrivilegeQuery(true), [target.schema])).rows;
|
|
663
|
+
const undeclaredCrossSchemaTables = crossSchemaTablePrivileges.filter((row) => !access.extensionDependencies.some((dependency) => dependency.name === row.extension_name &&
|
|
664
|
+
dependency.schema === row.schema_name));
|
|
665
|
+
if (undeclaredCrossSchemaTables.length > 0) {
|
|
666
|
+
throw new Error(`${LOG_PREFIX} runtime role ${identity.role_name} has forbidden cross-schema ` +
|
|
667
|
+
`table privileges: ${formatCrossSchemaPrivileges('table', undeclaredCrossSchemaTables)}.`);
|
|
668
|
+
}
|
|
669
|
+
const undeclaredCrossSchemaSequences = crossSchemaSequencePrivileges.filter((row) => !access.extensionDependencies.some((dependency) => dependency.name === row.extension_name &&
|
|
670
|
+
dependency.schema === row.schema_name));
|
|
671
|
+
if (undeclaredCrossSchemaSequences.length > 0) {
|
|
672
|
+
throw new Error(`${LOG_PREFIX} runtime role ${identity.role_name} has forbidden cross-schema ` +
|
|
673
|
+
`sequence privileges: ${formatCrossSchemaPrivileges('sequence', undeclaredCrossSchemaSequences)}.`);
|
|
674
|
+
}
|
|
675
|
+
const targetTablePrivileges = (await client.query(buildTablePrivilegeQuery(false), [
|
|
676
|
+
target.schema,
|
|
677
|
+
])).rows;
|
|
678
|
+
assertExactTargetPrivileges('table', targetTablePrivileges, access.tablePrivileges, access.writableRelations);
|
|
679
|
+
const targetSequencePrivileges = (await client.query(buildSequencePrivilegeQuery(false), [target.schema])).rows;
|
|
680
|
+
assertExactTargetPrivileges('sequence', targetSequencePrivileges, access.sequencePrivileges, access.writableRelations);
|
|
681
|
+
const targetTypes = (await client.query(`
|
|
682
|
+
SELECT t.typname AS object_name,
|
|
683
|
+
t.typtype AS type_kind,
|
|
684
|
+
has_type_privilege(current_user, t.oid, 'USAGE') AS can_use,
|
|
685
|
+
EXISTS (
|
|
686
|
+
SELECT 1
|
|
687
|
+
FROM aclexplode(COALESCE(t.typacl, acldefault('T', t.typowner))) acl
|
|
688
|
+
WHERE acl.grantee = 0
|
|
689
|
+
AND acl.privilege_type = 'USAGE'
|
|
690
|
+
) AS public_has_privilege,
|
|
691
|
+
pg_has_role(current_user, t.typowner, 'USAGE') AS owns_object
|
|
692
|
+
FROM pg_type t
|
|
693
|
+
JOIN pg_namespace n ON n.oid = t.typnamespace
|
|
694
|
+
WHERE n.nspname = $1
|
|
695
|
+
AND t.typelem = 0
|
|
696
|
+
AND t.typtype IN ('b', 'd', 'e', 'r', 'm')
|
|
697
|
+
ORDER BY t.typname
|
|
698
|
+
`, [target.schema])).rows;
|
|
699
|
+
assertTargetTypes(targetTypes);
|
|
700
|
+
const targetFunctions = (await client.query(`
|
|
701
|
+
SELECT p.proname AS object_name,
|
|
702
|
+
pg_get_function_identity_arguments(p.oid) AS identity_arguments,
|
|
703
|
+
has_function_privilege(current_user, p.oid, 'EXECUTE') AS can_execute,
|
|
704
|
+
EXISTS (
|
|
705
|
+
SELECT 1
|
|
706
|
+
FROM aclexplode(COALESCE(p.proacl, acldefault('f', p.proowner))) acl
|
|
707
|
+
WHERE acl.grantee = 0
|
|
708
|
+
AND acl.privilege_type = 'EXECUTE'
|
|
709
|
+
) AS public_has_privilege,
|
|
710
|
+
pg_has_role(current_user, p.proowner, 'USAGE') AS owns_object,
|
|
711
|
+
p.prosecdef AS security_definer,
|
|
712
|
+
p.proconfig AS configuration
|
|
713
|
+
FROM pg_proc p
|
|
714
|
+
JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
715
|
+
WHERE n.nspname = $1
|
|
716
|
+
ORDER BY p.proname, identity_arguments
|
|
717
|
+
`, [target.schema])).rows;
|
|
718
|
+
assertTargetFunctions(targetFunctions, access.targetFunctions, target.schema);
|
|
719
|
+
if (access.extensionDependencies.length > 0) {
|
|
720
|
+
const extensionNames = access.extensionDependencies.map((dependency) => dependency.name);
|
|
721
|
+
const extensionSchemaNames = [...extensionSchemas];
|
|
722
|
+
const extensionRows = (await client.query(`
|
|
723
|
+
SELECT e.extname AS extension_name,
|
|
724
|
+
n.nspname AS schema_name,
|
|
725
|
+
e.extversion AS extension_version,
|
|
726
|
+
pg_has_role(current_user, e.extowner, 'USAGE') AS owns_extension
|
|
727
|
+
FROM pg_extension e
|
|
728
|
+
JOIN pg_namespace n ON n.oid = e.extnamespace
|
|
729
|
+
WHERE e.extname = ANY($1::text[])
|
|
730
|
+
ORDER BY e.extname
|
|
731
|
+
`, [extensionNames])).rows;
|
|
732
|
+
assertExtensionMetadata(access.extensionDependencies, extensionRows);
|
|
733
|
+
const extensionTypes = (await client.query(`
|
|
734
|
+
SELECT n.nspname AS schema_name,
|
|
735
|
+
t.typname AS object_name,
|
|
736
|
+
e.extname AS extension_name,
|
|
737
|
+
EXISTS (
|
|
738
|
+
SELECT 1
|
|
739
|
+
FROM aclexplode(COALESCE(t.typacl, acldefault('T', t.typowner))) acl
|
|
740
|
+
WHERE acl.grantee = 0
|
|
741
|
+
AND acl.privilege_type = 'USAGE'
|
|
742
|
+
) AS public_has_privilege,
|
|
743
|
+
pg_has_role(current_user, t.typowner, 'USAGE') AS owns_object
|
|
744
|
+
FROM pg_type t
|
|
745
|
+
JOIN pg_namespace n ON n.oid = t.typnamespace
|
|
746
|
+
LEFT JOIN pg_depend d
|
|
747
|
+
ON d.classid = 'pg_type'::regclass
|
|
748
|
+
AND d.objid = t.oid
|
|
749
|
+
AND d.objsubid = 0
|
|
750
|
+
AND d.refclassid = 'pg_extension'::regclass
|
|
751
|
+
AND d.deptype = 'e'
|
|
752
|
+
LEFT JOIN pg_extension e ON e.oid = d.refobjid
|
|
753
|
+
WHERE n.nspname = ANY($1::text[])
|
|
754
|
+
-- Array type privileges are inherited from their element type
|
|
755
|
+
-- and PostgreSQL rejects direct GRANT/REVOKE on array types.
|
|
756
|
+
AND t.typelem = 0
|
|
757
|
+
AND has_type_privilege(current_user, t.oid, 'USAGE')
|
|
758
|
+
ORDER BY n.nspname, t.typname
|
|
759
|
+
`, [extensionSchemaNames])).rows;
|
|
760
|
+
const extensionFunctions = (await client.query(`
|
|
761
|
+
SELECT n.nspname AS schema_name,
|
|
762
|
+
p.proname AS object_name,
|
|
763
|
+
pg_get_function_identity_arguments(p.oid) AS identity_arguments,
|
|
764
|
+
e.extname AS extension_name,
|
|
765
|
+
EXISTS (
|
|
766
|
+
SELECT 1
|
|
767
|
+
FROM aclexplode(COALESCE(p.proacl, acldefault('f', p.proowner))) acl
|
|
768
|
+
WHERE acl.grantee = 0
|
|
769
|
+
AND acl.privilege_type = 'EXECUTE'
|
|
770
|
+
) AS public_has_privilege,
|
|
771
|
+
pg_has_role(current_user, p.proowner, 'USAGE') AS owns_object,
|
|
772
|
+
p.prosecdef AS security_definer,
|
|
773
|
+
p.proconfig AS configuration
|
|
774
|
+
FROM pg_proc p
|
|
775
|
+
JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
776
|
+
LEFT JOIN pg_depend d
|
|
777
|
+
ON d.classid = 'pg_proc'::regclass
|
|
778
|
+
AND d.objid = p.oid
|
|
779
|
+
AND d.objsubid = 0
|
|
780
|
+
AND d.refclassid = 'pg_extension'::regclass
|
|
781
|
+
AND d.deptype = 'e'
|
|
782
|
+
LEFT JOIN pg_extension e ON e.oid = d.refobjid
|
|
783
|
+
WHERE n.nspname = ANY($1::text[])
|
|
784
|
+
AND has_function_privilege(current_user, p.oid, 'EXECUTE')
|
|
785
|
+
ORDER BY n.nspname, p.proname, identity_arguments
|
|
786
|
+
`, [extensionSchemaNames])).rows;
|
|
787
|
+
assertExactExtensionObjects(access.extensionDependencies, extensionTypes, extensionFunctions, crossSchemaTablePrivileges, crossSchemaSequencePrivileges);
|
|
788
|
+
}
|
|
789
|
+
const ledgerPrivileges = requireSingleRow((await client.query(`
|
|
790
|
+
SELECT has_table_privilege(current_user, c.oid, 'SELECT') AS can_select,
|
|
791
|
+
has_table_privilege(current_user, c.oid, 'INSERT') AS can_insert,
|
|
792
|
+
has_table_privilege(current_user, c.oid, 'UPDATE') AS can_update,
|
|
793
|
+
has_table_privilege(current_user, c.oid, 'DELETE') AS can_delete,
|
|
794
|
+
has_table_privilege(current_user, c.oid, 'TRUNCATE') AS can_truncate,
|
|
795
|
+
has_table_privilege(current_user, c.oid, 'REFERENCES') AS can_references,
|
|
796
|
+
has_table_privilege(current_user, c.oid, 'TRIGGER') AS can_trigger,
|
|
797
|
+
pg_has_role(current_user, c.relowner, 'USAGE') AS owns_object,
|
|
798
|
+
ARRAY(
|
|
799
|
+
SELECT acl.privilege_type
|
|
800
|
+
FROM aclexplode(COALESCE(c.relacl, acldefault('r', c.relowner))) acl
|
|
801
|
+
WHERE acl.grantee = 0
|
|
802
|
+
ORDER BY acl.privilege_type
|
|
803
|
+
) AS public_privileges
|
|
804
|
+
FROM pg_class c
|
|
805
|
+
WHERE c.oid = to_regclass(
|
|
806
|
+
format('%I.%I', $1::text, '_prisma_migrations')
|
|
807
|
+
)
|
|
808
|
+
`, [target.schema])).rows, 'migration ledger privilege');
|
|
809
|
+
if (!ledgerPrivileges.can_select ||
|
|
810
|
+
ledgerPrivileges.public_privileges.length > 0 ||
|
|
811
|
+
ledgerPrivileges.owns_object) {
|
|
812
|
+
throw new Error(`${LOG_PREFIX} runtime role ${identity.role_name} does not have exact private SELECT-only access to ` +
|
|
813
|
+
`${target.schema}._prisma_migrations (PUBLIC=[${ledgerPrivileges.public_privileges.join('/')}], owns=${ledgerPrivileges.owns_object}).`);
|
|
814
|
+
}
|
|
815
|
+
if (ledgerPrivileges.can_insert ||
|
|
816
|
+
ledgerPrivileges.can_update ||
|
|
817
|
+
ledgerPrivileges.can_delete ||
|
|
818
|
+
ledgerPrivileges.can_truncate ||
|
|
819
|
+
ledgerPrivileges.can_references ||
|
|
820
|
+
ledgerPrivileges.can_trigger) {
|
|
821
|
+
throw new Error(`${LOG_PREFIX} runtime role ${identity.role_name} has forbidden write privilege on ` +
|
|
822
|
+
`${target.schema}._prisma_migrations.`);
|
|
181
823
|
}
|
|
182
824
|
const migrationRows = (await client.query(`
|
|
183
825
|
SELECT id::text,
|
|
@@ -185,20 +827,13 @@ async function verifyExternallyManagedRuntime(runtimeUrl, target, access, compat
|
|
|
185
827
|
checksum,
|
|
186
828
|
started_at::text,
|
|
187
829
|
finished_at::text,
|
|
830
|
+
rolled_back_at::text,
|
|
831
|
+
logs,
|
|
188
832
|
applied_steps_count
|
|
189
833
|
FROM ${quoteIdentifier(target.schema)}."_prisma_migrations"
|
|
190
|
-
|
|
191
|
-
AND rolled_back_at IS NULL
|
|
192
|
-
ORDER BY finished_at, id
|
|
834
|
+
ORDER BY started_at NULLS FIRST, id
|
|
193
835
|
`)).rows;
|
|
194
|
-
const appliedMigrations = migrationRows
|
|
195
|
-
id: row.id,
|
|
196
|
-
name: row.migration_name,
|
|
197
|
-
checksum: row.checksum,
|
|
198
|
-
startedAt: row.started_at,
|
|
199
|
-
finishedAt: row.finished_at,
|
|
200
|
-
appliedStepsCount: row.applied_steps_count,
|
|
201
|
-
}));
|
|
836
|
+
const appliedMigrations = validateMigrationLedger(migrationRows);
|
|
202
837
|
const observedLedgerDigest = digestLedger(appliedMigrations);
|
|
203
838
|
const context = Object.freeze({
|
|
204
839
|
target: Object.freeze({ ...target }),
|