feat: Bot API L1-L4风险分级标注 + 操作审计日志

- 新增 app/risk_levels.py: RISK_LEVELS定义 + @risk_level装饰器 + API_RISK_MAP
  (L1只读21 / L2业务写5 / L3批量写3 / L4=0安全底线)
- 新增 app/api/audit_log.py: Bot API审计中间件 → backend/logs/bot_audit.log
  (JSON行: timestamp/bot_name/endpoint/method/risk_level/entity_id/status,
   L3额外记rows行数, 不阻塞业务)
- bot_bridge/bot_bridge_v2/bot_kpis/bot_iron_law 全部29路由标注级别
- 新增 GET /api/cma/bot/risk-levels (X-BOT-KEY鉴权): API→级别→处理方式清单
- main.py 注册审计中间件
- tests/test_risk_levels.py: 覆盖路由标注/risk-levels端点/L4不存在/审计日志
This commit is contained in:
Hermes CI Fix
2026-08-28 00:31:31 +08:00
parent 5ceda333e2
commit 758f820970
8 changed files with 586 additions and 0 deletions
+226
View File
@@ -0,0 +1,226 @@
"""
Bot API 操作审计中间件
======================
每次 Bot API 调用(/api/cma/bot*)记录一行 JSON 审计日志到
backend/logs/bot_audit.log(可用环境变量 CMA_BOT_AUDIT_LOG 覆盖路径)。
JSON 行字段: timestamp / bot_name / endpoint / method / risk_level /
entity_id / status
L3 批量写额外记录 rows(导入行数 / 批量条数)。
设计约束:
- 不阻塞业务: 所有日志写入失败仅静默跳过,不影响请求结果
- 只读 bodystarlette 会缓存并回放给下游),仅在 application/json 时解析
- 不改动任何现有 API 行为与多租户隔离
"""
import json
import logging
import os
from datetime import datetime
from fastapi import Request
_LOG_DIR = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
"logs",
)
_DEFAULT_LOG_PATH = os.path.join(_LOG_DIR, "bot_audit.log")
_audit_logger = None
def _get_logger():
"""构造/复用审计 logger(路径取 CMA_BOT_AUDIT_LOG 覆盖值,便于测试隔离)"""
global _audit_logger
if _audit_logger is None:
log_path = os.getenv("CMA_BOT_AUDIT_LOG") or _DEFAULT_LOG_PATH
os.makedirs(os.path.dirname(log_path), exist_ok=True)
logger = logging.getLogger("cma.bot_audit")
logger.setLevel(logging.INFO)
logger.propagate = False
# 清掉旧 handler,避免日志名单例导致路径切换失效
for h in list(logger.handlers):
logger.removeHandler(h)
try:
h.close()
except Exception:
pass
handler = logging.FileHandler(log_path, encoding="utf-8")
handler.setFormatter(logging.Formatter("%(message)s"))
logger.addHandler(handler)
_audit_logger = logger
return _audit_logger
def write_audit_line(record: dict):
"""写一行 JSON 审计日志;任何异常都静默(不阻塞业务)"""
try:
_get_logger().info(json.dumps(record, ensure_ascii=False))
except Exception:
pass
def _resolve_bot_name(request: Request) -> str:
"""解析调用方标识: X-BOT-KEY → 配置名/原始keybridge → bot-bridge;其余 → web-user/unknown"""
key = request.headers.get("X-BOT-KEY")
if key:
try:
from app.api.bot_bridge import _BOT_API_KEYS
info = _BOT_API_KEYS.get(key)
if isinstance(info, dict) and info.get("name"):
return info["name"]
except Exception:
pass
return key
if request.headers.get("X-BRIDGE-TOKEN"):
return "bot-bridge"
if (request.headers.get("Authorization") or "").startswith("Bearer "):
return "web-user"
return "unknown"
def _resolve_risk_level(request: Request, route=None, endpoint=None):
"""级别解析顺序: 路由函数装饰器标注 → API_RISK_MAP(route.path) → API_RISK_MAP(请求URL)"""
if endpoint is not None:
level = getattr(endpoint, "risk_level", None)
if level:
return level
if route is not None:
from app.risk_levels import get_risk_level
path = getattr(route, "path", None)
if path:
for m in getattr(route, "methods", set()) or set():
if m in ("GET", "POST", "PUT", "DELETE", "PATCH"):
level = get_risk_level(m, path)
if level:
return level
try:
from app.risk_levels import get_risk_level
return get_risk_level(request.method, request.url.path)
except Exception:
return None
async def _extract_entity_id(request: Request):
"""多租户 entity_id 提取(尽力而为): 查询参数 → JSON body(仅 application/json"""
try:
q = request.query_params.get("entity_id")
if q is not None and str(q) != "":
return int(q) if str(q).isdigit() else q
except Exception:
pass
ctype = (request.headers.get("content-type") or "").lower()
if "application/json" in ctype:
try:
raw = await request.body()
if raw:
data = json.loads(raw)
eid = data.get("entity_id")
if eid is not None:
return int(eid) if str(eid).isdigit() else eid
except Exception:
pass
return None
def _extract_l3_rows(level, resp_body: bytes):
"""L3 批量写: 从响应体提取行数/条数(imported / kpi_updated / causality_links / total"""
if level != "L3" or not resp_body:
return None
try:
data = json.loads(resp_body)
except Exception:
return None
if not isinstance(data, dict):
return None
for key in ("imported", "kpi_updated"):
v = data.get(key)
if isinstance(v, (int, float)):
return int(v)
links = data.get("causality_links")
if isinstance(links, list):
return len(links)
total = data.get("total")
if isinstance(total, (int, float)):
return int(total)
return None
async def bot_audit_middleware(request: Request, call_next):
"""HTTP 中间件: 仅审计 /api/cma/bot* 路径;任何异常不影响业务"""
path = request.url.path
if not path.startswith("/api/cma/bot"):
return await call_next(request)
entity_id = None
try:
entity_id = await _extract_entity_id(request)
except Exception:
entity_id = None
try:
response = await call_next(request)
except Exception:
# 兜底记录(全局异常handler会返回500,此处保证审计不丢)
try:
write_audit_line({
"timestamp": datetime.now().isoformat(timespec="seconds"),
"bot_name": _resolve_bot_name(request),
"endpoint": path,
"method": request.method,
"risk_level": _resolve_risk_level(request) or "NA",
"entity_id": entity_id,
"status": 500,
})
except Exception:
pass
raise
# 级别解析(route 由路由器在 call_next 内写入 scope
level = None
try:
route = request.scope.get("route")
endpoint = getattr(route, "endpoint", None) if route else None
level = _resolve_risk_level(request, route=route, endpoint=endpoint)
except Exception:
level = None
# 捕获响应体(L3 需要行数),并重放
resp_body = b""
try:
body_iterator = getattr(response, "body_iterator", None)
if body_iterator is not None:
chunks = [chunk async for chunk in body_iterator]
resp_body = b"".join(chunks)
rows = _extract_l3_rows(level, resp_body)
except Exception:
rows = None
record = {
"timestamp": datetime.now().isoformat(timespec="seconds"),
"bot_name": _resolve_bot_name(request),
"endpoint": path,
"method": request.method,
"risk_level": level or "NA",
"entity_id": entity_id,
"status": response.status_code,
}
if rows is not None:
record["rows"] = rows
try:
write_audit_line(record)
except Exception:
pass
# 响应体已被消费 → 重建响应(Bot接口均为小JSON,非流式)
if resp_body:
from fastapi.responses import Response
return Response(
content=resp_body,
status_code=response.status_code,
headers=dict(response.headers),
media_type=response.media_type,
)
return response
+36
View File
@@ -18,6 +18,7 @@ from app.models import (
from app.models.budget_plan import BudgetPlan
from app.models.cost_model import StandardCost, ActualCost, AbcActivity, AbcAllocation
from app.models import KPICausality
from app.risk_levels import risk_level
import json
logger = logging.getLogger("cma.bot_bridge")
@@ -79,6 +80,7 @@ def _model_dict(obj, fields: dict):
# ═══════════════ 端点 ═══════════════
@router.get("/ping")
@risk_level("L1")
def ping():
return {"status": "ok", "version": "1.0", "timestamp": datetime.now().isoformat()}
@@ -86,6 +88,7 @@ def ping():
# ── 总览 ──
@router.get("/overview")
@risk_level("L1")
def bot_overview(
bot: dict = Depends(verify_bot_key),
db: Session = Depends(get_db),
@@ -110,6 +113,7 @@ def bot_overview(
# ── KPI ──
@router.get("/kpis")
@risk_level("L1")
def bot_kpis(
dimension: Optional[str] = Query(None),
status: str = Query("active"),
@@ -145,6 +149,7 @@ def bot_kpis(
@router.get("/kpis/{kpi_id}/history")
@risk_level("L1")
def bot_kpi_history(
kpi_id: int, limit: int = Query(12, le=60),
bot: dict = Depends(verify_bot_key),
@@ -171,6 +176,7 @@ def bot_kpi_history(
# ── 战略地图 ──
@router.get("/strategic-maps")
@risk_level("L1")
def bot_maps(
bot: dict = Depends(verify_bot_key),
db: Session = Depends(get_db),
@@ -198,6 +204,7 @@ def bot_maps(
# ── 预警 ──
@router.get("/alerts")
@risk_level("L1")
def bot_alerts(
status: str = Query("pending"),
level: Optional[str] = Query(None),
@@ -228,6 +235,7 @@ def bot_alerts(
# ── 预算 ──
@router.get("/budget/plans")
@risk_level("L1")
def bot_budget_plans(
year: Optional[int] = Query(None),
bot: dict = Depends(verify_bot_key),
@@ -255,6 +263,7 @@ def bot_budget_plans(
# ── 成本 ──
@router.get("/cost/standard")
@risk_level("L1")
def bot_standard_costs(
bot: dict = Depends(verify_bot_key),
db: Session = Depends(get_db),
@@ -278,6 +287,7 @@ def bot_standard_costs(
@router.get("/cost/actual")
@risk_level("L1")
def bot_actual_costs(
period: Optional[str] = Query(None),
bot: dict = Depends(verify_bot_key),
@@ -306,6 +316,7 @@ def bot_actual_costs(
# ── 行动方案 ──
@router.get("/actions")
@risk_level("L1")
def bot_actions(
status: Optional[str] = Query(None),
bot: dict = Depends(verify_bot_key),
@@ -333,6 +344,7 @@ def bot_actions(
# ── 组织 ──
@router.get("/organization")
@risk_level("L1")
def bot_org(
bot: dict = Depends(verify_bot_key),
db: Session = Depends(get_db),
@@ -354,6 +366,7 @@ def bot_org(
# ── 数据源 ──
@router.get("/data-sources")
@risk_level("L1")
def bot_data_sources(
bot: dict = Depends(verify_bot_key),
db: Session = Depends(get_db),
@@ -377,6 +390,7 @@ def bot_data_sources(
# ── 用户 ──
@router.get("/users")
@risk_level("L1")
def bot_users(
bot: dict = Depends(verify_bot_key),
db: Session = Depends(get_db),
@@ -395,6 +409,7 @@ def bot_users(
# ── 统一查询(BOT首选) ──
@router.get("/query")
@risk_level("L1")
def bot_query(
q: str = Query("overview", description="overview/kpis/alerts/maps/budget/cost/actions/all"),
bot: dict = Depends(verify_bot_key),
@@ -482,6 +497,7 @@ def bot_query(
@router.post("/import")
@risk_level("L3")
def bot_import_excel(
file: UploadFile = File(...),
bot: dict = Depends(verify_bot_key),
@@ -546,6 +562,7 @@ def bot_import_excel(
# ── 自然语言查询 ──
@router.post("/okr/create")
@risk_level("L2")
def bot_okr_create(
title: str = Query(...),
quarter: str = Query(...),
@@ -563,6 +580,7 @@ def bot_okr_create(
@router.get("/okr/list")
@risk_level("L1")
def bot_okr_list(
quarter: Optional[str] = Query(None),
bot: dict = Depends(verify_bot_key),
@@ -584,6 +602,7 @@ def bot_okr_list(
@router.get("/nlp")
@risk_level("L1")
def bot_nlp(
intent: str = Query("overview"),
bot: dict = Depends(verify_bot_key),
@@ -613,6 +632,7 @@ def bot_nlp(
# ════════════════════════════════════════════════════════════
@router.post("/kpi-value-with-check")
@risk_level("L2")
def bot_kpi_value_with_check(data: dict, db: Session = Depends(get_db), bot: dict = Depends(verify_bot_key)):
"""聚合A: 写KPI值 + 自动跑该KPI预警检查(Agent一次调用,免自拼check-all
body: {kpi_id, actual_value, period?, entity_id?, run_check?}"""
@@ -681,6 +701,7 @@ def bot_kpi_value_with_check(data: dict, db: Session = Depends(get_db), bot: dic
@router.post("/kpis/create-with-links")
@risk_level("L3")
def bot_kpi_create_with_links(data: dict, db: Session = Depends(get_db), bot: dict = Depends(verify_bot_key)):
"""聚合B: 创建KPI + 关联战略地图 + 批量因果链(Agent建KPI标准动作)
body: {kpi_code, kpi_name, dimension, entity_id?, target_value?, unit?, link_map_id?, link_causality?}"""
@@ -736,3 +757,18 @@ def bot_kpi_create_with_links(data: dict, db: Session = Depends(get_db), bot: di
db.commit()
return {"kpi_id": kpi.id, "kpi_code": kpi.kpi_code, "map_id": link_map_id,
"causality_links": links, "status": "ok"}
@router.get("/risk-levels")
@risk_level("L1")
def bot_risk_levels(bot: dict = Depends(verify_bot_key)):
"""Bot API风险分级清单(API→级别→处理方式)— 验收/巡检/授权决策用"""
from app.risk_levels import RISK_LEVELS, list_api_risk_map, risk_summary
return {
"bot": bot["name"],
"risk_levels": RISK_LEVELS,
"apis": list_api_risk_map(),
"summary": risk_summary(),
"note": "L4(危险)不向Bot API开放:Bot只能通过白名单API读写,"
"DROP/TRUNCATE/批量DELETE/生产结构修改物理不可能,由终端层+approval-gate拦截",
}
+5
View File
@@ -23,6 +23,7 @@ from app.models import (
KPIDefinition, KPIValue, KPIAlert,
ActionPlan, Entity,
)
from app.risk_levels import risk_level
logger = logging.getLogger("cma.bot_bridge_v2")
@@ -279,6 +280,7 @@ ALERT_THRESHOLDS = {
# ═══════════════════════════════════════════════
@router.post("/mpm-result")
@risk_level("L3")
def receive_mpm_result(
data: dict,
bridge_bot: str = Depends(verify_bridge_token),
@@ -404,6 +406,7 @@ def receive_mpm_result(
@router.post("/kpi-result")
@risk_level("L2")
def push_kpi_result(
data: dict,
bridge_bot: str = Depends(verify_bridge_token),
@@ -521,6 +524,7 @@ def push_kpi_result(
@router.post("/verify/{action_plan_id}")
@risk_level("L2")
def verify_action_plan(
action_plan_id: int,
bridge_bot: str = Depends(verify_bridge_token),
@@ -599,6 +603,7 @@ def verify_action_plan(
@router.get("/verify/{action_plan_id}/history")
@risk_level("L1")
def verify_history(
action_plan_id: int,
bridge_bot: str = Depends(verify_bridge_token),
+3
View File
@@ -9,6 +9,7 @@ from sqlalchemy.orm import Session
from sqlalchemy import func
from app.database import get_db
from app.models import ActionPlan
from app.risk_levels import risk_level
logger = logging.getLogger("cma.iron_law")
@@ -150,6 +151,7 @@ def _query_action_plan_verify(db: Session):
# ═══════════════ 端点 ═══════════════
@router.get("/iron-law")
@risk_level("L1")
def get_iron_law_kpis(
bot: dict = Depends(verify_bot_key),
db: Session = Depends(get_db),
@@ -224,6 +226,7 @@ def get_iron_law_kpis(
@router.get("/iron-law/bots")
@risk_level("L1")
def get_bot_iron_law_ranking(
bot: dict = Depends(verify_bot_key),
db: Session = Depends(get_db),
+3
View File
@@ -7,6 +7,7 @@ from datetime import datetime
from app.database import get_db
from app.auth_middleware import require_auth
from app.models import KPIDefinition, KPIValue
from app.risk_levels import risk_level
router = APIRouter(prefix="/api/cma/bot-kpis", tags=["Bot KPI管理"],
dependencies=[Depends(require_auth)],
@@ -46,6 +47,7 @@ def _calc_bot_kpi_score(current_value, target_value, is_reverse=False):
@router.get("")
@risk_level("L1")
def list_bot_kpis(
source: str = Query("finance-bot", description="Bot标识"),
period: Optional[str] = None,
@@ -132,6 +134,7 @@ def list_bot_kpis(
@router.post("/{kpi_id}/value")
@risk_level("L2")
def update_bot_kpi_value(
kpi_id: int,
data: dict,