- 操作日志审计机制:audit 上下文(成功与失败都记、独立提交、操作人标量 固化、commit 后查询前置约定),用户/角色全部写操作接入;request_data 按键名的结构化脱敏 + UTF-8 字节边界截断 - 角色管理:CRUD 与启停;admin 禁删禁停、有绑定禁删(FOR UPDATE 锁行)、 编码创建后不可改、停用角色不可再分配 - 日志查询:操作/登录两端点,列表与详情两段式(详情含已脱敏请求参数); 时间参数统一转本地 naive(修混用时区 500 与偏移丢弃) - 用户管理:补齐 reset_password 操作人、编辑空串归一 NULL、并发唯一键兜底 400 - 测试 301 例全绿(单元 223 / 集成 78) Co-Authored-By: Claude Code <noreply@anthropic.com>
201 lines
7.3 KiB
Python
201 lines
7.3 KiB
Python
"""操作日志数据访问。
|
||
|
||
本层只 flush 不 commit —— 事务边界由 services 层控制。
|
||
|
||
写入前处理(截断 / 脱敏 / 序列化)集中在本层,与 `login_log_repository`
|
||
的做法一致:模型定义的长度常量在这里落实,调用方只需传业务值。
|
||
"""
|
||
|
||
import json
|
||
from dataclasses import dataclass
|
||
|
||
from sqlalchemy import Select, func, select
|
||
from sqlalchemy.orm import Session, defer
|
||
|
||
from app.core.logging import scrub
|
||
from app.models import OperationLog
|
||
from app.models.operation_log import (
|
||
ACTION_MAX_LEN,
|
||
ERROR_MESSAGE_MAX_LEN,
|
||
IP_MAX_LEN,
|
||
METHOD_MAX_LEN,
|
||
MODULE_MAX_LEN,
|
||
PATH_MAX_LEN,
|
||
REQUEST_DATA_MAX_BYTES,
|
||
SUMMARY_MAX_LEN,
|
||
USER_AGENT_MAX_LEN,
|
||
USERNAME_MAX_LEN,
|
||
)
|
||
from app.repositories.query_utils import LogFilters, apply_log_filters
|
||
|
||
# 键名(不区分大小写)含以下子串的字段,其值一律整体抹除。
|
||
# 口径与 core.logging.scrub 的键名正则一致,但改为结构化处理 ——
|
||
# 文本正则有三种逃逸形态:值以空白开头时整体匹配失败(完全不脱敏)、
|
||
# 值含空格/逗号时只抹首段、值含引号时既残留又破坏 JSON 结构。
|
||
_SENSITIVE_KEY_PARTS = ("password", "passwd", "pwd", "secret", "token", "app_key", "access_key")
|
||
|
||
_MASK = "***"
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class OperationLogEntry:
|
||
"""一条待写入的操作日志(全字段 DTO)。
|
||
|
||
替代 14 个平铺参数:字段若在 audit() / _safe_write() / add() 三处
|
||
重复展开,增删字段时任何一侧漏改都只表现为审计数据不完整、不报错
|
||
(success 默认 0 的语义使漏传尤其隐蔽)。
|
||
"""
|
||
|
||
user_id: int | None
|
||
username: str
|
||
module: str
|
||
action: str
|
||
summary: str
|
||
success: bool
|
||
method: str | None = None
|
||
path: str | None = None
|
||
ip: str | None = None
|
||
user_agent: str | None = None
|
||
request_data: dict | None = None
|
||
error_message: str | None = None
|
||
duration_ms: int | None = None
|
||
|
||
|
||
def add(session: Session, entry: OperationLogEntry) -> None:
|
||
"""写入一条操作日志。只 flush 不 commit。
|
||
|
||
所有文本列按模型定义的长度常量截断 —— 超长在 MySQL 严格模式下抛 1406,
|
||
而「审计失败不影响业务」的约定会把该异常吞掉,结果是整条日志**静默丢失**,
|
||
事后极难排查。截断是唯一防线,必须在此层无条件执行。
|
||
"""
|
||
session.add(
|
||
OperationLog(
|
||
user_id=entry.user_id,
|
||
username=entry.username[:USERNAME_MAX_LEN],
|
||
module=entry.module[:MODULE_MAX_LEN],
|
||
action=entry.action[:ACTION_MAX_LEN],
|
||
summary=entry.summary[:SUMMARY_MAX_LEN],
|
||
method=_clip(entry.method, METHOD_MAX_LEN),
|
||
path=_clip(entry.path, PATH_MAX_LEN),
|
||
ip=_clip(entry.ip, IP_MAX_LEN),
|
||
user_agent=_clip(entry.user_agent, USER_AGENT_MAX_LEN),
|
||
request_data=_serialize_request_data(entry.request_data),
|
||
success=1 if entry.success else 0,
|
||
error_message=_clip(entry.error_message, ERROR_MESSAGE_MAX_LEN),
|
||
duration_ms=entry.duration_ms,
|
||
)
|
||
)
|
||
session.flush()
|
||
|
||
|
||
def get_by_id(session: Session, log_id: int) -> OperationLog | None:
|
||
"""按主键查询单条日志(详情端点用,含 request_data)。不存在返回 None。"""
|
||
return session.get(OperationLog, log_id)
|
||
|
||
|
||
def list_logs(
|
||
session: Session,
|
||
*,
|
||
offset: int,
|
||
limit: int,
|
||
filters: LogFilters,
|
||
module: str | None = None,
|
||
action: str | None = None,
|
||
) -> list[OperationLog]:
|
||
"""分页查询操作日志。
|
||
|
||
**按 id 倒序而非 create_time**:后者是秒级精度,同一秒内的多条记录排序
|
||
不确定,翻页会重复或遗漏(与用户列表同一理由)。
|
||
|
||
**排除 request_data 列**(TEXT,单行最大 60000 字节):列表页默认不
|
||
展示它,全列加载会让翻页负担随日志表增长线性放大;详情走 `get_by_id`
|
||
按需读取。
|
||
"""
|
||
stmt = _apply_filters(
|
||
select(OperationLog).options(defer(OperationLog.request_data)),
|
||
filters=filters,
|
||
module=module,
|
||
action=action,
|
||
)
|
||
stmt = stmt.order_by(OperationLog.id.desc()).offset(offset).limit(limit)
|
||
return list(session.execute(stmt).scalars())
|
||
|
||
|
||
def count_logs(
|
||
session: Session,
|
||
*,
|
||
filters: LogFilters,
|
||
module: str | None = None,
|
||
action: str | None = None,
|
||
) -> int:
|
||
"""统计符合条件的日志总数,供分页使用。"""
|
||
stmt = _apply_filters(
|
||
select(func.count(OperationLog.id)), filters=filters, module=module, action=action
|
||
)
|
||
return int(session.execute(stmt).scalar_one())
|
||
|
||
|
||
def _apply_filters(
|
||
stmt: Select,
|
||
*,
|
||
filters: LogFilters,
|
||
module: str | None,
|
||
action: str | None,
|
||
) -> Select:
|
||
"""应用筛选条件(公共部分复用 query_utils,与登录日志同口径)。"""
|
||
stmt = apply_log_filters(stmt, OperationLog, filters)
|
||
if module:
|
||
stmt = stmt.where(OperationLog.module == module)
|
||
if action:
|
||
stmt = stmt.where(OperationLog.action == action)
|
||
return stmt
|
||
|
||
|
||
def _clip(text: str | None, limit: int) -> str | None:
|
||
"""按字符数截断(长度常量与数据库列宽均按字符定义)。"""
|
||
return text[:limit] if text else None
|
||
|
||
|
||
def _is_sensitive_key(key: object) -> bool:
|
||
name = str(key).lower()
|
||
return any(part in name for part in _SENSITIVE_KEY_PARTS)
|
||
|
||
|
||
def _sanitize_sensitive(value: object) -> object:
|
||
"""递归抹除请求参数中的敏感字段值(dict 按键名整体替换、list 逐项递归)。"""
|
||
if isinstance(value, dict):
|
||
return {
|
||
key: _MASK if _is_sensitive_key(key) else _sanitize_sensitive(item)
|
||
for key, item in value.items()
|
||
}
|
||
if isinstance(value, list):
|
||
return [_sanitize_sensitive(item) for item in value]
|
||
return value
|
||
|
||
|
||
def _serialize_request_data(data: dict | None) -> str | None:
|
||
"""请求参数 → JSON 文本:两段脱敏 → 字节边界截断。
|
||
|
||
脱敏分两层,各司其职:
|
||
|
||
- **结构化抹除**(`_sanitize_sensitive`):按键名整体置 ``***``,
|
||
不依赖文本模式匹配 —— 密码含空格、引号、前导空白等形态都不受影响
|
||
- `core.logging.scrub` 作文本兜底:覆盖手机号、Bearer 等无法按键名
|
||
判断的值
|
||
|
||
截断必须在 **UTF-8 字节边界** 进行:request_data 列是 TEXT(65535 字节
|
||
上限),而 request_data 上限 60000 字节也是按字节定的;若按字符切片,
|
||
中文场景下字符数与字节数相差 3 倍同样可能超限;先编码再按字节切片
|
||
则可能把多字节字符切成两半产生非法序列。故顺序为:编码 → 字节切片 →
|
||
宽容解码丢弃残片。encode 用 ``errors="replace"`` 兜底孤立代理字符 ——
|
||
请求 JSON 里的 ``\\ud800`` 转义会解析为孤立代理,直接 encode 会抛
|
||
UnicodeEncodeError,被审计兜底逻辑吞掉后该请求的记录**静默丢失**。
|
||
"""
|
||
if not data:
|
||
return None
|
||
text = scrub(json.dumps(_sanitize_sensitive(data), ensure_ascii=False, default=str))
|
||
encoded = text.encode("utf-8", errors="replace")
|
||
if len(encoded) <= REQUEST_DATA_MAX_BYTES:
|
||
return text
|
||
return encoded[:REQUEST_DATA_MAX_BYTES].decode("utf-8", errors="ignore")
|