"""集中式用户数据变更审计模块 在 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/department-matching/test', # 只读匹配试算 '/api/department-matching/check', # 只读对照表核验 '/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