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

2
src/infrastructure/cache/__init__.py vendored Normal file
View File

@@ -0,0 +1,2 @@
from src.infrastructure.cache.client import create_redis_client
from src.infrastructure.cache.keydb_client import KeydbCache

18
src/infrastructure/cache/client.py vendored Normal file
View File

@@ -0,0 +1,18 @@
import redis.asyncio as redis
from redis.asyncio.client import Redis
from src.infrastructure.config import settings
def create_redis_client() -> Redis:
kw = {
'max_connections': 50,
'decode_responses': True,
'socket_timeout': 5,
'socket_connect_timeout': 5,
'health_check_interval': 30,
'retry_on_timeout': True,
'socket_keepalive': True,
}
if settings.REDIS_PASSWORD:
kw['password'] = settings.REDIS_PASSWORD
return redis.from_url(settings.REDIS_URL, **kw)

View File

@@ -0,0 +1,20 @@
from redis.asyncio.client import Redis
from src.application.contracts import ICache
class KeydbCache(ICache):
def __init__(self, redis_client: Redis):
self._r = redis_client
async def set(self, key: str, value: str, ttl: int) -> None:
return bool(await self._r.set(key, value, ex=ttl))
async def set_nx(self, key: str, value: str, ttl: int) -> bool:
return bool(await self._r.set(key, value, ex=ttl, nx=True))
async def get(self, key: str) -> str | None:
return await self._r.get(key)
async def delete(self, key: str) -> bool:
deleted = await self._r.delete(key)
return deleted > 0

View File

@@ -0,0 +1 @@
from src.infrastructure.config.settings import settings

View File

@@ -0,0 +1,270 @@
from __future__ import annotations
from functools import lru_cache
from typing import List, Literal
import os
from dotenv import load_dotenv, find_dotenv
from pydantic import AliasChoices, Field, field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from src.infrastructure.vault import create_hvac_client_from_approle, read_kv2_secret
env_file = find_dotenv(".env")
if env_file:
load_dotenv(env_file)
def normalize_vault_base_url(raw: str) -> str:
u = raw.strip().rstrip('/')
if not u:
return raw.strip()
if '://' not in u:
return f'https://{u}'
return u
class Settings(BaseSettings):
VAULT_ADDR: str = Field(default='http://localhost:8200')
VAULT_ROLE_ID: str = Field(..., description='AppRole role_id')
VAULT_SECRET_ID: str = Field(
...,
description='AppRole secret_id',
validation_alias=AliasChoices('VAULT_SECRET_ID', 'VAULT_SECRET_TOKEN'),
)
VAULT_NAMESPACE: str | None = Field(default=None)
VAULT_MOUNT_POINT: str = Field(default='dev-secrets')
VAULT_JWT_KID_PATH: str = 'jwt/kid'
VAULT_JWT_KIDS_PREFIX: str = 'jwt/kids'
VAULT_CRYPTO_MASTER_KEY_PATH: str = 'crypto/master'
VAULT_LEGAL_DOCS_S3_SECRET_PATH: str = 's3/legal-docs'
JWT_KEYS_REFRESH_SECONDS: int = 3600
DATABASE_HOST: str
DATABASE_PORT: int = Field(default=5432, ge=1, le=65535)
DATABASE_NAME: str
DATABASE_USER: str
DATABASE_PASSWORD: str
DATABASE_POOL_SIZE: int = 10
DATABASE_MAX_OVERFLOW: int = 20
DATABASE_POOL_TIMEOUT: int = 30
DATABASE_POOL_RECYCLE: int = 3600
DATABASE_ECHO: bool = False
ADMIN_COOKIE_SECURE: bool = False
ADMIN_COOKIE_DOMAIN: str | None = '.elcsa.ru'
CORS_ALLOW_ORIGIN_REGEX: str = r'https?://([a-z0-9-]+\.)*admin\.elcsa\.ru(:\d+)?$'
DOCS_USERNAME: str = 'admin'
DOCS_PASSWORD: str = 'admin'
JWT_ACCESS_TTL_SECONDS: int = 8 * 60 * 60
ADMIN_JWT_ISSUER: str | None = 'admin-service'
JWT_AUDIENCE: str | None = None
JWT_ALGORITHM: str = 'RS256'
REDIS_HOST: str = 'localhost'
REDIS_PORT: int = 6379
REDIS_PASSWORD: str | None = None
REDIS_DB: int = 0
LEGAL_DOCS_S3_BUCKET: str = ''
LEGAL_DOCS_S3_REGION: str = 'us-east-1'
LEGAL_DOCS_S3_ACCESS_KEY_ID: str = ''
LEGAL_DOCS_S3_SECRET_ACCESS_KEY: str = ''
LEGAL_DOCS_S3_ENDPOINT_URL: str = ''
LEGAL_DOCS_S3_KEY_PREFIX: str = 'legal-docs'
LEGAL_DOCS_S3_PRESIGNED_TTL_SECONDS: int = 3600
RATE_LIMIT_REQUESTS: int = 60
RATE_LIMIT_WINDOW: int = 60
LOG_LEVEL: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] = 'INFO'
LOG_FORMAT: Literal['JSON', 'TEXT'] = 'TEXT'
@field_validator('VAULT_ADDR', mode='before')
@classmethod
def vault_addr_scheme(cls, v):
if v is None or not isinstance(v, str):
return v
return normalize_vault_base_url(v)
@field_validator('ADMIN_COOKIE_DOMAIN', mode='before')
@classmethod
def normalize_admin_cookie_domain(cls, v):
if v is None or (isinstance(v, str) and not v.strip()):
return '.elcsa.ru'
s = str(v).strip()
sl = s.lower()
if sl in ('.elcsa.ru', 'elcsa.ru'):
return '.elcsa.ru'
if sl.endswith('.elcsa.ru') and not sl.startswith('.'):
return '.elcsa.ru'
return s
@field_validator('REDIS_PASSWORD', mode='before')
@classmethod
def empty_redis_password_to_none(cls, v):
if v is None or (isinstance(v, str) and not v.strip()):
return None
return v
model_config = SettingsConfigDict(
env_file='.env',
env_file_encoding='utf-8',
case_sensitive=True,
extra='ignore',
populate_by_name=True,
)
@staticmethod
def _vault_kv(mapping: dict, *keys: str):
for k in keys:
if k in mapping and mapping[k] is not None:
return mapping[k]
return None
@classmethod
def _apply_s3_from_vault(cls, data: dict, s3: dict) -> None:
bucket = cls._vault_kv(s3, 'bucket_name', 'BUCKET_NAME', 'bucket', 'LEGAL_DOCS_S3_BUCKET')
endpoint = cls._vault_kv(s3, 's3_endpoint_url', 'S3_ENDPOINT_URL', 'endpoint_url', 'LEGAL_DOCS_S3_ENDPOINT_URL')
ak = cls._vault_kv(s3, 's3_access_key_id', 'S3_ACCESS_KEY_ID', 'ACCESS_KEY_ID', 'LEGAL_DOCS_S3_ACCESS_KEY_ID')
sk = cls._vault_kv(
s3, 's3_secret_access_key', 'S3_SECRET_ACCESS_KEY', 'SECRET_ACCESS_KEY', 'LEGAL_DOCS_S3_SECRET_ACCESS_KEY'
)
if bucket:
data['LEGAL_DOCS_S3_BUCKET'] = str(bucket).strip()
if endpoint:
data['LEGAL_DOCS_S3_ENDPOINT_URL'] = str(endpoint).strip()
if ak:
data['LEGAL_DOCS_S3_ACCESS_KEY_ID'] = str(ak).strip()
if sk:
data['LEGAL_DOCS_S3_SECRET_ACCESS_KEY'] = str(sk).strip()
region = cls._vault_kv(s3, 's3_region', 'S3_REGION', 'region', 'LEGAL_DOCS_S3_REGION')
if region:
data['LEGAL_DOCS_S3_REGION'] = str(region).strip()
prefix = cls._vault_kv(s3, 'key_prefix', 'LEGAL_DOCS_S3_KEY_PREFIX', 's3_key_prefix')
if prefix:
data['LEGAL_DOCS_S3_KEY_PREFIX'] = str(prefix).strip()
@model_validator(mode='before')
@classmethod
def load_from_vault(cls, data: dict):
if not isinstance(data, dict):
return data
addr_raw = data.get('VAULT_ADDR') or os.getenv('VAULT_ADDR') or 'http://localhost:8200'
addr = normalize_vault_base_url(addr_raw)
data['VAULT_ADDR'] = addr
role_id = data.get('VAULT_ROLE_ID') or os.getenv('VAULT_ROLE_ID')
secret_id = (
data.get('VAULT_SECRET_ID')
or data.get('VAULT_SECRET_TOKEN')
or os.getenv('VAULT_SECRET_ID')
or os.getenv('VAULT_SECRET_TOKEN')
)
namespace = data.get('VAULT_NAMESPACE')
if namespace is None:
namespace = os.getenv('VAULT_NAMESPACE')
namespace = namespace if namespace else None
mount = data.get('VAULT_MOUNT_POINT') or os.getenv('VAULT_MOUNT_POINT') or 'dev-secrets'
if not role_id or not secret_id:
raise RuntimeError(
'VAULT_ROLE_ID and VAULT_SECRET_ID (or VAULT_SECRET_TOKEN) are required for Vault AppRole'
)
data['VAULT_ROLE_ID'] = str(role_id).strip()
data['VAULT_SECRET_ID'] = str(secret_id).strip()
client = create_hvac_client_from_approle(
url=addr,
role_id=role_id,
secret_id=secret_id,
namespace=namespace,
timeout=5,
)
def read_secret(path: str) -> dict:
return read_kv2_secret(client=client, mount_point=mount, path=path)
def read_secret_optional(path: str) -> dict:
try:
return read_secret(path)
except Exception:
return {}
database = read_secret('database')
db_ci = {str(k).lower(): v for k, v in database.items()}
def db_nonempty(key: str) -> bool:
v = db_ci.get(key)
if v is None:
return False
if isinstance(v, str) and not v.strip():
return False
return True
required_db = ['host', 'name', 'user', 'password', 'port']
missing_db = [k for k in required_db if not db_nonempty(k)]
if missing_db:
raise RuntimeError(f'Vault secret database missing non-empty keys: {missing_db}')
data['DATABASE_HOST'] = str(db_ci['host']).strip()
data['DATABASE_PORT'] = int(db_ci['port'])
data['DATABASE_NAME'] = str(db_ci['name']).strip()
data['DATABASE_USER'] = str(db_ci['user']).strip()
data['DATABASE_PASSWORD'] = str(db_ci['password']).strip()
redis_secret = read_secret_optional('redis')
if redis_secret:
rd_ci = {str(k).lower(): v for k, v in redis_secret.items()}
def rd_set(field: str, env_key: str, *, as_int: bool = False) -> None:
v = rd_ci.get(field)
if v is None:
return
if isinstance(v, str) and not v.strip():
return
if as_int:
data[env_key] = int(v)
else:
data[env_key] = str(v).strip()
rd_set('host', 'REDIS_HOST')
rd_set('port', 'REDIS_PORT', as_int=True)
rd_set('password', 'REDIS_PASSWORD')
rd_set('db', 'REDIS_DB', as_int=True)
s3_path = (
data.get('VAULT_LEGAL_DOCS_S3_SECRET_PATH')
or os.getenv('VAULT_LEGAL_DOCS_S3_SECRET_PATH')
or 's3/legal-docs'
)
s3_secret = read_secret_optional(str(s3_path))
if s3_secret:
cls._apply_s3_from_vault(data, s3_secret)
return data
@property
def DATABASE_URL(self) -> str:
return (
f"postgresql+asyncpg://{self.DATABASE_USER}:{self.DATABASE_PASSWORD}"
f"@{self.DATABASE_HOST}:{self.DATABASE_PORT}/{self.DATABASE_NAME}"
)
@property
def REDIS_URL(self) -> str:
return f'redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}'
@property
def EXCLUDED_PATHS(self) -> List[str]:
return ['/docs', '/redoc', '/openapi.json', '/ping', '/health']
@lru_cache(maxsize=1)
def get_settings() -> Settings:
return Settings()
settings = get_settings()

