249 lines
9.5 KiB
Python
249 lines
9.5 KiB
Python
"""集中式用户数据变更审计模块
|
||
|
||
在 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
|