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
+5 -2
View File
@@ -9,11 +9,14 @@ from datetime import datetime
import json
from app.database import get_db
from app.auth_middleware import require_auth, require_role
from app.auth_middleware import require_auth, require_role, require_auth_401
from app.models import KPIDefinition, OperationLog, RolePermission
router = APIRouter(prefix="/api/cma/alignment", tags=["KPI目标对齐"],
# 不设全局权限,每个接口单独控制
# 用户端点:router 级要求会话 Bearer 鉴权(cma-api-auth-surface-audit-20260914);
# 原注释「不设全局权限,每个接口单独控制」保留语义——写接口仍各自叠加 require_role,
# router 级只是「至少已认证」底线(补齐 /modes、GET /config 原无鉴权的缺口)。
dependencies=[Depends(require_auth_401)],
)
# 三种对齐模式定义
+8 -3
View File
@@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import User, UserEntity, Entity, OperationLog
from app.auth_middleware import create_token, require_auth, ROLES
from app.auth_middleware import create_token, require_auth, require_auth_401, ROLES
router = APIRouter(prefix="/api/cma/auth", tags=["认证"])
@@ -111,8 +111,13 @@ def get_me(request: Request, current_user: User = Depends(require_auth), db: Ses
@router.get("/roles")
def list_roles():
"""返回角色列表(给前端用)"""
def list_roles(_=Depends(require_auth_401)):
"""返回角色列表(给前端用)
2026-09-14 安全修复(cma-api-auth-surface-audit-20260914):原无鉴权公开返回角色
枚举(对外暴露系统角色模型 + 便于针对性撞库)。仓内检索确认无任何前端/脚本调用方
(前端用 /permissions/modules 的 roles 字段),故直接补 require_auth_401。
"""
return {
"data": [
{"code": k, "name": v["name"], "priority": v["priority"]}
+7 -1
View File
@@ -6,6 +6,7 @@ from fastapi import APIRouter, Depends, Query, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db
from app.deps import get_entity_id
from app.auth_middleware import require_auth_401
from app.models import StandardCost, ActualCost, AbcActivity, AbcAllocation
from app.models.cost_model import RESEARCH_PROJECT_TYPES
from app.utils.cost_engine import (
@@ -14,7 +15,12 @@ from app.utils.cost_engine import (
)
logger = logging.getLogger("cma.cost")
router = APIRouter(prefix="/api/cma/cost", tags=["成本分析"])
# 用户端点:统一要求会话 Bearer 鉴权(cma-api-auth-surface-audit-20260914)
# 本 router 原无任何鉴权依赖(端点仅 assert_entity_scope,无 token 走"Bot 服务通道"分支放行)
# → 无 token 可读成本数据、可写标准成本。收口为 require_auth_401(无 token → 401)。
# Bot 通道走 /api/cma/bot/cost/*(X-BOT-KEY)不受影响。
router = APIRouter(prefix="/api/cma/cost", tags=["成本分析"],
dependencies=[Depends(require_auth_401)])
def _resolve_research_dims(data: dict, current=None) -> tuple:
+6 -2
View File
@@ -3,10 +3,14 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import NotificationChannel, NotificationLog
from app.auth_middleware import require_role
from app.auth_middleware import require_role, require_auth_401
from datetime import datetime
router = APIRouter(prefix="/api/cma/notifications", tags=["通知配置"])
# 用户端点:统一要求会话 Bearer 鉴权(cma-api-auth-surface-audit-20260914)
# 缺口:本 router 6 个端点全部无鉴权——无 token 可读写通知渠道(config 内含 webhook
# 地址/令牌等凭据)、可触发渠道测试推送、可读推送日志。收口为 require_auth_401。
router = APIRouter(prefix="/api/cma/notifications", tags=["通知配置"],
dependencies=[Depends(require_auth_401)])
def ch_to_dict(c):
+6 -2
View File
@@ -7,9 +7,13 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import RolePermission
from app.auth_middleware import require_auth, require_role
from app.auth_middleware import require_auth, require_role, require_auth_401
router = APIRouter(prefix="/api/cma/permissions", tags=["权限管理"])
# 用户端点:统一要求会话 Bearer 鉴权(cma-api-auth-surface-audit-20260914)
# 缺口:/modules 与 GET /config 无鉴权——无 token 可读全量角色权限矩阵(横向越权情报)。
# 写端点 PUT /config 原有 require_role(ceo,it),保留(router 级为「至少已认证」底线)。
router = APIRouter(prefix="/api/cma/permissions", tags=["权限管理"],
dependencies=[Depends(require_auth_401)])
# 模块定义(所有可配置的模块)
MODULES = [
+7 -1
View File
@@ -11,10 +11,16 @@ from app.utils.cash_forecast_engine import (
)
from app.database import get_db
from app.deps import get_entity_id, resolve_entity_for_request
from app.auth_middleware import require_auth_401
from sqlalchemy.orm import Session
logger = logging.getLogger("cma.predict")
router = APIRouter(prefix="/api/cma/predict", tags=["预测模拟"])
# 用户端点:统一要求会话 Bearer 鉴权(cma-api-auth-surface-audit-20260914)
# 本 router 原无任何鉴权依赖:无 token 可调用全部预测/模拟端点(含多个 CPU 密集的
# POST 计算端点与 cash-forecast 落库),既泄露经营数据也构成计算资源滥用面。
# 收口为 require_auth_401(无 token → 401)。Bot 通道(X-BOT-KEY)不走本 router。
router = APIRouter(prefix="/api/cma/predict", tags=["预测模拟"],
dependencies=[Depends(require_auth_401)])
@router.post("/cvp")
+5 -1
View File
@@ -4,10 +4,14 @@ from typing import Optional
from sqlalchemy.orm import Session
from app.database import get_db
from app.deps import assert_entity_scope
from app.auth_middleware import require_auth_401
from app.models import ProductSales
from collections import defaultdict
router = APIRouter(prefix="/api/cma/products", tags=["产品矩阵"])
# 用户端点:统一要求会话 Bearer 鉴权(cma-api-auth-surface-audit-20260914)
# 无 token → 401(原「无 token 亦可取数」缺口收口);Bot 通道走 /api/cma/bot/*(X-BOT-KEY)不受影响。
router = APIRouter(prefix="/api/cma/products", tags=["产品矩阵"],
dependencies=[Depends(require_auth_401)])
def _calc_quadrant(trend: float, margin: float) -> str:
+6 -1
View File
@@ -3,9 +3,14 @@ from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.database import get_db
from app.auth_middleware import require_auth_401
from app.models import SystemConfig, Entity
router = APIRouter(prefix="/api/cma/tenant", tags=["多租户"])
# 用户端点:统一要求会话 Bearer 鉴权(cma-api-auth-surface-audit-20260914)
# 缺口:本 router 原无鉴权——无 token 可读当前租户、并可 POST /switch 改写
# system_config.current_tenant(状态改写面)。收口为 require_auth_401。
router = APIRouter(prefix="/api/cma/tenant", tags=["多租户"],
dependencies=[Depends(require_auth_401)])
TENANT_KEY = "current_tenant"