Files
cma-management/backend/app/deps.py
T
fullstack-bot b815b7f623 fix(security): close unauthenticated CMA user API surface + audit tool (cma-api-auth-surface-audit-20260914)
Root cause: main.py include_router had no global auth dependency; user routers
(products/cost/predict/...) relied on assert_entity_scope, which treats a
missing session token as the Bot service channel and lets the request through.
Result: 46 user endpoints returned data without any token on a public site.

Changes:
- auth_middleware.py: add require_auth_401 (missing Bearer -> 401, delegates to
  require_auth otherwise); 401/403 semantics are explicit.
- router-level Depends(require_auth_401) on products, cost, predict,
  notifications, permissions, tenant, alignment; endpoint-level on
  /auth/roles and /admin/cache/clear.
- deps.py: document that get_entity_id/assert_entity_scope are NOT auth.
- frontend: add the missing Bearer header to 5 tokenless fetch/axios calls that
  hit the newly protected endpoints (KPIAlignment x2, RolePermissions x2,
  Dashboard x1).
- tests: new test_api_auth_surface_20260914.py (58 cases: no-token 401,
  token 200, cross-entity 403, bot channel unchanged); adapt test_predict /
  test_kpi_forecast to send session tokens like production.
- scripts/audit_api_auth_surface.py: reusable FastAPI dependency-graph audit
  (423 endpoints; A bot / B gap / C public), exits 1 when a B-class gap exists.
2026-09-14 16:41:15 +08:00

170 lines
8.2 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服务白名单"""
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)