- 新增 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不存在/审计日志
227 lines
7.5 KiB
Python
227 lines
7.5 KiB
Python
"""
|
||
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(导入行数 / 批量条数)。
|
||
|
||
设计约束:
|
||
- 不阻塞业务: 所有日志写入失败仅静默跳过,不影响请求结果
|
||
- 只读 body(starlette 会缓存并回放给下游),仅在 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 → 配置名/原始key;bridge → 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
|