This commit is contained in:
2026-06-03 13:52:45 +03:00
commit f7309c4b4a
140 changed files with 7134 additions and 0 deletions

View File

@@ -0,0 +1 @@
from src.presentation.decorators.admin_auth import require_admin_access, require_admin_role

View File

@@ -0,0 +1,47 @@
from fastapi import Depends, Request
from fastapi.security.utils import get_authorization_scheme_param
from src.application.contracts import IJwtService
from src.application.domain.dto import AdminAuthContext
from src.application.domain.exceptions import ApplicationException
from src.presentation.dependencies.security import get_jwt_service
def _extract_bearer_token(request: Request) -> str | None:
auth = request.headers.get('Authorization')
if not auth:
return None
scheme, param = get_authorization_scheme_param(auth)
if scheme.lower() == 'bearer' and param:
return param
return None
async def require_admin_access(
request: Request,
jwt_service: IJwtService = Depends(get_jwt_service),
) -> AdminAuthContext:
token = _extract_bearer_token(request)
if not token:
raise ApplicationException(status_code=401, message='Authorization Bearer token required')
payload = await jwt_service.decode_access_token(token)
if payload.type != 'access':
raise ApplicationException(status_code=401, message='Invalid token type')
role = payload.role
if not role:
raise ApplicationException(status_code=401, message='Token missing role')
return AdminAuthContext(admin_user_id=payload.sub, role=role)
def require_admin_role(*allowed_roles: str):
allowed = set(allowed_roles)
async def dependency(auth: AdminAuthContext = Depends(require_admin_access)) -> AdminAuthContext:
if auth.role not in allowed:
raise ApplicationException(status_code=403, message='Insufficient permissions')
return auth
return dependency

View File

@@ -0,0 +1,61 @@
from __future__ import annotations
import inspect
from functools import wraps
from typing import Callable, Awaitable, Any, Optional, Annotated
from fastapi import Request, Header
from src.application.domain.exceptions import ApplicationException
from src.infrastructure.security import CsrfService
def csrf_protect(
expected_subject_getter: Optional[Callable[[Request], Optional[str]]] = None,
):
def decorator(func: Callable[..., Awaitable[Any]]):
sig = inspect.signature(func)
params = list(sig.parameters.values())
has_request = any(p.annotation is Request or p.name == 'request' for p in params)
if not has_request:
raise RuntimeError('csrf_protect requires endpoint to accept `request: Request`')
has_header = any(p.name == 'x_csrf_token' for p in params)
if not has_header:
params.append(
inspect.Parameter(
name='x_csrf_token',
kind=inspect.Parameter.KEYWORD_ONLY,
default=None,
annotation=Annotated[str | None, Header(alias='X-CSRF-Token')],
)
)
@wraps(func)
async def wrapper(*args, **kwargs):
request: Request | None = kwargs.get('request')
if request is None:
for arg in args:
if isinstance(arg, Request):
request = arg
break
if request is None:
raise ApplicationException(
status_code=500,
message='Request is required for CSRF protection',
)
csrf = CsrfService()
cookie_token, _ = csrf.extract(request.cookies, request.headers)
header_token = kwargs.get('x_csrf_token')
expected_subject = expected_subject_getter(request) if expected_subject_getter else None
csrf.verify_pair(cookie_token, header_token, expected_subject)
kwargs.pop('x_csrf_token', None)
return await func(*args, **kwargs)
wrapper.__signature__ = sig.replace(parameters=params)
return wrapper
return decorator

View File

