0.7.1-fix
All checks were successful
Deploy on push / deploy (push) Successful in 9s

This commit is contained in:
Untone 2025-07-02 22:49:20 +03:00
parent 82111ed0f6
commit 27c5a57709
7 changed files with 232 additions and 125 deletions

View File

@ -1,6 +1,19 @@
# Changelog # 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 ## [0.7.0] - 2025-07-02
### Исправления RBAC системы в админ-панели ### Исправления RBAC системы в админ-панели

View File

@ -210,13 +210,11 @@ async def validate_graphql_context(info: GraphQLResolveInfo) -> None:
author = session.query(Author).filter(Author.id == auth_state.author_id).one() author = session.query(Author).filter(Author.id == auth_state.author_id).one()
logger.debug(f"[validate_graphql_context] Найден автор: id={author.id}, email={author.email}") logger.debug(f"[validate_graphql_context] Найден автор: id={author.id}, email={author.email}")
# Получаем разрешения из ролей # Создаем объект авторизации с пустыми разрешениями
scopes = await author.get_permissions() # Разрешения будут проверяться через RBAC систему по требованию
# Создаем объект авторизации
auth_cred = AuthCredentials( auth_cred = AuthCredentials(
author_id=author.id, author_id=author.id,
scopes=scopes, scopes={}, # Пустой словарь разрешений
logged_in=True, logged_in=True,
error_message="", error_message="",
email=author.email, email=author.email,

View File

@ -117,8 +117,9 @@ class AuthMiddleware:
token=None, token=None,
), UnauthenticatedUser() ), UnauthenticatedUser()
# Получаем разрешения из ролей # Создаем пустой словарь разрешений
scopes = await author.get_permissions() # Разрешения будут проверяться через RBAC систему по требованию
scopes = {}
# Получаем роли для пользователя # Получаем роли для пользователя
ca = session.query(CommunityAuthor).filter_by(author_id=author.id, community_id=1).first() ca = session.query(CommunityAuthor).filter_by(author_id=author.id, community_id=1).first()

View File

@ -235,7 +235,14 @@ class Community(BaseModel):
if with_roles: if with_roles:
member_info["roles"] = ca.role_list # type: ignore[assignment] 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) members.append(member_info)

View File

@ -9,15 +9,65 @@
"community": ["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"], "draft": ["create", "read", "update_own", "update_any", "delete_own", "delete_any"],
"reaction": [ "reaction": [
"create:LIKE", "read:LIKE", "update_own:LIKE", "update_any:LIKE", "delete_own:LIKE", "delete_any:LIKE", "create:LIKE",
"create:COMMENT", "read:COMMENT", "update_own:COMMENT", "update_any:COMMENT", "delete_own:COMMENT", "delete_any:COMMENT", "read:LIKE",
"create:QUOTE", "read:QUOTE", "update_own:QUOTE", "update_any:QUOTE", "delete_own:QUOTE", "delete_any:QUOTE", "update_own:LIKE",
"create:DISLIKE", "read:DISLIKE", "update_own:DISLIKE", "update_any:DISLIKE", "delete_own:DISLIKE", "delete_any:DISLIKE", "update_any:LIKE",
"create:CREDIT", "read:CREDIT", "update_own:CREDIT", "update_any:CREDIT", "delete_own:CREDIT", "delete_any:CREDIT", "delete_own:LIKE",
"create:PROOF", "read:PROOF", "update_own:PROOF", "update_any:PROOF", "delete_own:PROOF", "delete_any:PROOF", "delete_any:LIKE",
"create:DISPROOF", "read:DISPROOF", "update_own:DISPROOF", "update_any:DISPROOF", "delete_own:DISPROOF", "delete_any:DISPROOF", "create:COMMENT",
"create:AGREE", "read:AGREE", "update_own:AGREE", "update_any:AGREE", "delete_own:AGREE", "delete_any:AGREE", "read:COMMENT",
"create:DISAGREE", "read:DISAGREE", "update_own:DISAGREE", "update_any:DISAGREE", "delete_own:DISAGREE", "delete_any:DISAGREE", "update_own:COMMENT",
"create:SILENT", "read:SILENT", "update_own:SILENT", "update_any:SILENT", "delete_own:SILENT", "delete_any:SILENT" "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"
] ]
} }

View File

