fix(security): 多租户隔离全量修复 security-fix multi-tenant (OpenCode审查P0)

- bot_bridge 18数据端点全部 entity_id 隔离(Depends(get_entity_id)/body),/ping /risk-levels 豁免
- alert_rules 11端点 entity_id 隔离 + KPIAlert/DynamicThresholdCache 写入 entity_id
- reports 17端点隔离 + generate_report 写 ReportHistory.entity_id + history 按 entity 过滤
- ai_analysis 移除硬编码默认key,改 _require_deepseek_key() 强制 env 缺失 503
- budget auto-decompose 硬编码 entity_id==1 改请求 entity
- kpis update_kpi 加 UPDATE_KPI_WHITELIST 白名单(status/important_flag 不可越权改)
- data_quality 收敛:删 MySQL JSON 版 _run_rule_checks,check-governance 复用 _run_governance_checks(SQLite 兼容)
- _eval_threshold invert 参数修复(>=↔< 等取反),red 分支不传 invert 保持行为
- 新增 test_security_multitenant.py 13条(bot_bridge/alert_rules/reports 隔离 + invert + SQLite governance)
- models 6表加 entity_id 列;生产库已 ALTER + 按真实归属回填(kpi_alerts 472行中216行属entity≠1)
This commit is contained in:
Hermes CI Fix
2026-08-31 10:14:22 +08:00
parent 72072dda8c
commit 74dc9baff5
11 changed files with 560 additions and 348 deletions
+9 -148
View File
@@ -341,151 +341,6 @@ RULES_META = {
DETAIL_LIMIT = 10 # 每条规则detail最多列出的条数(避免响应过大)
def _run_rule_checks(db: Session, entity_id: int = 0):
"""执行7条DAMA治理规则,返回 issues 列表。entity_id=0 表示全部实体。"""
entity_filter = " AND cp.entity_id = :eid" if entity_id else ""
issues = []
# ── 规则1 单位校验 ──
rows = db.execute(text(
"SELECT cp.id, cp.entity_id, cp.amount, cp.source, cp.description "
"FROM cash_plans cp WHERE cp.amount > 10000" + entity_filter + " ORDER BY cp.amount DESC"
), {"eid": entity_id}).fetchall()
issues.append({
"rule": "unit_check", "level": "error",
"count": len(rows),
"detail": [f"plan#{r.id} 金额{r.amount}(疑似元)" for r in rows[:DETAIL_LIMIT]]
+ (["…等%d" % len(rows)] if len(rows) > DETAIL_LIMIT else []),
})
# ── 规则2 重复预警(同plan_id多条pending应收预警)──
if entity_id:
dup_sql = text(
"SELECT JSON_EXTRACT(a.suggestion, '$.plan_id') AS pid, COUNT(*) c, MAX(p.entity_id) eid "
"FROM kpi_alerts a JOIN cash_plans p ON p.id = JSON_EXTRACT(a.suggestion, '$.plan_id') "
"WHERE a.alert_type='cash_plan' AND a.status='pending' AND JSON_VALID(a.suggestion) "
"AND a.suggestion LIKE '%plan_id%' AND p.entity_id = :eid "
"GROUP BY pid HAVING c > 1 ORDER BY c DESC"
)
else:
dup_sql = text(
"SELECT JSON_EXTRACT(a.suggestion, '$.plan_id') AS pid, COUNT(*) c, MAX(p.entity_id) eid "
"FROM kpi_alerts a JOIN cash_plans p ON p.id = JSON_EXTRACT(a.suggestion, '$.plan_id') "
"WHERE a.alert_type='cash_plan' AND a.status='pending' AND JSON_VALID(a.suggestion) "
"AND a.suggestion LIKE '%plan_id%' "
"GROUP BY pid HAVING c > 1 ORDER BY c DESC"
)
dup_rows = db.execute(dup_sql, {"eid": entity_id}).fetchall()
issues.append({
"rule": "dup_alert", "level": "error",
"count": len(dup_rows),
"detail": [f"plan#{r.pid} 重复预警×{r.c}" for r in dup_rows[:DETAIL_LIMIT]]
+ (["…等%d个plan" % len(dup_rows)] if len(dup_rows) > DETAIL_LIMIT else []),
})
# ── 规则3 孤儿预警(plan_id指向不存在的cash_plans)──
orphan_sql = text(
"SELECT a.id, a.kpi_id, JSON_EXTRACT(a.suggestion, '$.plan_id') AS pid "
"FROM kpi_alerts a "
"WHERE a.alert_type='cash_plan' AND a.status='pending' AND JSON_VALID(a.suggestion) "
"AND a.suggestion LIKE '%plan_id%' "
"AND NOT EXISTS (SELECT 1 FROM cash_plans p WHERE p.id = JSON_EXTRACT(a.suggestion, '$.plan_id')) "
"ORDER BY a.id LIMIT 200"
)
orphan_rows = db.execute(orphan_sql).fetchall()
issues.append({
"rule": "orphan_check", "level": "error",
"count": len(orphan_rows),
"detail": [f"预警#{r.id}(kpi#{r.kpi_id}) → plan#{r.pid} 不存在" for r in orphan_rows[:DETAIL_LIMIT]]
+ (["…等%d" % len(orphan_rows)] if len(orphan_rows) > DETAIL_LIMIT else []),
})
# ── 规则4 虚拟污染(source含test/虚拟标识)──
rows = db.execute(text(
"SELECT cp.id, cp.entity_id, cp.source, cp.description FROM cash_plans cp "
"WHERE cp.source LIKE '%test%' OR cp.source LIKE '%虚拟%' OR cp.source LIKE '%demo%'"
+ entity_filter + " ORDER BY cp.id LIMIT 200"
), {"eid": entity_id}).fetchall()
issues.append({
"rule": "virtual_pollution", "level": "error",
"count": len(rows),
"detail": [f"plan#{r.id} source={r.source}" for r in rows[:DETAIL_LIMIT]]
+ (["…等%d" % len(rows)] if len(rows) > DETAIL_LIMIT else []),
})
# ── 规则5 实体归属(kpi_values.entity_id != kpi_definitions.entity_id)──
if entity_id:
ent_sql = text(
"SELECT v.id, v.kpi_id, d.kpi_code, v.entity_id AS v_eid, d.entity_id AS d_eid "
"FROM kpi_values v JOIN kpi_definitions d ON v.kpi_id = d.id "
"WHERE v.entity_id != d.entity_id AND v.entity_id = :eid ORDER BY v.id LIMIT 200"
)
else:
ent_sql = text(
"SELECT v.id, v.kpi_id, d.kpi_code, v.entity_id AS v_eid, d.entity_id AS d_eid "
"FROM kpi_values v JOIN kpi_definitions d ON v.kpi_id = d.id "
"WHERE v.entity_id != d.entity_id ORDER BY v.id LIMIT 200"
)
ent_rows = db.execute(ent_sql, {"eid": entity_id}).fetchall()
issues.append({
"rule": "entity_check", "level": "error",
"count": len(ent_rows),
"detail": [f"值#{r.id} {r.kpi_code} 实体{r.v_eid}≠定义实体{r.d_eid}" for r in ent_rows[:DETAIL_LIMIT]]
+ (["…等%d" % len(ent_rows)] if len(ent_rows) > DETAIL_LIMIT else []),
})
# ── 规则6 KPI完整性(active KPI无任何值)──
if entity_id:
comp_sql = text(
"SELECT d.id, d.kpi_code, d.kpi_name FROM kpi_definitions d "
"WHERE d.status='active' AND d.entity_id = :eid "
"AND NOT EXISTS (SELECT 1 FROM kpi_values v WHERE v.kpi_id = d.id) ORDER BY d.id LIMIT 300"
)
else:
comp_sql = text(
"SELECT d.id, d.kpi_code, d.kpi_name FROM kpi_definitions d "
"WHERE d.status='active' "
"AND NOT EXISTS (SELECT 1 FROM kpi_values v WHERE v.kpi_id = d.id) ORDER BY d.id LIMIT 300"
)
comp_rows = db.execute(comp_sql, {"eid": entity_id}).fetchall()
issues.append({
"rule": "kpi_completeness", "level": "warning",
"count": len(comp_rows),
"detail": [f"{r.kpi_code} {r.kpi_name}(无值)" for r in comp_rows[:DETAIL_LIMIT]]
+ (["…等%d个KPI" % len(comp_rows)] if len(comp_rows) > DETAIL_LIMIT else []),
})
# ── 规则7 勾稽验证(预算月度合计 vs 年度目标差异>20%)──
if entity_id:
recon_sql = text(
"SELECT d.kpi_code, d.kpi_name, d.target_yearly, "
"SUM(b.budget_value) AS monthly_sum, "
"ROUND((SUM(b.budget_value) - d.target_yearly) / d.target_yearly * 100, 1) AS diff_pct "
"FROM kpi_definitions d JOIN budget_plans b ON b.kpi_id = d.id "
"WHERE d.status='active' AND d.target_yearly > 0 AND d.entity_id = :eid "
"GROUP BY d.id HAVING ABS(diff_pct) > 20 ORDER BY ABS(diff_pct) DESC LIMIT 200"
)
else:
recon_sql = text(
"SELECT d.kpi_code, d.kpi_name, d.target_yearly, "
"SUM(b.budget_value) AS monthly_sum, "
"ROUND((SUM(b.budget_value) - d.target_yearly) / d.target_yearly * 100, 1) AS diff_pct "
"FROM kpi_definitions d JOIN budget_plans b ON b.kpi_id = d.id "
"WHERE d.status='active' AND d.target_yearly > 0 "
"GROUP BY d.id HAVING ABS(diff_pct) > 20 ORDER BY ABS(diff_pct) DESC LIMIT 200"
)
recon_rows = db.execute(recon_sql, {"eid": entity_id}).fetchall()
issues.append({
"rule": "reconciliation", "level": "warning",
"count": len(recon_rows),
"detail": [f"{r.kpi_code} 预算合计{round(r.monthly_sum, 1)} vs 年度目标{r.target_yearly} 差异{r.diff_pct}%" for r in recon_rows[:DETAIL_LIMIT]]
+ (["…等%d个KPI" % len(recon_rows)] if len(recon_rows) > DETAIL_LIMIT else []),
})
return issues
@router.get("/check-governance")
def check_governance(
entity_id: Optional[int] = Query(0, description="实体ID: 0=全部, 1=酣客, 2=博海"),
@@ -495,10 +350,16 @@ def check_governance(
评分规则: 满分100,error级规则每条扣10分,warning级规则每条扣5分,
每条规则最多扣一次分(按规则是否命中,不按count累扣),最低0分。
"""
issues = _run_rule_checks(db, entity_id or 0)
# 计算评分
收敛说明(2026-08-31 安全修复 P1-2):规则执行统一复用
_run_governance_checksPython 解析 suggestion JSONSQLite 兼容),
删除原 _run_rule_checksMySQL JSON_EXTRACT/JSON_VALID 版,SQLite 不兼容)。
本端点保留原评分口径与响应结构(governance-check 端点使用新的扣分口径)。
"""
gov = _run_governance_checks(db, entity_id or 0)
issues = gov["issues"]
# 计算评分(check-governance 原口径:error 扣10 / warning 扣5,每规则最多扣一次)
score = 100
for item in issues:
if item["count"] > 0: