@pgpmjs/core 3.0.9 → 3.1.1

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,532 @@
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
+ * @throws Error if roles is undefined or missing required properties
13
+ */
14
+ export function generateCreateBaseRolesSQL(roles) {
15
+ if (!roles) {
16
+ throw new Error('generateCreateBaseRolesSQL: roles parameter is undefined. ' +
17
+ 'Ensure getConnEnvOptions().roles is defined. ' +
18
+ 'Check that pgpm.config.js or pgpm.json does not set db.roles to undefined.');
19
+ }
20
+ if (!roles.anonymous || !roles.authenticated || !roles.administrator) {
21
+ throw new Error('generateCreateBaseRolesSQL: roles is missing required properties. ' +
22
+ `Got: anonymous=${roles.anonymous}, authenticated=${roles.authenticated}, administrator=${roles.administrator}. ` +
23
+ 'Ensure all role names are defined in your configuration.');
24
+ }
25
+ const r = {
26
+ anonymous: roles.anonymous,
27
+ authenticated: roles.authenticated,
28
+ administrator: roles.administrator
29
+ };
30
+ return `
31
+ BEGIN;
32
+ DO $do$
33
+ DECLARE
34
+ v_anonymous text := ${sqlLiteral(r.anonymous)};
35
+ v_authenticated text := ${sqlLiteral(r.authenticated)};
36
+ v_administrator text := ${sqlLiteral(r.administrator)};
37
+ BEGIN
38
+ -- Create anonymous role: pre-check + exception handling for TOCTOU safety
39
+ IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = v_anonymous) THEN
40
+ BEGIN
41
+ EXECUTE format('CREATE ROLE %I', v_anonymous);
42
+ EXCEPTION
43
+ WHEN duplicate_object THEN
44
+ -- 42710: Role already exists (race condition); safe to ignore
45
+ NULL;
46
+ WHEN unique_violation THEN
47
+ -- 23505: Concurrent CREATE ROLE hit unique index; safe to ignore
48
+ NULL;
49
+ WHEN insufficient_privilege THEN
50
+ -- 42501: Must surface this error - caller lacks permission
51
+ RAISE;
52
+ END;
53
+ END IF;
54
+
55
+ -- Create authenticated role: pre-check + exception handling for TOCTOU safety
56
+ IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = v_authenticated) THEN
57
+ BEGIN
58
+ EXECUTE format('CREATE ROLE %I', v_authenticated);
59
+ EXCEPTION
60
+ WHEN duplicate_object THEN
61
+ -- 42710: Role already exists (race condition); safe to ignore
62
+ NULL;
63
+ WHEN unique_violation THEN
64
+ -- 23505: Concurrent CREATE ROLE hit unique index; safe to ignore
65
+ NULL;
66
+ WHEN insufficient_privilege THEN
67
+ -- 42501: Must surface this error - caller lacks permission
68
+ RAISE;
69
+ END;
70
+ END IF;
71
+
72
+ -- Create administrator role: pre-check + exception handling for TOCTOU safety
73
+ IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = v_administrator) THEN
74
+ BEGIN
75
+ EXECUTE format('CREATE ROLE %I', v_administrator);
76
+ EXCEPTION
77
+ WHEN duplicate_object THEN
78
+ -- 42710: Role already exists (race condition); safe to ignore
79
+ NULL;
80
+ WHEN unique_violation THEN
81
+ -- 23505: Concurrent CREATE ROLE hit unique index; safe to ignore
82
+ NULL;
83
+ WHEN insufficient_privilege THEN
84
+ -- 42501: Must surface this error - caller lacks permission
85
+ RAISE;
86
+ END;
87
+ END IF;
88
+
89
+ -- Set role attributes (safe to run even if role already exists)
90
+ EXECUTE format('ALTER ROLE %I WITH NOCREATEDB NOSUPERUSER NOCREATEROLE NOLOGIN NOREPLICATION NOBYPASSRLS', v_anonymous);
91
+ EXECUTE format('ALTER ROLE %I WITH NOCREATEDB NOSUPERUSER NOCREATEROLE NOLOGIN NOREPLICATION NOBYPASSRLS', v_authenticated);
92
+ EXECUTE format('ALTER ROLE %I WITH NOCREATEDB NOSUPERUSER NOCREATEROLE NOLOGIN NOREPLICATION BYPASSRLS', v_administrator);
93
+ END
94
+ $do$;
95
+ COMMIT;
96
+ `;
97
+ }
98
+ /**
99
+ * Generate SQL to create a user with password and grant base roles.
100
+ * Callers should use getConnEnvOptions() from @pgpmjs/env to get merged values.
101
+ * @param roles - Role mapping from getConnEnvOptions().roles!
102
+ * @param useLocksForRoles - Whether to use advisory locks (from getConnEnvOptions().useLocksForRoles)
103
+ */
104
+ export function generateCreateUserSQL(username, password, roles, useLocksForRoles = false) {
105
+ if (!roles) {
106
+ throw new Error('generateCreateUserSQL: roles parameter is undefined. ' +
107
+ 'Ensure getConnEnvOptions().roles is defined.');
108
+ }
109
+ if (!roles.anonymous || !roles.authenticated) {
110
+ throw new Error('generateCreateUserSQL: roles is missing required properties. ' +
111
+ `Got: anonymous=${roles.anonymous}, authenticated=${roles.authenticated}.`);
112
+ }
113
+ const r = {
114
+ anonymous: roles.anonymous,
115
+ authenticated: roles.authenticated
116
+ };
117
+ const lockStatement = useLocksForRoles
118
+ ? `PERFORM pg_advisory_xact_lock(42, hashtext(v_username));`
119
+ : '';
120
+ return `
121
+ BEGIN;
122
+ DO $do$
123
+ DECLARE
124
+ v_username text := ${sqlLiteral(username)};
125
+ v_password text := ${sqlLiteral(password)};
126
+ v_anonymous text := ${sqlLiteral(r.anonymous)};
127
+ v_authenticated text := ${sqlLiteral(r.authenticated)};
128
+ BEGIN
129
+ ${lockStatement}
130
+ -- Pre-check + exception handling for TOCTOU safety
131
+ IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = v_username) THEN
132
+ BEGIN
133
+ EXECUTE format('CREATE ROLE %I LOGIN PASSWORD %L', v_username, v_password);
134
+ EXCEPTION
135
+ WHEN duplicate_object THEN
136
+ -- 42710: Role already exists (race condition); safe to ignore
137
+ NULL;
138
+ WHEN unique_violation THEN
139
+ -- 23505: Concurrent CREATE ROLE hit unique index; safe to ignore
140
+ NULL;
141
+ WHEN insufficient_privilege THEN
142
+ -- 42501: Must surface this error - caller lacks permission
143
+ RAISE;
144
+ END;
145
+ END IF;
146
+
147
+ -- Grant anonymous to user
148
+ IF NOT EXISTS (
149
+ SELECT 1 FROM pg_auth_members am
150
+ JOIN pg_roles r1 ON am.roleid = r1.oid
151
+ JOIN pg_roles r2 ON am.member = r2.oid
152
+ WHERE r1.rolname = v_anonymous AND r2.rolname = v_username
153
+ ) THEN
154
+ BEGIN
155
+ EXECUTE format('GRANT %I TO %I', v_anonymous, v_username);
156
+ EXCEPTION
157
+ WHEN unique_violation THEN
158
+ -- 23505: Membership was granted concurrently; safe to ignore
159
+ NULL;
160
+ WHEN undefined_object THEN
161
+ -- 42704: One of the roles doesn't exist; log notice and continue
162
+ RAISE NOTICE 'Missing role when granting % to %', v_anonymous, v_username;
163
+ WHEN insufficient_privilege THEN
164
+ -- 42501: Must surface this error - caller lacks permission
165
+ RAISE;
166
+ WHEN invalid_grant_operation THEN
167
+ -- 0LP01: Must surface this error - invalid grant operation
168
+ RAISE;
169
+ END;
170
+ END IF;
171
+
172
+ -- Grant authenticated to user
173
+ IF NOT EXISTS (
174
+ SELECT 1 FROM pg_auth_members am
175
+ JOIN pg_roles r1 ON am.roleid = r1.oid
176
+ JOIN pg_roles r2 ON am.member = r2.oid
177
+ WHERE r1.rolname = v_authenticated AND r2.rolname = v_username
178
+ ) THEN
179
+ BEGIN
180
+ EXECUTE format('GRANT %I TO %I', v_authenticated, v_username);
181
+ EXCEPTION
182
+ WHEN unique_violation THEN
183
+ -- 23505: Membership was granted concurrently; safe to ignore
184
+ NULL;
185
+ WHEN undefined_object THEN
186
+ -- 42704: One of the roles doesn't exist; log notice and continue
187
+ RAISE NOTICE 'Missing role when granting % to %', v_authenticated, v_username;
188
+ WHEN insufficient_privilege THEN
189
+ -- 42501: Must surface this error - caller lacks permission
190
+ RAISE;
191
+ WHEN invalid_grant_operation THEN
192
+ -- 0LP01: Must surface this error - invalid grant operation
193
+ RAISE;
194
+ END;
195
+ END IF;
196
+ END
197
+ $do$;
198
+ COMMIT;
199
+ `;
200
+ }
201
+ /**
202
+ * Generate SQL to create test users with grants to base roles.
203
+ * Callers should use getConnEnvOptions() from @pgpmjs/env to get merged values.
204
+ * @param roles - Role mapping from getConnEnvOptions().roles!
205
+ * @param connections - Test user credentials from getConnEnvOptions().connections!
206
+ */
207
+ export function generateCreateTestUsersSQL(roles, connections) {
208
+ if (!roles) {
209
+ throw new Error('generateCreateTestUsersSQL: roles parameter is undefined. ' +
210
+ 'Ensure getConnEnvOptions().roles is defined.');
211
+ }
212
+ if (!roles.anonymous || !roles.authenticated || !roles.administrator) {
213
+ throw new Error('generateCreateTestUsersSQL: roles is missing required properties. ' +
214
+ `Got: anonymous=${roles.anonymous}, authenticated=${roles.authenticated}, administrator=${roles.administrator}.`);
215
+ }
216
+ if (!connections) {
217
+ throw new Error('generateCreateTestUsersSQL: connections parameter is undefined. ' +
218
+ 'Ensure getConnEnvOptions().connections is defined.');
219
+ }
220
+ if (!connections.app?.user || !connections.app?.password || !connections.admin?.user || !connections.admin?.password) {
221
+ throw new Error('generateCreateTestUsersSQL: connections is missing required properties. ' +
222
+ 'Ensure app.user, app.password, admin.user, and admin.password are defined.');
223
+ }
224
+ const r = {
225
+ anonymous: roles.anonymous,
226
+ authenticated: roles.authenticated,
227
+ administrator: roles.administrator
228
+ };
229
+ const users = {
230
+ app: { user: connections.app.user, password: connections.app.password },
231
+ admin: { user: connections.admin.user, password: connections.admin.password }
232
+ };
233
+ return `
234
+ BEGIN;
235
+ DO $do$
236
+ DECLARE
237
+ v_app_user text := ${sqlLiteral(users.app.user)};
238
+ v_app_user_password text := ${sqlLiteral(users.app.password)};
239
+ v_app_admin text := ${sqlLiteral(users.admin.user)};
240
+ v_app_admin_password text := ${sqlLiteral(users.admin.password)};
241
+ v_anonymous text := ${sqlLiteral(r.anonymous)};
242
+ v_authenticated text := ${sqlLiteral(r.authenticated)};
243
+ v_administrator text := ${sqlLiteral(r.administrator)};
244
+ BEGIN
245
+ -- Create app_user: pre-check + exception handling for TOCTOU safety
246
+ IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = v_app_user) THEN
247
+ BEGIN
248
+ EXECUTE format('CREATE ROLE %I LOGIN PASSWORD %L', v_app_user, v_app_user_password);
249
+ EXCEPTION
250
+ WHEN duplicate_object THEN NULL;
251
+ WHEN unique_violation THEN NULL;
252
+ WHEN insufficient_privilege THEN RAISE;
253
+ END;
254
+ END IF;
255
+
256
+ -- Create app_admin: pre-check + exception handling for TOCTOU safety
257
+ IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = v_app_admin) THEN
258
+ BEGIN
259
+ EXECUTE format('CREATE ROLE %I LOGIN PASSWORD %L', v_app_admin, v_app_admin_password);
260
+ EXCEPTION
261
+ WHEN duplicate_object THEN NULL;
262
+ WHEN unique_violation THEN NULL;
263
+ WHEN insufficient_privilege THEN RAISE;
264
+ END;
265
+ END IF;
266
+
267
+ -- Grant anonymous to app_user
268
+ IF NOT EXISTS (
269
+ SELECT 1 FROM pg_auth_members am
270
+ JOIN pg_roles r1 ON am.roleid = r1.oid
271
+ JOIN pg_roles r2 ON am.member = r2.oid
272
+ WHERE r1.rolname = v_anonymous AND r2.rolname = v_app_user
273
+ ) THEN
274
+ BEGIN
275
+ EXECUTE format('GRANT %I TO %I', v_anonymous, v_app_user);
276
+ EXCEPTION
277
+ WHEN unique_violation THEN NULL;
278
+ WHEN undefined_object THEN RAISE NOTICE 'Missing role when granting % to %', v_anonymous, v_app_user;
279
+ WHEN insufficient_privilege THEN RAISE;
280
+ WHEN invalid_grant_operation THEN RAISE;
281
+ END;
282
+ END IF;
283
+
284
+ -- Grant authenticated to app_user
285
+ IF NOT EXISTS (
286
+ SELECT 1 FROM pg_auth_members am
287
+ JOIN pg_roles r1 ON am.roleid = r1.oid
288
+ JOIN pg_roles r2 ON am.member = r2.oid
289
+ WHERE r1.rolname = v_authenticated AND r2.rolname = v_app_user
290
+ ) THEN
291
+ BEGIN
292
+ EXECUTE format('GRANT %I TO %I', v_authenticated, v_app_user);
293
+ EXCEPTION
294
+ WHEN unique_violation THEN NULL;
295
+ WHEN undefined_object THEN RAISE NOTICE 'Missing role when granting % to %', v_authenticated, v_app_user;
296
+ WHEN insufficient_privilege THEN RAISE;
297
+ WHEN invalid_grant_operation THEN RAISE;
298
+ END;
299
+ END IF;
300
+
301
+ -- Grant anonymous to administrator
302
+ IF NOT EXISTS (
303
+ SELECT 1 FROM pg_auth_members am
304
+ JOIN pg_roles r1 ON am.roleid = r1.oid
305
+ JOIN pg_roles r2 ON am.member = r2.oid
306
+ WHERE r1.rolname = v_anonymous AND r2.rolname = v_administrator
307
+ ) THEN
308
+ BEGIN
309
+ EXECUTE format('GRANT %I TO %I', v_anonymous, v_administrator);
310
+ EXCEPTION
311
+ WHEN unique_violation THEN NULL;
312
+ WHEN undefined_object THEN RAISE NOTICE 'Missing role when granting % to %', v_anonymous, v_administrator;
313
+ WHEN insufficient_privilege THEN RAISE;
314
+ WHEN invalid_grant_operation THEN RAISE;
315
+ END;
316
+ END IF;
317
+
318
+ -- Grant authenticated to administrator
319
+ IF NOT EXISTS (
320
+ SELECT 1 FROM pg_auth_members am
321
+ JOIN pg_roles r1 ON am.roleid = r1.oid
322
+ JOIN pg_roles r2 ON am.member = r2.oid
323
+ WHERE r1.rolname = v_authenticated AND r2.rolname = v_administrator
324
+ ) THEN
325
+ BEGIN
326
+ EXECUTE format('GRANT %I TO %I', v_authenticated, v_administrator);
327
+ EXCEPTION
328
+ WHEN unique_violation THEN NULL;
329
+ WHEN undefined_object THEN RAISE NOTICE 'Missing role when granting % to %', v_authenticated, v_administrator;
330
+ WHEN insufficient_privilege THEN RAISE;
331
+ WHEN invalid_grant_operation THEN RAISE;
332
+ END;
333
+ END IF;
334
+
335
+ -- Grant administrator to app_admin
336
+ IF NOT EXISTS (
337
+ SELECT 1 FROM pg_auth_members am
338
+ JOIN pg_roles r1 ON am.roleid = r1.oid
339
+ JOIN pg_roles r2 ON am.member = r2.oid
340
+ WHERE r1.rolname = v_administrator AND r2.rolname = v_app_admin
341
+ ) THEN
342
+ BEGIN
343
+ EXECUTE format('GRANT %I TO %I', v_administrator, v_app_admin);
344
+ EXCEPTION
345
+ WHEN unique_violation THEN NULL;
346
+ WHEN undefined_object THEN RAISE NOTICE 'Missing role when granting % to %', v_administrator, v_app_admin;
347
+ WHEN insufficient_privilege THEN RAISE;
348
+ WHEN invalid_grant_operation THEN RAISE;
349
+ END;
350
+ END IF;
351
+ END
352
+ $do$;
353
+ COMMIT;
354
+ `;
355
+ }
356
+ /**
357
+ * Generate SQL to grant a role to a user
358
+ */
359
+ export function generateGrantRoleSQL(role, user) {
360
+ return `
361
+ DO $$
362
+ DECLARE
363
+ v_user text := ${sqlLiteral(user)};
364
+ v_role text := ${sqlLiteral(role)};
365
+ BEGIN
366
+ -- Pre-check to avoid unnecessary GRANTs; still catch TOCTOU under concurrency
367
+ IF NOT EXISTS (
368
+ SELECT 1 FROM pg_auth_members am
369
+ JOIN pg_roles r1 ON am.roleid = r1.oid
370
+ JOIN pg_roles r2 ON am.member = r2.oid
371
+ WHERE r1.rolname = v_role AND r2.rolname = v_user
372
+ ) THEN
373
+ BEGIN
374
+ EXECUTE format('GRANT %I TO %I', v_role, v_user);
375
+ EXCEPTION
376
+ WHEN unique_violation THEN
377
+ -- 23505: Concurrent membership grant; safe to ignore
378
+ NULL;
379
+ WHEN undefined_object THEN
380
+ -- 42704: Role or user missing; emit notice and continue
381
+ RAISE NOTICE 'Missing role when granting % to %', v_role, v_user;
382
+ WHEN insufficient_privilege THEN
383
+ -- 42501: Must surface this error - caller lacks permission
384
+ RAISE;
385
+ WHEN invalid_grant_operation THEN
386
+ -- 0LP01: Must surface this error - invalid grant operation
387
+ RAISE;
388
+ END;
389
+ END IF;
390
+ END
391
+ $$;
392
+ `;
393
+ }
394
+ /**
395
+ * Generate SQL to remove a user and revoke grants.
396
+ * Callers should use getConnEnvOptions() from @pgpmjs/env to get merged values.
397
+ * @param roles - Role mapping from getConnEnvOptions().roles!
398
+ * @param useLocksForRoles - Whether to use advisory locks (from getConnEnvOptions().useLocksForRoles)
399
+ */
400
+ export function generateRemoveUserSQL(username, roles, useLocksForRoles = false) {
401
+ if (!roles) {
402
+ throw new Error('generateRemoveUserSQL: roles parameter is undefined. ' +
403
+ 'Ensure getConnEnvOptions().roles is defined.');
404
+ }
405
+ if (!roles.anonymous || !roles.authenticated) {
406
+ throw new Error('generateRemoveUserSQL: roles is missing required properties. ' +
407
+ `Got: anonymous=${roles.anonymous}, authenticated=${roles.authenticated}.`);
408
+ }
409
+ const r = {
410
+ anonymous: roles.anonymous,
411
+ authenticated: roles.authenticated
412
+ };
413
+ const lockStatement = useLocksForRoles
414
+ ? `PERFORM pg_advisory_xact_lock(42, hashtext(v_username));`
415
+ : '';
416
+ return `
417
+ BEGIN;
418
+ DO $do$
419
+ DECLARE
420
+ v_username text := ${sqlLiteral(username)};
421
+ v_anonymous text := ${sqlLiteral(r.anonymous)};
422
+ v_authenticated text := ${sqlLiteral(r.authenticated)};
423
+ BEGIN
424
+ ${lockStatement}
425
+ IF EXISTS (
426
+ SELECT 1
427
+ FROM pg_catalog.pg_roles
428
+ WHERE rolname = v_username
429
+ ) THEN
430
+ -- REVOKE anonymous membership
431
+ BEGIN
432
+ EXECUTE format('REVOKE %I FROM %I', v_anonymous, v_username);
433
+ EXCEPTION
434
+ WHEN undefined_object THEN
435
+ -- 42704: Role doesn't exist; safe to ignore
436
+ NULL;
437
+ WHEN insufficient_privilege THEN
438
+ -- 42501: Must surface this error - caller lacks permission
439
+ RAISE;
440
+ END;
441
+
442
+ -- REVOKE authenticated membership
443
+ BEGIN
444
+ EXECUTE format('REVOKE %I FROM %I', v_authenticated, v_username);
445
+ EXCEPTION
446
+ WHEN undefined_object THEN
447
+ -- 42704: Role doesn't exist; safe to ignore
448
+ NULL;
449
+ WHEN insufficient_privilege THEN
450
+ -- 42501: Must surface this error - caller lacks permission
451
+ RAISE;
452
+ END;
453
+
454
+ -- DROP ROLE
455
+ BEGIN
456
+ EXECUTE format('DROP ROLE %I', v_username);
457
+ EXCEPTION
458
+ WHEN undefined_object THEN
459
+ -- 42704: Role doesn't exist; safe to ignore
460
+ NULL;
461
+ WHEN dependent_objects_still_exist THEN
462
+ -- 2BP01: Must surface this error - role has dependent objects
463
+ RAISE;
464
+ WHEN object_in_use THEN
465
+ -- 55006: Must surface this error - role is in use
466
+ RAISE;
467
+ WHEN insufficient_privilege THEN
468
+ -- 42501: Must surface this error - caller lacks permission
469
+ RAISE;
470
+ END;
471
+ END IF;
472
+ END
473
+ $do$;
474
+ COMMIT;
475
+ `;
476
+ }
477
+ /**
478
+ * Generate SQL to create a user with grants to specified roles (for test harness)
479
+ * @param useLocksForRoles - Whether to use advisory locks (from getConnEnvOptions().useLocksForRoles)
480
+ */
481
+ export function generateCreateUserWithGrantsSQL(username, password, rolesToGrant, useLocksForRoles = false) {
482
+ const lockStatement = useLocksForRoles
483
+ ? `PERFORM pg_advisory_xact_lock(42, hashtext(v_user));`
484
+ : '';
485
+ // Generate variable declarations for all roles
486
+ const roleVarDeclarations = rolesToGrant.map((role, i) => ` v_role_${i} text := ${sqlLiteral(role)};`).join('\n');
487
+ // Generate grant blocks using the variables
488
+ const grantBlocks = rolesToGrant.map((_, i) => `
489
+ -- Grant role ${i}
490
+ IF NOT EXISTS (
491
+ SELECT 1 FROM pg_auth_members am
492
+ JOIN pg_roles r1 ON am.roleid = r1.oid
493
+ JOIN pg_roles r2 ON am.member = r2.oid
494
+ WHERE r1.rolname = v_role_${i} AND r2.rolname = v_user
495
+ ) THEN
496
+ BEGIN
497
+ EXECUTE format('GRANT %I TO %I', v_role_${i}, v_user);
498
+ EXCEPTION
499
+ WHEN unique_violation THEN NULL;
500
+ WHEN undefined_object THEN RAISE NOTICE 'Missing role when granting % to %', v_role_${i}, v_user;
501
+ WHEN insufficient_privilege THEN RAISE;
502
+ WHEN invalid_grant_operation THEN RAISE;
503
+ END;
504
+ END IF;`).join('\n');
505
+ return `
506
+ DO $$
507
+ DECLARE
508
+ v_user text := ${sqlLiteral(username)};
509
+ v_password text := ${sqlLiteral(password)};
510
+ ${roleVarDeclarations}
511
+ BEGIN
512
+ ${lockStatement}
513
+ -- Create role: pre-check + exception handling for TOCTOU safety
514
+ IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = v_user) THEN
515
+ BEGIN
516
+ EXECUTE format('CREATE ROLE %I LOGIN PASSWORD %L', v_user, v_password);
517
+ EXCEPTION
518
+ WHEN duplicate_object THEN
519
+ -- 42710: Role already exists (race condition); safe to ignore
520
+ NULL;
521
+ WHEN unique_violation THEN
522
+ -- 23505: Concurrent CREATE ROLE hit unique index; safe to ignore
523
+ NULL;
524
+ WHEN insufficient_privilege THEN
525
+ -- 42501: Must surface this error - caller lacks permission
526
+ RAISE;
527
+ END;
528
+ END IF;
529
+ ${grantBlocks}
530
+ END $$;
531
+ `;
532
+ }
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';