背景:系统此前没有操作审计。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 单循环无此问题)。
84 lines
3.8 KiB
Python
84 lines
3.8 KiB
Python
"""操作审计日志模型
|
||
|
||
设计参考 MOM(KCGL) 的 audit_logs,但按 Track 的技术栈与诉求做了取舍:
|
||
|
||
- 主键用 UUID(与库内其它表一致),而非 MOM 的自增 int。
|
||
- 增加 request_id:与 core/logging.py 的结构化日志打通 —— 凭一个 ID 就能把
|
||
「接口访问日志」和「审计记录」对上,排障时不用再猜。MOM 无此字段。
|
||
- 保留 module / action / target_* 的业务语义,使审计能按业务维度检索,
|
||
而不是只能按时间翻。
|
||
- 绝不记录请求体:登录等接口 body 含明文密码,一旦落库就成了长期泄露面。
|
||
|
||
与既有 task_logs 的分工:task_logs 是「任务流转轨迹」(有 task_id 非空约束,
|
||
只能挂在任务上,供流转树渲染);本表是「操作审计」,覆盖登录、导出、
|
||
产品增删改、权限变更等与单个任务无关的动作,且额外记录来源 IP / UA / 耗时结果。
|
||
"""
|
||
import uuid
|
||
from datetime import datetime
|
||
|
||
from sqlalchemy import DateTime, Integer, String, Text
|
||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||
from sqlalchemy.orm import Mapped, mapped_column
|
||
|
||
from app.models.base import Base
|
||
from app.core.time_utils import get_beijing_time
|
||
|
||
|
||
class AuditLog(Base):
|
||
__tablename__ = "audit_logs"
|
||
|
||
id: Mapped[uuid.UUID] = mapped_column(
|
||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4,
|
||
)
|
||
|
||
# ---- 操作人(逻辑外键 → MOM sys_user,仅存账号,无物理约束)----
|
||
user_id: Mapped[str | None] = mapped_column(
|
||
String(64), nullable=True, index=True, comment="操作人账号(逻辑外键→MOM)",
|
||
)
|
||
display_name: Mapped[str | None] = mapped_column(
|
||
String(100), nullable=True, comment="操作人显示名",
|
||
)
|
||
role: Mapped[str | None] = mapped_column(
|
||
String(50), nullable=True, comment="操作时角色快照",
|
||
)
|
||
|
||
# ---- 业务语义 ----
|
||
action: Mapped[str] = mapped_column(
|
||
String(50), nullable=False, index=True, comment="动作: create/update/delete/export/login/...",
|
||
)
|
||
module: Mapped[str] = mapped_column(
|
||
String(50), nullable=False, index=True, comment="业务模块: product/task/order/auth/print/...",
|
||
)
|
||
target_type: Mapped[str | None] = mapped_column(
|
||
String(50), nullable=True, comment="目标类型(表名或实体名)",
|
||
)
|
||
target_id: Mapped[str | None] = mapped_column(
|
||
String(100), nullable=True, index=True, comment="目标ID",
|
||
)
|
||
target_name: Mapped[str | None] = mapped_column(
|
||
String(200), nullable=True, comment="目标显示名(如产品身份证/工单号)",
|
||
)
|
||
details: Mapped[dict | None] = mapped_column(
|
||
JSONB, nullable=True, comment="变更详情 {old:{}, new:{}};禁止写入密码等敏感字段",
|
||
)
|
||
|
||
# ---- 请求上下文(由中间件自动填充)----
|
||
ip_address: Mapped[str | None] = mapped_column(String(50), nullable=True, comment="来源IP")
|
||
user_agent: Mapped[str | None] = mapped_column(String(500), nullable=True, comment="浏览器UA")
|
||
method: Mapped[str | None] = mapped_column(String(10), nullable=True, comment="HTTP方法")
|
||
url: Mapped[str | None] = mapped_column(String(500), nullable=True, comment="请求路径")
|
||
status_code: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="响应状态码")
|
||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True, comment="错误信息(如有)")
|
||
|
||
# ---- 与结构化日志对账用 ----
|
||
request_id: Mapped[str | None] = mapped_column(
|
||
String(64), nullable=True, index=True, comment="关联 core/logging 的 request_id",
|
||
)
|
||
|
||
created_at: Mapped[datetime] = mapped_column(
|
||
DateTime(timezone=True), default=get_beijing_time, index=True, comment="操作时间",
|
||
)
|
||
|
||
def __repr__(self) -> str:
|
||
return f"<AuditLog {self.action} {self.module} by {self.user_id}>"
|