按需为每个 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 头、幂等。
196 lines
7.2 KiB
Python
196 lines
7.2 KiB
Python
"""审计日志中间件:每个 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",
|
||
]
|