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 import json
from app.database import get_db 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 from app.models import KPIDefinition, OperationLog, RolePermission
router = APIRouter(prefix="/api/cma/alignment", tags=["KPI目标对齐"], 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 sqlalchemy.orm import Session
from app.database import get_db from app.database import get_db
from app.models import User, UserEntity, Entity, OperationLog 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=["认证"]) 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") @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 { return {
"data": [ "data": [
{"code": k, "name": v["name"], "priority": v["priority"]} {"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 sqlalchemy.orm import Session
from app.database import get_db from app.database import get_db
from app.deps import get_entity_id 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 import StandardCost, ActualCost, AbcActivity, AbcAllocation
from app.models.cost_model import RESEARCH_PROJECT_TYPES from app.models.cost_model import RESEARCH_PROJECT_TYPES
from app.utils.cost_engine import ( from app.utils.cost_engine import (
@@ -14,7 +15,12 @@ from app.utils.cost_engine import (
) )
logger = logging.getLogger("cma.cost") 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: 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 sqlalchemy.orm import Session
from app.database import get_db from app.database import get_db
from app.models import NotificationChannel, NotificationLog 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 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): 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 sqlalchemy.orm import Session
from app.database import get_db from app.database import get_db
from app.models import RolePermission 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 = [ MODULES = [
+7 -1
View File
@@ -11,10 +11,16 @@ from app.utils.cash_forecast_engine import (
) )
from app.database import get_db from app.database import get_db
from app.deps import get_entity_id, resolve_entity_for_request from app.deps import get_entity_id, resolve_entity_for_request
from app.auth_middleware import require_auth_401
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
logger = logging.getLogger("cma.predict") 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") @router.post("/cvp")
+5 -1
View File
@@ -4,10 +4,14 @@ from typing import Optional
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.database import get_db from app.database import get_db
from app.deps import assert_entity_scope from app.deps import assert_entity_scope
from app.auth_middleware import require_auth_401
from app.models import ProductSales from app.models import ProductSales
from collections import defaultdict 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: 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 pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.database import get_db from app.database import get_db
from app.auth_middleware import require_auth_401
from app.models import SystemConfig, Entity 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" TENANT_KEY = "current_tenant"
+18
View File
@@ -9,6 +9,7 @@ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.database import get_db from app.database import get_db
from app.models import User, RolePermission from app.models import User, RolePermission
from typing import Optional
import secrets import secrets
import json import json
@@ -199,6 +200,23 @@ def require_role(*roles: str):
return role_checker return role_checker
# ── 401 语义的认证依赖(cma-api-auth-surface-audit-20260914)──────────────
# FastAPI 的 HTTPBearer(auto_error=True) 对"缺 Authorization 头"返回 403;
# 安全语义上「未认证」应为 401(401=未认证 / 403=无权)。
# 本依赖用 auto_error=False 自行判空 → 缺 token 显式 401,有 token 仍完全委托 require_auth。
_optional_bearer = HTTPBearer(auto_error=False)
def require_auth_401(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(_optional_bearer),
db: Session = Depends(get_db),
) -> User:
"""用户端点统一鉴权入口:缺 Bearer → 401;有 token → require_auth 校验(无效 → 401)"""
if credentials is None or not credentials.credentials:
raise HTTPException(status_code=401, detail="未认证:缺少 Bearer token")
return require_auth(credentials=credentials, db=db)
def has_permission(user: User, module: str, db: Session = None) -> bool: def has_permission(user: User, module: str, db: Session = None) -> bool:
routes, _ = _load_permissions(db) routes, _ = _load_permissions(db)
return module in routes.get(user.role, []) return module in routes.get(user.role, [])
+9
View File
@@ -116,6 +116,15 @@ def assert_entity_scope(
- 会话 token 绑定实体存在(登录用户):explicit 与之不符 → 403(含 0=全部) - 会话 token 绑定实体存在(登录用户):explicit 与之不符 → 403(含 0=全部)
- 无 token(Bot 服务通道):explicit 需存在且 active,否则 403(防越权注入) - 无 token(Bot 服务通道):explicit 需存在且 active,否则 403(防越权注入)
返回会话账套 entity_id(无 token → None,由调用方按原口径兜底)。 返回会话账套 entity_id(无 token → None,由调用方按原口径兜底)。
⚠️ 契约(cma-api-auth-surface-audit-20260914):本函数**不是鉴权**。
「无 token → 按 Bot 服务通道放行」这一分支**仅对真正以 X-BOT-KEY 鉴权的 Bot 端点有效**
(bot_bridge / bot_kpis / bot_iron_law / bot_suggestions / cash_vouchers_json 等,
见 app/api/bot_bridge.py 的 verify_bot_key)。
用户端点(前端 Bearer 调用)**必须**在 router 级或端点级挂 `require_auth` /
`require_auth_401`;否则未挂鉴权的用户端点 = 公开可访问(无 token 直接取数/写数)。
自查:`grep -n 'require_auth' app/api/<文件>.py` 无命中 且 该文件路由非 /api/cma/bot* →
即存在鉴权缺口。
""" """
session_entity = get_session_entity(request) session_entity = get_session_entity(request)
if session_entity is not None: if session_entity is not None:
+10 -15
View File
@@ -10,7 +10,7 @@ from app.database import init_db, get_db
from app.api import auth, kpis, kpi_governance, templates, maps, dashboard, data, alerts, ai_analysis, ai_suggestions, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict, reports, security, knowledge, bot_bridge, bot_bridge_v2, bot_suggestions, lead, tenant, customer_dashboard, deviation_push, budget_generate, knowledge_articles, kpi_causality, data_quality, bi_reports, entities, bsc_layers, okr, okr_templates, subjects, driver_budget, bot_kpis, ontology, bot_iron_law, analysis_results, expenses, cash, tax_compliance, verify, growth_quality, products, data_classification, value_sources, zero_based, derivation_rules, cash_classify, cashflow_structure, cash_vouchers_json from app.api import auth, kpis, kpi_governance, templates, maps, dashboard, data, alerts, ai_analysis, ai_suggestions, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict, reports, security, knowledge, bot_bridge, bot_bridge_v2, bot_suggestions, lead, tenant, customer_dashboard, deviation_push, budget_generate, knowledge_articles, kpi_causality, data_quality, bi_reports, entities, bsc_layers, okr, okr_templates, subjects, driver_budget, bot_kpis, ontology, bot_iron_law, analysis_results, expenses, cash, tax_compliance, verify, growth_quality, products, data_classification, value_sources, zero_based, derivation_rules, cash_classify, cashflow_structure, cash_vouchers_json
from app.utils.cache import clear_all as clear_cache, delete as delete_cache from app.utils.cache import clear_all as clear_cache, delete as delete_cache
from scripts.erp_sync import run_sync as run_erp_sync from scripts.erp_sync import run_sync as run_erp_sync
from app.auth_middleware import require_auth from app.auth_middleware import require_auth, require_auth_401
from app.api.audit_log import bot_audit_middleware from app.api.audit_log import bot_audit_middleware
load_dotenv() load_dotenv()
@@ -118,18 +118,8 @@ _ERP_CHANNEL_DISABLED_REASON = ("ERP直连同步通道已于2026-08-31停用(e
# ── admin 端点鉴权(2026-09-13)────────────────────────────── # ── admin 端点鉴权(2026-09-13)──────────────────────────────
# 未带 token → 显式 401(缺少认证),校验逻辑仍完全委托 require_auth(无效 token → 401)。 # 未带 token → 显式 401(缺少认证),校验逻辑仍完全委托 require_auth(无效 token → 401)。
# 说明:FastAPI 的 HTTPBearer(auto_error=True) 对"缺 header"返回 403;本端点验收要求 401, # 依赖实现已上移到 app/auth_middleware.py 的 require_auth_401(用户端点统一入口,
# 故用 auto_error=False 的实例自行判空,不改动全局 require_auth(避免其他端点行为漂移)。 # cma-api-auth-surface-audit-20260914):原局部定义等价迁移,其他端点行为不变。
_optional_bearer = HTTPBearer(auto_error=False)
def require_auth_401(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(_optional_bearer),
db=Depends(get_db),
):
if credentials is None or not credentials.credentials:
raise HTTPException(status_code=401, detail="未认证:缺少 Bearer token")
return require_auth(credentials=credentials, db=db)
@app.post("/api/cma/admin/erp-sync") @app.post("/api/cma/admin/erp-sync")
@@ -184,8 +174,13 @@ def admin_check_alerts(entity_id: Optional[int] = None, user=Depends(require_aut
@app.post("/api/cma/admin/cache/clear") @app.post("/api/cma/admin/cache/clear")
def admin_clear_cache(module: str = None): def admin_clear_cache(module: str = None, user=Depends(require_auth_401)):
"""清空缓存,指定module则只清该模块""" """清空缓存,指定module则只清该模块
2026-09-14 安全修复(cma-api-auth-surface-audit-20260914):本端点原为无鉴权公开
可写端点(无 token 亦可清空全站缓存 → 可被用于性能打击);补 require_auth_401,
未带 token → 401。
"""
if module: if module:
delete_cache(module) delete_cache(module)
return {"message": f"缓存已清空: {module}"} return {"message": f"缓存已清空: {module}"}
+157
View File
@@ -0,0 +1,157 @@
#!/usr/bin/env python3
"""CMA API 鉴权面审计器(cma-api-auth-surface-audit-20260914)
用途
----
枚举 FastAPI app 的**全部** APIRoute,逐个判定其鉴权姿态并分类:
A 类 — 合法 Bot 通道:以 X-BOT-KEY / bridge token 鉴权(verify_bot_key / verify_bridge_token)
B 类 — 用户端点缺鉴权(缺口):既无 X-BOT-KEY 也无 require_auth 依赖
C 类 — 显式公开:在 PUBLIC_PATHS 白名单内(登录/注册/验证码/官网留资/健康探针等)
判定依据是**依赖图**(route.dependant 递归),不是源码文本 grep——router 级
dependencies=[Depends(require_auth_401)] 与端点级 Depends(require_auth) 都能识别。
用法(在 backend 目录下)
-------------------------
./venv/bin/python3 scripts/audit_api_auth_surface.py # 人类可读表格
./venv/bin/python3 scripts/audit_api_auth_surface.py --json # 机器可读
退出码:0 = 无 B 类缺口;1 = 存在 B 类缺口(可直接当 CI 闸门用)。
"""
import os
import sys
import json
import argparse
# 允许直接以脚本方式运行(把 backend 根加入 sys.path)
_HERE = os.path.dirname(os.path.abspath(__file__))
_BACKEND = os.path.dirname(_HERE)
if _BACKEND not in sys.path:
sys.path.insert(0, _BACKEND)
# 测试/离线环境变量兜底(不连接真实 MySQL:仅枚举路由,不触发 get_db)
os.environ.setdefault("CMA_DB_USER", "audit")
os.environ.setdefault("CMA_DB_PASS", "audit")
os.environ.setdefault("CMA_DB_HOST", "localhost")
os.environ.setdefault("CMA_DB_PORT", "3306")
os.environ.setdefault("CMA_DB_NAME", "audit")
AUTH_STRICT = {"require_auth", "require_auth_401", "role_checker"}
BOT_AUTH = {"verify_bot_key", "verify_bridge_token"}
# C 类:显式登记的公开端点((METHOD, path))
PUBLIC_PATHS = {
("GET", "/health"),
("POST", "/api/cma/auth/login"),
("POST", "/api/cma/auth/register"),
("GET", "/api/cma/auth/login-entities"),
("GET", "/api/cma/bot/ping"),
("GET", "/api/cma/security/captcha/request"),
("GET", "/api/cma/security/captcha/request2"),
("POST", "/api/cma/security/captcha/verify"),
("POST", "/api/cma/lead"),
}
def dep_names(route) -> set:
"""递归收集 route 依赖图中所有可调用对象的名字"""
acc = set()
def walk(dep, depth=0):
if depth > 12:
return
fn = getattr(dep.call, "__name__", None)
acc.add(fn if fn else dep.call.__class__.__name__)
for sub in dep.dependencies:
walk(sub, depth + 1)
for dep in route.dependant.dependencies:
walk(dep)
return acc
def collect_routes(app) -> list:
"""返回全部 APIRoute 的鉴权姿态行"""
from fastapi.routing import APIRoute
rows = []
for r in app.routes:
if not isinstance(r, APIRoute):
continue
methods = sorted(m for m in r.methods if m not in ("HEAD", "OPTIONS"))
deps = dep_names(r)
auth = sorted(deps & AUTH_STRICT)
bot = sorted(deps & BOT_AUTH)
public = bool(methods) and all((m, r.path) in PUBLIC_PATHS for m in methods)
if auth:
cls = "AUTHED"
elif bot:
cls = "A" # Bot 通道
elif public:
cls = "C" # 显式公开
else:
cls = "B" # 缺口
rows.append({
"path": r.path,
"methods": ",".join(methods),
"handler": r.endpoint.__name__,
"module": r.endpoint.__module__,
"auth": auth,
"bot_auth": bot,
"class": cls,
})
rows.sort(key=lambda x: (x["class"], x["path"], x["methods"]))
return rows
def build_app():
"""构建 app 实例(不连接真实 DB:仅导入模块)"""
# 先让 database 模块指向内存 SQLite,避免任何意外真实连接
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app import database as db_module
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False},
poolclass=StaticPool)
db_module.engine = engine
db_module.SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
from app.main import app
return app
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--json", action="store_true", help="输出 JSON")
args = ap.parse_args()
rows = collect_routes(build_app())
b = [r for r in rows if r["class"] == "B"]
a = [r for r in rows if r["class"] == "A"]
c = [r for r in rows if r["class"] == "C"]
authed = [r for r in rows if r["class"] == "AUTHED"]
if args.json:
print(json.dumps({"total": len(rows), "authed": len(authed), "A": len(a),
"B": len(b), "C": len(c), "rows": rows}, ensure_ascii=False, indent=1))
else:
print(f"CMA API 鉴权面审计 — 端点总数 {len(rows)}")
print(f" AUTHED(require_auth/role) = {len(authed)}")
print(f" A 类(合法 Bot 通道 X-BOT-KEY) = {len(a)}")
print(f" C 类(显式公开) = {len(c)}")
print(f" B 类(缺鉴权缺口) = {len(b)}")
if b:
print("\n=== B 类缺口(需补鉴权)===")
for r in b:
print(f' {r["methods"]:16s} {r["path"]:52s} {r["module"]}')
if c:
print("\n=== C 类(显式公开)===")
for r in c:
print(f' {r["methods"]:16s} {r["path"]:52s} {r["module"]}')
return 1 if b else 0
if __name__ == "__main__":
sys.exit(main())
@@ -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}"
+43 -11
View File
@@ -2,18 +2,53 @@
覆盖:线性回归预测 / 移动平均预测 / 置信区间 / 趋势方向判定 / 覆盖:线性回归预测 / 移动平均预测 / 置信区间 / 趋势方向判定 /
边界情况(历史<2条、无历史、未来期数=0)/ entity_id 多租户隔离。 边界情况(历史<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 import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from sqlalchemy.orm import Session 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 app.utils import kpi_forecast_engine as engine
from tests.conftest import create_test_kpi, create_test_user, get_token_for_user, auth_header from tests.conftest import create_test_kpi, create_test_user, get_token_for_user, auth_header
BASE = "/api/cma/predict" 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"): 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: class TestEntityIsolation:
def test_entity2_kpi_invisible_to_entity1(self, client: TestClient, db: Session): def test_entity2_kpi_invisible_to_entity1(self, client: TestClient, db: Session):
"""entity2 的KPI在 entity1 下查询 → 404(不串数据)""" """entity2 的KPI在 entity1 会话下查询 → 404(不串数据)"""
_seed_entity2(db) _seed_entity2(db)
kpi2 = create_test_kpi(db, kpi_code="F_ISO_E2", kpi_name="博海专属KPI", kpi2 = create_test_kpi(db, kpi_code="F_ISO_E2", kpi_name="博海专属KPI",
dimension="finance", entity_id=2) dimension="finance", entity_id=2)
_seed_history(db, kpi2, [100, 110, 120, 130], entity_id=2) _seed_history(db, kpi2, [100, 110, 120, 130], entity_id=2)
# 无token时 entity_id 走 query 参数 → entity1 # 会话账套1:查不到 entity2 的 KPI → 404(鉴权收口后账套由 token 决定)
resp = client.get(f"{BASE}/kpi-forecast", params={ resp = client.get(f"{BASE}/kpi-forecast", params={"kpi_code": "F_ISO_E2"})
"entity_id": 1, "kpi_code": "F_ISO_E2",
})
assert resp.status_code == 404 assert resp.status_code == 404
# entity2 自己能查到 # 会话账套2 自己:能查到
resp = client.get(f"{BASE}/kpi-forecast", params={ token2 = _token_for_entity(client, db, 2)
"entity_id": 2, "kpi_code": "F_ISO_E2", resp = client.get(f"{BASE}/kpi-forecast", params={"kpi_code": "F_ISO_E2"},
}) headers=auth_header(token2))
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.json()["entity_id"] == 2 assert resp.json()["entity_id"] == 2
@@ -298,7 +331,6 @@ class TestEntityIsolation:
def test_token_binds_entity(self, client: TestClient, db: Session): def test_token_binds_entity(self, client: TestClient, db: Session):
"""token绑定entity1时显式传entity_id=2 → 403 越权拦截""" """token绑定entity1时显式传entity_id=2 → 403 越权拦截"""
create_test_user(db)
token = get_token_for_user(client) token = get_token_for_user(client)
kpi = create_test_kpi(db, kpi_code="F_ISO_TK", kpi_name="TokenKPI") kpi = create_test_kpi(db, kpi_code="F_ISO_TK", kpi_name="TokenKPI")
_seed_history(db, kpi, [1, 2, 3, 4]) _seed_history(db, kpi, [1, 2, 3, 4])
+42 -6
View File
@@ -4,17 +4,52 @@
cvp / investment / sensitivity / scenario / cvp-detailed / cash-forecast / cvp / investment / sensitivity / scenario / cvp-detailed / cash-forecast /
cash-forecast/history / accuracy / scenario-suggestions / cash-forecast/history / accuracy / scenario-suggestions /
scenario-suggestion/generate / real-option / growth-quality scenario-suggestion/generate / real-option / growth-quality
鉴权(cma-api-auth-surface-audit-20260914):/api/cma/predict/* 已收口为「用户端点必须
带会话 Bearer」。本文件聚焦业务逻辑,`_predict_auth` autouse fixture 统一注入账套1的
测试 token 作为 client 默认头;需要其他账套的用例自行用 `_token_for_entity` 传 headers。
""" """
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models import CashForecast, ForecastAccuracy from app.models import CashForecast, ForecastAccuracy, Entity, User, UserEntity
from tests.conftest import create_test_user, get_token_for_user, auth_header from tests.conftest import create_test_user, get_token_for_user, auth_header
BASE = "/api/cma/predict" 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 _predict_auth(client: TestClient, db: Session):
"""统一注入账套1的测试 token(显式传 headers= 的用例仍可覆盖,httpx 请求级优先)"""
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
class TestCvp: class TestCvp:
def test_cvp_basic(self, client: TestClient, db: Session): def test_cvp_basic(self, client: TestClient, db: Session):
"""本量利分析""" """本量利分析"""
@@ -117,7 +152,6 @@ class TestCashForecast:
def test_cash_forecast_history_empty(self, client: TestClient, db: Session): def test_cash_forecast_history_empty(self, client: TestClient, db: Session):
"""历史为空""" """历史为空"""
create_test_user(db)
token = get_token_for_user(client) token = get_token_for_user(client)
resp = client.get(f"{BASE}/cash-forecast/history", headers=auth_header(token)) resp = client.get(f"{BASE}/cash-forecast/history", headers=auth_header(token))
assert resp.status_code == 200 assert resp.status_code == 200
@@ -125,7 +159,6 @@ class TestCashForecast:
def test_accuracy_empty(self, client: TestClient, db: Session): def test_accuracy_empty(self, client: TestClient, db: Session):
"""准确率为空""" """准确率为空"""
create_test_user(db)
token = get_token_for_user(client) token = get_token_for_user(client)
resp = client.get(f"{BASE}/accuracy", headers=auth_header(token)) resp = client.get(f"{BASE}/accuracy", headers=auth_header(token))
assert resp.status_code == 200 assert resp.status_code == 200
@@ -219,20 +252,23 @@ class TestGrowthQuality:
assert "overall" in data or "level" in data or "score" in data assert "overall" in data or "level" in data or "score" in data
def test_growth_quality_entity2(self, client: TestClient, db: Session): def test_growth_quality_entity2(self, client: TestClient, db: Session):
"""博海增长质量""" """博海增长质量(需绑定 entity2 的会话 token,鉴权收口后不再靠 body entity_id)"""
resp = client.post(f"{BASE}/growth-quality", json={"entity_id": 2}) token2 = _token_for_entity(client, db, 2)
resp = client.post(f"{BASE}/growth-quality", json={"entity_id": 2},
headers=auth_header(token2))
assert resp.status_code == 200 assert resp.status_code == 200
data = resp.json() data = resp.json()
assert data["entity_name"] == "陕西博海网络科技" assert data["entity_name"] == "陕西博海网络科技"
def test_growth_quality_custom(self, client: TestClient, db: Session): def test_growth_quality_custom(self, client: TestClient, db: Session):
"""自定义企业数据(低分场景)""" """自定义企业数据(低分场景)"""
token99 = _token_for_entity(client, db, 99)
resp = client.post(f"{BASE}/growth-quality", json={ resp = client.post(f"{BASE}/growth-quality", json={
"entity_id": 99, "entity_id": 99,
"name": "测试企业", "name": "测试企业",
"rebateRate": 90, "trueGrossMargin": 5, "cashRatio": 3, "rebateRate": 90, "trueGrossMargin": 5, "cashRatio": 3,
"expenseGrowthRate": 3, "revenueGrowthRate": 1, "mgmtRatio": 500, "expenseGrowthRate": 3, "revenueGrowthRate": 1, "mgmtRatio": 500,
}) }, headers=auth_header(token99))
assert resp.status_code == 200 assert resp.status_code == 200
data = resp.json() data = resp.json()
assert data["entity_name"] == "测试企业" assert data["entity_name"] == "测试企业"
+4 -1
View File
@@ -482,7 +482,10 @@ async function loadData() {
finSummary.value = (r as any)?.summary || {} finSummary.value = (r as any)?.summary || {}
dataRange.value = (r as any)?.period || '' dataRange.value = (r as any)?.period || ''
} else if (userRole.value === 'it') { } else if (userRole.value === 'it') {
const channelRes = await axios.get('/api/cma/notifications/channels').then(r => r.data) // 鉴权收口(cma-api-auth-surface-audit-20260914):/notifications/channels 已要求 Bearer
const channelRes = await axios.get('/api/cma/notifications/channels', {
headers: { 'Authorization': `Bearer ${localStorage.getItem('cma_token')}` },
}).then(r => r.data)
enabledChannels.value = channelRes.data?.filter((c: any) => c.enabled).length || 0 enabledChannels.value = channelRes.data?.filter((c: any) => c.enabled).length || 0
} }
} catch (e) { console.error('加载数据失败:', e) } } catch (e) { console.error('加载数据失败:', e) }
+6 -2
View File
@@ -158,7 +158,7 @@ async function loadMapList() {
try { try {
const token = localStorage.getItem('cma_token') const token = localStorage.getItem('cma_token')
const r = await fetch('/api/cma/maps', { const r = await fetch('/api/cma/maps', {
headers: { 'Authorization': `'Bearer ' + token` }, headers: { 'Authorization': `Bearer ${token}` },
}).then(r => r.json()) }).then(r => r.json())
mapList.value = r.data || [] mapList.value = r.data || []
} catch {} } catch {}
@@ -271,7 +271,11 @@ const devClass = (d: any) => {
async function loadConfig() { async function loadConfig() {
try { try {
const r = await fetch('/api/cma/alignment/config').then(r => r.json()) // 鉴权收口(cma-api-auth-surface-audit-20260914):/alignment/* 已要求 Bearer,补 token
const token = localStorage.getItem('cma_token')
const r = await fetch('/api/cma/alignment/config', {
headers: { 'Authorization': `Bearer ${token}` },
}).then(r => r.json())
alignModes.value = r.modes || [] alignModes.value = r.modes || []
alignConfigured.value = r.configured || false alignConfigured.value = r.configured || false
if (r.configured && r.mode) { if (r.configured && r.mode) {
+10 -3
View File
@@ -231,9 +231,12 @@ async function resetPermConfig() {
async function loadPermData() { async function loadPermData() {
try { try {
// 鉴权收口(cma-api-auth-surface-audit-20260914):/permissions/* 已要求 Bearer,补 token
const token = localStorage.getItem('cma_token')
const authHeaders = { 'Authorization': `Bearer ${token}` }
const [modRes, cfgRes] = await Promise.all([ const [modRes, cfgRes] = await Promise.all([
fetch('/api/cma/permissions/modules').then(r => r.json()), fetch('/api/cma/permissions/modules', { headers: authHeaders }).then(r => r.json()),
fetch('/api/cma/permissions/config').then(r => r.json()), fetch('/api/cma/permissions/config', { headers: authHeaders }).then(r => r.json()),
]) ])
modules.value = modRes.modules || [] modules.value = modRes.modules || []
actions.value = modRes.actions || [] actions.value = modRes.actions || []
@@ -272,7 +275,11 @@ function feedsLabel(feeds: string[] | null) {
async function loadAlignmentConfig() { async function loadAlignmentConfig() {
try { try {
const r = await fetch('/api/cma/alignment/config').then(r => r.json()) // 鉴权收口(cma-api-auth-surface-audit-20260914):/alignment/* 已要求 Bearer,补 token
const token = localStorage.getItem('cma_token')
const r = await fetch('/api/cma/alignment/config', {
headers: { 'Authorization': `Bearer ${token}` },
}).then(r => r.json())
alignmentModes.value = r.modes || [] alignmentModes.value = r.modes || []
alignConfigured.value = r.configured || false alignConfigured.value = r.configured || false
currentMode.value = null currentMode.value = null