@@ -0,0 +1,171 @@
from __future__ import annotations
import functools
import inspect
import hashlib
from typing import Any, Awaitable, Callable, Literal, Optional, Protocol, runtime_checkable
from fastapi import Request
from redis.asyncio.client import Redis
from src.application.contracts import ILogger
from src.application.domain.exceptions import ApplicationException
from src.infrastructure.logger import get_logger
from src.presentation.dependencies import get_redis
def _find_request(args: tuple[Any, ...], kwargs: dict[str, Any]) -> Request:
req = kwargs.get('request')
if isinstance(req, Request):
return req
for a in args:
if isinstance(a, Request):
return a
raise RuntimeError('rate_limit decorator requires fastapi.Request argument')
def _client_ip(request: Request) -> str:
xff = request.headers.get('x-forwarded-for')
if xff:
return xff.split(',')[0].strip()
if request.client:
return request.client.host
return 'unknown'
_LUA_INCR_EXPIRE_TTL = '''
local key = KEYS[1]
local window = tonumber(ARGV[1])
local current = redis.call('INCR', key)
if current == 1 then
redis.call('EXPIRE', key, window)
end
local ttl = redis.call('TTL', key)
return { current, ttl }
'''
Scope = Literal['ip', 'device', 'user', 'key']
@runtime_checkable
class KeyBuilder1(Protocol):
def __call__(self, request: Request) -> str: ...
@runtime_checkable
class KeyBuilder3(Protocol):
def __call__(self, request: Request, args: tuple[Any, ...], kwargs: dict[str, Any]) -> str: ...
KeyBuilder = KeyBuilder1 | KeyBuilder3
def _call_key_builder(builder: KeyBuilder, request: Request, args: tuple[Any, ...], kwargs: dict[str, Any]) -> str:
try:
sig = inspect.signature(builder)
if len(sig.parameters) >= 3:
return builder(request, args, kwargs)
return builder(request)
except Exception as e:
try:
return builder(request, args, kwargs)
except Exception:
raise e
def _email_rl_key(request: Request, args: tuple[Any, ...], kwargs: dict[str, Any]) -> str:
body = kwargs.get('body')
if body is None and args:
for a in args:
if hasattr(a, 'email'):
body = a
break
email = (getattr(body, 'email', '') or '').strip().lower()
if not email:
email = _client_ip(request)
digest = hashlib.sha256(email.encode('utf-8')).hexdigest()[:24]
return f'email:{digest}'
def rate_limit(
*,
limit: int,
window_seconds: int,
scope: Scope = 'ip',
key_prefix: str = 'rl',
key_builder: Optional[KeyBuilder] = None,
fail_open: bool = True,
) -> Callable[[Callable[..., Awaitable[Any]]], Callable[..., Awaitable[Any]]]:
if limit <= 0:
raise ValueError('rate_limit: limit must be > 0')
if window_seconds <= 0:
raise ValueError('rate_limit: window_seconds must be > 0')
if scope == 'key' and not key_builder:
raise ValueError('rate_limit: scope="key" requires key_builder')
def decorator(func: Callable[..., Awaitable[Any]]):
@functools.wraps(func)
async def wrapper(*args: Any, **kwargs: Any):
request = _find_request(args, kwargs)
logger: ILogger = get_logger()
if scope == 'ip':
ident = _client_ip(request)
elif scope == 'device':
ident = request.cookies.get('device_id') or _client_ip(request)
elif scope == 'user':
user = getattr(request.state, 'user', None)
user_id = getattr(user, 'id', None) if user else None
ident = str(user_id) if user_id else _client_ip(request)
else:
try:
ident = _call_key_builder(key_builder, request, args, kwargs) # type: ignore[arg-type]
except Exception as e:
logger.error(f'RateLimit key_builder failed error={str(e)}')
raise ApplicationException(500, 'Rate limiter key_builder failed')
route = request.url.path
method = request.method
redis_key = f'{key_prefix}:{scope}:{method}:{route}:{ident}'
logger.debug(f'RateLimit check key={redis_key} limit={limit} window={window_seconds}')
try:
redis: Redis = get_redis(request)
result = await redis.eval(
_LUA_INCR_EXPIRE_TTL,
1,
redis_key,
str(window_seconds),
)
count = int(result[0])
ttl_raw = int(result[1]) if result and len(result) > 1 else window_seconds
ttl = window_seconds if ttl_raw < 0 else ttl_raw
except Exception as e:
logger.error(f'RateLimit redis failure key={redis_key} error={str(e)}')
if fail_open:
logger.warning(f'RateLimit fail-open activated key={redis_key}')
return await func(*args, **kwargs)
raise ApplicationException(503, 'Rate limiter unavailable')
if count > limit:
retry_after = max(ttl, 0)
logger.warning(f'RateLimit exceeded key={redis_key} count={count} limit={limit} retry_after={retry_after}')
raise ApplicationException(
status_code=429,
message='Too Many Requests',
headers={'Retry-After': str(retry_after)},
)
logger.debug(f'RateLimit passed key={redis_key} count={count}')
return await func(*args, **kwargs)
return wrapper
return decorator

View File

@@ -0,0 +1,6 @@
from src.presentation.dependencies.commands import (
get_admin_login_command,
get_admin_logout_command,
get_admin_jwt_refresh_command,
get_admin_me_command,
)

View File

@@ -0,0 +1,12 @@
from fastapi import Depends, Request
from redis.asyncio.client import Redis
from src.application.contracts import ICache
from src.infrastructure.cache import KeydbCache
def get_redis(request: Request) -> Redis:
return request.app.state.redis
def get_cache(redis_client: Redis = Depends(get_redis)) -> ICache:
return KeydbCache(redis_client)

View File

