This commit is contained in:
17
rbac/__init__.py
Normal file
17
rbac/__init__.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from rbac.interface import set_community_queries, set_rbac_operations
|
||||
from utils.logger import root_logger as logger
|
||||
|
||||
|
||||
def initialize_rbac() -> None:
|
||||
"""
|
||||
Инициализирует RBAC систему с dependency injection.
|
||||
|
||||
Должна быть вызвана один раз при старте приложения после импорта всех модулей.
|
||||
"""
|
||||
from rbac.operations import community_queries, rbac_operations
|
||||
|
||||
# Устанавливаем реализации
|
||||
set_rbac_operations(rbac_operations)
|
||||
set_community_queries(community_queries)
|
||||
|
||||
logger.info("🧿 RBAC система инициализирована с dependency injection")
|
||||
416
rbac/api.py
Normal file
416
rbac/api.py
Normal file
@@ -0,0 +1,416 @@
|
||||
"""
|
||||
RBAC: динамическая система прав для ролей и сообществ.
|
||||
|
||||
- Каталог всех сущностей и действий хранится в permissions_catalog.json
|
||||
- Дефолтные права ролей — в default_role_permissions.json
|
||||
- Кастомные права ролей для каждого сообщества — в Redis (ключ community:roles:{community_id})
|
||||
- При создании сообщества автоматически копируются дефолтные права
|
||||
- Декораторы получают роли пользователя из CommunityAuthor для конкретного сообщества
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from functools import wraps
|
||||
from typing import Any, Callable
|
||||
|
||||
from auth.orm import Author
|
||||
from rbac.interface import get_community_queries, get_rbac_operations
|
||||
from storage.db import local_session
|
||||
from settings import ADMIN_EMAILS
|
||||
from utils.logger import root_logger as logger
|
||||
|
||||
|
||||
async def initialize_community_permissions(community_id: int) -> None:
|
||||
"""
|
||||
Инициализирует права для нового сообщества на основе дефолтных настроек с учетом иерархии.
|
||||
|
||||
Args:
|
||||
community_id: ID сообщества
|
||||
"""
|
||||
rbac_ops = get_rbac_operations()
|
||||
await rbac_ops.initialize_community_permissions(community_id)
|
||||
|
||||
|
||||
async def get_permissions_for_role(role: str, community_id: int) -> list[str]:
|
||||
"""
|
||||
Получает список разрешений для конкретной роли в сообществе.
|
||||
Иерархия уже применена при инициализации сообщества.
|
||||
|
||||
Args:
|
||||
role: Название роли
|
||||
community_id: ID сообщества
|
||||
|
||||
Returns:
|
||||
Список разрешений для роли
|
||||
"""
|
||||
rbac_ops = get_rbac_operations()
|
||||
return await rbac_ops.get_permissions_for_role(role, community_id)
|
||||
|
||||
|
||||
async def update_all_communities_permissions() -> None:
|
||||
"""
|
||||
Обновляет права для всех существующих сообществ на основе актуальных дефолтных настроек.
|
||||
|
||||
Используется в админ-панели для применения изменений в правах на все сообщества.
|
||||
"""
|
||||
rbac_ops = get_rbac_operations()
|
||||
|
||||
# Поздний импорт для избежания циклических зависимостей
|
||||
from orm.community import Community
|
||||
|
||||
try:
|
||||
with local_session() as session:
|
||||
# Получаем все сообщества
|
||||
communities = session.query(Community).all()
|
||||
|
||||
for community in communities:
|
||||
# Сбрасываем кеш прав для каждого сообщества
|
||||
from storage.redis import redis
|
||||
|
||||
key = f"community:roles:{community.id}"
|
||||
await redis.execute("DEL", key)
|
||||
|
||||
# Переинициализируем права с актуальными дефолтными настройками
|
||||
await rbac_ops.initialize_community_permissions(community.id)
|
||||
|
||||
logger.info(f"Обновлены права для {len(communities)} сообществ")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при обновлении прав всех сообществ: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
|
||||
# --- Получение ролей пользователя ---
|
||||
|
||||
|
||||
def get_user_roles_in_community(author_id: int, community_id: int = 1, session: Any = None) -> list[str]:
|
||||
"""
|
||||
Получает роли пользователя в сообществе через новую систему CommunityAuthor
|
||||
"""
|
||||
community_queries = get_community_queries()
|
||||
return community_queries.get_user_roles_in_community(author_id, community_id, session)
|
||||
|
||||
|
||||
async def user_has_permission(author_id: int, permission: str, community_id: int, session: Any = None) -> bool:
|
||||
"""
|
||||
Проверяет, есть ли у пользователя конкретное разрешение в сообществе.
|
||||
|
||||
Args:
|
||||
author_id: ID автора
|
||||
permission: Разрешение для проверки
|
||||
community_id: ID сообщества
|
||||
session: Опциональная сессия БД (для тестов)
|
||||
|
||||
Returns:
|
||||
True если разрешение есть, False если нет
|
||||
"""
|
||||
rbac_ops = get_rbac_operations()
|
||||
return await rbac_ops.user_has_permission(author_id, permission, community_id, session)
|
||||
|
||||
|
||||
# --- Проверка прав ---
|
||||
async def roles_have_permission(role_slugs: list[str], permission: str, community_id: int) -> bool:
|
||||
"""
|
||||
Проверяет, есть ли у набора ролей конкретное разрешение в сообществе.
|
||||
|
||||
Args:
|
||||
role_slugs: Список ролей для проверки
|
||||
permission: Разрешение для проверки
|
||||
community_id: ID сообщества
|
||||
|
||||
Returns:
|
||||
True если хотя бы одна роль имеет разрешение
|
||||
"""
|
||||
rbac_ops = get_rbac_operations()
|
||||
return await rbac_ops._roles_have_permission(role_slugs, permission, community_id)
|
||||
|
||||
|
||||
# --- Декораторы ---
|
||||
class RBACError(Exception):
|
||||
"""Исключение для ошибок RBAC."""
|
||||
|
||||
|
||||
def get_user_roles_from_context(info) -> tuple[list[str], int]:
|
||||
"""
|
||||
Получение ролей пользователя из GraphQL контекста с учетом сообщества.
|
||||
|
||||
Returns:
|
||||
Кортеж (роли_пользователя, community_id)
|
||||
"""
|
||||
# Получаем ID автора из контекста
|
||||
if isinstance(info.context, dict):
|
||||
author_data = info.context.get("author", {})
|
||||
else:
|
||||
author_data = getattr(info.context, "author", {})
|
||||
author_id = author_data.get("id") if isinstance(author_data, dict) else None
|
||||
logger.debug(f"[get_user_roles_from_context] author_data: {author_data}, author_id: {author_id}")
|
||||
|
||||
# Если author_id не найден в context.author, пробуем получить из scope.auth
|
||||
if not author_id and hasattr(info.context, "request"):
|
||||
request = info.context.request
|
||||
logger.debug(f"[get_user_roles_from_context] Проверяем request.scope: {hasattr(request, 'scope')}")
|
||||
if hasattr(request, "scope") and "auth" in request.scope:
|
||||
auth_credentials = request.scope["auth"]
|
||||
logger.debug(f"[get_user_roles_from_context] Найден auth в scope: {type(auth_credentials)}")
|
||||
if hasattr(auth_credentials, "author_id") and auth_credentials.author_id:
|
||||
author_id = auth_credentials.author_id
|
||||
logger.debug(f"[get_user_roles_from_context] Получен author_id из scope.auth: {author_id}")
|
||||
elif isinstance(auth_credentials, dict) and "author_id" in auth_credentials:
|
||||
author_id = auth_credentials["author_id"]
|
||||
logger.debug(f"[get_user_roles_from_context] Получен author_id из scope.auth (dict): {author_id}")
|
||||
else:
|
||||
logger.debug("[get_user_roles_from_context] scope.auth не найден или пуст")
|
||||
if hasattr(request, "scope"):
|
||||
logger.debug(f"[get_user_roles_from_context] Ключи в scope: {list(request.scope.keys())}")
|
||||
|
||||
if not author_id:
|
||||
logger.debug("[get_user_roles_from_context] author_id не найден ни в context.author, ни в scope.auth")
|
||||
return [], 0
|
||||
|
||||
# Получаем community_id из аргументов мутации
|
||||
community_id = get_community_id_from_context(info)
|
||||
logger.debug(f"[get_user_roles_from_context] Получен community_id: {community_id}")
|
||||
|
||||
# Получаем роли пользователя в сообществе
|
||||
try:
|
||||
user_roles = get_user_roles_in_community(author_id, community_id)
|
||||
logger.debug(
|
||||
f"[get_user_roles_from_context] Роли пользователя {author_id} в сообществе {community_id}: {user_roles}"
|
||||
)
|
||||
|
||||
# Проверяем, является ли пользователь системным администратором
|
||||
try:
|
||||
admin_emails = ADMIN_EMAILS.split(",") if ADMIN_EMAILS else []
|
||||
|
||||
with local_session() as session:
|
||||
author = session.query(Author).where(Author.id == author_id).first()
|
||||
if author and author.email and author.email in admin_emails and "admin" not in user_roles:
|
||||
# Системный администратор автоматически получает роль admin в любом сообществе
|
||||
user_roles = [*user_roles, "admin"]
|
||||
logger.debug(
|
||||
f"[get_user_roles_from_context] Добавлена роль admin для системного администратора {author.email}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[get_user_roles_from_context] Ошибка при проверке системного администратора: {e}")
|
||||
|
||||
return user_roles, community_id
|
||||
except Exception as e:
|
||||
logger.error(f"[get_user_roles_from_context] Ошибка при получении ролей: {e}")
|
||||
return [], community_id
|
||||
|
||||
|
||||
def get_community_id_from_context(info) -> int:
|
||||
"""
|
||||
Получение community_id из GraphQL контекста или аргументов.
|
||||
"""
|
||||
# Пробуем из контекста
|
||||
if isinstance(info.context, dict):
|
||||
community_id = info.context.get("community_id")
|
||||
else:
|
||||
community_id = getattr(info.context, "community_id", None)
|
||||
if community_id:
|
||||
return int(community_id)
|
||||
|
||||
# Пробуем из аргументов resolver'а
|
||||
logger.debug(
|
||||
f"[get_community_id_from_context] Проверяем info.variable_values: {getattr(info, 'variable_values', None)}"
|
||||
)
|
||||
|
||||
# Пробуем получить переменные из разных источников
|
||||
variables = {}
|
||||
|
||||
# Способ 1: info.variable_values
|
||||
if hasattr(info, "variable_values") and info.variable_values:
|
||||
variables.update(info.variable_values)
|
||||
logger.debug(f"[get_community_id_from_context] Добавлены переменные из variable_values: {info.variable_values}")
|
||||
|
||||
# Способ 2: info.variable_values (альтернативный способ)
|
||||
if hasattr(info, "variable_values"):
|
||||
logger.debug(f"[get_community_id_from_context] variable_values тип: {type(info.variable_values)}")
|
||||
logger.debug(f"[get_community_id_from_context] variable_values содержимое: {info.variable_values}")
|
||||
|
||||
# Способ 3: из kwargs (аргументы функции)
|
||||
if hasattr(info, "context") and hasattr(info.context, "kwargs"):
|
||||
variables.update(info.context.kwargs)
|
||||
logger.debug(f"[get_community_id_from_context] Добавлены переменные из context.kwargs: {info.context.kwargs}")
|
||||
|
||||
logger.debug(f"[get_community_id_from_context] Итоговые переменные: {variables}")
|
||||
|
||||
if "community_id" in variables:
|
||||
return int(variables["community_id"])
|
||||
if "communityId" in variables:
|
||||
return int(variables["communityId"])
|
||||
|
||||
# Для мутации delete_community получаем slug и находим community_id
|
||||
if "slug" in variables:
|
||||
slug = variables["slug"]
|
||||
try:
|
||||
from orm.community import Community # Поздний импорт
|
||||
|
||||
with local_session() as session:
|
||||
community = session.query(Community).filter_by(slug=slug).first()
|
||||
if community:
|
||||
logger.debug(f"[get_community_id_from_context] Найден community_id {community.id} для slug {slug}")
|
||||
return community.id
|
||||
logger.warning(f"[get_community_id_from_context] Сообщество с slug {slug} не найдено")
|
||||
except Exception as e:
|
||||
logger.exception(f"[get_community_id_from_context] Ошибка при поиске community_id: {e}")
|
||||
|
||||
# Пробуем из прямых аргументов
|
||||
if hasattr(info, "field_asts") and info.field_asts:
|
||||
for field_ast in info.field_asts:
|
||||
if hasattr(field_ast, "arguments"):
|
||||
for arg in field_ast.arguments:
|
||||
if arg.name.value in ["community_id", "communityId"]:
|
||||
return int(arg.value.value)
|
||||
|
||||
# Fallback: основное сообщество
|
||||
logger.debug("[get_community_id_from_context] Используем дефолтный community_id: 1")
|
||||
return 1
|
||||
|
||||
|
||||
def require_permission(permission: str) -> Callable:
|
||||
"""
|
||||
Декоратор для проверки конкретного разрешения у пользователя в сообществе.
|
||||
|
||||
Args:
|
||||
permission: Требуемое разрешение (например, "shout:create")
|
||||
"""
|
||||
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
info = args[1] if len(args) > 1 else None
|
||||
if not info or not hasattr(info, "context"):
|
||||
raise RBACError("GraphQL info context не найден")
|
||||
|
||||
logger.debug(f"[require_permission] Проверяем права: {permission}")
|
||||
logger.debug(f"[require_permission] args: {args}")
|
||||
logger.debug(f"[require_permission] kwargs: {kwargs}")
|
||||
|
||||
user_roles, community_id = get_user_roles_from_context(info)
|
||||
logger.debug(f"[require_permission] user_roles: {user_roles}, community_id: {community_id}")
|
||||
|
||||
has_permission = await roles_have_permission(user_roles, permission, community_id)
|
||||
logger.debug(f"[require_permission] has_permission: {has_permission}")
|
||||
|
||||
if not has_permission:
|
||||
raise RBACError("Недостаточно прав. Требуется: ", permission)
|
||||
|
||||
return await func(*args, **kwargs) if asyncio.iscoroutinefunction(func) else func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def require_role(role: str) -> Callable:
|
||||
"""
|
||||
Декоратор для проверки конкретной роли у пользователя в сообществе.
|
||||
|
||||
Args:
|
||||
role: Требуемая роль (например, "admin", "editor")
|
||||
"""
|
||||
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
info = args[1] if len(args) > 1 else None
|
||||
if not info or not hasattr(info, "context"):
|
||||
raise RBACError("GraphQL info context не найден")
|
||||
|
||||
user_roles, community_id = get_user_roles_from_context(info)
|
||||
if role not in user_roles:
|
||||
raise RBACError("Требуется роль в сообществе", role)
|
||||
|
||||
return await func(*args, **kwargs) if asyncio.iscoroutinefunction(func) else func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def require_any_permission(permissions: list[str]) -> Callable:
|
||||
"""
|
||||
Декоратор для проверки любого из списка разрешений.
|
||||
|
||||
Args:
|
||||
permissions: Список разрешений, любое из которых подходит
|
||||
"""
|
||||
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
info = args[1] if len(args) > 1 else None
|
||||
if not info or not hasattr(info, "context"):
|
||||
raise RBACError("GraphQL info context не найден")
|
||||
|
||||
user_roles, community_id = get_user_roles_from_context(info)
|
||||
|
||||
# Проверяем каждое разрешение отдельно
|
||||
has_any = False
|
||||
for perm in permissions:
|
||||
if await roles_have_permission(user_roles, perm, community_id):
|
||||
has_any = True
|
||||
break
|
||||
|
||||
if not has_any:
|
||||
raise RBACError("Недостаточно прав. Требуется любое из: ", permissions)
|
||||
|
||||
return await func(*args, **kwargs) if asyncio.iscoroutinefunction(func) else func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def require_all_permissions(permissions: list[str]) -> Callable:
|
||||
"""
|
||||
Декоратор для проверки всех разрешений из списка.
|
||||
|
||||
Args:
|
||||
permissions: Список разрешений, все из которых требуются
|
||||
"""
|
||||
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
info = args[1] if len(args) > 1 else None
|
||||
if not info or not hasattr(info, "context"):
|
||||
raise RBACError("GraphQL info context не найден")
|
||||
|
||||
user_roles, community_id = get_user_roles_from_context(info)
|
||||
|
||||
# Проверяем каждое разрешение отдельно
|
||||
missing_perms = []
|
||||
for perm in permissions:
|
||||
if not await roles_have_permission(user_roles, perm, community_id):
|
||||
missing_perms.append(perm)
|
||||
|
||||
if missing_perms:
|
||||
raise RBACError("Недостаточно прав. Отсутствуют: ", missing_perms)
|
||||
|
||||
return await func(*args, **kwargs) if asyncio.iscoroutinefunction(func) else func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def admin_only(func: Callable) -> Callable:
|
||||
"""
|
||||
Декоратор для ограничения доступа только администраторам сообщества.
|
||||
"""
|
||||
|
||||
@wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
info = args[1] if len(args) > 1 else None
|
||||
if not info or not hasattr(info, "context"):
|
||||
raise RBACError("GraphQL info context не найден")
|
||||
|
||||
user_roles, community_id = get_user_roles_from_context(info)
|
||||
if "admin" not in user_roles:
|
||||
raise RBACError("Доступ только для администраторов сообщества", community_id)
|
||||
|
||||
return await func(*args, **kwargs) if asyncio.iscoroutinefunction(func) else func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
121
rbac/default_role_permissions.json
Normal file
121
rbac/default_role_permissions.json
Normal file
@@ -0,0 +1,121 @@
|
||||
{
|
||||
"reader": [
|
||||
"shout:read",
|
||||
"topic:read",
|
||||
"collection:read",
|
||||
"community:read",
|
||||
"bookmark:read",
|
||||
"bookmark:create",
|
||||
"bookmark:update",
|
||||
"bookmark:delete",
|
||||
"invite:read",
|
||||
"invite:accept",
|
||||
"invite:decline",
|
||||
"chat:read",
|
||||
"chat:create",
|
||||
"chat:update",
|
||||
"chat:delete",
|
||||
"message:read",
|
||||
"message:create",
|
||||
"message:update",
|
||||
"message:delete",
|
||||
"reaction:read:COMMENT",
|
||||
"reaction:create:COMMENT",
|
||||
"reaction:update:COMMENT",
|
||||
"reaction:delete:COMMENT",
|
||||
"reaction:read:QUOTE",
|
||||
"reaction:create:QUOTE",
|
||||
"reaction:update:QUOTE",
|
||||
"reaction:delete:QUOTE",
|
||||
"reaction:read:LIKE",
|
||||
"reaction:create:LIKE",
|
||||
"reaction:update:LIKE",
|
||||
"reaction:delete:LIKE",
|
||||
"reaction:read:DISLIKE",
|
||||
"reaction:create:DISLIKE",
|
||||
"reaction:update:DISLIKE",
|
||||
"reaction:delete:DISLIKE",
|
||||
"reaction:read:CREDIT",
|
||||
"reaction:read:PROOF",
|
||||
"reaction:read:DISPROOF",
|
||||
"reaction:read:AGREE",
|
||||
"reaction:read:DISAGREE"
|
||||
],
|
||||
"author": [
|
||||
"reader",
|
||||
"draft:read",
|
||||
"draft:create",
|
||||
"draft:update",
|
||||
"draft:delete",
|
||||
"shout:create",
|
||||
"shout:update",
|
||||
"shout:delete",
|
||||
"collection:create",
|
||||
"collection:update",
|
||||
"collection:delete",
|
||||
"invite:create",
|
||||
"invite:update",
|
||||
"invite:delete",
|
||||
"reaction:create:SILENT",
|
||||
"reaction:read:SILENT",
|
||||
"reaction:update:SILENT",
|
||||
"reaction:delete:SILENT"
|
||||
],
|
||||
"artist": [
|
||||
"author",
|
||||
"reaction:create:CREDIT",
|
||||
"reaction:read:CREDIT",
|
||||
"reaction:update:CREDIT",
|
||||
"reaction:delete:CREDIT"
|
||||
],
|
||||
"expert": [
|
||||
"reader",
|
||||
"reaction:create:PROOF",
|
||||
"reaction:read:PROOF",
|
||||
"reaction:update:PROOF",
|
||||
"reaction:delete:PROOF",
|
||||
"reaction:create:DISPROOF",
|
||||
"reaction:read:DISPROOF",
|
||||
"reaction:update:DISPROOF",
|
||||
"reaction:delete:DISPROOF",
|
||||
"reaction:create:AGREE",
|
||||
"reaction:read:AGREE",
|
||||
"reaction:update:AGREE",
|
||||
"reaction:delete:AGREE",
|
||||
"reaction:create:DISAGREE",
|
||||
"reaction:read:DISAGREE",
|
||||
"reaction:update:DISAGREE",
|
||||
"reaction:delete:DISAGREE"
|
||||
],
|
||||
"editor": [
|
||||
"author",
|
||||
"shout:delete_any",
|
||||
"shout:update_any",
|
||||
"topic:create",
|
||||
"topic:delete",
|
||||
"topic:update",
|
||||
"topic:merge",
|
||||
"reaction:delete_any:*",
|
||||
"reaction:update_any:*",
|
||||
"invite:delete_any",
|
||||
"invite:update_any",
|
||||
"collection:delete_any",
|
||||
"collection:update_any",
|
||||
"community:create",
|
||||
"community:update",
|
||||
"community:delete",
|
||||
"draft:delete_any",
|
||||
"draft:update_any"
|
||||
],
|
||||
"admin": [
|
||||
"editor",
|
||||
"author:delete_any",
|
||||
"author:update_any",
|
||||
"chat:delete_any",
|
||||
"chat:update_any",
|
||||
"message:delete_any",
|
||||
"message:update_any",
|
||||
"community:delete_any",
|
||||
"community:update_any"
|
||||
]
|
||||
}
|
||||
75
rbac/interface.py
Normal file
75
rbac/interface.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""
|
||||
Интерфейс для RBAC операций, исключающий циркулярные импорты.
|
||||
|
||||
Этот модуль содержит только типы и абстрактные интерфейсы,
|
||||
не импортирует ORM модели и не создает циклических зависимостей.
|
||||
"""
|
||||
|
||||
from typing import Any, Protocol
|
||||
|
||||
|
||||
class RBACOperations(Protocol):
|
||||
"""
|
||||
Протокол для RBAC операций, позволяющий ORM моделям
|
||||
выполнять операции с правами без прямого импорта rbac.api
|
||||
"""
|
||||
|
||||
async def get_permissions_for_role(self, role: str, community_id: int) -> list[str]:
|
||||
"""Получает разрешения для роли в сообществе"""
|
||||
...
|
||||
|
||||
async def initialize_community_permissions(self, community_id: int) -> None:
|
||||
"""Инициализирует права для нового сообщества"""
|
||||
...
|
||||
|
||||
async def user_has_permission(
|
||||
self, author_id: int, permission: str, community_id: int, session: Any = None
|
||||
) -> bool:
|
||||
"""Проверяет разрешение пользователя в сообществе"""
|
||||
...
|
||||
|
||||
async def _roles_have_permission(self, role_slugs: list[str], permission: str, community_id: int) -> bool:
|
||||
"""Проверяет, есть ли у набора ролей конкретное разрешение в сообществе"""
|
||||
...
|
||||
|
||||
|
||||
class CommunityAuthorQueries(Protocol):
|
||||
"""
|
||||
Протокол для запросов CommunityAuthor, позволяющий RBAC
|
||||
выполнять запросы без прямого импорта ORM моделей
|
||||
"""
|
||||
|
||||
def get_user_roles_in_community(self, author_id: int, community_id: int, session: Any = None) -> list[str]:
|
||||
"""Получает роли пользователя в сообществе"""
|
||||
...
|
||||
|
||||
|
||||
# Глобальные переменные для dependency injection
|
||||
_rbac_operations: RBACOperations | None = None
|
||||
_community_queries: CommunityAuthorQueries | None = None
|
||||
|
||||
|
||||
def set_rbac_operations(ops: RBACOperations) -> None:
|
||||
"""Устанавливает реализацию RBAC операций"""
|
||||
global _rbac_operations # noqa: PLW0603
|
||||
_rbac_operations = ops
|
||||
|
||||
|
||||
def set_community_queries(queries: CommunityAuthorQueries) -> None:
|
||||
"""Устанавливает реализацию запросов сообщества"""
|
||||
global _community_queries # noqa: PLW0603
|
||||
_community_queries = queries
|
||||
|
||||
|
||||
def get_rbac_operations() -> RBACOperations:
|
||||
"""Получает реализацию RBAC операций"""
|
||||
if _rbac_operations is None:
|
||||
raise RuntimeError("RBAC operations не инициализированы. Вызовите set_rbac_operations()")
|
||||
return _rbac_operations
|
||||
|
||||
|
||||
def get_community_queries() -> CommunityAuthorQueries:
|
||||
"""Получает реализацию запросов сообщества"""
|
||||
if _community_queries is None:
|
||||
raise RuntimeError("Community queries не инициализированы. Вызовите set_community_queries()")
|
||||
return _community_queries
|
||||
200
rbac/operations.py
Normal file
200
rbac/operations.py
Normal file
@@ -0,0 +1,200 @@
|
||||
"""
|
||||
Реализация RBAC операций для использования через интерфейс.
|
||||
|
||||
Этот модуль предоставляет конкретную реализацию RBAC операций,
|
||||
не импортирует ORM модели напрямую, используя dependency injection.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from rbac.interface import CommunityAuthorQueries, RBACOperations, get_community_queries
|
||||
from storage.db import local_session
|
||||
from storage.redis import redis
|
||||
from utils.logger import root_logger as logger
|
||||
|
||||
# --- Загрузка каталога сущностей и дефолтных прав ---
|
||||
|
||||
with Path("rbac/permissions_catalog.json").open() as f:
|
||||
PERMISSIONS_CATALOG = json.load(f)
|
||||
|
||||
with Path("rbac/default_role_permissions.json").open() as f:
|
||||
DEFAULT_ROLE_PERMISSIONS = json.load(f)
|
||||
|
||||
role_names = list(DEFAULT_ROLE_PERMISSIONS.keys())
|
||||
|
||||
|
||||
class RBACOperationsImpl(RBACOperations):
|
||||
"""Конкретная реализация RBAC операций"""
|
||||
|
||||
async def get_permissions_for_role(self, role: str, community_id: int) -> list[str]:
|
||||
"""
|
||||
Получает список разрешений для конкретной роли в сообществе.
|
||||
Иерархия уже применена при инициализации сообщества.
|
||||
|
||||
Args:
|
||||
role: Название роли
|
||||
community_id: ID сообщества
|
||||
|
||||
Returns:
|
||||
Список разрешений для роли
|
||||
"""
|
||||
role_perms = await self._get_role_permissions_for_community(community_id)
|
||||
return role_perms.get(role, [])
|
||||
|
||||
async def initialize_community_permissions(self, community_id: int) -> None:
|
||||
"""
|
||||
Инициализирует права для нового сообщества на основе дефолтных настроек с учетом иерархии.
|
||||
|
||||
Args:
|
||||
community_id: ID сообщества
|
||||
"""
|
||||
key = f"community:roles:{community_id}"
|
||||
|
||||
# Проверяем, не инициализировано ли уже
|
||||
existing = await redis.execute("GET", key)
|
||||
if existing:
|
||||
logger.debug(f"Права для сообщества {community_id} уже инициализированы")
|
||||
return
|
||||
|
||||
# Создаем полные списки разрешений с учетом иерархии
|
||||
expanded_permissions = {}
|
||||
|
||||
def get_role_permissions(role: str, processed_roles: set[str] | None = None) -> set[str]:
|
||||
"""
|
||||
Рекурсивно получает все разрешения для роли, включая наследованные
|
||||
|
||||
Args:
|
||||
role: Название роли
|
||||
processed_roles: Список уже обработанных ролей для предотвращения зацикливания
|
||||
|
||||
Returns:
|
||||
Множество разрешений
|
||||
"""
|
||||
if processed_roles is None:
|
||||
processed_roles = set()
|
||||
|
||||
if role in processed_roles:
|
||||
return set()
|
||||
|
||||
processed_roles.add(role)
|
||||
|
||||
# Получаем прямые разрешения роли
|
||||
direct_permissions = set(DEFAULT_ROLE_PERMISSIONS.get(role, []))
|
||||
|
||||
# Проверяем, есть ли наследование роли
|
||||
for perm in list(direct_permissions):
|
||||
if perm in role_names:
|
||||
# Если пермишен - это название роли, добавляем все её разрешения
|
||||
direct_permissions.remove(perm)
|
||||
direct_permissions.update(get_role_permissions(perm, processed_roles))
|
||||
|
||||
return direct_permissions
|
||||
|
||||
# Формируем расширенные разрешения для каждой роли
|
||||
for role in role_names:
|
||||
expanded_permissions[role] = list(get_role_permissions(role))
|
||||
|
||||
# Сохраняем в Redis уже развернутые списки с учетом иерархии
|
||||
await redis.execute("SET", key, json.dumps(expanded_permissions))
|
||||
logger.info(f"Инициализированы права с иерархией для сообщества {community_id}")
|
||||
|
||||
async def user_has_permission(
|
||||
self, author_id: int, permission: str, community_id: int, session: Any = None
|
||||
) -> bool:
|
||||
"""
|
||||
Проверяет, есть ли у пользователя конкретное разрешение в сообществе.
|
||||
|
||||
Args:
|
||||
author_id: ID автора
|
||||
permission: Разрешение для проверки
|
||||
community_id: ID сообщества
|
||||
session: Опциональная сессия БД (для тестов)
|
||||
|
||||
Returns:
|
||||
True если разрешение есть, False если нет
|
||||
"""
|
||||
community_queries = get_community_queries()
|
||||
user_roles = community_queries.get_user_roles_in_community(author_id, community_id, session)
|
||||
return await self._roles_have_permission(user_roles, permission, community_id)
|
||||
|
||||
async def _get_role_permissions_for_community(self, community_id: int) -> dict:
|
||||
"""
|
||||
Получает права ролей для конкретного сообщества.
|
||||
Если права не настроены, автоматически инициализирует их дефолтными.
|
||||
|
||||
Args:
|
||||
community_id: ID сообщества
|
||||
|
||||
Returns:
|
||||
Словарь прав ролей для сообщества
|
||||
"""
|
||||
key = f"community:roles:{community_id}"
|
||||
data = await redis.execute("GET", key)
|
||||
|
||||
if data:
|
||||
return json.loads(data)
|
||||
|
||||
# Автоматически инициализируем, если не найдено
|
||||
await self.initialize_community_permissions(community_id)
|
||||
|
||||
# Получаем инициализированные разрешения
|
||||
data = await redis.execute("GET", key)
|
||||
if data:
|
||||
return json.loads(data)
|
||||
|
||||
# Fallback на дефолтные разрешения если что-то пошло не так
|
||||
return DEFAULT_ROLE_PERMISSIONS
|
||||
|
||||
async def _roles_have_permission(self, role_slugs: list[str], permission: str, community_id: int) -> bool:
|
||||
"""
|
||||
Проверяет, есть ли у набора ролей конкретное разрешение в сообществе.
|
||||
|
||||
Args:
|
||||
role_slugs: Список ролей для проверки
|
||||
permission: Разрешение для проверки
|
||||
community_id: ID сообщества
|
||||
|
||||
Returns:
|
||||
True если хотя бы одна роль имеет разрешение
|
||||
"""
|
||||
role_perms = await self._get_role_permissions_for_community(community_id)
|
||||
return any(permission in role_perms.get(role, []) for role in role_slugs)
|
||||
|
||||
|
||||
class CommunityAuthorQueriesImpl(CommunityAuthorQueries):
|
||||
"""Конкретная реализация запросов CommunityAuthor через поздний импорт"""
|
||||
|
||||
def get_user_roles_in_community(self, author_id: int, community_id: int = 1, session: Any = None) -> list[str]:
|
||||
"""
|
||||
Получает роли пользователя в сообществе через новую систему CommunityAuthor
|
||||
"""
|
||||
# Поздний импорт для избежания циклических зависимостей
|
||||
from orm.community import CommunityAuthor
|
||||
|
||||
try:
|
||||
if session:
|
||||
ca = (
|
||||
session.query(CommunityAuthor)
|
||||
.where(CommunityAuthor.author_id == author_id, CommunityAuthor.community_id == community_id)
|
||||
.first()
|
||||
)
|
||||
return ca.role_list if ca else []
|
||||
|
||||
# Используем local_session для продакшена
|
||||
with local_session() as db_session:
|
||||
ca = (
|
||||
db_session.query(CommunityAuthor)
|
||||
.where(CommunityAuthor.author_id == author_id, CommunityAuthor.community_id == community_id)
|
||||
.first()
|
||||
)
|
||||
return ca.role_list if ca else []
|
||||
except Exception as e:
|
||||
logger.error(f"[get_user_roles_in_community] Ошибка при получении ролей: {e}")
|
||||
return []
|
||||
|
||||
|
||||
# Создаем экземпляры реализаций
|
||||
rbac_operations = RBACOperationsImpl()
|
||||
community_queries = CommunityAuthorQueriesImpl()
|
||||
163
rbac/permissions.py
Normal file
163
rbac/permissions.py
Normal file
@@ -0,0 +1,163 @@
|
||||
"""
|
||||
Модуль для проверки разрешений пользователей в контексте сообществ.
|
||||
|
||||
Позволяет проверять доступ пользователя к определенным операциям в сообществе
|
||||
на основе его роли в этом сообществе.
|
||||
"""
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from auth.orm import Author
|
||||
from orm.community import Community, CommunityAuthor
|
||||
from settings import ADMIN_EMAILS as ADMIN_EMAILS_LIST
|
||||
|
||||
ADMIN_EMAILS = ADMIN_EMAILS_LIST.split(",")
|
||||
|
||||
|
||||
class ContextualPermissionCheck:
|
||||
"""
|
||||
Класс для проверки контекстно-зависимых разрешений.
|
||||
|
||||
Позволяет проверять разрешения пользователя в контексте сообщества,
|
||||
учитывая как глобальные роли пользователя, так и его роли внутри сообщества.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
async def check_community_permission(
|
||||
cls, session: Session, author_id: int, community_slug: str, resource: str, operation: str
|
||||
) -> bool:
|
||||
"""
|
||||
Проверяет наличие разрешения у пользователя в контексте сообщества.
|
||||
|
||||
Args:
|
||||
session: Сессия SQLAlchemy
|
||||
author_id: ID автора/пользователя
|
||||
community_slug: Slug сообщества
|
||||
resource: Ресурс для доступа
|
||||
operation: Операция над ресурсом
|
||||
|
||||
Returns:
|
||||
bool: True, если пользователь имеет разрешение, иначе False
|
||||
"""
|
||||
# 1. Проверка глобальных разрешений (например, администратор)
|
||||
author = session.query(Author).where(Author.id == author_id).one_or_none()
|
||||
if not author:
|
||||
return False
|
||||
# Если это администратор (по списку email)
|
||||
if author.email in ADMIN_EMAILS:
|
||||
return True
|
||||
|
||||
# 2. Проверка разрешений в контексте сообщества
|
||||
# Получаем информацию о сообществе
|
||||
community = session.query(Community).where(Community.slug == community_slug).one_or_none()
|
||||
if not community:
|
||||
return False
|
||||
|
||||
# Если автор является создателем сообщества, то у него есть полные права
|
||||
if community.created_by == author_id:
|
||||
return True
|
||||
|
||||
# Проверяем наличие разрешения для этих ролей
|
||||
permission_id = f"{resource}:{operation}"
|
||||
ca = CommunityAuthor.find_author_in_community(author_id, community.id, session)
|
||||
return bool(ca.has_permission(permission_id)) if ca else False
|
||||
|
||||
@classmethod
|
||||
def get_user_community_roles(cls, session: Session, author_id: int, community_slug: str) -> list[str]:
|
||||
"""
|
||||
Получает список ролей пользователя в сообществе.
|
||||
|
||||
Args:
|
||||
session: Сессия SQLAlchemy
|
||||
author_id: ID автора/пользователя
|
||||
community_slug: Slug сообщества
|
||||
|
||||
Returns:
|
||||
List[str]: Список ролей пользователя в сообществе
|
||||
"""
|
||||
# Получаем информацию о сообществе
|
||||
community = session.query(Community).where(Community.slug == community_slug).one_or_none()
|
||||
if not community:
|
||||
return []
|
||||
|
||||
# Если автор является создателем сообщества, то у него есть роль владельца
|
||||
if community.created_by == author_id:
|
||||
return ["editor", "author", "expert", "reader"]
|
||||
|
||||
# Находим связь автор-сообщество
|
||||
ca = CommunityAuthor.find_author_in_community(author_id, community.id, session)
|
||||
return ca.role_list if ca else []
|
||||
|
||||
@classmethod
|
||||
def check_permission(
|
||||
cls, session: Session, author_id: int, community_slug: str, resource: str, operation: str
|
||||
) -> bool:
|
||||
"""
|
||||
Проверяет наличие разрешения у пользователя в контексте сообщества.
|
||||
Синхронный метод для обратной совместимости.
|
||||
|
||||
Args:
|
||||
session: Сессия SQLAlchemy
|
||||
author_id: ID автора/пользователя
|
||||
community_slug: Slug сообщества
|
||||
resource: Ресурс для доступа
|
||||
operation: Операция над ресурсом
|
||||
|
||||
Returns:
|
||||
bool: True, если пользователь имеет разрешение, иначе False
|
||||
"""
|
||||
# Используем тот же алгоритм, что и в асинхронной версии
|
||||
author = session.query(Author).where(Author.id == author_id).one_or_none()
|
||||
if not author:
|
||||
return False
|
||||
# Если это администратор (по списку email)
|
||||
if author.email in ADMIN_EMAILS:
|
||||
return True
|
||||
|
||||
# Получаем информацию о сообществе
|
||||
community = session.query(Community).where(Community.slug == community_slug).one_or_none()
|
||||
if not community:
|
||||
return False
|
||||
|
||||
# Если автор является создателем сообщества, то у него есть полные права
|
||||
if community.created_by == author_id:
|
||||
return True
|
||||
|
||||
# Проверяем наличие разрешения для этих ролей
|
||||
permission_id = f"{resource}:{operation}"
|
||||
ca = CommunityAuthor.find_author_in_community(author_id, community.id, session)
|
||||
|
||||
# Возвращаем результат проверки разрешения
|
||||
return bool(ca and ca.has_permission(permission_id))
|
||||
|
||||
async def can_delete_community(self, user_id: int, community: Community, session: Session) -> bool:
|
||||
"""
|
||||
Проверяет, может ли пользователь удалить сообщество.
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
community: Объект сообщества
|
||||
session: Сессия SQLAlchemy
|
||||
|
||||
Returns:
|
||||
bool: True, если пользователь может удалить сообщество, иначе False
|
||||
"""
|
||||
# Если пользователь - создатель сообщества
|
||||
if community.created_by == user_id:
|
||||
return True
|
||||
|
||||
# Проверяем, есть ли у пользователя роль администратора или редактора
|
||||
author = session.query(Author).where(Author.id == user_id).first()
|
||||
if not author:
|
||||
return False
|
||||
|
||||
# Проверка по email (глобальные администраторы)
|
||||
if author.email in ADMIN_EMAILS:
|
||||
return True
|
||||
|
||||
# Проверка ролей в сообществе
|
||||
community_author = CommunityAuthor.find_author_in_community(user_id, community.id, session)
|
||||
if community_author:
|
||||
return "admin" in community_author.role_list or "editor" in community_author.role_list
|
||||
|
||||
return False
|
||||
73
rbac/permissions_catalog.json
Normal file
73
rbac/permissions_catalog.json
Normal file
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"shout": ["create", "read", "update_own", "update_any", "delete_own", "delete_any"],
|
||||
"topic": ["create", "read", "update_own", "update_any", "delete_own", "delete_any", "merge"],
|
||||
"collection": ["create", "read", "update_own", "update_any", "delete_own", "delete_any"],
|
||||
"bookmark": ["create", "read", "update_own", "update_any", "delete_own", "delete_any"],
|
||||
"invite": ["create", "read", "update_own", "update_any", "delete_own", "delete_any"],
|
||||
"chat": ["create", "read", "update_own", "update_any", "delete_own", "delete_any"],
|
||||
"message": ["create", "read", "update_own", "update_any", "delete_own", "delete_any"],
|
||||
"community": ["create", "read", "update_own", "update_any", "delete_own", "delete_any"],
|
||||
"draft": ["create", "read", "update_own", "update_any", "delete_own", "delete_any"],
|
||||
"reaction": [
|
||||
"create:LIKE",
|
||||
"read:LIKE",
|
||||
"update_own:LIKE",
|
||||
"update_any:LIKE",
|
||||
"delete_own:LIKE",
|
||||
"delete_any:LIKE",
|
||||
"create:COMMENT",
|
||||
"read:COMMENT",
|
||||
"update_own:COMMENT",
|
||||
"update_any:COMMENT",
|
||||
"delete_own:COMMENT",
|
||||
"delete_any:COMMENT",
|
||||
"create:QUOTE",
|
||||
"read:QUOTE",
|
||||
"update_own:QUOTE",
|
||||
"update_any:QUOTE",
|
||||
"delete_own:QUOTE",
|
||||
"delete_any:QUOTE",
|
||||
"create:DISLIKE",
|
||||
"read:DISLIKE",
|
||||
"update_own:DISLIKE",
|
||||
"update_any:DISLIKE",
|
||||
"delete_own:DISLIKE",
|
||||
"delete_any:DISLIKE",
|
||||
"create:CREDIT",
|
||||
"read:CREDIT",
|
||||
"update_own:CREDIT",
|
||||
"update_any:CREDIT",
|
||||
"delete_own:CREDIT",
|
||||
"delete_any:CREDIT",
|
||||
"create:PROOF",
|
||||
"read:PROOF",
|
||||
"update_own:PROOF",
|
||||
"update_any:PROOF",
|
||||
"delete_own:PROOF",
|
||||
"delete_any:PROOF",
|
||||
"create:DISPROOF",
|
||||
"read:DISPROOF",
|
||||
"update_own:DISPROOF",
|
||||
"update_any:DISPROOF",
|
||||
"delete_own:DISPROOF",
|
||||
"delete_any:DISPROOF",
|
||||
"create:AGREE",
|
||||
"read:AGREE",
|
||||
"update_own:AGREE",
|
||||
"update_any:AGREE",
|
||||
"delete_own:AGREE",
|
||||
"delete_any:AGREE",
|
||||
"create:DISAGREE",
|
||||
"read:DISAGREE",
|
||||
"update_own:DISAGREE",
|
||||
"update_any:DISAGREE",
|
||||
"delete_own:DISAGREE",
|
||||
"delete_any:DISAGREE",
|
||||
"create:SILENT",
|
||||
"read:SILENT",
|
||||
"update_own:SILENT",
|
||||
"update_any:SILENT",
|
||||
"delete_own:SILENT",
|
||||
"delete_any:SILENT"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user