2026-10-28 11:14:17 +08:00

249 lines
9.5 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.

"""集中式用户数据变更审计模块
在 app.py 中通过 init_audit(app) 注册 before_request / after_request 钩子:
- 针对已登录用户的 POST/PUT/PATCH/DELETE 成功响应自动记录审计日志;
- 成功操作自动记录;权限拒绝和高风险失败同样记录结果状态;
- 明确非数据变更接口(登录/退出/库存导出/知识库 lint/合同生成/查询类)不记录;
- 请求若已通过 add_operation_log 生成可读业务日志,则不重复插入,也不追加技术路径;
- 敏感字段(password/token/secret 等)统一脱敏为 [已隐藏];
- IP 优先取 X-Forwarded-For 首个地址,其次 remote_addr。
所有审计动作失败均静默忽略,绝不影响业务响应。
"""
from flask import g, request, session
import json
import uuid
from services import db as _db
from services.audit_schema import ensure_audit_schema, infer_page, infer_action, classify_event, classify_risk, infer_object
# 明确非数据变更的接口(登录/退出/导出/知识库自检/合同生成/查询),不做审计
EXCLUDED_PATHS = frozenset({
'/api/login', # 登录(无会话上下文,且登录日志单独直接写入)
'/api/logout', # 退出(仅清理会话,不产生数据变更)
'/api/inventory/export', # 库存导出(生成文件,不修改业务数据)
'/api/knowledge/lint', # 知识库 lint(自动修复外部知识库索引,非业务数据)
'/api/contract/generate', # 合同生成(仅读取订单生成 Excel 文件,不写业务数据)
'/api/domestic/orders/by_ids', # 采购订单查询(合同弹窗预览用)
'/api/contract/supplier_groups', # 供货商分组查询(合同预览用)
'/api/bom_packaging/save', # 计划预测主保存的附属同步,避免一次操作重复记日志
})
# 敏感字段:精确匹配或 *_password 后缀
SENSITIVE_KEYWORDS = frozenset({
'password', 'new_password', 'confirm_password', 'token',
'access_token', 'refresh_token', 'secret', 'api_key',
'authorization', 'cookie',
})
# 详情长度上限(字符)
DETAIL_MAX_LEN = 12000
AUDIT_METHODS = frozenset({'POST', 'PUT', 'PATCH', 'DELETE'})
# 业务日志 action 名称(供日志页面展示/筛选)
ACTION_DATA_CHANGE = '数据变更'
def _is_sensitive_key(key):
k = str(key).lower()
return k in SENSITIVE_KEYWORDS or k.endswith('_password')
def mask_sensitive(data):
"""递归脱敏:敏感 key 的值替换为 [已隐藏]。"""
if isinstance(data, dict):
return {
k: ('[已隐藏]' if _is_sensitive_key(k) else mask_sensitive(v))
for k, v in data.items()
}
if isinstance(data, list):
return [mask_sensitive(v) for v in data]
return data
def _client_ip():
"""优先取 X-Forwarded-For 首个地址,其次 remote_addr。"""
xff = request.headers.get('X-Forwarded-For', '')
if xff:
first = xff.split(',')[0].strip()
if first:
return first
return request.remote_addr or ''
def _brief_value(value):
"""把提交值压缩为适合日志阅读的短文本。"""
if isinstance(value, list):
return f'{len(value)}项'
if isinstance(value, dict):
return f'{len(value)}个字段'
if value in (None, ''):
return '(空)'
text = str(value)
return text if len(text) <= 120 else text[:120] + '…'
def _brief_mapping(data):
masked = mask_sensitive(data)
labels={'order_filter':'筛选条件','filters':'筛选条件'}
parts=[]
for key,value in masked.items():
label=labels.get(key,key)
if isinstance(value,dict) and not value: text='无'
elif isinstance(value,list) and not value: text='无'
else:text=_brief_value(value)
parts.append(f'{label}:{text}')
return ';'.join(parts)
def _submitted_content():
"""提取并脱敏提交内容;嵌套对象只显示数量,避免日志堆满原始 JSON。"""
data = request.get_json(silent=True)
if isinstance(data, dict):
return _brief_mapping(data)
if isinstance(data, list):
return f'列表:{len(data)}项'
parts = []
form = request.form.to_dict() if request.form else {}
if form:
parts.append('表单:' + _brief_mapping(form))
if request.files:
names = []
for f in request.files.values():
filename = getattr(f, 'filename', '')
if filename:
names.append(filename)
if names:
parts.append('文件名: ' + ', '.join(names))
return '; '.join(parts)
def _audit_context_text():
"""构建通用的简明审计文本。"""
content = _submitted_content()
text = f'{request.method} {request.path}'
if content:
text += f';{content}'
if len(text) > DETAIL_MAX_LEN:
text = text[:DETAIL_MAX_LEN] + '…(详情超长已截断)'
return text
def _is_read_only_request():
path=request.path.lower().rstrip('/')
if path in EXCLUDED_PATHS:return True
if any(path.endswith(s) for s in ('/list','/column-values','/options','/stats','/summary','/detail','/reports')):return True
if any(token in path for token in ('/search','/query','/preview','/by_ids')):return True
if path in ('/api/ai/ask','/api/planning/column-widths/save'):return True
return False
def _should_record():
if request.method not in AUDIT_METHODS:
return False
# 仅审计已登录用户
if not getattr(g, 'audit_user_id', None):
return False
if _is_read_only_request():
return False
return True
def _response_success(response):
"""成功判定:HTTP < 400,且(无 code 字段 或 code 为 0/200)。"""
if response.status_code >= 400:
return False
try:
body = response.get_json(silent=True)
except Exception:
body = None
if isinstance(body, dict) and 'code' in body:
return str(body.get('code')) in ('0', '200')
return True
def _audit_result(response):
if response.status_code in (401, 403): return 'denied'
try:
body = response.get_json(silent=True)
except Exception:
body = None
if isinstance(body, dict) and str(body.get('code')) in ('401', '403'):
return 'denied'
return 'success' if _response_success(response) else 'failed'
def _high_risk_failure(response):
if response.status_code in (401, 403): return True
path = request.path.lower()
return any(token in path for token in ('delete','rollback','permission','audit','confirm_order','update_price'))
def _insert_audit_log(response):
ensure_audit_schema()
data = mask_sensitive(request.get_json(silent=True))
object_type, object_id = infer_object(data, request.path)
result = _audit_result(response)
role = session.get('role')
position = session.get('position')
if g.audit_user_id and not position:
rows = _db.query("SELECT position FROM dashboard_users WHERE id=%s LIMIT 1", (g.audit_user_id,))
position = rows[0].get('position') if isinstance(rows, (list, tuple)) and rows else ''
action = ACTION_DATA_CHANGE
log_id = _db.query("""INSERT INTO dashboard_logs
(user_id,action,detail,ip_address,page,field,event_type,result_status,risk_level,
object_type,object_id,new_values,request_method,request_path,request_id,user_agent,
role_snapshot,position_snapshot,http_status)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""",
(g.audit_user_id,action,_audit_context_text(),_client_ip(),infer_page(request.path,action),None,
classify_event(action,request.path,request.method),result,classify_risk(action,'',request.path),
object_type or None,object_id or None,json.dumps(data,ensure_ascii=False,default=str) if data is not None else None,
request.method,request.path,g.audit_request_id,request.headers.get('User-Agent','')[:500],
role,position or None,response.status_code))
try:
from services.audit_governance import finalize_log
finalize_log(log_id)
except Exception:
pass
def init_audit(app):
"""注册集中式审计钩子到 Flask app。"""
@app.before_request
def _audit_before():
g.audit_user_id = session.get('user_id')
g.audit_log_ids = []
g.audit_request_id = uuid.uuid4().hex
@app.after_request
def _audit_after(response):
try:
if not _should_record():
return response
success = _response_success(response)
log_ids = getattr(g, 'audit_log_ids', None)
if log_ids and not success and _high_risk_failure(response):
# 仅对失败/拒绝的高风险业务日志补充最终状态;成功业务日志已结构化写入。
ensure_audit_schema()
placeholders = ','.join(['%s'] * len(log_ids))
result = _audit_result(response)
_db.query(f"UPDATE dashboard_logs SET result_status=%s,http_status=%s WHERE id IN ({placeholders})",
tuple([result, response.status_code] + list(log_ids)))
try:
from services.audit_governance import finalize_log
for log_id in log_ids: finalize_log(log_id)
except Exception:
pass
elif not log_ids and (success or _high_risk_failure(response)):
_insert_audit_log(response)
except Exception:
# 审计失败绝不干扰业务响应
pass
return response
return app