import pg from 'pg'; import { assertDestructiveTestDatabase, resolveDestructiveTestConfirmation, } from './lib/destructive-test-database-guard.js'; const { Pool } = pg; const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:postgres@127.0.0.1:54322/postgres'; const destructiveTestConfirmation = resolveDestructiveTestConfirmation(); const ids = { mainTenant: '00000000-0000-0000-0000-000000000001', partnerTenant: '00000000-0000-0000-0000-000000000901', normalAuthUser: '00000000-0000-0000-0000-00000000a101', platformAdminAuthUser: '00000000-0000-0000-0000-00000000a999', partnerAdminUser: '00000000-0000-0000-0000-000000000907', }; const readChecks = [ { name: 'tenant_branding', sql: 'select tenant_id::text as tenant_id, brand_name as label from public.tenant_branding order by tenant_id', requiredTenantIds: [ids.mainTenant, ids.partnerTenant], }, { name: 'tenant_settings', sql: 'select tenant_id::text as tenant_id, public_config::text as label from public.tenant_settings order by tenant_id', requiredTenantIds: [ids.mainTenant, ids.partnerTenant], }, { name: 'tenant_domains', sql: 'select tenant_id::text as tenant_id, host as label from public.tenant_domains order by tenant_id', requiredTenantIds: [ids.mainTenant, ids.partnerTenant], }, { name: 'tenant_memberships', sql: 'select tenant_id::text as tenant_id, role as label from public.tenant_memberships order by tenant_id, role', requiredTenantIds: [ids.mainTenant, ids.partnerTenant], }, { name: 'regions', sql: 'select tenant_id::text as tenant_id, name as label from public.regions order by tenant_id', requiredTenantIds: [ids.mainTenant], }, { name: 'questions', sql: 'select tenant_id::text as tenant_id, legacy_id as label from public.questions order by tenant_id, id', requiredTenantIds: [ids.mainTenant], }, { name: 'student_profiles', sql: 'select tenant_id::text as tenant_id, user_id::text as label from public.student_profiles order by tenant_id, user_id', requiredTenantIds: [ids.mainTenant], }, { name: 'orders', sql: 'select tenant_id::text as tenant_id, order_no as label from public.orders order by tenant_id', requiredTenantIds: [ids.mainTenant], }, { name: 'content_assets', sql: 'select tenant_id::text as tenant_id, asset_key as label from public.content_assets order by tenant_id', requiredTenantIds: [ids.mainTenant], }, { name: 'tenant_subscriptions', sql: 'select tenant_id::text as tenant_id, plan_code as label from public.tenant_subscriptions order by tenant_id', requiredTenantIds: [ids.partnerTenant], }, { name: 'tenant_invoices', sql: 'select tenant_id::text as tenant_id, invoice_no as label from public.tenant_invoices order by tenant_id', requiredTenantIds: [ids.partnerTenant], }, { name: 'tenant_usage_records', sql: 'select tenant_id::text as tenant_id, metric_key as label from public.tenant_usage_records order by tenant_id, metric_key', requiredTenantIds: [ids.partnerTenant], }, ]; const writeChecks = [ { name: 'tenant_branding cross-tenant update', setupRole: 'authenticated', tenantId: ids.mainTenant, roleClaim: 'authenticated', sql: ` update public.tenant_branding set slogan = slogan where tenant_id = $1::uuid returning tenant_id::text `, params: [ids.partnerTenant], }, { name: 'regions cross-tenant insert', setupRole: 'authenticated', tenantId: ids.mainTenant, roleClaim: 'authenticated', sql: ` insert into public.regions (tenant_id, legacy_id, name, code, sort_order, is_active) values ($1::uuid, 'rls-cross-tenant-probe', 'RLS 越权探针', 'RLS-X', 9999, false) returning tenant_id::text `, params: [ids.partnerTenant], expectErrorCode: '42501', }, ]; const probeGrantStatements = [ 'grant usage on schema app to authenticated, anon', 'grant usage on schema public to authenticated, anon', `grant select on public.tenant_branding, public.tenant_settings, public.tenant_domains, public.tenant_memberships, public.regions, public.questions, public.student_profiles, public.orders, public.content_assets, public.tenant_subscriptions, public.tenant_invoices, public.tenant_usage_records to authenticated, anon`, 'grant update on public.tenant_branding to authenticated', 'grant insert on public.regions to authenticated', ]; const pool = new Pool({ connectionString: databaseUrl }); const results = []; function pass(name, detail = {}) { results.push({ status: 'pass', name, detail }); console.log(`PASS ${name}`); } function fail(name, message, detail = {}) { results.push({ status: 'fail', name, message, detail }); console.error(`FAIL ${name}: ${message}`); if (Object.keys(detail).length > 0) console.error(JSON.stringify(detail, null, 2)); } function assert(condition, name, message, detail = {}) { if (condition) pass(name, detail); else fail(name, message, detail); } function onlyTenantRows(rows, tenantId) { return rows.every(row => row.tenant_id === tenantId); } function tenantRows(rows, tenantId) { return rows.filter(row => row.tenant_id === tenantId); } function canonicalRows(rows) { return rows.map(row => JSON.stringify(row)).sort(); } function sameRows(actual, expected) { return JSON.stringify(canonicalRows(actual)) === JSON.stringify(canonicalRows(expected)); } async function withRlsContext( client, { dbRole = 'authenticated', tenantId = '', roleClaim = 'authenticated', sub = '' }, action, extraGrantStatements = [], ) { await client.query('begin'); try { for (const statement of [...probeGrantStatements, ...extraGrantStatements]) await client.query(statement); await client.query(`set local role ${dbRole}`); if (tenantId) { await client.query("select set_config('request.jwt.claim.tenant_id', $1, true)", [tenantId]); } else { await client.query("select set_config('request.jwt.claim.tenant_id', '', true)"); } await client.query("select set_config('request.jwt.claim.role', $1, true)", [roleClaim]); await client.query("select set_config('request.jwt.claim.app_role', $1, true)", [roleClaim]); if (sub) await client.query("select set_config('request.jwt.claim.sub', $1, true)", [sub]); const value = await action(); await client.query('rollback'); return value; } catch (error) { await client.query('rollback').catch(() => {}); throw error; } } async function queryAs(client, context, sql, params = []) { return withRlsContext(client, context, async () => { const result = await client.query(sql, params); return result.rows; }); } async function captureReadBaselines(client) { const baselines = new Map(); for (const check of readChecks) { const result = await client.query(check.sql); baselines.set(check.name, result.rows); } return baselines; } async function runReadIsolationChecks(client, baselines) { for (const check of readChecks) { const baselineRows = baselines.get(check.name) || []; const expectedMainRows = tenantRows(baselineRows, ids.mainTenant); const mainRows = await queryAs(client, { tenantId: ids.mainTenant }, check.sql); assert( onlyTenantRows(mainRows, ids.mainTenant), `rls.read.${check.name}.main_no_leak`, '主租户上下文不应看到其它租户数据', { rows: mainRows }, ); assert( sameRows(mainRows, expectedMainRows), `rls.read.${check.name}.main_expected_seed`, '主租户上下文应返回基线快照中该租户的完整数据子集', { expectedRows: expectedMainRows, rows: mainRows }, ); const expectedPartnerRows = tenantRows(baselineRows, ids.partnerTenant); const partnerRows = await queryAs(client, { tenantId: ids.partnerTenant }, check.sql); assert( onlyTenantRows(partnerRows, ids.partnerTenant), `rls.read.${check.name}.partner_no_leak`, '伙伴租户上下文不应看到其它租户数据', { rows: partnerRows }, ); assert( sameRows(partnerRows, expectedPartnerRows), `rls.read.${check.name}.partner_expected_seed`, '伙伴租户上下文应返回基线快照中该租户的完整数据子集', { expectedRows: expectedPartnerRows, rows: partnerRows }, ); const anonymousRows = await queryAs(client, { dbRole: 'anon', tenantId: '', roleClaim: 'anon' }, check.sql); assert( anonymousRows.length === 0, `rls.read.${check.name}.no_tenant_claim_empty`, '没有 tenant_id claim 的上下文不应看到租户数据', { rows: anonymousRows }, ); } } async function runPlatformAdminChecks(client, baselines) { for (const check of readChecks) { const rows = await queryAs( client, { tenantId: '', roleClaim: 'platform_admin', sub: ids.platformAdminAuthUser }, check.sql, ); const baselineRows = baselines.get(check.name) || []; assert( sameRows(rows, baselineRows), `rls.platform_admin.${check.name}.cross_tenant_visibility`, '平台管理员 RLS 旁路应返回当前表的完整基线快照', { expectedRows: baselineRows, rows }, ); } } async function runForgedPlatformAdminChecks(client, baselines) { for (const check of readChecks) { const rows = await queryAs( client, { tenantId: ids.mainTenant, roleClaim: 'platform_admin', sub: ids.normalAuthUser, }, check.sql, ); assert( onlyTenantRows(rows, ids.mainTenant), `rls.forged_platform_admin.${check.name}.no_cross_tenant_leak`, '伪造 platform_admin claim 的普通用户不应获得跨租户可见性', { rows }, ); const expectedMainRows = tenantRows(baselines.get(check.name) || [], ids.mainTenant); assert( sameRows(rows, expectedMainRows), `rls.forged_platform_admin.${check.name}.tenant_scope_preserved`, '伪造 platform_admin claim 后仍应按普通租户上下文执行 RLS', { expectedRows: expectedMainRows, rows }, ); } } async function runPlatformPrivilegeEscalationCheck(client) { const result = await withRlsContext( client, { tenantId: ids.mainTenant, roleClaim: 'platform_admin', sub: ids.normalAuthUser, }, async () => { const before = await client.query( ` select auth_user_id::text, primary_role, status, platform_permissions from public.platform_users where auth_user_id = $1::uuid `, [ids.normalAuthUser], ); const update = await client.query( ` update public.platform_users set primary_role = 'platform_admin', status = 'disabled', platform_permissions = '{"*":true}'::jsonb where auth_user_id = $1::uuid `, [ids.normalAuthUser], ); const after = await client.query( ` select auth_user_id::text, primary_role, status, platform_permissions from public.platform_users where auth_user_id = $1::uuid `, [ids.normalAuthUser], ); return { before: before.rows, updateRowCount: update.rowCount, after: after.rows }; }, ['grant select, update on public.platform_users to authenticated'], ); assert( result.before.length === 1, 'rls.platform_users.normal_user_self_read_probe', '权限提升探针需要能读取普通用户自身记录', result, ); assert( result.updateRowCount === 0, 'rls.platform_users.self_privilege_escalation_blocked', '即使误授 authenticated UPDATE,RLS 也不应允许用户将自身提升为平台超管', result, ); assert( result.after.length === 1 && result.after[0].primary_role === result.before[0].primary_role && result.after[0].status === result.before[0].status && JSON.stringify(result.after[0].platform_permissions) === JSON.stringify(result.before[0].platform_permissions), 'rls.platform_users.sensitive_fields_unchanged', '普通用户的角色、状态和平台权限字段不应被客户端修改', result, ); } async function runWriteIsolationChecks(client) { for (const check of writeChecks) { try { const rows = await queryAs( client, { dbRole: check.setupRole, tenantId: check.tenantId, roleClaim: check.roleClaim }, check.sql, check.params, ); assert( rows.length === 0, `rls.write.${check.name}`, '跨租户写入不应成功返回任何行', { rows }, ); } catch (error) { if (check.expectErrorCode && error.code === check.expectErrorCode) { pass(`rls.write.${check.name}`, { expectedErrorCode: error.code }); } else { fail(`rls.write.${check.name}`, error.message, { code: error.code }); } } } } async function runQuestionVersionIntegrityChecks(client) { const constraints = await client.query( ` select conname, convalidated from pg_constraint where conrelid in ('public.questions'::regclass, 'public.question_versions'::regclass) and conname = any($1::text[]) `, [[ 'questions_tenant_id_id_key', 'question_versions_tenant_question_id_id_key', 'question_versions_tenant_question_fkey', 'questions_tenant_current_version_fkey', ]], ); const constraintState = new Map( constraints.rows.map(row => [row.conname, row.convalidated === true]), ); for (const name of [ 'questions_tenant_id_id_key', 'question_versions_tenant_question_id_id_key', 'question_versions_tenant_question_fkey', 'questions_tenant_current_version_fkey', ]) { assert( constraintState.get(name) === true, `schema.question_versions.${name}.validated`, 'Question version tenant integrity constraints must exist and be validated', { constraints: constraints.rows }, ); } await client.query('begin'); try { const questionA = await client.query( ` insert into public.questions (tenant_id, legacy_id, type, status) values ($1, $2, 'choice', 'draft') returning id `, [ids.mainTenant, `question-integrity-a-${Date.now()}`], ); const questionB = await client.query( ` insert into public.questions (tenant_id, legacy_id, type, status) values ($1, $2, 'choice', 'draft') returning id `, [ids.mainTenant, `question-integrity-b-${Date.now()}`], ); const versionA = await client.query( ` insert into public.question_versions (tenant_id, question_id, version_no, content) values ($1, $2, 1, 'question integrity probe') returning id `, [ids.mainTenant, questionA.rows[0].id], ); await client.query('savepoint cross_tenant_version'); try { await client.query( ` insert into public.question_versions (tenant_id, question_id, version_no, content) values ($1, $2, 2, 'must be rejected') `, [ids.partnerTenant, questionA.rows[0].id], ); fail( 'schema.question_versions.cross_tenant_parent_rejected', 'A question version must not reference a question from another tenant', ); } catch (error) { assert( error.code === '23503', 'schema.question_versions.cross_tenant_parent_rejected', 'Cross-tenant question version insertion must fail with a foreign key violation', { code: error.code, message: error.message }, ); } finally { await client.query('rollback to savepoint cross_tenant_version'); } await client.query('savepoint cross_question_pointer'); try { await client.query( 'update public.questions set current_version_id = $1 where id = $2', [versionA.rows[0].id, questionB.rows[0].id], ); fail( 'schema.questions.cross_question_current_version_rejected', 'A question must not point at another question\'s current version', ); } catch (error) { assert( error.code === '23503', 'schema.questions.cross_question_current_version_rejected', 'Cross-question current version assignment must fail with a foreign key violation', { code: error.code, message: error.message }, ); } finally { await client.query('rollback to savepoint cross_question_pointer'); } const validPointer = await client.query( 'update public.questions set current_version_id = $1 where id = $2 returning id', [versionA.rows[0].id, questionA.rows[0].id], ); assert( validPointer.rowCount === 1, 'schema.questions.same_question_current_version_allowed', 'A question must be able to reference its own version in the same tenant', ); } finally { await client.query('rollback').catch(() => {}); } } async function runCoreTenantForeignKeyIntegrityChecks(client) { const constraintNames = [ 'question_versions_tenant_id_id_key', 'practice_sessions_tenant_id_id_key', 'orders_tenant_id_id_key', 'content_assets_tenant_id_id_key', 'practice_sessions_tenant_user_id_key', 'answer_records_question_version_requires_question_check', 'answer_records_tenant_question_fkey', 'answer_records_tenant_question_version_pair_fkey', 'answer_records_tenant_user_session_fkey', 'favorite_questions_tenant_question_fkey', 'wrong_questions_tenant_question_fkey', 'payments_tenant_order_fkey', 'content_export_jobs_tenant_asset_fkey', ]; const constraints = await client.query( ` select conname, convalidated from pg_constraint where conname = any($1::text[]) `, [constraintNames], ); const constraintState = new Map( constraints.rows.map(row => [row.conname, row.convalidated === true]), ); for (const name of constraintNames) { assert( constraintState.get(name) === true, `schema.core_tenant_foreign_keys.${name}.validated`, 'Core tenant foreign key constraints must exist and be validated', { constraints: constraints.rows }, ); } await client.query('begin'); try { const parent = await client.query( ` select version.question_id as "questionId", version.id as "versionId", session.id as "sessionId", session.user_id as "sessionUserId", (select o.id from public.orders o where o.tenant_id = $1 order by o.id limit 1) as "orderId", (select a.id from public.content_assets a where a.tenant_id = $1 order by a.id limit 1) as "assetId" from lateral ( select v.id, v.question_id from public.question_versions v where v.tenant_id = $1 order by v.id limit 1 ) version cross join lateral ( select s.id, s.user_id from public.practice_sessions s where s.tenant_id = $1 order by s.id limit 1 ) session `, [ids.mainTenant], ); const references = parent.rows[0]; assert( Object.values(references || {}).every(Boolean), 'schema.core_tenant_foreign_keys.parent_fixtures_available', 'Core tenant foreign key probes require main-tenant parent fixtures', { references }, ); const probes = [ { name: 'answer_question', sql: `insert into public.answer_records (tenant_id, user_id, question_id) values ($1, $2, $3)`, params: [ids.partnerTenant, ids.partnerAdminUser, references.questionId], }, { name: 'answer_question_version', sql: `insert into public.answer_records (tenant_id, user_id, question_id, question_version_id) values ($1, $2, $3, $4)`, params: [ids.partnerTenant, ids.partnerAdminUser, references.questionId, references.versionId], }, { name: 'answer_practice_session', sql: `insert into public.answer_records (tenant_id, user_id, practice_session_id) values ($1, $2, $3)`, params: [ids.partnerTenant, ids.partnerAdminUser, references.sessionId], }, { name: 'favorite_question', sql: `insert into public.favorite_questions (tenant_id, user_id, question_id) values ($1, $2, $3)`, params: [ids.partnerTenant, ids.partnerAdminUser, references.questionId], }, { name: 'wrong_question', sql: `insert into public.wrong_questions (tenant_id, user_id, question_id) values ($1, $2, $3)`, params: [ids.partnerTenant, ids.partnerAdminUser, references.questionId], }, { name: 'payment_order', sql: `insert into public.payments (tenant_id, order_id, provider, amount_cents) values ($1, $2, 'rls-integrity-probe', 1)`, params: [ids.partnerTenant, references.orderId], }, { name: 'export_asset', sql: `insert into public.content_export_jobs ( tenant_id, export_type, format, scope_type, scope_id, asset_id ) values ($1, 'questions', 'json', 'entry', gen_random_uuid(), $2)`, params: [ids.partnerTenant, references.assetId], }, ]; for (const probe of probes) { const savepoint = `core_tenant_fk_${probe.name}`; await client.query(`savepoint ${savepoint}`); try { await client.query(probe.sql, probe.params); fail( `schema.core_tenant_foreign_keys.${probe.name}.cross_tenant_rejected`, 'Cross-tenant parent references must be rejected', ); } catch (error) { assert( error.code === '23503', `schema.core_tenant_foreign_keys.${probe.name}.cross_tenant_rejected`, 'Cross-tenant parent references must fail with a foreign key violation', { code: error.code, message: error.message }, ); } finally { await client.query(`rollback to savepoint ${savepoint}`); } } const validAnswer = await client.query( ` insert into public.answer_records ( tenant_id, user_id, question_id, question_version_id, practice_session_id ) values ($1, $2, $3, $4, $5) returning id `, [ids.mainTenant, references.sessionUserId, references.questionId, references.versionId, references.sessionId], ); assert( validAnswer.rowCount === 1, 'schema.core_tenant_foreign_keys.same_tenant_answer_allowed', 'A same-tenant answer record must remain writable after composite foreign keys', ); const differentQuestion = await client.query( ` select id from public.questions where tenant_id = $1 and id <> $2 order by id limit 1 `, [ids.mainTenant, references.questionId], ); assert( Boolean(differentQuestion.rows[0]?.id), 'schema.core_tenant_foreign_keys.different_question_fixture_available', 'Answer version-question integrity probe requires a second question', ); await client.query('savepoint answer_version_question_pair'); try { await client.query( ` insert into public.answer_records ( tenant_id, user_id, question_id, question_version_id ) values ($1, $2, $3, $4) `, [ids.mainTenant, '00000000-0000-0000-0000-000000000101', differentQuestion.rows[0].id, references.versionId], ); fail( 'schema.core_tenant_foreign_keys.answer_version_question_pair_rejected', 'An answer must not combine a question with another question\'s version', ); } catch (error) { assert( error.code === '23503', 'schema.core_tenant_foreign_keys.answer_version_question_pair_rejected', 'Mismatched answer question/version pairs must fail with a foreign key violation', { code: error.code, message: error.message }, ); } finally { await client.query('rollback to savepoint answer_version_question_pair'); } const alternateUser = await client.query( ` select user_id as "userId" from public.tenant_memberships where tenant_id = $1 and user_id <> $2 order by user_id limit 1 `, [ids.mainTenant, references.sessionUserId], ); assert( Boolean(alternateUser.rows[0]?.userId), 'schema.core_tenant_foreign_keys.alternate_session_user_fixture_available', 'Answer session-user integrity probe requires another tenant user', ); await client.query('savepoint answer_session_user_pair'); try { await client.query( ` insert into public.answer_records ( tenant_id, user_id, practice_session_id ) values ($1, $2, $3) `, [ids.mainTenant, alternateUser.rows[0].userId, references.sessionId], ); fail( 'schema.core_tenant_foreign_keys.answer_session_user_pair_rejected', 'An answer must not reference another user\'s practice session', ); } catch (error) { assert( error.code === '23503', 'schema.core_tenant_foreign_keys.answer_session_user_pair_rejected', 'Mismatched answer user/session pairs must fail with a foreign key violation', { code: error.code, message: error.message }, ); } finally { await client.query('rollback to savepoint answer_session_user_pair'); } } finally { await client.query('rollback').catch(() => {}); } } async function verifySeed(client, baselines) { const result = await client.query( ` select tenant_id::text, count(*)::int as count from public.tenant_branding where tenant_id in ($1::uuid, $2::uuid) group by tenant_id `, [ids.mainTenant, ids.partnerTenant], ); const counts = new Map(result.rows.map(row => [row.tenant_id, Number(row.count)])); assert( counts.get(ids.mainTenant) === 1 && counts.get(ids.partnerTenant) === 1, 'rls.seed.main_and_partner_tenants', '需要先运行 npm run db:smoke-seed,确保主租户和伙伴租户 seed 都存在', { rows: result.rows }, ); for (const check of readChecks) { const rows = baselines.get(check.name) || []; for (const tenantId of check.requiredTenantIds) { const fixtures = tenantRows(rows, tenantId); assert( fixtures.length > 0, `rls.seed.${check.name}.${tenantId === ids.mainTenant ? 'main' : 'partner'}_fixture`, 'RLS 深测表必须有明确的非空租户夹具,避免空快照假通过', { tenantId, rows }, ); } } } async function main() { const client = await pool.connect(); try { await assertDestructiveTestDatabase({ client, databaseUrl, confirmation: destructiveTestConfirmation, operation: 'RLS tenant isolation test', }); const baselines = await captureReadBaselines(client); await verifySeed(client, baselines); await runReadIsolationChecks(client, baselines); await runPlatformAdminChecks(client, baselines); await runForgedPlatformAdminChecks(client, baselines); await runPlatformPrivilegeEscalationCheck(client); await runWriteIsolationChecks(client); await runQuestionVersionIntegrityChecks(client); await runCoreTenantForeignKeyIntegrityChecks(client); } finally { client.release(); await pool.end(); } const failed = results.filter(item => item.status === 'fail'); console.log(`\nRLS tenant isolation checks: ${results.length - failed.length} passed, ${failed.length} failed.`); if (failed.length > 0) process.exitCode = 1; } main().catch(error => { console.error(error); process.exitCode = 1; });