From 115a0ec75583e1a83aae356e20630a0283d2798a Mon Sep 17 00:00:00 2001 From: Hermes CI Fix Date: Mon, 14 Sep 2026 11:19:05 +0800 Subject: [PATCH] =?UTF-8?q?fix(security):=204=E7=AB=AF=E7=82=B9=E8=A1=A5?= =?UTF-8?q?=E8=B7=A8=E7=A7=9F=E6=88=B7=E9=89=B4=E6=9D=83=20=E2=80=94=20?= =?UTF-8?q?=E5=AE=9E=E4=BD=931=E4=BC=9A=E8=AF=9D=E8=AF=BB=E5=AE=9E?= =?UTF-8?q?=E4=BD=932=E6=95=B0=E6=8D=AE(200)=E2=86=92403=20(cma-cross-enti?= =?UTF-8?q?ty-endpoints-auth-20260914)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景(项目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)。 --- backend/app/api/action_plans.py | 23 +++-- backend/app/api/alerts.py | 24 +++-- backend/app/api/auth.py | 8 +- backend/app/api/data_quality.py | 20 +++- backend/app/api/products.py | 16 ++- backend/app/deps.py | 79 +++++++++++++++ backend/tests/test_security_multitenant.py | 110 +++++++++++++++++++++ 7 files changed, 258 insertions(+), 22 deletions(-) diff --git a/backend/app/api/action_plans.py b/backend/app/api/action_plans.py index 4728901d..dc2e4898 100644 --- a/backend/app/api/action_plans.py +++ b/backend/app/api/action_plans.py @@ -1,5 +1,5 @@ """改善行动计划 API — 管理会计OS""" -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request from sqlalchemy.orm import Session from datetime import datetime, timezone from typing import Optional @@ -7,7 +7,7 @@ import re import logging from calendar import monthrange 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.models import ActionPlan, KPIAlert, KPIDefinition, KPIValue, User, Objective, KR from app.api.verify import build_auto_verify_rule @@ -430,10 +430,19 @@ COSO_CHECKLIST_DATA = { @router.get("/coso-checklist") -def get_coso_checklist(entity: str = "hanke"): - """COSO内控自检表 - CMA P1 COSO五要素""" - data = COSO_CHECKLIST_DATA.get(entity) +def get_coso_checklist( + request: Request, + 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: - data = COSO_CHECKLIST_DATA["hanke"] - data["entity_name"] = f"未知实体({entity}),默认返回酣客数据" + raise HTTPException(404, f"实体 entity={slug} 无 COSO 自检数据") return data diff --git a/backend/app/api/alerts.py b/backend/app/api/alerts.py index 48eb5a28..aa460abb 100644 --- a/backend/app/api/alerts.py +++ b/backend/app/api/alerts.py @@ -1,8 +1,9 @@ """预警 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 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.models import KPIAlert, OperationLog, ActionPlan, KPIDefinition import logging @@ -328,10 +329,19 @@ RISK_MATRIX_DATA = { @router.get("/risk-matrix") -def get_risk_matrix(entity: str = Query("hanke", description="hanke/bohai")): - """风险矩阵热力图数据 - CMA P2 ERM四象限""" - data = RISK_MATRIX_DATA.get(entity) +def get_risk_matrix( + request: Request, + 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: - data = RISK_MATRIX_DATA["hanke"] - data["entity_name"] = f"未知实体({entity}),默认返回酣客数据" + raise HTTPException(404, f"实体 entity={slug} 无风险矩阵数据") return data diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 499f3457..f49cd393 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -168,7 +168,13 @@ def switch_entity( current_user: User = Depends(require_auth), 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") if not entity_id: raise HTTPException(400, "缺少entity_id") diff --git a/backend/app/api/data_quality.py b/backend/app/api/data_quality.py index e68a44b8..8d1d9bef 100644 --- a/backend/app/api/data_quality.py +++ b/backend/app/api/data_quality.py @@ -1,7 +1,7 @@ """自动数据质量监控 — 任务3 定期检查KPI值异常、连续持平、数据缺失等 """ -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException, Query, Request from sqlalchemy.orm import Session from sqlalchemy import func, and_, text from typing import Optional @@ -10,6 +10,7 @@ import json import logging from app.database import get_db +from app.deps import assert_entity_scope from app.auth_middleware import require_auth, require_role from app.models import KPIDefinition, KPIValue, KpiDataQualityLog, OperationLog 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") 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), ): """财务七规则检查(DAMA数据治理)— 只读幂等,不写库。 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-总扣分) - 实体筛选: 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) diff --git a/backend/app/api/products.py b/backend/app/api/products.py index d607fe58..b82e277e 100644 --- a/backend/app/api/products.py +++ b/backend/app/api/products.py @@ -1,7 +1,9 @@ """波士顿产品矩阵 API — 四象限分析""" -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, Query, Request +from typing import Optional from sqlalchemy.orm import Session from app.database import get_db +from app.deps import assert_entity_scope from app.models import ProductSales from collections import defaultdict @@ -29,11 +31,19 @@ def _calc_quadrant(trend: float, margin: float) -> str: @router.get("/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="趋势计算月数"), 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个月用于计算趋势) periods = db.query(ProductSales.period_month).filter( ProductSales.entity_id == entity_id diff --git a/backend/app/deps.py b/backend/app/deps.py index 2869bb6a..a8ba7088 100644 --- a/backend/app/deps.py +++ b/backend/app/deps.py @@ -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) diff --git a/backend/tests/test_security_multitenant.py b/backend/tests/test_security_multitenant.py index ecfebf50..0b490954 100644 --- a/backend/tests/test_security_multitenant.py +++ b/backend/tests/test_security_multitenant.py @@ -230,3 +230,113 @@ class TestCheckGovernanceSQLite: 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", "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]