419 lines
13 KiB
Python
419 lines
13 KiB
Python
#!/usr/bin/env python
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
Permission Utils - 基于API路径的动态权限鉴权
|
||
|
||
工作原理:
|
||
1. 用户访问某个API时,根据请求的路径和方法,查找Permission表中是否有对应的权限记录
|
||
2. 如果有权限记录,检查用户的角色是否关联了该权限
|
||
3. 如果用户角色有该权限,则放行;否则返回403
|
||
4. 如果Permission表中没有该API的权限记录,则默认放行(未配置权限的API不做限制)
|
||
"""
|
||
import json
|
||
import re
|
||
from typing import List, Optional, Dict, Any, Set
|
||
|
||
from fastapi import HTTPException, status
|
||
from sqlalchemy import select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
from sqlalchemy.orm import selectinload
|
||
|
||
from utils.redis import RedisClient
|
||
|
||
# Redis 缓存 key 前缀和 TTL
|
||
API_PERMISSION_CACHE_KEY = "api_permission:path_map" # Hash: (path:method) -> permission_id
|
||
ROLE_PERMISSION_CACHE_PREFIX = "api_permission:role:" # role_permission:role:{role_id} -> Set[permission_id]
|
||
API_PERMISSION_CACHE_TTL = 300 # API路径映射缓存 5分钟
|
||
ROLE_PERMISSION_CACHE_TTL = 300 # 角色权限缓存 5分钟
|
||
|
||
|
||
# HTTP方法映射(与Permission模型中的定义一致)
|
||
HTTP_METHOD_MAP = {
|
||
'GET': 0,
|
||
'POST': 1,
|
||
'PUT': 2,
|
||
'DELETE': 3,
|
||
'PATCH': 4,
|
||
'ALL': 5,
|
||
}
|
||
|
||
|
||
class APIPermissionChecker:
|
||
"""
|
||
基于API路径的动态权限检查器
|
||
|
||
在AuthMiddleware中调用,根据请求的API路径和方法检查用户是否有权限
|
||
"""
|
||
|
||
def __init__(self):
|
||
pass
|
||
|
||
async def load_permissions_cache(self, db: AsyncSession):
|
||
"""
|
||
加载所有API权限到 Redis 缓存
|
||
|
||
建议在应用启动时调用,或者定期刷新
|
||
"""
|
||
from core.permission.model import Permission
|
||
|
||
result = await db.execute(
|
||
select(Permission).where(
|
||
Permission.is_active == True, # noqa: E712
|
||
Permission.is_deleted == False, # noqa: E712
|
||
Permission.permission_type == 1, # 只缓存API权限
|
||
Permission.api_path.isnot(None)
|
||
)
|
||
)
|
||
permissions = result.scalars().all()
|
||
|
||
redis = await RedisClient.get_client()
|
||
# 先删除旧缓存
|
||
await redis.delete(API_PERMISSION_CACHE_KEY)
|
||
|
||
cache_data = {}
|
||
for perm in permissions:
|
||
if perm.api_path:
|
||
# 存储权限ID,key为 "path:method"
|
||
cache_key = f"{perm.api_path}:{perm.http_method}"
|
||
cache_data[cache_key] = perm.id
|
||
# 如果是ALL方法,也存储到各个具体方法
|
||
if perm.http_method == 5: # ALL
|
||
for method_code in [0, 1, 2, 3, 4]:
|
||
key = f"{perm.api_path}:{method_code}"
|
||
if key not in cache_data:
|
||
cache_data[key] = perm.id
|
||
|
||
if cache_data:
|
||
await redis.hset(API_PERMISSION_CACHE_KEY, mapping=cache_data)
|
||
await redis.expire(API_PERMISSION_CACHE_KEY, API_PERMISSION_CACHE_TTL)
|
||
|
||
async def clear_cache(self):
|
||
"""清除所有权限相关的 Redis 缓存"""
|
||
redis = await RedisClient.get_client()
|
||
# 清除 API 路径映射缓存
|
||
await redis.delete(API_PERMISSION_CACHE_KEY)
|
||
# 清除所有角色权限缓存
|
||
cursor = 0
|
||
while True:
|
||
cursor, keys = await redis.scan(cursor, match=f"{ROLE_PERMISSION_CACHE_PREFIX}*", count=100)
|
||
if keys:
|
||
await redis.delete(*keys)
|
||
if cursor == 0:
|
||
break
|
||
|
||
def _match_path(self, request_path: str, permission_path: str) -> bool:
|
||
"""
|
||
匹配请求路径和权限路径
|
||
|
||
支持路径参数,如 /api/user/{id} 匹配 /api/user/123
|
||
"""
|
||
# 将权限路径中的{xxx}替换为正则表达式
|
||
pattern = re.sub(r'\{[^}]+\}', r'[^/]+', permission_path)
|
||
pattern = f'^{pattern}$'
|
||
return bool(re.match(pattern, request_path))
|
||
|
||
async def find_permission_id(self, request_path: str, http_method: str) -> Optional[str]:
|
||
"""
|
||
根据请求路径和方法查找对应的权限ID(从 Redis 缓存查询)
|
||
|
||
:param request_path: 请求路径,如 /api/core/user
|
||
:param http_method: HTTP方法,如 GET, POST
|
||
:return: 权限ID,如果没有找到则返回None
|
||
"""
|
||
method_code = HTTP_METHOD_MAP.get(http_method.upper(), 0)
|
||
redis = await RedisClient.get_client()
|
||
|
||
# 1. 精确匹配
|
||
cache_key = f"{request_path}:{method_code}"
|
||
perm_id = await redis.hget(API_PERMISSION_CACHE_KEY, cache_key)
|
||
if perm_id:
|
||
return perm_id
|
||
|
||
# 2. 尝试匹配ALL方法
|
||
cache_key_all = f"{request_path}:5"
|
||
perm_id = await redis.hget(API_PERMISSION_CACHE_KEY, cache_key_all)
|
||
if perm_id:
|
||
return perm_id
|
||
|
||
# 3. 路径参数匹配(需要获取所有缓存条目)
|
||
all_entries = await redis.hgetall(API_PERMISSION_CACHE_KEY)
|
||
if all_entries:
|
||
for entry_key, entry_perm_id in all_entries.items():
|
||
# entry_key 格式: "path:method"
|
||
# 需要从末尾分离出 method(最后一个冒号后的数字)
|
||
last_colon = entry_key.rfind(':')
|
||
if last_colon == -1:
|
||
continue
|
||
perm_path = entry_key[:last_colon]
|
||
try:
|
||
perm_method = int(entry_key[last_colon + 1:])
|
||
except ValueError:
|
||
continue
|
||
|
||
if perm_method in (method_code, 5): # 匹配具体方法或ALL
|
||
if '{' in perm_path and self._match_path(request_path, perm_path):
|
||
return entry_perm_id
|
||
|
||
return None
|
||
|
||
async def check_permission(
|
||
self,
|
||
db: AsyncSession,
|
||
user_id: str,
|
||
role_id: Optional[str] = None,
|
||
is_superuser: bool = False,
|
||
request_path: str = "",
|
||
http_method: str = "",
|
||
role_ids: Optional[List[str]] = None,
|
||
) -> tuple[bool, str]:
|
||
"""
|
||
检查用户是否有访问指定API的权限(支持多角色)
|
||
|
||
:param db: 数据库会话
|
||
:param user_id: 用户ID
|
||
:param role_id: 角色ID(单个,向后兼容)
|
||
:param is_superuser: 是否超级管理员
|
||
:param request_path: 请求路径
|
||
:param http_method: HTTP方法
|
||
:param role_ids: 角色ID列表(多个角色)
|
||
:return: (是否有权限, 错误信息)
|
||
"""
|
||
# 超级管理员跳过权限检查
|
||
if is_superuser:
|
||
return True, ""
|
||
|
||
# 确保缓存已加载
|
||
redis = await RedisClient.get_client()
|
||
cache_exists = await redis.exists(API_PERMISSION_CACHE_KEY)
|
||
if not cache_exists:
|
||
await self.load_permissions_cache(db)
|
||
|
||
# 查找该API对应的权限
|
||
permission_id = await self.find_permission_id(request_path, http_method)
|
||
|
||
# 如果该API没有配置权限,默认放行
|
||
if not permission_id:
|
||
return True, ""
|
||
|
||
# 处理角色ID列表
|
||
if role_ids is None:
|
||
role_ids = [role_id] if role_id else []
|
||
|
||
# 用户没有角色,无权限
|
||
if not role_ids:
|
||
return False, "用户未分配角色,无权访问此接口"
|
||
|
||
# 检查用户的任一角色是否有该权限(只要有一个角色有权限即可)
|
||
for rid in role_ids:
|
||
has_permission = await self._check_role_has_permission(db, rid, permission_id)
|
||
if has_permission:
|
||
return True, ""
|
||
|
||
return False, "权限不足,无权访问此接口"
|
||
|
||
async def _get_role_permission_ids(
|
||
self,
|
||
db: AsyncSession,
|
||
role_id: str,
|
||
) -> Set[str]:
|
||
"""
|
||
获取角色的所有权限ID集合(Redis 缓存)
|
||
"""
|
||
redis = await RedisClient.get_client()
|
||
cache_key = f"{ROLE_PERMISSION_CACHE_PREFIX}{role_id}"
|
||
|
||
# 从 Redis 获取缓存
|
||
cached = await redis.get(cache_key)
|
||
# if cached:
|
||
# try:
|
||
# return set(json.loads(cached))
|
||
# except (json.JSONDecodeError, TypeError):
|
||
# pass
|
||
|
||
# 缓存未命中,从数据库加载
|
||
from core.role.model import Role
|
||
|
||
result = await db.execute(
|
||
select(Role)
|
||
.options(selectinload(Role.permissions))
|
||
.where(
|
||
Role.id == role_id,
|
||
Role.status == True, # noqa: E712
|
||
Role.is_deleted == False # noqa: E712
|
||
)
|
||
)
|
||
role = result.scalar_one_or_none()
|
||
|
||
perm_ids: Set[str] = set()
|
||
if role and role.permissions:
|
||
for perm in role.permissions:
|
||
if perm.is_active:
|
||
perm_ids.add(perm.id)
|
||
|
||
# 写入 Redis 缓存
|
||
await redis.set(cache_key, json.dumps(list(perm_ids)), ex=ROLE_PERMISSION_CACHE_TTL)
|
||
return perm_ids
|
||
|
||
async def _check_role_has_permission(
|
||
self,
|
||
db: AsyncSession,
|
||
role_id: str,
|
||
permission_id: str
|
||
) -> bool:
|
||
"""
|
||
检查角色是否有指定权限
|
||
"""
|
||
perm_ids = await self._get_role_permission_ids(db, role_id)
|
||
return permission_id in perm_ids
|
||
|
||
|
||
# 全局权限检查器实例
|
||
api_permission_checker = APIPermissionChecker()
|
||
|
||
|
||
async def check_api_permission(
|
||
db: AsyncSession,
|
||
user_id: str,
|
||
role_id: Optional[str] = None,
|
||
is_superuser: bool = False,
|
||
request_path: str = "",
|
||
http_method: str = "",
|
||
role_ids: Optional[List[str]] = None,
|
||
) -> tuple[bool, str]:
|
||
"""
|
||
检查API权限的便捷函数(支持多角色)
|
||
|
||
:return: (是否有权限, 错误信息)
|
||
"""
|
||
return await api_permission_checker.check_permission(
|
||
db, user_id, role_id, is_superuser, request_path, http_method, role_ids
|
||
)
|
||
|
||
|
||
async def refresh_permission_cache(db: AsyncSession):
|
||
"""
|
||
刷新权限缓存
|
||
|
||
当权限数据变更时调用此函数
|
||
"""
|
||
await api_permission_checker.load_permissions_cache(db)
|
||
|
||
|
||
async def clear_permission_cache():
|
||
"""
|
||
清除权限缓存
|
||
"""
|
||
await api_permission_checker.clear_cache()
|
||
|
||
|
||
async def clear_role_permission_cache(role_id: str):
|
||
"""
|
||
清除指定角色的权限缓存
|
||
|
||
:param role_id: 角色ID
|
||
"""
|
||
redis = await RedisClient.get_client()
|
||
await redis.delete(f"{ROLE_PERMISSION_CACHE_PREFIX}{role_id}")
|
||
|
||
|
||
async def clear_all_role_permission_cache():
|
||
"""
|
||
清除所有角色的权限缓存
|
||
"""
|
||
redis = await RedisClient.get_client()
|
||
cursor = 0
|
||
while True:
|
||
cursor, keys = await redis.scan(cursor, match=f"{ROLE_PERMISSION_CACHE_PREFIX}*", count=100)
|
||
if keys:
|
||
await redis.delete(*keys)
|
||
if cursor == 0:
|
||
break
|
||
|
||
|
||
async def get_user_api_permissions(
|
||
db: AsyncSession,
|
||
role_id: Optional[str] = None,
|
||
is_superuser: bool = False,
|
||
role_ids: Optional[List[str]] = None,
|
||
) -> Set[str]:
|
||
"""
|
||
获取用户的API权限列表(返回API路径集合,支持多角色)
|
||
"""
|
||
if is_superuser:
|
||
return {"*"} # 超级管理员有所有权限
|
||
|
||
# 处理角色ID列表
|
||
if role_ids is None:
|
||
role_ids = [role_id] if role_id else []
|
||
|
||
if not role_ids:
|
||
return set()
|
||
|
||
from core.role.model import Role
|
||
|
||
api_paths = set()
|
||
|
||
# 合并所有角色的权限
|
||
for rid in role_ids:
|
||
result = await db.execute(
|
||
select(Role)
|
||
.options(selectinload(Role.permissions))
|
||
.where(
|
||
Role.id == rid,
|
||
Role.status == True, # noqa: E712
|
||
Role.is_deleted == False # noqa: E712
|
||
)
|
||
)
|
||
role = result.scalar_one_or_none()
|
||
|
||
if role and role.permissions:
|
||
for perm in role.permissions:
|
||
if perm.is_active and perm.api_path:
|
||
api_paths.add(perm.api_path)
|
||
|
||
return api_paths
|
||
|
||
|
||
async def get_user_permission_codes(
|
||
db: AsyncSession,
|
||
role_id: Optional[str] = None,
|
||
is_superuser: bool = False,
|
||
role_ids: Optional[List[str]] = None,
|
||
) -> Set[str]:
|
||
"""
|
||
获取用户的权限代码列表(返回权限代码集合,支持多角色)
|
||
"""
|
||
if is_superuser:
|
||
return {"*"} # 超级管理员有所有权限
|
||
|
||
# 处理角色ID列表
|
||
if role_ids is None:
|
||
role_ids = [role_id] if role_id else []
|
||
|
||
if not role_ids:
|
||
return set()
|
||
|
||
from core.role.model import Role
|
||
|
||
permission_codes = set()
|
||
|
||
# 合并所有角色的权限
|
||
for rid in role_ids:
|
||
result = await db.execute(
|
||
select(Role)
|
||
.options(selectinload(Role.permissions))
|
||
.where(
|
||
Role.id == rid,
|
||
Role.status == True, # noqa: E712
|
||
Role.is_deleted == False # noqa: E712
|
||
)
|
||
)
|
||
role = result.scalar_one_or_none()
|
||
|
||
if role and role.permissions:
|
||
for perm in role.permissions:
|
||
if perm.is_active and perm.code:
|
||
permission_codes.add(perm.code)
|
||
|
||
return permission_codes
|