forked from wangziqi/gongxue-base
feat: establish production SaaS foundation
This commit is contained in:
251
scripts/bootstrap-backend-runtime-roles.js
vendored
Normal file
251
scripts/bootstrap-backend-runtime-roles.js
vendored
Normal file
@@ -0,0 +1,251 @@
|
||||
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();
|
||||
Reference in New Issue
Block a user