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
+16 -7
View File
@@ -1,5 +1,5 @@
"""改善行动计划 API — 管理会计OS""" """改善行动计划 API — 管理会计OS"""
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional from typing import Optional
@@ -7,7 +7,7 @@ import re
import logging import logging
from calendar import monthrange from calendar import monthrange
from app.database import get_db from app.database import get_db
from app.deps import get_entity_id from app.deps import get_entity_id, assert_entity_slug_scope, resolve_session_slug
from app.auth_middleware import require_role, require_auth from app.auth_middleware import require_role, require_auth
from app.models import ActionPlan, KPIAlert, KPIDefinition, KPIValue, User, Objective, KR from app.models import ActionPlan, KPIAlert, KPIDefinition, KPIValue, User, Objective, KR
from app.api.verify import build_auto_verify_rule from app.api.verify import build_auto_verify_rule
@@ -430,10 +430,19 @@ COSO_CHECKLIST_DATA = {
@router.get("/coso-checklist") @router.get("/coso-checklist")
def get_coso_checklist(entity: str = "hanke"): def get_coso_checklist(
"""COSO内控自检表 - CMA P1 COSO五要素""" request: Request,
data = COSO_CHECKLIST_DATA.get(entity) entity: Optional[str] = None,
):
"""COSO内控自检表 - CMA P1 COSO五要素
多租户(cma-cross-entity-endpoints-auth-20260914):
非法实体值 → 400 显式报错(不再静默回落酣客数据);跨实体 → 403。
"""
slug = assert_entity_slug_scope(request, entity)
if slug is None:
slug = resolve_session_slug(request, "hanke")
data = COSO_CHECKLIST_DATA.get(slug)
if not data: if not data:
data = COSO_CHECKLIST_DATA["hanke"] raise HTTPException(404, f"实体 entity={slug} 无 COSO 自检数据")
data["entity_name"] = f"未知实体({entity}),默认返回酣客数据"
return data return data
+17 -7
View File
@@ -1,8 +1,9 @@
"""预警 API""" """预警 API"""
from fastapi import APIRouter, Depends, Query, HTTPException from fastapi import APIRouter, Depends, Query, HTTPException, Request
from typing import Optional
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.database import get_db from app.database import get_db
from app.deps import get_entity_id from app.deps import get_entity_id, assert_entity_slug_scope, resolve_session_slug
from app.auth_middleware import require_auth, require_role from app.auth_middleware import require_auth, require_role
from app.models import KPIAlert, OperationLog, ActionPlan, KPIDefinition from app.models import KPIAlert, OperationLog, ActionPlan, KPIDefinition
import logging import logging
@@ -328,10 +329,19 @@ RISK_MATRIX_DATA = {
@router.get("/risk-matrix") @router.get("/risk-matrix")
def get_risk_matrix(entity: str = Query("hanke", description="hanke/bohai")): def get_risk_matrix(
"""风险矩阵热力图数据 - CMA P2 ERM四象限""" request: Request,
data = RISK_MATRIX_DATA.get(entity) entity: Optional[str] = Query(None, description="hanke/bohai(须与会话账套一致);缺省按会话账套"),
):
"""风险矩阵热力图数据 - CMA P2 ERM四象限
多租户(cma-cross-entity-endpoints-auth-20260914):
非法实体值 → 400 显式报错(不再静默回落酣客数据);跨实体 → 403。
"""
slug = assert_entity_slug_scope(request, entity)
if slug is None:
slug = resolve_session_slug(request, "hanke")
data = RISK_MATRIX_DATA.get(slug)
if not data: if not data:
data = RISK_MATRIX_DATA["hanke"] raise HTTPException(404, f"实体 entity={slug} 无风险矩阵数据")
data["entity_name"] = f"未知实体({entity}),默认返回酣客数据"
return data return data
+7 -1
View File
@@ -168,7 +168,13 @@ def switch_entity(
current_user: User = Depends(require_auth), current_user: User = Depends(require_auth),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""账套切换:校验授权 → 重新签发token → 写操作日志(方案B:无缝刷新token)""" """账套切换:校验授权 → 重新签发token → 写操作日志(方案B:无缝刷新token)
DEPRECATED(cma-cross-entity-endpoints-auth-20260914,P3):
自 2026-09-14 移除应用内企业切换器后,前端已无任何调用(换企业 = 退出重登)。
端点保留仅为兼容存量脚本/回滚(不删);新代码请改用「重新登录携带 entity_id」。
注:本端点曾用于切换会话账套;当前账套口径以登录 token 绑定为准。
"""
entity_id = data.get("entity_id") entity_id = data.get("entity_id")
if not entity_id: if not entity_id:
raise HTTPException(400, "缺少entity_id") raise HTTPException(400, "缺少entity_id")
+16 -4
View File
@@ -1,7 +1,7 @@
"""自动数据质量监控 — 任务3 """自动数据质量监控 — 任务3
定期检查KPI值异常、连续持平、数据缺失等 定期检查KPI值异常、连续持平、数据缺失等
""" """
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import func, and_, text from sqlalchemy import func, and_, text
from typing import Optional from typing import Optional
@@ -10,6 +10,7 @@ import json
import logging import logging
from app.database import get_db from app.database import get_db
from app.deps import assert_entity_scope
from app.auth_middleware import require_auth, require_role from app.auth_middleware import require_auth, require_role
from app.models import KPIDefinition, KPIValue, KpiDataQualityLog, OperationLog from app.models import KPIDefinition, KPIValue, KpiDataQualityLog, OperationLog
from app.api.kpis import kpi_to_dict from app.api.kpis import kpi_to_dict
@@ -564,13 +565,24 @@ def _run_governance_checks(db: Session, entity_id: int = 0):
@router.get("/governance-check") @router.get("/governance-check")
def governance_check( def governance_check(
entity_id: Optional[int] = Query(0, description="实体ID: 0=全部, 1=酣客, 2=博海"), request: Request,
entity_id: Optional[int] = Query(None, ge=0, description="实体ID: 0=全部(仅Bot通道), 1=酣客, 2=博海;须与会话账套一致"),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""财务七规则检查(DAMA数据治理)— 只读幂等,不写库。 """财务七规则检查(DAMA数据治理)— 只读幂等,不写库。
7条规则: unit_check/dup_alert/orphan_check/virtual_pollution/entity_check/kpi_completeness/reconciliation 7条规则: unit_check/dup_alert/orphan_check/virtual_pollution/entity_check/kpi_completeness/reconciliation
评分: 满分100,error 扣 min(15, count*3),warning 扣 min(10, count*1),score=max(0, 100-总扣分) 评分: 满分100,error 扣 min(15, count*3),warning 扣 min(10, count*1),score=max(0, 100-总扣分)
实体筛选: entity_id 参数(0=全部)。 实体筛选: entity_id 参数(0=全部,仅无会话token的Bot通道)。
多租户(cma-cross-entity-endpoints-auth-20260914):
登录会话按 token 绑定账套取数,显式 entity_id 与会话账套不一致 → 403(同 growth-quality 文案);
未传 entity_id 时按会话账套(不再默认全部实体)。
""" """
return _run_governance_checks(db, entity_id or 0) session_entity = assert_entity_scope(request, entity_id, db)
if session_entity is not None:
scope = session_entity
else:
# Bot 通道(无 token):保持原口径(缺省 0=全部)
scope = 0 if entity_id is None else entity_id
return _run_governance_checks(db, scope)
+13 -3
View File
@@ -1,7 +1,9 @@
"""波士顿产品矩阵 API — 四象限分析""" """波士顿产品矩阵 API — 四象限分析"""
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query, Request
from typing import Optional
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.database import get_db from app.database import get_db
from app.deps import assert_entity_scope
from app.models import ProductSales from app.models import ProductSales
from collections import defaultdict from collections import defaultdict
@@ -29,11 +31,19 @@ def _calc_quadrant(trend: float, margin: float) -> str:
@router.get("/matrix") @router.get("/matrix")
def get_product_matrix( def get_product_matrix(
entity_id: int = Query(1, description="1=酣客 2=博海"), request: Request,
entity_id: Optional[int] = Query(None, ge=1, description="实体ID(须与会话账套一致);缺省按会话账套"),
months: int = Query(3, ge=1, le=6, description="趋势计算月数"), months: int = Query(3, ge=1, le=6, description="趋势计算月数"),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""产品矩阵:横轴=销售趋势,纵轴=毛利率,气泡=销售额""" """产品矩阵:横轴=销售趋势,纵轴=毛利率,气泡=销售额
多租户(cma-cross-entity-endpoints-auth-20260914):
登录会话以 token 绑定账套为准,显式 entity_id 不一致 → 403(同 growth-quality 文案)。
"""
session_entity = assert_entity_scope(request, entity_id, db)
target_entity = session_entity if session_entity is not None else (entity_id or 1)
entity_id = target_entity
# 取最近 months+1 个月(多取1个月用于计算趋势) # 取最近 months+1 个月(多取1个月用于计算趋势)
periods = db.query(ProductSales.period_month).filter( periods = db.query(ProductSales.period_month).filter(
ProductSales.entity_id == entity_id ProductSales.entity_id == entity_id
+79
View File
@@ -79,3 +79,82 @@ def resolve_entity_for_request(request: Request, fallback: int = 1) -> int:
if token_entity is not None: if token_entity is not None:
return token_entity return token_entity
return fallback 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)
+110
View File
@@ -230,3 +230,113 @@ class TestCheckGovernanceSQLite:
resp = client.get("/api/cma/data-quality/governance-check", headers=auth_header(token)) resp = client.get("/api/cma/data-quality/governance-check", headers=auth_header(token))
assert resp.status_code == 200, resp.text[:300] assert resp.status_code == 200, resp.text[:300]
assert resp.json()["total_rules"] == 7 assert resp.json()["total_rules"] == 7
# ============================================================
# 4 端点跨租户鉴权补单(cma-cross-entity-endpoints-auth-20260914)
# 对齐参照:growth-quality / predict.accuracy(已 403)
# ============================================================
class TestCrossEntityEndpointAuth:
"""会话 entity=1 显式请求 entity=2 → 403;同实体 → 200;非法实体 → 显式报错"""
def _login(self, client, db):
_ensure_entity(db, 2)
create_test_user(db)
return get_token_for_user(client, "testadmin", "admin123") # 登录账套 entity_id=1
# ── 1. data-quality/governance-check ──────────────────
def test_governance_check_cross_entity_403(self, client, db):
token = self._login(client, db)
r = client.get("/api/cma/data-quality/governance-check?entity_id=2", headers=auth_header(token))
assert r.status_code == 403, f"跨实体应 403,实际 {r.status_code}: {r.text[:200]}"
assert "无权访问企业 entity_id=2(当前账套: 1)" in r.text
def test_governance_check_same_entity_200(self, client, db):
token = self._login(client, db)
r = client.get("/api/cma/data-quality/governance-check?entity_id=1", headers=auth_header(token))
assert r.status_code == 200, r.text[:200]
assert r.json()["entity_id"] == 1
def test_governance_check_no_param_scoped_to_session(self, client, db):
"""未传 entity_id:按会话账套(不再默认 0=全部)"""
token = self._login(client, db)
r = client.get("/api/cma/data-quality/governance-check", headers=auth_header(token))
assert r.status_code == 200, r.text[:200]
assert r.json()["entity_id"] == 1
def test_governance_check_illegal_entity_403(self, client, db):
token = self._login(client, db)
r = client.get("/api/cma/data-quality/governance-check?entity_id=999", headers=auth_header(token))
assert r.status_code == 403, r.text[:200]
# ── 2. products/matrix ────────────────────────────────
def test_products_matrix_cross_entity_403(self, client, db):
token = self._login(client, db)
r = client.get("/api/cma/products/matrix?entity_id=2&months=6", headers=auth_header(token))
assert r.status_code == 403, f"跨实体应 403,实际 {r.status_code}: {r.text[:200]}"
def test_products_matrix_same_entity_200(self, client, db):
token = self._login(client, db)
r = client.get("/api/cma/products/matrix?entity_id=1", headers=auth_header(token))
assert r.status_code == 200, r.text[:200]
assert r.json()["entity_id"] == 1
def test_products_matrix_no_param_scoped_to_session(self, client, db):
token = self._login(client, db)
r = client.get("/api/cma/products/matrix", headers=auth_header(token))
assert r.status_code == 200, r.text[:200]
assert r.json()["entity_id"] == 1
# ── 3. alerts/risk-matrix(字符串实体)────────────────
def test_risk_matrix_cross_entity_403(self, client, db):
token = self._login(client, db)
r = client.get("/api/cma/alerts/risk-matrix?entity=bohai", headers=auth_header(token))
assert r.status_code == 403, f"跨实体应 403,实际 {r.status_code}: {r.text[:200]}"
assert "无权访问企业 entity_id=2(当前账套: 1)" in r.text
def test_risk_matrix_same_entity_200(self, client, db):
token = self._login(client, db)
r = client.get("/api/cma/alerts/risk-matrix?entity=hanke", headers=auth_header(token))
assert r.status_code == 200, r.text[:200]
assert "酣客" in r.json()["entity_name"]
def test_risk_matrix_no_param_uses_session_entity(self, client, db):
token = self._login(client, db)
r = client.get("/api/cma/alerts/risk-matrix", headers=auth_header(token))
assert r.status_code == 200, r.text[:200]
assert "酣客" in r.json()["entity_name"]
def test_risk_matrix_illegal_entity_explicit_error(self, client, db):
"""非法实体 → 400 显式报错(不再静默回落酣客数据)"""
token = self._login(client, db)
r = client.get("/api/cma/alerts/risk-matrix?entity=2", headers=auth_header(token))
assert r.status_code == 400, f"非法实体应 400,实际 {r.status_code}: {r.text[:200]}"
assert "非法实体" in r.text
assert "默认返回酣客数据" not in r.text
# ── 4. action-plans/coso-checklist ────────────────────
def test_coso_checklist_cross_entity_403(self, client, db):
token = self._login(client, db)
r = client.get("/api/cma/action-plans/coso-checklist?entity=bohai", headers=auth_header(token))
assert r.status_code == 403, f"跨实体应 403,实际 {r.status_code}: {r.text[:200]}"
def test_coso_checklist_same_entity_200(self, client, db):
token = self._login(client, db)
r = client.get("/api/cma/action-plans/coso-checklist?entity=hanke", headers=auth_header(token))
assert r.status_code == 200, r.text[:200]
assert "酣客" in r.json()["entity_name"]
def test_coso_checklist_illegal_entity_explicit_error(self, client, db):
token = self._login(client, db)
r = client.get("/api/cma/action-plans/coso-checklist?entity=999", headers=auth_header(token))
assert r.status_code == 400, r.text[:200]
assert "非法实体" in r.text
# ── 对齐参照端点行为不变 ──────────────────────────────
def test_reference_403_endpoints_unchanged(self, client, db):
"""growth-quality / predict.accuracy 既有 403 行为不变"""
token = self._login(client, db)
r1 = client.get("/api/cma/growth-quality/periods?entity_id=2", headers=auth_header(token))
assert r1.status_code == 403, r1.text[:200]
r2 = client.get("/api/cma/predict/accuracy?entity_id=2", headers=auth_header(token))
assert r2.status_code == 403, r2.text[:200]