From d56ba24f318d92ee3bb6fe8d79002299387c9783 Mon Sep 17 00:00:00 2001 From: Hermes CI Fix Date: Tue, 11 Aug 2026 11:30:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20deps=E8=B6=8A=E6=9D=83=E9=98=B2?= =?UTF-8?q?=E6=8A=A4(query/header=E4=B8=8Etoken=E4=B8=8D=E4=B8=80=E8=87=B4?= =?UTF-8?q?403)=20+=20resolve=5Fentity=5Ffor=5Frequest=20+=20=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E2=91=A5=E5=BC=80=E5=8F=91=E8=A7=84=E8=8C=83=E6=B2=89?= =?UTF-8?q?=E6=B7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/deps.py | 21 ++++++++ docs/account-mode-dev-spec.md | 96 +++++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 docs/account-mode-dev-spec.md diff --git a/backend/app/deps.py b/backend/app/deps.py index 3d8bfab3..2869bb6a 100644 --- a/backend/app/deps.py +++ b/backend/app/deps.py @@ -30,6 +30,14 @@ def get_entity_id( if auth.startswith("Bearer "): token_entity = get_token_entity_id(auth[7:]) if token_entity is not None: + # 越权防护:显式传入的 query/header entity_id 与 token 绑定不一致 → 403 + explicit = None + if entity_id is not None: + explicit = entity_id + elif x_entity_id and x_entity_id.isdigit(): + explicit = int(x_entity_id) + if explicit is not None and explicit != token_entity: + raise HTTPException(403, f"无权访问企业 entity_id={explicit}(当前账套: {token_entity})") return token_entity # token存在但是旧格式/无entity → 账套模式下强制走白名单或默认(由require_auth拦截) # 这里不抛401:公开接口可能带旧token,交给require_auth统一处理 @@ -58,3 +66,16 @@ def get_entity_id( raise HTTPException(403, f"企业 entity_id={candidate} 不存在或未激活") return 1 # 默认酣客(无token、无参数时兜底,兼容存量公开接口) + + +def resolve_entity_for_request(request: Request, fallback: int = 1) -> int: + """账套模式:请求级entity解析(供从body读取entity_id的接口使用) + 登录用户(带Bearer token)→ token绑定的entity(唯一来源) + 无token(Bot服务通道)→ 回退到调用方传入的fallback(body中的entity_id等) + """ + auth = request.headers.get("Authorization", "") + if auth.startswith("Bearer "): + token_entity = get_token_entity_id(auth[7:]) + if token_entity is not None: + return token_entity + return fallback diff --git a/docs/account-mode-dev-spec.md b/docs/account-mode-dev-spec.md new file mode 100644 index 00000000..e77ec17b --- /dev/null +++ b/docs/account-mode-dev-spec.md @@ -0,0 +1,96 @@ +# CMA 账套模式开发规范(2026-08-11 定稿) + +> 依据:研学Bot《cma-account-mode-research-20260811.md》+ 全栈Bot落地改造。 +> 适用范围:cma-management 仓库全部后端/前端开发。 + +--- + +## 1. 铁律:新表必须带 entity_id + +**任何新增业务数据表(租户表)必须包含 `entity_id` 列**,用于账套隔离。 + +```python +class 新模型(Base): + __tablename__ = "xxx" + id = Column(Integer, primary_key=True, index=True) + entity_id = Column(Integer, ForeignKey("entities.id"), nullable=False, index=True, comment="企业ID(账套)") + # ... 业务字段 +``` + +### 1.1 全局表 vs 租户表归类 + +| 类型 | 说明 | 示例 | entity_id | +|------|------|------|:---:| +| **全局表** | 系统级、跨账套共享 | users / entities / user_entities / role_permissions / system_configs / notification_channels / kpi_templates / okr_templates / bi_report_templates | ❌ 不带 | +| **租户表** | 业务数据、按账套隔离 | kpi_definitions / kpi_values / alerts / data_source_config / objectives / action_plans / org_nodes / budget_plans / cost_* / bsc_layer_config / kpi_hierarchy / cash_* / mpm_results / bi_reports / kpi_causality / report_history | ✅ **必须带** | + +**判断口诀**:数据属于"某个企业/账套" → 租户表带 entity_id;数据属于"系统/平台" → 全局表。 + +### 1.2 新表检查清单 + +- [ ] 建表 SQL / ORM 模型包含 `entity_id`(`nullable=False` + index) +- [ ] 查询接口默认按 entity_id 过滤(通过 `Depends(get_entity_id)` 或显式参数) +- [ ] 写入接口强制使用当前 token 绑定的 entity_id(禁止客户端传入) +- [ ] 存量表缺 entity_id 的,补列并回填默认值后设为 NOT NULL + +--- + +## 2. 账套解析规则(后端) + +```text +get_entity_id 解析链(backend/app/deps.py): + 1. 有 Bearer token(登录用户)→ 强制使用 token 绑定的 entity_id(忽略 query/header/body,防越权) + 2. 无 token(Bot服务通道白名单)→ query → header → body,校验 entity 状态 = active + 3. 兜底默认 1(酣客) +``` + +### 2.1 API 开发约定 + +- **业务 API**:一律使用 `entity_id: int = Depends(get_entity_id)` 获取当前账套,禁止自己解析 query 参数 +- **禁止**在业务 API 里让客户端传 `entity_id` 来指定账套(token 绑定才是唯一来源) +- 需要"某账套数据"的 Bot/服务通道:无 token 时通过 query/header 传 entity_id,后端校验 `Entity.status == "active"` + +### 2.2 用户授权 + +- 用户可访问哪些账套 = `user_entities` 表(user_id ↔ entity_id 多对多) +- 登录/切换时必须校验 `user_has_entity(db, user_id, entity_id)`,未授权返回 **403** +- 新用户注册默认授权 entity_id=1(酣客) + +--- + +## 3. 账套切换(前端) + +- 切换走 `POST /api/cma/auth/switch-entity` → 后端校验授权 → **重新签发 token**(绑定新 entity)→ 整页刷新 +- **禁止**直接改 `localStorage['cma_entity_id']` 切换(token 未变会导致数据错乱) +- 拦截器**不自动附加** `X-Entity-Id` / `entity_id`(账套由 token 绑定,删除自动附加逻辑) +- 登录页公司下拉数据源:`GET /api/cma/auth/login-entities?username=xxx`(按授权过滤) + +--- + +## 4. 安全红线 + +1. 任何请求携带的 `entity_id` 与 token 绑定不一致时,**以 token 为准**(忽略 query/header) +2. 未授权账套访问返回 403(登录、切换、业务 API 三层都校验) +3. Redis token 存储结构 = `JSON {user_id, entity_id}`;旧 int 格式 token 一律视为无效(强制重新登录) +4. Bot 通道(X-BOT-KEY)不走用户 token,靠 query/header 指定账套时仍须校验 entity active + +--- + +## 5. 验证要点(每次改动后) + +```bash +# 1. 隔离验证:登录账套A → 全系统只显示A的数据 +curl -s -X POST http://127.0.0.1:8010/api/cma/auth/login \ + -H "Content-Type: application/json" \ + -d '{"username":"admin","password":"admin123","entity_id":2}' +# → token;用该token访问 /api/cma/kpis 只返回博海数据 + +# 2. 越权验证:未授权entity请求返回403 +curl -s -X POST http://127.0.0.1:8010/api/cma/auth/switch-entity \ + -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ + -d '{"entity_id":999}' # → 403 + +# 3. Bot通道:无token带query entity_id 仍可用 +curl -s -H "X-BOT-KEY: cma-bot-finance-2026" \ + "http://127.0.0.1:8010/api/cma/bot/overview" # → 200 +```