fix(security): close unauthenticated CMA user API surface + audit tool (cma-api-auth-surface-audit-20260914)

Root cause: main.py include_router had no global auth dependency; user routers
(products/cost/predict/...) relied on assert_entity_scope, which treats a
missing session token as the Bot service channel and lets the request through.
Result: 46 user endpoints returned data without any token on a public site.

Changes:
- auth_middleware.py: add require_auth_401 (missing Bearer -> 401, delegates to
  require_auth otherwise); 401/403 semantics are explicit.
- router-level Depends(require_auth_401) on products, cost, predict,
  notifications, permissions, tenant, alignment; endpoint-level on
  /auth/roles and /admin/cache/clear.
- deps.py: document that get_entity_id/assert_entity_scope are NOT auth.
- frontend: add the missing Bearer header to 5 tokenless fetch/axios calls that
  hit the newly protected endpoints (KPIAlignment x2, RolePermissions x2,
  Dashboard x1).
- tests: new test_api_auth_surface_20260914.py (58 cases: no-token 401,
  token 200, cross-entity 403, bot channel unchanged); adapt test_predict /
  test_kpi_forecast to send session tokens like production.
- scripts/audit_api_auth_surface.py: reusable FastAPI dependency-graph audit
  (423 endpoints; A bot / B gap / C public), exits 1 when a B-class gap exists.
This commit is contained in:
fullstack-bot
2026-09-14 16:41:15 +08:00
parent aac1bd1df1
commit b815b7f623
18 changed files with 604 additions and 51 deletions
+43 -11
View File
@@ -2,18 +2,53 @@
覆盖:线性回归预测 / 移动平均预测 / 置信区间 / 趋势方向判定 /
边界情况(历史<2条、无历史、未来期数=0)/ entity_id 多租户隔离。
鉴权(cma-api-auth-surface-audit-20260914):/api/cma/predict/* 已收口为「用户端点必须
带会话 Bearer」。`_kpi_forecast_auth` autouse fixture 统一注入账套1的测试 token;
多账套隔离用例用 `_token_for_entity` 取对应账套 token 传 headers。
"""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from app.models import Entity, KPIValue
from app.models import Entity, KPIValue, User, UserEntity
from app.utils import kpi_forecast_engine as engine
from tests.conftest import create_test_kpi, create_test_user, get_token_for_user, auth_header
BASE = "/api/cma/predict"
def _token_for_entity(client: TestClient, db: Session, eid: int) -> str:
"""为指定账套签发测试 token(自动补实体 + 授权)"""
ent = db.query(Entity).filter(Entity.id == eid).first()
if not ent:
db.add(Entity(id=eid, name=f"企业{eid}", short_name=f"E{eid}", status="active"))
db.commit()
user = db.query(User).filter(User.username == "testadmin").first()
if user is None:
user = create_test_user(db)
db.commit()
rel = db.query(UserEntity).filter(
UserEntity.user_id == user.id, UserEntity.entity_id == eid).first()
if not rel:
db.add(UserEntity(user_id=user.id, entity_id=eid))
db.commit()
resp = client.post("/api/cma/auth/login", json={
"username": "testadmin", "password": "admin123", "entity_id": eid})
assert resp.status_code == 200, f"登录 entity{eid} 失败: {resp.text[:200]}"
return resp.json()["token"]
@pytest.fixture(autouse=True)
def _kpi_forecast_auth(client: TestClient, db: Session):
"""统一注入账套1的测试 token(显式传 headers= 的用例仍可覆盖)"""
if db.query(User).filter(User.username == "testadmin").first() is None:
create_test_user(db)
db.commit()
client.headers["Authorization"] = f"Bearer {get_token_for_user(client)}"
yield
# ── 测试数据工厂 ──
def _seed_history(db: Session, kpi, values: list, entity_id: int = 1, start_period: str = "2026-01"):
@@ -266,20 +301,18 @@ class TestKpiForecastFinanceApi:
class TestEntityIsolation:
def test_entity2_kpi_invisible_to_entity1(self, client: TestClient, db: Session):
"""entity2 的KPI在 entity1 下查询 → 404(不串数据)"""
"""entity2 的KPI在 entity1 会话下查询 → 404(不串数据)"""
_seed_entity2(db)
kpi2 = create_test_kpi(db, kpi_code="F_ISO_E2", kpi_name="博海专属KPI",
dimension="finance", entity_id=2)
_seed_history(db, kpi2, [100, 110, 120, 130], entity_id=2)
# 无token时 entity_id 走 query 参数 → entity1
resp = client.get(f"{BASE}/kpi-forecast", params={
"entity_id": 1, "kpi_code": "F_ISO_E2",
})
# 会话账套1:查不到 entity2 的 KPI → 404(鉴权收口后账套由 token 决定)
resp = client.get(f"{BASE}/kpi-forecast", params={"kpi_code": "F_ISO_E2"})
assert resp.status_code == 404
# entity2 自己能查到
resp = client.get(f"{BASE}/kpi-forecast", params={
"entity_id": 2, "kpi_code": "F_ISO_E2",
})
# 会话账套2 自己:能查到
token2 = _token_for_entity(client, db, 2)
resp = client.get(f"{BASE}/kpi-forecast", params={"kpi_code": "F_ISO_E2"},
headers=auth_header(token2))
assert resp.status_code == 200
assert resp.json()["entity_id"] == 2
@@ -298,7 +331,6 @@ class TestEntityIsolation:
def test_token_binds_entity(self, client: TestClient, db: Session):
"""token绑定entity1时显式传entity_id=2 → 403 越权拦截"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, kpi_code="F_ISO_TK", kpi_name="TokenKPI")
_seed_history(db, kpi, [1, 2, 3, 4])