"""多租户公共依赖 — 账套模式:token优先,query/header降级为Bot服务白名单""" 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 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. 兜底默认 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} 不存在或未激活") 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 # ============================================================ # 跨租户读取防护(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)