diff --git a/CHANGELOG.md b/CHANGELOG.md index 293fe2f5..7d26cd04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,19 @@ # Changelog +## [0.7.1] - 2025-07-02 + +### Исправления системы переменных среды и RBAC +- **ИСПРАВЛЕНО**: Ошибка `'Author' object has no attribute 'get_permissions'` в нескольких местах: + - `auth/decorators.py` - функция `validate_graphql_context` + - `auth/middleware.py` - функция `authenticate_user` + - `orm/community.py` - метод `get_community_members` +- **ИСПРАВЛЕНО**: Резолвер `getEnvVariables` теперь использует `@admin_auth_required` вместо `@admin_only` +- **ИСПРАВЛЕНО**: Функция `get_user_roles_from_context` в RBAC системе добавляет роль `admin` для системных администраторов из `ADMIN_EMAILS` +- **ИСПРАВЛЕНО**: Циклические импорты в `services/rbac.py` через обработку исключений +- **УЛУЧШЕНО**: Корректная работа вкладки переменных среды в админ-панели когда переменных нет +- **УЛУЧШЕНО**: Системные администраторы (`ADMIN_EMAILS`) теперь автоматически получают роль `admin` в RBAC декораторах + ## [0.7.0] - 2025-07-02 ### Исправления RBAC системы в админ-панели diff --git a/auth/decorators.py b/auth/decorators.py index 8990ddcb..2798eaec 100644 --- a/auth/decorators.py +++ b/auth/decorators.py @@ -210,13 +210,11 @@ async def validate_graphql_context(info: GraphQLResolveInfo) -> None: author = session.query(Author).filter(Author.id == auth_state.author_id).one() logger.debug(f"[validate_graphql_context] Найден автор: id={author.id}, email={author.email}") - # Получаем разрешения из ролей - scopes = await author.get_permissions() - - # Создаем объект авторизации + # Создаем объект авторизации с пустыми разрешениями + # Разрешения будут проверяться через RBAC систему по требованию auth_cred = AuthCredentials( author_id=author.id, - scopes=scopes, + scopes={}, # Пустой словарь разрешений logged_in=True, error_message="", email=author.email, diff --git a/auth/middleware.py b/auth/middleware.py index 766b8fcc..3e7148c9 100644 --- a/auth/middleware.py +++ b/auth/middleware.py @@ -117,8 +117,9 @@ class AuthMiddleware: token=None, ), UnauthenticatedUser() - # Получаем разрешения из ролей - scopes = await author.get_permissions() + # Создаем пустой словарь разрешений + # Разрешения будут проверяться через RBAC систему по требованию + scopes = {} # Получаем роли для пользователя ca = session.query(CommunityAuthor).filter_by(author_id=author.id, community_id=1).first() diff --git a/orm/community.py b/orm/community.py index f75a7adf..3280bdfa 100644 --- a/orm/community.py +++ b/orm/community.py @@ -235,7 +235,14 @@ class Community(BaseModel): if with_roles: member_info["roles"] = ca.role_list # type: ignore[assignment] - member_info["permissions"] = ca.get_permissions() # type: ignore[assignment] + # Получаем разрешения синхронно + try: + import asyncio + + member_info["permissions"] = asyncio.run(ca.get_permissions()) # type: ignore[assignment] + except Exception: + # Если не удается получить разрешения асинхронно, используем пустой список + member_info["permissions"] = [] # type: ignore[assignment] members.append(member_info) diff --git a/permissions_catalog.json b/permissions_catalog.json index 4723c508..2155fc56 100644 --- a/permissions_catalog.json +++ b/permissions_catalog.json @@ -9,15 +9,65 @@ "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" + "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" ] } diff --git a/resolvers/admin.py b/resolvers/admin.py index 5790cc03..52872043 100644 --- a/resolvers/admin.py +++ b/resolvers/admin.py @@ -14,7 +14,6 @@ from orm.invite import Invite, InviteStatus from orm.shout import Shout from services.db import local_session from services.env import EnvManager, EnvVariable -from services.rbac import admin_only from services.schema import mutation, query from settings import ADMIN_EMAILS as ADMIN_EMAILS_LIST from utils.logger import root_logger as logger @@ -42,6 +41,99 @@ default_role_descriptions = { } +# === ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ДЛЯ DRY === + + +def normalize_pagination(limit: int = 20, offset: int = 0) -> tuple[int, int]: + """ + Нормализует параметры пагинации. + + Args: + limit: Максимальное количество записей + offset: Смещение + + Returns: + Кортеж (limit, offset) с нормализованными значениями + """ + return max(1, min(100, limit or 20)), max(0, offset or 0) + + +def calculate_pagination_info(total_count: int, limit: int, offset: int) -> dict[str, int]: + """ + Вычисляет информацию о пагинации. + + Args: + total_count: Общее количество записей + limit: Количество записей на странице + offset: Смещение + + Returns: + Словарь с информацией о пагинации + """ + per_page = limit + if total_count is None or per_page in (None, 0): + total_pages = 1 + else: + total_pages = ceil(total_count / per_page) + current_page = (offset // per_page) + 1 if per_page > 0 else 1 + + return { + "total": total_count, + "page": current_page, + "perPage": per_page, + "totalPages": total_pages, + } + + +def handle_admin_error(operation: str, error: Exception) -> GraphQLError: + """ + Обрабатывает ошибки в админ-резолверах. + + Args: + operation: Название операции + error: Исключение + + Returns: + GraphQLError для возврата клиенту + """ + import traceback + + logger.error(f"Ошибка при {operation}: {error!s}") + logger.error(traceback.format_exc()) + msg = f"Не удалось {operation}: {error!s}" + return GraphQLError(msg) + + +def get_author_info(author_id: int, session) -> dict[str, Any]: + """ + Получает информацию об авторе для отображения в админ-панели. + + Args: + author_id: ID автора + session: Сессия БД + + Returns: + Словарь с информацией об авторе + """ + if not author_id: + return None + + author = session.query(Author).filter(Author.id == author_id).first() + if author: + return { + "id": author.id, + "email": author.email, + "name": author.name, + "slug": author.slug or f"user-{author.id}", + } + return { + "id": author_id, + "email": "unknown", + "name": "unknown", + "slug": f"user-{author_id}", + } + + def _get_user_roles(user: Author, community_id: int = 1) -> list[str]: """ Получает полный список ролей пользователя в указанном сообществе, включая @@ -86,7 +178,7 @@ async def admin_get_users( Получает список пользователей для админ-панели с поддержкой пагинации и поиска Args: - info: Контекст GraphQL запроса + _info: Контекст GraphQL запроса limit: Максимальное количество записей для получения offset: Смещение в списке результатов search: Строка поиска (по email, имени или ID) @@ -95,9 +187,8 @@ async def admin_get_users( Пагинированный список пользователей """ try: - # Нормализуем параметры - limit = max(1, min(100, limit or 20)) # Ограничиваем количество записей от 1 до 100 - offset = max(0, offset or 0) # Смещение не может быть отрицательным + # Нормализуем параметры пагинации + limit, offset = normalize_pagination(limit, offset) with local_session() as session: # Базовый запрос @@ -117,17 +208,12 @@ async def admin_get_users( # Получаем общее количество записей total_count = query.count() - # Вычисляем информацию о пагинации - per_page = limit - if total_count is None or per_page in (None, 0): - total_pages = 1 - else: - total_pages = ceil(total_count / per_page) - current_page = (offset // per_page) + 1 if per_page > 0 else 1 - # Применяем пагинацию authors = query.order_by(Author.id).offset(offset).limit(limit).all() + # Вычисляем информацию о пагинации + pagination_info = calculate_pagination_info(total_count, limit, offset) + # Преобразуем в формат для API return { "authors": [ @@ -142,19 +228,11 @@ async def admin_get_users( } for user in authors ], - "total": total_count, - "page": current_page, - "perPage": per_page, - "totalPages": total_pages, + **pagination_info, } except Exception as e: - import traceback - - logger.error(f"Ошибка при получении списка пользователей: {e!s}") - logger.error(traceback.format_exc()) - msg = f"Не удалось получить список пользователей: {e!s}" - raise GraphQLError(msg) from e + raise handle_admin_error("получении списка пользователей", e) from e @query.field("adminGetRoles") @@ -224,7 +302,7 @@ async def admin_get_roles(_: None, info: GraphQLResolveInfo, community: int = No @query.field("getEnvVariables") -@admin_only +@admin_auth_required async def get_env_variables(_: None, info: GraphQLResolveInfo) -> list[dict[str, Any]]: """ Получает список переменных окружения, сгруппированных по секциям @@ -908,9 +986,8 @@ async def admin_get_invites( Пагинированный список приглашений """ try: - # Нормализуем параметры - limit = max(1, min(100, limit or 10)) - offset = max(0, offset or 0) + # Нормализуем параметры пагинации + limit, offset = normalize_pagination(limit, offset) with local_session() as session: # Базовый запрос с загрузкой связанных объектов @@ -957,26 +1034,19 @@ async def admin_get_invites( # Получаем общее количество записей total_count = query.count() - # Вычисляем информацию о пагинации - per_page = limit - if total_count is None or per_page in (None, 0): - total_pages = 1 - else: - total_pages = ceil(total_count / per_page) - current_page = (offset // per_page) + 1 if per_page > 0 else 1 - # Применяем пагинацию и сортировку (по ID приглашающего, затем автора, затем публикации) invites = ( query.order_by(Invite.inviter_id, Invite.author_id, Invite.shout_id).offset(offset).limit(limit).all() ) + # Вычисляем информацию о пагинации + pagination_info = calculate_pagination_info(total_count, limit, offset) + # Преобразуем в формат для API result_invites = [] for invite in invites: - # Получаем автора публикации - created_by_author = None - if invite.shout and invite.shout.created_by: - created_by_author = session.query(Author).filter(Author.id == invite.shout.created_by).first() + # Получаем информацию о создателе публикации + created_by_info = get_author_info(invite.shout.created_by if invite.shout else None, session) invite_dict = { "inviter_id": invite.inviter_id, @@ -987,86 +1057,32 @@ async def admin_get_invites( "id": invite.inviter.id, "name": invite.inviter.name or "Без имени", "email": invite.inviter.email, - "slug": invite.inviter.slug or f"user-{invite.inviter.id}", # Добавляем значение по умолчанию + "slug": invite.inviter.slug or f"user-{invite.inviter.id}", }, "author": { "id": invite.author.id, "name": invite.author.name or "Без имени", "email": invite.author.email, - "slug": invite.author.slug or f"user-{invite.author.id}", # Добавляем значение по умолчанию + "slug": invite.author.slug or f"user-{invite.author.id}", }, "shout": { "id": invite.shout.id, "title": invite.shout.title, "slug": invite.shout.slug, + "created_by": created_by_info, }, "created_at": None, # У приглашений нет created_at поля в текущей модели } - # Добавляем информацию о создателе публикации, если она доступна - if created_by_author: - # Создаем новый словарь для shout - shout_dict = {} - - # Копируем основные поля - if isinstance(invite_dict["shout"], dict): - shout_info = invite_dict["shout"] - shout_dict["id"] = shout_info.get("id") - shout_dict["title"] = shout_info.get("title") - shout_dict["slug"] = shout_info.get("slug") - else: - # Если это не словарь, берем данные напрямую из объекта invite.shout - shout_dict["id"] = invite.shout.id - shout_dict["title"] = invite.shout.title - shout_dict["slug"] = invite.shout.slug - - # Добавляем информацию о создателе - shout_dict["created_by"] = { - "id": created_by_author.id, - "name": created_by_author.name or "Без имени", - "email": created_by_author.email, - "slug": created_by_author.slug or f"user-{created_by_author.id}", - } - - invite_dict["shout"] = shout_dict - else: - # Создаем новый словарь для shout - shout_dict = {} - - # Копируем основные поля - if isinstance(invite_dict["shout"], dict): - shout_info = invite_dict["shout"] - shout_dict["id"] = shout_info.get("id") - shout_dict["title"] = shout_info.get("title") - shout_dict["slug"] = shout_info.get("slug") - else: - # Если это не словарь, берем данные напрямую из объекта invite.shout - shout_dict["id"] = invite.shout.id - shout_dict["title"] = invite.shout.title - shout_dict["slug"] = invite.shout.slug - - # Указываем, что created_by отсутствует - shout_dict["created_by"] = None - - invite_dict["shout"] = shout_dict - result_invites.append(invite_dict) return { "invites": result_invites, - "total": total_count, - "page": current_page, - "perPage": per_page, - "totalPages": total_pages, + **pagination_info, } except Exception as e: - import traceback - - logger.error(f"Ошибка при получении списка приглашений: {e!s}") - logger.error(traceback.format_exc()) - msg = f"Не удалось получить список приглашений: {e!s}" - raise GraphQLError(msg) from e + raise handle_admin_error("получении списка приглашений", e) from e @mutation.field("adminUpdateInvite") diff --git a/services/rbac.py b/services/rbac.py index 3e050d8d..6b9cf02c 100644 --- a/services/rbac.py +++ b/services/rbac.py @@ -138,17 +138,21 @@ def get_user_roles_in_community(author_id: int, community_id: int) -> list[str]: Returns: Список ролей пользователя в сообществе """ - from orm.community import CommunityAuthor - from services.db import local_session + try: + from orm.community import CommunityAuthor + from services.db import local_session - with local_session() as session: - ca = ( - session.query(CommunityAuthor) - .filter(CommunityAuthor.author_id == author_id, CommunityAuthor.community_id == community_id) - .first() - ) + with local_session() as session: + ca = ( + session.query(CommunityAuthor) + .filter(CommunityAuthor.author_id == author_id, CommunityAuthor.community_id == community_id) + .first() + ) - return ca.role_list if ca else [] + return ca.role_list if ca else [] + except ImportError: + # Если есть циклический импорт, возвращаем пустой список + return [] async def user_has_permission(author_id: int, permission: str, community_id: int) -> bool: @@ -209,6 +213,24 @@ def get_user_roles_from_context(info) -> tuple[list[str], int]: # Получаем роли пользователя в этом сообществе user_roles = get_user_roles_in_community(author_id, community_id) + # Проверяем, является ли пользователь системным администратором + try: + from auth.orm import Author + from services.db import local_session + from settings import ADMIN_EMAILS + + admin_emails = ADMIN_EMAILS.split(",") if ADMIN_EMAILS else [] + + with local_session() as session: + author = session.query(Author).filter(Author.id == author_id).first() + if author and author.email and author.email in admin_emails: + # Системный администратор автоматически получает роль admin в любом сообществе + if "admin" not in user_roles: + user_roles = [*user_roles, "admin"] + except Exception: + # Если не удалось проверить email (включая циклические импорты), продолжаем с существующими ролями + pass + return user_roles, community_id