@ -14,7 +14,6 @@ from orm.invite import Invite, InviteStatus
from orm.shout import Shout from orm.shout import Shout
from services.db import local_session from services.db import local_session
from services.env import EnvManager, EnvVariable from services.env import EnvManager, EnvVariable
from services.rbac import admin_only
from services.schema import mutation, query from services.schema import mutation, query
from settings import ADMIN_EMAILS as ADMIN_EMAILS_LIST from settings import ADMIN_EMAILS as ADMIN_EMAILS_LIST
from utils.logger import root_logger as logger 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]: def _get_user_roles(user: Author, community_id: int = 1) -> list[str]:
""" """
Получает полный список ролей пользователя в указанном сообществе, включая Получает полный список ролей пользователя в указанном сообществе, включая
@ -86,7 +178,7 @@ async def admin_get_users(
Получает список пользователей для админ-панели с поддержкой пагинации и поиска Получает список пользователей для админ-панели с поддержкой пагинации и поиска
Args: Args:
info: Контекст GraphQL запроса _info: Контекст GraphQL запроса
limit: Максимальное количество записей для получения limit: Максимальное количество записей для получения
offset: Смещение в списке результатов offset: Смещение в списке результатов
search: Строка поиска (по email, имени или ID) search: Строка поиска (по email, имени или ID)
@ -95,9 +187,8 @@ async def admin_get_users(
Пагинированный список пользователей Пагинированный список пользователей
""" """
try: try:
# Нормализуем параметры # Нормализуем параметры пагинации
limit = max(1, min(100, limit or 20)) # Ограничиваем количество записей от 1 до 100 limit, offset = normalize_pagination(limit, offset)
offset = max(0, offset or 0) # Смещение не может быть отрицательным
with local_session() as session: with local_session() as session:
# Базовый запрос # Базовый запрос
@ -117,17 +208,12 @@ async def admin_get_users(
# Получаем общее количество записей # Получаем общее количество записей
total_count = query.count() 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() authors = query.order_by(Author.id).offset(offset).limit(limit).all()
# Вычисляем информацию о пагинации
pagination_info = calculate_pagination_info(total_count, limit, offset)
# Преобразуем в формат для API # Преобразуем в формат для API
return { return {
"authors": [ "authors": [
@ -142,19 +228,11 @@ async def admin_get_users(
} }
for user in authors for user in authors
], ],
"total": total_count, **pagination_info,
"page": current_page,
"perPage": per_page,
"totalPages": total_pages,
} }
except Exception as e: except Exception as e:
import traceback raise handle_admin_error("получении списка пользователей", e) from e
logger.error(f"Ошибка при получении списка пользователей: {e!s}")
logger.error(traceback.format_exc())
msg = f"Не удалось получить список пользователей: {e!s}"
raise GraphQLError(msg) from e
@query.field("adminGetRoles") @query.field("adminGetRoles")
@ -224,7 +302,7 @@ async def admin_get_roles(_: None, info: GraphQLResolveInfo, community: int = No
@query.field("getEnvVariables") @query.field("getEnvVariables")
@admin_only @admin_auth_required
async def get_env_variables(_: None, info: GraphQLResolveInfo) -> list[dict[str, Any]]: async def get_env_variables(_: None, info: GraphQLResolveInfo) -> list[dict[str, Any]]:
""" """
Получает список переменных окружения, сгруппированных по секциям Получает список переменных окружения, сгруппированных по секциям
@ -908,9 +986,8 @@ async def admin_get_invites(
Пагинированный список приглашений Пагинированный список приглашений
""" """
try: try:
# Нормализуем параметры # Нормализуем параметры пагинации
limit = max(1, min(100, limit or 10)) limit, offset = normalize_pagination(limit, offset)
offset = max(0, offset or 0)
with local_session() as session: with local_session() as session:
# Базовый запрос с загрузкой связанных объектов # Базовый запрос с загрузкой связанных объектов
@ -957,26 +1034,19 @@ async def admin_get_invites(
# Получаем общее количество записей # Получаем общее количество записей
total_count = query.count() 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 приглашающего, затем автора, затем публикации) # Применяем пагинацию и сортировку (по ID приглашающего, затем автора, затем публикации)
invites = ( invites = (
query.order_by(Invite.inviter_id, Invite.author_id, Invite.shout_id).offset(offset).limit(limit).all() 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 # Преобразуем в формат для API
result_invites = [] result_invites = []
for invite in invites: for invite in invites:
# Получаем автора публикации # Получаем информацию о создателе публикации
created_by_author = None created_by_info = get_author_info(invite.shout.created_by if invite.shout else None, session)
if invite.shout and invite.shout.created_by:
created_by_author = session.query(Author).filter(Author.id == invite.shout.created_by).first()
invite_dict = { invite_dict = {
"inviter_id": invite.inviter_id, "inviter_id": invite.inviter_id,
@ -987,86 +1057,32 @@ async def admin_get_invites(
"id": invite.inviter.id, "id": invite.inviter.id,
"name": invite.inviter.name or "Без имени", "name": invite.inviter.name or "Без имени",
"email": invite.inviter.email, "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": { "author": {
"id": invite.author.id, "id": invite.author.id,
"name": invite.author.name or "Без имени", "name": invite.author.name or "Без имени",
"email": invite.author.email, "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": { "shout": {
"id": invite.shout.id, "id": invite.shout.id,
"title": invite.shout.title, "title": invite.shout.title,
"slug": invite.shout.slug, "slug": invite.shout.slug,
"created_by": created_by_info,
}, },
"created_at": None, # У приглашений нет created_at поля в текущей модели "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) result_invites.append(invite_dict)
return { return {
"invites": result_invites, "invites": result_invites,
"total": total_count, **pagination_info,
"page": current_page,
"perPage": per_page,
"totalPages": total_pages,
} }
except Exception as e: except Exception as e:
import traceback raise handle_admin_error("получении списка приглашений", e) from e
logger.error(f"Ошибка при получении списка приглашений: {e!s}")
logger.error(traceback.format_exc())
msg = f"Не удалось получить список приглашений: {e!s}"
raise GraphQLError(msg) from e
@mutation.field("adminUpdateInvite") @mutation.field("adminUpdateInvite")

View File

@ -138,6 +138,7 @@ def get_user_roles_in_community(author_id: int, community_id: int) -> list[str]:
Returns: Returns:
Список ролей пользователя в сообществе Список ролей пользователя в сообществе
""" """
try:
from orm.community import CommunityAuthor from orm.community import CommunityAuthor
from services.db import local_session from services.db import local_session
@ -149,6 +150,9 @@ def get_user_roles_in_community(author_id: int, community_id: int) -> list[str]:
) )
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: 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) 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 return user_roles, community_id