@@ -0,0 +1,145 @@
from functools import lru_cache
from fastapi import Depends
from src.application.abstractions import IUnitOfWork
from src.application.commands import (
AdminLoginCommand,
GetAdminMeCommand,
CreateOrganizationCommand,
CreateOrganizationWalletsCommand,
GetOrganizationCommand,
GetPurchaseRequestCommand,
ListOrganizationsCommand,
GetOrganizationDocumentCommand,
ListOrganizationDocumentsCommand,
ListPurchaseRequestsCommand,
SetPurchaseRequestQuoteCommand,
UpdateOrganizationCommand,
UpdatePurchaseRequestStatusCommand,
UploadOrganizationDocumentCommand,
)
from src.application.contracts import IHashService, IJwtService, ILogger
from src.infrastructure.config import settings
from src.infrastructure.storage.s3_documents_service import S3DocumentsService
from src.presentation.dependencies.logger import get_logger
from src.presentation.dependencies.security import get_hash_service, get_jwt_service
from src.presentation.dependencies.unit_of_work import get_unit_of_work
@lru_cache(maxsize=1)
def _s3_documents_service() -> S3DocumentsService:
return S3DocumentsService(
bucket=settings.LEGAL_DOCS_S3_BUCKET,
region=settings.LEGAL_DOCS_S3_REGION,
access_key_id=settings.LEGAL_DOCS_S3_ACCESS_KEY_ID or None,
secret_access_key=settings.LEGAL_DOCS_S3_SECRET_ACCESS_KEY or None,
endpoint_url=settings.LEGAL_DOCS_S3_ENDPOINT_URL or None,
key_prefix=settings.LEGAL_DOCS_S3_KEY_PREFIX,
presigned_ttl_seconds=settings.LEGAL_DOCS_S3_PRESIGNED_TTL_SECONDS,
)
def get_s3_documents_service() -> S3DocumentsService:
return _s3_documents_service()
def get_admin_login_command(
uow: IUnitOfWork = Depends(get_unit_of_work),
hash_service: IHashService = Depends(get_hash_service),
jwt_service: IJwtService = Depends(get_jwt_service),
logger: ILogger = Depends(get_logger),
) -> AdminLoginCommand:
return AdminLoginCommand(uow, hash_service, jwt_service, logger)
def get_admin_me_command(
uow: IUnitOfWork = Depends(get_unit_of_work),
logger: ILogger = Depends(get_logger),
) -> GetAdminMeCommand:
return GetAdminMeCommand(uow, logger)
def get_create_organization_command(
uow: IUnitOfWork = Depends(get_unit_of_work),
hash_service: IHashService = Depends(get_hash_service),
logger: ILogger = Depends(get_logger),
) -> CreateOrganizationCommand:
return CreateOrganizationCommand(uow, hash_service, logger)
def get_create_organization_wallets_command(
uow: IUnitOfWork = Depends(get_unit_of_work),
logger: ILogger = Depends(get_logger),
) -> CreateOrganizationWalletsCommand:
return CreateOrganizationWalletsCommand(uow, logger)
def get_upload_organization_document_command(
uow: IUnitOfWork = Depends(get_unit_of_work),
logger: ILogger = Depends(get_logger),
) -> UploadOrganizationDocumentCommand:
return UploadOrganizationDocumentCommand(uow, get_s3_documents_service(), logger)
def get_list_organizations_command(
uow: IUnitOfWork = Depends(get_unit_of_work),
logger: ILogger = Depends(get_logger),
) -> ListOrganizationsCommand:
return ListOrganizationsCommand(uow, logger)
def get_get_organization_command(
uow: IUnitOfWork = Depends(get_unit_of_work),
logger: ILogger = Depends(get_logger),
) -> GetOrganizationCommand:
return GetOrganizationCommand(uow, logger)
def get_update_organization_command(
uow: IUnitOfWork = Depends(get_unit_of_work),
logger: ILogger = Depends(get_logger),
) -> UpdateOrganizationCommand:
return UpdateOrganizationCommand(uow, logger)
def get_list_organization_documents_command(
uow: IUnitOfWork = Depends(get_unit_of_work),
logger: ILogger = Depends(get_logger),
) -> ListOrganizationDocumentsCommand:
return ListOrganizationDocumentsCommand(uow, logger)
def get_get_organization_document_command(
uow: IUnitOfWork = Depends(get_unit_of_work),
logger: ILogger = Depends(get_logger),
) -> GetOrganizationDocumentCommand:
return GetOrganizationDocumentCommand(uow, logger)
def get_list_purchase_requests_command(
uow: IUnitOfWork = Depends(get_unit_of_work),
logger: ILogger = Depends(get_logger),
) -> ListPurchaseRequestsCommand:
return ListPurchaseRequestsCommand(uow, logger)
def get_get_purchase_request_command(
uow: IUnitOfWork = Depends(get_unit_of_work),
logger: ILogger = Depends(get_logger),
) -> GetPurchaseRequestCommand:
return GetPurchaseRequestCommand(uow, logger)
def get_update_purchase_request_status_command(
uow: IUnitOfWork = Depends(get_unit_of_work),
logger: ILogger = Depends(get_logger),
) -> UpdatePurchaseRequestStatusCommand:
return UpdatePurchaseRequestStatusCommand(uow, logger)
def get_set_purchase_request_quote_command(
uow: IUnitOfWork = Depends(get_unit_of_work),
logger: ILogger = Depends(get_logger),
) -> SetPurchaseRequestQuoteCommand:
return SetPurchaseRequestQuoteCommand(uow, logger)

View File

@@ -0,0 +1,7 @@
from functools import lru_cache
from src.application.contracts import ILogger
from src.infrastructure.logger import logger
@lru_cache
def get_logger() -> ILogger:
return logger

View File

@@ -0,0 +1,8 @@
from functools import lru_cache
from src.application.contracts import IQueueMessanger
from src.infrastructure.messanger import RabbitClient
@lru_cache(maxsize=1)
def get_rabbit() -> IQueueMessanger:
return RabbitClient()

View File

@@ -0,0 +1,25 @@
from functools import lru_cache
from fastapi import Depends
from src.application.contracts import IHashService, IJwtService, ILogger
from src.infrastructure.security import HashService, JwtService
from src.infrastructure.vault import JwtKeyStore
from src.presentation.dependencies.logger import get_logger
@lru_cache(maxsize=1)
def _hash_service(logger: ILogger) -> IHashService:
return HashService(logger=logger)
def get_hash_service(logger: ILogger = Depends(get_logger)) -> IHashService:
return _hash_service(logger)
@lru_cache(maxsize=1)
def _jwt_service(logger: ILogger) -> IJwtService:
key_store = JwtKeyStore.get_instance()
return JwtService(logger=logger, key_store=key_store)
def get_jwt_service(logger: ILogger = Depends(get_logger)) -> IJwtService:
return _jwt_service(logger)

