Files
cma-management/backend/tests/test_security_multitenant.py
Hermes CI Fix 40d522cd34 fix(security): retire default-credential surface (yanxue-20260914-cma-defaultcred-code-fix)
- Login.vue: no prefilled default creds; no anonymous entity enumeration on mount

- deploy.sh: readiness polling (0.5s/30s cap) replaces fixed sleep 3; login smoke reads same-source 600 secret file, missing file = explicit abort

- 19 verify/check/gen_report scripts: shared _admin_creds() loader (CMA_ADMIN_ENV -> ops .secrets -> profile copy); no hardcoded creds

- tests: single TEST_USER_PASSWORD fixture constant; docs: pointer to 600 secret file; removed 4 legacy .bak + pycache residue
2026-09-14 17:39:53 +08:00

378 lines
19 KiB
Python
Raw Permalink 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.
"""多租户隔离安全修复测试(2026-08-31 OpenCode 安全审查 P0)
覆盖 DoD 输出物8/9/10:
- bot_bridge 跨 entity 隔离(X-Entity-Id header 带不同账套返回不同数据)
- alert_rules create 写入 entity_id(跨 entity 不可见)
- reports 跨 entity 过滤(profit-summary / kpi-trends)
- _eval_threshold invert 参数(低于阈值触发红灯)
- /check-governance 下线(cma-residual-cross-entity-cleanup-20260914)→ 404;governance-check 保留
"""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from app.models import KPIDefinition, KPIAlert, Entity, UserEntity
from app.api.alert_rules import AlertRule, _eval_threshold
from tests.conftest import create_test_kpi, create_test_user, get_token_for_user, auth_header, TEST_USER_PASSWORD
BOT_KEY = {"X-BOT-KEY": "cma-bot-finance-2026"}
def _ensure_entity(db: Session, eid: int, name: str = None) -> Entity:
"""确保测试库存在指定 entity(BOT 通道 get_entity_id 会校验 active)"""
ent = db.query(Entity).filter(Entity.id == eid).first()
if not ent:
ent = Entity(id=eid, name=name or f"企业{eid}", short_name=f"E{eid}", status="active")
db.add(ent)
db.commit()
db.refresh(ent)
return ent
def _seed_entity_kpi(db: Session, entity_id: int, code: str, name: str = None) -> KPIDefinition:
"""创建指定账套的 KPI(多租户测试专用)"""
kpi = create_test_kpi(db, kpi_code=code, kpi_name=name or code, entity_id=entity_id,
target_value=100.0, unit="万元", frequency="monthly",
dimension="finance", status="active")
return kpi
# ============================================================
# bot_bridge 跨 entity 隔离(P0-1)
# ============================================================
class TestBotBridgeIsolation:
def test_kpis_entity_isolation(self, client: TestClient, db: Session):
"""BOT Key + X-Entity-Id=1 → 只返回 entity1 的 KPI;X-Entity-Id=2 → 只返回 entity2"""
_ensure_entity(db, 2)
_seed_entity_kpi(db, entity_id=1, code="E1_REVENUE")
_seed_entity_kpi(db, entity_id=2, code="E2_REVENUE")
db.commit()
r1 = client.get("/api/cma/bot/kpis", headers={**BOT_KEY, "X-Entity-Id": "1"})
assert r1.status_code == 200
codes1 = {i["code"] for i in r1.json()["items"]}
assert "E1_REVENUE" in codes1
assert "E2_REVENUE" not in codes1
r2 = client.get("/api/cma/bot/kpis", headers={**BOT_KEY, "X-Entity-Id": "2"})
assert r2.status_code == 200
codes2 = {i["code"] for i in r2.json()["items"]}
assert "E2_REVENUE" in codes2
assert "E1_REVENUE" not in codes2
def test_overview_entity_isolation(self, client: TestClient, db: Session):
"""overview 统计按 entity 过滤:entity1 只统计自己的 KPI/预警"""
_ensure_entity(db, 2)
k1 = _seed_entity_kpi(db, entity_id=1, code="O1_KPI")
_seed_entity_kpi(db, entity_id=2, code="O2_KPI")
db.add(KPIAlert(kpi_id=k1.id, alert_level="red", alert_message="e1预警",
status="pending", entity_id=1))
db.commit()
r1 = client.get("/api/cma/bot/overview", headers={**BOT_KEY, "X-Entity-Id": "1"})
assert r1.status_code == 200
assert r1.json()["stats"]["kpis_total"] == 1
assert r1.json()["stats"]["alerts_open"] == 1
r2 = client.get("/api/cma/bot/overview", headers={**BOT_KEY, "X-Entity-Id": "2"})
assert r2.status_code == 200
assert r2.json()["stats"]["kpis_total"] == 1
assert r2.json()["stats"]["alerts_open"] == 0
def test_query_param_entity_isolation(self, client: TestClient, db: Session):
"""无 token 时 entity_id 也可通过 query 参数传入(Bot 通道)"""
_ensure_entity(db, 2)
_seed_entity_kpi(db, entity_id=1, code="Q1_KPI")
_seed_entity_kpi(db, entity_id=2, code="Q2_KPI")
db.commit()
r = client.get("/api/cma/bot/kpis?entity_id=1", headers=BOT_KEY)
assert r.status_code == 200
codes = {i["code"] for i in r.json()["items"]}
assert "Q1_KPI" in codes
assert "Q2_KPI" not in codes
# ============================================================
# alert_rules 隔离(P0-2)
# ============================================================
class TestAlertRulesIsolation:
def test_create_alert_rule_writes_entity_id(self, client: TestClient, db: Session):
"""create 写入 entity_id:以 entity1 身份创建的规则,entity2 不可见"""
_ensure_entity(db, 2)
k1 = _seed_entity_kpi(db, entity_id=1, code="AR_E1")
db.commit()
create_test_user(db)
token = get_token_for_user(client, "testadmin", TEST_USER_PASSWORD)
headers = auth_header(token)
resp = client.post("/api/cma/alert-rules", headers=headers, json={
"kpi_id": k1.id,
"rule_type": "static",
"params": {"operator": ">=", "threshold": 80.0},
"trigger_on": "actual",
})
assert resp.status_code == 200, resp.text
# DB 回查:规则 entity_id = 1(用户 token 绑定 entity1)
rule = db.query(AlertRule).filter(AlertRule.kpi_id == k1.id).first()
assert rule is not None
assert rule.entity_id == 1
def test_get_kpi_rules_entity_scoped(self, client: TestClient, db: Session):
"""get_kpi_rules 按 entity 过滤:entity2 查不到 entity1 的规则"""
_ensure_entity(db, 2)
k1 = _seed_entity_kpi(db, entity_id=1, code="GR_E1")
db.add(AlertRule(kpi_id=k1.id, rule_type="static",
params={"operator": ">=", "threshold": 80.0},
entity_id=1))
db.commit()
create_test_user(db)
token = get_token_for_user(client, "testadmin", TEST_USER_PASSWORD)
r1 = client.get(f"/api/cma/alert-rules/kpi/{k1.id}", headers=auth_header(token))
assert r1.status_code == 200
assert len(r1.json()["data"]) >= 1
# ============================================================
# reports 跨 entity 过滤(P1-3)
# ============================================================
class TestReportsIsolation:
def test_profit_summary_entity_filtered(self, client: TestClient, db: Session):
"""profit-summary 按 entity 过滤:entity2 的 KPI 值对 entity1 不可见"""
_ensure_entity(db, 2)
from app.models import KPIValue
k1 = _seed_entity_kpi(db, entity_id=1, code="F_REVENUE")
_seed_entity_kpi(db, entity_id=2, code="F_REVENUE_2")
db.add(KPIValue(kpi_id=k1.id, period="2026-06", actual_value=888.0,
data_status="verified", entity_id=1))
db.commit()
create_test_user(db)
token = get_token_for_user(client, "testadmin", TEST_USER_PASSWORD)
r1 = client.get("/api/cma/reports/profit-summary?period=2026-06", headers=auth_header(token))
assert r1.status_code == 200
# entity1 的 token → 读到 entity1 的收入项(F_REVENUE 命中,含888值)
items = r1.json()["items"]
assert len(items) >= 1
def test_kpi_trends_entity_filtered(self, client: TestClient, db: Session):
"""kpi-trends 按 entity 过滤:entity2 看不到 entity1 的 KPI 列表"""
_ensure_entity(db, 2)
_seed_entity_kpi(db, entity_id=1, code="TR_E1")
_seed_entity_kpi(db, entity_id=2, code="TR_E2")
db.commit()
create_test_user(db)
token = get_token_for_user(client, "testadmin", TEST_USER_PASSWORD)
r1 = client.get("/api/cma/reports/kpi-trends", headers=auth_header(token))
assert r1.status_code == 200
kpis1 = r1.json().get("data", [])
codes1 = {k["kpi_code"] for k in kpis1}
assert "TR_E1" in codes1
assert "TR_E2" not in codes1
# ============================================================
# _eval_threshold invert 参数(P1-1)
# ============================================================
class TestInvertThreshold:
def test_invert_lower_threshold_triggers_red(self):
"""invert=True:值低于阈值时取反(低于下限触发红灯场景)"""
assert _eval_threshold(500, "<600", invert=True) is False # 500 < 600,原命中 → invert 后不命中
assert _eval_threshold(700, "<600", invert=True) is True # 700 >= 600,原不命中 → invert 后命中
def test_invert_greater_threshold(self):
"""invert=True:值高于阈值时取反"""
assert _eval_threshold(30, ">25", invert=True) is False # 30 > 25,原命中 → invert 后不命中
assert _eval_threshold(10, ">25", invert=True) is True # 10 <= 25,原不命中 → invert 后命中
def test_invert_gt_eq_and_lt_eq(self):
""">= 与 <= 的 invert 取反"""
assert _eval_threshold(80, ">=90", invert=True) is True # 80 < 90 → invert 命中
assert _eval_threshold(95, ">=90", invert=True) is False
assert _eval_threshold(95, "<=90", invert=True) is True # 95 > 90 → invert 命中
assert _eval_threshold(85, "<=90", invert=True) is False
def test_red_branch_no_invert_behavior_preserved(self):
"""_check_static red 分支不传 invert:字面阈值行为不变(回归保护)"""
assert _eval_threshold(500, "<600") is True # 低于600 → 命中(默认字面)
assert _eval_threshold(700, "<600") is False
assert _eval_threshold(30, ">25") is True
assert _eval_threshold(10, ">25") is False
# ============================================================
# /check-governance 下线(cma-residual-cross-entity-cleanup-20260914)
# ============================================================
class TestCheckGovernanceRetired:
def test_check_governance_endpoint_removed(self, client: TestClient, db: Session):
"""/check-governance 已下线 → 404(该端点 entity_id 参数无会话鉴权,跨租户)"""
_seed_entity_kpi(db, entity_id=1, code="GOV_KPI")
db.commit()
create_test_user(db)
token = get_token_for_user(client, "testadmin", TEST_USER_PASSWORD)
resp = client.get("/api/cma/data-quality/check-governance", headers=auth_header(token))
assert resp.status_code == 404, f"应 404,实际 {resp.status_code}: {resp.text[:200]}"
# 跨租户取数路径随之消失(同参数不再有响应体)
resp2 = client.get("/api/cma/data-quality/check-governance?entity_id=2", headers=auth_header(token))
assert resp2.status_code == 404, resp2.text[:200]
def test_governance_check_still_works(self, client: TestClient, db: Session):
"""governance-check 端点(唯一入口)在收敛后仍正常"""
_seed_entity_kpi(db, entity_id=1, code="GOV2_KPI")
db.commit()
create_test_user(db)
token = get_token_for_user(client, "testadmin", TEST_USER_PASSWORD)
resp = client.get("/api/cma/data-quality/governance-check", headers=auth_header(token))
assert resp.status_code == 200, resp.text[:300]
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", TEST_USER_PASSWORD) # 登录账套 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]
# ============================================================
# reports/dupont 口径收敛(cma-residual-cross-entity-cleanup-20260914)
# 旧「跨实体对比」豁免(2026-08-31)撤销 → 分析对象=会话账套
# ============================================================
class TestDupontSessionScope:
BASE = "/api/cma/reports/dupont"
def _login(self, client, db):
_ensure_entity(db, 2)
create_test_user(db)
return get_token_for_user(client, "testadmin", TEST_USER_PASSWORD) # 登录账套 entity_id=1
def test_dupont_cross_entity_bohai_403(self, client, db):
"""酣客会话显式 entity=bohai(博海真实KPI)→ 403(旧豁免已撤销)"""
token = self._login(client, db)
r = client.get(f"{self.BASE}?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_dupont_no_param_is_session_entity(self, client, db):
"""无 entity 参数 → 按会话账套(酣客),不再默认博海"""
token = self._login(client, db)
r = client.get(self.BASE, headers=auth_header(token))
assert r.status_code == 200, r.text[:200]
assert r.json()["entity"] == "hanke"
assert "酣客" in r.json()["entity_name"]
def test_dupont_same_entity_200(self, client, db):
"""同实体显式参数 → 200"""
token = self._login(client, db)
r = client.get(f"{self.BASE}?entity=hanke", headers=auth_header(token))
assert r.status_code == 200, r.text[:200]
assert r.json()["entity"] == "hanke"