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
@@ -0,0 +1,255 @@
"""CMA API 鉴权面收口测试(cma-api-auth-surface-audit-20260914)
背景:项目Bot 实测发现部分用户端点无 token 亦可取数(products/matrix、cost/*、predict/*
等)。根因:这些 router 未挂任何鉴权依赖,端点仅用 assert_entity_scope(它**不是鉴权**,
无 token 时按「Bot 服务通道」放行)。
本文件锁定收口后的鉴权姿态:
1. B 类(原缺鉴权用户端点)无 token → **401**(46 个端点逐条断言)
2. C 类(显式公开端点)无 token 仍按原语义可用
3. A 类(X-BOT-KEY Bot 通道)行为不变:带 key 200 / 无 key 401
4. 既有 require_auth 端点(kpis/maps)无 token → 403(对照,不回归)
5. 持 token 正常 200;跨实体(token entity=1 + entity_id=2)仍 403
6. 结构性不变量:全量路由巡检无 B 类缺口(新增用户端点漏挂鉴权 → 本用例 FAIL)
"""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from app.models import Entity, UserEntity
from tests.conftest import create_test_user, auth_header
BOT_KEY = {"X-BOT-KEY": "cma-bot-finance-2026"}
# ── B 类收口清单:原「无 token 亦可访问」的用户端点(46 个)──────────────
# 无 token 必须 401。模板路径用具体 id=1(鉴权依赖先于路径/body 校验执行)。
B_CLASS_ENDPOINTS = [
("POST", "/api/cma/admin/cache/clear"),
("GET", "/api/cma/alignment/config"),
("GET", "/api/cma/alignment/modes"),
("GET", "/api/cma/auth/roles"),
("GET", "/api/cma/cost/abc/activities"),
("POST", "/api/cma/cost/abc/activities"),
("POST", "/api/cma/cost/abc/allocate"),
("GET", "/api/cma/cost/abc/allocations"),
("GET", "/api/cma/cost/actual-costs"),
("POST", "/api/cma/cost/actual-costs"),
("GET", "/api/cma/cost/breakdown"),
("GET", "/api/cma/cost/comparison"),
("GET", "/api/cma/cost/dashboard"),
("GET", "/api/cma/cost/overview"),
("GET", "/api/cma/cost/standard-costs"),
("POST", "/api/cma/cost/standard-costs"),
("PUT", "/api/cma/cost/standard-costs/1"),
("DELETE", "/api/cma/cost/standard-costs/1"),
("GET", "/api/cma/cost/variance"),
("GET", "/api/cma/notifications/channels"),
("POST", "/api/cma/notifications/channels"),
("PUT", "/api/cma/notifications/channels/1"),
("DELETE", "/api/cma/notifications/channels/1"),
("POST", "/api/cma/notifications/channels/1/test"),
("GET", "/api/cma/notifications/logs"),
("GET", "/api/cma/permissions/config"),
("GET", "/api/cma/permissions/modules"),
("GET", "/api/cma/predict/accuracy"),
("POST", "/api/cma/predict/cash-forecast"),
("GET", "/api/cma/predict/cash-forecast/history"),
("POST", "/api/cma/predict/cvp"),
("POST", "/api/cma/predict/cvp-detailed"),
("POST", "/api/cma/predict/growth-quality"),
("POST", "/api/cma/predict/investment"),
("GET", "/api/cma/predict/kpi-forecast"),
("GET", "/api/cma/predict/kpi-forecast/finance"),
("GET", "/api/cma/predict/kpi-forecast/sensitivity"),
("POST", "/api/cma/predict/real-option"),
("POST", "/api/cma/predict/relevant-decision"),
("POST", "/api/cma/predict/scenario"),
("POST", "/api/cma/predict/scenario-suggestion/generate"),
("GET", "/api/cma/predict/scenario-suggestions"),
("POST", "/api/cma/predict/sensitivity"),
("GET", "/api/cma/products/matrix"),
("GET", "/api/cma/tenant/current"),
("POST", "/api/cma/tenant/switch"),
]
# 种子端点(项目Bot 实测无 token 200 → 收口后必须 401;持 token 必须 200)
SEED_NO_TOKEN_401 = [
"/api/cma/products/matrix",
"/api/cma/cost/dashboard",
"/api/cma/cost/overview",
"/api/cma/predict/accuracy",
]
def _ensure_entity(db: Session, eid: int) -> Entity:
ent = db.query(Entity).filter(Entity.id == eid).first()
if not ent:
ent = Entity(id=eid, name=f"企业{eid}", short_name=f"E{eid}", status="active")
db.add(ent)
db.commit()
db.refresh(ent)
return ent
def _login(client: TestClient, entity_id: int = 1) -> str:
resp = client.post("/api/cma/auth/login", json={
"username": "testadmin", "password": "admin123", "entity_id": entity_id,
})
assert resp.status_code == 200, f"登录 entity{entity_id} 失败: {resp.text[:300]}"
return resp.json().get("token")
@pytest.fixture
def seeded(db: Session):
"""测试用户 + entity1/entity2(授权走登录时的默认授予)"""
create_test_user(db)
_ensure_entity(db, 1)
_ensure_entity(db, 2)
db.commit()
return db
# ============================================================
# 1. B 类:无 token → 401(逐端点)
# ============================================================
class TestBClassRequiresAuth:
@pytest.mark.parametrize("method,path", B_CLASS_ENDPOINTS,
ids=[f"{m} {p}" for m, p in B_CLASS_ENDPOINTS])
def test_no_token_401(self, client: TestClient, method: str, path: str):
resp = client.request(method, path)
assert resp.status_code == 401, (
f"{method} {path} 无 token 期望 401,实际 {resp.status_code}: {resp.text[:200]}"
)
def test_seed_endpoints_401(self, client: TestClient):
"""验收种子项:4 个原「无 token 200」端点收口后 401"""
for path in SEED_NO_TOKEN_401:
resp = client.get(path)
assert resp.status_code == 401, f"{path} 期望 401,实际 {resp.status_code}"
def test_invalid_token_401(self, client: TestClient):
"""伪造/失效 token 亦 401(不因鉴权收口而放宽)"""
for path in SEED_NO_TOKEN_401:
resp = client.get(path, headers=auth_header("deadbeef-not-a-real-token"))
assert resp.status_code == 401, f"{path} 期望 401,实际 {resp.status_code}"
# ============================================================
# 2. B 类:持 token → 200(功能不回归)
# ============================================================
class TestBClassWithTokenWorks:
def test_seed_endpoints_200_with_token(self, client: TestClient, seeded):
token = _login(client, 1)
for path in SEED_NO_TOKEN_401:
resp = client.get(path, headers=auth_header(token))
assert resp.status_code == 200, (
f"{path} 持 token 期望 200,实际 {resp.status_code}: {resp.text[:200]}"
)
def test_previously_authed_endpoints_still_work(self, client: TestClient, seeded):
"""原已带鉴权的端点(alignment/tree、permissions/config PUT 之外)持 token 正常"""
token = _login(client, 1)
for path in ["/api/cma/alignment/config", "/api/cma/alignment/modes",
"/api/cma/permissions/modules", "/api/cma/permissions/config",
"/api/cma/notifications/channels", "/api/cma/notifications/logs",
"/api/cma/tenant/current", "/api/cma/cost/standard-costs",
"/api/cma/cost/abc/activities", "/api/cma/cost/abc/allocations"]:
resp = client.get(path, headers=auth_header(token))
assert resp.status_code == 200, (
f"{path} 持 token 期望 200,实际 {resp.status_code}: {resp.text[:200]}"
)
# ============================================================
# 3. 跨实体不回归:token entity=1 + entity_id=2 → 403(文案同 get_entity_id)
# ============================================================
class TestCrossEntityStillForbidden:
def test_cross_entity_403(self, client: TestClient, seeded):
token = _login(client, 1)
checks = [
("/api/cma/products/matrix", {"entity_id": 2}),
("/api/cma/cost/dashboard", {"entity_id": 2}),
("/api/cma/cost/overview", {"entity_id": 2}),
("/api/cma/predict/accuracy", {"entity_id": 2}),
]
for path, params in checks:
resp = client.get(path, params=params, headers=auth_header(token))
assert resp.status_code == 403, (
f"{path} 跨实体期望 403,实际 {resp.status_code}: {resp.text[:200]}"
)
assert "无权访问企业" in resp.text
def test_same_entity_200(self, client: TestClient, seeded):
token = _login(client, 1)
for path in ["/api/cma/products/matrix", "/api/cma/cost/dashboard"]:
resp = client.get(path, params={"entity_id": 1}, headers=auth_header(token))
assert resp.status_code == 200, f"{path} 同实体期望 200,实际 {resp.status_code}"
# ============================================================
# 4. A 类 Bot 通道行为不变
# ============================================================
class TestBotChannelUnchanged:
def test_bot_endpoints_with_key_200(self, client: TestClient, seeded):
for path in ["/api/cma/bot/kpis", "/api/cma/bot/overview",
"/api/cma/bot/cost/actual", "/api/cma/bot/cost/standard",
"/api/cma/bot/alerts", "/api/cma/bot/strategic-maps"]:
resp = client.get(path, headers={**BOT_KEY, "X-Entity-Id": "1"})
assert resp.status_code == 200, (
f"{path} 带 X-BOT-KEY 期望 200,实际 {resp.status_code}: {resp.text[:200]}"
)
def test_bot_endpoints_without_key_401(self, client: TestClient, seeded):
for path in ["/api/cma/bot/kpis", "/api/cma/bot/overview"]:
resp = client.get(path)
assert resp.status_code == 401, (
f"{path} 无 X-BOT-KEY 期望 401,实际 {resp.status_code}"
)
# ============================================================
# 5. C 类公开端点 & 既有 403 对照
# ============================================================
class TestPublicAndExistingBaseline:
def test_public_endpoints_unchanged(self, client: TestClient, seeded):
assert client.get("/health").status_code == 200
assert client.get("/api/cma/bot/ping").status_code == 200
assert client.get("/api/cma/security/captcha/request").status_code == 200
assert client.get("/api/cma/auth/login-entities").status_code == 200
# 登录端点公开但密码错仍 401
r = client.post("/api/cma/auth/login", json={
"username": "testadmin", "password": "wrong", "entity_id": 1})
assert r.status_code == 401
def test_existing_403_endpoints_unchanged(self, client: TestClient, seeded):
"""对照:原 403 端点(kpis/maps/org-tree)不回归"""
token = _login(client, 1)
for path in ["/api/cma/kpis", "/api/cma/maps", "/api/cma/org/tree"]:
assert client.get(path).status_code == 403, f"{path} 无 token 期望仍 403"
assert client.get(path, headers=auth_header(token)).status_code == 200
# ============================================================
# 6. 结构性不变量:全量路由巡检无 B 类缺口
# ============================================================
class TestAuthSurfaceInvariant:
def test_no_gap_in_route_table(self):
"""任何新用户端点漏挂 require_auth → 本用例 FAIL(防缺口回归)"""
from scripts.audit_api_auth_surface import collect_routes, build_app
rows = collect_routes(build_app())
gaps = [f'{r["methods"]} {r["path"]} ({r["module"]})'
for r in rows if r["class"] == "B"]
assert gaps == [], "发现无鉴权用户端点(B 类缺口):\n" + "\n".join(gaps)
def test_bot_and_auth_counts(self):
"""A 类 Bot 通道 / C 类公开 / 已鉴权 端点计数快照(防误删鉴权或误加公开)"""
from scripts.audit_api_auth_surface import collect_routes, build_app
rows = collect_routes(build_app())
by = {}
for r in rows:
by[r["class"]] = by.get(r["class"], 0) + 1
assert by.get("A", 0) >= 30, f"Bot 通道端点疑似减少: {by}"
assert by.get("C", 0) == 9, f"公开端点集被改动(应为 9): {by}"
assert by.get("AUTHED", 0) >= 384, f"已鉴权端点疑似减少: {by}"