@xemahq/biome-database-nest 0.8.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.
@@ -3,11 +3,18 @@ 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");
6
7
  const migration_mode_1 = require("./migration-mode");
7
8
  const LOG_PREFIX = '[biome-database]';
8
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;
9
10
  const SHA_256_PATTERN = /^[0-9a-f]{64}$/;
10
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
+ ];
11
18
  const ALL_SEQUENCE_PRIVILEGES = Object.values(migration_mode_1.RuntimeSequencePrivilege);
12
19
  const TABLE_DML_SET = new Set(ALL_TABLE_DML);
13
20
  const SEQUENCE_PRIVILEGE_SET = new Set(ALL_SEQUENCE_PRIVILEGES);
@@ -30,6 +37,29 @@ function assertExpectation(expectation) {
30
37
  expectation.tablePrivileges.length === 0) {
31
38
  throw new Error(`${LOG_PREFIX} externally-managed runtime access must require at least one table privilege.`);
32
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
+ }
33
63
  if (expectation.tablePrivileges.some((privilege) => !TABLE_DML_SET.has(privilege)) ||
34
64
  new Set(expectation.tablePrivileges).size !==
35
65
  expectation.tablePrivileges.length) {
@@ -42,122 +72,404 @@ function assertExpectation(expectation) {
42
72
  throw new Error(`${LOG_PREFIX} runtime sequence privileges must be unique RuntimeSequencePrivilege values.`);
43
73
  }
44
74
  const memberships = expectation.authorizedRoleMemberships;
45
- if (!Array.isArray(memberships)) {
46
- throw new Error(`${LOG_PREFIX} externally-managed runtime access must explicitly declare 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.`);
47
77
  }
48
- for (const role of memberships) {
49
- assertNonEmpty(role, 'authorized role membership');
78
+ if (!Array.isArray(expectation.targetFunctions)) {
79
+ throw new Error(`${LOG_PREFIX} externally-managed runtime access must explicitly declare targetFunctions.`);
50
80
  }
51
- if (new Set(memberships).size !== memberships.length) {
52
- throw new Error(`${LOG_PREFIX} authorizedRoleMemberships must not contain duplicates.`);
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);
53
97
  }
98
+ assertExtensionDependencies(expectation.extensionDependencies);
54
99
  }
55
- function buildMissingTablePrivilegeQuery(privileges) {
56
- const projections = privileges
57
- .map((privilege) => `CASE WHEN NOT has_table_privilege(current_user, ` +
58
- `format('%I.%I', table_schema, table_name), '${privilege}') ` +
59
- `THEN '${privilege}' END`)
60
- .join(', ');
61
- return `
62
- SELECT table_name AS object_name,
63
- array_remove(ARRAY[${projections}], NULL) AS missing_privileges
64
- FROM information_schema.tables
65
- WHERE table_schema = $1
66
- AND table_type = 'BASE TABLE'
67
- AND table_name <> '_prisma_migrations'
68
- AND NOT (
69
- ${privileges
70
- .map((privilege) => `has_table_privilege(current_user, ` +
71
- `format('%I.%I', table_schema, table_name), '${privilege}')`)
72
- .join(' AND ')}
73
- )
74
- ORDER BY table_name
75
- `;
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');
76
111
  }
77
- function buildMissingSequencePrivilegeQuery(privileges) {
78
- if (privileges.length === 0) {
79
- return null;
80
- }
81
- const projections = privileges
82
- .map((privilege) => `CASE WHEN NOT has_sequence_privilege(current_user, ` +
83
- `format('%I.%I', sequence_schema, sequence_name), '${privilege}') ` +
84
- `THEN '${privilege}' END`)
85
- .join(', ');
86
- return `
87
- SELECT sequence_name AS object_name,
88
- array_remove(ARRAY[${projections}], NULL) AS missing_privileges
89
- FROM information_schema.sequences
90
- WHERE sequence_schema = $1
91
- AND NOT (
92
- ${privileges
93
- .map((privilege) => `has_sequence_privilege(current_user, ` +
94
- `format('%I.%I', sequence_schema, sequence_name), '${privilege}')`)
95
- .join(' AND ')}
96
- )
97
- ORDER BY sequence_name
98
- `;
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
+ }
99
160
  }
