# 通千算智AI教案 · 多租户数据隔离评估

> 版本：v1.0 ｜ 日期：2026-11-12  
> 调研依据：`调研-海外产品管理端视角-2026-10-27.md` §12.1 / §12.2（区域版多租户隔离、K-12 分析平台跨学区数据管道挑战）  
> 结论摘要：**维持现有「单库 + 字段级隔离」，不迁移 schema-per-tenant**；但补一层零行为变更的守卫工具，把分散在各路由的手写校验收敛成可审计清单。

---

## 一、现状盘点（实测 server.js）

### 1.1 架构形态

项目是 **Node 原生 http + 单一内存态 `db` 对象（落盘 db.json）+ 按用户目录存放的课件文件**，不是 SQL 数据库。因此"schema-per-tenant / RLS / Supabase RLS"这类关系型方案**在物理上不适用**——这是评估的最重要前提。

### 1.2 三层隔离现状

| 数据类型 | 存储位置 | 隔离方式 | 强度 |
|----------|----------|----------|------|
| **云端课件** | `USER_DECKS_DIR(user.username)/<id>.json` | **按用户目录物理隔离**，`DECK_ID_RE` 校验文件名防穿越 | 🟢 强 |
| **组织内嵌资源** | `org.resources` / `org.preps` | `resOf(org, id)` / `prepOf(org, id)` 在**组织数组内部**查找，天然限定作用域 | 🟢 强 |
| **全局数组型实体** | `db.classes` / `db.users` | 每个路由手写 `c.orgId === org.id` / `u.orgId === org.id` 过滤 | 🟡 正确但分散 |

### 1.3 已有的关键防线（逐条核对）

| 防线 | 位置 | 说明 |
|------|------|------|
| 班级列表只返回本校 | `server.js:1310` | `.filter(c => c.orgId === org.id)`，注释明确"任何跨校 id 查询在此即被过滤" |
| 班级增改删带 org 条件 | `server.js:1320/1350` | `find(c => c.id === id && c.orgId === org.id)` |
| 成员操作带 org 条件 | `server.js:1597/1628/1662` | `db.users.find(u => u.id === uid && u.orgId === org.id)` |
| 校长移交校验 | `server.js:1696` | `u.orgId === org.id` |
| 学情周报班级名以服务端记录为准 | `server.js:4894/7107` | 防伪造他校班级 id/name 进入周报；退校后旧错题归属自动失效 |
| 授课关系必须本校成员 | `server.js:1331` | 防任意用户名挂进班级 |
| 组织配额预占句柄 | `checkAiQuota` | 请求中途退校/换校也不会把消耗记错学校 |
| 审计日志不记内容 | `audit()` | 只记"谁-何时-对什么-做了什么"，绝不记课件/教案/错题内容 |
| 私有化部署 | 信任中心页 | 数据物理隔离、完全不出校（信创/教育局统采场景的终极隔离形态） |

**判断：现有隔离逻辑本身是正确的，历史上多个修复（E02/E05/C-F13/伪造班级 id 关死）说明团队已针对性处理过越权问题。**

---

## 二、真实风险：不是"隔离失效"，是"校验分散"

逐个路由核对后发现，隔离责任**完全靠开发者在每个 handler 里手写**，没有统一入口：

1. 全局数组（`db.classes` / `db.users`）的每一处查询都要记得带 `orgId === org.id`；新增路由漏写一次就是越权口子
2. 组织内嵌数组靠 `orgOf(user)` 后的 `resOf(org, id)` 隐式保证，新人不易察觉这层保护的存在
3. 路由还有 `.cjs` 模块（`auth.cjs` / `billing.cjs`）通过依赖注入注册，隔离约定跨文件传递，审计时容易漏看
4. 此前没有"哪些路由应该做归属校验"的清单，无法机器自检

对照海外标杆：MagicSchool / SchoolAI 用 **SIS/LMS rostering + 组织上下文中间件**在每个请求入口统一注入租户，而不是靠各端点自觉。这个思路值得借，但不必照搬其重型身份体系。

---

## 三、方案对比与决策