View File

@@ -0,0 +1,10 @@
from fastapi import Depends
from src.application.abstractions import IUnitOfWork
from src.application.contracts import ILogger
from src.infrastructure.database import UnitOfWork
from src.infrastructure.database.context import async_session_maker
from src.infrastructure.logger import get_logger
def get_unit_of_work(logger: ILogger = Depends(get_logger)) -> IUnitOfWork:
return UnitOfWork(session_factory=async_session_maker, logger=logger)

View File

@@ -0,0 +1,2 @@
from src.presentation.handler.application_exception_handler import application_exception_handler
from src.presentation.handler.unhandled_exception_handler import unhandled_exception_handler

View File

@@ -0,0 +1,15 @@
from fastapi import Request
from fastapi.responses import ORJSONResponse
from src.application.domain.exceptions import ApplicationException
async def application_exception_handler(_request: Request, exc: ApplicationException) -> ORJSONResponse:
detail = exc.message
if 500 <= exc.status_code:
detail = 'Internal Server Error'
return ORJSONResponse(
status_code=exc.status_code,
content={'detail': detail},
headers=dict(exc.headers) if exc.headers else None,
)

View File

@@ -0,0 +1,12 @@
from fastapi import Request
from fastapi.responses import ORJSONResponse
from starlette import status
from src.infrastructure.logger import logger
async def unhandled_exception_handler(_request: Request, exc: Exception) -> ORJSONResponse:
logger.exception(f'Unhandled exception: {type(exc).__name__}')
return ORJSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={'detail': 'Internal Server Error'},
)

View File

@@ -0,0 +1,2 @@
from src.presentation.middleware.trace_id import TraceIDMiddleware
from src.presentation.middleware.security_headers import SecurityHeadersMiddleware

View File

@@ -0,0 +1,51 @@
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
def __init__(
self,
app,
*,
hsts: bool = True,
hsts_max_age: int = 31536000, # 1 год
hsts_include_subdomains: bool = True,
hsts_preload: bool = False,
frame_options: str = 'DENY', # или 'SAMEORIGIN'
referrer_policy: str = 'strict-origin-when-cross-origin',
content_security_policy: str | None = None,
):
super().__init__(app)
self.hsts = hsts
self.hsts_max_age = hsts_max_age
self.hsts_include_subdomains = hsts_include_subdomains
self.hsts_preload = hsts_preload
self.frame_options = frame_options
self.referrer_policy = referrer_policy
self.csp = content_security_policy
async def dispatch(self, request: Request, call_next) -> Response:
response: Response = await call_next(request)
if request.url.path in ('/docs', '/redoc', '/openapi.json'):
return response
if self.hsts and request.url.scheme == 'https':
hsts = f'max-age={self.hsts_max_age}'
if self.hsts_include_subdomains:
hsts += '; includeSubDomains'
if self.hsts_preload:
hsts += '; preload'
response.headers['Strict-Transport-Security'] = hsts
response.headers['X-Content-Type-Options'] = 'nosniff'
response.headers['X-Frame-Options'] = self.frame_options
response.headers['Referrer-Policy'] = self.referrer_policy
if self.csp:
response.headers['Content-Security-Policy'] = self.csp
return response

View File

@@ -0,0 +1,69 @@
from __future__ import annotations
from typing import Optional
from contextvars import Token
from starlette.requests import Request
from starlette.types import ASGIApp, Message, Receive, Scope, Send
from ulid import ULID
from src.application.contracts import ILogger
from src.infrastructure.config import settings
from src.infrastructure.context_vars import trace_id_var
class TraceIDMiddleware:
def __init__(
self,
app: ASGIApp,
logger: ILogger,
response_header_name: str = "X-Trace-ID",
attach_response_header: bool = True,
) -> None:
self.app = app
self.logger = logger
self.response_header_name = response_header_name
self.attach_response_header = attach_response_header
def _is_excluded(self, path: str) -> bool:
return any(path == p or path.startswith(p.rstrip("/") + "/") for p in settings.EXCLUDED_PATHS)
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
request = Request(scope)
if self._is_excluded(request.url.path):
await self.app(scope, receive, send)
return
trace_id = request.headers.get("X-Trace-ID") or request.headers.get("X-Request-ID")
if not trace_id:
trace_id = str(ULID())
request.state.trace_id = trace_id
token: Token = trace_id_var.set(trace_id)
self.logger.debug(f"Request started: {request.method} {request.url} - TraceID: {trace_id}")
status_code_holder: dict[str, Optional[int]] = {"status": None}
async def send_wrapper(message: Message) -> None:
if message["type"] == "http.response.start":
status_code_holder["status"] = int(message["status"])
if self.attach_response_header:
headers = list(message.get("headers", []))
headers.append((self.response_header_name.lower().encode(), trace_id.encode()))
message["headers"] = headers
await send(message)
try:
await self.app(scope, receive, send_wrapper)
finally:
status = status_code_holder["status"]
status_part = f"{status}" if status is not None else "unknown"
self.logger.debug(
f"Request finished: {request.method} {request.url} - TraceID: {trace_id} - Status: {status_part}"
)
trace_id_var.reset(token)