View File

@@ -0,0 +1 @@
from src.infrastructure.context_vars.trace_id import trace_id_var

View File

@@ -0,0 +1,4 @@
from contextvars import ContextVar
trace_id_var: ContextVar[str] = ContextVar('trace_id', default='N/A')

View File

View File

@@ -0,0 +1,185 @@
from __future__ import annotations
import base64
import os
import re
from dataclasses import dataclass
from bip_utils import (
Bip39MnemonicGenerator,
Bip39SeedGenerator,
Bip39WordsNum,
Bip44,
Bip44Changes,
Bip44Coins,
Bip84,
Bip84Coins,
)
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from src.infrastructure.vault.utils import create_hvac_client_from_approle, read_kv2_secret
KEY_LEN = 32
IV_LEN = 12
TAG_LEN = 16
DERIVATION_PATHS: dict[str, str] = {
'ETH': "m/44'/60'/0'/0/0",
'BSC': "m/44'/60'/0'/0/0",
'BTC': "m/84'/0'/0'/0/0",
'TRX': "m/44'/195'/0'/0/0",
'SOL': "m/44'/501'/0'/0'",
}
ALL_CHAINS: tuple[str, ...] = ('ETH', 'BSC', 'BTC', 'TRX', 'SOL')
_master_key: bytes | None = None
@dataclass(frozen=True)
class DerivedWallet:
chain: str
address: str
derivation_path: str
class CryptoNotReadyError(RuntimeError):
pass
def load_master_key_from_vault(
*,
vault_addr: str,
vault_role_id: str,
vault_secret_id: str,
vault_namespace: str | None,
mount_point: str,
path: str,
) -> None:
global _master_key
if _master_key is not None:
return
client = create_hvac_client_from_approle(
url=vault_addr,
role_id=vault_role_id,
secret_id=vault_secret_id,
namespace=vault_namespace,
timeout=5,
)
secrets = read_kv2_secret(client=client, mount_point=mount_point, path=path)
if not secrets:
raise RuntimeError(f'Failed to load crypto master key from Vault path {path}')
raw = secrets.get('key')
if not raw or not isinstance(raw, str):
if secrets.get('master_key') or secrets.get('MASTER_KEY'):
raise RuntimeError('Crypto master key misconfigured: alternate field present but canonical "key" missing')
raise RuntimeError('Crypto master key invalid: expected hex string in Vault field "key"')
if not re.fullmatch(r'[0-9a-fA-F]{64}', raw):
raise RuntimeError('Crypto master key invalid: must be 64-char hex (32 bytes)')
key = bytes.fromhex(raw)
if len(key) != KEY_LEN:
raise RuntimeError(f'Crypto master key invalid: got {len(key)} bytes, expected {KEY_LEN}')
_master_key = key
def is_crypto_ready() -> bool:
return _master_key is not None and len(_master_key) == KEY_LEN
def generate_mnemonic() -> str:
return str(Bip39MnemonicGenerator().FromWordsNumber(Bip39WordsNum.WORDS_NUM_12))
def derive_all_addresses(mnemonic: str) -> list[DerivedWallet]:
seed_bytes = Bip39SeedGenerator(mnemonic).Generate()
eth_ctx = Bip44.FromSeed(seed_bytes, Bip44Coins.ETHEREUM)
eth_addr = (
eth_ctx.Purpose()
.Coin()
.Account(0)
.Change(Bip44Changes.CHAIN_EXT)
.AddressIndex(0)
.PublicKey()
.ToAddress()
)
btc_ctx = Bip84.FromSeed(seed_bytes, Bip84Coins.BITCOIN)
btc_addr = (
btc_ctx.Purpose()
.Coin()
.Account(0)
.Change(Bip44Changes.CHAIN_EXT)
.AddressIndex(0)
.PublicKey()
.ToAddress()
)
trx_ctx = Bip44.FromSeed(seed_bytes, Bip44Coins.TRON)
trx_addr = (
trx_ctx.Purpose()
.Coin()
.Account(0)
.Change(Bip44Changes.CHAIN_EXT)
.AddressIndex(0)
.PublicKey()
.ToAddress()
)
sol_ctx = Bip44.FromSeed(seed_bytes, Bip44Coins.SOLANA)
sol_addr = (
sol_ctx.Purpose()
.Coin()
.Account(0)
.Change(Bip44Changes.CHAIN_EXT)
.AddressIndex(0)
.PublicKey()
.ToAddress()
)
return [
DerivedWallet(chain='ETH', address=eth_addr, derivation_path=DERIVATION_PATHS['ETH']),
DerivedWallet(chain='BSC', address=eth_addr, derivation_path=DERIVATION_PATHS['BSC']),
DerivedWallet(chain='BTC', address=btc_addr, derivation_path=DERIVATION_PATHS['BTC']),
DerivedWallet(chain='TRX', address=trx_addr, derivation_path=DERIVATION_PATHS['TRX']),
DerivedWallet(chain='SOL', address=sol_addr, derivation_path=DERIVATION_PATHS['SOL']),
]
def encrypt_mnemonic(plaintext: str) -> str:
if _master_key is None:
raise CryptoNotReadyError('Crypto service not ready')
if not plaintext:
raise ValueError('encrypt_mnemonic: plaintext must be non-empty')
iv = os.urandom(IV_LEN)
aesgcm = AESGCM(_master_key)
ct_with_tag = aesgcm.encrypt(iv, plaintext.encode('utf-8'), None)
tag = ct_with_tag[-TAG_LEN:]
ct = ct_with_tag[:-TAG_LEN]
return base64.b64encode(iv + ct + tag).decode('ascii')
def decrypt_mnemonic(blob: str) -> str:
if _master_key is None:
raise CryptoNotReadyError('Crypto service not ready')
if not blob:
raise ValueError('decrypt_mnemonic: blob must be non-empty')
raw = base64.b64decode(blob)
if len(raw) < IV_LEN + TAG_LEN + 1:
raise ValueError('decrypt_mnemonic: blob too short')
iv = raw[:IV_LEN]
tag = raw[-TAG_LEN:]
ct = raw[IV_LEN:-TAG_LEN]
aesgcm = AESGCM(_master_key)
try:
return aesgcm.decrypt(iv, ct + tag, None).decode('utf-8')
except Exception as exc:
raise ValueError('decrypt_mnemonic: authentication failed') from exc

View File

@@ -0,0 +1 @@
from src.infrastructure.database.unit_of_work import UnitOfWork

View File

