From a2fedd892ac74092463b48136bc05b56d096a7f1 Mon Sep 17 00:00:00 2001 From: Hermes CI Fix Date: Fri, 18 Sep 2026 17:18:52 +0800 Subject: [PATCH] fix(cma): isolate KPI alignment tree by entity (project-20260918-alignment-entity-leak) GET /api/cma/alignment/tree queried all active KPIs without entity_id filter, so switching the three alignment modes showed other tenants' KPIs. Add get_entity_id dependency and filter KPIDefinition by entity_id. Three mode builders (_build_vertical_tree/_build_horizontal_tree/_build_bsc_chain) unchanged. Auth semantics unchanged; cross-entity explicit entity_id still returns 403. Test: tests/test_alignment_entity_isolation_20260918.py (3 modes x both directions, 403, 401). --- backend/app/api/alignment.py | 16 +- ...est_alignment_entity_isolation_20260918.py | 188 ++++++++++++++++++ 2 files changed, 201 insertions(+), 3 deletions(-) create mode 100644 backend/tests/test_alignment_entity_isolation_20260918.py diff --git a/backend/app/api/alignment.py b/backend/app/api/alignment.py index 961949db..6d660633 100644 --- a/backend/app/api/alignment.py +++ b/backend/app/api/alignment.py @@ -10,6 +10,7 @@ import json from app.database import get_db from app.auth_middleware import require_auth, require_role, require_auth_401 +from app.deps import get_entity_id from app.models import KPIDefinition, OperationLog, RolePermission router = APIRouter(prefix="/api/cma/alignment", tags=["KPI目标对齐"], @@ -90,14 +91,20 @@ def set_alignment_config( @router.get("/tree") def get_alignment_tree( kpi_id: Optional[int] = Query(None), + entity_id: int = Depends(get_entity_id), db: Session = Depends(get_db), user = Depends(require_auth), ): - """获取KPI对齐关系树 + """获取KPI对齐关系树(按当前账套隔离) 根据当前系统配置的对齐模式,返回KPI的父子层级关系。 如果指定kpi_id,返回该KPI及其下级树; 如果不指定,返回整个对齐树。 + + 账套隔离(2026-09-18 project-20260918-alignment-entity-leak): + 只取 entity_id 归属本账套的 KPI(entity_id 来自会话 token 绑定,写法对齐 + growth-quality / predict.accuracy)。三种模式共用本列表 → 三模式同时隔离。 + 跨实体显式传参(?entity_id=<他人账套>)由 get_entity_id 返回 403。 """ # 获取当前模式 config_perm = db.query(RolePermission).filter(RolePermission.key == "alignment_config").first() @@ -105,8 +112,11 @@ def get_alignment_tree( if not mode: raise HTTPException(400, "系统未配置对齐模式,请先在系统设置中初始化") - # 获取所有KPI - kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").order_by(KPIDefinition.kpi_code).all() + # 获取本账套所有KPI(账套隔离:仅当前 entity_id,不再返回全实体 KPI) + kpis = (db.query(KPIDefinition) + .filter(KPIDefinition.entity_id == entity_id, KPIDefinition.status == "active") + .order_by(KPIDefinition.kpi_code) + .all()) kpi_map = {k.id: k for k in kpis} # 构建父子关系 diff --git a/backend/tests/test_alignment_entity_isolation_20260918.py b/backend/tests/test_alignment_entity_isolation_20260918.py new file mode 100644 index 00000000..ee1bee77 --- /dev/null +++ b/backend/tests/test_alignment_entity_isolation_20260918.py @@ -0,0 +1,188 @@ +"""战略执行看板(/alignment)账套隔离回归测试 — 2026-09-18 + +任务:project-20260918-alignment-entity-leak(任总 2026-09-18 派单) +缺陷:GET /api/cma/alignment/tree 未接账套(entity_id)→ 返回全实体 active KPI, + 三种对齐模式共用同一份 KPI 列表 → 酣客账套视图混入博海 KPI。 + +本用例覆盖(三模式 × 双向隔离): +1. 纵向分解 / 横向支撑 / BSC瀑布链 三种模式,均只返回会话账套的 KPI; +2. 双向:entity1 会话看不到 entity2 的 KPI,entity2 会话看不到 entity1 的 KPI; +3. 跨实体显式传参 ?entity_id=<对方> → 403(对齐 get_entity_id 既有语义); +4. 鉴权语义未改:无 token → 401; +5. total 计数 = 本账套 active KPI 数(不是全库)。 +""" +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + +from app.models import Entity, KPIDefinition, RolePermission +from tests.conftest import ( + TEST_USER_PASSWORD, + auth_header, + create_test_user, + get_token_for_user, +) + +ALIGN = "/api/cma/alignment" +MODES = ["vertical_decomposition", "horizontal_support", "bsc_chain"] + +# 两个账套各自的 KPI 编码(kpi_code 库内唯一,故两侧编码前缀不同) +E1_CODES = ["HK_REVENUE_001", "HK_REVENUE_002", "HK_PROFIT_001"] +E2_CODES = ["BH_REVENUE_001", "BH_PROFIT_001"] + + +def _ensure_entity(db: Session, eid: int, name: str) -> Entity: + ent = db.query(Entity).filter(Entity.id == eid).first() + if not ent: + ent = Entity(id=eid, name=name, short_name=name, status="active") + db.add(ent) + db.commit() + db.refresh(ent) + return ent + + +def _set_mode(db: Session, mode: str) -> None: + """写入全局对齐模式配置(RolePermission.alignment_config,不分账套)""" + perm = db.query(RolePermission).filter(RolePermission.key == "alignment_config").first() + if perm: + perm.value = {"mode": mode} + else: + db.add(RolePermission(key="alignment_config", value={"mode": mode})) + db.commit() + + +def _mk_kpi(db: Session, code: str, eid: int, dimension: str, category: str) -> KPIDefinition: + kpi = KPIDefinition( + entity_id=eid, + kpi_code=code, + kpi_name=f"{code}-名称", + dimension=dimension, + category=category, + formula="-", + data_source_type="excel", + data_source="Excel导入", + data_owner="财务部", + frequency="monthly", + unit="元", + target_value=0, + kpi_level="operational", + status="active", + ) + db.add(kpi) + db.commit() + db.refresh(kpi) + return kpi + + +def _seed(db: Session) -> None: + """两个账套各 2-3 个 active KPI(三模式均可构建出层级)""" + _ensure_entity(db, 1, "酣客") + _ensure_entity(db, 2, "博海") + for c in E1_CODES: + _mk_kpi(db, c, 1, "finance", "revenue_growth") + for c in E2_CODES: + _mk_kpi(db, c, 2, "finance", "revenue_growth") + + +def _login(client: TestClient, entity_id: int) -> str: + resp = client.post("/api/cma/auth/login", json={ + "username": "testadmin", + "password": TEST_USER_PASSWORD, + "entity_id": entity_id, + }) + assert resp.status_code == 200, f"登录 entity{entity_id} 失败: {resp.text[:300]}" + return resp.json()["token"] + + +def _collect_codes(nodes) -> set: + """递归收集树内所有 kpi_code(维度组/类别组节点无 kpi_code,跳过)""" + found = set() + for node in nodes or []: + if not isinstance(node, dict): + continue + if node.get("kpi_code"): + found.add(node["kpi_code"]) + found |= _collect_codes(node.get("children")) + return found + + +@pytest.mark.parametrize("mode", MODES) +def test_tree_isolated_by_entity_both_directions(client: TestClient, db: Session, mode: str): + """三模式 × 双向:只返回本账套 KPI,不含对方账套 code""" + _seed(db) + create_test_user(db) + _set_mode(db, mode) + + token1 = _login(client, 1) + token2 = _login(client, 2) + + r1 = client.get(f"{ALIGN}/tree", headers=auth_header(token1)) + assert r1.status_code == 200, r1.text + body1 = r1.json() + codes1 = _collect_codes(body1["tree"]) + + r2 = client.get(f"{ALIGN}/tree", headers=auth_header(token2)) + assert r2.status_code == 200, r2.text + body2 = r2.json() + codes2 = _collect_codes(body2["tree"]) + + # 酣客(e1):只见酣客 code,绝不含博海 code + assert codes1 == set(E1_CODES), f"[{mode}] entity1 返回 {sorted(codes1)}" + assert not any(c.startswith("BH_") for c in codes1), f"[{mode}] entity1 混入博海 KPI" + # 博海(e2):反向同理 + assert codes2 == set(E2_CODES), f"[{mode}] entity2 返回 {sorted(codes2)}" + assert not any(c.startswith("HK_") for c in codes2), f"[{mode}] entity2 混入酣客 KPI" + + # total = 本账套 active KPI 数(修复前 = 全库 5) + assert body1["total"] == len(E1_CODES), f"[{mode}] entity1 total={body1['total']}" + assert body2["total"] == len(E2_CODES), f"[{mode}] entity2 total={body2['total']}" + assert body1["mode"] == mode and body2["mode"] == mode + + +def test_tree_cross_entity_explicit_param_403(client: TestClient, db: Session): + """跨实体显式传参 → 403(会话 token 绑定账套为准,未改鉴权语义外的任何行为)""" + _seed(db) + create_test_user(db) + _set_mode(db, "horizontal_support") + + token1 = _login(client, 1) + token2 = _login(client, 2) + + r = client.get(f"{ALIGN}/tree?entity_id=2", headers=auth_header(token1)) + assert r.status_code == 403, f"entity1 会话读 entity2 应 403,实际 {r.status_code}" + assert "无权访问" in r.json()["detail"] + + r = client.get(f"{ALIGN}/tree?entity_id=1", headers=auth_header(token2)) + assert r.status_code == 403, f"entity2 会话读 entity1 应 403,实际 {r.status_code}" + + +def test_tree_without_token_401(client: TestClient, db: Session): + """鉴权底线不变:无 Bearer token → 401""" + _seed(db) + _set_mode(db, "bsc_chain") + r = client.get(f"{ALIGN}/tree") + assert r.status_code == 401, f"无 token 应 401,实际 {r.status_code}" + + +def test_tree_same_entity_explicit_param_ok(client: TestClient, db: Session): + """同账套显式传参与 token 一致 → 200(不是一律拒绝)""" + _seed(db) + create_test_user(db) + _set_mode(db, "bsc_chain") + token1 = get_token_for_user(client) # entity1 + r = client.get(f"{ALIGN}/tree?entity_id=1", headers=auth_header(token1)) + assert r.status_code == 200, r.text + assert _collect_codes(r.json()["tree"]) == set(E1_CODES) + + +def test_tree_kpi_id_of_other_entity_not_returned(client: TestClient, db: Session): + """kpi_id 指向对方账套的 KPI → 不返回该 KPI(隔离后树内查无此 id)""" + _seed(db) + create_test_user(db) + _set_mode(db, "vertical_decomposition") + other = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == E2_CODES[0]).first() + token1 = _login(client, 1) + r = client.get(f"{ALIGN}/tree?kpi_id={other.id}", headers=auth_header(token1)) + assert r.status_code == 200 + body = r.json() + assert body is None or not _collect_codes([body]), f"entity1 不应取到 entity2 的 KPI: {body}"