From d3158e0940635e4decea97d161bd7a1af6b8a703 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Wed, 8 Jul 2026 03:06:36 +0000 Subject: [PATCH 1/3] feat(pgpm): opt-in authenticated_client role via admin-users bootstrap --client --- .../cli/src/commands/admin-users/bootstrap.ts | 8 ++ .../roles/roles-sql-generators.test.ts | 56 +++++++++ pgpm/core/src/init/client.ts | 21 ++++ pgpm/core/src/roles/index.ts | 109 ++++++++++++++++++ pgpm/types/src/pgpm.ts | 3 + postgres/pgsql-client/src/roles.ts | 1 + 6 files changed, 198 insertions(+) diff --git a/pgpm/cli/src/commands/admin-users/bootstrap.ts b/pgpm/cli/src/commands/admin-users/bootstrap.ts index 9ca5a4303a..55c10258c7 100644 --- a/pgpm/cli/src/commands/admin-users/bootstrap.ts +++ b/pgpm/cli/src/commands/admin-users/bootstrap.ts @@ -17,9 +17,14 @@ Admin Users Bootstrap Command: Options: --help, -h Show this help message --cwd Working directory (default: current directory) + --client Also create the restricted authenticated_client role + (for SQL-level proxy clients: inherits from + authenticated; set_config/pg_notify revoked from + PUBLIC; server-enforced statement_timeout) Examples: pgpm admin-users bootstrap # Initialize postgres roles + pgpm admin-users bootstrap --client # Also create authenticated_client `; export default async ( @@ -56,6 +61,9 @@ export default async ( try { await init.bootstrapRoles(db.roles!); + if (argv.client) { + await init.bootstrapClientRole(db.roles!); + } log.success('postgres roles and permissions initialized successfully.'); } finally { await init.close(); diff --git a/pgpm/core/__tests__/roles/roles-sql-generators.test.ts b/pgpm/core/__tests__/roles/roles-sql-generators.test.ts index 47455f0e86..94eefef71d 100644 --- a/pgpm/core/__tests__/roles/roles-sql-generators.test.ts +++ b/pgpm/core/__tests__/roles/roles-sql-generators.test.ts @@ -1,5 +1,6 @@ import { generateCreateBaseRolesSQL, + generateCreateClientRoleSQL, generateCreateUserSQL, generateCreateTestUsersSQL, generateRemoveUserSQL @@ -59,6 +60,61 @@ describe('Role SQL Generators - Input Validation', () => { }); }); + describe('generateCreateClientRoleSQL', () => { + it('should throw an error when roles is undefined', () => { + expect(() => { + generateCreateClientRoleSQL(undefined as any); + }).toThrow('generateCreateClientRoleSQL: roles parameter is undefined'); + }); + + it('should throw an error when roles.authenticated is missing', () => { + expect(() => { + generateCreateClientRoleSQL({ + authenticatedClient: 'authenticated_client' + }); + }).toThrow('generateCreateClientRoleSQL: roles is missing required properties'); + }); + + it('should throw an error when roles.authenticatedClient is missing', () => { + expect(() => { + generateCreateClientRoleSQL({ + authenticated: 'authenticated' + }); + }).toThrow('generateCreateClientRoleSQL: roles is missing required properties'); + }); + + it('should generate valid SQL when all roles are provided', () => { + const sql = generateCreateClientRoleSQL({ + authenticated: 'auth', + authenticatedClient: 'auth_client' + }); + expect(sql).toContain('auth'); + expect(sql).toContain('auth_client'); + expect(sql).toContain('CREATE ROLE'); + expect(sql).toContain('NOBYPASSRLS'); + expect(sql).toContain('GRANT %I TO %I'); + expect(sql).toContain('statement_timeout'); + expect(sql).toContain('REVOKE EXECUTE ON FUNCTION pg_catalog.set_config(text, text, boolean) FROM PUBLIC'); + expect(sql).toContain('REVOKE EXECUTE ON FUNCTION pg_catalog.pg_notify(text, text) FROM PUBLIC'); + }); + + it('should apply a custom statement timeout', () => { + const sql = generateCreateClientRoleSQL( + { authenticated: 'authenticated', authenticatedClient: 'authenticated_client' }, + '30s' + ); + expect(sql).toContain(`'30s'`); + }); + + it('should escape single quotes in role names', () => { + const sql = generateCreateClientRoleSQL({ + authenticated: "auth'role", + authenticatedClient: 'authenticated_client' + }); + expect(sql).toContain("'auth''role'"); + }); + }); + describe('generateCreateUserSQL', () => { it('should throw an error when roles is undefined', () => { expect(() => { diff --git a/pgpm/core/src/init/client.ts b/pgpm/core/src/init/client.ts index de933356c5..26ca39bb0f 100644 --- a/pgpm/core/src/init/client.ts +++ b/pgpm/core/src/init/client.ts @@ -5,6 +5,7 @@ import { getPgPool } from 'pg-cache'; import { PgConfig } from 'pg-env'; import { generateCreateBaseRolesSQL, + generateCreateClientRoleSQL, generateCreateUserSQL, generateCreateTestUsersSQL, generateRemoveUserSQL @@ -40,6 +41,26 @@ export class PgpmInit { } } + /** + * Bootstrap the restricted client role (authenticated_client) for SQL-level + * proxy clients. Opt-in via `pgpm admin-users bootstrap --client`. + * Callers should use getConnEnvOptions() from @pgpmjs/env to get merged values. + * @param roles - Role mapping from getConnEnvOptions().roles! + */ + async bootstrapClientRole(roles: RoleMapping): Promise { + try { + log.info('Bootstrapping PGPM client role...'); + + const sql = generateCreateClientRoleSQL(roles); + await this.pool.query(sql); + + log.success('Successfully bootstrapped PGPM client role'); + } catch (error) { + log.error('Failed to bootstrap client role:', error); + throw error; + } + } + /** * Bootstrap test roles (app_user, app_admin with grants to base roles). * Callers should use getConnEnvOptions() from @pgpmjs/env to get merged values. diff --git a/pgpm/core/src/roles/index.ts b/pgpm/core/src/roles/index.ts index ae78a1a7d0..3527d9746c 100644 --- a/pgpm/core/src/roles/index.ts +++ b/pgpm/core/src/roles/index.ts @@ -104,6 +104,115 @@ COMMIT; `; } +/** + * Generate SQL to create the restricted `authenticated_client` role used by + * SQL-level proxy clients (opt-in via `admin-users bootstrap --client`). + * + * The role inherits table/schema grants from `authenticated` but is stripped + * of GUC-mutation and pub/sub abilities at the Postgres grant level: + * - set_config() revoked from PUBLIC (claims cannot be forged in-session) + * - pg_notify() revoked from PUBLIC (pub/sub closed at any call depth) + * - server-enforced statement_timeout baseline + * + * Note: revoking from PUBLIC affects all non-superuser roles in the database; + * roles that legitimately need set_config()/pg_notify() (e.g. a proxy login + * role or job workers) must be re-granted EXECUTE explicitly. + * + * @param roles - Role mapping from getConnEnvOptions().roles! + * @param statementTimeout - Baseline statement_timeout for the role (default '15s') + */ +export function generateCreateClientRoleSQL( + roles: RoleMapping, + statementTimeout = '15s' +): string { + if (!roles) { + throw new Error( + 'generateCreateClientRoleSQL: roles parameter is undefined. ' + + 'Ensure getConnEnvOptions().roles is defined.' + ); + } + if (!roles.authenticated || !roles.authenticatedClient) { + throw new Error( + 'generateCreateClientRoleSQL: roles is missing required properties. ' + + `Got: authenticated=${roles.authenticated}, authenticatedClient=${roles.authenticatedClient}. ` + + 'Ensure all role names are defined in your configuration.' + ); + } + const r = { + authenticated: roles.authenticated, + authenticatedClient: roles.authenticatedClient + }; + + return ` +BEGIN; +DO $do$ +DECLARE + v_authenticated text := ${sqlLiteral(r.authenticated)}; + v_client text := ${sqlLiteral(r.authenticatedClient)}; + v_statement_timeout text := ${sqlLiteral(statementTimeout)}; +BEGIN + -- Create client role: pre-check + exception handling for TOCTOU safety + IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = v_client) THEN + BEGIN + EXECUTE format('CREATE ROLE %I', v_client); + EXCEPTION + WHEN duplicate_object THEN + -- 42710: Role already exists (race condition); safe to ignore + NULL; + WHEN unique_violation THEN + -- 23505: Concurrent CREATE ROLE hit unique index; safe to ignore + NULL; + WHEN insufficient_privilege THEN + -- 42501: Must surface this error - caller lacks permission + RAISE; + END; + END IF; + + -- Strip escalation attributes (safe to run even if role already exists) + EXECUTE format('ALTER ROLE %I WITH NOCREATEDB NOSUPERUSER NOCREATEROLE NOLOGIN NOREPLICATION NOBYPASSRLS', v_client); + + -- Inherit table/schema grants from authenticated + IF NOT EXISTS ( + SELECT 1 FROM pg_auth_members am + JOIN pg_roles r1 ON am.roleid = r1.oid + JOIN pg_roles r2 ON am.member = r2.oid + WHERE r1.rolname = v_authenticated AND r2.rolname = v_client + ) THEN + BEGIN + EXECUTE format('GRANT %I TO %I', v_authenticated, v_client); + EXCEPTION + WHEN unique_violation THEN + -- 23505: Membership was granted concurrently; safe to ignore + NULL; + WHEN undefined_object THEN + -- 42704: One of the roles doesn't exist; log notice and continue + RAISE NOTICE 'Missing role when granting % to %', v_authenticated, v_client; + WHEN insufficient_privilege THEN + -- 42501: Must surface this error - caller lacks permission + RAISE; + WHEN invalid_grant_operation THEN + -- 0LP01: Must surface this error - invalid grant operation + RAISE; + END; + END IF; + + -- Server-enforced baseline statement timeout for the client role + EXECUTE format('ALTER ROLE %I SET statement_timeout = %L', v_client, v_statement_timeout); + + -- Claim integrity: revoke set_config() from PUBLIC so no proxied role can + -- mutate GUCs (jwt.claims.*) in-session. Roles that need it (e.g. a proxy + -- login role) must be re-granted EXECUTE explicitly. + REVOKE EXECUTE ON FUNCTION pg_catalog.set_config(text, text, boolean) FROM PUBLIC; + + -- Pub/sub: revoke pg_notify() from PUBLIC so no proxied role can emit a + -- NOTIFY at any call depth (incl. nested inside SECURITY INVOKER functions). + REVOKE EXECUTE ON FUNCTION pg_catalog.pg_notify(text, text) FROM PUBLIC; +END +$do$; +COMMIT; +`; +} + /** * Generate SQL to create a user with password and grant base roles. * Callers should use getConnEnvOptions() from @pgpmjs/env to get merged values. diff --git a/pgpm/types/src/pgpm.ts b/pgpm/types/src/pgpm.ts index 4a277d5b4a..4c8282f373 100644 --- a/pgpm/types/src/pgpm.ts +++ b/pgpm/types/src/pgpm.ts @@ -60,6 +60,8 @@ export interface RoleMapping { authenticated?: string; /** Administrator role name */ administrator?: string; + /** Restricted proxy client role name (opt-in; created via `admin-users bootstrap --client`) */ + authenticatedClient?: string; /** Default role for new connections */ default?: string; } @@ -280,6 +282,7 @@ export const pgpmDefaults: PgpmOptions = { anonymous: 'anonymous', authenticated: 'authenticated', administrator: 'administrator', + authenticatedClient: 'authenticated_client', default: 'anonymous' }, useLocksForRoles: false diff --git a/postgres/pgsql-client/src/roles.ts b/postgres/pgsql-client/src/roles.ts index c6a6889ab2..9f825199ca 100644 --- a/postgres/pgsql-client/src/roles.ts +++ b/postgres/pgsql-client/src/roles.ts @@ -7,6 +7,7 @@ export const DEFAULT_ROLE_MAPPING: Required = { anonymous: 'anonymous', authenticated: 'authenticated', administrator: 'administrator', + authenticatedClient: 'authenticated_client', default: 'anonymous' }; From 0d36df4a99b7360847647f932df31cb4fdca623e Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Wed, 8 Jul 2026 03:14:32 +0000 Subject: [PATCH 2/3] test: update env snapshots for authenticatedClient default --- graphql/env/__tests__/__snapshots__/merge.test.ts.snap | 1 + pgpm/env/__tests__/__snapshots__/merge.test.ts.snap | 1 + 2 files changed, 2 insertions(+) diff --git a/graphql/env/__tests__/__snapshots__/merge.test.ts.snap b/graphql/env/__tests__/__snapshots__/merge.test.ts.snap index b6969aaf51..98dcc95521 100644 --- a/graphql/env/__tests__/__snapshots__/merge.test.ts.snap +++ b/graphql/env/__tests__/__snapshots__/merge.test.ts.snap @@ -44,6 +44,7 @@ exports[`getEnvOptions merges pgpm defaults, graphql defaults, config, env, and "administrator": "administrator", "anonymous": "anonymous", "authenticated": "authenticated", + "authenticatedClient": "authenticated_client", "default": "anonymous", }, "rootDb": "postgres", diff --git a/pgpm/env/__tests__/__snapshots__/merge.test.ts.snap b/pgpm/env/__tests__/__snapshots__/merge.test.ts.snap index 8ba29ba739..b60b50f48a 100644 --- a/pgpm/env/__tests__/__snapshots__/merge.test.ts.snap +++ b/pgpm/env/__tests__/__snapshots__/merge.test.ts.snap @@ -29,6 +29,7 @@ exports[`getEnvOptions merges defaults, config, env, and overrides 1`] = ` "administrator": "administrator", "anonymous": "anonymous", "authenticated": "authenticated", + "authenticatedClient": "authenticated_client", "default": "anonymous", }, "rootDb": "postgres", From 5aa7e2c726c542081af03007be1f577f93736916 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Wed, 8 Jul 2026 04:51:46 +0000 Subject: [PATCH 3/3] refactor(pgpm): drop PUBLIC revokes from client role generator (deployment concern) --- .../cli/src/commands/admin-users/bootstrap.ts | 3 +-- .../roles/roles-sql-generators.test.ts | 3 +-- pgpm/core/src/roles/index.ts | 23 ++++--------------- 3 files changed, 7 insertions(+), 22 deletions(-) diff --git a/pgpm/cli/src/commands/admin-users/bootstrap.ts b/pgpm/cli/src/commands/admin-users/bootstrap.ts index 55c10258c7..6c21c35d02 100644 --- a/pgpm/cli/src/commands/admin-users/bootstrap.ts +++ b/pgpm/cli/src/commands/admin-users/bootstrap.ts @@ -19,8 +19,7 @@ Options: --cwd Working directory (default: current directory) --client Also create the restricted authenticated_client role (for SQL-level proxy clients: inherits from - authenticated; set_config/pg_notify revoked from - PUBLIC; server-enforced statement_timeout) + authenticated; server-enforced statement_timeout) Examples: pgpm admin-users bootstrap # Initialize postgres roles diff --git a/pgpm/core/__tests__/roles/roles-sql-generators.test.ts b/pgpm/core/__tests__/roles/roles-sql-generators.test.ts index 94eefef71d..fa0dd9834f 100644 --- a/pgpm/core/__tests__/roles/roles-sql-generators.test.ts +++ b/pgpm/core/__tests__/roles/roles-sql-generators.test.ts @@ -94,8 +94,7 @@ describe('Role SQL Generators - Input Validation', () => { expect(sql).toContain('NOBYPASSRLS'); expect(sql).toContain('GRANT %I TO %I'); expect(sql).toContain('statement_timeout'); - expect(sql).toContain('REVOKE EXECUTE ON FUNCTION pg_catalog.set_config(text, text, boolean) FROM PUBLIC'); - expect(sql).toContain('REVOKE EXECUTE ON FUNCTION pg_catalog.pg_notify(text, text) FROM PUBLIC'); + expect(sql).not.toContain('REVOKE'); }); it('should apply a custom statement timeout', () => { diff --git a/pgpm/core/src/roles/index.ts b/pgpm/core/src/roles/index.ts index 3527d9746c..cc14932f87 100644 --- a/pgpm/core/src/roles/index.ts +++ b/pgpm/core/src/roles/index.ts @@ -108,15 +108,11 @@ COMMIT; * Generate SQL to create the restricted `authenticated_client` role used by * SQL-level proxy clients (opt-in via `admin-users bootstrap --client`). * - * The role inherits table/schema grants from `authenticated` but is stripped - * of GUC-mutation and pub/sub abilities at the Postgres grant level: - * - set_config() revoked from PUBLIC (claims cannot be forged in-session) - * - pg_notify() revoked from PUBLIC (pub/sub closed at any call depth) - * - server-enforced statement_timeout baseline - * - * Note: revoking from PUBLIC affects all non-superuser roles in the database; - * roles that legitimately need set_config()/pg_notify() (e.g. a proxy login - * role or job workers) must be re-granted EXECUTE explicitly. + * The role inherits table/schema grants from `authenticated` and gets a + * server-enforced statement_timeout baseline. Grant-level hardening such as + * revoking set_config()/pg_notify() from PUBLIC is intentionally left to the + * proxy deployment config, since those revokes affect every non-superuser + * role in the database (the app server and job workers depend on both). * * @param roles - Role mapping from getConnEnvOptions().roles! * @param statementTimeout - Baseline statement_timeout for the role (default '15s') @@ -198,15 +194,6 @@ BEGIN -- Server-enforced baseline statement timeout for the client role EXECUTE format('ALTER ROLE %I SET statement_timeout = %L', v_client, v_statement_timeout); - - -- Claim integrity: revoke set_config() from PUBLIC so no proxied role can - -- mutate GUCs (jwt.claims.*) in-session. Roles that need it (e.g. a proxy - -- login role) must be re-granted EXECUTE explicitly. - REVOKE EXECUTE ON FUNCTION pg_catalog.set_config(text, text, boolean) FROM PUBLIC; - - -- Pub/sub: revoke pg_notify() from PUBLIC so no proxied role can emit a - -- NOTIFY at any call depth (incl. nested inside SECURITY INVOKER functions). - REVOKE EXECUTE ON FUNCTION pg_catalog.pg_notify(text, text) FROM PUBLIC; END $do$; COMMIT;