背景:系统此前没有操作审计。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 单循环无此问题)。
82 lines
3.7 KiB
Python
82 lines
3.7 KiB
Python
"""add_audit_logs
|
||
|
||
Revision ID: j1k2l3m4n5o6
|
||
Revises: i1j2k3l4m5n6
|
||
Create Date: 2026-09-21
|
||
|
||
操作审计日志表(audit_logs)
|
||
--------------------------
|
||
新增一张独立的审计表,用于记录 task_logs 覆盖不到的操作:
|
||
登录、导出、产品增删改、收口、权限/配置变更等与单个任务无关的动作。
|
||
|
||
为什么另起一张表而不复用 task_logs:
|
||
task_logs.task_id 是 NOT NULL 外键,只能挂在任务上,无法表达「张三导出了
|
||
产品清单」这类动作;且缺少来源 IP / UA / 结果状态等审计必需字段。
|
||
|
||
存量数据无需回填(本表从上线时刻开始记录)。
|
||
"""
|
||
from typing import Sequence, Union
|
||
|
||
import sqlalchemy as sa
|
||
from alembic import op
|
||
from sqlalchemy.dialects import postgresql
|
||
|
||
revision: str = "j1k2l3m4n5o6"
|
||
down_revision: Union[str, None] = "i1j2k3l4m5n6"
|
||
branch_labels: Union[str, Sequence[str], None] = None
|
||
depends_on: Union[str, Sequence[str], None] = None
|
||
|
||
|
||
def upgrade() -> None:
|
||
op.create_table(
|
||
"audit_logs",
|
||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, nullable=False),
|
||
# 操作人
|
||
sa.Column("user_id", sa.String(64), nullable=True, comment="操作人账号(逻辑外键→MOM)"),
|
||
sa.Column("display_name", sa.String(100), nullable=True, comment="操作人显示名"),
|
||
sa.Column("role", sa.String(50), nullable=True, comment="操作时角色快照"),
|
||
# 业务语义
|
||
sa.Column("action", sa.String(50), nullable=False, comment="动作"),
|
||
sa.Column("module", sa.String(50), nullable=False, comment="业务模块"),
|
||
sa.Column("target_type", sa.String(50), nullable=True),
|
||
sa.Column("target_id", sa.String(100), nullable=True),
|
||
sa.Column("target_name", sa.String(200), nullable=True),
|
||
sa.Column("details", postgresql.JSONB(), nullable=True, comment="变更详情"),
|
||
# 请求上下文
|
||
sa.Column("ip_address", sa.String(50), nullable=True),
|
||
sa.Column("user_agent", sa.String(500), nullable=True),
|
||
sa.Column("method", sa.String(10), nullable=True),
|
||
sa.Column("url", sa.String(500), nullable=True),
|
||
sa.Column("status_code", sa.Integer(), nullable=True),
|
||
sa.Column("error_message", sa.Text(), nullable=True),
|
||
# 与结构化日志对账
|
||
sa.Column("request_id", sa.String(64), nullable=True),
|
||
sa.Column(
|
||
"created_at",
|
||
sa.DateTime(timezone=True),
|
||
nullable=False,
|
||
server_default=sa.func.now(),
|
||
),
|
||
)
|
||
|
||
# 索引:按审计页最常用的检索维度建
|
||
op.create_index("ix_audit_logs_created_at", "audit_logs", ["created_at"])
|
||
op.create_index("ix_audit_logs_user_id", "audit_logs", ["user_id"])
|
||
op.create_index("ix_audit_logs_module", "audit_logs", ["module"])
|
||
op.create_index("ix_audit_logs_action", "audit_logs", ["action"])
|
||
op.create_index("ix_audit_logs_target_id", "audit_logs", ["target_id"])
|
||
op.create_index("ix_audit_logs_request_id", "audit_logs", ["request_id"])
|
||
# 组合索引:审计页默认「按时间倒序 + 按模块/动作过滤」
|
||
op.create_index("ix_audit_logs_module_created", "audit_logs", ["module", "created_at"])
|
||
|
||
|
||
def downgrade() -> None:
|
||
op.drop_index("ix_audit_logs_module_created", table_name="audit_logs")
|
||
op.drop_index("ix_audit_logs_request_id", table_name="audit_logs")
|
||
op.drop_index("ix_audit_logs_target_id", table_name="audit_logs")
|
||
op.drop_index("ix_audit_logs_action", table_name="audit_logs")
|
||
op.drop_index("ix_audit_logs_module", table_name="audit_logs")
|
||
op.drop_index("ix_audit_logs_user_id", table_name="audit_logs")
|
||
op.drop_index("ix_audit_logs_created_at", table_name="audit_logs")
|
||
op.drop_table("audit_logs")
|