forked from wangziqi/gongxue-base
252 lines
9.7 KiB
JavaScript
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();
|