背景:系统此前没有操作审计。task_logs 的 task_id 是 NOT NULL 外键,只能挂在 任务上,且全项目仅 4 处写入点 —— 登录、导出、产品增删改、收编完全不留痕。 需求方整理的问题清单里「无审计日志查看页」正源于此:不是没有页面,是没数据。 设计参考 MOM(KCGL) 的 audit_logs / audit_listener,但按 Track 栈做了取舍: 1) 写入时机:MOM 用 SQLAlchemy event listener + 同事务写入,优点是零侵入, 缺点是**业务回滚时审计一起消失**,而失败/被拒的操作(越权尝试、参数错误) 恰恰最需要留痕。Track 改为响应生成后用**独立 session** 写入: - 业务回滚不影响审计(已验证 422/401 失败操作同样落库) - 审计写入失败也不影响业务(全包裹 try/except) - 代价:非原子提交,响应后进程立即被 kill 可能丢一条(已注释说明取舍) 2) 采集方式:中间件自动采集写操作 + 导出/下载/打印这类「读但敏感」的 GET。 路径段推导 module/action/target_id。不做手写埋点,因为手写必然漏 —— task_logs 只有 4 处写入点就是前车之鉴。 3) 增量价值:新增 request_id 字段,与 core/logging.py 的结构化日志打通, 凭一个 ID 就能从审计记录直接跳到那一次接口日志。MOM 无此字段。 4) 敏感信息:details 经 sanitize_details 递归剔除 password/token/secret 等键; 中间件不读请求体,登录明文密码不会落库(已断言表内无密码痕迹)。 配套改动: - core/roles.py:角色常量与 is_admin 收敛为单一事实来源。此前同一份 「管理员角色」规则散在 task_service、products.py 内联判断和前端 constants/task.ts 三处,已因此发生过「移动端漏判 SUPERVISOR 误挡主管」。 task_service 改为从 core.roles 导入同名常量,保持既有引用可用。 - core/deps.py:抽出 require_roles/require_admin 可复用依赖,替代内联判断。 - main.py:500 响应显式补 X-Request-ID 头 —— 该响应由 ServerErrorMiddleware 生成,位于 RequestContextMiddleware 外层,中间件没机会写头。 - auth.py:登录校验前把「尝试的账号」写入 request.state,使登录事件 (含失败登录)可归属到人,可用于追踪暴力破解。 验证:本地起 PostgreSQL 17 + 迁移后跑端到端测试,32/32 通过 (TestClient 每个请求新建事件循环,与模块级 asyncpg 连接池冲突会报 "got Future attached to a different loop",故改用 httpx.AsyncClient + ASGITransport 单循环;生产 uvicorn 单循环无此问题)。
196 lines
6.8 KiB
Python
196 lines
6.8 KiB
Python
"""审计采集中间件
|
||
|
||
在响应生成后,把「谁 / 何时 / 从哪来 / 调了哪个接口 / 做了什么 / 结果如何」
|
||
落进 audit_logs。
|
||
|
||
为什么用中间件自动采集,而不是在每个业务函数里手写 record_audit
|
||
------------------------------------------------------------------
|
||
1. 手写必然漏。新加的端点很容易忘记补审计,而审计的价值恰恰建立在「完整」上。
|
||
现状可佐证:task_logs 全项目只有 4 处写入点,凡是不挂在任务上的动作
|
||
(登录、导出、改产品)全都没有留痕。
|
||
2. 中间件能拿到业务函数拿不到的事实:真实来源 IP、UA、最终状态码、
|
||
以及与结构化日志对齐的 request_id。
|
||
3. 业务语义(module / target)由路径推导,不如手写精确,但对「谁动了什么」
|
||
的追责场景已经够用;关键动作后续可再调 record_audit 补 details 做增强。
|
||
|
||
采集范围
|
||
--------
|
||
- 所有写操作(POST/PUT/PATCH/DELETE)
|
||
- 少数**读但敏感**的操作:导出、下载、打印(本项目 GET /people-history/export
|
||
就是导出,只按方法过滤会漏掉)
|
||
|
||
明确不采集:GET /health*、/docs、/openapi.json —— 探针与文档的噪声没有审计价值。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
|
||
from fastapi import Request
|
||
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
||
from starlette.responses import Response
|
||
|
||
from app.services.audit_service import record_audit
|
||
|
||
logger = logging.getLogger("track.audit")
|
||
|
||
# 写操作一律采集
|
||
_MUTATING_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
|
||
|
||
# 读操作里需要留痕的(导出/下载/打印属于「读」,但把数据带出了系统)
|
||
_SENSITIVE_READ_KEYWORDS = frozenset({"export", "download", "print"})
|
||
|
||
# 永久忽略的路径前缀
|
||
_IGNORED_PREFIXES = ("/health", "/docs", "/redoc", "/openapi.json")
|
||
|
||
# 路径段 → 审计模块
|
||
_PATH_MODULE: dict[str, str] = {
|
||
"products": "product",
|
||
"tasks": "task",
|
||
"orders": "order",
|
||
"records": "record",
|
||
"print": "print",
|
||
"materials": "material",
|
||
"users": "user",
|
||
"upload": "upload",
|
||
"notifications": "notification",
|
||
"app-version": "app",
|
||
"analytics": "analytics",
|
||
"dashboard": "dashboard",
|
||
"holidays": "holiday",
|
||
"screen": "screen",
|
||
"webhooks": "external",
|
||
"external": "external",
|
||
"audit": "audit",
|
||
"auth": "auth",
|
||
}
|
||
|
||
# 路径段 → 动作(优先于按 HTTP 方法推断)
|
||
_SEGMENT_ACTION: dict[str, str] = {
|
||
"login": "login",
|
||
"logout": "logout",
|
||
"refresh": "refresh",
|
||
"export": "export",
|
||
"download": "export",
|
||
"print": "print",
|
||
"upload": "upload",
|
||
"finalize": "finalize",
|
||
"receive": "receive",
|
||
"transfer": "transfer",
|
||
"reject": "reject",
|
||
"recall": "recall",
|
||
"spawn": "spawn",
|
||
"complete": "complete",
|
||
"end": "end",
|
||
}
|
||
|
||
_METHOD_ACTION: dict[str, str] = {
|
||
"POST": "create",
|
||
"PUT": "update",
|
||
"PATCH": "update",
|
||
"DELETE": "delete",
|
||
"GET": "read",
|
||
}
|
||
|
||
# 不可能是业务 ID 的路径段,避免把动作词误当成 target_id
|
||
_NON_ID_SEGMENTS = frozenset(
|
||
set(_SEGMENT_ACTION) | {"api", "v1", "me", "options", "export", "lookup", "batch"}
|
||
)
|
||
|
||
|
||
def _derive_module_and_action(path: str, method: str) -> tuple[str, str, str | None]:
|
||
"""由请求路径与 HTTP 方法推导 (module, action, target_id)"""
|
||
parts = [p for p in path.split("/") if p]
|
||
|
||
module = "other"
|
||
module_idx = -1
|
||
for i, seg in enumerate(parts):
|
||
if seg in _PATH_MODULE:
|
||
module = _PATH_MODULE[seg]
|
||
module_idx = i
|
||
break
|
||
|
||
action = None
|
||
for seg in reversed(parts):
|
||
if seg in _SEGMENT_ACTION:
|
||
action = _SEGMENT_ACTION[seg]
|
||
break
|
||
if action is None:
|
||
action = _METHOD_ACTION.get(method, method.lower())
|
||
|
||
target_id = None
|
||
if module_idx >= 0 and module_idx + 1 < len(parts):
|
||
candidate = parts[module_idx + 1]
|
||
if candidate not in _NON_ID_SEGMENTS:
|
||
target_id = candidate
|
||
|
||
return module, action, target_id
|
||
|
||
|
||
class AuditMiddleware(BaseHTTPMiddleware):
|
||
"""写操作审计采集。
|
||
|
||
必须注册在 RequestContextMiddleware **内层**,因为它依赖后者写入
|
||
request.state 的 request_id 才能与结构化日志对账。
|
||
"""
|
||
|
||
def _should_audit(self, request: Request) -> bool:
|
||
path = request.url.path
|
||
if path.startswith(_IGNORED_PREFIXES):
|
||
return False
|
||
if request.method in _MUTATING_METHODS:
|
||
return True
|
||
if request.method == "GET":
|
||
lowered = path.lower()
|
||
return any(kw in lowered for kw in _SENSITIVE_READ_KEYWORDS)
|
||
return False
|
||
|
||
async def dispatch(
|
||
self, request: Request, call_next: RequestResponseEndpoint
|
||
) -> Response:
|
||
if not self._should_audit(request):
|
||
return await call_next(request)
|
||
|
||
status_code = 500
|
||
error_message: str | None = None
|
||
try:
|
||
response = await call_next(request)
|
||
status_code = response.status_code
|
||
return response
|
||
except Exception as exc:
|
||
# 异常最终由 ServerErrorMiddleware 转成 500;这里先标记,
|
||
# 保证「失败的操作也有审计」——这正是选用独立 session 的目的
|
||
error_message = f"{type(exc).__name__}: {exc}"[:1000]
|
||
raise
|
||
finally:
|
||
await self._write(request, status_code, error_message)
|
||
|
||
async def _write(
|
||
self, request: Request, status_code: int, error_message: str | None
|
||
) -> None:
|
||
try:
|
||
module, action, target_id = _derive_module_and_action(
|
||
request.url.path, request.method
|
||
)
|
||
client = request.client
|
||
await record_audit(
|
||
action=action,
|
||
module=module,
|
||
user_id=getattr(request.state, "audit_user", None),
|
||
display_name=getattr(request.state, "audit_display_name", None),
|
||
role=getattr(request.state, "audit_role", None),
|
||
target_type=module,
|
||
target_id=target_id,
|
||
# 对产品而言路径里的 ID 就是身份证号,本身即人可读的标识
|
||
target_name=target_id if module == "product" else None,
|
||
ip_address=client.host if client else None,
|
||
user_agent=request.headers.get("user-agent"),
|
||
method=request.method,
|
||
url=request.url.path,
|
||
status_code=status_code,
|
||
error_message=error_message,
|
||
request_id=getattr(request.state, "request_id", None),
|
||
)
|
||
except Exception:
|
||
# record_audit 内部已兜底;这里再兜一层,确保审计绝不冒泡成 500
|
||
logger.exception("审计采集失败(已忽略)")
|