""" 角色权限中间件 — 管理会计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 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, int] = {} 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) -> str: token = secrets.token_hex(32) if _redis_available: _redis.setex(f"{TOKEN_PREFIX}{token}", TOKEN_TTL, user_id) else: _token_store[token] = user_id return token def _resolve_user_id(token: str) -> int | None: if _redis_available: val = _redis.get(f"{TOKEN_PREFIX}{token}") if val is not None: return int(val) return None return _token_store.get(token) 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 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)]