Files
cma-management/backend/app/auth_middleware.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

270 lines
9.6 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.
"""
角色权限中间件 — 管理会计OS
4角色: ceo(CEO/总览), finance(财务), business(业务), it(IT/运维)
权限配置支持从数据库动态加载
"""
from fastapi import Request, HTTPException, Depends
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import User, RolePermission
from typing import Optional
import secrets
import json
# 角色定义(固定)
ROLES = {
"ceo": {"name": "CEO", "priority": 1},
"finance": {"name": "财务", "priority": 2},
"business": {"name": "业务", "priority": 3},
"it": {"name": "IT运维", "priority": 4},
}
# 默认权限(数据库没有时的 fallback)
DEFAULT_ROUTE_PERMISSIONS = {
"ceo": ["dashboard", "kpis", "kpi_detail", "maps", "alerts", "ai_analysis", "data_source", "user_manage", "system_config"],
"finance": ["dashboard", "kpis", "kpi_detail", "maps", "alerts", "ai_analysis", "data_source"],
"business": ["dashboard", "kpis", "kpi_detail", "alerts"],
"it": ["dashboard", "kpis", "kpi_detail", "alerts", "data_source", "user_manage", "system_config"],
}
DEFAULT_KPI_VISIBILITY = {
"ceo": ["*"], # CEO看全部维度
"finance": ["finance_*"], # 财务只看财务
"business": ["customer_*", "process_*", "learning_*"], # 业务看客户/流程/学习
"it": ["*"], # IT看全部(运维)
}
DEFAULT_ACTION_PERMISSIONS = {
"ceo": ["read", "approve"],
"finance": ["read", "write", "import", "export"],
"business": ["read", "write"],
"it": ["read", "write", "delete", "admin"],
}
import logging
logger = logging.getLogger("cma.auth")
# Redis token 存储(跨 worker 共享)
try:
import redis as redis_lib
_redis = redis_lib.Redis(
host="127.0.0.1", port=6379, db=1,
decode_responses=True, socket_connect_timeout=2, socket_timeout=3
)
_redis.ping()
_redis_available = True
except Exception:
_redis = None
_redis_available = False
logger.warning("Redis不可用,token存储降级到内存(不支持多worker)")
# 内存 fallback
_token_store: dict[str, dict] = {}
TOKEN_PREFIX = "cma:token:"
TOKEN_TTL = 86400 # 24小时
# 缓存权限配置(每5分钟刷新)
_permissions_cache = {"route": None, "action": None, "ts": 0}
_PERM_CACHE_TTL = 300
def _load_permissions(db: Session = None):
"""从数据库加载权限配置"""
import time
now = time.time()
if db is None:
if now - _permissions_cache["ts"] < _PERM_CACHE_TTL:
return _permissions_cache["route"] or DEFAULT_ROUTE_PERMISSIONS, _permissions_cache["action"] or DEFAULT_ACTION_PERMISSIONS
return DEFAULT_ROUTE_PERMISSIONS, DEFAULT_ACTION_PERMISSIONS
try:
route_perm = db.query(RolePermission).filter(RolePermission.key == "route_permissions").first()
action_perm = db.query(RolePermission).filter(RolePermission.key == "action_permissions").first()
routes = route_perm.value if route_perm else DEFAULT_ROUTE_PERMISSIONS
actions = action_perm.value if action_perm else DEFAULT_ACTION_PERMISSIONS
_permissions_cache["route"] = routes
_permissions_cache["action"] = actions
_permissions_cache["ts"] = now
return routes, actions
except Exception:
return DEFAULT_ROUTE_PERMISSIONS, DEFAULT_ACTION_PERMISSIONS
def create_token(user_id: int, entity_id: int = None) -> str:
"""签发token:Redis存储 JSON {user_id, entity_id}(账套模式)
兼容旧调用 create_token(user_id) → entity_id=None(切换器会重新签发)
"""
token = secrets.token_hex(32)
payload = json.dumps({"user_id": user_id, "entity_id": entity_id}, ensure_ascii=False)
if _redis_available:
_redis.setex(f"{TOKEN_PREFIX}{token}", TOKEN_TTL, payload)
else:
_token_store[token] = payload
return token
def _resolve_token_data(token: str) -> dict | None:
"""解析token → {user_id, entity_id}
- 新格式 JSON → 返回 dict
- 旧格式 int(改造前)→ 返回 None(强制下线,账套模式需重新登录)
- 不存在 → None
"""
if _redis_available:
val = _redis.get(f"{TOKEN_PREFIX}{token}")
if val is None:
return None
try:
data = json.loads(val)
if isinstance(data, dict) and "user_id" in data:
return data
except (json.JSONDecodeError, ValueError, TypeError):
pass
# 旧格式纯 int → 强制下线
return None
val = _token_store.get(token)
if val is None:
return None
if isinstance(val, dict):
return val
try:
data = json.loads(val)
if isinstance(data, dict) and "user_id" in data:
return data
except (json.JSONDecodeError, ValueError, TypeError):
pass
return None
def _resolve_user_id(token: str) -> int | None:
data = _resolve_token_data(token)
return int(data["user_id"]) if data else None
def get_token_entity_id(token: str) -> int | None:
"""从token解析绑定的entity_id(token不存在/旧格式 → None)"""
data = _resolve_token_data(token)
if not data:
return None
eid = data.get("entity_id")
return int(eid) if eid else None
def user_has_entity(db: Session, user_id: int, entity_id: int) -> bool:
"""校验用户是否被授权访问指定企业(账套授权表 user_entities)"""
from app.models import UserEntity, Entity
ent = db.query(Entity).filter(Entity.id == entity_id).first()
if not ent or ent.status != "active":
return False
rel = db.query(UserEntity).filter(
UserEntity.user_id == user_id,
UserEntity.entity_id == entity_id,
).first()
return rel is not None
def extract_bearer_token(request) -> str | None:
"""从Request提取Bearer token(无/非Bearer格式 → None)"""
auth = request.headers.get("Authorization", "")
if auth.startswith("Bearer "):
return auth[7:].strip()
return None
def require_auth(
credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer(auto_error=True)),
db: Session = Depends(get_db),
) -> User:
token = credentials.credentials
user_id = _resolve_user_id(token)
if user_id is None:
raise HTTPException(401, "无效的token,请重新登录")
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(401, "用户不存在")
return user
def require_role(*roles: str):
async def role_checker(
current_user: User = Depends(require_auth),
) -> User:
if current_user.role not in roles:
raise HTTPException(403, f"权限不足: 需要 {', '.join(roles)} 角色")
return current_user
return role_checker
# ── 401 语义的认证依赖(cma-api-auth-surface-audit-20260914)──────────────
# FastAPI 的 HTTPBearer(auto_error=True) 对"缺 Authorization 头"返回 403;
# 安全语义上「未认证」应为 401(401=未认证 / 403=无权)。
# 本依赖用 auto_error=False 自行判空 → 缺 token 显式 401,有 token 仍完全委托 require_auth。
_optional_bearer = HTTPBearer(auto_error=False)
def require_auth_401(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(_optional_bearer),
db: Session = Depends(get_db),
) -> User:
"""用户端点统一鉴权入口:缺 Bearer → 401;有 token → require_auth 校验(无效 → 401)"""
if credentials is None or not credentials.credentials:
raise HTTPException(status_code=401, detail="未认证:缺少 Bearer token")
return require_auth(credentials=credentials, db=db)
def has_permission(user: User, module: str, db: Session = None) -> bool:
routes, _ = _load_permissions(db)
return module in routes.get(user.role, [])
def has_action(user: User, action: str, db: Session = None) -> bool:
_, actions = _load_permissions(db)
return action in actions.get(user.role, [])
# ─── KPI可见性(按维度/分类过滤) ───
def _load_kpi_visibility(db: Session = None):
"""从RolePermission表加载kpi_visibility配置(独立缓存)"""
import time
if not hasattr(_load_kpi_visibility, "_cache"):
_load_kpi_visibility._cache = {"data": None, "ts": 0}
cache = _load_kpi_visibility._cache
now = time.time()
if db is None or (cache["data"] and now - cache["ts"] < _PERM_CACHE_TTL):
return cache["data"] or DEFAULT_KPI_VISIBILITY
try:
perm = db.query(RolePermission).filter(RolePermission.key == "kpi_visibility").first()
cache["data"] = perm.value if perm else DEFAULT_KPI_VISIBILITY
cache["ts"] = now
return cache["data"]
except Exception:
return DEFAULT_KPI_VISIBILITY
def kpi_visible_dims(role: str, db: Session = None) -> list[str]:
"""返回角色可见的维度列表(空列表=全部可见)"""
vis = _load_kpi_visibility(db)
rules = vis.get(role, ["*"])
if "*" in rules:
return [] # 空=全部可见
# 提取维度前缀:finance_* -> finance
dims = set()
for r in rules:
if r.endswith("_*"):
dims.add(r[:-2])
return list(dims)
def filter_kpis_by_role(kpis: list, role: str, db: Session = None) -> list:
"""按角色可见性过滤KPI列表"""
dims = kpi_visible_dims(role, db)
if not dims:
return kpis # 全部可见
return [k for k in kpis if k.dimension in dims or (hasattr(k, 'dimension') and k.dimension in dims)]