Files
cma-management/backend/tests/test_api_auth_surface_20260914.py
T
fullstack-bot b815b7f623 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.
2026-09-14 16:41:15 +08:00

256 lines
12 KiB
Python
Raw 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.
"""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}"