feat(analyst): Implement audit logging for query denial and clarification

- Enhanced the `AnalystAgent` class to include an `_audit_terminal` method for logging query denials, clarifications, and errors, ensuring compliance and traceability.
- Updated error handling paths to call the new audit method, capturing relevant details such as question, user authentication, and SQL context.
- Introduced new validation checks in `sql_guard.py` to enforce ownership filters for sensitive queries, improving security measures.
- Added unit tests to verify the correct logging behavior and ownership filter enforcement, ensuring robust functionality.

This update significantly strengthens the auditing capabilities of the analyst agent, enhancing security and compliance in query handling.
This commit is contained in:
2026-09-11 14:45:16 +08:00
parent aea97a243c
commit 0fb7d34d7a
19 changed files with 376 additions and 75 deletions
+59 -5
View File
@@ -85,14 +85,16 @@ class AnalystAgent:
try:
domain = assert_analyst_query_access(auth)
except AnalystAuthError as exc:
return self._deny(exc.error_code, exc.message, trace_id)
resp = self._deny(exc.error_code, exc.message, trace_id)
return self._audit_terminal(question, auth, session_id, trace_id, resp)
scope: list[str] = resolve_analyst_scope(auth, domain, self.repo)
# 1) 指标消歧(N-01)
amb = self._detect_ambiguity(question)
if amb is not None:
return self._clarify(amb, trace_id)
resp = self._clarify(amb, trace_id)
return self._audit_terminal(question, auth, session_id, trace_id, resp)
# 2) 模板填参(D-06)或 LLM 生成 SQL
template_key: str | None = None
@@ -110,7 +112,10 @@ class AnalystAgent:
try:
vres = validate(sql_text, domain, scope)
except SqlGuardError as exc:
return self._deny(exc.error_code, exc.message, trace_id, domain)
resp = self._deny(exc.error_code, exc.message, trace_id, domain)
return self._audit_terminal(
question, auth, session_id, trace_id, resp, sql=sql_text
)
# 4) 执行(缓存优先)
perm_fp = self.cache.permission_fingerprint(auth.subject_id, domain, scope)
@@ -128,7 +133,10 @@ class AnalystAgent:
latency = int((time.time() - t0) * 1000)
self.cache.set_result(perm_fp, sql_text, (exec_result, data_as_of), vres.tables)
except Exception as exc: # noqa: BLE001
return self._error(f"SQL 执行失败:{exc}", trace_id)
resp = self._error(f"SQL 执行失败:{exc}", trace_id)
return self._audit_terminal(
question, auth, session_id, trace_id, resp, sql=sql_text
)
table = TableData(columns=exec_result["columns"], rows=exec_result["rows"])
empty_state = classify_empty(exec_result["rows"], sql_text)
@@ -143,7 +151,10 @@ class AnalystAgent:
)
cost_est += estimate_cost(g_usage)
except Exception as exc: # noqa: BLE001
return self._error(f"解读生成失败:{exc}", trace_id)
resp = self._error(f"解读生成失败:{exc}", trace_id)
return self._audit_terminal(
question, auth, session_id, trace_id, resp, sql=sql_text
)
status = "degrade" if (guard_result is not None and not guard_result.passed) else "success"
if status == "degrade":
answer = "解读校验未通过,请以下方表格数据为准。"
@@ -411,6 +422,49 @@ class AnalystAgent:
except Exception: # noqa: BLE001
pass
def _audit_terminal(
self,
question: str,
auth: AnalystAuthContext,
session_id: str,
trace_id: str,
resp: AnalystResponse,
*,
sql: str = "",
) -> AnalystResponse:
"""阻断/clarify/error 路径留痕(TEST-AN-001 缺口 D)。"""
sql_text = (sql or "").strip()
sql_hash = self.cache.sql_hash(sql_text) if sql_text else "blocked"
summary = {
"status": resp.status,
"error_code": resp.error_code,
"source": "blocked",
}
try:
self.repo.log_query(
session_id=session_id,
trace_id=trace_id,
staff_id=auth.subject_id,
nl_question=question,
generated_sql=sql_text,
sql_hash=sql_hash or None,
row_count=0,
exec_status="blocked",
result_summary=summary,
exec_latency_ms=0,
has_disclaimer=False,
)
self.repo.log_audit(
trace_id=trace_id,
event_type="analyst_query",
actor_id=auth.subject_id,
decision=resp.status,
input_summary={"question": question, "error_code": resp.error_code},
)
except Exception: # noqa: BLE001
pass
return resp
def build_graph(agent: AnalystAgent):
"""LangGraph StateGraph 适配(架构对齐用;核心逻辑仍在 run())。"""
+37 -9
View File
@@ -33,6 +33,16 @@ CUSTOMER_TABLES = {
"core_holding", "core_trade", "core_cash_flow",
}
# 含 customer_id 维度的 Agent 表(须与 Core 客户表同等行级约束)
AGENT_ROW_SCOPED_TABLES = {
"risk_alert", "customer_profile_l1", "customer_profile_l2", "customer_profile_l3",
}
ROW_SCOPED_TABLES = CUSTOMER_TABLES | AGENT_ROW_SCOPED_TABLES
# 运营/聚合域禁止查明细(SELECT * 不含 customer_id 子串时仍可能泄露)
OPS_FORBIDDEN_DETAIL_TABLES = AGENT_ROW_SCOPED_TABLES
# 敏感列(存储层已脱敏;此处供下游解读/日志二次校验,避免引用明文)
SENSITIVE_COLUMNS = {
"mobile", "phone", "id_card", "id_no", "idcard", "bank_card", "card_no",
@@ -102,6 +112,16 @@ def _ops_has_customer_detail(sql: str) -> bool:
return "customer_id" in cleaned
def _has_ownership_filter(sql: str) -> bool:
"""WHERE/JOIN ON 中是否存在 customer_id 或 advisor_id 的 = / IN 过滤(非 SELECT 列名占位)。"""
low = sql.lower()
if re.search(r"\bwhere\b[\s\S]*?\b(customer_id|advisor_id)\s*(=|in\b)", low):
return True
if re.search(r"\bjoin\b[\s\S]*?\bon\b[\s\S]*?\b(customer_id|advisor_id)\s*=", low):
return True
return False
def validate(sql: str, domain: str, scope_customer_ids: list[str] | None = None) -> ValidationResult:
"""对生成/改写的 SQL 做只读 + 表白名单 + 域规则校验。
@@ -118,7 +138,11 @@ def validate(sql: str, domain: str, scope_customer_ids: list[str] | None = None)
raise SqlGuardError("SQL_NOT_SELECT", "仅允许 SELECT 只读查询")
low = sql.lower()
for kw in FORBIDDEN_KEYWORDS:
if kw in low:
if " " in kw or "(" in kw:
if kw in low:
raise SqlGuardError("SQL_FORBIDDEN", f"检测到危险关键字:{kw}")
continue
if re.search(rf"\b{re.escape(kw)}\b", low):
raise SqlGuardError("SQL_FORBIDDEN", f"检测到危险关键字:{kw}")
# 2) 表白名单(CTE 别名除外)
@@ -132,6 +156,11 @@ def validate(sql: str, domain: str, scope_customer_ids: list[str] | None = None)
# 3) 域规则
if domain == "ops" or domain == "aggregate":
if any(t in OPS_FORBIDDEN_DETAIL_TABLES for t in tables):
raise SqlGuardError(
"AUTH_403_SCOPE",
"运营角色不可查预警台账或画像明细表",
)
if _ops_has_customer_detail(sql):
raise SqlGuardError("AUTH_403_SCOPE", "运营角色仅可查客户维度之上的聚合结果")
if extract_customer_literals(sql):
@@ -143,11 +172,10 @@ def validate(sql: str, domain: str, scope_customer_ids: list[str] | None = None)
out = [c for c in literals if c.upper() not in {s.upper() for s in scope}]
if out:
raise SqlGuardError("AUTH_403_NOT_ASSIGNED", f"无权访问客户:{', '.join(out)}")
# 涉及客户表的查询必须显式带归属过滤(customer_id / advisor_id),否则视为未收敛范围
touches_customer = any(t in CUSTOMER_TABLES for t in tables)
if touches_customer and "customer_id" not in low and "advisor_id" not in low:
touches_row_scoped = any(t in ROW_SCOPED_TABLES for t in tables)
if touches_row_scoped and not _has_ownership_filter(sql):
raise SqlGuardError("AUTH_403_SCOPE", "涉及客户数据的查询必须包含归属过滤条件")
result.has_customer_detail = touches_customer
result.has_customer_detail = touches_row_scoped
elif domain == "self":
literals = extract_customer_literals(sql)
@@ -157,10 +185,10 @@ def validate(sql: str, domain: str, scope_customer_ids: list[str] | None = None)
out = [c for c in literals if c.upper() not in {s.upper() for s in scope}]
if out:
raise SqlGuardError("AUTH_403_NOT_OWNER", f"仅能查询本人数据,无权访问:{', '.join(out)}")
touches_customer = any(t in CUSTOMER_TABLES for t in tables)
if touches_customer and "customer_id" not in low:
raise SqlGuardError("AUTH_403_SCOPE", "客户问数涉及客户表时必须带本人 customer_id 过滤")
result.has_customer_detail = touches_customer
touches_row_scoped = any(t in ROW_SCOPED_TABLES for t in tables)
if touches_row_scoped and not _has_ownership_filter(sql):
raise SqlGuardError("AUTH_403_SCOPE", "客户问数涉及客户维度表时必须带本人 customer_id 过滤")
result.has_customer_detail = touches_row_scoped
elif domain == "risk" or domain == "risk_officer":
# 台账全量 + 客户只读:允许白名单内全部表