Files
cma-management/docs/manuals/multitenant-isolation.md
T

69 lines
9.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 多租户隔离 / 账套切换 操作手册
> 模块 slug: multitenant-isolation(对应队列 cma-module-queue.json)
> 最近一次修复后状态定稿:2026-09-07(project-20260907-opencode-multitenant-fix 完成并验证后,commit ac645a5)
> 这是**横切所有数据模块**的隔离机制手册:任何新增模块/API 都要先读「④ 已知边界/坑」再动手。
## ① 模块定位
- **模块名**:多租户隔离 / 账套切换(Multi-tenant Isolation / Entity Switch)
- **解决什么问题**:同一套 CMA 系统服务多家企业,各企业(账套/entity)数据天然隔离、互不可见、互不可写。企业清单:**entity 1 = 陕西酣客(白酒)、entity 2 = 陕西博海(IT,本司)**、3-5 = demo。登录 token 绑定 entity_id,全系统按 entity_id 过滤。
- **所属流程环节**:数据 → 账套切换(登录/会话/所有业务查询写入口的前置)
- **主要角色**:所有用户(ceo/finance/business/it,登录选账套);系统管理员(多账套授权切换);Bot 服务通道(X-BOT-KEY 无 token 白名单)
## ② 操作步骤
1. **登录选账套**:/login 页先点开公司下拉(登录页公司列表来自公开接口 `GET /api/cma/auth/login-entities?username=xxx`,按用户名返回其授权企业)→ 选企业 → 填账号密码 → 点「登 录」按钮(按钮文本是"登 录"中间有空格)。**不带 entity_id 登录会返回「账套模式:请选择登录企业」**。登录成功返回 `{"token": "...", "entity_id": N, "entity_name": "...", "user": {...}}`;token 是 Redis `cma:token:{token}` 的 key,value=JSON `{user_id, entity_id}`(**旧 int 格式 token 一律视为无效 → 401 强制重新登录**)
2. **切换账套**:登录后右上角账套切换器 → 列出 `GET /api/cma/auth/my-entities`(当前用户授权企业)→ 选目标企业 → 前端调 `POST /api/cma/auth/switch-entity`(body `{"entity_id": N}`,带当前 Bearer token):后端校验 user_entities 授权 → 删除旧 token → 重新签发新 token → 写 OperationLog → 前端替换 localStorage(cma_token/cma_entity_id/cma_entity_name/cma_user)+ `location.reload()`。切后所有数据视图变为新账套
3. **越权防护语义**(后端 deps.py `get_entity_id` 解析链):
- 有 Bearer token → **强制用 token 绑定 entity**;显式 query/header/body 的 entity_id 与 token 不一致 → **403**
- 无 token(Bot 白名单通道 X-BOT-KEY / X-Entity-Id header / body entity_id)→ 校验 `Entity.status == "active"`,不通过 403,缺省回落 entity 1
- 用户未授权企业:登录该企业 403、切换该企业 403
4. **隔离表现**:登录 A 账套只看到 A 账套数据(KPI/预算/成本/预警/待办/知识分区);跨账套直连 API 访问他人资源 → **404**(资源归属校验语义,见下);跨账套提醒/待办不会出现
5. **Bot 数据通道**:`/api/cma/bot/*`(X-BOT-KEY)与 `/api/cma/bot-bridge/*`(X-BRIDGE-TOKEN)无 token,entity 经 header/body 显式传递,隔离同样生效(bot_kpis 等按 KPI 归属校验)
## ③ 验证路径
- **自动脚本**:
1. 专项越权测试(新增隔离用例):`cd /root/cma-management/backend && venv/bin/python3 -m pytest tests/test_multitenant_ai_20260907.py -q` → 期望 **11 passed**
2. 工作台隔离脚本:`cd /root/cma-management/backend && venv/bin/python3 scripts/verify_dashboard_isolation.py` → 登录 entity1 → my-dashboard action_plans/reminders 回查 DB 归属全为 entity1,输出末尾 `PASS: entity1 工作台数据无跨账套泄漏`
3. 生产双 token 越权实测配方(建临时用户→断言→删除):见 cma-fullstack-ops 技能 references/multitenant-opencode-20260907.md「生产库双 token 越权实测配方」段(cost 双实体零交集 / ai kpi-analysis 跨 entity 404 / dashboard alert_count 与 DB 各自相等 / review-plans stats / bot-kpis 跨 entity 404 / kpis create 白名单注入拦截)
- **期望输出**:上列 1/2 脚本全过;配方断言逐项 PASS
- **已测基线**(最近一次验证日期+结果):2026-09-07 pytest 专项 11 passed(16.56s);生产双 token 全断言 PASS;同批全量 pytest 745 passed / 10 failed(10 failed 经 git stash 在基线 HEAD 复跑同批 = **存量非回归**,勿当回归)/ 1 xfailed
## ④ 已知边界 / 坑
- **历史 bug**:
- 2026-08-11 账套模式上线:token 改 JSON 结构绑 entity_id;此前登录无企业概念,存量接口 entity_id 回落默认 1(隔离失效)
- 2026-08-31 首轮全量隔离修复(security-multitenant-isolation):12+ API 文件统一 `Depends(get_entity_id)`(原 Query(1)/Form(1)/裸 int 默认全部回落 entity 1);KPIAlert/ActionPlan/OrgNode 等表**补 entity_id 列并回填**(kpi_alerts 472 行回填后 216 行属 entity≠1,证明隔离必要);dashboard.py my_plans/reminders 曾漏 entity 过滤 → 待办含其他账套方案(commit 815acda)
- 2026-09-07 OpenCode 二轮审查 7 处缺口(commit ac645a5,P0):
- ai_analysis.py:generate_rule_suggestions 的 pending 预警按 KPIAlert.entity_id 过滤;kpi_analysis/dashboard_analysis 查询带 entity(跨企业 kpi_id → 404);dashboard_analysis 系列端点补 `Depends(get_entity_id)` 签名;**stream/分析缓存 key 改为 `dashboard_analysis:{role}:{entity_id}`(原无 entity → 跨企业串缓存)**
- dashboard.py:summary alert_count 补 KPIAlert.entity_id 过滤
- bot_kpis.py:update_bot_kpi_value 查 KPI 后 `if kpi.entity_id != entity_id: 404`(归属校验)
- cost.py + cost_engine.py:cost_overview/variance/breakdown/dashboard 补 get_entity_id;engine 函数加 `entity_id: Optional[int]=None` 参数(None=兼容存量调用不过滤,**API 端点必须传**)
- kpis.py create_kpi:字段白名单 `UPDATE_KPI_WHITELIST | {"kpi_code"}` 循环拦截,**防 status/important_flag/主键/entity_id=999 注入**(此前 `KPIDefinition(**data)` 裸展开)
- **边界条件**:
- **404 vs 403 语义**:跨账套访问**具体资源**(他人 KPI 详情/他人 bot KPI/跨企业 kpi_id)→ 404(资源不存在语义,防探测);**登录/切换未授权企业、token 与显式 entity 不一致** → 403。改隔离代码勿把 404 改成 403 或空返回混用
- 同名 KPI 跨账套交集是**正常现象**(两家企业可各自定义 process 指标)——实测隔离用 **KPI id 回查 DB 归属**断言,勿用 code 集合判交集
- 空态 ≠ bug:entity2(博海)无逾期数据时 reminders 为空属正常空态
- 知识侧分区:knowledge_events/summaries 严格 entity_id 隔离(企业记忆);knowledge_articles 共享层 **entity_id NULL = 全局共享**(查询 `or_(entity_id==cur, entity_id.is_(None))`,勿按企业复制)
- OperationLog **无 entity_id 列**:按 user_id 成员过滤(宁可漏不可串),P2 另立项
- **⚠️ 易踩点**(新增/修改任何写端点前自查):
1. 签名缺 `entity_id: int = Depends(get_entity_id)` + 构造函数漏 `entity_id=entity_id` + update/delete 缺 `entity_id == entity_id` 过滤——三类缺口都会静默把数据写进错误账套(2026-09-05 cost 实证:写端点整个没绑 entity,前端任何账套录入全落 entity 1)
2. 带 Redis 缓存的接口 cache key **必须含 entity_id**(串缓存是最隐蔽的隔离失效)
3. 改函数签名后 grep 所有**内部直调点**(FastAPI 内部直调需显式传 db/entity_id,否则 Depends 对象 AttributeError)
4. body 读 entity_id 的创建接口用 `resolve_entity_for_request(request, data.get("entity_id") or 1)`(token 优先),勿直接 `or 1`
5. 截断会话残留:函数体引用 entity_id 但签名缺失 → NameError;改完逐端点扫「签名有/函数体有」两列(可复用 scan_entity_isolation.py)
6. 任务单快照可能双向过期(说"有列"实际没有 / 说"没有列"实际 8-31 已加)——动手前 inspect 生产库列再定 直滤 vs JOIN
## ⑤ 修改记录
| 日期 | 修改人 | 改动 | commit | 影响 |
|------|--------|------|--------|------|
| 2026-09-07 | - | 手册创建(修复后状态定稿) | - | - |
| 2026-09-07 | OpenCode审查+全栈Bot | ai_analysis/dashboard/bot_kpis/cost/kpis 隔离缺口修复(缓存key/归属404/写白名单/engine entity 参数)+ 11 条越权测试 | ac645a5 | P0 越权/串缓存 |
| 2026-09-07 | 全栈Bot | .gitignore 补 cma.db/cma_import.db | 1fa0836 | CI deploy hook |
| 2026-08-31 | 全栈Bot | 首轮全量隔离:API 统一 Depends(get_entity_id) + KPIAlert/ActionPlan 等加列回填 + dashboard 提醒过滤 | 815acda 等 | 隔离门禁 5 项 |
| 2026-08-11 | 全栈Bot | 账套模式上线(token 绑 entity、user_entities 授权) | account-mode-refactor | 登录选企业 |
## ⑥ 关联
- **依赖表**:users / user_entities(授权,多对多唯一约束)/ entities(账套,status=active 校验)/ OperationLog;业务表一律带 entity_id 列(kpi_definitions/kpi_values/kpi_alerts/action_plans/budget_plans/cost*/ai_suggestions...)
- **依赖接口**:POST /api/cma/auth/login(必带 entity_id);POST /api/cma/auth/switch-entity;GET /api/cma/auth/my-entities;GET /api/cma/auth/login-entities(公开);deps.py get_entity_id / resolve_entity_for_request
- **关联模块**:全部数据模块(横切);Bot 通道(/api/cma/bot/*、/bot-bridge/*)
- **验证资产**:tests/test_multitenant_ai_20260907.py(11 条)、tests/test_security_multitenant.py、tests/test_security_knowledge_partition.py(8 条)、backend/scripts/verify_dashboard_isolation.py、verify_knowledge_partition_e2e.py;配方见 cma-fullstack-ops references/multitenant-opencode-20260907.md + security-multitenant-isolation-20260831.md