feat(audit): 新增操作审计日志(表/中间件/查询接口)+ 角色常量收敛

背景:系统此前没有操作审计。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 单循环无此问题)。
This commit is contained in:
openhands
2026-09-21 02:23:19 +00:00
parent 04eb87b091
commit 7635802a42
13 changed files with 805 additions and 5 deletions

View File

@ -4,6 +4,7 @@ from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from app.core.config import settings
from app.core.audit_middleware import AuditMiddleware
from app.core.health import router as health_router
from app.core.logging import request_id_var, setup_logging
from app.core.middleware import RequestContextMiddleware
@ -72,8 +73,11 @@ app.add_middleware(
expose_headers=["X-Request-ID"],
)
# Starlette 的 add_middleware 是「后添加者在外层」,故 RequestContextMiddleware
# 最后注册 → 最先进入请求,才能覆盖包括 CORS 预检在内的全部请求并回写 X-Request-ID
# Starlette 的 add_middleware 是「后添加者在外层」。执行顺序(由外到内):
# RequestContextMiddleware -> AuditMiddleware -> CORS -> 路由
# AuditMiddleware 必须在 RequestContext 内层,才能读到后者写入 request.state
# 的 request_id,从而把审计记录与结构化日志对上。
app.add_middleware(AuditMiddleware)
app.add_middleware(RequestContextMiddleware)
@ -96,6 +100,10 @@ async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONR
return JSONResponse(
status_code=500,
content={"detail": "服务器内部错误", "request_id": request_id},
# 该响应由 ServerErrorMiddleware(位于 RequestContextMiddleware 外层)
# 生成,中间件没机会再往响应头写 X-Request-ID,故在此显式补上,
# 保证报障时前端从响应头就能拿到可对账的 ID。
headers={"X-Request-ID": request_id} if request_id else None,
)