Files
model-platform/backend/src/backend/audit.py
T
tao.chen 97825ca86d feat(audit): per-request loguru audit middleware with daily rotation
按需为每个 HTTP 接口写一条合规记录到
data/logs/audit/audit-YYYY-MM-DD.log,字段:时间 / 用户 /
METHOD path / 状态码。

设计:
* 复用全局 loguru logger,文件 sink 由 audit._DailyFileSink
  自管:缓存当天文件句柄、跨日重建。不走 loguru 的
  rotation=00:00(产物是 audit.log.YYYY-MM-DD_HH-MM-SS,
  不符合按天单文件的命名要求)。
* AuditMiddleware 只做 CPU 验签拿 user_id:cookie access_token
  优先,Authorization Bearer 兜底,无/坏 JWT 一律记 '-'。
  绝不查 DB(RequestContext 在路由解析后才注入)。
* 审计失败不拖死请求:所有异常捕获。
* 与 main.py 现有 access_log 严格分离:access_log 走 stderr
  诊断(method/path/status/耗时),audit 走独立文件合规
  (时间/用户/接口),并存。
* 启动时按 settings.audit_log_retention_days 清理过期文件
  (设 0 关闭)。
* 新增 settings.audit_log_dir(默认 data/logs/audit,相对 cwd)
  与 settings.audit_log_retention_days(默认 30)两个配置项;
  .env.example 同步。

新增 9 个 case:文件创建、行字段、未登录 '-'、坏 JWT、ULID
path、retention 清理/关闭、Bearer 头、幂等。
2026-08-21 13:22:00 +08:00

