Files
cma-management/backend/app/auth_middleware.py
Hermes CI Fix b796897f8c fix(auth): unify site-wide 401/403 semantics (cma-auth-401-403-unify-20260915)
require_auth now uses HTTPBearer(auto_error=False) and raises 401 explicitly
when the Authorization header is missing or non-Bearer, matching
require_auth_401. Unauthenticated requests are 401 for every user endpoint;
authenticated-but-forbidden (cross-entity / role) stays 403.

- app/auth_middleware.py: single optional bearer, 401 on missing token
  (require_auth_401 kept as a compatibility alias)
- tests: update 4 pre-existing cases that asserted 403 without a token
  (test_auth, test_reports, test_kpi_causality, test_expenses,
  test_api_auth_surface_20260914) - intentional behavior change
- add tests/test_auth_401_unification_20260915.py: sample + full sweep
  (all AUTHED GET routes -> 401), invalid token -> 401, cross-entity and
  role-denied still 403, bot channel (X-BOT-KEY) and public paths unchanged
2026-09-15 17:00:52 +08:00

286 lines
11 KiB
Python
Raw Permalink 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
# ── 统一全站鉴权语义(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可见性(按维度/分类过滤) ───
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)]