forked from wangziqi/gongxue-base
267 lines
18 KiB
Markdown
267 lines
18 KiB
Markdown
# 多租户与鉴权安全契约
|
||
|
||
更新时间:2026-07-11
|
||
|
||
这个系统后续要卖给同行作为题库 SaaS,因此租户隔离、鉴权、资源权限和审计是商用红线。前端可以先按迁移期接口联调,也可以按 Supabase 官方推荐使用 publishable key + RLS 的客户端能力管理 Auth/session,但正式上云验收前必须完成本文件的 P0 项。
|
||
|
||
## 安全边界
|
||
|
||
| 边界 | 规则 |
|
||
| --- | --- |
|
||
| 平台超级管理员 | 按 `platform_permissions` 管理全部租户、SaaS 套餐、订阅、账单、用量、公共题库和平台审计 |
|
||
| 租户管理员 | 只能管理自己租户的品牌、域名、成员、内容、营销、订单和 CRM |
|
||
| 租户成员 | 按角色和 permissions 访问,例如运营、教师、销售、代理 |
|
||
| 学生用户 | 只能访问自己所在租户下被授权的内容和自己的学习数据 |
|
||
| 公共题库 | 必须通过平台授权/租户采纳后才对租户可见 |
|
||
| 私有资源 | 必须通过后端权限校验后签名访问,不能前端直连对象存储 |
|
||
|
||
## 当前迁移期状态
|
||
|
||
当前后端已经进入“session 优先、迁移头受控兼容”的状态:
|
||
|
||
- `Authorization: Bearer <tk_session>` 会优先解析 `app_private.auth_sessions`,并作为用户身份来源。
|
||
- `Authorization: Bearer <supabase_access_token>` 已支持服务端验签,后端通过 `auth.users.id -> platform_users.auth_user_id` 映射平台身份;普通租户用户再通过 `tenant_memberships` 映射当前租户角色。
|
||
- Supabase JWT 顶层 `role=authenticated` 不会覆盖数据库中的平台管理员身份;平台权限只认 active 的 `platform_users.primary_role='platform_admin'` 和 `platform_permissions`,也不要求平台管理员先加入某个租户。
|
||
- Supabase JWT 支持 `AUTH_JWT_SECRET` 或 `AUTH_JWT_JWKS_URL`;生产推荐优先配置 Supabase Auth JWKS 和 `AUTH_JWT_ISSUER`,或在自托管兼容模式下配置强随机 JWT secret。配置 JWKS 但缺少 issuer 会被生产 fail-fast 阻断。
|
||
- 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=*` 或 `CORS_TENANT_DOMAINS_ENABLED=false`。
|
||
- `CORS_ORIGIN` 只列少量中央平台/运维 Origin;租户 H5 Origin 必须命中 `active tenant_domains + active tenants`。动态查询使用有界正负 TTL 缓存与同 host 并发去重,查询故障、未知/禁用域名和非标准 HTTPS Origin 一律 fail closed。CORS 不得信任请求 `Host`/`X-Forwarded-Host` 或租户头进行准入。
|
||
|
||
这些只允许用于本地开发和内网联调,不允许作为正式云端验收方案。
|
||
|
||
Supabase 官方允许前端用 Data API 访问数据,但前提是 RLS、最小 grant 和 JWT 权限模型都正确。本项目的核心业务表默认不开放给 Taro 直写;任何新增直连表都必须先通过 RLS、跨租户、权限和性能评审。
|
||
|
||
`202607110001_data_api_acl_rls_hardening.sql` 把这个约定落到数据库:`anon/authenticated` 对 `public` 表、视图、序列和 RPC 默认无权限,后续新建对象也不会自动获得 Data API 权限。当前 Taro 只用 Supabase Auth,业务数据统一走 `apps/api`。如未来需要前端直连,必须在独立 migration 中逐对象写明 policy、`TO`、命令类型和最小 grant,并补同租户垂直越权测试。
|
||
|
||
## P0:正式云端测试前必须完成
|
||
|
||
1. 正式用户鉴权
|
||
- 已支持服务端 session 和 Supabase Auth JWT 解析可信 userId。
|
||
- 生产前必须用真实 Supabase Auth 项目或自托管 Auth 实例跑一轮云端 JWT 回归。
|
||
- 生产推荐配置:
|
||
```text
|
||
AUTH_JWT_JWKS_URL=https://<project-ref>.supabase.co/auth/v1/.well-known/jwks.json
|
||
AUTH_JWT_ISSUER=https://<project-ref>.supabase.co/auth/v1
|
||
AUTH_JWT_AUDIENCE=authenticated
|
||
```
|
||
- `npm run test:api` 已覆盖本地 HS256 JWT 和本地 JWKS/RS256 验签路径;`npm run smoke:auth:remote` 用于真实云端 access token 回归 `GET /api/auth/me`、`GET /api/profile/me`、租户后台、平台后台、坏 token 和错租户上下文。
|
||
- 远程 Auth smoke 的真实 access token 只能在验收命令行临时注入,不能写入仓库、前端 `runtime-config.json`、CI 日志或长期 `.env`。
|
||
- 前端禁止通过 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。
|
||
- 平台管理员已支持 `platform_users.platform_permissions` 细粒度权限,`{"*":true}` 为超级管理员;接口按 `platform:staff:*`、`platform:tenant:*`、`platform:billing:*`、`platform:audit:*`、`platform:question_bank:*` 等权限点强制校验。
|
||
- 平台员工管理已落到 `GET/PUT/PATCH /api/platform-admin/staff` 和 Taro 平台员工页;员工必须绑定 Supabase Auth 用户 ID,`platform_users.status='disabled'` 后不能再通过 Supabase JWT 映射为平台管理员,禁用时也会默认撤销迁移期 session。
|
||
- 首个超级管理员只能通过服务器侧 CLI 绑定一个已经存在的 Supabase Auth UUID,不能提供公开“创建首个超管”接口。先在 Auth 控制台或受控后台创建/确认账号,再在生产运维终端执行 dry-run:
|
||
```bash
|
||
DATABASE_URL='<production-database-url>' \
|
||
BOOTSTRAP_PLATFORM_ADMIN_AUTH_USER_ID='<auth.users UUID>' \
|
||
BOOTSTRAP_PLATFORM_ADMIN_USERNAME='<operator username>' \
|
||
BOOTSTRAP_PLATFORM_ADMIN_NAME='<display name>' \
|
||
npm run bootstrap:platform-admin
|
||
```
|
||
- 审核 dry-run 的脱敏结果后,才允许在同一受控终端执行:
|
||
```bash
|
||
npm run bootstrap:platform-admin -- --apply --confirm BOOTSTRAP_FIRST_PLATFORM_ADMIN
|
||
```
|
||
- CLI 使用事务级 advisory lock;已有 active 且已绑定 Auth 的平台管理员后永久拒绝再次引导。迁移库只允许绑定唯一一条未绑定的历史 `platform_admin`,多个候选会拒绝并要求人工消歧。成功后固定写入 `status='active'`、`platform_permissions={"*":true}` 和脱敏审计事件。
|
||
- 生产前继续补平台后台关键操作审计报表。
|
||
|
||
4. 生产配置 fail-fast
|
||
- `NODE_ENV=production` 时禁止默认 `AUTH_CODE_PEPPER`。
|
||
- 禁止默认 `AUTH_SESSION_SECRET`。
|
||
- 禁止默认 `AUTH_JWT_SECRET`,除非配置了 `AUTH_JWT_JWKS_URL`。
|
||
- 配置 `AUTH_JWT_JWKS_URL` 时必须同时配置 `AUTH_JWT_ISSUER`。
|
||
- 禁止默认 `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`,并确认 `db.environment.destructive_tests_disabled` 通过。
|
||
- 数据库门禁会阻断 active 租户 `public_config` 中非空但不是生产 HTTPS 的 `*Url/*Uri` 字段,并对尚未发布租户主题的 active 租户给出 warning;允许继续使用平台默认主题,但必须在上线审批中确认品牌表现。
|
||
|
||
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()` 的表。
|
||
- 同一门禁还会阻断 `anon/authenticated` 对 `public` 表/视图/序列/RPC 的直接权限、危险的默认 ACL、`platform_users` 客户端写 policy,以及仅信任陈旧 JWT `platform_admin` claim 的 RLS 旁路。
|
||
- `npm run test:data-api:security` 在不启动数据库时检查 deny-by-default migration、readiness 门禁和 Taro 不直连业务表的源码合同。
|
||
- `npm run test:rls` 会先提交破坏性 smoke seed,再模拟 Supabase `authenticated/anon/platform_admin` JWT claims;只允许连接 `app_private.environment_safety` 标记为 `local/test/ci` 且显式放行的隔离库。
|
||
- `test:rls` 为了模拟 PostgREST 角色会在事务内临时授予 `authenticated/anon` 查询探针权限,该事务内的 grant、写入探针和跨租户插入会回滚;但前置 seed 不会回滚,所以禁止连接生产或预发。生产上线的动态 RLS 证据必须来自生产 schema/脱敏快照克隆库。
|
||
- 禁止在 `source /etc/tiku-saas/api.env` 后运行 `test:rls`、`test:api` 或 `test:worker:*`;真实生产库只运行只读 readiness 或专门设计的无 seed 远程探针。
|
||
- 新增租户表时必须同时提交 migration、RLS policy、API 权限测试或明确说明只允许平台级访问的原因。
|
||
|
||
## 前端必须遵守
|
||
|
||
- 不信任本地缓存里的 tenantId/userId 作为安全依据。
|
||
- 前端只能使用 Supabase publishable key,不能出现 secret key 或 service role key。
|
||
- 不在页面里保存或展示任何商户密钥、短信密钥、OAuth secret。
|
||
- 不在前端硬编码对象存储 bucket、私有资源路径、商户号。
|
||
- 不在前端自行开通会员权益;必须通过订单/支付/激活码后端流程。
|
||
- 不允许“切换销售归属”这类破坏首绑保护的入口,除非后端提供带权限的管理接口。
|
||
- 不在前端直接判断“这个用户能不能看某题/某视频/某资料”的最终结果;必须请求后端。
|
||
- 切换租户、退出登录、登录新账号时,清理旧租户缓存和用户缓存。
|
||
- Taro 会话按 portal、域名或 tenantCode、tenantId 隔离,业务缓存再增加 userId;显式退出、确认失效、租户切换和账号切换会删除旧用户数据前缀。H5 通过跨标签事件同步身份变化,页面存储句柄固定 tenantId/userId,避免另一个标签切号后写入新账号空间。Supabase 与短信 app session 具有明确当前来源,不允许某一来源失效后静默回退到上一账号。
|
||
|
||
## 后端接口约定
|
||
|
||
所有业务表查询必须满足:
|
||
|
||
```text
|
||
where tenant_id = currentTenantId
|
||
```
|
||
|
||
管理类写接口必须满足:
|
||
|
||
```text
|
||
requireTenantPermission(auth, '<scope>:<action>')
|
||
```
|
||
|
||
平台类接口必须满足:
|
||
|
||
```text
|
||
requirePlatformAdmin(auth, '<platform:scope:action>')
|
||
```
|
||
|
||
资源下载必须满足:
|
||
|
||
```text
|
||
content_assets 台账存在
|
||
资源属于当前 tenant
|
||
资源状态允许访问
|
||
用户权益满足 visibility/access_rules
|
||
返回短期签名 URL
|
||
```
|
||
|
||
支付 webhook 必须满足:
|
||
|
||
```text
|
||
验签通过
|
||
provider event id 幂等
|
||
订单 tenant_id 匹配
|
||
金额以后端订单金额为准
|
||
事务内更新 payment/order/entitlement
|
||
记录 payment_events
|
||
```
|
||
|
||
## 权限点现状
|
||
|
||
平台权限点:
|
||
|
||
| 权限点 | 用途 |
|
||
| --- | --- |
|
||
| `platform:overview:read` | 查看平台总览 |
|
||
| `platform:staff:read` | 查看平台员工 |
|
||
| `platform:staff:write` | 创建/编辑平台员工 |
|
||
| `platform:staff:status` | 启停平台员工并撤销迁移期 session |
|
||
| `platform:tenant:read` | 查看租户列表和租户详情 |
|
||
| `platform:tenant:write` | 创建/编辑租户 |
|
||
| `platform:tenant:status` | 变更租户状态 |
|
||
| `platform:tenant:billing_profile` | 维护租户账务资料 |
|
||
| `platform:plan:read` | 查看 SaaS 套餐 |
|
||
| `platform:billing:read` | 查看平台账单、订阅候选和催缴记录 |
|
||
| `platform:billing:write` | 创建订阅和服务费账单 |
|
||
| `platform:billing:payment` | 确认平台服务费收款 |
|
||
| `platform:billing:dunning` | 执行逾期处理和内部催缴 |
|
||
| `platform:billing:notification` | 配置/查看催缴外部通知 |
|
||
| `platform:usage:read` | 查看租户用量 |
|
||
| `platform:usage:write` | 记录租户用量 |
|
||
| `platform:audit:read` | 查看平台审计 |
|
||
| `platform:audit:export` | 导出平台审计 |
|
||
| `platform:audit:alert` | 查看/处理平台审计告警 |
|
||
| `platform:audit:notification` | 配置/查看审计告警外部通知 |
|
||
| `platform:question_bank:read` | 查看平台公共题库 |
|
||
| `platform:question_bank:grant` | 授权平台公共题库 |
|
||
| `platform:question_bank:ops` | 查看跨租户公共题库采纳同步运营状态 |
|
||
|
||
前端平台后台启动后可调用:
|
||
|
||
```text
|
||
GET /api/platform-admin/permissions
|
||
```
|
||
|
||
响应中的 `effective` 只用于菜单和按钮可见性;真正权限仍由后端每个接口强制校验。生产 readiness 会阻断 `primary_role='platform_admin'` 但 `platform_permissions` 为空的账号,避免平台账号上线后权限不明确。
|
||
|
||
当前默认角色:
|
||
|
||
| 角色 | 默认权限 |
|
||
| --- | --- |
|
||
| 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 test:auth:foundation` 通过,覆盖标准 Supabase JWT 的平台身份映射、恶意 JWT 角色不提权和首个超管 CLI 的 dry-run/拒绝/审计契约。
|
||
- `npm run smoke:auth:remote` 在预生产/生产 API 上通过,并使用真实 Supabase Auth access token 覆盖学生、租户管理员、平台管理员、坏 token 和错租户上下文。
|
||
- 在生产 schema/脱敏快照的隔离克隆库上 `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` 必须通过。
|
||
- `db.platform_admin_active`、`db.platform_admin_auth_binding`、`db.platform_admin_permissions` 必须通过;`db.tenant_public_urls` 必须通过,`db.tenant_theme_published` warning 必须有上线审批结论。
|
||
- 生产环境启动时默认密钥 fail-fast 生效。
|
||
- 跨租户学生读取题目/订单/资料返回拒绝。
|
||
- 销售只能查看自己权限范围内客资。
|
||
- 代理不能查看其他代理客资。
|
||
- 教师只能查看自己负责班级的学生,不能查看其它班级或跨租户学生。
|
||
- 教师不能修改租户商户密钥。
|
||
- 学生不能访问租户后台接口。
|
||
- 未开通权益不能下载 SVIP 资料或播放会员视频。
|
||
- 支付 webhook 重放不会重复开通权益。
|
||
- 激活码并发兑换只能成功一次。
|
||
- 对象存储签名 URL 有短 TTL。
|
||
- 后台关键操作写入 audit log。
|