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

@ -0,0 +1,195 @@
"""审计采集中间件
在响应生成后,把「谁 / 何时 / 从哪来 / 调了哪个接口 / 做了什么 / 结果如何」
落进 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("审计采集失败(已忽略)")

35
backend/app/core/deps.py Normal file
View File

@ -0,0 +1,35 @@
"""通用 FastAPI 依赖"""
from __future__ import annotations
from fastapi import Depends, HTTPException, status
from app.core.roles import ADMIN_ROLES
from app.services.auth_service import get_current_user
def require_roles(*roles: str):
"""生成「限定角色」依赖,避免同一个内联判断被复制到每个端点。
用法::
@router.get("/x")
async def x(current_user: dict = Depends(require_admin)):
...
失败一律 403 且不透露允许的角色集合(避免给探测者提供线索)。
"""
allowed = frozenset(roles)
async def _guard(current_user: dict = Depends(get_current_user)) -> dict:
if (current_user or {}).get("role") not in allowed:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="当前角色无权访问该接口",
)
return current_user
return _guard
# 审计日志等高权限接口复用同一实例
require_admin = require_roles(*ADMIN_ROLES)

31
backend/app/core/roles.py Normal file
View File

@ -0,0 +1,31 @@
"""角色定义与管理员判定 —— 单一事实来源
背景:角色字符串此前散落在至少三处 —— task_service.ADMIN_ROLES、
products.py 的内联判断、以及前端 constants/task.ts。同一份规则抄多份的后果
已经发生过:前端 constants/task.ts:233 的注释记录了一次「移动端只判了
SUPER_ADMIN、漏了 SUPERVISOR导致主管被误挡」的事故。
本模块把**角色常量与管理员判定**先收敛到一处,供后端统一引用。
完整的「角色 × 权限点」可配置矩阵是后续工作;但任何推进都应从这里出发,
不要再新增第四份副本。
"""
from __future__ import annotations
SUPER_ADMIN = "SUPER_ADMIN"
SUPERVISOR = "SUPERVISOR"
# 注意MOM 登录返回的默认角色是小写 operator见 auth_service.login
OPERATOR = "OPERATOR"
# 管理员角色:可执行收口、审计查看等高权限动作
ADMIN_ROLES: frozenset[str] = frozenset({SUPER_ADMIN, SUPERVISOR})
ROLE_LABELS: dict[str, str] = {
SUPER_ADMIN: "超级管理员",
SUPERVISOR: "主管",
OPERATOR: "操作员",
}
def is_admin(role: str | None) -> bool:
"""role 为 None / 未知值一律视为无权限fail-closed不做兜底放行"""
return role in ADMIN_ROLES