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
+64 -40
View File
@@ -9,8 +9,9 @@ from sqlalchemy import func, desc
from datetime import datetime
from typing import Optional
from app.database import get_db
from app.deps import get_entity_id
from app.models import (
User, StrategicMap, KPIDefinition, KPITemplate, KPIValue,
User, UserEntity, StrategicMap, KPIDefinition, KPITemplate, KPIValue,
DataSourceConfig, KPIAlert, OperationLog, NotificationChannel,
NotificationLog, RolePermission, ActionPlan, OrgNode,
StrategicMapVersion, MapObjective, Objective, KR,
@@ -92,20 +93,22 @@ def ping():
def bot_overview(
bot: dict = Depends(verify_bot_key),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""系统总览 — BOT首选入口"""
"""系统总览 — BOT首选入口(账套隔离 2026-08-31: 仅统计当前企业)"""
return {
"bot": bot,
"entity_id": entity_id,
"timestamp": datetime.now().isoformat(),
"stats": {
"kpis_total": db.query(func.count(KPIDefinition.id)).filter(KPIDefinition.status == "active").scalar() or 0,
"alerts_open": db.query(func.count(KPIAlert.id)).filter(KPIAlert.status == "pending").scalar() or 0,
"maps_total": db.query(func.count(StrategicMap.id)).scalar() or 0,
"budget_plans": db.query(func.count(BudgetPlan.id)).scalar() or 0,
"action_plans_pending": db.query(func.count(ActionPlan.id)).filter(ActionPlan.status.in_(["pending", "in_progress"])).scalar() or 0,
"data_sources": db.query(func.count(DataSourceConfig.id)).scalar() or 0,
"users": db.query(func.count(User.id)).scalar() or 0,
"org_nodes": db.query(func.count(OrgNode.id)).scalar() or 0,
"kpis_total": db.query(func.count(KPIDefinition.id)).filter(KPIDefinition.status == "active", KPIDefinition.entity_id == entity_id).scalar() or 0,
"alerts_open": db.query(func.count(KPIAlert.id)).filter(KPIAlert.status == "pending", KPIAlert.entity_id == entity_id).scalar() or 0,
"maps_total": db.query(func.count(StrategicMap.id)).filter(StrategicMap.entity_id == entity_id).scalar() or 0,
"budget_plans": db.query(func.count(BudgetPlan.id)).filter(BudgetPlan.entity_id == entity_id).scalar() or 0,
"action_plans_pending": db.query(func.count(ActionPlan.id)).filter(ActionPlan.entity_id == entity_id, ActionPlan.status.in_(["pending", "in_progress"])).scalar() or 0,
"data_sources": db.query(func.count(DataSourceConfig.id)).filter(DataSourceConfig.entity_id == entity_id).scalar() or 0,
"users": db.query(func.count(User.id)).join(UserEntity, UserEntity.user_id == User.id).filter(UserEntity.entity_id == entity_id).scalar() or 0,
"org_nodes": db.query(func.count(OrgNode.id)).filter(OrgNode.entity_id == entity_id).scalar() or 0,
}
}
@@ -120,8 +123,9 @@ def bot_kpis(
limit: int = Query(200, le=1000),
bot: dict = Depends(verify_bot_key),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
query = db.query(KPIDefinition).filter(KPIDefinition.status == status)
query = db.query(KPIDefinition).filter(KPIDefinition.status == status, KPIDefinition.entity_id == entity_id)
if dimension:
query = query.filter(KPIDefinition.dimension == dimension)
kpis = query.order_by(KPIDefinition.dimension, KPIDefinition.kpi_code).limit(limit).all()
@@ -154,9 +158,10 @@ def bot_kpi_history(
kpi_id: int, limit: int = Query(12, le=60),
bot: dict = Depends(verify_bot_key),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi:
if not kpi or kpi.entity_id != entity_id:
raise HTTPException(404, "KPI不存在")
values = db.query(KPIValue).filter(KPIValue.kpi_id == kpi_id)\
.order_by(KPIValue.period.desc()).limit(limit).all()
@@ -180,8 +185,9 @@ def bot_kpi_history(
def bot_maps(
bot: dict = Depends(verify_bot_key),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
maps = db.query(StrategicMap).order_by(StrategicMap.id.desc()).all()
maps = db.query(StrategicMap).filter(StrategicMap.entity_id == entity_id).order_by(StrategicMap.id.desc()).all()
result = []
for m in maps:
objectives = db.query(MapObjective).filter(MapObjective.map_id == m.id).all()
@@ -211,8 +217,9 @@ def bot_alerts(
limit: int = Query(50, le=200),
bot: dict = Depends(verify_bot_key),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
query = db.query(KPIAlert)
query = db.query(KPIAlert).filter(KPIAlert.entity_id == entity_id)
query = query.filter(KPIAlert.status == status)
if level:
query = query.filter(KPIAlert.alert_level == level)
@@ -240,8 +247,9 @@ def bot_budget_plans(
year: Optional[int] = Query(None),
bot: dict = Depends(verify_bot_key),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
query = db.query(BudgetPlan)
query = db.query(BudgetPlan).filter(BudgetPlan.entity_id == entity_id)
if year:
query = query.filter(BudgetPlan.budget_year == year)
plans = query.order_by(BudgetPlan.period.desc()).limit(200).all()
@@ -267,8 +275,9 @@ def bot_budget_plans(
def bot_standard_costs(
bot: dict = Depends(verify_bot_key),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
costs = db.query(StandardCost).filter(StandardCost.status == "active").limit(200).all()
costs = db.query(StandardCost).filter(StandardCost.status == "active", StandardCost.entity_id == entity_id).limit(200).all()
return {
"total": len(costs),
"items": [
@@ -292,8 +301,9 @@ def bot_actual_costs(
period: Optional[str] = Query(None),
bot: dict = Depends(verify_bot_key),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
query = db.query(ActualCost)
query = db.query(ActualCost).filter(ActualCost.entity_id == entity_id)
if period:
query = query.filter(ActualCost.period == period)
costs = query.order_by(ActualCost.period.desc()).limit(200).all()
@@ -321,8 +331,9 @@ def bot_actions(
status: Optional[str] = Query(None),
bot: dict = Depends(verify_bot_key),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
query = db.query(ActionPlan)
query = db.query(ActionPlan).filter(ActionPlan.entity_id == entity_id)
if status:
query = query.filter(ActionPlan.status == status)
plans = query.order_by(ActionPlan.priority, ActionPlan.id.desc()).limit(100).all()
@@ -348,8 +359,9 @@ def bot_actions(
def bot_org(
bot: dict = Depends(verify_bot_key),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
nodes = db.query(OrgNode).order_by(OrgNode.level, OrgNode.sort_order).all()
nodes = db.query(OrgNode).filter(OrgNode.entity_id == entity_id).order_by(OrgNode.level, OrgNode.sort_order).all()
return {
"total": len(nodes),
"items": [
@@ -370,8 +382,9 @@ def bot_org(
def bot_data_sources(
bot: dict = Depends(verify_bot_key),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
sources = db.query(DataSourceConfig).all()
sources = db.query(DataSourceConfig).filter(DataSourceConfig.entity_id == entity_id).all()
return {
"total": len(sources),
"items": [
@@ -394,8 +407,11 @@ def bot_data_sources(
def bot_users(
bot: dict = Depends(verify_bot_key),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
users = db.query(User).all()
# 用户是全局实体,通过 user_entities 授权表按企业过滤(安全修复 2026-08-31)
users = db.query(User).join(UserEntity, UserEntity.user_id == User.id)\
.filter(UserEntity.entity_id == entity_id).all()
return {
"total": len(users),
"items": [
@@ -414,20 +430,21 @@ def bot_query(
q: str = Query("overview", description="overview/kpis/alerts/maps/budget/cost/actions/all"),
bot: dict = Depends(verify_bot_key),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""统一查询入口 — BOT用这个一次拿完需要的数据"""
result = {"bot": bot["name"], "role": bot["role"], "timestamp": datetime.now().isoformat()}
"""统一查询入口 — BOT用这个一次拿完需要的数据(账套隔离 2026-08-31"""
result = {"bot": bot["name"], "role": bot["role"], "entity_id": entity_id, "timestamp": datetime.now().isoformat()}
if q in ("overview", "all"):
result["overview"] = {
"kpis": db.query(func.count(KPIDefinition.id)).filter(KPIDefinition.status == "active").scalar() or 0,
"alerts_open": db.query(func.count(KPIAlert.id)).filter(KPIAlert.status == "pending").scalar() or 0,
"maps": db.query(func.count(StrategicMap.id)).scalar() or 0,
"budget_plans": db.query(func.count(BudgetPlan.id)).scalar() or 0,
"kpis": db.query(func.count(KPIDefinition.id)).filter(KPIDefinition.status == "active", KPIDefinition.entity_id == entity_id).scalar() or 0,
"alerts_open": db.query(func.count(KPIAlert.id)).filter(KPIAlert.status == "pending", KPIAlert.entity_id == entity_id).scalar() or 0,
"maps": db.query(func.count(StrategicMap.id)).filter(StrategicMap.entity_id == entity_id).scalar() or 0,
"budget_plans": db.query(func.count(BudgetPlan.id)).filter(BudgetPlan.entity_id == entity_id).scalar() or 0,
}
if q in ("kpis", "all"):
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").limit(100).all()
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active", KPIDefinition.entity_id == entity_id).limit(100).all()
result["kpis"] = [
{"id": k.id, "name": k.kpi_name, "code": k.kpi_code,
"dimension": k.dimension, "target": _float(k.target_value), "unit": k.unit}
@@ -435,7 +452,7 @@ def bot_query(
]
if q in ("alerts", "all"):
alerts = db.query(KPIAlert).filter(KPIAlert.status == "pending")\
alerts = db.query(KPIAlert).filter(KPIAlert.status == "pending", KPIAlert.entity_id == entity_id)\
.order_by(KPIAlert.created_at.desc()).limit(20).all()
result["alerts"] = [
{"id": a.id, "level": a.alert_level, "message": a.alert_message,
@@ -444,7 +461,7 @@ def bot_query(
]
if q in ("maps", "all"):
maps = db.query(StrategicMap).limit(10).all()
maps = db.query(StrategicMap).filter(StrategicMap.entity_id == entity_id).limit(10).all()
result["maps"] = [
{"id": m.id, "title": m.title, "status": m.status,
"version": m.version, "created_at": _safe_iso(m.created_at)}
@@ -452,7 +469,7 @@ def bot_query(
]
if q in ("budget", "all"):
plans = db.query(BudgetPlan).limit(50).all()
plans = db.query(BudgetPlan).filter(BudgetPlan.entity_id == entity_id).limit(50).all()
result["budget"] = [
{"id": p.id, "period": p.period, "budget_value": _float(p.budget_value),
"year": p.budget_year, "month": p.budget_month, "status": p.status,
@@ -461,7 +478,7 @@ def bot_query(
]
if q in ("cost", "all"):
sc = db.query(StandardCost).limit(50).all()
sc = db.query(StandardCost).filter(StandardCost.entity_id == entity_id).limit(50).all()
result["costs"] = [
{"id": c.id, "product": c.product_name, "type": c.cost_type,
"standard": _float(c.standard_cost), "unit": c.unit}
@@ -469,7 +486,7 @@ def bot_query(
]
if q in ("okr", "all"):
objs = db.query(Objective).filter(Objective.status == "active").all()
objs = db.query(Objective).filter(Objective.status == "active", Objective.entity_id == entity_id).all()
result["okr"] = []
for o in objs:
# KR完整修复(2026-08-27): 从krs表读取
@@ -486,7 +503,7 @@ def bot_query(
})
if q in ("actions", "all"):
acts = db.query(ActionPlan).limit(30).all()
acts = db.query(ActionPlan).filter(ActionPlan.entity_id == entity_id).limit(30).all()
result["actions"] = [
{"id": a.id, "title": a.title, "status": a.status,
"progress": a.progress, "assignee": a.assignee}
@@ -502,8 +519,9 @@ def bot_import_excel(
file: UploadFile = File(...),
bot: dict = Depends(verify_bot_key),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""Bot上传Excel导入KPI数据到CMA"""
"""Bot上传Excel导入KPI数据到CMA(账套隔离 2026-08-31: 仅导入当前企业KPI"""
import pandas as pd, io, hashlib
from app.models import KPIValue
try:
@@ -547,6 +565,9 @@ def bot_import_excel(
if not kpi:
errors.append(f"{idx+2}行: KPI编码 '{kpi_code}' 不存在,跳过")
continue
if kpi.entity_id != entity_id:
errors.append(f"{idx+2}行: KPI编码 '{kpi_code}' 不属于当前企业(entity={kpi.entity_id}),跳过")
continue
kv = KPIValue(kpi_id=kpi.id, entity_id=kpi.entity_id, period=period, actual_value=val,
source_batch=hashlib.md5(f"{datetime.now()}".encode()).hexdigest()[:12])
@@ -569,10 +590,11 @@ def bot_okr_create(
dimension: Optional[str] = Query(None),
bot: dict = Depends(verify_bot_key),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""Bot创建OKR目标"""
"""Bot创建OKR目标(账套隔离 2026-08-31: 写入token企业)"""
from app.models import Objective
obj = Objective(title=title, quarter=quarter, dimension=dimension, owner=bot["name"])
obj = Objective(entity_id=entity_id, title=title, quarter=quarter, dimension=dimension, owner=bot["name"])
db.add(obj)
db.commit()
db.refresh(obj)
@@ -585,10 +607,11 @@ def bot_okr_list(
quarter: Optional[str] = Query(None),
bot: dict = Depends(verify_bot_key),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""Bot列出OKR(含KR进度)"""
"""Bot列出OKR(含KR进度)(账套隔离 2026-08-31"""
from app.models import Objective
q = db.query(Objective)
q = db.query(Objective).filter(Objective.entity_id == entity_id)
if quarter:
q = q.filter(Objective.quarter == quarter)
objs = q.order_by(Objective.quarter.desc()).all()
@@ -607,6 +630,7 @@ def bot_nlp(
intent: str = Query("overview"),
bot: dict = Depends(verify_bot_key),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""
自然语言意图映射:
@@ -623,7 +647,7 @@ def bot_nlp(
"okr": "okr", "目标": "okr", "季度目标": "okr",
}
resolved = m.get(intent, intent)
return bot_query(q=resolved, bot=bot, db=db)
return bot_query(q=resolved, bot=bot, db=db, entity_id=entity_id)
# ════════════════════════════════════════════════════════════