Files
gongxue-base/scripts/bootstrap-backend-runtime-roles.js
2026-07-12 19:26:57 +08:00

252 lines
9.7 KiB
JavaScript

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();