Files
cma-management/backend/app/deps.py
T
wecom-fullstack 52b693ddbb ASK-20260924-15/17: Bot 通道缺省账套→400 + 差异/趋势入口期间口径回落
- deps.get_entity_id: Bot 通道(带 X-BOT-KEY)无显式账套 → 400(旧为兜底 entity 1=酣客);
  无 key 的存量公开/用户端点行为不变(仍 401/兜底 1)
- 新增 deps.require_bot_entity_id():写端点缺省账套一处收口 → 400(406/422 同族)
  接入 bot_bridge(×2) / bot_bridge_v2 / bot_suggestions / cash_vouchers_json,并同步文档口径
- deviation_engine.run_deviation_check: period=None 不再硬编码当月,逐 KPI 回落
  「该 KPI 自己的最近可用期间」(裁定2b,单一实现收口到 alert_entity_scope);(当月无
  kpi_values ⇒ 差异/趋势入口永久 0 新增);R2/R3 窗口判定改为逐 KPI;显式 period 语义不变
- alert_generator.resolve_latest_period 委托同一实现(消除期间口径两份)
- 测试:新增 tests/test_ask15_17_engine_and_entity_required_20260925.py(12 例);
  4 个既有测试文件把「旧兜底账套 1」显式化(断言未改)
2026-09-25 22:42:25 +08:00