@@ -0,0 +1,22 @@
from sqlalchemy.ext.asyncio import async_sessionmaker
from sqlalchemy.ext.asyncio.engine import create_async_engine
from sqlalchemy.ext.asyncio.session import AsyncSession
from typing import AsyncGenerator
from src.infrastructure.config import settings
engine = create_async_engine(
settings.DATABASE_URL,
pool_size=settings.DATABASE_POOL_SIZE,
max_overflow=settings.DATABASE_MAX_OVERFLOW,
pool_timeout=settings.DATABASE_POOL_TIMEOUT,
pool_recycle=settings.DATABASE_POOL_RECYCLE,
echo=settings.DATABASE_ECHO
)
async_session_maker = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
async def get_session() -> AsyncGenerator[AsyncSession, None]:
async with async_session_maker() as session:
yield session

View File

@@ -0,0 +1 @@
from src.infrastructure.database.decorators.transactional import transactional

View File

@@ -0,0 +1,15 @@
from __future__ import annotations
from functools import wraps
from typing import Callable, Awaitable, TypeVar, ParamSpec
P = ParamSpec("P")
R = TypeVar("R")
def transactional(method: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]:
@wraps(method)
async def wrapper(self, *args: P.args, **kwargs: P.kwargs) -> R:
async with self._unit_of_work:
return await method(self, *args, **kwargs)
return wrapper

View File

@@ -0,0 +1,19 @@
from src.infrastructure.database.models.base import Base
from src.infrastructure.database.models.user import UserModel
from src.infrastructure.database.models.admin_user import AdminUserModel
from src.infrastructure.database.models.admin_session import AdminSessionModel
from src.infrastructure.database.models.legal_entity import LegalEntityModel
from src.infrastructure.database.models.organization_wallet import OrganizationWalletModel
from src.infrastructure.database.models.organization_document import OrganizationDocumentModel
from src.infrastructure.database.models.purchase_request import PurchaseRequestModel
__all__ = [
'Base',
'UserModel',
'AdminUserModel',
'AdminSessionModel',
'LegalEntityModel',
'OrganizationWalletModel',
'OrganizationDocumentModel',
'PurchaseRequestModel',
]

View File

@@ -0,0 +1,43 @@
from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, Index, String
from sqlalchemy.orm import Mapped, mapped_column
from ulid import ULID
from src.infrastructure.database.models.base import Base
from src.infrastructure.database.models.mixins import AuditTimestampsMixin, UlidPrimaryKeyMixin
class AdminSessionModel(Base, UlidPrimaryKeyMixin, AuditTimestampsMixin):
__tablename__ = 'admin_sessions'
sid: Mapped[str] = mapped_column(
String(26),
unique=True,
index=True,
nullable=False,
default=lambda: str(ULID()),
)
admin_user_id: Mapped[str] = mapped_column(
String(26),
ForeignKey('admin_users.id', ondelete='CASCADE'),
index=True,
nullable=False,
)
device_id: Mapped[str] = mapped_column(String(26), nullable=False, index=True)
user_agent: Mapped[str | None] = mapped_column(String(500), nullable=True)
first_ip: Mapped[str | None] = mapped_column(String(64), nullable=True)
last_ip: Mapped[str | None] = mapped_column(String(64), nullable=True)
last_seen_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=lambda: datetime.now(timezone.utc),
)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
refresh_jti_hash: Mapped[str | None] = mapped_column(String(255), nullable=True)
refresh_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
Index('ux_admin_sessions_user_device', AdminSessionModel.admin_user_id, AdminSessionModel.device_id, unique=True)

View File

@@ -0,0 +1,21 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import Boolean, DateTime, String
from sqlalchemy.orm import Mapped, mapped_column
from src.infrastructure.database.models.base import Base
from src.infrastructure.database.models.mixins import AuditTimestampsMixin, UlidPrimaryKeyMixin
class AdminUserModel(Base, UlidPrimaryKeyMixin, AuditTimestampsMixin):
__tablename__ = 'admin_users'
email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
first_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
last_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
role: Mapped[str] = mapped_column(String(32), nullable=False, server_default='operator', default='operator')
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default='true', default=True)
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)

View File

@@ -0,0 +1,19 @@
from sqlalchemy.ext.asyncio import AsyncAttrs
from sqlalchemy.orm import DeclarativeBase
class Base(AsyncAttrs, DeclarativeBase):
__abstract__ = True
def __repr__(self) -> str:
class_name = self.__class__.__name__
attributes = ', '.join(f"{col.name}={getattr(self, col.name, None)!r}"
for col in self.__table__.columns)
return f"<{class_name}({attributes})>"
def __str__(self) -> str:
class_name = self.__class__.__name__
attributes = ', '.join(f"{col.name}={getattr(self, col.name)}"
for col in self.__table__.columns
if getattr(self, col.name) is not None)
return f"{class_name}({attributes})"

View File

@@ -0,0 +1,42 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from src.infrastructure.database.models.base import Base
from src.infrastructure.database.models.mixins import AuditTimestampsMixin, UlidPrimaryKeyMixin
class LegalEntityModel(Base, UlidPrimaryKeyMixin, AuditTimestampsMixin):
__tablename__ = 'legal_entities'
user_id: Mapped[str] = mapped_column(
String(26),
ForeignKey('users.id', ondelete='RESTRICT'),
nullable=False,
unique=True,
index=True,
)
name: Mapped[str] = mapped_column(String(512), nullable=False)
short_name: Mapped[str | None] = mapped_column(String(256), nullable=True)
inn: Mapped[str] = mapped_column(String(12), nullable=False, index=True)
ogrn: Mapped[str | None] = mapped_column(String(15), nullable=True)
kpp: Mapped[str | None] = mapped_column(String(9), nullable=True)
legal_address: Mapped[str | None] = mapped_column(Text, nullable=True)
actual_address: Mapped[str | None] = mapped_column(Text, nullable=True)
bank_details: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True)
contact_person: Mapped[str | None] = mapped_column(String(256), nullable=True)
contact_phone: Mapped[str | None] = mapped_column(String(16), nullable=True)
status: Mapped[str] = mapped_column(String(32), nullable=False, server_default='active', default='active')
kyc_verified: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default='true', default=True)
kyc_verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
encrypted_mnemonic: Mapped[str | None] = mapped_column(Text, nullable=True)
created_by: Mapped[str | None] = mapped_column(
String(26),
ForeignKey('admin_users.id'),
nullable=True,
)

View File

@@ -0,0 +1,3 @@
from src.infrastructure.database.models.mixins.audit import AuditTimestampsMixin
from src.infrastructure.database.models.mixins.ulid import UlidPrimaryKeyMixin
from src.infrastructure.database.models.mixins.soft_delete import SoftDeleteMixin

View File

@@ -0,0 +1,16 @@
from sqlalchemy import DateTime, func
from sqlalchemy.orm import Mapped, mapped_column
class AuditTimestampsMixin:
created_at: Mapped[DateTime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
)
updated_at: Mapped[DateTime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
onupdate=func.now(),
)

View File

@@ -0,0 +1,6 @@
from sqlalchemy import Boolean
from sqlalchemy.orm import Mapped, mapped_column
class SoftDeleteMixin:
is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default='false', default=False)

View File

@@ -0,0 +1,8 @@
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column
from ulid import ULID
class UlidPrimaryKeyMixin:
id: Mapped[str] = mapped_column(String(26), primary_key=True, default=lambda: str(ULID()))

View File

@@ -0,0 +1,35 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import BigInteger, DateTime, ForeignKey, String, func
from sqlalchemy.orm import Mapped, mapped_column
from src.infrastructure.database.models.base import Base
from src.infrastructure.database.models.mixins import UlidPrimaryKeyMixin
class OrganizationDocumentModel(Base, UlidPrimaryKeyMixin):
__tablename__ = 'organization_documents'
organization_id: Mapped[str] = mapped_column(
String(26),
ForeignKey('legal_entities.id', ondelete='RESTRICT'),
nullable=False,
index=True,
)
document_type: Mapped[str] = mapped_column(String(64), nullable=False)
file_name: Mapped[str] = mapped_column(String(512), nullable=False)
s3_key: Mapped[str] = mapped_column(String(1024), nullable=False)
content_type: Mapped[str] = mapped_column(String(128), nullable=False)
file_size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False)
uploaded_by: Mapped[str | None] = mapped_column(
String(26),
ForeignKey('admin_users.id'),
nullable=True,
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
)

View File

@@ -0,0 +1,28 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, func
from sqlalchemy.orm import Mapped, mapped_column
from src.infrastructure.database.models.base import Base
from src.infrastructure.database.models.mixins import UlidPrimaryKeyMixin
class OrganizationWalletModel(Base, UlidPrimaryKeyMixin):
__tablename__ = 'organization_wallets'
organization_id: Mapped[str] = mapped_column(
String(26),
ForeignKey('legal_entities.id', ondelete='RESTRICT'),
nullable=False,
index=True,
)
chain: Mapped[str] = mapped_column(String(16), nullable=False)
address: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
derivation_path: Mapped[str] = mapped_column(String(64), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
)

View File