View File

@@ -0,0 +1,12 @@
from fastapi import APIRouter
from src.presentation.routing.auth import auth_router
from src.presentation.routing.documents import documents_router
from src.presentation.routing.organizations import organizations_router
from src.presentation.routing.purchase_requests import purchase_requests_router
v1_router = APIRouter(prefix='/v1')
v1_router.include_router(auth_router)
v1_router.include_router(organizations_router)
v1_router.include_router(documents_router)
v1_router.include_router(purchase_requests_router)

View File

@@ -0,0 +1,47 @@
from fastapi import APIRouter, Depends, status
from fastapi.responses import ORJSONResponse
from src.application.commands import AdminLoginCommand, GetAdminMeCommand
from src.application.domain.dto import AdminAuthContext
from src.presentation.decorators.admin_auth import require_admin_access
from src.presentation.dependencies.commands import get_admin_login_command, get_admin_me_command
from src.presentation.schemas.admin_auth import AdminLoginRequest, AdminLoginResponse, AdminMeResponse
auth_router = APIRouter(prefix='/auth', tags=['auth'])
@auth_router.post('/login', response_model=AdminLoginResponse, status_code=status.HTTP_200_OK)
async def admin_login(
body: AdminLoginRequest,
command: AdminLoginCommand = Depends(get_admin_login_command),
):
dto = await command(email=str(body.email), password=body.password)
return AdminLoginResponse(
access_token=dto.access_token,
id=dto.id,
email=dto.email,
first_name=dto.first_name,
last_name=dto.last_name,
role=dto.role,
)
@auth_router.post('/logout', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
async def admin_logout():
"""Клиент удаляет access_token локально. Сервер stateless."""
return {'ok': True}
@auth_router.get('/me', response_model=AdminMeResponse)
async def admin_me(
auth: AdminAuthContext = Depends(require_admin_access),
command: GetAdminMeCommand = Depends(get_admin_me_command),
):
admin = await command(auth.admin_user_id)
return AdminMeResponse(
id=admin.id,
email=admin.email,
first_name=admin.first_name,
last_name=admin.last_name,
role=admin.role,
)

View File

@@ -0,0 +1,70 @@
from fastapi import APIRouter, Depends, File, Form, UploadFile, status
from src.application.commands import (
GetOrganizationDocumentCommand,
ListOrganizationDocumentsCommand,
UploadOrganizationDocumentCommand,
)
from src.application.domain.dto import AdminAuthContext
from src.presentation.decorators.admin_auth import require_admin_access, require_admin_role
from src.presentation.dependencies.commands import (
get_get_organization_document_command,
get_list_organization_documents_command,
get_s3_documents_service,
get_upload_organization_document_command,
)
from src.infrastructure.storage.s3_documents_service import S3DocumentsService
from src.presentation.schemas.mappers import document_to_response
from src.presentation.schemas.organization import DocumentResponse
documents_router = APIRouter(prefix='/organizations/{organization_id}/documents', tags=['documents'])
@documents_router.get('', response_model=list[DocumentResponse])
async def list_documents(
organization_id: str,
auth: AdminAuthContext = Depends(require_admin_access),
command: ListOrganizationDocumentsCommand = Depends(get_list_organization_documents_command),
s3: S3DocumentsService = Depends(get_s3_documents_service),
):
docs = await command(organization_id)
result: list[DocumentResponse] = []
for doc in docs:
url = await s3.generate_presigned_download_url(key=doc.s3_key)
result.append(document_to_response(doc, download_url=url))
return result
@documents_router.post('', response_model=DocumentResponse, status_code=status.HTTP_201_CREATED)
async def upload_document(
organization_id: str,
document_type: str = Form(...),
file: UploadFile = File(...),
auth: AdminAuthContext = Depends(require_admin_role('compliance', 'superadmin')),
command: UploadOrganizationDocumentCommand = Depends(get_upload_organization_document_command),
s3: S3DocumentsService = Depends(get_s3_documents_service),
):
body = await file.read()
saved = await command(
organization_id=organization_id,
admin_user_id=auth.admin_user_id,
document_type=document_type,
file_name=file.filename or 'document',
content_type=file.content_type or 'application/octet-stream',
body=body,
)
url = await s3.generate_presigned_download_url(key=saved.s3_key)
return document_to_response(saved, download_url=url)
@documents_router.get('/{document_id}', response_model=DocumentResponse)
async def get_document(
organization_id: str,
document_id: str,
auth: AdminAuthContext = Depends(require_admin_access),
command: GetOrganizationDocumentCommand = Depends(get_get_organization_document_command),
s3: S3DocumentsService = Depends(get_s3_documents_service),
):
doc = await command(organization_id, document_id)
url = await s3.generate_presigned_download_url(key=doc.s3_key)
return document_to_response(doc, download_url=url)

View File

@@ -0,0 +1,70 @@
from fastapi import APIRouter, Depends, Request
from fastapi.responses import ORJSONResponse
from starlette import status
from src.application.commands import AdminJwtRefreshCommand
from src.application.domain.exceptions import ApplicationException, RefreshConcurrentException
from src.infrastructure.config import settings
from src.presentation.dependencies.commands import get_admin_jwt_refresh_command
jwt_router = APIRouter(prefix='/jwt', tags=['jwt'])
def _clear_auth_cookies(response: ORJSONResponse) -> None:
response.delete_cookie('access_token', path='/', domain=settings.ADMIN_COOKIE_DOMAIN)
response.delete_cookie('refresh_token', path='/', domain=settings.ADMIN_COOKIE_DOMAIN)
def _set_auth_cookies(response: ORJSONResponse, access: str, refresh: str) -> None:
response.set_cookie(
key='access_token',
value=access,
httponly=True,
secure=settings.ADMIN_COOKIE_SECURE,
samesite='lax',
path='/',
domain=settings.ADMIN_COOKIE_DOMAIN,
max_age=int(settings.JWT_ACCESS_TTL_SECONDS),
)
response.set_cookie(
key='refresh_token',
value=refresh,
httponly=True,
secure=settings.ADMIN_COOKIE_SECURE,
samesite='lax',
path='/',
domain=settings.ADMIN_COOKIE_DOMAIN,
max_age=int(settings.JWT_REFRESH_TTL_SECONDS),
)
@jwt_router.post('/refresh', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
async def refresh_tokens(
request: Request,
command: AdminJwtRefreshCommand = Depends(get_admin_jwt_refresh_command),
):
refresh_token = request.cookies.get('refresh_token')
if not refresh_token:
response = ORJSONResponse({'ok': False, 'error': 'No refresh token'}, status_code=401)
_clear_auth_cookies(response)
return response
xff = request.headers.get('x-forwarded-for')
ip = xff.split(',')[0].strip() if xff else (request.client.host if request.client else None)
user_agent = request.headers.get('user-agent')
try:
tokens = await command(refresh_token=refresh_token, ip=ip, user_agent=user_agent)
except RefreshConcurrentException:
return ORJSONResponse({'result': True, 'concurrent': True}, status_code=status.HTTP_200_OK)
except ApplicationException as exc:
if exc.status_code == status.HTTP_401_UNAUTHORIZED:
response = ORJSONResponse({'result': False}, status_code=401)
_clear_auth_cookies(response)
return response
raise
access, refresh = tokens
response = ORJSONResponse({'result': True})
_set_auth_cookies(response, access, refresh)
return response

View File

@@ -0,0 +1,102 @@
from fastapi import APIRouter, Depends, Query, status
from src.application.commands import (
CreateOrganizationCommand,
CreateOrganizationWalletsCommand,
GetOrganizationCommand,
ListOrganizationsCommand,
UpdateOrganizationCommand,
)
from src.application.domain.dto import AdminAuthContext
from src.presentation.decorators.admin_auth import require_admin_access, require_admin_role
from src.presentation.dependencies.commands import (
get_create_organization_command,
get_create_organization_wallets_command,
get_get_organization_command,
get_list_organizations_command,
get_update_organization_command,
)
from src.presentation.schemas.mappers import organization_to_response, wallet_to_response
from src.presentation.schemas.organization import (
CreateOrganizationRequest,
OrganizationListResponse,
OrganizationResponse,
UpdateOrganizationRequest,
WalletResponse,
)
organizations_router = APIRouter(prefix='/organizations', tags=['organizations'])
@organizations_router.get('', response_model=OrganizationListResponse)
async def list_organizations(
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
auth: AdminAuthContext = Depends(require_admin_access),
command: ListOrganizationsCommand = Depends(get_list_organizations_command),
):
items, total = await command(limit=limit, offset=offset)
return OrganizationListResponse(
items=[organization_to_response(x) for x in items],
total=total,
)
@organizations_router.post('', response_model=OrganizationResponse, status_code=status.HTTP_201_CREATED)
async def create_organization(
body: CreateOrganizationRequest,
auth: AdminAuthContext = Depends(require_admin_role('compliance', 'superadmin')),
command: CreateOrganizationCommand = Depends(get_create_organization_command),
):
org = await command(
admin_user_id=auth.admin_user_id,
email=str(body.email),
password=body.password,
name=body.name,
short_name=body.short_name,
inn=body.inn,
ogrn=body.ogrn,
kpp=body.kpp,
legal_address=body.legal_address,
actual_address=body.actual_address,
bank_details=body.bank_details,
contact_person=body.contact_person,
contact_phone=body.contact_phone,
status=body.status,
)
return organization_to_response(org)
@organizations_router.get('/{organization_id}', response_model=OrganizationResponse)
async def get_organization(
organization_id: str,
auth: AdminAuthContext = Depends(require_admin_access),
command: GetOrganizationCommand = Depends(get_get_organization_command),
):
org = await command(organization_id)
return organization_to_response(org)
@organizations_router.patch('/{organization_id}', response_model=OrganizationResponse)
async def update_organization(
organization_id: str,
body: UpdateOrganizationRequest,
auth: AdminAuthContext = Depends(require_admin_role('compliance', 'superadmin')),
command: UpdateOrganizationCommand = Depends(get_update_organization_command),
):
org = await command(organization_id, values=body.model_dump(exclude_unset=True))
return organization_to_response(org)
@organizations_router.post(
'/{organization_id}/wallets/create',
response_model=list[WalletResponse],
status_code=status.HTTP_201_CREATED,
)
async def create_organization_wallets(
organization_id: str,
auth: AdminAuthContext = Depends(require_admin_role('compliance', 'superadmin')),
command: CreateOrganizationWalletsCommand = Depends(get_create_organization_wallets_command),
):
wallets = await command(organization_id=organization_id)
return [wallet_to_response(w) for w in wallets]

View File

@@ -0,0 +1,90 @@
from fastapi import APIRouter, Depends, Query, status
from src.application.commands import (
GetPurchaseRequestCommand,
ListPurchaseRequestsCommand,
SetPurchaseRequestQuoteCommand,
UpdatePurchaseRequestStatusCommand,
)
from src.application.domain.dto import AdminAuthContext
from src.presentation.decorators.admin_auth import require_admin_access, require_admin_role
from src.presentation.dependencies.commands import (
get_get_purchase_request_command,
get_list_purchase_requests_command,
get_set_purchase_request_quote_command,
get_update_purchase_request_status_command,
)
from src.presentation.schemas.mappers import purchase_request_to_response
from src.presentation.schemas.organization import (
PurchaseRequestListResponse,
PurchaseRequestResponse,
SetPurchaseRequestQuoteBody,
UpdatePurchaseRequestStatusBody,
)
purchase_requests_router = APIRouter(prefix='/purchase-requests', tags=['purchase-requests'])
@purchase_requests_router.get('', response_model=PurchaseRequestListResponse)
async def list_purchase_requests(
status_filter: str | None = Query(default=None, alias='status'),
organization_id: str | None = Query(default=None),
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
auth: AdminAuthContext = Depends(require_admin_access),
command: ListPurchaseRequestsCommand = Depends(get_list_purchase_requests_command),
):
items, total = await command(
status=status_filter,
organization_id=organization_id,
limit=limit,
offset=offset,
)
return PurchaseRequestListResponse(
items=[purchase_request_to_response(x) for x in items],
total=total,
)
@purchase_requests_router.get('/{request_id}', response_model=PurchaseRequestResponse)
async def get_purchase_request(
request_id: str,
auth: AdminAuthContext = Depends(require_admin_access),
command: GetPurchaseRequestCommand = Depends(get_get_purchase_request_command),
):
item = await command(request_id)
return purchase_request_to_response(item)
@purchase_requests_router.patch('/{request_id}/status', response_model=PurchaseRequestResponse)
async def update_purchase_request_status(
request_id: str,
body: UpdatePurchaseRequestStatusBody,
auth: AdminAuthContext = Depends(require_admin_role('compliance', 'superadmin')),
command: UpdatePurchaseRequestStatusCommand = Depends(get_update_purchase_request_status_command),
):
item = await command(
request_id,
status=body.status,
admin_comment=body.admin_comment,
assigned_to=body.assigned_to,
tx_hash=body.tx_hash,
)
return purchase_request_to_response(item)
@purchase_requests_router.post('/{request_id}/quote', response_model=PurchaseRequestResponse)
async def set_purchase_request_quote(
request_id: str,
body: SetPurchaseRequestQuoteBody,
auth: AdminAuthContext = Depends(require_admin_role('compliance', 'superadmin')),
command: SetPurchaseRequestQuoteCommand = Depends(get_set_purchase_request_quote_command),
):
item = await command(
request_id,
rub_amount=body.rub_amount,
exchange_rate=body.exchange_rate,
service_fee_percent=body.service_fee_percent,
admin_comment=body.admin_comment,
)
return purchase_request_to_response(item)

View File

@@ -0,0 +1 @@
from src.presentation.schemas.error import ErrorResponse

View File

@@ -0,0 +1,24 @@
from pydantic import BaseModel, EmailStr, Field
class AdminLoginRequest(BaseModel):
email: EmailStr
password: str = Field(min_length=8)
class AdminLoginResponse(BaseModel):
access_token: str
token_type: str = 'Bearer'
id: str
email: str
first_name: str | None
last_name: str | None
role: str
class AdminMeResponse(BaseModel):
id: str
email: str
first_name: str | None
last_name: str | None
role: str

View File

@@ -0,0 +1,6 @@
from pydantic import BaseModel
from pydantic import Field
class ErrorResponse(BaseModel):
detail: str = Field(title='Detail')

View File

@@ -0,0 +1,87 @@
from __future__ import annotations
from src.application.domain.entities.organization import (
LegalEntityEntity,
OrganizationDocumentEntity,
OrganizationWalletEntity,
PurchaseRequestEntity,
)
from src.presentation.schemas.organization import (
DocumentResponse,
OrganizationResponse,
PurchaseRequestResponse,
WalletResponse,
)
def organization_to_response(entity: LegalEntityEntity) -> OrganizationResponse:
return OrganizationResponse(
id=entity.id,
user_id=entity.user_id,
name=entity.name,
short_name=entity.short_name,
inn=entity.inn,
ogrn=entity.ogrn,
kpp=entity.kpp,
legal_address=entity.legal_address,
actual_address=entity.actual_address,
bank_details=entity.bank_details,
contact_person=entity.contact_person,
contact_phone=entity.contact_phone,
status=entity.status,
kyc_verified=entity.kyc_verified,
kyc_verified_at=entity.kyc_verified_at.isoformat() if entity.kyc_verified_at else None,
has_wallets=bool(entity.encrypted_mnemonic),
created_by=entity.created_by,
created_at=entity.created_at.isoformat() if entity.created_at else None,
updated_at=entity.updated_at.isoformat() if entity.updated_at else None,
)
def wallet_to_response(entity: OrganizationWalletEntity) -> WalletResponse:
return WalletResponse(
id=entity.id,
chain=entity.chain,
address=entity.address,
derivation_path=entity.derivation_path,
created_at=entity.created_at.isoformat() if entity.created_at else None,
)
def document_to_response(
entity: OrganizationDocumentEntity,
*,
download_url: str | None = None,
) -> DocumentResponse:
return DocumentResponse(
id=entity.id,
organization_id=entity.organization_id,
document_type=entity.document_type,
file_name=entity.file_name,
content_type=entity.content_type,
file_size_bytes=entity.file_size_bytes,
uploaded_by=entity.uploaded_by,
created_at=entity.created_at.isoformat() if entity.created_at else None,
download_url=download_url,
)
def purchase_request_to_response(entity: PurchaseRequestEntity) -> PurchaseRequestResponse:
return PurchaseRequestResponse(
id=entity.id,
organization_id=entity.organization_id,
status=entity.status,
usdt_amount=str(entity.usdt_amount),
rub_amount=str(entity.rub_amount) if entity.rub_amount is not None else None,
exchange_rate=str(entity.exchange_rate) if entity.exchange_rate is not None else None,
service_fee_percent=str(entity.service_fee_percent) if entity.service_fee_percent is not None else None,
comment=entity.comment,
admin_comment=entity.admin_comment,
target_wallet_chain=entity.target_wallet_chain,
target_wallet_address=entity.target_wallet_address,
tx_hash=entity.tx_hash,
assigned_to=entity.assigned_to,
created_at=entity.created_at.isoformat() if entity.created_at else None,
updated_at=entity.updated_at.isoformat() if entity.updated_at else None,
completed_at=entity.completed_at.isoformat() if entity.completed_at else None,
)

View File

@@ -0,0 +1,120 @@
from __future__ import annotations
from decimal import Decimal
from typing import Any
from pydantic import BaseModel, EmailStr, Field
class CreateOrganizationRequest(BaseModel):
email: EmailStr
password: str = Field(min_length=8)
name: str = Field(min_length=1, max_length=512)
short_name: str | None = Field(default=None, max_length=256)
inn: str = Field(min_length=10, max_length=12)
ogrn: str | None = Field(default=None, max_length=15)
kpp: str | None = Field(default=None, max_length=9)
legal_address: str | None = None
actual_address: str | None = None
bank_details: dict[str, Any] | None = None
contact_person: str | None = Field(default=None, max_length=256)
contact_phone: str | None = Field(default=None, max_length=16)
status: str = 'active'
class UpdateOrganizationRequest(BaseModel):
name: str | None = Field(default=None, max_length=512)
short_name: str | None = Field(default=None, max_length=256)
ogrn: str | None = Field(default=None, max_length=15)
kpp: str | None = Field(default=None, max_length=9)
legal_address: str | None = None
actual_address: str | None = None
bank_details: dict[str, Any] | None = None
contact_person: str | None = Field(default=None, max_length=256)
contact_phone: str | None = Field(default=None, max_length=16)
status: str | None = None
class OrganizationResponse(BaseModel):
id: str
user_id: str
name: str
short_name: str | None
inn: str
ogrn: str | None
kpp: str | None
legal_address: str | None
actual_address: str | None
bank_details: dict[str, Any] | None
contact_person: str | None
contact_phone: str | None
status: str
kyc_verified: bool
kyc_verified_at: str | None
has_wallets: bool
created_by: str | None
created_at: str | None
updated_at: str | None
class OrganizationListResponse(BaseModel):
items: list[OrganizationResponse]
total: int
class WalletResponse(BaseModel):
id: str
chain: str
address: str
derivation_path: str
created_at: str | None
class DocumentResponse(BaseModel):
id: str
organization_id: str
document_type: str
file_name: str
content_type: str
file_size_bytes: int
uploaded_by: str | None
created_at: str | None
download_url: str | None = None
class UpdatePurchaseRequestStatusBody(BaseModel):
status: str
admin_comment: str | None = None
assigned_to: str | None = None
tx_hash: str | None = None
class SetPurchaseRequestQuoteBody(BaseModel):
rub_amount: Decimal = Field(gt=0)
exchange_rate: Decimal = Field(gt=0)
service_fee_percent: Decimal | None = None
admin_comment: str | None = None
class PurchaseRequestResponse(BaseModel):
id: str
organization_id: str
status: str
usdt_amount: str
rub_amount: str | None
exchange_rate: str | None
service_fee_percent: str | None
comment: str | None
admin_comment: str | None
target_wallet_chain: str | None
target_wallet_address: str | None
tx_hash: str | None
assigned_to: str | None
created_at: str | None
updated_at: str | None
completed_at: str | None
class PurchaseRequestListResponse(BaseModel):
items: list[PurchaseRequestResponse]
total: int