""" 角色权限中间件 — 管理会计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 # ── 统一全站鉴权语义(cma-auth-401-403-unify-20260915)──────────────────── # 口径:**未认证(缺 token / 无效 token)→ 401**;**已认证但无权(跨实体 / 角色不足)→ 403**。 # FastAPI 的 HTTPBearer(auto_error=True) 对「缺 Authorization 头」返回 403,与 # require_auth_401 的 401 语义在同站并存(同一种「没带 token」两种状态码)。 # 现统一为 auto_error=False 自行判空:缺 header / 非 Bearer 格式 → 显式 401; # 有 token → 仍按原逻辑校验身份(无效/过期 token → 401)。 # 403 语义保留:require_role(角色不足)、deps.assert_entity_scope(跨实体无权)。 _optional_bearer = HTTPBearer(auto_error=False) def require_auth( credentials: Optional[HTTPAuthorizationCredentials] = Depends(_optional_bearer), db: Session = Depends(get_db), ) -> User: """认证依赖(全站统一):缺 token / 非 Bearer 格式 → 401;无效 token → 401。 已认证但无权(角色/实体)由 require_role、deps.assert_entity_scope 另行返回 403。 """ if credentials is None or not credentials.credentials: raise HTTPException(status_code=401, detail="未认证:缺少 Bearer token") 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) # 2026-09-15(cma-auth-401-403-unify-20260915):语义与 _optional_bearer 已统一, # 本入口保留作兼容别名(既有 router 与审计器 AUTH_STRICT 均引用此名)。 def require_auth_401( credentials: Optional[HTTPAuthorizationCredentials] = Depends(_optional_bearer), db: Session = Depends(get_db), ) -> User: """用户端点统一鉴权入口:缺 Bearer → 401;有 token → require_auth 校验(无效 → 401) 2026-09-15(cma-auth-401-403-unify-20260915)起与 require_auth 语义完全一致 (require_auth 也改为 auto_error=False 自判空 → 缺 token 401)。 保留本名称:既有 router 的 dependencies=[Depends(require_auth_401)] 与 backend/scripts/audit_api_auth_surface.py 的 AUTH_STRICT 分类仍引用它。 """ 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可见性(按维度/分类过滤) ─── # # ⚠️ 口径裁定(2026-09-23 dama-entity-isolation-sweep-20260923 §一②): # `role_permissions.key = 'kpi_visibility'` **属「非账套维度」的全局配置,不拆账套** # —— 理由:① 值本身是 `{role: [维度规则]}` 的**角色维度**字典,无账套语义; # ② 角色枚举(ceo/finance/business/it)是平台级字典,`role_permissions` 表 # 亦无 `entity_id` 列(`key` unique),「按角色看哪些维度」是集团统一口径; # ③ 与 `route_permissions` / `action_permissions` 同族(同为角色级权限面), # 若只拆本键会与同表另两键口径分裂。 # 判别口径:**同表同族的键按同一维度处理**;本键不消费任何账套状态、也不被账套写侧改写。 # 登记:`docs/global-config-keys-caliber-20260923.md`(键维度字典)。 def _load_kpi_visibility(db: Session = None): """从RolePermission表加载kpi_visibility配置(独立缓存) **非账套维度**(角色级天然全局,口径见上方注释与本档登记): 只读 `role_permissions.key = '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)]