@@ -0,0 +1,37 @@
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from sqlalchemy import DateTime, ForeignKey, Numeric, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from src.infrastructure.database.models.base import Base
from src.infrastructure.database.models.mixins import AuditTimestampsMixin, UlidPrimaryKeyMixin
class PurchaseRequestModel(Base, UlidPrimaryKeyMixin, AuditTimestampsMixin):
__tablename__ = 'purchase_requests'
organization_id: Mapped[str] = mapped_column(
String(26),
ForeignKey('legal_entities.id', ondelete='RESTRICT'),
nullable=False,
index=True,
)
status: Mapped[str] = mapped_column(String(32), nullable=False, server_default='submitted', default='submitted')
usdt_amount: Mapped[Decimal] = mapped_column(Numeric(18, 8), nullable=False)
rub_amount: Mapped[Decimal | None] = mapped_column(Numeric(18, 2), nullable=True)
exchange_rate: Mapped[Decimal | None] = mapped_column(Numeric(18, 8), nullable=True)
service_fee_percent: Mapped[Decimal | None] = mapped_column(Numeric(5, 2), nullable=True)
comment: Mapped[str | None] = mapped_column(Text, nullable=True)
admin_comment: Mapped[str | None] = mapped_column(Text, nullable=True)
target_wallet_chain: Mapped[str | None] = mapped_column(String(16), nullable=True, server_default='ETH')
target_wallet_address: Mapped[str | None] = mapped_column(String(128), nullable=True)
tx_hash: Mapped[str | None] = mapped_column(String(128), nullable=True)
assigned_to: Mapped[str | None] = mapped_column(
String(26),
ForeignKey('admin_users.id'),
nullable=True,
)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)

View File

@@ -0,0 +1,30 @@
from __future__ import annotations
from sqlalchemy import Boolean, Date, String, DateTime, Text
from sqlalchemy.orm import Mapped,mapped_column
from src.infrastructure.database.models.base import Base
from src.infrastructure.database.models.mixins import UlidPrimaryKeyMixin,AuditTimestampsMixin,SoftDeleteMixin
class UserModel(Base, UlidPrimaryKeyMixin, AuditTimestampsMixin, SoftDeleteMixin):
__tablename__ = 'users'
email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
last_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
first_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
middle_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
birth_date: Mapped[Date | None] = mapped_column(Date, nullable=True)
encrypted_mnemonic: Mapped[str | None] = mapped_column(Text, nullable=True)
phone: Mapped[str | None] = mapped_column(String(16), nullable=True)
passport_data: Mapped[str | None] = mapped_column(String(255), nullable=True)
inn: Mapped[str | None] = mapped_column(String(12), nullable=True)
erc20: Mapped[str | None] = mapped_column(String(255), nullable=True)
avatar_link: Mapped[str | None] = mapped_column(Text, nullable=True)
kyc_verified: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default='false', default=False)
kyc_verified_at: Mapped[DateTime | None] = mapped_column(DateTime(timezone=True), nullable=True)

View File

@@ -0,0 +1,17 @@
from src.infrastructure.database.repositories.admin_user_repository import AdminUserRepository
from src.infrastructure.database.repositories.admin_session_repository import AdminSessionRepository
from src.infrastructure.database.repositories.user_repository import UserRepository
from src.infrastructure.database.repositories.legal_entity_repository import LegalEntityRepository
from src.infrastructure.database.repositories.organization_wallet_repository import OrganizationWalletRepository
from src.infrastructure.database.repositories.organization_document_repository import OrganizationDocumentRepository
from src.infrastructure.database.repositories.purchase_request_repository import PurchaseRequestRepository
__all__ = [
'AdminUserRepository',
'AdminSessionRepository',
'UserRepository',
'LegalEntityRepository',
'OrganizationWalletRepository',
'OrganizationDocumentRepository',
'PurchaseRequestRepository',
]

View File

@@ -0,0 +1,120 @@
from __future__ import annotations
from datetime import datetime
from typing import Optional
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from src.application.abstractions.repositories import IAdminSessionRepository
from src.application.contracts import ILogger
from src.application.domain.entities.admin_session import AdminSessionEntity
from src.infrastructure.database.models import AdminSessionModel
class AdminSessionRepository(IAdminSessionRepository):
def __init__(self, session: AsyncSession, logger: ILogger):
self._session = session
self._logger = logger
def _to_entity(self, m: AdminSessionModel) -> AdminSessionEntity:
return AdminSessionEntity(
sid=m.sid,
admin_user_id=m.admin_user_id,
device_id=m.device_id,
revoked_at=m.revoked_at,
last_seen_at=m.last_seen_at,
refresh_jti_hash=m.refresh_jti_hash,
refresh_expires_at=m.refresh_expires_at,
user_agent=m.user_agent,
first_ip=m.first_ip,
last_ip=m.last_ip,
)
async def get_by_sid(self, sid: str) -> Optional[AdminSessionEntity]:
res = await self._session.execute(select(AdminSessionModel).where(AdminSessionModel.sid == sid))
m = res.scalar_one_or_none()
return self._to_entity(m) if m else None
async def upsert_by_device(
self,
*,
admin_user_id: str,
device_id: str,
sid: str,
refresh_jti_hash: str,
refresh_expires_at: datetime,
user_agent: str | None,
ip: str | None,
now: datetime,
) -> AdminSessionEntity:
res = await self._session.execute(
select(AdminSessionModel).where(
AdminSessionModel.admin_user_id == admin_user_id,
AdminSessionModel.device_id == device_id,
)
)
m = res.scalar_one_or_none()
if m is None:
m = AdminSessionModel(
sid=sid,
admin_user_id=admin_user_id,
device_id=device_id,
revoked_at=None,
last_seen_at=now,
refresh_jti_hash=refresh_jti_hash,
refresh_expires_at=refresh_expires_at,
user_agent=user_agent,
first_ip=ip,
last_ip=ip,
)
self._session.add(m)
else:
m.sid = sid
m.revoked_at = None
m.last_seen_at = now
m.refresh_jti_hash = refresh_jti_hash
m.refresh_expires_at = refresh_expires_at
m.user_agent = user_agent
m.last_ip = ip
await self._session.flush()
return self._to_entity(m)
async def revoke_by_sid(self, sid: str, now: datetime) -> None:
await self._session.execute(
update(AdminSessionModel)
.where(AdminSessionModel.sid == sid, AdminSessionModel.revoked_at.is_(None))
.values(revoked_at=now)
)
await self._session.flush()
async def rotate_refresh_if_match(
self,
*,
sid: str,
old_jti_hash: str,
new_jti_hash: str,
new_refresh_expires_at: datetime,
now: datetime,
ip: str | None,
user_agent: str | None,
) -> bool:
values = {
'refresh_jti_hash': new_jti_hash,
'refresh_expires_at': new_refresh_expires_at,
'last_seen_at': now,
'user_agent': user_agent,
}
if ip is not None:
values['last_ip'] = ip
res = await self._session.execute(
update(AdminSessionModel)
.where(
AdminSessionModel.sid == sid,
AdminSessionModel.revoked_at.is_(None),
AdminSessionModel.refresh_jti_hash == old_jti_hash,
)
.values(**values)
)
await self._session.flush()
return (res.rowcount or 0) > 0

View File

@@ -0,0 +1,70 @@
from __future__ import annotations
from datetime import datetime
from fastapi import status
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
from src.application.abstractions.repositories import IAdminUserRepository
from src.application.contracts import ILogger
from src.application.domain.entities.admin_user import AdminUserEntity
from src.application.domain.exceptions import ApplicationException
from src.infrastructure.database.models import AdminUserModel
class AdminUserRepository(IAdminUserRepository):
def __init__(self, session: AsyncSession, logger: ILogger):
self._session = session
self._logger = logger
def _to_entity(self, m: AdminUserModel) -> AdminUserEntity:
return AdminUserEntity(
id=m.id,
email=m.email,
password_hash=m.password_hash,
first_name=m.first_name,
last_name=m.last_name,
role=m.role,
is_active=m.is_active,
last_login_at=m.last_login_at,
created_at=m.created_at,
updated_at=m.updated_at,
)
async def get_by_email(self, email: str) -> AdminUserEntity:
try:
stmt = select(AdminUserModel).where(func.lower(AdminUserModel.email) == email.lower())
result = await self._session.execute(stmt)
user = result.scalar_one_or_none()
if user is None:
raise ApplicationException(status_code=status.HTTP_404_NOT_FOUND, message='Admin user not found')
return self._to_entity(user)
except ApplicationException:
raise
except SQLAlchemyError as exc:
self._logger.exception(str(exc))
raise ApplicationException(status_code=500, message='Database error')
async def get_by_id(self, admin_user_id: str) -> AdminUserEntity:
try:
stmt = select(AdminUserModel).where(AdminUserModel.id == admin_user_id)
result = await self._session.execute(stmt)
user = result.scalar_one_or_none()
if user is None:
raise ApplicationException(status_code=status.HTTP_404_NOT_FOUND, message='Admin user not found')
return self._to_entity(user)
except ApplicationException:
raise
except SQLAlchemyError as exc:
self._logger.exception(str(exc))
raise ApplicationException(status_code=500, message='Database error')
async def update_last_login(self, admin_user_id: str, *, last_login_at: datetime) -> None:
await self._session.execute(
update(AdminUserModel)
.where(AdminUserModel.id == admin_user_id)
.values(last_login_at=last_login_at)
)
await self._session.flush()

View File

