""" 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