69 lines
9.7 KiB
Markdown
69 lines
9.7 KiB
Markdown
# 多租户隔离 / 账套切换 操作手册
|
||
|
||
> 模块 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
|