@@ -0,0 +1,123 @@
from __future__ import annotations
from typing import Any
from fastapi import status
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
from src.application.abstractions.repositories import ILegalEntityRepository
from src.application.contracts import ILogger
from src.application.domain.entities.organization import LegalEntityEntity
from src.application.domain.exceptions import ApplicationException
from src.infrastructure.database.models import LegalEntityModel
class LegalEntityRepository(ILegalEntityRepository):
def __init__(self, session: AsyncSession, logger: ILogger):
self._session = session
self._logger = logger
def _to_entity(self, m: LegalEntityModel) -> LegalEntityEntity:
return LegalEntityEntity(
id=m.id,
user_id=m.user_id,
name=m.name,
short_name=m.short_name,
inn=m.inn,
ogrn=m.ogrn,
kpp=m.kpp,
legal_address=m.legal_address,
actual_address=m.actual_address,
bank_details=m.bank_details,
contact_person=m.contact_person,
contact_phone=m.contact_phone,
status=m.status,
kyc_verified=m.kyc_verified,
kyc_verified_at=m.kyc_verified_at,
encrypted_mnemonic=m.encrypted_mnemonic,
created_by=m.created_by,
created_at=m.created_at,
updated_at=m.updated_at,
)
async def create(
self,
*,
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,
created_by: str | None,
) -> LegalEntityEntity:
entity = LegalEntityModel(
user_id=user_id,
name=name,
short_name=short_name,
inn=inn,
ogrn=ogrn,
kpp=kpp,
legal_address=legal_address,
actual_address=actual_address,
bank_details=bank_details,
contact_person=contact_person,
contact_phone=contact_phone,
status=status,
kyc_verified=kyc_verified,
kyc_verified_at=kyc_verified_at,
created_by=created_by,
)
self._session.add(entity)
try:
await self._session.flush()
return self._to_entity(entity)
except IntegrityError:
raise ApplicationException(status_code=409, message='Organization with this INN or user already exists')
except SQLAlchemyError as exc:
self._logger.exception(str(exc))
raise ApplicationException(status_code=500, message='Database error')
async def get_by_id(self, organization_id: str) -> LegalEntityEntity:
res = await self._session.execute(select(LegalEntityModel).where(LegalEntityModel.id == organization_id))
m = res.scalar_one_or_none()
if m is None:
raise ApplicationException(status_code=404, message='Organization not found')
return self._to_entity(m)
async def list_all(self, *, limit: int, offset: int) -> list[LegalEntityEntity]:
res = await self._session.execute(
select(LegalEntityModel).order_by(LegalEntityModel.created_at.desc()).limit(limit).offset(offset)
)
return [self._to_entity(m) for m in res.scalars().all()]
async def count_all(self) -> int:
res = await self._session.execute(select(func.count()).select_from(LegalEntityModel))
return int(res.scalar_one())
async def update(self, organization_id: str, *, values: dict[str, Any]) -> LegalEntityEntity:
if not values:
return await self.get_by_id(organization_id)
await self._session.execute(
update(LegalEntityModel).where(LegalEntityModel.id == organization_id).values(**values)
)
await self._session.flush()
return await self.get_by_id(organization_id)
async def set_encrypted_mnemonic(self, organization_id: str, encrypted_mnemonic: str) -> None:
await self._session.execute(
update(LegalEntityModel)
.where(LegalEntityModel.id == organization_id)
.values(encrypted_mnemonic=encrypted_mnemonic)
)
await self._session.flush()

View File

@@ -0,0 +1,66 @@
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.exc import SQLAlchemyError
from src.application.abstractions.repositories import IOrganizationDocumentRepository
from src.application.contracts import ILogger
from src.application.domain.entities.organization import OrganizationDocumentEntity
from src.application.domain.exceptions import ApplicationException
from src.infrastructure.database.models import OrganizationDocumentModel
class OrganizationDocumentRepository(IOrganizationDocumentRepository):
def __init__(self, session: AsyncSession, logger: ILogger):
self._session = session
self._logger = logger
def _to_entity(self, m: OrganizationDocumentModel) -> OrganizationDocumentEntity:
return OrganizationDocumentEntity(
id=m.id,
organization_id=m.organization_id,
document_type=m.document_type,
file_name=m.file_name,
s3_key=m.s3_key,
content_type=m.content_type,
file_size_bytes=m.file_size_bytes,
uploaded_by=m.uploaded_by,
created_at=m.created_at,
)
async def create(self, document: OrganizationDocumentEntity) -> OrganizationDocumentEntity:
model = OrganizationDocumentModel(
id=document.id,
organization_id=document.organization_id,
document_type=document.document_type,
file_name=document.file_name,
s3_key=document.s3_key,
content_type=document.content_type,
file_size_bytes=document.file_size_bytes,
uploaded_by=document.uploaded_by,
)
self._session.add(model)
try:
await self._session.flush()
return self._to_entity(model)
except SQLAlchemyError as exc:
self._logger.exception(str(exc))
raise ApplicationException(status_code=500, message='Database error')
async def get_by_id(self, document_id: str) -> OrganizationDocumentEntity:
res = await self._session.execute(
select(OrganizationDocumentModel).where(OrganizationDocumentModel.id == document_id)
)
m = res.scalar_one_or_none()
if m is None:
raise ApplicationException(status_code=404, message='Document not found')
return self._to_entity(m)
async def list_by_organization(self, organization_id: str) -> list[OrganizationDocumentEntity]:
res = await self._session.execute(
select(OrganizationDocumentModel)
.where(OrganizationDocumentModel.organization_id == organization_id)
.order_by(OrganizationDocumentModel.created_at.desc())
)
return [self._to_entity(m) for m in res.scalars().all()]

View File

@@ -0,0 +1,64 @@
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
from src.application.abstractions.repositories import IOrganizationWalletRepository
from src.application.contracts import ILogger
from src.application.domain.entities.organization import OrganizationWalletEntity
from src.application.domain.exceptions import ApplicationException
from src.infrastructure.database.models import OrganizationWalletModel
class OrganizationWalletRepository(IOrganizationWalletRepository):
def __init__(self, session: AsyncSession, logger: ILogger):
self._session = session
self._logger = logger
def _to_entity(self, m: OrganizationWalletModel) -> OrganizationWalletEntity:
return OrganizationWalletEntity(
id=m.id,
organization_id=m.organization_id,
chain=m.chain,
address=m.address,
derivation_path=m.derivation_path,
created_at=m.created_at,
)
async def create_many(self, wallets: list[OrganizationWalletEntity]) -> list[OrganizationWalletEntity]:
models = [
OrganizationWalletModel(
id=w.id,
organization_id=w.organization_id,
chain=w.chain,
address=w.address,
derivation_path=w.derivation_path,
)
for w in wallets
]
self._session.add_all(models)
try:
await self._session.flush()
return [self._to_entity(m) for m in models]
except IntegrityError:
raise ApplicationException(status_code=409, message='Wallets already exist for organization')
except SQLAlchemyError as exc:
self._logger.exception(str(exc))
raise ApplicationException(status_code=500, message='Database error')
async def list_by_organization(self, organization_id: str) -> list[OrganizationWalletEntity]:
res = await self._session.execute(
select(OrganizationWalletModel)
.where(OrganizationWalletModel.organization_id == organization_id)
.order_by(OrganizationWalletModel.chain)
)
return [self._to_entity(m) for m in res.scalars().all()]
async def exists_for_organization(self, organization_id: str) -> bool:
res = await self._session.execute(
select(OrganizationWalletModel.id)
.where(OrganizationWalletModel.organization_id == organization_id)
.limit(1)
)
return res.scalar_one_or_none() is not None

View File

@@ -0,0 +1,81 @@
from __future__ import annotations
from typing import Any
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.exc import SQLAlchemyError
from src.application.abstractions.repositories import IPurchaseRequestRepository
from src.application.contracts import ILogger
from src.application.domain.entities.organization import PurchaseRequestEntity
from src.application.domain.exceptions import ApplicationException
from src.infrastructure.database.models import PurchaseRequestModel
class PurchaseRequestRepository(IPurchaseRequestRepository):
def __init__(self, session: AsyncSession, logger: ILogger):
self._session = session
self._logger = logger
def _to_entity(self, m: PurchaseRequestModel) -> PurchaseRequestEntity:
return PurchaseRequestEntity(
id=m.id,
organization_id=m.organization_id,
status=m.status,
usdt_amount=m.usdt_amount,
rub_amount=m.rub_amount,
exchange_rate=m.exchange_rate,
service_fee_percent=m.service_fee_percent,
comment=m.comment,
admin_comment=m.admin_comment,
target_wallet_chain=m.target_wallet_chain,
target_wallet_address=m.target_wallet_address,
tx_hash=m.tx_hash,
assigned_to=m.assigned_to,
created_at=m.created_at,
updated_at=m.updated_at,
completed_at=m.completed_at,
)
def _apply_filters(self, stmt, *, status: str | None, organization_id: str | None):
if status:
stmt = stmt.where(PurchaseRequestModel.status == status)
if organization_id:
stmt = stmt.where(PurchaseRequestModel.organization_id == organization_id)
return stmt
async def get_by_id(self, request_id: str) -> PurchaseRequestEntity:
res = await self._session.execute(select(PurchaseRequestModel).where(PurchaseRequestModel.id == request_id))
m = res.scalar_one_or_none()
if m is None:
raise ApplicationException(status_code=404, message='Purchase request not found')
return self._to_entity(m)
async def list_all(
self,
*,
status: str | None,
organization_id: str | None,
limit: int,
offset: int,
) -> list[PurchaseRequestEntity]:
stmt = select(PurchaseRequestModel).order_by(PurchaseRequestModel.created_at.desc())
stmt = self._apply_filters(stmt, status=status, organization_id=organization_id)
res = await self._session.execute(stmt.limit(limit).offset(offset))
return [self._to_entity(m) for m in res.scalars().all()]
async def count_all(self, *, status: str | None, organization_id: str | None) -> int:
stmt = select(func.count()).select_from(PurchaseRequestModel)
stmt = self._apply_filters(stmt, status=status, organization_id=organization_id)
res = await self._session.execute(stmt)
return int(res.scalar_one())
async def update(self, request_id: str, *, values: dict[str, Any]) -> PurchaseRequestEntity:
if not values:
return await self.get_by_id(request_id)
await self._session.execute(
update(PurchaseRequestModel).where(PurchaseRequestModel.id == request_id).values(**values)
)
await self._session.flush()
return await self.get_by_id(request_id)

