forked from wangziqi/gongxue-base
test: add rls tenant isolation regression
This commit is contained in:
12
README.md
12
README.md
@@ -260,6 +260,7 @@ npm run test:worker:assets
|
||||
npm run test:worker:exports
|
||||
npm run test:worker:imports
|
||||
npm run test:worker:public-banks
|
||||
npm run test:rls
|
||||
```
|
||||
|
||||
## 生产就绪检查
|
||||
@@ -278,6 +279,14 @@ npm run readiness:production:db
|
||||
|
||||
这个检查会阻断默认弱密钥、`CORS=*`、mock 短信、legacy 身份头、local_dev 存储、对象存储未配置、CRM insecure localhost 等生产风险;带 `:db` 的版本还会检查租户 provider 公开配置是否混入密钥、活跃短信/OAuth/支付 provider 是否缺少 `app_private.tenant_secrets`、域名是否未验证。
|
||||
|
||||
RLS 需要同时跑动态隔离验收:
|
||||
|
||||
```bash
|
||||
npm run test:rls
|
||||
```
|
||||
|
||||
这个命令会先执行本地 smoke seed,再在事务内模拟 Supabase `authenticated/anon/platform_admin` JWT claims,验证主租户和合作商租户的品牌、设置、域名、成员、题库、订单、资源、SaaS 账单等代表性表不会跨租户泄露;同时验证无 `tenant_id` claim 不能读取租户数据,普通租户上下文不能跨租户写入。脚本里的临时 grant 会随事务回滚,不会改变实际 schema 权限。
|
||||
|
||||
## PocketBase 迁移 Dry-Run
|
||||
|
||||
把旧 PocketBase 导出的集合 JSON 放到仓库根目录 `pb_export/` 后,先执行不写数据库的静态 dry-run:
|
||||
@@ -367,13 +376,14 @@ npm run check:worker
|
||||
npm run test:worker:commerce
|
||||
npm run test:worker:assets
|
||||
npm run test:worker:exports
|
||||
npm run test:rls
|
||||
npm run test:api
|
||||
npm run check:refactor
|
||||
npm run audit:runtime
|
||||
git diff --check
|
||||
```
|
||||
|
||||
结果:通过。`npm run test:api` 覆盖资源访问事件、锁定 CDN 资源拒绝、provider-managed CDN 显式放行、学生短 TTL 下载/预览、访问记录查询、安全扫描门禁、官方账单下载任务权限和脱敏响应、异常订单运营台、人工调整凭证提交/复核/事件/报表、租户隔离,以及凭证审批不修改订单/支付/权益。`npm run test:worker:commerce` 覆盖支付/退款补偿、微信/支付宝官方账单下载、账单 hash 校验、导入 `provider_download` 对账批次和密钥不泄露。`npm run test:worker:assets` 覆盖托管资源复检、内置安全扫描、外部 HTTP scanner 通过/失败/不可用 fail-closed、扫描失败/跳过事件和异常资源自动下架。`npm run test:worker:exports` 覆盖导出 worker 生成可信资源并标记 `securityScanStatus=passed`。`npm run audit:runtime` 无 high/critical 漏洞;当前运行时依赖树仍有 `exceljs -> uuid` 的 moderate 级提示,修复需要破坏性降级 `exceljs`,后续应在导入 Excel 回归充分后单独处理。
|
||||
结果:通过。`npm run test:rls` 覆盖 75 条运行时 RLS 断言,包含主租户、合作商租户、无租户 claim、平台管理员旁路和跨租户写入拒绝。`npm run test:api` 覆盖资源访问事件、锁定 CDN 资源拒绝、provider-managed CDN 显式放行、学生短 TTL 下载/预览、访问记录查询、安全扫描门禁、官方账单下载任务权限和脱敏响应、异常订单运营台、人工调整凭证提交/复核/事件/报表、租户隔离,以及凭证审批不修改订单/支付/权益。`npm run test:worker:commerce` 覆盖支付/退款补偿、微信/支付宝官方账单下载、账单 hash 校验、导入 `provider_download` 对账批次和密钥不泄露。`npm run test:worker:assets` 覆盖托管资源复检、内置安全扫描、外部 HTTP scanner 通过/失败/不可用 fail-closed、扫描失败/跳过事件和异常资源自动下架。`npm run test:worker:exports` 覆盖导出 worker 生成可信资源并标记 `securityScanStatus=passed`。`npm run audit:runtime` 无 high/critical 漏洞;当前运行时依赖树仍有 `exceljs -> uuid` 的 moderate 级提示,修复需要破坏性降级 `exceljs`,后续应在导入 Excel 回归充分后单独处理。
|
||||
|
||||
注意:`apps/taro` 是静态构建工程,线上发布 `apps/taro/dist/**`,不发布 `node_modules`。Taro 4.2.0 当前构建工具链仍会触发 `npm run audit:taro:toolchain` 的上游 high/critical 提示,不能用 `npm audit fix --force` 降级到 Taro 3 破坏构建;上线验收时以 `audit:runtime`、构建产物、前端密钥检查和静态服务器配置为准,并持续跟进 Taro 官方修复。
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
| 模块 | 当前状态 | 已经具备 | 上线前还要补 |
|
||||
| --- | --- | --- | --- |
|
||||
| 多租户底座 | 可联调 | 租户、域名、品牌、设置、RLS 基础、审计、Supabase JWT/API 身份映射 | 真实云端 Auth/JWKS 回归、生产 RLS 深测 |
|
||||
| 多租户底座 | 可联调 | 租户、域名、品牌、设置、RLS 基础、审计、Supabase JWT/API 身份映射;`npm run test:rls` 已提供本地动态租户隔离验收,覆盖主租户、合作商租户、无租户 claim、平台管理员旁路和跨租户写入拒绝 | 真实云端 Auth/JWKS 回归、生产 RLS 深测 |
|
||||
| 平台后台 | 基础完成 | 租户、套餐、订阅、账单、服务费、用量、公共题库授权、公共题库自动同步 worker、公共题库冲突单条/批量处理 API、公共题库同步通知第一版 | 自动计费、平台审计、更完整运营消息 |
|
||||
| 租户后台 | 可联调 | 品牌、域名、支付账户、登录配置、密钥掩码、活动、兑换码、优惠券、勋章管理/发放、成员权限、角色模板、菜单/模块/字段权限配置 API、班级/教师/学生范围权限;Taro 工作台已接权限驱动模块入口,学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户设置页已接角色模板和成员绑定操作台第一版,营销中心已接 CRM 配置/队列和分佣结算操作台第一版 | 更细的数据范围组合、成员批量运营、真实打款/导出/凭证和完整权限菜单 |
|
||||
| 题库与练习 | 可联调 | 内容入口、任意深度分类、题目集合、顺序/随机/全真模拟蓝图、组卷快照、客观题后端判分、主观题 `selfJudgedCorrect` 自评、阅读理解/案例分析 `subAnswers` 多小题判分、答题、错题、收藏、模考报告、排行榜、公共题库采纳快照、手动同步、自动同步 worker、冲突查询/单条和批量处理 API、公共题库同步通知、JSON/试卷 payload 导出、PDF/Word 异步导出 worker、水印和资料发布路径、每日一练九宫格 metadata、PDF/Word 运营版式和 ZIP 图片素材包 | 长题干/公式图片混排体验、导出模板精排、导出操作台、排行榜防刷/预聚合 |
|
||||
@@ -75,7 +75,7 @@
|
||||
|
||||
### P0:上云测试和前端主链路前必须处理
|
||||
|
||||
- 生产鉴权:API 已支持 Supabase Auth JWT、HS256 本地测试和 JWKS/RS256 集成回归;继续做真实云端 Auth/JWKS 回归、RLS 深测,并在生产关闭 `x-user-id` 与 `x-platform-admin-key` 兼容入口。生产配置 JWKS 时必须同时配置 `AUTH_JWT_ISSUER`。
|
||||
- 生产鉴权:API 已支持 Supabase Auth JWT、HS256 本地测试和 JWKS/RS256 集成回归;`npm run test:rls` 已补本地运行时 RLS 深测,继续做真实云端 Auth/JWKS 回归和生产数据库 RLS 验收,并在生产关闭 `x-user-id` 与 `x-platform-admin-key` 兼容入口。生产配置 JWKS 时必须同时配置 `AUTH_JWT_ISSUER`。
|
||||
- 对象存储:上传/下载签名已接入阿里云 OSS、腾讯云 COS、Supabase Storage;上传确认、PDF/图片预览签名、动态水印上下文、assets worker 复检、内置安全扫描、外部 HTTP scanner 接入层和题库导出 PDF/Word/每日一练 ZIP worker 已完成,继续补视频播放防盗链、真实 AV/内容安全服务联调和转码/CDN 级水印。
|
||||
- 真实数据 dry-run:导出 PocketBase 用户、题库、单词、知识手册、分数线、订单、权益,先跑 `npm run pb:import:dry-run`,再跑迁移和校验报告。
|
||||
- 生产环境配置:`.env.example` 和 `npm run readiness:production` / `npm run readiness:production:db` 已补;继续补数据库迁移流程、备份恢复、日志、告警和 API 容器部署说明。
|
||||
@@ -105,6 +105,7 @@
|
||||
后端进入“可支撑前端全面联调”的最低标准:
|
||||
|
||||
- `npm run check:refactor` 在本地通过。
|
||||
- `npm run test:rls` 在本地通过,且新增租户表的 RLS 动态用例已补齐或有明确豁免说明。
|
||||
- 使用真实 PocketBase 导出数据完成一次 dry-run,产出问题清单和修复后的二次导入报告。
|
||||
- 核心学生链路 API 可以在 Taro H5 和小程序双端跑通:登录、首页、题库、练习、错题、收藏、单词、手册、会员、排行榜、个人中心。
|
||||
- 租户隔离、后台角色权限、资源访问权限、订单权益和内容导入至少有集成测试覆盖。
|
||||
|
||||
@@ -92,6 +92,8 @@ Supabase 官方允许前端用 Data API 访问数据,但前提是 RLS、最小
|
||||
- API SQL 必须显式带 `tenant_id`。
|
||||
- 测试必须覆盖跨租户读取、写入、下载、后台权限越权。
|
||||
- `npm run readiness:production:db` 会阻断带 `tenant_id` 但未启用 RLS、没有 policy、或 public policy 未包含 `app.current_tenant_id()` 的表。
|
||||
- `npm run test:rls` 会在本地 smoke seed 后模拟 Supabase `authenticated/anon/platform_admin` JWT claims,动态验证主租户和合作商租户代表性表不会跨租户读写泄露,并验证无 `tenant_id` claim 不能读取租户数据。
|
||||
- `test:rls` 为了模拟 PostgREST 角色会在事务内临时授予 `authenticated/anon` 查询探针权限,所有 grant、写入探针和跨租户插入都会回滚;它验证的是 RLS policy 行为,不代表生产要开放核心业务表直连。
|
||||
- 新增租户表时必须同时提交 migration、RLS policy、API 权限测试或明确说明只允许平台级访问的原因。
|
||||
|
||||
## 前端必须遵守
|
||||
@@ -183,6 +185,7 @@ provider event id 幂等
|
||||
|
||||
- `npm run audit:runtime` 为 0 high/critical 漏洞;Taro 构建工具链 audit 单独跟踪,不能用破坏性降级绕过。
|
||||
- `npm run check:refactor` 通过。
|
||||
- `npm run test:rls` 通过;必须确认主租户、合作商租户、无租户 claim、平台管理员旁路和跨租户写入拒绝都有运行时证据。
|
||||
- `npm run readiness:production` 没有 blocker。
|
||||
- `npm run readiness:production:db` 没有 blocker,尤其是 `db.rls.tenant_tables_enabled`、`db.rls.tenant_tables_policy`、`db.rls.public_tenant_context` 必须通过。
|
||||
- 生产环境启动时默认密钥 fail-fast 生效。
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
1. 生产鉴权
|
||||
- 已支持 Supabase Auth JWT 和迁移期 `tk_` session 双入口;JWT 通过 `auth.users.id -> platform_users.auth_user_id -> tenant_memberships` 映射业务身份。
|
||||
- 已覆盖学生、租户管理员、平台管理员、错租户、坏签名、禁用 legacy header 的 API 集成测试。
|
||||
- 已补自定义角色模板、菜单/模块/字段级配置 API、班级/学生范围权限;继续补真实云端 Auth/JWKS 回归和 RLS 深测。
|
||||
- 已补自定义角色模板、菜单/模块/字段级配置 API、班级/学生范围权限;已新增 `npm run test:rls` 本地动态 RLS 深测,覆盖主租户、合作商租户、无租户 claim、平台管理员旁路和跨租户写入拒绝;继续补真实云端 Auth/JWKS 回归和生产数据库 RLS 验收。
|
||||
- 前端联调时禁止继续使用 `x-user-id`;`x-tenant-id` 只作为租户上下文,不能作为身份依据。
|
||||
|
||||
2. 对象存储
|
||||
@@ -72,6 +72,7 @@
|
||||
|
||||
4. 部署配置
|
||||
- 生产 `.env` 模板和 `npm run readiness:production` / `npm run readiness:production:db` 已补,后续上云必须作为验收 gate。
|
||||
- 本地/预生产必须同时跑 `npm run test:rls`,它验证运行时 JWT claim 下的租户隔离,和 `readiness:production:db` 的静态 policy 检查互补。
|
||||
- 确认数据库迁移流程、备份恢复、日志、告警。
|
||||
- 准备 API 容器部署和 Supabase 云端/自托管连接方案。
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
"test:worker:exports": "npm run db:smoke-seed && npm run build:worker && node scripts/export-worker-integration-test.js",
|
||||
"test:worker:imports": "npm run db:smoke-seed && npm run build:worker && node scripts/import-worker-integration-test.js",
|
||||
"test:worker:public-banks": "npm run db:smoke-seed && npm run build:worker && node scripts/public-bank-worker-integration-test.js",
|
||||
"test:rls": "npm run db:smoke-seed && node scripts/rls-tenant-isolation-test.js",
|
||||
"test:readiness": "node scripts/production-readiness-check-test.js && node scripts/production-config-failfast-test.js && node --import tsx scripts/taro-runtime-config-test.js && node --import tsx scripts/taro-api-auth-mode-test.js",
|
||||
"test:pb:dry-run": "node scripts/pb-dry-run-report-test.js",
|
||||
"readiness:production": "node scripts/production-readiness-check.js --skip-db",
|
||||
|
||||
315
scripts/rls-tenant-isolation-test.js
Normal file
315
scripts/rls-tenant-isolation-test.js
Normal file
@@ -0,0 +1,315 @@
|
||||
import pg from 'pg';
|
||||
|
||||
const { Pool } = pg;
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:postgres@127.0.0.1:54322/postgres';
|
||||
|
||||
const ids = {
|
||||
mainTenant: '00000000-0000-0000-0000-000000000001',
|
||||
partnerTenant: '00000000-0000-0000-0000-000000000901',
|
||||
mainUser: '00000000-0000-0000-0000-000000000101',
|
||||
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',
|
||||
expectMain: true,
|
||||
expectPartner: true,
|
||||
},
|
||||
{
|
||||
name: 'tenant_settings',
|
||||
sql: 'select tenant_id::text as tenant_id, public_config::text as label from public.tenant_settings order by tenant_id',
|
||||
expectMain: true,
|
||||
expectPartner: true,
|
||||
},
|
||||
{
|
||||
name: 'tenant_domains',
|
||||
sql: 'select tenant_id::text as tenant_id, host as label from public.tenant_domains order by tenant_id',
|
||||
expectMain: true,
|
||||
expectPartner: true,
|
||||
},
|
||||
{
|
||||
name: 'tenant_memberships',
|
||||
sql: 'select tenant_id::text as tenant_id, role as label from public.tenant_memberships order by tenant_id, role',
|
||||
expectMain: true,
|
||||
expectPartner: true,
|
||||
},
|
||||
{
|
||||
name: 'regions',
|
||||
sql: 'select tenant_id::text as tenant_id, name as label from public.regions order by tenant_id',
|
||||
expectMain: true,
|
||||
expectPartner: true,
|
||||
},
|
||||
{
|
||||
name: 'questions',
|
||||
sql: 'select tenant_id::text as tenant_id, legacy_id as label from public.questions order by tenant_id, id',
|
||||
expectMain: true,
|
||||
expectPartner: false,
|
||||
},
|
||||
{
|
||||
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',
|
||||
expectMain: true,
|
||||
expectPartner: false,
|
||||
},
|
||||
{
|
||||
name: 'orders',
|
||||
sql: 'select tenant_id::text as tenant_id, order_no as label from public.orders order by tenant_id',
|
||||
expectMain: true,
|
||||
expectPartner: false,
|
||||
},
|
||||
{
|
||||
name: 'content_assets',
|
||||
sql: 'select tenant_id::text as tenant_id, asset_key as label from public.content_assets order by tenant_id',
|
||||
expectMain: true,
|
||||
expectPartner: false,
|
||||
},
|
||||
{
|
||||
name: 'tenant_subscriptions',
|
||||
sql: 'select tenant_id::text as tenant_id, plan_code as label from public.tenant_subscriptions order by tenant_id',
|
||||
expectMain: false,
|
||||
expectPartner: true,
|
||||
},
|
||||
{
|
||||
name: 'tenant_invoices',
|
||||
sql: 'select tenant_id::text as tenant_id, invoice_no as label from public.tenant_invoices order by tenant_id',
|
||||
expectMain: false,
|
||||
expectPartner: true,
|
||||
},
|
||||
{
|
||||
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',
|
||||
expectMain: false,
|
||||
expectPartner: true,
|
||||
},
|
||||
];
|
||||
|
||||
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 hasTenantRows(rows, tenantId) {
|
||||
return rows.some(row => row.tenant_id === tenantId);
|
||||
}
|
||||
|
||||
async function withRlsContext(client, { dbRole = 'authenticated', tenantId = '', roleClaim = 'authenticated', sub = '' }, action) {
|
||||
await client.query('begin');
|
||||
try {
|
||||
for (const statement of probeGrantStatements) 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 runReadIsolationChecks(client) {
|
||||
for (const check of readChecks) {
|
||||
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(
|
||||
hasTenantRows(mainRows, ids.mainTenant) === check.expectMain,
|
||||
`rls.read.${check.name}.main_expected_seed`,
|
||||
'主租户 seed 数据存在性不符合预期',
|
||||
{ expected: check.expectMain, rows: mainRows },
|
||||
);
|
||||
|
||||
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(
|
||||
hasTenantRows(partnerRows, ids.partnerTenant) === check.expectPartner,
|
||||
`rls.read.${check.name}.partner_expected_seed`,
|
||||
'伙伴租户 seed 数据存在性不符合预期',
|
||||
{ expected: check.expectPartner, 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) {
|
||||
for (const check of readChecks) {
|
||||
const rows = await queryAs(client, { tenantId: '', roleClaim: 'platform_admin', sub: ids.mainUser }, check.sql);
|
||||
const hasMain = hasTenantRows(rows, ids.mainTenant);
|
||||
const hasPartner = hasTenantRows(rows, ids.partnerTenant);
|
||||
assert(
|
||||
hasMain === check.expectMain && hasPartner === check.expectPartner,
|
||||
`rls.platform_admin.${check.name}.cross_tenant_visibility`,
|
||||
'平台管理员 RLS 旁路应只暴露当前表已有的多租户 seed 数据',
|
||||
{ expectedMain: check.expectMain, expectedPartner: check.expectPartner, rows },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 verifySeed(client) {
|
||||
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 },
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await verifySeed(client);
|
||||
await runReadIsolationChecks(client);
|
||||
await runPlatformAdminChecks(client);
|
||||
await runWriteIsolationChecks(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;
|
||||
});
|
||||
Reference in New Issue
Block a user