100
- function buildCrossSchemaTablePrivilegeQuery() {
101
- const projections = ALL_TABLE_DML.map((privilege) => `CASE WHEN has_table_privilege(current_user, ` +
102
- `format('%I.%I', table_schema, table_name), '${privilege}') ` +
161
+ function buildTablePrivilegeQuery(crossSchema) {
162
+ const projections = ALL_TABLE_PRIVILEGES.map((privilege) => `CASE WHEN has_table_privilege(current_user, c.oid, '${privilege}') ` +
103
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(', ');
104
168
  return `
105
- SELECT table_schema AS schema_name,
106
- table_name AS object_name,
107
- array_remove(ARRAY[${projections}], NULL) AS privileges
108
- FROM information_schema.tables
109
- WHERE table_schema <> $1
110
- AND table_schema NOT IN ('pg_catalog', 'information_schema')
111
- AND table_schema NOT LIKE 'pg_toast%'
112
- AND table_schema NOT LIKE 'pg_temp_%'
113
- AND (
114
- ${ALL_TABLE_DML.map((privilege) => `has_table_privilege(current_user, ` +
115
- `format('%I.%I', table_schema, table_name), '${privilege}')`).join(' OR ')}
116
- )
117
- ORDER BY table_schema, table_name
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
118
195
  `;
119
196
  }
120
- function buildCrossSchemaSequencePrivilegeQuery() {
121
- const projections = ALL_SEQUENCE_PRIVILEGES.map((privilege) => `CASE WHEN has_sequence_privilege(current_user, ` +
122
- `format('%I.%I', sequence_schema, sequence_name), '${privilege}') ` +
197
+ function buildSequencePrivilegeQuery(crossSchema) {
198
+ const projections = ALL_SEQUENCE_PRIVILEGES.map((privilege) => `CASE WHEN has_sequence_privilege(current_user, c.oid, '${privilege}') ` +
123
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(', ');
124
204
  return `
125
- SELECT sequence_schema AS schema_name,
126
- sequence_name AS object_name,
127
- array_remove(ARRAY[${projections}], NULL) AS privileges
128
- FROM information_schema.sequences
129
- WHERE sequence_schema <> $1
130
- AND sequence_schema NOT IN ('pg_catalog', 'information_schema')
131
- AND sequence_schema NOT LIKE 'pg_toast%'
132
- AND sequence_schema NOT LIKE 'pg_temp_%'
133
- AND (
134
- ${ALL_SEQUENCE_PRIVILEGES.map((privilege) => `has_sequence_privilege(current_user, ` +
135
- `format('%I.%I', sequence_schema, sequence_name), '${privilege}')`).join(' OR ')}
136
- )
137
- ORDER BY sequence_schema, sequence_name
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
138
229
  `;
139
230
  }
140
- function formatMissingPrivileges(kind, rows) {
141
- return rows
142
- .map((row) => `${kind} ${row.object_name} missing ${row.missing_privileges.join('/')}`)
143
- .join(', ');
144
- }
145
231
  function formatCrossSchemaPrivileges(kind, rows) {
146
232
  return rows
147
233
  .map((row) => `${kind} ${row.schema_name}.${row.object_name} ` +
148
234
  `[${row.privileges.join('/')}]`)
149
235
  .join(', ');
150
236
  }
151
- function assertExactRoleMemberships(observedRows, authorized, expectedRole) {
152
- if (authorized.includes(expectedRole)) {
153
- throw new Error(`${LOG_PREFIX} authorizedRoleMemberships must not include the runtime login role itself.`);
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
+ }
154
274
  }
155
- const observed = observedRows.map((row) => row.role_name).sort();
156
- const expected = [...authorized].sort();
157
- if (observed.length !== expected.length ||
158
- observed.some((role, index) => role !== expected[index])) {
159
- throw new Error(`${LOG_PREFIX} runtime role membership mismatch: expected ` +
160
- `[${expected.join(', ')}], observed [${observed.join(', ')}].`);
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(', '));
161
473
  }
162
474
  }
163
475
  function parseLedgerTimestamp(value, migrationName, field) {
@@ -224,6 +536,7 @@ function assertTarget(target) {
224
536
  assertNonEmpty(target.schema, 'expected target schema');
225
537
  assertNonEmpty(target.applicationName, 'expected application_name');
226
538
  assertNonEmpty(target.expectedRole, 'expected runtime role');
539
+ (0, credential_generation_1.assertDatabaseCredentialGeneration)(target.credentialGeneration, `${LOG_PREFIX} expected credential generation`);
227
540
  }
228
541
  async function verifyExternallyManagedRuntime(runtimeUrl, target, access, compatibility) {
229
542
  assertTarget(target);
@@ -276,7 +589,7 @@ async function verifyExternallyManagedRuntime(runtimeUrl, target, access, compat
276
589
  AND pg_has_role(current_user, oid, 'MEMBER')
277
590
  ORDER BY rolname
278
591
  `)).rows;
279
- assertExactRoleMemberships(roleMemberships, access.authorizedRoleMemberships, target.expectedRole);
592
+ assertNoRoleMemberships(roleMemberships);
280
593
  const databasePrivileges = requireSingleRow((await client.query(`
281
594
  SELECT has_database_privilege(current_user, current_database(), 'CONNECT') AS can_connect,
282
595
  has_database_privilege(current_user, current_database(), 'CREATE') AS can_create,
@@ -295,7 +608,13 @@ async function verifyExternallyManagedRuntime(runtimeUrl, target, access, compat
295
608
  SELECT nspname AS schema_name,
296
609
  has_schema_privilege(current_user, oid, 'USAGE') AS can_use,
297
610
  has_schema_privilege(current_user, oid, 'CREATE') AS can_create,
298
- pg_has_role(current_user, nspowner, 'USAGE') AS owns_schema
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
299
618
  FROM pg_namespace
300
619
  WHERE nspname NOT IN ('pg_catalog', 'information_schema')
301
620
  AND nspname NOT LIKE 'pg_toast%'
@@ -306,6 +625,9 @@ async function verifyExternallyManagedRuntime(runtimeUrl, target, access, compat
306
625
  if (!targetSchemaPrivileges.can_use) {
307
626
  throw new Error(`${LOG_PREFIX} runtime role ${identity.role_name} lacks USAGE on schema ${target.schema}.`);
308
627
  }
628
+ if (targetSchemaPrivileges.public_can_use) {
629
+ throw new Error(`${LOG_PREFIX} target schema ${target.schema} grants forbidden USAGE to PUBLIC.`);
630
+ }
309
631
  const persistentDdlSchemas = persistentSchemaPrivileges.filter((row) => row.can_create || row.owns_schema);
310
632
  if (persistentDdlSchemas.length > 0) {
311
633
  throw new Error(`${LOG_PREFIX} runtime role ${identity.role_name} has forbidden persistent ` +
@@ -313,62 +635,189 @@ async function verifyExternallyManagedRuntime(runtimeUrl, target, access, compat
313
635
  .map((row) => row.schema_name)
314
636
  .join(', ')}.`);
315
637
  }
316
- const crossSchemaUsage = persistentSchemaPrivileges.filter((row) => row.schema_name !== target.schema && row.can_use);
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}.`);
641
+ }
642
+ const crossSchemaUsage = persistentSchemaPrivileges.filter((row) => row.schema_name !== target.schema &&
643
+ row.can_use &&
644
+ !extensionSchemas.has(row.schema_name));
317
645
  if (crossSchemaUsage.length > 0) {
318
646
  throw new Error(`${LOG_PREFIX} runtime role ${identity.role_name} has forbidden cross-schema ` +
319
647
  `USAGE on: ${crossSchemaUsage
320
648
  .map((row) => row.schema_name)
321
649
  .join(', ')}.`);
322
650
  }
323
- const crossSchemaTablePrivileges = (await client.query(buildCrossSchemaTablePrivilegeQuery(), [target.schema])).rows;
324
- if (crossSchemaTablePrivileges.length > 0) {
325
- throw new Error(`${LOG_PREFIX} runtime role ${identity.role_name} has forbidden cross-schema ` +
326
- `table privileges: ${formatCrossSchemaPrivileges('table', crossSchemaTablePrivileges)}.`);
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(', '));
327
658
  }
328
- const crossSchemaSequencePrivileges = (await client.query(buildCrossSchemaSequencePrivilegeQuery(), [target.schema])).rows;
329
- if (crossSchemaSequencePrivileges.length > 0) {
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) {
330
666
  throw new Error(`${LOG_PREFIX} runtime role ${identity.role_name} has forbidden cross-schema ` +
331
- `sequence privileges: ${formatCrossSchemaPrivileges('sequence', crossSchemaSequencePrivileges)}.`);
667
+ `table privileges: ${formatCrossSchemaPrivileges('table', undeclaredCrossSchemaTables)}.`);
332
668
  }
333
- const missingTablePrivileges = (await client.query(buildMissingTablePrivilegeQuery(access.tablePrivileges), [target.schema])).rows;
334
- if (missingTablePrivileges.length > 0) {
335
- throw new Error(`${LOG_PREFIX} runtime DML contract failed: ${formatMissingPrivileges('table', missingTablePrivileges)}.`);
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)}.`);
336
674
  }
337
- const sequenceQuery = buildMissingSequencePrivilegeQuery(access.sequencePrivileges);
338
- if (sequenceQuery !== null) {
339
- const missingSequencePrivileges = (await client.query(sequenceQuery, [target.schema])).rows;
340
- if (missingSequencePrivileges.length > 0) {
341
- throw new Error(`${LOG_PREFIX} runtime DML contract failed: ${formatMissingPrivileges('sequence', missingSequencePrivileges)}.`);
342
- }
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);
343
788
  }
344
789
  const ledgerPrivileges = requireSingleRow((await client.query(`
345
- SELECT has_table_privilege(
346
- current_user,
347
- format('%I.%I', $1, '_prisma_migrations'),
348
- 'SELECT'
349
- ) AS can_select,
350
- has_table_privilege(
351
- current_user,
352
- format('%I.%I', $1, '_prisma_migrations'),
353
- 'INSERT'
354
- ) AS can_insert,
355
- has_table_privilege(
356
- current_user,
357
- format('%I.%I', $1, '_prisma_migrations'),
358
- 'UPDATE'
359
- ) AS can_update,
360
- has_table_privilege(
361
- current_user,
362
- format('%I.%I', $1, '_prisma_migrations'),
363
- 'DELETE'
364
- ) AS can_delete
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
+ )
365
808
  `, [target.schema])).rows, 'migration ledger privilege');
366
- if (!ledgerPrivileges.can_select) {
367
- throw new Error(`${LOG_PREFIX} runtime role ${identity.role_name} lacks required SELECT on ${target.schema}._prisma_migrations.`);
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}).`);
368
814
  }
369
815
  if (ledgerPrivileges.can_insert ||
370
816
  ledgerPrivileges.can_update ||
371
- ledgerPrivileges.can_delete) {
817
+ ledgerPrivileges.can_delete ||
818
+ ledgerPrivileges.can_truncate ||
819
+ ledgerPrivileges.can_references ||
820
+ ledgerPrivileges.can_trigger) {
372
821
  throw new Error(`${LOG_PREFIX} runtime role ${identity.role_name} has forbidden write privilege on ` +
373
822
  `${target.schema}._prisma_migrations.`);
374
823
  }