Files
cma-management/backend/app/deps.py
T
Hermes CI Fix 115a0ec755 fix(security): 4端点补跨租户鉴权 — 实体1会话读实体2数据(200)→403 (cma-cross-entity-endpoints-auth-20260914)
背景(项目Bot 补充复验 §四#2):以下 4 个只读端点未做会话账套鉴权,
用 entity=1(酣客) 会话 token + 显式 entity 参数可读到 entity=2(博海) 数据(HTTP 200):
  - GET /data-quality/governance-check?entity_id=2   → 200 score=88(对照 entity1=72)
  - GET /products/matrix?entity_id=2                 → 200
  - GET /alerts/risk-matrix?entity=bohai             → 200 博海风险矩阵
  - GET /action-plans/coso-checklist?entity=bohai    → 200 博海 COSO total_score=55
另:risk-matrix / coso-checklist 在非法实体值下静默回落酣客数据,UI 标签与数据不一致。

改动:
1. deps.py 新增统一防护入口 assert_entity_scope / assert_entity_slug_scope /
   get_session_entity / resolve_session_slug(文案对齐 get_entity_id:无权访问企业 entity_id=X(当前账套: Y))
2. governance-check / products/matrix:entity_id 须等于会话账套,否则 403;
   缺省按会话账套(governance-check 原缺省 0=全部 → 登录态下按账套收敛)
3. risk-matrix / coso-checklist:entity 须等于会话账套,否则 403;
   非法值 → 400 显式报错(不再静默回落酣客)
4. P3:POST /auth/switch-entity 加 DEPRECATED 注释(前端已无调用,保留不删)
5. 测试:tests/test_security_multitenant.py 新增 TestCrossEntityEndpointAuth 15 例
   (跨实体 403 / 同实体 200 / 缺省按账套 / 非法显式报错 / 参照端点行为不变)

验证:pytest tests/ -q → 951 passed(10 项失败为改动前既有基线,已 stash 对照确认);
      真实后端冒烟 19/19 PASS(entity=1 会话 4 端点跨实体全 403)。
2026-09-14 11:19:05 +08:00

161 lines
7.5 KiB
Python
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.
"""多租户公共依赖 — 账套模式:token优先,query/header降级为Bot服务白名单"""
from fastapi import Request, Header, Query, Depends, HTTPException
from typing import Optional
from sqlalchemy.orm import Session
from app.database import get_db
from app.auth_middleware import get_token_entity_id
from app.models import Entity
def get_entity_id(
request: Request,
x_entity_id: Optional[str] = Header(None, alias="X-Entity-Id"),
entity_id: Optional[int] = Query(None, ge=1),
db: Session = Depends(get_db),
) -> int:
"""解析当前企业ID(账套模式):token优先 → query/header(Bot白名单)→ 默认1
解析链(倒置后):
1. Authorization Bearer token → 优先返回 token.entity_id(唯一可信来源)
2. query参数 / X-Entity-Id header → 仅Bot服务通道使用(校验entity状态active)
3. body中的 entity_id(POST场景)→ Bot服务通道兼容
4. 兜底默认 1(酣客)
安全说明:登录用户必须通过token绑定账套,query/header传入的entity_id
在token存在时被忽略,防止越权传参(旧漏洞:query优先且无授权校验)。
"""
# 1. token优先(账套模式唯一来源)
auth = request.headers.get("Authorization", "")
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统一处理
# 2. query/header → Bot服务通道白名单(校验entity状态active)
candidate = None
if entity_id is not None:
candidate = entity_id
elif x_entity_id and x_entity_id.isdigit():
candidate = int(x_entity_id)
# 3. body 中的 entity_id(POST场景,Bot通道兼容)
if candidate is None and request.method in ("POST", "PUT", "PATCH"):
try:
body = request.state.body_json or {}
if body.get("entity_id"):
candidate = int(body["entity_id"])
except Exception:
pass
if candidate is not None:
ent = db.query(Entity).filter(Entity.id == candidate).first()
if ent and ent.status == "active":
return candidate
# Bot通道校验不通过:不返回默认,直接拒绝(防越权注入无效entity)
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
# ============================================================
# 跨租户读取防护(cma-cross-entity-endpoints-auth-20260914 补单)
#
# 背景:4 个只读端点(data-quality/governance-check、products/matrix、
# alerts/risk-matrix、action-plans/coso-checklist)未做会话账套鉴权,
# 用实体1(酣客)会话 token + 显式 entity 参数可读到实体2(博海)数据(HTTP 200)。
# 本模块提供统一入口,行为对齐已正确 403 的 growth-quality / predict.accuracy:
# - 会话 token 绑定实体 与 显式传入实体 不一致 → 403(文案同 get_entity_id)
# - 字符串实体参数非法(非 hanke/bohai)→ 400 显式报错(不再静默回落酣客)
# - 无 token(Bot 服务通道)→ 保持原白名单/默认行为,只补存在性校验
# ============================================================
# entity_id ↔ 实体 slug(字符串实体参数端点使用 hanke/bohai)
ENTITY_SLUGS = {1: "hanke", 2: "bohai"}
ENTITY_SLUG_TO_ID = {slug: eid for eid, slug in ENTITY_SLUGS.items()}
def get_session_entity(request: Request) -> Optional[int]:
"""会话账套实体:Bearer token 绑定的 entity_id(无 token / 旧格式 token → None)"""
auth = request.headers.get("Authorization", "")
if not auth.startswith("Bearer "):
return None
return get_token_entity_id(auth[7:].strip())
def assert_entity_scope(
request: Request,
explicit: Optional[int],
db: Optional[Session] = None,
) -> Optional[int]:
"""显式 entity_id 的跨租户防护。
- 会话 token 绑定实体存在(登录用户):explicit 与之不符 → 403(含 0=全部)
- 无 token(Bot 服务通道):explicit 需存在且 active,否则 403(防越权注入)
返回会话账套 entity_id(无 token → None,由调用方按原口径兜底)。
"""
session_entity = get_session_entity(request)
if session_entity is not None:
if explicit is not None and explicit != session_entity:
raise HTTPException(403, f"无权访问企业 entity_id={explicit}(当前账套: {session_entity})")
return session_entity
# Bot 服务通道:显式实体必须存在且 active(0=全部 不校验)
if explicit is not None and explicit != 0 and db is not None:
ent = db.query(Entity).filter(Entity.id == explicit).first()
if not ent or ent.status != "active":
raise HTTPException(403, f"企业 entity_id={explicit} 不存在或未激活")
return None
def assert_entity_slug_scope(request: Request, slug: Optional[str]) -> Optional[str]:
"""字符串实体参数(hanke/bohai)的跨租户防护 + 非法值显式报错。
- 非法值 → 400 显式报错(不再静默回落酣客数据导致 UI 标签与数据不一致)
- 合法但与会话账套不一致 → 403(文案同 get_entity_id)
返回规范化 slug;slug 为空 → None(调用方按会话账套/默认兜底)。
"""
if slug is None or str(slug).strip() == "":
return None
key = str(slug).strip().lower()
if key not in ENTITY_SLUG_TO_ID:
raise HTTPException(
400,
f"非法实体 entity={slug}(合法值: hanke/bohai)",
)
eid = ENTITY_SLUG_TO_ID[key]
session_entity = get_session_entity(request)
if session_entity is not None and eid != session_entity:
raise HTTPException(403, f"无权访问企业 entity_id={eid}(当前账套: {session_entity})")
return key
def resolve_session_slug(request: Request, default: str = "hanke") -> str:
"""无显式实体参数时,按会话账套取默认 slug(无 token → default)"""
session_entity = get_session_entity(request)
if session_entity is None:
return default
return ENTITY_SLUGS.get(session_entity, default)