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/cli/src/commands/admin-users/bootstrap.ts b/pgpm/cli/src/commands/admin-users/bootstrap.ts index 9ca5a4303a..6c21c35d02 100644 --- a/pgpm/cli/src/commands/admin-users/bootstrap.ts +++ b/pgpm/cli/src/commands/admin-users/bootstrap.ts @@ -17,9 +17,13 @@ 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; 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 +60,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..fa0dd9834f 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,60 @@ 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).not.toContain('REVOKE'); + }); + + 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..cc14932f87 100644 --- a/pgpm/core/src/roles/index.ts +++ b/pgpm/core/src/roles/index.ts @@ -104,6 +104,102 @@ 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` 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') + */ +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); +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/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", 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' };