223 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""多租户公共依赖 — 账套模式:token优先,query/header降级为Bot服务白名单"""
import logging
from fastapi import Request, Header, Query, Depends, HTTPException
from typing import Optional
from sqlalchemy.orm import Session
from app.database import get_db
from app.auth_middleware import get_token_entity_id
from app.models import Entity
logger = logging.getLogger("cma.deps")
def get_entity_id(
request: Request,
x_entity_id: Optional[str] = Header(None, alias="X-Entity-Id"),
entity_id: Optional[int] = Query(None, ge=1),
db: Session = Depends(get_db),
) -> int:
"""解析当前企业ID(账套模式):token优先 → query/header(Bot白名单)→ 默认1
解析链(倒置后):
1. Authorization Bearer token → 优先返回 token.entity_id(唯一可信来源)
2. query参数 / X-Entity-Id header → 仅Bot服务通道使用(校验entity状态active)
3. body中的 entity_id(POST场景)→ Bot服务通道兼容
4. **Bot 通道(带 X-BOT-KEY)无任何显式账套 → 400 显式报错**;
无 X-BOT-KEY 的存量公开接口 → 兜底默认 1(酣客)
安全说明:登录用户必须通过token绑定账套,query/header传入的entity_id
在token存在时被忽略,防止越权传参(旧漏洞:query优先且无授权校验)。
"""
# 1. token优先(账套模式唯一来源)
auth = request.headers.get("Authorization", "")
if auth.startswith("Bearer "):
token_entity = get_token_entity_id(auth[7:])
if token_entity is not None:
# 越权防护:显式传入的 query/header entity_id 与 token 绑定不一致 → 403
explicit = None
if entity_id is not None:
explicit = entity_id
elif x_entity_id and x_entity_id.isdigit():
explicit = int(x_entity_id)
if explicit is not None and explicit != token_entity:
raise HTTPException(403, f"无权访问企业 entity_id={explicit}(当前账套: {token_entity})")
return token_entity
# token存在但是旧格式/无entity → 账套模式下强制走白名单或默认(由require_auth拦截)
# 这里不抛401:公开接口可能带旧token,交给require_auth统一处理
# 2. query/header → Bot服务通道白名单(校验entity状态active)
candidate = None
if entity_id is not None:
candidate = entity_id
elif x_entity_id and x_entity_id.isdigit():
candidate = int(x_entity_id)
# 3. body 中的 entity_id(POST场景,Bot通道兼容)
if candidate is None and request.method in ("POST", "PUT", "PATCH"):
try:
body = request.state.body_json or {}
if body.get("entity_id"):
candidate = int(body["entity_id"])
except Exception:
pass
if candidate is not None:
ent = db.query(Entity).filter(Entity.id == candidate).first()
if ent and ent.status == "active":
return candidate
# Bot通道校验不通过:不返回默认,直接拒绝(防越权注入无效entity)
raise HTTPException(403, f"企业 entity_id={candidate} 不存在或未激活")
# 4. 兜底(ASK-20260924-17,任总 2026-09-24 16:40「放行 17」):
# **Bot 通道缺省账套 = 报错,不再静默兜底 entity 1(酣客)**。
# 背景:Bot 取数接口不带账套参数时兜底 entity 1 ⇒ 跨 Bot/脚本漏传参数即用酣客数字
# 回答博海的问题(实测:不带参数 GET /api/cma/bot/overview → {"entity_id":1, "kpis_total":44})。
# 「问错账套」比看不到更危险,故 Bot 通道(带 X-BOT-KEY)要求**显式传账套**。
# 边界:无 X-BOT-KEY 的存量公开/历史接口保持原兜底行为(不在 ASK-17 范围)。
if str(request.headers.get("X-BOT-KEY") or "").strip():
raise HTTPException(
status_code=400,
detail=(
"缺少账套参数 entity_id:Bot 通道必须显式传账套"
"(query `?entity_id=<n>` 或 header `X-Entity-Id: <n>`;"
"1=陕西酣客、2=陕西博海),不再兜底默认账套(ASK-20260924-17)。"
),
)
return 1 # 无 token / 无参数的存量公开接口:保持原兜底(酣客)
def resolve_entity_for_request(request: Request, fallback: int = 1) -> int:
"""账套模式:请求级entity解析(供从body读取entity_id的接口使用)
登录用户(带Bearer token)→ token绑定的entity(唯一来源)
无token(Bot服务通道)→ 回退到调用方传入的fallback(body中的entity_id等)
"""
auth = request.headers.get("Authorization", "")
if auth.startswith("Bearer "):
token_entity = get_token_entity_id(auth[7:])
if token_entity is not None:
return token_entity
return fallback
def require_bot_entity_id(data: Optional[dict], *, source: str = "") -> int:
"""Bot 通道写端点:**账套参数必填**(ASK-20260924-17,任总 2026-09-24 16:40「放行 17」)
`deps.get_entity_id` 只能覆盖「用 Depends 取账套」的 GET 端点;Bot 写端点多从
body 里读 `entity_id`,原写法 `int(data.get("entity_id") or 1)` 会**静默兜底账套 1
(酣客)**——跨 Bot/脚本漏传参数即「用酣客数字回答博海的问题」。
本函数把「缺省即报错」收口到一处(`_require_*` 三处 + 建议通道 + 凭证导入共用):
· 显式传 `entity_id`(int 或数字字符串,含 0/负数由调用方各自的 active 校验接手)→ 返回 int;
· 缺失 / 空串 → **HTTP 400**,文案说明必须显式传账套及可选值;
· 非数字 → HTTP 422(与既有 `entity_id 无效` 文案同族,不静默回落)。
`source` 仅用于日志定位(哪条写入通道漏传)。
"""
raw = (data or {}).get("entity_id")
if raw is None or (isinstance(raw, str) and not raw.strip()):
logger.warning(
"Bot 通道缺少账套参数 entity_id → 400(source=%s)", source or "-")
raise HTTPException(
status_code=400,
detail=(
"缺少账套参数 entity_id:Bot 通道必须显式传账套"
"(body.entity_id / header `X-Entity-Id`;1=陕西酣客、2=陕西博海),"
"不再兜底默认账套(ASK-20260924-17)。"
),
)
try:
return int(raw)
except (TypeError, ValueError):
raise HTTPException(422, f"entity_id 无效: {raw!r}")
# ============================================================
# 跨租户读取防护(cma-cross-entity-endpoints-auth-20260914 补单)
#
# 背景:4 个只读端点(data-quality/governance-check、products/matrix、
# alerts/risk-matrix、action-plans/coso-checklist)未做会话账套鉴权,
# 用实体1(酣客)会话 token + 显式 entity 参数可读到实体2(博海)数据(HTTP 200)。
# 本模块提供统一入口,行为对齐已正确 403 的 growth-quality / predict.accuracy:
# - 会话 token 绑定实体 与 显式传入实体 不一致 → 403(文案同 get_entity_id)
# - 字符串实体参数非法(非 hanke/bohai)→ 400 显式报错(不再静默回落酣客)
# - 无 token(Bot 服务通道)→ 保持原白名单/默认行为,只补存在性校验
# ============================================================
# entity_id ↔ 实体 slug(字符串实体参数端点使用 hanke/bohai)
ENTITY_SLUGS = {1: "hanke", 2: "bohai"}
ENTITY_SLUG_TO_ID = {slug: eid for eid, slug in ENTITY_SLUGS.items()}
def get_session_entity(request: Request) -> Optional[int]:
"""会话账套实体:Bearer token 绑定的 entity_id(无 token / 旧格式 token → None)"""
auth = request.headers.get("Authorization", "")
if not auth.startswith("Bearer "):
return None
return get_token_entity_id(auth[7:].strip())
def assert_entity_scope(
request: Request,
explicit: Optional[int],
db: Optional[Session] = None,
) -> Optional[int]:
"""显式 entity_id 的跨租户防护。
- 会话 token 绑定实体存在(登录用户):explicit 与之不符 → 403(含 0=全部)
- 无 token(Bot 服务通道):explicit 需存在且 active,否则 403(防越权注入)
返回会话账套 entity_id(无 token → None,由调用方按原口径兜底)。
⚠️ 契约(cma-api-auth-surface-audit-20260914):本函数**不是鉴权**。
「无 token → 按 Bot 服务通道放行」这一分支**仅对真正以 X-BOT-KEY 鉴权的 Bot 端点有效**
(bot_bridge / bot_kpis / bot_iron_law / bot_suggestions / cash_vouchers_json 等,
见 app/api/bot_bridge.py 的 verify_bot_key)。
用户端点(前端 Bearer 调用)**必须**在 router 级或端点级挂 `require_auth` /
`require_auth_401`;否则未挂鉴权的用户端点 = 公开可访问(无 token 直接取数/写数)。
自查:`grep -n 'require_auth' app/api/<文件>.py` 无命中 且 该文件路由非 /api/cma/bot* →
即存在鉴权缺口。
"""
session_entity = get_session_entity(request)
if session_entity is not None:
if explicit is not None and explicit != session_entity:
raise HTTPException(403, f"无权访问企业 entity_id={explicit}(当前账套: {session_entity})")
return session_entity
# Bot 服务通道:显式实体必须存在且 active(0=全部 不校验)
if explicit is not None and explicit != 0 and db is not None:
ent = db.query(Entity).filter(Entity.id == explicit).first()
if not ent or ent.status != "active":
raise HTTPException(403, f"企业 entity_id={explicit} 不存在或未激活")
return None
def assert_entity_slug_scope(request: Request, slug: Optional[str]) -> Optional[str]:
"""字符串实体参数(hanke/bohai)的跨租户防护 + 非法值显式报错。
- 非法值 → 400 显式报错(不再静默回落酣客数据导致 UI 标签与数据不一致)
- 合法但与会话账套不一致 → 403(文案同 get_entity_id)
返回规范化 slug;slug 为空 → None(调用方按会话账套/默认兜底)。
"""
if slug is None or str(slug).strip() == "":
return None
key = str(slug).strip().lower()
if key not in ENTITY_SLUG_TO_ID:
raise HTTPException(
400,
f"非法实体 entity={slug}(合法值: hanke/bohai)",
)
eid = ENTITY_SLUG_TO_ID[key]
session_entity = get_session_entity(request)
if session_entity is not None and eid != session_entity:
raise HTTPException(403, f"无权访问企业 entity_id={eid}(当前账套: {session_entity})")
return key
def resolve_session_slug(request: Request, default: str = "hanke") -> str:
"""无显式实体参数时,按会话账套取默认 slug(无 token → default)"""
session_entity = get_session_entity(request)
if session_entity is None:
return default
return ENTITY_SLUGS.get(session_entity, default)