fix(security): ai_analysis多租户隔离缺口 + dashboard预警计数 + bot_kpis归属 + cost账套过滤 + kpis create白名单 (OpenCode审查20260907)

- ai_analysis: generate_rule_suggestions pending预警按entity过滤; kpi_analysis归属校验404;
  dashboard-analysis-stream/ask/review-plans 补 get_entity_id + KPI/ActionPlan/KPIAlert 全按账套过滤;
  stream cache key 含 entity_id (原跨企业串缓存)
- dashboard: summary alert_count 补 KPIAlert.entity_id 过滤 (kpi_total 已有)
- bot_kpis: update_bot_kpi_value 归属校验 404 (原任意登录可改他企 bot KPI)
- cost: ActualCost/StandardCost 确认多企业共用 → cost_overview/variance/breakdown/dashboard
  补 entity 过滤(engine 函数加 entity_id 透传); dashboard total_cost 既有 KeyError 修复
- kpis: create_kpi 字段白名单 (仿 UPDATE_KPI_WHITELIST+kpi_code), 拦截 status/important_flag 注入
- tests: 新增 test_multitenant_ai_20260907.py 11条越权用例 (跨entity 404/空返回断言)
- 全量 pytest: 745 passed / 10 failed(与基线 HEAD 一致, 存量问题非本次引入) / 1 xfailed
This commit is contained in:
Hermes CI Fix
2026-09-07 10:22:24 +08:00
parent d71c7fef67
commit ac645a56ee
7 changed files with 410 additions and 53 deletions
+54 -17
View File
@@ -156,10 +156,16 @@ def generate_rule_suggestions(db: Session, entity_id: int,
"kpi_id": b.kpi_id, "period": period, "budget_value": round(actual, 2),
})
# 4. pending预警 → 建行动方案
alerts = db.query(KPIAlert).filter(KPIAlert.status == "pending").all()
# 4. pending预警 → 建行动方案(多租户隔离 2026-09-07: 只取本账套预警)
alerts = db.query(KPIAlert).filter(
KPIAlert.entity_id == entity_id,
KPIAlert.status == "pending",
).all()
for a in alerts:
k = db.query(KPIDefinition).filter(KPIDefinition.id == a.kpi_id).first()
k = db.query(KPIDefinition).filter(
KPIDefinition.id == a.kpi_id,
KPIDefinition.entity_id == entity_id,
).first()
kpi_name = k.kpi_name if k else f"KPI#{a.kpi_id}"
title = f"处理预警:{kpi_name} {a.alert_message[:30]}"
content = f"存在待处理预警({a.alert_level}级):{a.alert_message}。建议建立行动方案跟进。"
@@ -291,8 +297,11 @@ async def dashboard_analysis(role: str = Query("ceo"), db: Session = Depends(get
"unit": k.unit,
})
# 获取预警
alerts = db.query(KPIAlert).filter(KPIAlert.status == "pending").count()
# 获取预警(多租户隔离 2026-09-07: 只统计本账套)
alerts = db.query(KPIAlert).filter(
KPIAlert.entity_id == entity_id,
KPIAlert.status == "pending",
).count()
# 构建分析prompt
kpi_text = "\n".join([f"- {k['name']}({k['code']}): 目标={k['target']}, 实际={k['actual']}({k['period']}), 维度={k['dimension']}" for k in kpi_summary if k['actual'] is not None])
@@ -339,8 +348,12 @@ async def kpi_analysis(kpi_id: int, db: Session = Depends(get_db),
if cached:
return cached
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
kpi = db.query(KPIDefinition).filter(
KPIDefinition.id == kpi_id,
KPIDefinition.entity_id == entity_id,
).first()
if not kpi:
# 跨企业 kpi_id 同样 404(多租户隔离 2026-09-07)
raise HTTPException(404, "KPI不存在")
values = db.query(KPIValue).filter(KPIValue.kpi_id == kpi_id).order_by(KPIValue.period.asc()).all()
@@ -422,9 +435,11 @@ async def _stream_analysis(prompt: str):
@router.get("/dashboard-analysis-stream")
async def dashboard_analysis_stream(role: str = Query("ceo"), db: Session = Depends(get_db)):
async def dashboard_analysis_stream(role: str = Query("ceo"), db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""AI分析驾驶舱数据 — SSE流式输出"""
cache_key = f"dashboard_analysis:{role}"
# 多租户隔离 2026-09-07: cache key 补 entity_id(原跨企业串缓存)
cache_key = f"dashboard_analysis:{role}:{entity_id}"
cached = cache_get("ai", cache_key)
if cached:
# 缓存存在,直接以流的形式一次性返回
@@ -434,7 +449,10 @@ async def dashboard_analysis_stream(role: str = Query("ceo"), db: Session = Depe
yield "data: {\"text\": \"[DONE]\"}\n\n"
return StreamingResponse(cached_stream(), media_type="text/event-stream")
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
kpis = db.query(KPIDefinition).filter(
KPIDefinition.entity_id == entity_id,
KPIDefinition.status == "active",
).all()
kpi_summary = []
for k in kpis:
latest = db.query(KPIValue).filter(KPIValue.kpi_id == k.id).order_by(KPIValue.period.desc()).first()
@@ -443,7 +461,10 @@ async def dashboard_analysis_stream(role: str = Query("ceo"), db: Session = Depe
"target": k.target_value, "actual": latest.actual_value if latest else None,
"period": latest.period if latest else None, "unit": k.unit,
})
alerts = db.query(KPIAlert).filter(KPIAlert.status == "pending").count()
alerts = db.query(KPIAlert).filter(
KPIAlert.entity_id == entity_id,
KPIAlert.status == "pending",
).count()
kpi_text = "\n".join([f"- {k['name']}({k['code']}): 目标={k['target']}, 实际={k['actual']}({k['period']}), 维度={k['dimension']}" for k in kpi_summary if k['actual'] is not None])
role_label = {"ceo": "CEO(总经理)", "finance": "财务部", "business": "业务部"}.get(role, "管理层")
prompt = f"""我是一家公司的管理层,以下是当前管理会计系统的KPI数据和系统状态,请给出专业的分析和管理建议:
@@ -468,6 +489,7 @@ async def ask_question(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_auth),
entity_id: int = Depends(get_entity_id),
):
"""自然语言查询 — CEO问企业经营问题"""
body = await request.json()
@@ -476,8 +498,11 @@ async def ask_question(
if not question:
raise HTTPException(400, "请输入问题")
# 收集系统数据作为上下文
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
# 收集系统数据作为上下文(多租户隔离 2026-09-07: 只取本账套)
kpis = db.query(KPIDefinition).filter(
KPIDefinition.entity_id == entity_id,
KPIDefinition.status == "active",
).all()
kpi_context = []
for k in kpis:
latest = db.query(KPIValue).filter(KPIValue.kpi_id == k.id).order_by(KPIValue.period.desc()).first()
@@ -487,13 +512,21 @@ async def ask_question(
f"{' ⚠️' + alert.alert_level if alert else ''}"
)
# 获取改善计划
plans = db.query(ActionPlan).order_by(ActionPlan.created_at.desc()).limit(10).all()
# 获取改善计划(多租户隔离 2026-09-07: 只取本账套)
plans = db.query(ActionPlan).filter(
ActionPlan.entity_id == entity_id,
).order_by(ActionPlan.created_at.desc()).limit(10).all()
plan_context = [f"- {p.title}({p.assignee}, {p.status}, {p.progress}%)" for p in plans]
# 获取预警
red_alerts = db.query(KPIAlert).filter(KPIAlert.status == "pending", KPIAlert.alert_level == "red").count()
yellow_alerts = db.query(KPIAlert).filter(KPIAlert.status == "pending", KPIAlert.alert_level == "yellow").count()
red_alerts = db.query(KPIAlert).filter(
KPIAlert.entity_id == entity_id,
KPIAlert.status == "pending", KPIAlert.alert_level == "red",
).count()
yellow_alerts = db.query(KPIAlert).filter(
KPIAlert.entity_id == entity_id,
KPIAlert.status == "pending", KPIAlert.alert_level == "yellow",
).count()
system_context = f"""你是管理会计OS的AI助手,基于以下企业数据回答管理层问题。
@@ -525,9 +558,13 @@ async def ask_question(
async def review_plans(
db: Session = Depends(get_db),
current_user: User = Depends(require_auth),
entity_id: int = Depends(get_entity_id),
):
"""AI复盘改善行动计划执行效果"""
plans = db.query(ActionPlan).order_by(ActionPlan.created_at.asc()).all()
# 多租户隔离 2026-09-07: ActionPlan 按 entity_id 过滤(表 2026-08-31 已加列回填)
plans = db.query(ActionPlan).filter(
ActionPlan.entity_id == entity_id,
).order_by(ActionPlan.created_at.asc()).all()
if not plans:
return {"analysis": "暂无改善行动计划,无法复盘"}
+5
View File
@@ -6,6 +6,7 @@ from datetime import datetime
from app.database import get_db
from app.auth_middleware import require_auth
from app.deps import get_entity_id
from app.models import KPIDefinition, KPIValue
from app.risk_levels import risk_level
@@ -142,6 +143,7 @@ def update_bot_kpi_value(
kpi_id: int,
data: dict,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""更新Bot KPI当前值"""
kpi = db.query(KPIDefinition).filter(
@@ -150,6 +152,9 @@ def update_bot_kpi_value(
).first()
if not kpi:
raise HTTPException(404, "Bot KPI不存在")
# 多租户隔离 2026-09-07: 归属校验,禁止跨企业修改他企 Bot KPI
if kpi.entity_id != entity_id:
raise HTTPException(404, "Bot KPI不存在")
actual_value = data.get("actual_value")
if actual_value is None:
+36 -20
View File
@@ -170,6 +170,8 @@ def create_actual_cost(data: dict, db: Session = Depends(get_db),
# ============================================================
# ABC 作业成本
# 注:ABC 作业中心/分配为单实体演示数据(ab_activities/ab_allocations 全为 entity 1,
# 历史演示用途),OpenCode 多租户审查 2026-09-07 判定暂不强制按账套隔离;若启用多企业 ABC 需补 entity 过滤。
# ============================================================
@router.get("/abc/activities")
@@ -229,34 +231,41 @@ def list_allocations(period: Optional[str] = Query(None),
# ============================================================
@router.get("/overview")
def cost_overview(period: Optional[str] = Query(None)):
"""成本总览(总成本、结构占比、趋势)"""
def cost_overview(period: Optional[str] = Query(None),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""成本总览(总成本、结构占比、趋势)— 多租户隔离 2026-09-07"""
if period is None:
period = datetime.now().strftime("%Y-%m")
return get_cost_overview(period)
return get_cost_overview(period, entity_id=entity_id)
@router.get("/variance")
def cost_variance(product_code: str = Query(...),
period: Optional[str] = Query(None)):
"""量差价差分析"""
period: Optional[str] = Query(None),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""量差价差分析 — 多租户隔离 2026-09-07"""
if period is None:
period = datetime.now().strftime("%Y-%m")
return calc_product_variance(product_code, period)
return calc_product_variance(product_code, period, entity_id=entity_id)
@router.get("/breakdown")
def cost_breakdown(product_code: str = Query(...),
period: Optional[str] = Query(None)):
"""成本构成(料/工/费占比)"""
period: Optional[str] = Query(None),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""成本构成(料/工/费占比)— 多租户隔离 2026-09-07"""
if period is None:
period = datetime.now().strftime("%Y-%m")
return get_cost_breakdown(product_code, period)
return get_cost_breakdown(product_code, period, entity_id=entity_id)
@router.get("/comparison")
def cost_method_comparison(entity: str = Query("hanke")):
"""三种成本法对比分析:传统/变动/作业成本法 (CMA P1)"""
"""三种成本法对比分析:传统/变动/作业成本法 (CMA P1)
注:纯静态教学样例数据(无 DB 访问),不涉多租户泄漏——OpenCode 审查 2026-09-07 豁免标注"""
if entity == "hanke":
gross_revenue = 713 # 万
channel_rebate_rate = 0.828
@@ -317,35 +326,42 @@ def cost_method_comparison(entity: str = Query("hanke")):
@router.get("/dashboard")
def cost_dashboard(period: Optional[str] = Query(None)):
"""成本分析首页—汇总数据"""
def cost_dashboard(period: Optional[str] = Query(None),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""成本分析首页—汇总数据(多租户隔离 2026-09-07: ActualCost 按账套过滤)"""
if period is None:
period = datetime.now().strftime("%Y-%m")
overview = get_cost_overview(period)
overview = get_cost_overview(period, entity_id=entity_id)
# 获取所有产品列表
db = get_db().__next__()
# 获取所有产品列表(本账套)
db_session = db if db is not None else get_db().__next__()
try:
products = db.query(ActualCost.product_code, ActualCost.product_name).filter(
ActualCost.period == period
products = db_session.query(ActualCost.product_code, ActualCost.product_name).filter(
ActualCost.entity_id == entity_id,
ActualCost.period == period,
).distinct().all()
product_list = [{"code": p[0], "name": p[1]} for p in products]
# 各产品成本
product_costs = []
for code, name in products:
costs = db.query(ActualCost).filter(
costs = db_session.query(ActualCost).filter(
ActualCost.entity_id == entity_id,
ActualCost.product_code == code,
ActualCost.period == period,
).all()
total = round(sum(c.actual_cost for c in costs), 2)
product_costs.append({"product_code": code, "product_name": name, "total_cost": total})
finally:
db.close()
if db is None:
db_session.close()
return {
"period": period,
"overview": overview,
"products": product_list,
"total_cost": round(sum(p["total_cost"] for p in product_list) + overview.get("erp_cost", 0), 2) if product_list else 0,
# 修复既有bug(2026-09-07测试实证): 原对 product_list 求 p["total_cost"] 必 KeyError(产品列表无total_cost键)
# → 改为 product_costs(含各产品总额),有产品数据时端点不再 500
"total_cost": round(sum(p["total_cost"] for p in product_costs) + overview.get("erp_cost", 0), 2) if product_costs else 0,
}
+4 -1
View File
@@ -79,7 +79,10 @@ def get_dashboard_summary(role: str = Query("ceo"), period: str = Query("month")
return cached
kpi_total = db.query(func.count(KPIDefinition.id)).filter(
KPIDefinition.status == "active", KPIDefinition.entity_id == entity_id).scalar()
alert_count = db.query(func.count(KPIAlert.id)).filter(KPIAlert.status == "pending").scalar()
alert_count = db.query(func.count(KPIAlert.id)).filter(
KPIAlert.entity_id == entity_id,
KPIAlert.status == "pending",
).scalar()
dims = db.query(KPIDefinition.dimension, func.count(KPIDefinition.id)).filter(
KPIDefinition.status == "active", KPIDefinition.entity_id == entity_id
).group_by(KPIDefinition.dimension).all()
+7
View File
@@ -731,6 +731,13 @@ def create_kpi(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES, enti
raise HTTPException(422, detail={"message": "数据校验不通过", "errors": errs})
data["entity_id"] = entity_id # 账套隔离: 强制写入token企业 (2026-08-23 P1a)
data = apply_calc_type_inference(data)
# create 侧字段白名单(OpenCode 审查残留项修复 2026-09-07):
# 仿 update 白名单 + kpi_code(新建必填),拦截 status/important_flag/主键等敏感字段注入
# (update 2026-08-31 已有白名单;create 此前直接 KPIDefinition(**data) 展开,可被注入)
_create_allow = UPDATE_KPI_WHITELIST | {"kpi_code"}
for k in list(data.keys()):
if k != "entity_id" and k not in _create_allow:
data.pop(k, None)
kpi = KPIDefinition(**data)
db.add(kpi)
db.commit()