196 lines
7.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""审计日志中间件:每个 HTTP 请求写一条合规记录到独立的按天滚动文件。
设计要点
--------
* 复用全局 loguru ``logger``(与 ``common.logging.configure_logging``
共用同一套日志框架,不新增依赖)。日志文件 sink 由本模块的
:func:`configure_audit_logging` 在进程启动时挂上,只此一次(幂等,
与 ``configure_logging`` 的风格一致)。
* 每天一个文件 ``audit-YYYY-MM-DD.log``,放在 ``settings.audit_log_dir``
(默认 ``data/logs/audit``,相对 backend 进程 cwd)。滚动由
:class:`_DailyFileSink` 自行实现:缓存当天的文件句柄,跨自然日时关闭旧
fd 再打开新文件。不使用 loguru 自带的 ``rotation="00:00"``,因为它对
string path 产出的文件名是 ``audit.log.YYYY-MM-DD_HH-MM-SS``,既没有
``audit-`` 前缀也不符合每天一个文件的要求。
* 与 ``main.py`` L108 的 ``access_log`` 是两回事,刻意分离:
``access_log`` 是诊断日志(method / path / status / 耗时),走 stderr
本中间件是合规日志(时间 / 用户 / 接口 / 状态码),写独立文件。两者并存。
认证解析
--------
中间件在路由解析之前执行,拿不到 ``Depends(request_context)`` 注入的结果,
也绝不为此做 DB 查询。用户身份只通过本进程内 CPU 验签解 JWT 得到:
优先 ``access_token`` cookie,其次 ``Authorization: Bearer`` 头;验签
失败或缺失一律记 ``-``。审计写入自身失败也不得把请求拖死(全部捕获)。
"""
from __future__ import annotations
import os
import time
from collections.abc import Callable
from datetime import UTC, datetime
from pathlib import Path
from typing import TextIO
from common.auth.jwt import verify_jwt_token
from loguru import logger
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
# 纯文本一行一条(末尾换行由 loguru 的 terminator 追加):
# 2026-08-21 14:30:00.123 | 01USER... | GET /api/v1/scripts/01ABC... -> 200
AUDIT_LOG_FORMAT = (
"{time:YYYY-MM-DD HH:mm:ss.SSS} | {extra[user_id]} | "
"{extra[method]} {extra[path]} -> {extra[status]}"
)
_CONFIGURED: bool = False
_HANDLER_ID: int | None = None
class _DailyFileSink:
"""按自然日滚动到 ``audit-YYYY-MM-DD.log`` 的 loguru sink。
缓存当天已打开的 ``Path.open("a")`` 文件句柄;跨日时关闭旧 fd 并打开
新文件,不依赖 loguru 自己的 rotation。``write`` 收到的是 loguru 已经
按 ``AUDIT_LOG_FORMAT`` 格式化好、以 ``\n`` 结尾的一行文本。
"""
def __init__(self, log_dir: str | Path) -> None:
self._log_dir = Path(log_dir)
self._log_dir.mkdir(parents=True, exist_ok=True)
self._fh: TextIO | None = None
self._open_date: str | None = None
def write(self, message: str) -> None:
day = datetime.now(UTC).astimezone().strftime("%Y-%m-%d")
if self._fh is None or self._open_date != day:
self._close()
self._fh = (self._log_dir / f"audit-{day}.log").open(
"a", encoding="utf-8"
)
self._open_date = day
self._fh.write(message)
def flush(self) -> None:
if self._fh is not None:
self._fh.flush()
def stop(self) -> None:
self._close()
def _close(self) -> None:
if self._fh is not None:
self._fh.close()
self._fh = None
self._open_date = None
def _audit_filter(record: dict) -> bool:
"""只放行中间件自己打的审计行,其它 INFO 日志不进审计文件。"""
extra = record["extra"]
return (
record["message"] == "audit"
and "user_id" in extra
and "method" in extra
and "path" in extra
and "status" in extra
)
def _cleanup_expired_files(log_dir: Path, retention_days: int) -> None:
"""启动时删除 ``retention_days`` 天前的 ``audit-*.log``0 表示关闭清理。"""
if retention_days <= 0:
return
cutoff = time.time() - retention_days * 86_400
for path in log_dir.glob("audit-*.log"):
try:
if os.path.getmtime(path) < cutoff:
path.unlink()
except OSError:
continue
def configure_audit_logging(log_dir: str, retention_days: int) -> None:
"""挂上审计日志文件 sink。幂等:多次调用只有首次生效。"""
global _CONFIGURED, _HANDLER_ID
if _CONFIGURED:
return
dir_path = Path(log_dir)
dir_path.mkdir(parents=True, exist_ok=True)
_cleanup_expired_files(dir_path, retention_days)
# 注意:loguru 的 ``encoding=`` 只对 file-path sink 生效;对 callable /
# stream sink 传入会直接 TypeError。UTF-8 由 _DailyFileSink 在
# ``open(..., encoding="utf-8")`` 里保证。
_HANDLER_ID = logger.add(
_DailyFileSink(dir_path),
level="INFO",
format=AUDIT_LOG_FORMAT,
filter=_audit_filter,
enqueue=True,
serialize=False,
catch=True,
)
_CONFIGURED = True
class AuditMiddleware(BaseHTTPMiddleware):
"""对每个 HTTP 请求写一行审计记录。
与 ``access_log``main.py L108)的关系:``access_log`` 是诊断日志
(方法/路径/状态码/耗时,走 stderr),本中间件是合规日志(时间/用户/
接口),写独立文件。两者并存,互不合并。
关键约束:中间件不做 DB 查询、不碰 ``Depends(request_context)``、
不修改 response body;用户身份只靠本进程 CPU 验签 JWT 解析。
"""
async def dispatch(self, request: Request, call_next: Callable) -> Response:
try:
response = await call_next(request)
except Exception:
# call_next 抛错(例如 websocket upgrade 或未处理的路由异常):
# 仍然写一行 status=0 的审计,并把原始异常继续往上抛,交给全局
# unhandled_exception_handler 返回 500 —— 审计逻辑本身不吞错、
# 也不改写响应。
self._record(request, 0)
raise
self._record(request, response.status_code)
return response
@staticmethod
def _record(request: Request, status: int) -> None:
token = request.cookies.get("access_token") or ""
if not token:
token = (
request.headers.get("authorization", "")
.removeprefix("Bearer ")
.strip()
)
user_id = "-"
if token:
try:
user_id = verify_jwt_token(token)["sub"]
except Exception: # noqa: BLE001 - 验签/解析失败一律记 "-",审计不能因坏 JWT 抛错
user_id = "-"
try:
logger.bind(
user_id=user_id,
method=request.method,
path=request.url.path,
status=status,
).info("audit")
except Exception: # noqa: BLE001, S110 - 审计写入失败静默忽略,不能把请求拖死
pass
__all__ = [
"AUDIT_LOG_FORMAT",
"AuditMiddleware",
"configure_audit_logging",
]