View File

@@ -0,0 +1,94 @@
from __future__ import annotations
from datetime import datetime
from fastapi import status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
from src.application.abstractions.repositories import IUserRepository
from src.application.contracts import ILogger
from src.application.domain.entities import UserEntity
from src.application.domain.enums.account_type import AccountType
from src.application.domain.exceptions import ApplicationException
from src.infrastructure.database.models import UserModel
class UserRepository(IUserRepository):
def __init__(self, session: AsyncSession, logger: ILogger):
self._session = session
self._logger = logger
def _to_entity(self, user: UserModel) -> UserEntity:
return UserEntity(
id=user.id,
email=user.email,
password_hash=user.password_hash,
first_name=user.first_name,
middle_name=user.middle_name,
last_name=user.last_name,
birth_date=user.birth_date,
encrypted_mnemonic=user.encrypted_mnemonic,
phone=user.phone,
passport_data=user.passport_data,
inn=user.inn,
erc20=user.erc20,
avatar_link=user.avatar_link,
kyc_verified=user.kyc_verified,
is_deleted=user.is_deleted,
created_at=user.created_at,
updated_at=user.updated_at,
kyc_verified_at=user.kyc_verified_at,
account_type=user.account_type,
provisioned_by=user.provisioned_by,
provisioned_at=user.provisioned_at,
)
async def create_legal_entity_user(
self,
*,
email: str,
password_hash: str,
provisioned_by: str,
provisioned_at: datetime,
kyc_verified: bool,
kyc_verified_at: datetime,
) -> UserEntity:
user = UserModel(
email=email,
password_hash=password_hash,
account_type=AccountType.LEGAL_ENTITY,
provisioned_by=provisioned_by,
provisioned_at=provisioned_at,
kyc_verified=kyc_verified,
kyc_verified_at=kyc_verified_at,
)
self._session.add(user)
try:
await self._session.flush()
return self._to_entity(user)
except IntegrityError:
raise ApplicationException(status_code=409, message='User with this email already exists')
except SQLAlchemyError as exc:
self._logger.exception(str(exc))
raise ApplicationException(status_code=500, message='Database error')
async def get_user_by_email(self, email: str) -> UserEntity:
try:
stmt = select(UserModel).where(UserModel.email == email, UserModel.is_deleted.is_(False))
result = await self._session.execute(stmt)
user = result.scalar_one_or_none()
if user is None:
raise ApplicationException(status_code=404, message='User not found')
return self._to_entity(user)
except ApplicationException:
raise
except SQLAlchemyError as exc:
self._logger.exception(str(exc))
raise ApplicationException(status_code=500, message='Database error')
async def exists_by_email(self, email: str) -> bool:
stmt = select(UserModel.id).where(UserModel.email == email, UserModel.is_deleted.is_(False)).limit(1)
result = await self._session.execute(stmt)
return result.scalar_one_or_none() is not None

View File

@@ -0,0 +1,105 @@
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from src.application.abstractions import IUnitOfWork
from src.application.abstractions.repositories import (
IAdminSessionRepository,
IAdminUserRepository,
ILegalEntityRepository,
IOrganizationDocumentRepository,
IOrganizationWalletRepository,
IPurchaseRequestRepository,
IUserRepository,
)
from src.application.contracts import ILogger
from src.application.domain.exceptions import RefreshConcurrentException
from src.infrastructure.database.repositories import (
AdminSessionRepository,
AdminUserRepository,
LegalEntityRepository,
OrganizationDocumentRepository,
OrganizationWalletRepository,
PurchaseRequestRepository,
UserRepository,
)
class UnitOfWork(IUnitOfWork):
def __init__(self, session_factory: async_sessionmaker[AsyncSession], logger: ILogger):
self.session_factory = session_factory
self._session: AsyncSession | None = None
self._user_repository: IUserRepository | None = None
self._admin_user_repository: IAdminUserRepository | None = None
self._admin_session_repository: IAdminSessionRepository | None = None
self._legal_entity_repository: ILegalEntityRepository | None = None
self._organization_wallet_repository: IOrganizationWalletRepository | None = None
self._organization_document_repository: IOrganizationDocumentRepository | None = None
self._purchase_request_repository: IPurchaseRequestRepository | None = None
self._logger: ILogger = logger
async def __aenter__(self):
self._session = self.session_factory()
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
if exc_type:
if not isinstance(exc_val, RefreshConcurrentException):
self._logger.error(str(exc_val))
await self._session.rollback()
else:
await self._session.flush()
await self._session.commit()
await self._session.close()
async def commit(self) -> None:
await self._session.commit()
async def rollback(self) -> None:
await self._session.rollback()
@property
def user_repository(self) -> IUserRepository:
if self._user_repository is None:
self._user_repository = UserRepository(session=self._session, logger=self._logger)
return self._user_repository
@property
def admin_user_repository(self) -> IAdminUserRepository:
if self._admin_user_repository is None:
self._admin_user_repository = AdminUserRepository(session=self._session, logger=self._logger)
return self._admin_user_repository
@property
def admin_session_repository(self) -> IAdminSessionRepository:
if self._admin_session_repository is None:
self._admin_session_repository = AdminSessionRepository(session=self._session, logger=self._logger)
return self._admin_session_repository
@property
def legal_entity_repository(self) -> ILegalEntityRepository:
if self._legal_entity_repository is None:
self._legal_entity_repository = LegalEntityRepository(session=self._session, logger=self._logger)
return self._legal_entity_repository
@property
def organization_wallet_repository(self) -> IOrganizationWalletRepository:
if self._organization_wallet_repository is None:
self._organization_wallet_repository = OrganizationWalletRepository(
session=self._session, logger=self._logger
)
return self._organization_wallet_repository
@property
def organization_document_repository(self) -> IOrganizationDocumentRepository:
if self._organization_document_repository is None:
self._organization_document_repository = OrganizationDocumentRepository(
session=self._session, logger=self._logger
)
return self._organization_document_repository
@property
def purchase_request_repository(self) -> IPurchaseRequestRepository:
if self._purchase_request_repository is None:
self._purchase_request_repository = PurchaseRequestRepository(
session=self._session, logger=self._logger
)
return self._purchase_request_repository

View File

@@ -0,0 +1,28 @@
from src.application.contracts import ILogger
from src.application.domain.enums import LogFormat
from src.application.domain.enums import LogLevel
from src.infrastructure.config.settings import settings
from src.infrastructure.logger.logger import Logger
log_levels = {
'DEBUG': LogLevel.DEBUG,
'INFO': LogLevel.INFO,
'WARNING': LogLevel.WARNING,
'ERROR': LogLevel.ERROR,
'CRITICAL': LogLevel.CRITICAL,
'EXCEPTION': LogLevel.EXCEPTION,
}
log_formats = {
'JSON': LogFormat.JSON,
'TEXT': LogFormat.TEXT,
}
logger = Logger(
min_level=log_levels.get(settings.LOG_LEVEL, LogLevel.INFO),
log_format=log_formats.get(settings.LOG_FORMAT, LogFormat.JSON),
)
def get_logger() -> ILogger:
return logger

View File