| 方案 | 收益 | 成本 / 风险 | 决策 |
|------|------|-------------|------|
| **A. 维持现状，纯文档化** | 零改动 | 新增路由漏校验的风险不降 | ❌ 不够 |
| **B. 轻量守卫层 + 审计清单（本轮采纳）** | 统一工具、启动期自检、零行为变更 | 不改动存量 handler，防护是"引导 + 提醒"而非强制 | ✅ **采纳** |
| **C. 分发层强制拦截所有 org 路由** | 强制隔离 | 会改变现有 handler 的鉴权顺序与错误码，回归面大；部分 org 路由允许个人用户访问（如 `org/me` 返回 `org:null`），强制拦截会破坏 | ⚠️ 暂缓，待路由盘点后再评估 |
| **D. schema-per-tenant / database-per-tenant** | 物理隔离最强 | 项目不是 SQL；db.json 单文件内存态根本不适用；迁移 = 重写存储层；私有化部署已提供更强的物理隔离出口 | ❌ 不适用 |
| **E. 迁移 PostgreSQL + RLS** | 对齐 CloudBase PG / Supabase 最佳实践 | 与现有技术栈完全不同，属平台级重构；当前规模（县域单校/少量学校）收益不抵成本 | ❌ 中期也不建议，除非产品转向区域大规模多租户 |

**决策依据**：调研里的"区域版多租户"是 P2 中期项，且我方已有**私有化部署**这条更符合国内 K12 采购语境的隔离路径（"数据不出校"是讯飞/希沃的核心合规卖点）。在区域统采真正落地前，为一个单文件 db 做 schema 迁移是过度工程。

---

## 四、本轮落地（`server.js` [tenant-guard] 块）

**零行为变更**——不改变任何现有 handler 的返回，只做三件事：

| # | 能力 | 实现 |
|---|------|------|
| ① | 分发前注入租户上下文 | `req.tenantId = authUser(req)?.orgId || ''`，新路由直接用，不必各写一遍 `orgOf(user)` |
| ② | 统一守卫工具 | `requireSameOrg(req, res, targetOrgId)`（登录 + 同校校验）、`scopedByOrg(req, items)`（全局数组按当前组织过滤） |
| ③ | 启动期自检 | `TENANT_SCOPED_PATTERNS` 清单 + `auditTenantScopes()`：凡 `/api/org/*` 路由未登记清单即打印告警；`server.listen` 回调中执行（覆盖 `.cjs` 注入注册的路由） |

### 新路由开发约定（写进开发规范）

```
访问 org 资源 → 优先用 resOf(org, id) / prepOf(org, id)（天然作用域）
访问全局数组 → 必须 scopedByOrg(req, items) 或显式 x.orgId === req.tenantId
跨校 id 参数 → 必须 requireSameOrg(req, res, targetOrgId)
新增 /api/org/* 路由 → 确认落在 TENANT_SCOPED_PATTERNS 覆盖范围，否则复核
```

---

## 五、何时才需要升级到更强隔离

设置明确触发条件，避免提前过度工程：

| 触发条件 | 应对 |
|----------|------|
| 区域版上线，单一环境承载 ≥ 20 所学校 | 评估按 org 拆分 db 文件 / 独立数据目录（file-per-tenant，仍是当前栈内可做的） |
| 出现"跨校数据可见"的真实工单 | 立即把方案 C（分发层强制拦截）提上日程，并补越权回归用例 |
| 采购方明确要求物理隔离 / 数据不出校 | 直接走**私有化部署**（已具备），无需改 SaaS 架构 |
| 数据规模使 db.json 单文件成为瓶颈 | 才考虑引入 PostgreSQL，届时再按 CloudBase PG 最佳实践做 RLS |

---

## 六、遗留待办（不阻塞本轮，明确记录）

1. **越权回归用例**：目前 `scripts/regression-school.mjs` 有 403 断言，但可补一组系统性用例——A 校用户用 B 校 `classId` / `prepId` / `resId` / `deckId` 请求所有相关接口，全部应 403/404。这是验证隔离的最有效手段，优先级高于任何架构改造。
2. **`org/me` 等个人可访问路由的语义澄清**：它返回 `org:null` 是合法行为，方案 C 若要强制拦截须先豁免这类路由。
3. **分享链接的跨校可见性**：`/api/deck/share` 是**主动分享**语义（生成只读链接），属产品设计而非隔离缺陷；建议在信任中心页说明"分享链接可被持有者访问，可随时撤销"。

---

## 七、代码索引

| 内容 | 位置 |
|------|------|
| 守卫层 / 清单 / 自检 | `server/server.js` `[tenant-guard]` 块（`route()` 定义后） |
| 租户上下文注入 | `server/server.js` `http.createServer` 分发循环前 |
| 自检调用 | `server/server.js` `server.listen` 回调 |
| 组织归属基础函数 | `server/server.js:364` `orgOf` / `orgMembers` |
| 私有化部署说明 | `public/trust.html`「私有化部署说明」区 |
| DPA 数据流向 | `doc/DPA-数据处理协议.md` §五、`doc/DPA-子处理者清单.md` §二 |

---

*本评估随区域版规划与真实工单迭代。任何隔离架构调整须先补第六节的越权回归用例。*
