import fs from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import pg from 'pg'; import { describeDatabaseTarget } from './lib/destructive-test-database-guard.js'; const { Client } = pg; const CONFIRMATION = 'BOOTSTRAP_BACKEND_RUNTIME_ROLES'; const sqlPath = fileURLToPath(new URL('./deploy/sql/bootstrap-backend-runtime-roles.sql', import.meta.url)); function argumentValue(argv, name) { const index = argv.indexOf(name); if (index >= 0) return String(argv[index + 1] || '').trim(); const prefix = `${name}=`; const item = argv.find(value => value.startsWith(prefix)); return item ? item.slice(prefix.length).trim() : ''; } export function parseBackendRuntimeRoleBootstrapOptions( argv = process.argv.slice(2), env = process.env, ) { const apply = argv.includes('--apply'); const adminUrl = String(env.DATABASE_ADMIN_URL || '').trim(); const confirmation = argumentValue(argv, '--confirm'); if (apply && !adminUrl) throw new Error('DATABASE_ADMIN_URL is required with --apply'); if (apply && confirmation !== CONFIRMATION) { throw new Error(`--confirm=${CONFIRMATION} is required with --apply`); } return { apply, adminUrl, confirmation, json: argv.includes('--json') }; } function roleIsSafe(row) { const config = Array.isArray(row?.rolconfig) ? row.rolconfig.map(String) : []; return row && row.rolcanlogin === true && row.rolsuper === false && row.rolinherit === false && row.rolcreatedb === false && row.rolcreaterole === false && row.rolreplication === false && row.rolbypassrls === true && row.hasParentRoles === false && config.includes('search_path=pg_catalog, public, extensions'); } async function loadRoleState(client) { const result = await client.query(` select role_row.rolname, role_row.rolcanlogin, role_row.rolsuper, role_row.rolinherit, role_row.rolcreatedb, role_row.rolcreaterole, role_row.rolreplication, role_row.rolbypassrls, role_row.rolconfig, exists ( select 1 from pg_auth_members membership where membership.member = role_row.oid ) as "hasParentRoles" from pg_roles role_row where role_row.rolname = any(array['tiku_api', 'tiku_worker']::name[]) order by role_row.rolname `); return result.rows; } async function loadPublicFunctionExecutionState(client) { const result = await client.query(` select requested_role.role_name, role_row.oid is not null as role_exists, coalesce(( select count(*)::integer from pg_proc function_row join pg_namespace namespace on namespace.oid = function_row.pronamespace where namespace.nspname = 'public' and role_row.oid is not null and has_function_privilege(role_row.oid, function_row.oid, 'EXECUTE') ), 0)::integer as executable_function_count from unnest(array['anon', 'authenticated', 'tiku_api', 'tiku_worker']::name[]) as requested_role(role_name) left join pg_roles role_row on role_row.rolname = requested_role.role_name order by requested_role.role_name `); return result.rows; } async function loadExtensionState(client) { const result = await client.query(` select extension.extname, namespace.nspname as schema_name, count(procedure_row.oid)::integer as function_count, count(procedure_row.oid) filter ( where has_function_privilege('anon', procedure_row.oid, 'EXECUTE') )::integer as anon_execute_count, count(procedure_row.oid) filter ( where has_function_privilege('authenticated', procedure_row.oid, 'EXECUTE') )::integer as authenticated_execute_count, count(procedure_row.oid) filter ( where has_function_privilege('tiku_api', procedure_row.oid, 'EXECUTE') )::integer as api_execute_count, count(procedure_row.oid) filter ( where has_function_privilege('tiku_worker', procedure_row.oid, 'EXECUTE') )::integer as worker_execute_count from pg_extension extension join pg_namespace namespace on namespace.oid = extension.extnamespace left join pg_depend dependency on dependency.refclassid = 'pg_extension'::regclass and dependency.refobjid = extension.oid and dependency.classid = 'pg_proc'::regclass and dependency.deptype = 'e' left join pg_proc procedure_row on procedure_row.oid = dependency.objid where extension.extname = any(array['pgcrypto', 'citext', 'ltree', 'pg_trgm']::name[]) group by extension.extname, namespace.nspname order by extension.extname `); return result.rows; } export async function bootstrapBackendRuntimeRoles(options) { if (!options.apply) { return { status: 'plan', apply: false, confirmation: CONFIRMATION, sqlPath: path.relative(process.cwd(), sqlPath), changes: [ 'Create tiku_api and tiku_worker if missing without assigning passwords', 'Enforce LOGIN NOINHERIT NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION BYPASSRLS', 'Move required extensions out of public and set search_path=pg_catalog,public,extensions', 'Close client extension RPC execution while preserving backend citext/ltree operations', ], }; } const target = describeDatabaseTarget(options.adminUrl); const client = new Client({ connectionString: options.adminUrl, application_name: 'tiku-runtime-role-bootstrap', }); await client.connect(); try { const identityResult = await client.query(` select current_user, current_setting('server_version_num')::integer as server_version_num, rolsuper from pg_roles where rolname = current_user `); const identity = identityResult.rows[0]; if (!identity?.rolsuper) { throw new Error(`DATABASE_ADMIN_URL must connect as a PostgreSQL superuser; ${identity?.current_user || 'current role'} is not superuser`); } if (Number(identity.server_version_num) < 130000) { throw new Error('PostgreSQL 13 or newer is required'); } const sql = await fs.readFile(sqlPath, 'utf8'); await client.query('begin'); try { await client.query(sql); await client.query('commit'); } catch (error) { await client.query('rollback').catch(() => undefined); throw error; } const roles = await loadRoleState(client); if (roles.length !== 2 || roles.some(row => !roleIsSafe(row))) { throw new Error('Runtime role bootstrap verification failed'); } const publicFunctionExecution = await loadPublicFunctionExecutionState(client); if ( publicFunctionExecution.length !== 4 || publicFunctionExecution.some(row => !row.role_exists || Number(row.executable_function_count) !== 0) ) { throw new Error('Public extension function execution bootstrap verification failed'); } const extensions = await loadExtensionState(client); const expectedExtensions = new Set(['pgcrypto', 'citext', 'ltree', 'pg_trgm']); if ( extensions.length !== expectedExtensions.size || extensions.some(row => !expectedExtensions.has(row.extname) || row.schema_name !== 'extensions') ) { throw new Error('Required extension schema bootstrap verification failed'); } for (const row of extensions) { const functionCount = Number(row.function_count); const backendExecuteCount = row.extname === 'pgcrypto' ? 0 : functionCount; if ( functionCount <= 0 || Number(row.anon_execute_count) !== 0 || Number(row.authenticated_execute_count) !== 0 || Number(row.api_execute_count) !== backendExecuteCount || Number(row.worker_execute_count) !== backendExecuteCount ) { throw new Error(`Extension function ACL bootstrap verification failed for ${row.extname}`); } } return { status: 'pass', apply: true, target, administrator: identity.current_user, roles: roles.map(row => ({ name: row.rolname, login: row.rolcanlogin, bypassRls: row.rolbypassrls, noInherit: row.rolinherit === false, hasParentRoles: row.hasParentRoles, searchPath: row.rolconfig, })), publicFunctionExecution: publicFunctionExecution.map(row => ({ role: row.role_name, executableFunctionCount: Number(row.executable_function_count), })), extensions: extensions.map(row => ({ name: row.extname, schema: row.schema_name, functionCount: Number(row.function_count), })), }; } finally { await client.end(); } } async function main() { let options; try { options = parseBackendRuntimeRoleBootstrapOptions(); const result = await bootstrapBackendRuntimeRoles(options); if (options.json) console.log(JSON.stringify(result, null, 2)); else if (result.status === 'plan') { console.log('Backend runtime role bootstrap plan'); result.changes.forEach(item => console.log(`- ${item}`)); console.log(`Apply with --apply --confirm=${CONFIRMATION} and DATABASE_ADMIN_URL.`); } else { console.log(`Backend runtime role bootstrap complete for ${result.target.host}:${result.target.port}/${result.target.database}`); } } catch (error) { const failure = { status: 'fail', error: error instanceof Error ? error.message : String(error) }; if (options?.json || process.argv.includes('--json')) console.log(JSON.stringify(failure, null, 2)); else console.error(failure.error); process.exitCode = 1; } } const currentFile = fileURLToPath(import.meta.url); if (process.argv[1] && fileURLToPath(pathToFileURL(process.argv[1])) === currentFile) await main();