@@ -0,0 +1,129 @@
import traceback
import inspect
import sys
import json
from datetime import datetime
from typing import Callable, Optional, Any
from ulid import ULID
from src.application.contracts import ILogger
from src.application.domain.enums import LogFormat, LogLevel
from src.infrastructure.context_vars import trace_id_var
class Logger(ILogger):
_instance = None
__default_format = LogFormat.JSON
def __new__(cls, *args: Any, **kwargs: Any) -> "Logger":
if cls._instance is None:
cls._instance = super(Logger, cls).__new__(cls)
return cls._instance
def __init__(
self,
log_format: LogFormat = __default_format,
min_level: LogLevel = LogLevel.INFO,
id_generator: Optional[Callable[[], str]] = lambda: str(ULID()),
instance_id: str = "N/A",
):
self.log_format = log_format
self.min_level = min_level
self.id_generator = id_generator
self.instance_id = instance_id
def set_instance_id(self, instance_id: str) -> None:
self.instance_id = instance_id
def get_instance_id(self) -> str:
return self.instance_id
def set_format(self, log_format: LogFormat) -> None:
if not isinstance(log_format, LogFormat):
raise ValueError("Log format must be an instance of LogFormat enum")
self.log_format = log_format
def set_min_level(self, level: LogLevel) -> None:
self.min_level = level
def new_trace_id(self) -> str:
trace_id = str(ULID()) if self.id_generator is None else self.id_generator()
trace_id_var.set(trace_id)
return trace_id
def set_trace_id(self, trace_id: str) -> None:
trace_id_var.set(trace_id)
def get_trace_id(self) -> str:
return trace_id_var.get()
def clear_trace_id(self) -> None:
trace_id_var.set("N/A")
def _prepare_log_data(self, level: LogLevel, message: str) -> dict[str, Any]:
current_frame = inspect.currentframe()
if (
current_frame
and current_frame.f_back
and current_frame.f_back.f_back
and current_frame.f_back.f_back.f_back
):
frame = current_frame.f_back.f_back.f_back
filename = frame.f_code.co_filename
line_number = frame.f_lineno
else:
filename = "unknown"
line_number = 0
log_data = {
"timestamp": datetime.now().isoformat(),
"level": level.name,
"instance_id": self.instance_id,
"file": filename,
"line": line_number,
"trace_id": trace_id_var.get(),
"message": message,
}
if level == LogLevel.EXCEPTION:
log_data["exception"] = traceback.format_exc()
return log_data
def _log(self, level: LogLevel, message: str) -> None:
if level >= self.min_level:
log_data = self._prepare_log_data(level, message)
if self.log_format == LogFormat.JSON:
log_message = json.dumps(log_data, ensure_ascii=False)
else:
log_message = (
f"{log_data['timestamp']} - {log_data['level']} - "
f"{log_data['instance_id']} - {log_data['trace_id']} - "
f"{log_data['file']}:{log_data['line']} - "
f"{log_data['message']}"
)
if "exception" in log_data:
log_message += f"\nTraceback:\n{log_data['exception']}"
self._write(log_message)
def _write(self, message: str) -> None:
sys.stdout.write(message + "\n")
def debug(self, message: str) -> None:
self._log(LogLevel.DEBUG, message)
def info(self, message: str) -> None:
self._log(LogLevel.INFO, message)
def warning(self, message: str) -> None:
self._log(LogLevel.WARNING, message)
def error(self, message: str) -> None:
self._log(LogLevel.ERROR, message)
def critical(self, message: str) -> None:
self._log(LogLevel.CRITICAL, message)
def exception(self, message: str) -> None:
self._log(LogLevel.EXCEPTION, message)

View File

@@ -0,0 +1 @@
from src.infrastructure.messanger.rabbit_client import RabbitClient

View File

@@ -0,0 +1,72 @@
from typing import Any, Mapping
from faststream.rabbit import RabbitBroker
from src.application.contracts import IQueueMessanger
from src.infrastructure.config import settings
class RabbitClient(IQueueMessanger):
def __init__(self) -> None:
self._broker = RabbitBroker(
settings.RABBIT_URL,
)
self._connected = False
async def connect(self) -> None:
if self._connected:
return
await self._broker.connect()
self._connected = True
async def close(self) -> None:
if not self._connected:
return
await self._broker.close()
self._connected = False
async def _ensure_connected(self) -> None:
if not self._connected:
await self.connect()
async def publish_to_queue(
self,
queue: str,
message: Any,
*,
persist: bool | None = None,
headers: Mapping[str, Any] | None = None,
correlation_id: str | None = None,
message_id: str | None = None,
) -> None:
await self._ensure_connected()
await self._broker.publish(
message,
queue=queue,
persist=settings.RABBIT_PUBLISH_PERSIST if persist is None else persist,
headers=headers,
correlation_id=correlation_id,
message_id=message_id,
)
async def publish(
self,
message: Any,
*,
exchange: str,
routing_key: str,
persist: bool | None = None,
headers: Mapping[str, Any] | None = None,
correlation_id: str | None = None,
message_id: str | None = None,
) -> None:
await self._ensure_connected()
await self._broker.publish(
message,
exchange=exchange,
routing_key=routing_key,
persist=settings.RABBIT_PUBLISH_PERSIST if persist is None else persist,
headers=headers,
correlation_id=correlation_id,
message_id=message_id,
)

View File

@@ -0,0 +1,3 @@
from src.infrastructure.security.jwt import JwtService
from src.infrastructure.security.csrf import CsrfService
from src.infrastructure.security.hash import HashService

View File

@@ -0,0 +1,81 @@
from __future__ import annotations
import secrets
from typing import Any, Optional, Mapping
from itsdangerous import URLSafeTimedSerializer, SignatureExpired, BadSignature
from src.application.contracts import ICsrfService
from src.application.domain.exceptions import ApplicationException
from src.infrastructure.config.settings import settings
class CsrfService(ICsrfService):
COOKIE_NAME = "csrf_token"
HEADER_NAME = "X-CSRF-Token"
SALT = "csrf"
TTL_SECONDS = 3600
def __init__(self) -> None:
self._serializer = URLSafeTimedSerializer(
secret_key=settings.CSRF_SECRET_KEY,
salt=self.SALT,
)
@property
def cookie_name(self) -> str:
return self.COOKIE_NAME
@property
def header_name(self) -> str:
return self.HEADER_NAME
@property
def ttl_seconds(self) -> int:
return self.TTL_SECONDS
def issue(self, subject: Optional[str] = None) -> str:
payload = {
"sub": subject,
"nonce": secrets.token_urlsafe(32),
}
return self._serializer.dumps(payload)
def verify(self, token: str, expected_subject: Optional[str] = None) -> dict[str, Any]:
try:
data = self._serializer.loads(token, max_age=self.TTL_SECONDS)
except SignatureExpired:
raise ApplicationException(
status_code=403,
message="CSRF token expired",
)
except BadSignature:
raise ApplicationException(
status_code=403,
message="CSRF token invalid",
)
if expected_subject is not None and data.get("sub") != expected_subject:
raise ApplicationException(
status_code=403,
message="CSRF token subject mismatch",
)
return data
def extract(self, cookies: Mapping[str, str], headers: Mapping[str, str]) -> tuple[Optional[str], Optional[str]]:
cookie_token = cookies.get(self.COOKIE_NAME)
header_token = headers.get(self.HEADER_NAME)
return cookie_token, header_token
def verify_pair(self, cookie_token: Optional[str], header_token: Optional[str], expected_subject: Optional[str] = None) -> None:
if not cookie_token or not header_token:
raise ApplicationException(
status_code=403,
message="CSRF token missing",
)
if not secrets.compare_digest(cookie_token, header_token):
raise ApplicationException(
status_code=403,
message="CSRF token mismatch",
)
self.verify(cookie_token, expected_subject=expected_subject)

View File

@@ -0,0 +1,17 @@
import bcrypt
from src.application.contracts import IHashService, ILogger
class HashService(IHashService):
def __init__(self, logger: ILogger):
self._logger = logger
async def hash(self, value: str) -> str:
hashed_value = bcrypt.hashpw(value.encode(), bcrypt.gensalt())
self._logger.info(f'Hash value {hashed_value.decode()}')
return hashed_value.decode()
async def verify(self, hashed_value: str, plain_value: str) -> bool:
self._logger.info(f'Hash value {hashed_value[:10]}')
return bcrypt.checkpw(plain_value.encode(), hashed_value.encode())

View File

@@ -0,0 +1,118 @@
from __future__ import annotations
from datetime import datetime, timezone, timedelta
from jose import jwt, ExpiredSignatureError, JWTError
from src.application.contracts import ILogger, IJwtService
from src.application.domain.dto import AccessTokenPayload
from src.application.domain.exceptions import ApplicationException
from src.infrastructure.config.settings import settings
from src.infrastructure.vault import JwtKeyStore
class JwtService(IJwtService):
def __init__(self, logger: ILogger, key_store: JwtKeyStore) -> None:
self._logger = logger
self._key_store = key_store
@property
def _issuer(self) -> str | None:
return settings.ADMIN_JWT_ISSUER
async def create_access_token(self, user_id: str, *, role: str) -> str:
now = datetime.now(timezone.utc)
exp = now + timedelta(seconds=int(settings.JWT_ACCESS_TTL_SECONDS))
payload: dict[str, object] = {
'sub': user_id,
'type': 'access',
'role': role,
'iat': int(now.timestamp()),
'nbf': int(now.timestamp()),
'exp': int(exp.timestamp()),
}
if self._issuer:
payload['iss'] = self._issuer
if settings.JWT_AUDIENCE:
payload['aud'] = settings.JWT_AUDIENCE
try:
kid, private_pem = await self._key_store.get_signing_key()
token = jwt.encode(payload, private_pem, algorithm=settings.JWT_ALGORITHM, headers={'kid': kid})
self._logger.info(f'Admin access token created admin_user_id={user_id} kid={kid}')
return token
except ApplicationException:
raise
except Exception as exception:
self._logger.error(f'JWT signing failed admin_user_id={user_id} error={exception}')
raise ApplicationException(status_code=500, message='JWT signing failed')
async def decode_access_token(self, token: str) -> AccessTokenPayload:
payload = await self._decode_and_verify(token)
if payload.get('type') != 'access':
raise ApplicationException(status_code=401, message='Invalid token type')
try:
return AccessTokenPayload(
sub=str(payload['sub']),
type='access',
role=str(payload['role']) if payload.get('role') else None,
iat=int(payload['iat']),
nbf=int(payload['nbf']),
exp=int(payload['exp']),
iss=payload.get('iss'),
aud=payload.get('aud'),
)
except KeyError as exception:
raise ApplicationException(status_code=401, message=f'Missing token claim: {exception}')
async def _decode_and_verify(self, token: str) -> dict:
kid: str | None = None
try:
header = jwt.get_unverified_header(token)
kid = header.get('kid')
if not kid:
raise ApplicationException(status_code=401, message='Missing token header: kid')
if header.get('alg') != settings.JWT_ALGORITHM:
raise ApplicationException(status_code=401, message='Invalid token algorithm')
public_pem = await self._key_store.get_public_key_for_kid(str(kid))
if not public_pem:
raise ApplicationException(status_code=401, message='Unknown token kid')
options = {
'verify_signature': True,
'verify_exp': True,
'verify_nbf': True,
'verify_iat': True,
'verify_aud': bool(settings.JWT_AUDIENCE),
'verify_iss': bool(self._issuer),
'require_exp': True,
'require_iat': True,
'require_nbf': True,
'require_sub': True,
'leeway': 10,
}
payload = jwt.decode(
token,
public_pem,
algorithms=[settings.JWT_ALGORITHM],
audience=settings.JWT_AUDIENCE or None,
issuer=self._issuer or None,
options=options,
)
if 'type' not in payload:
raise ApplicationException(status_code=401, message='Missing token claim: type')
if payload.get('type') == 'access' and 'role' not in payload:
raise ApplicationException(status_code=401, message='Missing token claim: role')
return payload
except ExpiredSignatureError:
raise ApplicationException(status_code=401, message='Token expired')
except ApplicationException:
raise
except JWTError:
raise ApplicationException(status_code=401, message='Invalid token')
except Exception as exception:
self._logger.error(f'Unexpected JWT decode error kid={kid} error={exception}')
raise ApplicationException(status_code=500, message='JWT decode failed')

