forked from xiongyuxing/tiku-backend.net
feat: enforce tenant isolation and shared question bank
This commit is contained in:
@@ -23,7 +23,8 @@
|
||||
|
||||
- ASP.NET Core Controller API。
|
||||
- EF Core + Npgsql + PostgreSQL。
|
||||
- 单例 `NpgsqlDataSource` + `AddDbContextPool<TikuDbContext>`。
|
||||
- 单例 `NpgsqlDataSource` + scoped `AddDbContext<TikuDbContext>`。
|
||||
- `ITenantContext`、全局 Query Filter、写入拦截器和 PostgreSQL 组合约束。
|
||||
- Serilog 结构化日志。
|
||||
- CORS / RateLimiter / Options 校验。
|
||||
- Scalar / OpenAPI 基础入口。
|
||||
@@ -32,10 +33,10 @@
|
||||
|
||||
### 数据库
|
||||
|
||||
已完成 greenfield 初始 schema,并持续追加必要 migration。当前数据库模型已经覆盖:
|
||||
已按无正式业务数据前提压缩为唯一 greenfield `InitialSchema`。当前数据库模型已经覆盖:
|
||||
|
||||
- 租户、用户、成员、认证、Session、短信验证码。
|
||||
- 题库、题目、题目版本、内容树、题集、练习蓝图。
|
||||
- 平台公共题库、租户私有题库、受控题目引用、题目版本、分类主干、题集和练习蓝图。
|
||||
- 词汇、手册、分数线动态字段与记录。
|
||||
- 资源、图片、App 资源、视频解析、导入任务。
|
||||
- 学习记录、答题、收藏、错题、报告、统计。
|
||||
@@ -45,6 +46,7 @@
|
||||
- 运营内容、通知、徽章、审计。
|
||||
- 平台账单、催缴、审计告警、对账/退款相关模型。
|
||||
- PocketBase 导入审计。
|
||||
- 自定义域名 DNS/TLS 生命周期和租户前端运行时配置。
|
||||
|
||||
### 已迁移业务闭环
|
||||
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
# 第三阶段:强租户隔离、共享题库与租户前端运行时
|
||||
|
||||
状态:已实施并通过真实 PostgreSQL 验收(2026-07-27)。
|
||||
|
||||
本阶段按 greenfield 项目实施,不兼容现有错误模型,也不承担正式业务数据迁移。阶段目标不是简单给现有查询补 `TenantId`,而是同时建立请求、EF Core、写入和 PostgreSQL 四层租户边界,并让公共题库能够完整参与租户学生的组卷、答题、收藏和错题闭环。
|
||||
|
||||
原先预计的 3~5 个工作日只覆盖基础租户过滤。加入共享题库、版本锁定、域名入口和租户前端运行时后,完整阶段预计 10~14 个工作日。
|
||||
|
||||
## 已确认的产品规则
|
||||
|
||||
- 系统只存在一个 `TenantMode.PlatformOwned` 平台主体,平台主体拥有全部公共题库。
|
||||
- `Trial` 或 `Active` 且未过期的租户自动拥有全部公共题库访问权,不逐库授权。
|
||||
- 租户可以上传自己的私有题库,私有题只对本租户学生和管理员可见。
|
||||
- 公共题和租户私有题可以混合进入同一试卷、章节练习或题单。
|
||||
- 平台分类是公共主干,租户可以在公共节点下增加仅本租户可见的扩展节点。
|
||||
- 新练习使用题目当前已发布版本;已开始练习锁定原版本,历史答题永久按原版本回放。
|
||||
- `PastDue` 或 `Cancelled` 租户不能浏览公共题答案或创建新的公共题练习;未过期的既有会话可以完成,历史答题、收藏、错题和统计继续可见。
|
||||
- 租户自定义域名通过 CNAME 指向统一前端和网关,浏览器使用同域 `/api` 访问后端。
|
||||
- 第一版前端配置覆盖品牌、主题令牌、功能开关、导航和首页模块,不实现任意低代码页面或脚本注入。
|
||||
- 本阶段不引入 PostgreSQL RLS。隔离由请求上下文、EF Core Query Filter、写入拦截器、数据库约束和真实 PostgreSQL 测试共同保证。
|
||||
|
||||
## 3A:请求租户与持久化边界
|
||||
|
||||
### 请求上下文
|
||||
|
||||
使用只读 `ITenantContext` 替换可由业务代码调用 `Load` 的 `ICurrentTenant`。公开属性固定为:
|
||||
|
||||
- `TenantId`
|
||||
- `TenantCode`
|
||||
- `ResolutionSource`
|
||||
- `IsResolved`
|
||||
- `IsSystem`
|
||||
|
||||
只有 API 入口的内部初始化器可以设置请求租户。普通 Controller 和 Service 只能读取上下文,不能切换租户。
|
||||
|
||||
平台任务、Worker 和公共题库基础设施通过 `ITenantExecutionScope` 创建新的 DI scope。System scope 必须提供调用方和原因,记录结构化审计;不允许在现有请求 scope 内临时改写租户。
|
||||
|
||||
### EF Core 防护
|
||||
|
||||
- 生产和测试均从 `AddDbContextPool<TikuDbContext>` 改为 scoped `AddDbContext<TikuDbContext>`。
|
||||
- 所有租户实体实现统一 marker interface。
|
||||
- 全局 Query Filter 自动添加当前 `TenantId`;未解析租户时查询默认返回空集合。
|
||||
- 普通租户 Query Filter 永远不自动包含平台主体数据,公共内容只能通过专用服务访问。
|
||||
- `SaveChangesInterceptor` 对 Added 自动写入当前租户,并拒绝伪造租户、跨租户修改或删除以及修改已有记录的租户归属。
|
||||
- 未解析租户的普通写入直接失败;System scope 也不能把已有记录改归其他租户。
|
||||
|
||||
模型启动审计必须拒绝以下情况:
|
||||
|
||||
- 实体存在 `TenantId` 却没有明确分类。
|
||||
- 租户范围内的唯一索引未包含 `TenantId`。
|
||||
- 租户实体之间的外键未包含组合租户键。
|
||||
- 新增租户实体却没有对应 Query Filter。
|
||||
|
||||
普通业务代码禁止调用 `IgnoreQueryFilters()`、`FromSql`、`ExecuteSql` 或直接创建 `NpgsqlCommand`。确有需要的实现只能位于集中基础设施边界,并由架构测试维护允许列表。
|
||||
|
||||
## 3B:域名入口与租户解析
|
||||
|
||||
租户解析必须在认证和任何租户数据库查询之前完成:
|
||||
|
||||
1. 网关移除客户端提供的 `Forwarded`、`X-Forwarded-Host` 等头。
|
||||
2. 只接收受信代理重写的 Host 信息。
|
||||
3. `TenantResolutionMiddleware` 通过只读 `ITenantDirectory` 查询 Active 域名和 Active 租户。
|
||||
4. 未知、未验证、已禁用域名在认证前返回 404。
|
||||
5. JWT tenant claim 与 Host 解析结果不一致时返回 403。
|
||||
|
||||
自定义域名请求不得通过 `host`、`tenantCode` 查询参数或 `x-tenant-code` 覆盖租户。显式 tenant code 只允许平台控制域名上的登录引导接口使用,多来源冲突必须拒绝。
|
||||
|
||||
域名生命周期固定为:
|
||||
|
||||
1. 创建 Pending 域名并生成验证令牌。
|
||||
2. 租户设置 CNAME 和 TXT 所有权记录。
|
||||
3. Worker 验证 DNS。
|
||||
4. 网关完成 TLS 配置。
|
||||
5. 域名变为 Active 后才参与请求解析。
|
||||
|
||||
`TenantDomain.Host` 全局唯一;每个租户最多一个 Active 主域名。解析结果使用短 TTL 缓存,域名或租户状态变化时主动失效。
|
||||
|
||||
## 3C:共享题库、租户私库与学习闭环
|
||||
|
||||
### 内容所有权
|
||||
|
||||
- 公共题库、题目和题目版本的 `TenantId` 为平台主体 ID。
|
||||
- 私有题库、题目和题目版本的 `TenantId` 为上传租户 ID。
|
||||
- 删除容易与真实所有权冲突的 `QuestionBank.SourceScope`。
|
||||
- 删除 `QuestionBankGrant` 和 `TenantQuestionBankAdoption` 作为访问控制主模型。
|
||||
- 新增 `TenantQuestionBankPreference`,只保存租户对公共题库的显示/隐藏、别名、排序和导航位置。
|
||||
- `IPublicQuestionAccessPolicy` 根据租户状态和订阅状态统一判断能否开始新的公共题练习。
|
||||
|
||||
数据库对平台主体增加唯一约束,保证只能存在一个 `PlatformOwned` 租户。
|
||||
|
||||
### 公共分类主干与租户扩展
|
||||
|
||||
分类节点具有明确的 `OwnerTenantId`。父节点引用保存 `ParentOwnerTenantId + ParentId`:
|
||||
|
||||
- 平台节点只能组成公共主干。
|
||||
- 租户扩展节点可以挂到平台节点或本租户节点。
|
||||
- 不允许租户 A 的节点挂到租户 B 的节点。
|
||||
- 公共题只能关联平台分类。
|
||||
- 租户私题可以关联平台分类或本租户扩展分类。
|
||||
|
||||
所有分类、题库和题目关联使用包含所有者 ID 的组合外键,并通过 PostgreSQL 约束或集中触发器验证“平台或本租户”规则。
|
||||
|
||||
### 受控题目引用
|
||||
|
||||
新增 `TenantQuestionReference`:
|
||||
|
||||
- `TenantId`:消费题目的租户。
|
||||
- `QuestionOwnerTenantId`:平台主体或当前租户。
|
||||
- `QuestionId`:实际题目。
|
||||
|
||||
三列建立唯一约束和组合外键。引用按需创建,不预生成“所有租户 × 全部公共题”。数据库只允许当前租户引用平台公共题或本租户私题,永远拒绝引用其他租户私题。
|
||||
|
||||
公开查询返回 `QuestionLocator { source, questionId }`,其中 `source` 只允许 `platform` 或 `tenant`。客户端不能提交任意 owner tenant ID;写操作由专用服务把 Locator 解析为受控题目引用。
|
||||
|
||||
### 组卷、练习和历史版本
|
||||
|
||||
- `QuestionCollectionItem` 改为引用 `TenantQuestionReference`,允许公共题和私题混合组卷。
|
||||
- 新增规范化 `PracticeSessionQuestion`,删除 `PracticeSession.QuestionIds` JSON。
|
||||
- SessionQuestion 保存题目顺序、分值、题目所有者、题目 ID 和创建会话时锁定的版本 ID。
|
||||
- 答题接口只接受 `sessionQuestionId`,不再接受裸 `QuestionId`;单题练习也创建轻量会话。
|
||||
- `AnswerRecord` 关联 SessionQuestion。
|
||||
- `FavoriteQuestion` 和 `WrongQuestion` 关联受控题目引用,不再假设学习租户和题目所有者相同。
|
||||
- 旧题目版本只能归档,不能物理删除。
|
||||
|
||||
## 3D:租户前端运行时配置
|
||||
|
||||
整合现有 Branding、Theme 和 PublicConfig,形成版本化 `TenantFrontendConfig`,配置范围包括:
|
||||
|
||||
- 品牌名称、Logo、Favicon 和客服信息。
|
||||
- 颜色、字体、间距和圆角等主题令牌。
|
||||
- 学生端功能开关。
|
||||
- 导航菜单。
|
||||
- 首页模块及排序。
|
||||
|
||||
配置采用 Draft → Preview → Publish。发布使用乐观并发版本号,服务端在发布前验证结构;不接受任意 HTML、JavaScript、外部脚本或管理端功能开关进入公开配置。
|
||||
|
||||
新增 `GET /api/runtime/bootstrap`:
|
||||
|
||||
- 只根据当前可信 Host 返回配置。
|
||||
- 返回 `schemaVersion`、`configVersion`、租户代码、品牌、主题、功能、导航和首页模块。
|
||||
- 不返回内部租户 ID、密钥或管理端配置。
|
||||
- 支持 ETag / 304。
|
||||
- 配置发布、域名停用或租户暂停时主动失效缓存。
|
||||
|
||||
现有 `tenant/resolve?host=` 不再作为自定义域名的公开切租户入口,只保留给受控平台登录引导流程。
|
||||
|
||||
## 3E:重建迁移与验收
|
||||
|
||||
项目尚无正式业务数据,因此不编写兼容迁移或双写逻辑:
|
||||
|
||||
- 完成模型重构后删除现有 Migration 历史和 Snapshot。
|
||||
- 重新生成单一 Initial Schema。
|
||||
- 重建本地开发数据库。
|
||||
- 使用 `Tiku.DbMigrator` 从空 PostgreSQL 执行完整迁移。
|
||||
- API 和 Worker 继续禁止启动时自动执行 Migration。
|
||||
|
||||
数据库必须落地以下硬约束:
|
||||
|
||||
- 唯一平台主体。
|
||||
- 域名全局唯一。
|
||||
- 租户唯一索引包含租户键。
|
||||
- 题目、版本和分类使用所有者组合键。
|
||||
- 题目引用只能指向平台或本租户。
|
||||
- SessionQuestion 的版本属于对应题目。
|
||||
- 学习记录与当前租户、学生和会话一致。
|
||||
|
||||
## 测试矩阵
|
||||
|
||||
真实 PostgreSQL 测试创建平台主体、租户 A、租户 B 及三套内容,至少覆盖:
|
||||
|
||||
- A 默认不能读取、修改、删除或 Attach 伪造 B 的租户数据。
|
||||
- A 可以访问公共题和 A 私题,不能构造 B 私题引用。
|
||||
- 公共题和私题可以混合组卷,并完成答题、收藏、错题和统计。
|
||||
- 公共题 V1 创建会话后发布 V2:旧会话仍用 V1,新会话使用 V2,历史答案可回放。
|
||||
- Trial/Active 可以创建公共题练习;PastDue/Cancelled 只能完成未过期会话并查看历史。
|
||||
- 域名 A 携带租户 B JWT 时被拒绝;伪造 Host、tenantCode 和转发头不能切租户。
|
||||
- Pending 域名、未知域名和 Suspended 租户无法获取运行时配置。
|
||||
- 配置草稿不影响线上;发布后 ETag 和缓存版本改变。
|
||||
- 新增租户实体漏写 Filter、组合外键或租户索引时模型测试失败。
|
||||
- 架构测试发现未经允许的 Query Filter 绕过或原生 SQL 时失败。
|
||||
- 从空 PostgreSQL 执行 Initial Schema、启动 API 并通过全量测试。
|
||||
|
||||
## 实施顺序和退出条件
|
||||
|
||||
实施顺序固定为:
|
||||
|
||||
1. 3A:只读租户上下文、Query Filter、写入拦截器。
|
||||
2. 3B:Host 解析、域名生命周期和 JWT 租户一致性。
|
||||
3. 3C:公私内容所有权、分类扩展、题目引用和学习闭环。
|
||||
4. 3D:前端运行时配置、发布和缓存。
|
||||
5. 3E:重建 Migration、真实 PostgreSQL 隔离测试和全量验证。
|
||||
|
||||
第三阶段只有在以下条件全部满足后才能结束:
|
||||
|
||||
- 新增普通租户实体即使开发者忘记手写 `TenantId` 条件也不会跨租户读取。
|
||||
- API 无法通过 DTO、Host、Header、JWT 或 Attach 伪造其他租户数据。
|
||||
- 公共题库和租户私库能够混合完成组卷、答题、收藏和错题闭环。
|
||||
- 题目发布新版本不会改变进行中会话或历史答案。
|
||||
- 租户自定义域名能够安全获得已发布的前端运行时配置。
|
||||
Reference in New Issue
Block a user