Files
opsxsql/backend/app/core/audit_middleware.py
T
logan 344c519209 feat: P0生产加固 — 认证安全+操作审计+错误提示
P0-1 认证安全:
- JWT secret 改为环境变量配置, 未设置则随机生成(重启失效)
- 新增 LoginRateLimitMiddleware: 5次/分钟登录频率限制(滑动窗口)
- 429 响应 + 前端 toast 提示

P0-2 操作审计日志:
- 新增 OperationLog 模型 (user/username/action/resource/detail/ip)
- 新增 AuditLogMiddleware 自动记录 POST/PUT/DELETE/PATCH 操作
- 新增 GET /audit-logs API (分页/过滤/统计)
- 前端 AuditLogsPage 对接真实 API (字段/路径修正)

P0-3 实例密码加密:
- 已确认 crypto.py AES-256-GCM 加密存储, 无需修改

P1 前端错误提示:
- 新增 useToast hook + ToastContainer 组件
- request.ts 拦截器: 4xx/5xx 错误 toast 显示后端 detail 信息
- 422 验证错误提取第一条 msg
- 网络失败显示通用提示

1182 tests passed
2026-05-25 12:57:59 +08:00

130 lines
3.8 KiB
Python

"""操作审计中间件 — 自动记录所有 POST/PUT/DELETE/PATCH 请求。"""
import time
import json
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
from app.core.database import async_session
from app.models.operation_log import OperationLog
# 不记录审计的路径(高频/非业务)
_EXCLUDE_PREFIXES = (
"/api/v1/audit-logs",
"/api/v1/dashboard",
"/docs",
"/openapi",
"/redoc",
"/api/v1/query/execute", # 查询执行单独记录
)
# 资源类型映射: URL前缀 -> 资源类型
_RESOURCE_MAP = {
"/api/v1/tickets": "ticket",
"/api/v1/instances": "instance",
"/api/v1/users": "user",
"/api/v1/audit-rules": "audit_rule",
"/api/v1/notifications": "notification",
"/api/v1/auth": "auth",
"/api/v1/workflows": "workflow",
"/api/v1/settings": "system_setting",
}
# action 映射
_ACTION_MAP = {
"POST": "create",
"PUT": "update",
"PATCH": "update",
"DELETE": "delete",
}
class AuditLogMiddleware(BaseHTTPMiddleware):
"""自动审计中间件 — 记录所有写操作。"""
async def dispatch(self, request: Request, call_next):
# 只审计写操作
if request.method not in ("POST", "PUT", "PATCH", "DELETE"):
return await call_next(request)
path = request.url.path
# 跳过排除列表
if any(path.startswith(p) for p in _EXCLUDE_PREFIXES):
return await call_next(request)
# 特殊: POST /auth/login 在 auth.py 中单独记录
if path == "/api/v1/auth/login" or path == "/api/v1/auth/logout":
return await call_next(request)
response = await call_next(request)
# 异步记录审计日志(不阻塞响应)
try:
await self._record(request, response.status_code)
except Exception:
pass
return response
@staticmethod
async def _record(request: Request, status_code: int):
path = request.url.path
method = request.method
# 确定 action
action = _ACTION_MAP.get(method, method.lower())
if path.endswith("/review"):
action = "approve" if status_code < 300 else "reject"
elif path.endswith("/execute"):
action = "execute"
elif path.endswith("/cancel"):
action = "cancel"
# 确定资源类型
resource_type = "other"
for prefix, rtype in _RESOURCE_MAP.items():
if path.startswith(prefix):
resource_type = rtype
break
# 提取 resource_id
resource_id = None
parts = path.rstrip("/").split("/")
for p in parts:
if p.isdigit():
resource_id = int(p)
break
# 获取操作者信息
user_id = None
username = None
try:
from app.core.security import decode_token
auth_header = request.headers.get("authorization", "")
if auth_header.startswith("Bearer "):
payload = decode_token(auth_header[7:])
user_id = int(payload.get("sub", 0))
username = payload.get("role", "unknown")
except Exception:
pass
# IP
forwarded = request.headers.get("x-forwarded-for")
ip_address = forwarded.split(",")[0].strip() if forwarded else (
request.client.host if request.client else None
)
# 写入DB
async with async_session() as db:
log = OperationLog(
user_id=user_id,
username=username,
action=action,
resource_type=resource_type,
resource_id=resource_id,
detail=f"{method} {path} -> {status_code}",
ip_address=ip_address,
)
db.add(log)
await db.commit()