From 1b6006f8e646bd1d4d04c5aa5b3f54da36e42927 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 30 Jun 2026 01:58:37 +0800 Subject: [PATCH] test: add rls tenant isolation regression --- README.md | 12 +- docs/refactor/backend-handoff-roadmap.md | 5 +- .../multitenant-auth-security-contract.md | 3 + docs/refactor/next-development-todo.md | 3 +- package.json | 1 + scripts/rls-tenant-isolation-test.js | 315 ++++++++++++++++++ 6 files changed, 335 insertions(+), 4 deletions(-) create mode 100644 scripts/rls-tenant-isolation-test.js diff --git a/README.md b/README.md index b82cd7cb..9f36f13c 100644 --- a/README.md +++ b/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 官方修复。 diff --git a/docs/refactor/backend-handoff-roadmap.md b/docs/refactor/backend-handoff-roadmap.md index 166c690a..8935c776 100644 --- a/docs/refactor/backend-handoff-roadmap.md +++ b/docs/refactor/backend-handoff-roadmap.md @@ -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 和小程序双端跑通:登录、首页、题库、练习、错题、收藏、单词、手册、会员、排行榜、个人中心。 - 租户隔离、后台角色权限、资源访问权限、订单权益和内容导入至少有集成测试覆盖。 diff --git a/docs/refactor/multitenant-auth-security-contract.md b/docs/refactor/multitenant-auth-security-contract.md index 22ce4661..8cf30eca 100644 --- a/docs/refactor/multitenant-auth-security-contract.md +++ b/docs/refactor/multitenant-auth-security-contract.md @@ -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 生效。 diff --git a/docs/refactor/next-development-todo.md b/docs/refactor/next-development-todo.md index e6ea5a17..7111723a 100644 --- a/docs/refactor/next-development-todo.md +++ b/docs/refactor/next-development-todo.md @@ -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 云端/自托管连接方案。 diff --git a/package.json b/package.json index f6dfadbb..991d5c81 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/rls-tenant-isolation-test.js b/scripts/rls-tenant-isolation-test.js new file mode 100644 index 00000000..98fdfdd9 --- /dev/null +++ b/scripts/rls-tenant-isolation-test.js @@ -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; +});