- 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」显式化(断言未改)
223 lines
11 KiB
Python
223 lines
11 KiB
Python
"""多租户公共依赖 — 账套模式: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)
|