@pgpmjs/core 3.0.9 → 3.1.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/esm/index.js CHANGED
@@ -17,5 +17,6 @@ export * from './files';
17
17
  export { cleanSql } from './migrate/clean';
18
18
  export { PgpmMigrate } from './migrate/client';
19
19
  export { PgpmInit } from './init/client';
20
+ export * from './roles';
20
21
  export { hashFile, hashString } from './migrate/utils/hash';
21
22
  export { executeQuery, withTransaction } from './migrate/utils/transaction';
@@ -1,7 +1,6 @@
1
1
  import { Logger } from '@pgpmjs/logger';
2
- import { readFileSync } from 'fs';
3
- import { join } from 'path';
4
2
  import { getPgPool } from 'pg-cache';
3
+ import { generateCreateBaseRolesSQL, generateCreateUserSQL, generateCreateTestUsersSQL, generateRemoveUserSQL } from '../roles';
5
4
  const log = new Logger('init');
6
5
  export class PgpmInit {
7
6
  pool;
@@ -11,13 +10,14 @@ export class PgpmInit {
11
10
  this.pool = getPgPool(this.pgConfig);
12
11
  }
13
12
  /**
14
- * Bootstrap standard roles (anonymous, authenticated, administrator)
13
+ * Bootstrap standard roles (anonymous, authenticated, administrator).
14
+ * Callers should use getConnEnvOptions() from @pgpmjs/env to get merged values.
15
+ * @param roles - Role mapping from getConnEnvOptions().roles!
15
16
  */
16
- async bootstrapRoles() {
17
+ async bootstrapRoles(roles) {
17
18
  try {
18
19
  log.info('Bootstrapping PGPM roles...');
19
- const sqlPath = join(__dirname, 'sql', 'bootstrap-roles.sql');
20
- const sql = readFileSync(sqlPath, 'utf-8');
20
+ const sql = generateCreateBaseRolesSQL(roles);
21
21
  await this.pool.query(sql);
22
22
  log.success('Successfully bootstrapped PGPM roles');
23
23
  }
@@ -27,14 +27,16 @@ export class PgpmInit {
27
27
  }
28
28
  }
29
29
  /**
30
- * Bootstrap test roles (roles only, no users)
30
+ * Bootstrap test roles (app_user, app_admin with grants to base roles).
31
+ * Callers should use getConnEnvOptions() from @pgpmjs/env to get merged values.
32
+ * @param roles - Role mapping from getConnEnvOptions().roles!
33
+ * @param connections - Test user credentials from getConnEnvOptions().connections!
31
34
  */
32
- async bootstrapTestRoles() {
35
+ async bootstrapTestRoles(roles, connections) {
33
36
  try {
34
37
  log.warn('WARNING: This command creates test roles and should NEVER be run on a production database!');
35
38
  log.info('Bootstrapping PGPM test roles...');
36
- const sqlPath = join(__dirname, 'sql', 'bootstrap-test-roles.sql');
37
- const sql = readFileSync(sqlPath, 'utf-8');
39
+ const sql = generateCreateTestUsersSQL(roles, connections);
38
40
  await this.pool.query(sql);
39
41
  log.success('Successfully bootstrapped PGPM test roles');
40
42
  }
@@ -44,58 +46,17 @@ export class PgpmInit {
44
46
  }
45
47
  }
46
48
  /**
47
- * Bootstrap database roles with custom username and password
49
+ * Bootstrap database roles with custom username and password.
50
+ * Callers should use getConnEnvOptions() from @pgpmjs/env to get merged values.
51
+ * @param username - The username to create
52
+ * @param password - The password for the user
53
+ * @param roles - Role mapping from getConnEnvOptions().roles!
54
+ * @param useLocksForRoles - Whether to use advisory locks (from getConnEnvOptions().useLocksForRoles)
48
55
  */
49
- async bootstrapDbRoles(username, password) {
56
+ async bootstrapDbRoles(username, password, roles, useLocksForRoles = false) {
50
57
  try {
51
58
  log.info(`Bootstrapping PGPM database roles for user: ${username}...`);
52
- const sql = `
53
- BEGIN;
54
- DO $do$
55
- DECLARE
56
- v_username TEXT := '${username.replace(/'/g, "''")}';
57
- v_password TEXT := '${password.replace(/'/g, "''")}';
58
- BEGIN
59
- BEGIN
60
- EXECUTE format('CREATE ROLE %I LOGIN PASSWORD %L', v_username, v_password);
61
- EXCEPTION
62
- WHEN duplicate_object THEN
63
- -- Role already exists; optionally sync attributes here with ALTER ROLE
64
- NULL;
65
- END;
66
- END
67
- $do$;
68
-
69
- -- Robust GRANTs under concurrency: GRANT can race on pg_auth_members unique index.
70
- -- Catch unique_violation (23505) and continue so CI/CD concurrent jobs don't fail.
71
- DO $do$
72
- DECLARE
73
- v_username TEXT := '${username.replace(/'/g, "''")}';
74
- BEGIN
75
- BEGIN
76
- EXECUTE format('GRANT %I TO %I', 'anonymous', v_username);
77
- EXCEPTION
78
- WHEN unique_violation THEN
79
- -- Membership was granted concurrently; ignore.
80
- NULL;
81
- WHEN undefined_object THEN
82
- -- One of the roles doesn't exist yet; order operations as needed.
83
- RAISE NOTICE 'Missing role when granting % to %', 'anonymous', v_username;
84
- END;
85
-
86
- BEGIN
87
- EXECUTE format('GRANT %I TO %I', 'authenticated', v_username);
88
- EXCEPTION
89
- WHEN unique_violation THEN
90
- -- Membership was granted concurrently; ignore.
91
- NULL;
92
- WHEN undefined_object THEN
93
- RAISE NOTICE 'Missing role when granting % to %', 'authenticated', v_username;
94
- END;
95
- END
96
- $do$;
97
- COMMIT;
98
- `;
59
+ const sql = generateCreateUserSQL(username, password, roles, useLocksForRoles);
99
60
  await this.pool.query(sql);
100
61
  log.success(`Successfully bootstrapped PGPM database roles for user: ${username}`);
101
62
  }
@@ -105,29 +66,16 @@ COMMIT;
105
66
  }
106
67
  }
107
68
  /**
108
- * Remove database roles and revoke grants
69
+ * Remove database roles and revoke grants.
70
+ * Callers should use getConnEnvOptions() from @pgpmjs/env to get merged values.
71
+ * @param username - The username to remove
72
+ * @param roles - Role mapping from getConnEnvOptions().roles!
73
+ * @param useLocksForRoles - Whether to use advisory locks (from getConnEnvOptions().useLocksForRoles)
109
74
  */
110
- async removeDbRoles(username) {
75
+ async removeDbRoles(username, roles, useLocksForRoles = false) {
111
76
  try {
112
77
  log.info(`Removing PGPM database roles for user: ${username}...`);
113
- const sql = `
114
- BEGIN;
115
- DO $do$
116
- BEGIN
117
- IF EXISTS (
118
- SELECT 1
119
- FROM
120
- pg_catalog.pg_roles
121
- WHERE
122
- rolname = '${username}') THEN
123
- REVOKE anonymous FROM ${username};
124
- REVOKE authenticated FROM ${username};
125
- DROP ROLE ${username};
126
- END IF;
127
- END
128
- $do$;
129
- COMMIT;
130
- `;
78
+ const sql = generateRemoveUserSQL(username, roles, useLocksForRoles);
131
79
  await this.pool.query(sql);
132
80
  log.success(`Successfully removed PGPM database roles for user: ${username}`);
133
81
  }
@@ -0,0 +1,489 @@
1
+ /**
2
+ * Safely escape a string for use as a SQL string literal.
3
+ * Doubles single quotes and wraps in single quotes.
4
+ */
5
+ function sqlLiteral(value) {
6
+ return `'${value.replace(/'/g, "''")}'`;
7
+ }
8
+ /**
9
+ * Generate SQL to create base roles (anonymous, authenticated, administrator).
10
+ * Callers should use getConnEnvOptions() from @pgpmjs/env to get merged values.
11
+ * @param roles - Role mapping from getConnEnvOptions().roles!
12
+ */
13
+ export function generateCreateBaseRolesSQL(roles) {
14
+ const r = {
15
+ anonymous: roles.anonymous,
16
+ authenticated: roles.authenticated,
17
+ administrator: roles.administrator
18
+ };
19
+ return `
20
+ BEGIN;
21
+ DO $do$
22
+ DECLARE
23
+ v_anonymous text := ${sqlLiteral(r.anonymous)};
24
+ v_authenticated text := ${sqlLiteral(r.authenticated)};
25
+ v_administrator text := ${sqlLiteral(r.administrator)};
26
+ BEGIN
27
+ -- Create anonymous role: pre-check + exception handling for TOCTOU safety
28
+ IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = v_anonymous) THEN
29
+ BEGIN
30
+ EXECUTE format('CREATE ROLE %I', v_anonymous);
31
+ EXCEPTION
32
+ WHEN duplicate_object THEN
33
+ -- 42710: Role already exists (race condition); safe to ignore
34
+ NULL;
35
+ WHEN unique_violation THEN
36
+ -- 23505: Concurrent CREATE ROLE hit unique index; safe to ignore
37
+ NULL;
38
+ WHEN insufficient_privilege THEN
39
+ -- 42501: Must surface this error - caller lacks permission
40
+ RAISE;
41
+ END;
42
+ END IF;
43
+
44
+ -- Create authenticated role: pre-check + exception handling for TOCTOU safety
45
+ IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = v_authenticated) THEN
46
+ BEGIN
47
+ EXECUTE format('CREATE ROLE %I', v_authenticated);
48
+ EXCEPTION
49
+ WHEN duplicate_object THEN
50
+ -- 42710: Role already exists (race condition); safe to ignore
51
+ NULL;
52
+ WHEN unique_violation THEN
53
+ -- 23505: Concurrent CREATE ROLE hit unique index; safe to ignore
54
+ NULL;
55
+ WHEN insufficient_privilege THEN
56
+ -- 42501: Must surface this error - caller lacks permission
57
+ RAISE;
58
+ END;
59
+ END IF;
60
+
61
+ -- Create administrator role: pre-check + exception handling for TOCTOU safety
62
+ IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = v_administrator) THEN
63
+ BEGIN
64
+ EXECUTE format('CREATE ROLE %I', v_administrator);
65
+ EXCEPTION
66
+ WHEN duplicate_object THEN
67
+ -- 42710: Role already exists (race condition); safe to ignore
68
+ NULL;
69
+ WHEN unique_violation THEN
70
+ -- 23505: Concurrent CREATE ROLE hit unique index; safe to ignore
71
+ NULL;
72
+ WHEN insufficient_privilege THEN
73
+ -- 42501: Must surface this error - caller lacks permission
74
+ RAISE;
75
+ END;
76
+ END IF;
77
+
78
+ -- Set role attributes (safe to run even if role already exists)
79
+ EXECUTE format('ALTER ROLE %I WITH NOCREATEDB NOSUPERUSER NOCREATEROLE NOLOGIN NOREPLICATION NOBYPASSRLS', v_anonymous);
80
+ EXECUTE format('ALTER ROLE %I WITH NOCREATEDB NOSUPERUSER NOCREATEROLE NOLOGIN NOREPLICATION NOBYPASSRLS', v_authenticated);
81
+ EXECUTE format('ALTER ROLE %I WITH NOCREATEDB NOSUPERUSER NOCREATEROLE NOLOGIN NOREPLICATION BYPASSRLS', v_administrator);
82
+ END
83
+ $do$;
84
+ COMMIT;
85
+ `;
86
+ }
87
+ /**
88
+ * Generate SQL to create a user with password and grant base roles.
89
+ * Callers should use getConnEnvOptions() from @pgpmjs/env to get merged values.
90
+ * @param roles - Role mapping from getConnEnvOptions().roles!
91
+ * @param useLocksForRoles - Whether to use advisory locks (from getConnEnvOptions().useLocksForRoles)
92
+ */
93
+ export function generateCreateUserSQL(username, password, roles, useLocksForRoles = false) {
94
+ const r = {
95
+ anonymous: roles.anonymous,
96
+ authenticated: roles.authenticated
97
+ };
98
+ const lockStatement = useLocksForRoles
99
+ ? `PERFORM pg_advisory_xact_lock(42, hashtext(v_username));`
100
+ : '';
101
+ return `
102
+ BEGIN;
103
+ DO $do$
104
+ DECLARE
105
+ v_username text := ${sqlLiteral(username)};
106
+ v_password text := ${sqlLiteral(password)};
107
+ v_anonymous text := ${sqlLiteral(r.anonymous)};
108
+ v_authenticated text := ${sqlLiteral(r.authenticated)};
109
+ BEGIN
110
+ ${lockStatement}
111
+ -- Pre-check + exception handling for TOCTOU safety
112
+ IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = v_username) THEN
113
+ BEGIN
114
+ EXECUTE format('CREATE ROLE %I LOGIN PASSWORD %L', v_username, v_password);
115
+ EXCEPTION
116
+ WHEN duplicate_object THEN
117
+ -- 42710: Role already exists (race condition); safe to ignore
118
+ NULL;
119
+ WHEN unique_violation THEN
120
+ -- 23505: Concurrent CREATE ROLE hit unique index; safe to ignore
121
+ NULL;
122
+ WHEN insufficient_privilege THEN
123
+ -- 42501: Must surface this error - caller lacks permission
124
+ RAISE;
125
+ END;
126
+ END IF;
127
+
128
+ -- Grant anonymous to user
129
+ IF NOT EXISTS (
130
+ SELECT 1 FROM pg_auth_members am
131
+ JOIN pg_roles r1 ON am.roleid = r1.oid
132
+ JOIN pg_roles r2 ON am.member = r2.oid
133
+ WHERE r1.rolname = v_anonymous AND r2.rolname = v_username
134
+ ) THEN
135
+ BEGIN
136
+ EXECUTE format('GRANT %I TO %I', v_anonymous, v_username);
137
+ EXCEPTION
138
+ WHEN unique_violation THEN
139
+ -- 23505: Membership was granted concurrently; safe to ignore
140
+ NULL;
141
+ WHEN undefined_object THEN
142
+ -- 42704: One of the roles doesn't exist; log notice and continue
143
+ RAISE NOTICE 'Missing role when granting % to %', v_anonymous, v_username;
144
+ WHEN insufficient_privilege THEN
145
+ -- 42501: Must surface this error - caller lacks permission
146
+ RAISE;
147
+ WHEN invalid_grant_operation THEN
148
+ -- 0LP01: Must surface this error - invalid grant operation
149
+ RAISE;
150
+ END;
151
+ END IF;
152
+
153
+ -- Grant authenticated to user
154
+ IF NOT EXISTS (
155
+ SELECT 1 FROM pg_auth_members am
156
+ JOIN pg_roles r1 ON am.roleid = r1.oid
157
+ JOIN pg_roles r2 ON am.member = r2.oid
158
+ WHERE r1.rolname = v_authenticated AND r2.rolname = v_username
159
+ ) THEN
160
+ BEGIN
161
+ EXECUTE format('GRANT %I TO %I', v_authenticated, v_username);
162
+ EXCEPTION
163
+ WHEN unique_violation THEN
164
+ -- 23505: Membership was granted concurrently; safe to ignore
165
+ NULL;
166
+ WHEN undefined_object THEN
167
+ -- 42704: One of the roles doesn't exist; log notice and continue
168
+ RAISE NOTICE 'Missing role when granting % to %', v_authenticated, v_username;
169
+ WHEN insufficient_privilege THEN
170
+ -- 42501: Must surface this error - caller lacks permission
171
+ RAISE;
172
+ WHEN invalid_grant_operation THEN
173
+ -- 0LP01: Must surface this error - invalid grant operation
174
+ RAISE;
175
+ END;
176
+ END IF;
177
+ END
178
+ $do$;
179
+ COMMIT;
180
+ `;
181
+ }
182
+ /**
183
+ * Generate SQL to create test users with grants to base roles.
184
+ * Callers should use getConnEnvOptions() from @pgpmjs/env to get merged values.
185
+ * @param roles - Role mapping from getConnEnvOptions().roles!
186
+ * @param connections - Test user credentials from getConnEnvOptions().connections!
187
+ */
188
+ export function generateCreateTestUsersSQL(roles, connections) {
189
+ const r = {
190
+ anonymous: roles.anonymous,
191
+ authenticated: roles.authenticated,
192
+ administrator: roles.administrator
193
+ };
194
+ const users = {
195
+ app: { user: connections.app.user, password: connections.app.password },
196
+ admin: { user: connections.admin.user, password: connections.admin.password }
197
+ };
198
+ return `
199
+ BEGIN;
200
+ DO $do$
201
+ DECLARE
202
+ v_app_user text := ${sqlLiteral(users.app.user)};
203
+ v_app_user_password text := ${sqlLiteral(users.app.password)};
204
+ v_app_admin text := ${sqlLiteral(users.admin.user)};
205
+ v_app_admin_password text := ${sqlLiteral(users.admin.password)};
206
+ v_anonymous text := ${sqlLiteral(r.anonymous)};
207
+ v_authenticated text := ${sqlLiteral(r.authenticated)};
208
+ v_administrator text := ${sqlLiteral(r.administrator)};
209
+ BEGIN
210
+ -- Create app_user: pre-check + exception handling for TOCTOU safety
211
+ IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = v_app_user) THEN
212
+ BEGIN
213
+ EXECUTE format('CREATE ROLE %I LOGIN PASSWORD %L', v_app_user, v_app_user_password);
214
+ EXCEPTION
215
+ WHEN duplicate_object THEN NULL;
216
+ WHEN unique_violation THEN NULL;
217
+ WHEN insufficient_privilege THEN RAISE;
218
+ END;
219
+ END IF;
220
+
221
+ -- Create app_admin: pre-check + exception handling for TOCTOU safety
222
+ IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = v_app_admin) THEN
223
+ BEGIN
224
+ EXECUTE format('CREATE ROLE %I LOGIN PASSWORD %L', v_app_admin, v_app_admin_password);
225
+ EXCEPTION
226
+ WHEN duplicate_object THEN NULL;
227
+ WHEN unique_violation THEN NULL;
228
+ WHEN insufficient_privilege THEN RAISE;
229
+ END;
230
+ END IF;
231
+
232
+ -- Grant anonymous to app_user
233
+ IF NOT EXISTS (
234
+ SELECT 1 FROM pg_auth_members am
235
+ JOIN pg_roles r1 ON am.roleid = r1.oid
236
+ JOIN pg_roles r2 ON am.member = r2.oid
237
+ WHERE r1.rolname = v_anonymous AND r2.rolname = v_app_user
238
+ ) THEN
239
+ BEGIN
240
+ EXECUTE format('GRANT %I TO %I', v_anonymous, v_app_user);
241
+ EXCEPTION
242
+ WHEN unique_violation THEN NULL;
243
+ WHEN undefined_object THEN RAISE NOTICE 'Missing role when granting % to %', v_anonymous, v_app_user;
244
+ WHEN insufficient_privilege THEN RAISE;
245
+ WHEN invalid_grant_operation THEN RAISE;
246
+ END;
247
+ END IF;
248
+
249
+ -- Grant authenticated to app_user
250
+ IF NOT EXISTS (
251
+ SELECT 1 FROM pg_auth_members am
252
+ JOIN pg_roles r1 ON am.roleid = r1.oid
253
+ JOIN pg_roles r2 ON am.member = r2.oid
254
+ WHERE r1.rolname = v_authenticated AND r2.rolname = v_app_user
255
+ ) THEN
256
+ BEGIN
257
+ EXECUTE format('GRANT %I TO %I', v_authenticated, v_app_user);
258
+ EXCEPTION
259
+ WHEN unique_violation THEN NULL;
260
+ WHEN undefined_object THEN RAISE NOTICE 'Missing role when granting % to %', v_authenticated, v_app_user;
261
+ WHEN insufficient_privilege THEN RAISE;
262
+ WHEN invalid_grant_operation THEN RAISE;
263
+ END;
264
+ END IF;
265
+
266
+ -- Grant anonymous to administrator
267
+ IF NOT EXISTS (
268
+ SELECT 1 FROM pg_auth_members am
269
+ JOIN pg_roles r1 ON am.roleid = r1.oid
270
+ JOIN pg_roles r2 ON am.member = r2.oid
271
+ WHERE r1.rolname = v_anonymous AND r2.rolname = v_administrator
272
+ ) THEN
273
+ BEGIN
274
+ EXECUTE format('GRANT %I TO %I', v_anonymous, v_administrator);
275
+ EXCEPTION
276
+ WHEN unique_violation THEN NULL;
277
+ WHEN undefined_object THEN RAISE NOTICE 'Missing role when granting % to %', v_anonymous, v_administrator;
278
+ WHEN insufficient_privilege THEN RAISE;
279
+ WHEN invalid_grant_operation THEN RAISE;
280
+ END;
281
+ END IF;
282
+
283
+ -- Grant authenticated to administrator
284
+ IF NOT EXISTS (
285
+ SELECT 1 FROM pg_auth_members am
286
+ JOIN pg_roles r1 ON am.roleid = r1.oid
287
+ JOIN pg_roles r2 ON am.member = r2.oid
288
+ WHERE r1.rolname = v_authenticated AND r2.rolname = v_administrator
289
+ ) THEN
290
+ BEGIN
291
+ EXECUTE format('GRANT %I TO %I', v_authenticated, v_administrator);
292
+ EXCEPTION
293
+ WHEN unique_violation THEN NULL;
294
+ WHEN undefined_object THEN RAISE NOTICE 'Missing role when granting % to %', v_authenticated, v_administrator;
295
+ WHEN insufficient_privilege THEN RAISE;
296
+ WHEN invalid_grant_operation THEN RAISE;
297
+ END;
298
+ END IF;
299
+
300
+ -- Grant administrator to app_admin
301
+ IF NOT EXISTS (
302
+ SELECT 1 FROM pg_auth_members am
303
+ JOIN pg_roles r1 ON am.roleid = r1.oid
304
+ JOIN pg_roles r2 ON am.member = r2.oid
305
+ WHERE r1.rolname = v_administrator AND r2.rolname = v_app_admin
306
+ ) THEN
307
+ BEGIN
308
+ EXECUTE format('GRANT %I TO %I', v_administrator, v_app_admin);
309
+ EXCEPTION
310
+ WHEN unique_violation THEN NULL;
311
+ WHEN undefined_object THEN RAISE NOTICE 'Missing role when granting % to %', v_administrator, v_app_admin;
312
+ WHEN insufficient_privilege THEN RAISE;
313
+ WHEN invalid_grant_operation THEN RAISE;
314
+ END;
315
+ END IF;
316
+ END
317
+ $do$;
318
+ COMMIT;
319
+ `;
320
+ }
321
+ /**
322
+ * Generate SQL to grant a role to a user
323
+ */
324
+ export function generateGrantRoleSQL(role, user) {
325
+ return `
326
+ DO $$
327
+ DECLARE
328
+ v_user text := ${sqlLiteral(user)};
329
+ v_role text := ${sqlLiteral(role)};
330
+ BEGIN
331
+ -- Pre-check to avoid unnecessary GRANTs; still catch TOCTOU under concurrency
332
+ IF NOT EXISTS (
333
+ SELECT 1 FROM pg_auth_members am
334
+ JOIN pg_roles r1 ON am.roleid = r1.oid
335
+ JOIN pg_roles r2 ON am.member = r2.oid
336
+ WHERE r1.rolname = v_role AND r2.rolname = v_user
337
+ ) THEN
338
+ BEGIN
339
+ EXECUTE format('GRANT %I TO %I', v_role, v_user);
340
+ EXCEPTION
341
+ WHEN unique_violation THEN
342
+ -- 23505: Concurrent membership grant; safe to ignore
343
+ NULL;
344
+ WHEN undefined_object THEN
345
+ -- 42704: Role or user missing; emit notice and continue
346
+ RAISE NOTICE 'Missing role when granting % to %', v_role, v_user;
347
+ WHEN insufficient_privilege THEN
348
+ -- 42501: Must surface this error - caller lacks permission
349
+ RAISE;
350
+ WHEN invalid_grant_operation THEN
351
+ -- 0LP01: Must surface this error - invalid grant operation
352
+ RAISE;
353
+ END;
354
+ END IF;
355
+ END
356
+ $$;
357
+ `;
358
+ }
359
+ /**
360
+ * Generate SQL to remove a user and revoke grants.
361
+ * Callers should use getConnEnvOptions() from @pgpmjs/env to get merged values.
362
+ * @param roles - Role mapping from getConnEnvOptions().roles!
363
+ * @param useLocksForRoles - Whether to use advisory locks (from getConnEnvOptions().useLocksForRoles)
364
+ */
365
+ export function generateRemoveUserSQL(username, roles, useLocksForRoles = false) {
366
+ const r = {
367
+ anonymous: roles.anonymous,
368
+ authenticated: roles.authenticated
369
+ };
370
+ const lockStatement = useLocksForRoles
371
+ ? `PERFORM pg_advisory_xact_lock(42, hashtext(v_username));`
372
+ : '';
373
+ return `
374
+ BEGIN;
375
+ DO $do$
376
+ DECLARE
377
+ v_username text := ${sqlLiteral(username)};
378
+ v_anonymous text := ${sqlLiteral(r.anonymous)};
379
+ v_authenticated text := ${sqlLiteral(r.authenticated)};
380
+ BEGIN
381
+ ${lockStatement}
382
+ IF EXISTS (
383
+ SELECT 1
384
+ FROM pg_catalog.pg_roles
385
+ WHERE rolname = v_username
386
+ ) THEN
387
+ -- REVOKE anonymous membership
388
+ BEGIN
389
+ EXECUTE format('REVOKE %I FROM %I', v_anonymous, v_username);
390
+ EXCEPTION
391
+ WHEN undefined_object THEN
392
+ -- 42704: Role doesn't exist; safe to ignore
393
+ NULL;
394
+ WHEN insufficient_privilege THEN
395
+ -- 42501: Must surface this error - caller lacks permission
396
+ RAISE;
397
+ END;
398
+
399
+ -- REVOKE authenticated membership
400
+ BEGIN
401
+ EXECUTE format('REVOKE %I FROM %I', v_authenticated, v_username);
402
+ EXCEPTION
403
+ WHEN undefined_object THEN
404
+ -- 42704: Role doesn't exist; safe to ignore
405
+ NULL;
406
+ WHEN insufficient_privilege THEN
407
+ -- 42501: Must surface this error - caller lacks permission
408
+ RAISE;
409
+ END;
410
+
411
+ -- DROP ROLE
412
+ BEGIN
413
+ EXECUTE format('DROP ROLE %I', v_username);
414
+ EXCEPTION
415
+ WHEN undefined_object THEN
416
+ -- 42704: Role doesn't exist; safe to ignore
417
+ NULL;
418
+ WHEN dependent_objects_still_exist THEN
419
+ -- 2BP01: Must surface this error - role has dependent objects
420
+ RAISE;
421
+ WHEN object_in_use THEN
422
+ -- 55006: Must surface this error - role is in use
423
+ RAISE;
424
+ WHEN insufficient_privilege THEN
425
+ -- 42501: Must surface this error - caller lacks permission
426
+ RAISE;
427
+ END;
428
+ END IF;
429
+ END
430
+ $do$;
431
+ COMMIT;
432
+ `;
433
+ }
434
+ /**
435
+ * Generate SQL to create a user with grants to specified roles (for test harness)
436
+ * @param useLocksForRoles - Whether to use advisory locks (from getConnEnvOptions().useLocksForRoles)
437
+ */
438
+ export function generateCreateUserWithGrantsSQL(username, password, rolesToGrant, useLocksForRoles = false) {
439
+ const lockStatement = useLocksForRoles
440
+ ? `PERFORM pg_advisory_xact_lock(42, hashtext(v_user));`
441
+ : '';
442
+ // Generate variable declarations for all roles
443
+ const roleVarDeclarations = rolesToGrant.map((role, i) => ` v_role_${i} text := ${sqlLiteral(role)};`).join('\n');
444
+ // Generate grant blocks using the variables
445
+ const grantBlocks = rolesToGrant.map((_, i) => `
446
+ -- Grant role ${i}
447
+ IF NOT EXISTS (
448
+ SELECT 1 FROM pg_auth_members am
449
+ JOIN pg_roles r1 ON am.roleid = r1.oid
450
+ JOIN pg_roles r2 ON am.member = r2.oid
451
+ WHERE r1.rolname = v_role_${i} AND r2.rolname = v_user
452
+ ) THEN
453
+ BEGIN
454
+ EXECUTE format('GRANT %I TO %I', v_role_${i}, v_user);
455
+ EXCEPTION
456
+ WHEN unique_violation THEN NULL;
457
+ WHEN undefined_object THEN RAISE NOTICE 'Missing role when granting % to %', v_role_${i}, v_user;
458
+ WHEN insufficient_privilege THEN RAISE;
459
+ WHEN invalid_grant_operation THEN RAISE;
460
+ END;
461
+ END IF;`).join('\n');
462
+ return `
463
+ DO $$
464
+ DECLARE
465
+ v_user text := ${sqlLiteral(username)};
466
+ v_password text := ${sqlLiteral(password)};
467
+ ${roleVarDeclarations}
468
+ BEGIN
469
+ ${lockStatement}
470
+ -- Create role: pre-check + exception handling for TOCTOU safety
471
+ IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = v_user) THEN
472
+ BEGIN
473
+ EXECUTE format('CREATE ROLE %I LOGIN PASSWORD %L', v_user, v_password);
474
+ EXCEPTION
475
+ WHEN duplicate_object THEN
476
+ -- 42710: Role already exists (race condition); safe to ignore
477
+ NULL;
478
+ WHEN unique_violation THEN
479
+ -- 23505: Concurrent CREATE ROLE hit unique index; safe to ignore
480
+ NULL;
481
+ WHEN insufficient_privilege THEN
482
+ -- 42501: Must surface this error - caller lacks permission
483
+ RAISE;
484
+ END;
485
+ END IF;
486
+ ${grantBlocks}
487
+ END $$;
488
+ `;
489
+ }
package/index.d.ts CHANGED
@@ -16,6 +16,7 @@ export * from './files';
16
16
  export { cleanSql } from './migrate/clean';
17
17
  export { PgpmMigrate } from './migrate/client';
18
18
  export { PgpmInit } from './init/client';
19
+ export * from './roles';
19
20
  export { DeployOptions, DeployResult, MigrateChange, MigratePlanFile, RevertOptions, RevertResult, StatusResult, VerifyOptions, VerifyResult } from './migrate/types';
20
21
  export { hashFile, hashString } from './migrate/utils/hash';
21
22
  export { executeQuery, TransactionContext, TransactionOptions, withTransaction } from './migrate/utils/transaction';
package/index.js CHANGED
@@ -37,6 +37,7 @@ var client_1 = require("./migrate/client");
37
37
  Object.defineProperty(exports, "PgpmMigrate", { enumerable: true, get: function () { return client_1.PgpmMigrate; } });
38
38
  var client_2 = require("./init/client");
39
39
  Object.defineProperty(exports, "PgpmInit", { enumerable: true, get: function () { return client_2.PgpmInit; } });
40
+ __exportStar(require("./roles"), exports);
40
41
  var hash_1 = require("./migrate/utils/hash");
41
42
  Object.defineProperty(exports, "hashFile", { enumerable: true, get: function () { return hash_1.hashFile; } });
42
43
  Object.defineProperty(exports, "hashString", { enumerable: true, get: function () { return hash_1.hashString; } });