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:
@@ -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])
|
||||
|
||||
Reference in New Issue
Block a user