View File

View File

@@ -0,0 +1,65 @@
from __future__ import annotations
from aiobotocore.session import get_session
class S3DocumentsService:
def __init__(
self,
*,
bucket: str,
region: str,
access_key_id: str | None,
secret_access_key: str | None,
endpoint_url: str | None,
key_prefix: str = 'legal-docs',
presigned_ttl_seconds: int = 3600,
):
self._bucket = bucket
self._region = region or 'us-east-1'
self._access_key_id = access_key_id
self._secret_access_key = secret_access_key
self._endpoint_url = endpoint_url.strip().rstrip('/') if endpoint_url and endpoint_url.strip() else None
self._key_prefix = key_prefix.strip('/')
self._presigned_ttl_seconds = presigned_ttl_seconds
def build_object_key(self, organization_id: str, document_id: str, file_name: str) -> str:
safe_name = file_name.replace('/', '_').replace('\\', '_')
return f'{self._key_prefix}/{organization_id}/{document_id}/{safe_name}'
def _client_kwargs(self) -> dict[str, object]:
kw: dict[str, object] = {'region_name': self._region}
if self._access_key_id:
kw['aws_access_key_id'] = self._access_key_id
if self._secret_access_key:
kw['aws_secret_access_key'] = self._secret_access_key
if self._endpoint_url:
kw['endpoint_url'] = self._endpoint_url
return kw
async def upload_bytes(self, *, key: str, body: bytes, content_type: str) -> str:
session = get_session()
async with session.create_client('s3', **self._client_kwargs()) as client:
await client.put_object(
Bucket=self._bucket,
Key=key,
Body=body,
ContentType=content_type,
)
return key
async def generate_presigned_download_url(self, *, key: str, expires_in: int | None = None) -> str:
ttl = expires_in if expires_in is not None else self._presigned_ttl_seconds
session = get_session()
async with session.create_client('s3', **self._client_kwargs()) as client:
url = await client.generate_presigned_url(
ClientMethod='get_object',
Params={'Bucket': self._bucket, 'Key': key},
ExpiresIn=ttl,
)
return url
async def delete_object(self, *, key: str) -> None:
session = get_session()
async with session.create_client('s3', **self._client_kwargs()) as client:
await client.delete_object(Bucket=self._bucket, Key=key)

View File

@@ -0,0 +1 @@
from src.infrastructure.utils.instance_id import generate_instance_id

View File

@@ -0,0 +1,14 @@
from ulid import ULID
def generate_instance_id() -> str:
"""
Generate a process-wide instance id in ULID format.
ULID is 26 chars (Crockford Base32) and lexicographically sortable by time.
"""
return str(ULID())

View File

@@ -0,0 +1,3 @@
from src.infrastructure.vault.utils import create_hvac_client_from_approle, read_kv2_secret
from src.infrastructure.vault.keys import JwtKeyStore
from src.infrastructure.vault.scheduler import start_jwt_keys_scheduler

View File

@@ -0,0 +1,118 @@
from __future__ import annotations
import asyncio
from datetime import datetime, timezone
from src.application.domain.dto import JwtKeySet, JwtKeyPair
from src.application.domain.exceptions import ApplicationException
from src.infrastructure.vault.utils import create_hvac_client_from_approle, read_kv2_secret
class JwtKeyStore:
_instance: "JwtKeyStore | None" = None
def __new__(cls, *args, **kwargs):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(
self,
*,
vault_addr: str,
vault_role_id: str,
vault_secret_id: str,
vault_namespace: str | None,
mount_point: str,
kid_path: str = 'jwt/kid',
kids_prefix: str = 'jwt/kids',
timeout_seconds: int = 5,
):
if getattr(self, '_initialized', False):
return
self._vault_addr = vault_addr
self._vault_role_id = vault_role_id
self._vault_secret_id = vault_secret_id
self._vault_namespace = vault_namespace
self._timeout = timeout_seconds
self._mount = mount_point
self._kid_path = kid_path
self._kids_prefix = kids_prefix
self._lock = asyncio.Lock()
self._keyset: JwtKeySet | None = None
self._last_refresh_at: datetime | None = None
self._initialized = True
@classmethod
def get_instance(cls) -> 'JwtKeyStore':
if cls._instance is None:
raise ApplicationException(status_code=500, message='JwtKeyStore not initialized')
return cls._instance
def _read_keyset_sync(self) -> JwtKeySet:
client = create_hvac_client_from_approle(
url=self._vault_addr,
role_id=self._vault_role_id,
secret_id=self._vault_secret_id,
namespace=self._vault_namespace,
timeout=self._timeout,
)
kids = read_kv2_secret(client=client, mount_point=self._mount, path=self._kid_path)
active_kid = kids.get('active')
previous_kid = kids.get('previous')
if not active_kid:
raise RuntimeError('Vault jwt/kid secret missing active')
active_pair = self._read_keypair_sync(client, active_kid)
prev_pair = None
if previous_kid and previous_kid != active_kid:
prev_pair = self._read_keypair_sync(client, previous_kid)
return JwtKeySet(active=active_pair, previous=prev_pair)
def _read_keypair_sync(self, client, kid: str) -> JwtKeyPair:
data = read_kv2_secret(
client=client,
mount_point=self._mount,
path=f'{self._kids_prefix}/{kid}',
)
priv = data.get('private_key')
pub = data.get('public_key')
if not priv or not pub:
raise RuntimeError(f'Vault jwt/kids/{kid} missing private_key/public_key')
return JwtKeyPair(kid=kid, private_key_pem=priv, public_key_pem=pub)
async def refresh(self) -> JwtKeySet:
keyset = await asyncio.to_thread(self._read_keyset_sync)
async with self._lock:
self._keyset = keyset
self._last_refresh_at = datetime.now(timezone.utc)
return keyset
async def get_signing_key(self) -> tuple[str, str]:
ks = await self._get_or_refresh()
return ks.active.kid, ks.active.private_key_pem
async def get_public_key_for_kid(self, kid: str) -> str | None:
ks = await self._get_or_refresh()
return ks.public_keys_by_kid().get(kid)
async def last_refresh_at(self) -> datetime | None:
async with self._lock:
return self._last_refresh_at
async def _get_or_refresh(self) -> JwtKeySet:
async with self._lock:
ks = self._keyset
return ks if ks else await self.refresh()

View File

@@ -0,0 +1,24 @@
from __future__ import annotations
import logging
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.interval import IntervalTrigger
from src.infrastructure.vault import JwtKeyStore
logger = logging.getLogger(__name__)
def start_jwt_keys_scheduler(store: JwtKeyStore, *, refresh_seconds: int = 3600) -> AsyncIOScheduler:
scheduler = AsyncIOScheduler()
scheduler.add_job(
store.refresh,
trigger=IntervalTrigger(seconds=refresh_seconds),
id="jwt_keys_refresh",
replace_existing=True,
max_instances=1,
coalesce=True,
misfire_grace_time=60,
)
scheduler.start()
logger.info("JWT keys scheduler started (interval=%s seconds)", refresh_seconds)
return scheduler

View File

@@ -0,0 +1,30 @@
from __future__ import annotations
import hvac
def create_hvac_client_from_approle(
*,
url: str,
role_id: str,
secret_id: str,
namespace: str | None = None,
timeout: int = 5,
) -> hvac.Client:
kwargs: dict = {'url': url, 'timeout': timeout}
if namespace:
kwargs['namespace'] = namespace
client = hvac.Client(**kwargs)
client.auth.approle.login(role_id=role_id, secret_id=secret_id)
if not client.is_authenticated():
raise RuntimeError(
'Vault AppRole authentication failed. Check VAULT_ADDR, VAULT_ROLE_ID, VAULT_SECRET_ID'
)
return client
def read_kv2_secret(*, client: hvac.Client, mount_point: str, path: str) -> dict:
secret = client.secrets.kv.v2.read_secret_version(
mount_point=mount_point,
path=path,
)
return secret["data"]["data"]