forked from wangziqi/gongxue-base
809 lines
27 KiB
JavaScript
809 lines
27 KiB
JavaScript
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;
|
||
});
|