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)。
This commit is contained in:
Hermes CI Fix
2026-09-14 11:19:05 +08:00
parent 1b73dbb272
commit 115a0ec755
7 changed files with 258 additions and 22 deletions
+79
View File
@@ -79,3 +79,82 @@ def resolve_entity_for_request(request: Request, fallback: int = 1) -> int:
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)