# 多租户与鉴权安全契约 更新时间:2026-06-29 这个系统后续要卖给同行作为题库 SaaS,因此租户隔离、鉴权、资源权限和审计是商用红线。前端可以先按迁移期接口联调,也可以按 Supabase 官方推荐使用 publishable key + RLS 的客户端能力管理 Auth/session,但正式上云验收前必须完成本文件的 P0 项。 ## 安全边界 | 边界 | 规则 | | --- | --- | | 平台超级管理员 | 可管理全部租户、SaaS 套餐、订阅、账单、用量、公共题库 | | 租户管理员 | 只能管理自己租户的品牌、域名、成员、内容、营销、订单和 CRM | | 租户成员 | 按角色和 permissions 访问,例如运营、教师、销售、代理 | | 学生用户 | 只能访问自己所在租户下被授权的内容和自己的学习数据 | | 公共题库 | 必须通过平台授权/租户采纳后才对租户可见 | | 私有资源 | 必须通过后端权限校验后签名访问,不能前端直连对象存储 | ## 当前迁移期状态 当前后端已经进入“session 优先、迁移头受控兼容”的状态: - `Authorization: Bearer ` 会优先解析 `app_private.auth_sessions`,并作为用户身份来源。 - `Authorization: Bearer ` 已支持服务端验签,后端通过 `auth.users.id -> platform_users.auth_user_id -> tenant_memberships` 映射到业务用户和租户成员。 - Supabase JWT 支持 `AUTH_JWT_SECRET` 或 `AUTH_JWT_JWKS_URL`;生产推荐优先配置 Supabase Auth JWKS,或在自托管兼容模式下配置强随机 JWT secret。 - JWT 可以在 `app_metadata.tenant_id` 或请求租户上下文中确定当前租户;如果两者冲突,后端拒绝,不允许前端覆盖 token 中的租户声明。 - 登录后如果请求中的 `x-user-id`、query/body `userId` 与 session 用户不一致,后端返回 `AUTH_USER_MISMATCH`。 - 登录后如果请求中的 `x-tenant-id` 与 session 租户不一致,后端返回 `AUTH_TENANT_MISMATCH`。 - 带了无效 bearer token 的用户态接口不会回退到 `x-user-id`。 - `x-user-id` 或 body/query 的 `userId` 只允许在 `ALLOW_LEGACY_AUTH_HEADERS=true` 的本地/迁移期环境使用。 - `x-platform-admin-key` 只允许在 `ALLOW_PLATFORM_ADMIN_KEY=true` 的本地/迁移期环境使用。 - 本地短信 provider 可使用 `mock`。 - 真实短信 provider 已支持阿里云和腾讯云,密钥只能从 `app_private.tenant_secrets` 读取。 - 微信小程序登录已由后端调用 `code2Session`,前端不得接触 AppSecret 或 session_key。 - `NODE_ENV=production` 下禁止 `ALLOW_LEGACY_AUTH_HEADERS=true`、`ALLOW_PLATFORM_ADMIN_KEY=true`、`AUTH_SMS_PROVIDER=mock`、默认/弱密钥和 `CORS_ORIGIN=*`。 这些只允许用于本地开发和内网联调,不允许作为正式云端验收方案。 Supabase 官方允许前端用 Data API 访问数据,但前提是 RLS、最小 grant 和 JWT 权限模型都正确。本项目的核心业务表默认不开放给 Taro 直写;任何新增直连表都必须先通过 RLS、跨租户、权限和性能评审。 ## P0:正式云端测试前必须完成 1. 正式用户鉴权 - 已支持服务端 session 和 Supabase Auth JWT 解析可信 userId。 - 生产前必须用真实 Supabase Auth 项目或自托管 Auth 实例跑一轮云端 JWT 回归。 - 前端禁止通过 query/body/header 指定 userId。 - `GET /api/auth/me` 已支持 Supabase JWT;后续要补租户成员、角色、权限返回。 2. 正式租户上下文 - H5 可由域名解析租户。 - 小程序可由 tenantCode 解析租户。 - 后端必须校验用户是否属于该租户。 - 跨租户请求必须返回 403 或 404。 3. 平台管理员鉴权 - `x-platform-admin-key` 已可通过 `ALLOW_PLATFORM_ADMIN_KEY=false` 禁用。 - 已支持平台管理员 Supabase JWT,且以后端 `platform_users.primary_role='platform_admin'` 为准,不只信 JWT claim。 - 生产前继续补平台后台关键操作审计报表和更细权限点。 4. 生产配置 fail-fast - `NODE_ENV=production` 时禁止默认 `AUTH_CODE_PEPPER`。 - 禁止默认 `AUTH_SESSION_SECRET`。 - 禁止默认 `AUTH_JWT_SECRET`,除非配置了 `AUTH_JWT_JWKS_URL`。 - 禁止默认 `PLATFORM_ADMIN_API_KEY`。 - 禁止 `CORS_ORIGIN=*`。 - 禁止 `AUTH_SMS_PROVIDER=mock`。 - 禁止 `ALLOW_LEGACY_AUTH_HEADERS=true`。 - 禁止 `ALLOW_PLATFORM_ADMIN_KEY=true`。 - 上云前必须运行 `npm run readiness:production`;连接生产数据库后再运行 `npm run readiness:production:db`。 5. 请求体大小限制 - 普通 JSON API 必须有默认上限。 - 导入接口可以有更大上限,但必须可配置且有最大值。 - 超限返回 413。 - 当前默认:`MAX_JSON_BODY_BYTES=1048576`,`MAX_IMPORT_JSON_BODY_BYTES=10485760`,硬上限 50MB。 6. 租户密钥保护 - 商户密钥、短信 secret、OAuth secret 不允许明文长期存储。 - 生产应使用 KMS/Vault 或 envelope encryption。 - API 只返回 `secretRef`、掩码和配置状态。 - provider endpoint 生产环境必须使用 HTTPS 官方域名;本地测试才允许 `localhost/127.0.0.1` fake server。 7. RLS 与 API 双层回归 - 数据库 RLS 要按 `tenant_id` 拦截。 - API SQL 必须显式带 `tenant_id`。 - 测试必须覆盖跨租户读取、写入、下载、后台权限越权。 - `npm run readiness:production:db` 会阻断带 `tenant_id` 但未启用 RLS、没有 policy、或 public policy 未包含 `app.current_tenant_id()` 的表。 - 新增租户表时必须同时提交 migration、RLS policy、API 权限测试或明确说明只允许平台级访问的原因。 ## 前端必须遵守 - 不信任本地缓存里的 tenantId/userId 作为安全依据。 - 前端只能使用 Supabase publishable key,不能出现 secret key 或 service role key。 - 不在页面里保存或展示任何商户密钥、短信密钥、OAuth secret。 - 不在前端硬编码对象存储 bucket、私有资源路径、商户号。 - 不在前端自行开通会员权益;必须通过订单/支付/激活码后端流程。 - 不允许“切换销售归属”这类破坏首绑保护的入口,除非后端提供带权限的管理接口。 - 不在前端直接判断“这个用户能不能看某题/某视频/某资料”的最终结果;必须请求后端。 - 切换租户、退出登录、登录新账号时,清理旧租户缓存和用户缓存。 ## 后端接口约定 所有业务表查询必须满足: ```text where tenant_id = currentTenantId ``` 管理类写接口必须满足: ```text requireTenantPermission(auth, ':') ``` 平台类接口必须满足: ```text requirePlatformAdmin(auth) ``` 资源下载必须满足: ```text content_assets 台账存在 资源属于当前 tenant 资源状态允许访问 用户权益满足 visibility/access_rules 返回短期签名 URL ``` 支付 webhook 必须满足: ```text 验签通过 provider event id 幂等 订单 tenant_id 匹配 金额以后端订单金额为准 事务内更新 payment/order/entitlement 记录 payment_events ``` ## 权限点现状 当前默认角色: | 角色 | 默认权限 | | --- | --- | | tenant_owner | `*` | | tenant_admin | `*` | | tenant_operator | 内容、营销、兑换码/优惠券只读、客资/CRM 只读 | | teacher | 内容维护、班级查看、学生查看 | | sales | 兑换码、优惠券、客资 | | agent | 兑换码/优惠券只读、本人的客资 | | student | 无后台权限 | 已支持租户自定义角色模板: - `tenant_role_templates` 保存租户内模板,成员通过 `tenant_memberships.role_template_id` 绑定。 - 权限判断顺序:成员 `permissions` 显式覆盖 > 角色模板 `permissions` > 系统角色默认权限。 - 模板可保存 `menuPermissions`、`modulePermissions`、`fieldPermissions`、`dataScope`,供 Taro/管理台做菜单、模块、字段可见性和数据范围 UI。 - 模板含 `*`、`tenant_owner`、`tenant_admin` 等管理员级能力时,只有租户 owner 可创建或授予;普通租户管理员不能自造全权限模板。 - 角色模板创建、更新、禁用都会写入 `audit_logs`。 - `tenant_classes` 和 `tenant_class_members` 提供班级、班主任、教师、助教、学生分组边界。 - 教师如无 `classes:write`、`students:write`、`members:read` 等全局管理权限,只能查看自己在 `tenant_class_members` 中负责的班级及这些班级下的学生;也可由角色模板 `dataScope.classIds` 显式限定。 - 学生手机号等敏感字段可由 `fieldPermissions` 控制,后端会对不可见字段返回 `null`,前端不得绕过其它接口补取。 - 学生批量导入、学生禁用/恢复、批量分班、备注、跟进任务分别使用 `students:bulk:write`、`students:status:write`、`students:notes:*`、`students:followups:*` 权限点。 - 教师默认只可对范围内学生创建备注和跟进任务;批量导入、禁用/恢复学生、跨班级指派跟进任务必须显式授权并通过后端范围校验。 - 备注支持 `tenant_staff`、`class_staff`、`author_only` 可见性;`author_only` 备注只能由作者或全局学生管理权限账号更新。 后续要补: - 前端角色模板配置 UI。 - 更细的数据范围 UI,例如地区、题库、销售团队、本人客资、班级学生组合规则。 ## 上线前安全验收清单 - `npm run audit:runtime` 为 0 high/critical 漏洞;Taro 构建工具链 audit 单独跟踪,不能用破坏性降级绕过。 - `npm run check:refactor` 通过。 - `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 生效。 - 跨租户学生读取题目/订单/资料返回拒绝。 - 销售只能查看自己权限范围内客资。 - 代理不能查看其他代理客资。 - 教师只能查看自己负责班级的学生,不能查看其它班级或跨租户学生。 - 教师不能修改租户商户密钥。 - 学生不能访问租户后台接口。 - 未开通权益不能下载 SVIP 资料或播放会员视频。 - 支付 webhook 重放不会重复开通权益。 - 激活码并发兑换只能成功一次。 - 对象存储签名 URL 有短 TTL。 - 后台关键操作写入 audit log。