refactored
Some checks failed
Deploy on push / deploy (push) Failing after 6s

This commit is contained in:
2025-08-17 17:56:31 +03:00
parent e78e12eeee
commit 9a2b792f08
98 changed files with 702 additions and 904 deletions

View File

@@ -5,7 +5,7 @@ from starlette.responses import JSONResponse, RedirectResponse, Response
from auth.core import verify_internal_auth
from auth.orm import Author
from auth.tokens.storage import TokenStorage
from services.db import local_session
from storage.db import local_session
from settings import (
SESSION_COOKIE_HTTPONLY,
SESSION_COOKIE_MAX_AGE,

View File

@@ -4,12 +4,14 @@
"""
import time
from sqlalchemy.orm.exc import NoResultFound
from auth.orm import Author
from auth.state import AuthState
from auth.tokens.storage import TokenStorage as TokenManager
from auth.orm import Author
from orm.community import CommunityAuthor
from services.db import local_session
from storage.db import local_session
from settings import ADMIN_EMAILS as ADMIN_EMAILS_LIST
from utils.logger import root_logger as logger
@@ -50,7 +52,7 @@ async def verify_internal_auth(token: str) -> tuple[int, list, bool]:
with local_session() as session:
try:
# Author уже импортирован в начале файла
author = session.query(Author).where(Author.id == user_id).one()
# Получаем роли
@@ -112,14 +114,14 @@ async def get_auth_token_from_request(request) -> str | None:
"""
# Отложенный импорт для избежания циклических зависимостей
from auth.decorators import get_auth_token
return await get_auth_token(request)
async def authenticate(request) -> AuthState:
"""
Получает токен из запроса и проверяет авторизацию.
Args:
request: Объект запроса
@@ -146,4 +148,3 @@ async def authenticate(request) -> AuthState:
auth_state.author_id = str(user_id)
auth_state.is_admin = is_admin
return auth_state

View File

@@ -5,28 +5,20 @@ from typing import Any
from graphql import GraphQLError, GraphQLResolveInfo
from sqlalchemy import exc
from auth.credentials import AuthCredentials
from auth.exceptions import OperationNotAllowedError
# Импорт базовых функций из реструктурированных модулей
from auth.core import authenticate
from auth.utils import get_auth_token
from auth.credentials import AuthCredentials
from auth.exceptions import OperationNotAllowedError
from auth.orm import Author
from auth.utils import get_auth_token, get_safe_headers
from orm.community import CommunityAuthor
from services.db import local_session
from services.redis import redis as redis_adapter
from storage.db import local_session
from settings import ADMIN_EMAILS as ADMIN_EMAILS_LIST
from utils.logger import root_logger as logger
ADMIN_EMAILS = ADMIN_EMAILS_LIST.split(",")
# Импортируем get_safe_headers из utils
from auth.utils import get_safe_headers
# get_auth_token теперь импортирован из auth.utils
async def validate_graphql_context(info: GraphQLResolveInfo) -> None:
"""
Проверяет валидность GraphQL контекста и проверяет авторизацию.

View File

@@ -4,8 +4,8 @@ from auth.exceptions import ExpiredTokenError, InvalidPasswordError, InvalidToke
from auth.jwtcodec import JWTCodec
from auth.orm import Author
from auth.password import Password
from services.db import local_session
from services.redis import redis
from storage.db import local_session
from storage.redis import redis
from utils.logger import root_logger as logger
AuthorType = TypeVar("AuthorType", bound=Author)

View File

@@ -7,7 +7,7 @@ DEPRECATED: Этот модуль переносится в auth/core.py
"""
# Импорт базовых функций из core модуля
from auth.core import verify_internal_auth, create_internal_session, authenticate
from auth.core import authenticate, create_internal_session, verify_internal_auth
# Re-export для обратной совместимости
__all__ = ["verify_internal_auth", "create_internal_session", "authenticate"]
__all__ = ["authenticate", "create_internal_session", "verify_internal_auth"]

View File

@@ -40,9 +40,7 @@ class JWTCodec:
# Если время истечения не указано, устанавливаем дефолтное
if not expiration:
expiration = datetime.datetime.now(datetime.UTC) + datetime.timedelta(
days=JWT_REFRESH_TOKEN_EXPIRE_DAYS
)
expiration = datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=JWT_REFRESH_TOKEN_EXPIRE_DAYS)
logger.debug(f"[JWTCodec.encode] Время истечения не указано, устанавливаем срок: {expiration}")
# Формируем payload с временными метками

View File

@@ -17,8 +17,8 @@ from starlette.types import ASGIApp
from auth.credentials import AuthCredentials
from auth.orm import Author
from auth.tokens.storage import TokenStorage as TokenManager
from services.db import local_session
from services.redis import redis as redis_adapter
from storage.db import local_session
from storage.redis import redis as redis_adapter
from settings import (
ADMIN_EMAILS as ADMIN_EMAILS_LIST,
)

View File

@@ -13,8 +13,8 @@ from starlette.responses import JSONResponse, RedirectResponse
from auth.orm import Author
from auth.tokens.storage import TokenStorage
from orm.community import Community, CommunityAuthor, CommunityFollower
from services.db import local_session
from services.redis import redis
from storage.db import local_session
from storage.redis import redis
from settings import (
FRONTEND_URL,
OAUTH_CLIENTS,

View File

@@ -1,163 +0,0 @@
"""
Модуль для проверки разрешений пользователей в контексте сообществ.
Позволяет проверять доступ пользователя к определенным операциям в сообществе
на основе его роли в этом сообществе.
"""
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

View File

@@ -1,80 +0,0 @@
"""
Интерфейс для RBAC операций, исключающий циркулярные импорты.
Этот модуль содержит только типы и абстрактные интерфейсы,
не импортирует ORM модели и не создает циклических зависимостей.
"""
from abc import ABC, abstractmethod
from typing import Any, Protocol
class RBACOperations(Protocol):
"""
Протокол для RBAC операций, позволяющий ORM моделям
выполнять операции с правами без прямого импорта services.rbac
"""
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
_rbac_operations = ops
def set_community_queries(queries: CommunityAuthorQueries) -> None:
"""Устанавливает реализацию запросов сообщества"""
global _community_queries
_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

View File

@@ -3,7 +3,6 @@
"""
class AuthState:
"""
Класс для хранения информации о состоянии авторизации пользователя.

View File

@@ -6,7 +6,7 @@ import asyncio
from typing import Any, Dict, List
from auth.jwtcodec import JWTCodec
from services.redis import redis as redis_adapter
from storage.redis import redis as redis_adapter
from utils.logger import root_logger as logger
from .base import BaseTokenManager

View File

@@ -5,7 +5,7 @@
import asyncio
from typing import Any, Dict
from services.redis import redis as redis_adapter
from storage.redis import redis as redis_adapter
from utils.logger import root_logger as logger
from .base import BaseTokenManager

View File

@@ -5,7 +5,7 @@
import json
import time
from services.redis import redis as redis_adapter
from storage.redis import redis as redis_adapter
from utils.logger import root_logger as logger
from .base import BaseTokenManager
@@ -84,9 +84,7 @@ class OAuthTokenManager(BaseTokenManager):
return await self._get_oauth_data_optimized(token_type, str(user_id), provider)
return None
async def _get_oauth_data_optimized(
self, token_type: TokenType, user_id: str, provider: str
) -> TokenData | None:
async def _get_oauth_data_optimized(self, token_type: TokenType, user_id: str, provider: str) -> TokenData | None:
"""Оптимизированное получение OAuth данных"""
if not user_id or not provider:
error_msg = "OAuth токены требуют user_id и provider"

View File

@@ -7,7 +7,7 @@ import time
from typing import Any, List
from auth.jwtcodec import JWTCodec
from services.redis import redis as redis_adapter
from storage.redis import redis as redis_adapter
from utils.logger import root_logger as logger
from .base import BaseTokenManager

View File

@@ -6,7 +6,7 @@ import json
import secrets
import time
from services.redis import redis as redis_adapter
from storage.redis import redis as redis_adapter
from utils.logger import root_logger as logger
from .base import BaseTokenManager

View File

@@ -4,6 +4,7 @@
"""
from typing import Any
from settings import SESSION_COOKIE_NAME, SESSION_TOKEN_HEADER
from utils.logger import root_logger as logger
@@ -113,26 +114,25 @@ async def get_auth_token(request: Any) -> str | None:
token = auth_header.replace("Bearer ", "", 1).strip()
logger.debug(f"[decorators] Извлечен Bearer токен: {len(token)}")
return token
else:
logger.debug("[decorators] Authorization заголовок не содержит Bearer токен")
logger.debug("[decorators] Authorization заголовок не содержит Bearer токен")
# 6. Проверяем cookies
if hasattr(request, "cookies") and request.cookies:
if isinstance(request.cookies, dict):
cookies = request.cookies
elif hasattr(request.cookies, "get"):
cookies = {k: request.cookies.get(k) for k in getattr(request.cookies, "keys", lambda: [])()}
cookies = {k: request.cookies.get(k) for k in getattr(request.cookies, "keys", list)()}
else:
cookies = {}
logger.debug(f"[decorators] Доступные cookies: {list(cookies.keys())}")
# Проверяем кастомную cookie
if SESSION_COOKIE_NAME in cookies:
token = cookies[SESSION_COOKIE_NAME]
logger.debug(f"[decorators] Токен найден в cookie {SESSION_COOKIE_NAME}: {len(token)}")
return token
# Проверяем стандартную cookie
if "auth_token" in cookies:
token = cookies["auth_token"]
@@ -150,29 +150,29 @@ async def get_auth_token(request: Any) -> str | None:
def extract_bearer_token(auth_header: str) -> str | None:
"""
Извлекает токен из заголовка Authorization с Bearer схемой.
Args:
auth_header: Заголовок Authorization
Returns:
Optional[str]: Извлеченный токен или None
"""
if not auth_header:
return None
if auth_header.startswith("Bearer "):
return auth_header[7:].strip()
return None
def format_auth_header(token: str) -> str:
"""
Форматирует токен в заголовок Authorization.
Args:
token: Токен авторизации
Returns:
str: Отформатированный заголовок
"""