Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions graphql/env/__tests__/__snapshots__/merge.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 7 additions & 0 deletions pgpm/cli/src/commands/admin-users/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,13 @@ Admin Users Bootstrap Command:
Options:
--help, -h Show this help message
--cwd <directory> 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 (
Expand Down Expand Up @@ -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();
Expand Down
55 changes: 55 additions & 0 deletions pgpm/core/__tests__/roles/roles-sql-generators.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
generateCreateBaseRolesSQL,
generateCreateClientRoleSQL,
generateCreateUserSQL,
generateCreateTestUsersSQL,
generateRemoveUserSQL
Expand Down Expand Up @@ -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(() => {
Expand Down
21 changes: 21 additions & 0 deletions pgpm/core/src/init/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { getPgPool } from 'pg-cache';
import { PgConfig } from 'pg-env';
import {
generateCreateBaseRolesSQL,
generateCreateClientRoleSQL,
generateCreateUserSQL,
generateCreateTestUsersSQL,
generateRemoveUserSQL
Expand Down Expand Up @@ -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<void> {
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.
Expand Down
96 changes: 96 additions & 0 deletions pgpm/core/src/roles/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions pgpm/env/__tests__/__snapshots__/merge.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions pgpm/types/src/pgpm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -280,6 +282,7 @@ export const pgpmDefaults: PgpmOptions = {
anonymous: 'anonymous',
authenticated: 'authenticated',
administrator: 'administrator',
authenticatedClient: 'authenticated_client',
default: 'anonymous'
},
useLocksForRoles: false
Expand Down
1 change: 1 addition & 0 deletions postgres/pgsql-client/src/roles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export const DEFAULT_ROLE_MAPPING: Required<RoleMapping> = {
anonymous: 'anonymous',
authenticated: 'authenticated',
administrator: 'administrator',
authenticatedClient: 'authenticated_client',
default: 'anonymous'
};

Expand Down
Loading