diff --git a/pyproject.toml b/pyproject.toml index 0822ea6..fb8c472 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,6 +6,8 @@ requires-python = "==3.12.*" dependencies = [ "apscheduler==3.11.2", "asyncpg==0.31.0", + "bip-utils>=2.9.3", + "cryptography>=44.0.0", "dotenv==0.9.9", "fastapi==0.128.7", "granian==2.6.1", diff --git a/src/application/abstractions/i_unit_of_work.py b/src/application/abstractions/i_unit_of_work.py index 01fb87d..e7fdb89 100644 --- a/src/application/abstractions/i_unit_of_work.py +++ b/src/application/abstractions/i_unit_of_work.py @@ -3,6 +3,7 @@ from typing import Protocol, runtime_checkable from src.application.abstractions.repositories import ( IUserRepository, ILegalEntityRepository, + IOrganizationWalletRepository, IPurchaseRequestRepository, ) @@ -21,5 +22,8 @@ class IUnitOfWork(Protocol): @property def legal_entity_repository(self) -> ILegalEntityRepository: ... + @property + def organization_wallet_repository(self) -> IOrganizationWalletRepository: ... + @property def purchase_request_repository(self) -> IPurchaseRequestRepository: ... diff --git a/src/application/abstractions/repositories/__init__.py b/src/application/abstractions/repositories/__init__.py index fe79fd2..4fbe3f3 100644 --- a/src/application/abstractions/repositories/__init__.py +++ b/src/application/abstractions/repositories/__init__.py @@ -1,3 +1,4 @@ from src.application.abstractions.repositories.i_user_repository import IUserRepository from src.application.abstractions.repositories.i_legal_entity_repository import ILegalEntityRepository from src.application.abstractions.repositories.i_purchase_request_repository import IPurchaseRequestRepository +from src.application.abstractions.repositories.i_organization_wallet_repository import IOrganizationWalletRepository diff --git a/src/application/abstractions/repositories/i_legal_entity_repository.py b/src/application/abstractions/repositories/i_legal_entity_repository.py index bd3ab48..7522dc2 100644 --- a/src/application/abstractions/repositories/i_legal_entity_repository.py +++ b/src/application/abstractions/repositories/i_legal_entity_repository.py @@ -7,3 +7,15 @@ class ILegalEntityRepository(ABC): @abstractmethod async def get_by_user_id(self, user_id: str) -> LegalEntityEntity | None: raise NotImplementedError + + @abstractmethod + async def get_by_id(self, organization_id: str) -> LegalEntityEntity: + raise NotImplementedError + + @abstractmethod + async def list_all(self, *, limit: int, offset: int) -> list[LegalEntityEntity]: + raise NotImplementedError + + @abstractmethod + async def count_all(self) -> int: + raise NotImplementedError diff --git a/src/application/abstractions/repositories/i_organization_wallet_repository.py b/src/application/abstractions/repositories/i_organization_wallet_repository.py new file mode 100644 index 0000000..0e62b0c --- /dev/null +++ b/src/application/abstractions/repositories/i_organization_wallet_repository.py @@ -0,0 +1,9 @@ +from abc import ABC, abstractmethod + +from src.application.domain.entities.organization_wallet import OrganizationWalletEntity + + +class IOrganizationWalletRepository(ABC): + @abstractmethod + async def list_by_organization(self, organization_id: str) -> list[OrganizationWalletEntity]: + raise NotImplementedError diff --git a/src/application/commands/__init__.py b/src/application/commands/__init__.py index 5f12e7c..5ca2461 100644 --- a/src/application/commands/__init__.py +++ b/src/application/commands/__init__.py @@ -1,3 +1,12 @@ +from src.application.commands.organization_commands import ( + GetOrganizationCommand, + ListOrganizationsCommand, +) +from src.application.commands.organization_wallet_commands import ( + GetOrganizationMnemonicCommand, + GetOrganizationSecretKeysCommand, + ListOrganizationWalletsCommand, +) from src.application.commands.purchase_request_commands import ( CreatePurchaseRequestCommand, GetPurchaseRequestCommand, diff --git a/src/application/commands/organization_commands.py b/src/application/commands/organization_commands.py new file mode 100644 index 0000000..1330778 --- /dev/null +++ b/src/application/commands/organization_commands.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from src.application.abstractions import IUnitOfWork +from src.application.contracts import ILogger +from src.application.domain.entities.legal_entity import LegalEntityEntity +from src.infrastructure.database.decorators import transactional + + +class ListOrganizationsCommand: + def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger): + self._unit_of_work = unit_of_work + self._logger = logger + + @transactional + async def __call__(self, *, limit: int = 50, offset: int = 0) -> tuple[list[LegalEntityEntity], int]: + items = await self._unit_of_work.legal_entity_repository.list_all(limit=limit, offset=offset) + total = await self._unit_of_work.legal_entity_repository.count_all() + return items, total + + +class GetOrganizationCommand: + def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger): + self._unit_of_work = unit_of_work + self._logger = logger + + @transactional + async def __call__(self, organization_id: str) -> LegalEntityEntity: + return await self._unit_of_work.legal_entity_repository.get_by_id(organization_id) diff --git a/src/application/commands/organization_wallet_commands.py b/src/application/commands/organization_wallet_commands.py new file mode 100644 index 0000000..f74e4f2 --- /dev/null +++ b/src/application/commands/organization_wallet_commands.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from src.application.abstractions import IUnitOfWork +from src.application.contracts import ILogger +from src.application.domain.entities.legal_entity import LegalEntityEntity +from src.application.domain.entities.organization_wallet import OrganizationWalletEntity +from src.application.domain.exceptions import NotFoundException, ServiceUnavailableException +from src.infrastructure.crypto.wallet_crypto import ( + DerivedSecretKey, + decrypt_mnemonic, + derive_all_private_keys, + is_crypto_ready, +) +from src.infrastructure.database.decorators import transactional + + +class ListOrganizationWalletsCommand: + def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger): + self._unit_of_work = unit_of_work + self._logger = logger + + @transactional + async def __call__(self, organization_id: str) -> list[OrganizationWalletEntity]: + await self._unit_of_work.legal_entity_repository.get_by_id(organization_id) + return await self._unit_of_work.organization_wallet_repository.list_by_organization(organization_id) + + +class GetOrganizationMnemonicCommand: + def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger): + self._unit_of_work = unit_of_work + self._logger = logger + + @transactional + async def __call__(self, organization_id: str) -> str: + org = await self._require_wallets_org(organization_id) + if not is_crypto_ready(): + raise ServiceUnavailableException(message='Crypto service not ready') + return decrypt_mnemonic(org.encrypted_mnemonic or '') + + async def _require_wallets_org(self, organization_id: str) -> LegalEntityEntity: + org = await self._unit_of_work.legal_entity_repository.get_by_id(organization_id) + if not org.encrypted_mnemonic: + raise NotFoundException(message='Wallets not created for organization') + return org + + +class GetOrganizationSecretKeysCommand: + def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger): + self._unit_of_work = unit_of_work + self._logger = logger + + @transactional + async def __call__(self, organization_id: str) -> list[DerivedSecretKey]: + org = await self._require_wallets_org(organization_id) + if not is_crypto_ready(): + raise ServiceUnavailableException(message='Crypto service not ready') + mnemonic = decrypt_mnemonic(org.encrypted_mnemonic or '') + return derive_all_private_keys(mnemonic) + + async def _require_wallets_org(self, organization_id: str) -> LegalEntityEntity: + org = await self._unit_of_work.legal_entity_repository.get_by_id(organization_id) + if not org.encrypted_mnemonic: + raise NotFoundException(message='Wallets not created for organization') + return org diff --git a/src/application/domain/dto/__init__.py b/src/application/domain/dto/__init__.py index fbf3570..5180d14 100644 --- a/src/application/domain/dto/__init__.py +++ b/src/application/domain/dto/__init__.py @@ -1,2 +1,2 @@ -from src.application.domain.dto.token import AccessTokenPayload, AuthContext +from src.application.domain.dto.token import AccessTokenPayload, AdminAuthContext, AuthContext from src.application.domain.dto.keys import JwtPublicKey, JwtPublicKeySet \ No newline at end of file diff --git a/src/application/domain/dto/token.py b/src/application/domain/dto/token.py index f46391c..e6a9e00 100644 --- a/src/application/domain/dto/token.py +++ b/src/application/domain/dto/token.py @@ -4,7 +4,8 @@ from pydantic import BaseModel class AccessTokenPayload(BaseModel): sub: str type: str - sid: str + sid: str | None = None + role: str | None = None iat: int nbf: int exp: int @@ -15,4 +16,9 @@ class AccessTokenPayload(BaseModel): class AuthContext(BaseModel): user_id: str sid: str - token: AccessTokenPayload \ No newline at end of file + token: AccessTokenPayload + + +class AdminAuthContext(BaseModel): + admin_user_id: str + role: str \ No newline at end of file diff --git a/src/application/domain/entities/legal_entity.py b/src/application/domain/entities/legal_entity.py index d3bbbba..a1aa94a 100644 --- a/src/application/domain/entities/legal_entity.py +++ b/src/application/domain/entities/legal_entity.py @@ -22,3 +22,7 @@ class LegalEntityEntity: contact_phone: str | None = None kyc_verified: bool = True kyc_verified_at: datetime | None = None + encrypted_mnemonic: str | None = None + created_by: str | None = None + created_at: datetime | None = None + updated_at: datetime | None = None diff --git a/src/application/domain/entities/organization_wallet.py b/src/application/domain/entities/organization_wallet.py new file mode 100644 index 0000000..bcdecc7 --- /dev/null +++ b/src/application/domain/entities/organization_wallet.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime + + +@dataclass(slots=True) +class OrganizationWalletEntity: + id: str + organization_id: str + chain: str + address: str + derivation_path: str + created_at: datetime | None = None diff --git a/src/infrastructure/config/settings.py b/src/infrastructure/config/settings.py index a1ceaa7..a1de366 100644 --- a/src/infrastructure/config/settings.py +++ b/src/infrastructure/config/settings.py @@ -39,6 +39,7 @@ class Settings(BaseSettings): VAULT_DOCS_SECRET_PATH: str = 'docs' VAULT_JWT_KID_PATH: str = 'jwt/kid' VAULT_JWT_KIDS_PREFIX: str = 'jwt/kids' + VAULT_CRYPTO_MASTER_KEY_PATH: str = 'crypto' VAULT_S3_SECRET_PATH: str = 's3/avatars' DATABASE_URL_DIRECT: str | None = Field(default=None, validation_alias='DATABASE_URL') diff --git a/src/infrastructure/crypto/__init__.py b/src/infrastructure/crypto/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/infrastructure/crypto/wallet_crypto.py b/src/infrastructure/crypto/wallet_crypto.py new file mode 100644 index 0000000..5ce112b --- /dev/null +++ b/src/infrastructure/crypto/wallet_crypto.py @@ -0,0 +1,189 @@ +from __future__ import annotations + +import base64 +import os +import re +from dataclasses import dataclass + +from bip_utils import ( + Bip39SeedGenerator, + 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'", +} + +_master_key: bytes | None = None + + +@dataclass(frozen=True) +class DerivedSecretKey: + chain: str + address: str + derivation_path: str + private_key: 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 derive_all_private_keys(mnemonic: str) -> list[DerivedSecretKey]: + seed_bytes = Bip39SeedGenerator(mnemonic).Generate() + + eth_ctx = Bip44.FromSeed(seed_bytes, Bip44Coins.ETHEREUM) + eth_node = ( + eth_ctx.Purpose() + .Coin() + .Account(0) + .Change(Bip44Changes.CHAIN_EXT) + .AddressIndex(0) + ) + eth_addr = eth_node.PublicKey().ToAddress() + eth_pk = eth_node.PrivateKey().Raw().ToHex() + + btc_ctx = Bip84.FromSeed(seed_bytes, Bip84Coins.BITCOIN) + btc_node = ( + btc_ctx.Purpose() + .Coin() + .Account(0) + .Change(Bip44Changes.CHAIN_EXT) + .AddressIndex(0) + ) + btc_addr = btc_node.PublicKey().ToAddress() + btc_pk = btc_node.PrivateKey().Raw().ToHex() + + trx_ctx = Bip44.FromSeed(seed_bytes, Bip44Coins.TRON) + trx_node = ( + trx_ctx.Purpose() + .Coin() + .Account(0) + .Change(Bip44Changes.CHAIN_EXT) + .AddressIndex(0) + ) + trx_addr = trx_node.PublicKey().ToAddress() + trx_pk = trx_node.PrivateKey().Raw().ToHex() + + sol_ctx = Bip44.FromSeed(seed_bytes, Bip44Coins.SOLANA) + sol_node = ( + sol_ctx.Purpose() + .Coin() + .Account(0) + .Change(Bip44Changes.CHAIN_EXT) + .AddressIndex(0) + ) + sol_addr = sol_node.PublicKey().ToAddress() + sol_pk = sol_node.PrivateKey().Raw().ToHex() + + return [ + DerivedSecretKey( + chain='ETH', + address=eth_addr, + derivation_path=DERIVATION_PATHS['ETH'], + private_key=eth_pk, + ), + DerivedSecretKey( + chain='BSC', + address=eth_addr, + derivation_path=DERIVATION_PATHS['BSC'], + private_key=eth_pk, + ), + DerivedSecretKey( + chain='BTC', + address=btc_addr, + derivation_path=DERIVATION_PATHS['BTC'], + private_key=btc_pk, + ), + DerivedSecretKey( + chain='TRX', + address=trx_addr, + derivation_path=DERIVATION_PATHS['TRX'], + private_key=trx_pk, + ), + DerivedSecretKey( + chain='SOL', + address=sol_addr, + derivation_path=DERIVATION_PATHS['SOL'], + private_key=sol_pk, + ), + ] + + +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 diff --git a/src/infrastructure/database/models/__init__.py b/src/infrastructure/database/models/__init__.py index cd8ddcd..5873b5d 100644 --- a/src/infrastructure/database/models/__init__.py +++ b/src/infrastructure/database/models/__init__.py @@ -2,5 +2,6 @@ from src.infrastructure.database.models.base import Base from src.infrastructure.database.models.user import UserModel from src.infrastructure.database.models.legal_entity import LegalEntityModel from src.infrastructure.database.models.purchase_request import PurchaseRequestModel +from src.infrastructure.database.models.organization_wallet import OrganizationWalletModel -__all__ = ['Base', 'UserModel', 'LegalEntityModel', 'PurchaseRequestModel'] +__all__ = ['Base', 'UserModel', 'LegalEntityModel', 'PurchaseRequestModel', 'OrganizationWalletModel'] diff --git a/src/infrastructure/database/models/organization_wallet.py b/src/infrastructure/database/models/organization_wallet.py new file mode 100644 index 0000000..a06cf44 --- /dev/null +++ b/src/infrastructure/database/models/organization_wallet.py @@ -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(), + ) diff --git a/src/infrastructure/database/repositories/__init__.py b/src/infrastructure/database/repositories/__init__.py index 7496069..1d11876 100644 --- a/src/infrastructure/database/repositories/__init__.py +++ b/src/infrastructure/database/repositories/__init__.py @@ -1,3 +1,4 @@ from src.infrastructure.database.repositories.user_repository import UserRepository from src.infrastructure.database.repositories.legal_entity_repository import LegalEntityRepository from src.infrastructure.database.repositories.purchase_request_repository import PurchaseRequestRepository +from src.infrastructure.database.repositories.organization_wallet_repository import OrganizationWalletRepository diff --git a/src/infrastructure/database/repositories/legal_entity_repository.py b/src/infrastructure/database/repositories/legal_entity_repository.py index f663062..1c157d2 100644 --- a/src/infrastructure/database/repositories/legal_entity_repository.py +++ b/src/infrastructure/database/repositories/legal_entity_repository.py @@ -1,13 +1,13 @@ from __future__ import annotations -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.exc import SQLAlchemyError from src.application.abstractions.repositories.i_legal_entity_repository import ILegalEntityRepository from src.application.contracts import ILogger from src.application.domain.entities.legal_entity import LegalEntityEntity -from src.application.domain.exceptions import InternalException +from src.application.domain.exceptions import InternalException, NotFoundException from src.infrastructure.database.models.legal_entity import LegalEntityModel @@ -34,6 +34,10 @@ class LegalEntityRepository(ILegalEntityRepository): contact_phone=model.contact_phone, kyc_verified=model.kyc_verified, kyc_verified_at=model.kyc_verified_at, + encrypted_mnemonic=model.encrypted_mnemonic, + created_by=model.created_by, + created_at=model.created_at, + updated_at=model.updated_at, ) async def get_by_user_id(self, user_id: str) -> LegalEntityEntity | None: @@ -47,3 +51,40 @@ class LegalEntityRepository(ILegalEntityRepository): except SQLAlchemyError as exc: self._logger.exception(str(exc)) raise InternalException(message=f'Database error: {exc}') from exc + + async def get_by_id(self, organization_id: str) -> LegalEntityEntity: + try: + stmt = select(LegalEntityModel).where(LegalEntityModel.id == organization_id) + result = await self._session.execute(stmt) + model = result.scalar_one_or_none() + if model is None: + raise NotFoundException(message='Organization not found') + return self._to_entity(model) + except NotFoundException: + raise + except SQLAlchemyError as exc: + self._logger.exception(str(exc)) + raise InternalException(message=f'Database error: {exc}') from exc + + async def list_all(self, *, limit: int, offset: int) -> list[LegalEntityEntity]: + try: + stmt = ( + select(LegalEntityModel) + .order_by(LegalEntityModel.created_at.desc()) + .limit(limit) + .offset(offset) + ) + result = await self._session.execute(stmt) + return [self._to_entity(m) for m in result.scalars().all()] + except SQLAlchemyError as exc: + self._logger.exception(str(exc)) + raise InternalException(message=f'Database error: {exc}') from exc + + async def count_all(self) -> int: + try: + stmt = select(func.count()).select_from(LegalEntityModel) + result = await self._session.execute(stmt) + return int(result.scalar_one()) + except SQLAlchemyError as exc: + self._logger.exception(str(exc)) + raise InternalException(message=f'Database error: {exc}') from exc diff --git a/src/infrastructure/database/repositories/organization_wallet_repository.py b/src/infrastructure/database/repositories/organization_wallet_repository.py new file mode 100644 index 0000000..fd188b2 --- /dev/null +++ b/src/infrastructure/database/repositories/organization_wallet_repository.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.exc import SQLAlchemyError + +from src.application.abstractions.repositories.i_organization_wallet_repository import IOrganizationWalletRepository +from src.application.contracts import ILogger +from src.application.domain.entities.organization_wallet import OrganizationWalletEntity +from src.application.domain.exceptions import InternalException +from src.infrastructure.database.models.organization_wallet import OrganizationWalletModel + + +class OrganizationWalletRepository(IOrganizationWalletRepository): + def __init__(self, session: AsyncSession, logger: ILogger): + self._session = session + self._logger = logger + + @staticmethod + def _to_entity(model: OrganizationWalletModel) -> OrganizationWalletEntity: + return OrganizationWalletEntity( + id=model.id, + organization_id=model.organization_id, + chain=model.chain, + address=model.address, + derivation_path=model.derivation_path, + created_at=model.created_at, + ) + + async def list_by_organization(self, organization_id: str) -> list[OrganizationWalletEntity]: + try: + stmt = ( + select(OrganizationWalletModel) + .where(OrganizationWalletModel.organization_id == organization_id) + .order_by(OrganizationWalletModel.chain) + ) + result = await self._session.execute(stmt) + return [self._to_entity(m) for m in result.scalars().all()] + except SQLAlchemyError as exc: + self._logger.exception(str(exc)) + raise InternalException(message=f'Database error: {exc}') from exc diff --git a/src/infrastructure/database/unit_of_work.py b/src/infrastructure/database/unit_of_work.py index 1aec81e..7b35059 100644 --- a/src/infrastructure/database/unit_of_work.py +++ b/src/infrastructure/database/unit_of_work.py @@ -3,12 +3,14 @@ from src.application.abstractions import IUnitOfWork from src.application.abstractions.repositories import ( IUserRepository, ILegalEntityRepository, + IOrganizationWalletRepository, IPurchaseRequestRepository, ) from src.application.contracts import ILogger from src.infrastructure.database.repositories import ( UserRepository, LegalEntityRepository, + OrganizationWalletRepository, PurchaseRequestRepository, ) @@ -19,6 +21,7 @@ class UnitOfWork(IUnitOfWork): self._session: AsyncSession = None self._user_repository: IUserRepository = None self._legal_entity_repository: ILegalEntityRepository = None + self._organization_wallet_repository: IOrganizationWalletRepository = None self._purchase_request_repository: IPurchaseRequestRepository = None self._logger: ILogger = logger @@ -26,6 +29,7 @@ class UnitOfWork(IUnitOfWork): self._logger.debug('UnitOfWork enter') self._user_repository = None self._legal_entity_repository = None + self._organization_wallet_repository = None self._purchase_request_repository = None self._session = self.session_factory() return self @@ -54,6 +58,14 @@ class UnitOfWork(IUnitOfWork): 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 purchase_request_repository(self) -> IPurchaseRequestRepository: if self._purchase_request_repository is None: diff --git a/src/infrastructure/security/jwt.py b/src/infrastructure/security/jwt.py index 4274902..90cf04d 100644 --- a/src/infrastructure/security/jwt.py +++ b/src/infrastructure/security/jwt.py @@ -23,7 +23,8 @@ class JwtService(IJwtService): return AccessTokenPayload( sub=str(payload['sub']), type='access', - sid=str(payload['sid']), + sid=str(payload['sid']) if payload.get('sid') else None, + role=str(payload['role']) if payload.get('role') else None, iat=int(payload['iat']), nbf=int(payload['nbf']), exp=int(payload['exp']), @@ -83,9 +84,9 @@ class JwtService(IJwtService): options=options, ) - if 'sid' not in payload: - self._logger.warning(f'JWT missing sid claim kid={kid}') - raise ApplicationException(status_code=401, message='Missing token claim: sid') + if 'sid' not in payload and 'role' not in payload: + self._logger.warning(f'JWT missing sid or role claim kid={kid}') + raise ApplicationException(status_code=401, message='Missing token claim: sid or role') if 'type' not in payload: self._logger.warning(f'JWT missing type claim kid={kid}') diff --git a/src/infrastructure/vault/__init__.py b/src/infrastructure/vault/__init__.py index dc30bb1..39f2c3d 100644 --- a/src/infrastructure/vault/__init__.py +++ b/src/infrastructure/vault/__init__.py @@ -1,4 +1,4 @@ from src.infrastructure.vault.client import VaultClient -from src.infrastructure.vault.utils import create_hvac_client, read_kv2_secret +from src.infrastructure.vault.utils import create_hvac_client, 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 \ No newline at end of file diff --git a/src/infrastructure/vault/utils.py b/src/infrastructure/vault/utils.py index 27b3ba9..7bde77a 100644 --- a/src/infrastructure/vault/utils.py +++ b/src/infrastructure/vault/utils.py @@ -2,6 +2,26 @@ 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 create_hvac_client(*, url: str, token: str, timeout: int = 5) -> hvac.Client: client = hvac.Client(url=url, token=token, timeout=timeout) if not client.is_authenticated(): diff --git a/src/main.py b/src/main.py index dcee91b..b730d07 100644 --- a/src/main.py +++ b/src/main.py @@ -22,7 +22,8 @@ from src.presentation.handlers import ( validation_exception_handler, ) from src.presentation.middleware import TraceIDMiddleware, SecurityHeadersMiddleware -from src.presentation.routing import purchase_requests_router +from src.infrastructure.crypto.wallet_crypto import load_master_key_from_vault +from src.presentation.routing import purchase_requests_router, v1_router security = HTTPBasic() @@ -66,6 +67,16 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: app.state.jwt_key_store = jwt_store app.state.jwt_keys_scheduler = jwt_scheduler + + load_master_key_from_vault( + vault_addr=settings.VAULT_ADDR, + vault_role_id=settings.VAULT_ROLE_ID, + vault_secret_id=settings.VAULT_SECRET_ID, + vault_namespace=settings.VAULT_NAMESPACE, + mount_point=settings.VAULT_MOUNT_POINT, + path=settings.VAULT_CRYPTO_MASTER_KEY_PATH, + ) + logger.info('Crypto master key loaded from Vault') yield await app.state.redis.aclose() logger.info(f'B2B service instance ended with id {instance_id}') @@ -89,6 +100,7 @@ app.add_exception_handler(HTTPException, http_exception_handler) app.add_exception_handler(ApplicationException, application_exception_handler) app.add_exception_handler(Exception, unhandled_exception_handler) +app.include_router(v1_router) app.include_router(purchase_requests_router) app.add_middleware(TraceIDMiddleware, logger=logger) diff --git a/src/presentation/decorators/admin_auth.py b/src/presentation/decorators/admin_auth.py new file mode 100644 index 0000000..28033e1 --- /dev/null +++ b/src/presentation/decorators/admin_auth.py @@ -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 ForbiddenException, UnauthorizedException +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 UnauthorizedException(message='Authorization Bearer token required') + + payload = await jwt_service.decode_access_token(token) + if payload.type != 'access': + raise UnauthorizedException(message='Invalid token type') + + role = payload.role + if not role: + raise UnauthorizedException(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 ForbiddenException(message='Insufficient permissions') + return auth + + return dependency diff --git a/src/presentation/decorators/auth.py b/src/presentation/decorators/auth.py index b1ea930..820becc 100644 --- a/src/presentation/decorators/auth.py +++ b/src/presentation/decorators/auth.py @@ -33,4 +33,7 @@ async def require_access_token( if payload.type != 'access': raise UnauthorizedException(message='Invalid token type') + if not payload.sid: + raise UnauthorizedException(message='Invalid token type') + return AuthContext(user_id=payload.sub, sid=payload.sid, token=payload) diff --git a/src/presentation/dependencies/commands.py b/src/presentation/dependencies/commands.py index 116a8db..891a111 100644 --- a/src/presentation/dependencies/commands.py +++ b/src/presentation/dependencies/commands.py @@ -2,7 +2,12 @@ from fastapi import Depends from src.application.abstractions import IUnitOfWork from src.application.commands import ( CreatePurchaseRequestCommand, + GetOrganizationCommand, + GetOrganizationMnemonicCommand, + GetOrganizationSecretKeysCommand, GetPurchaseRequestCommand, + ListOrganizationWalletsCommand, + ListOrganizationsCommand, ListPurchaseRequestsCommand, ) from src.application.contracts import ILogger @@ -29,3 +34,38 @@ def get_get_purchase_request_command( unit_of_work: IUnitOfWork = Depends(get_unit_of_work), ) -> GetPurchaseRequestCommand: return GetPurchaseRequestCommand(unit_of_work=unit_of_work, logger=logger) + + +def get_list_organizations_command( + logger: ILogger = Depends(get_logger), + unit_of_work: IUnitOfWork = Depends(get_unit_of_work), +) -> ListOrganizationsCommand: + return ListOrganizationsCommand(unit_of_work=unit_of_work, logger=logger) + + +def get_get_organization_command( + logger: ILogger = Depends(get_logger), + unit_of_work: IUnitOfWork = Depends(get_unit_of_work), +) -> GetOrganizationCommand: + return GetOrganizationCommand(unit_of_work=unit_of_work, logger=logger) + + +def get_list_organization_wallets_command( + logger: ILogger = Depends(get_logger), + unit_of_work: IUnitOfWork = Depends(get_unit_of_work), +) -> ListOrganizationWalletsCommand: + return ListOrganizationWalletsCommand(unit_of_work=unit_of_work, logger=logger) + + +def get_get_organization_mnemonic_command( + logger: ILogger = Depends(get_logger), + unit_of_work: IUnitOfWork = Depends(get_unit_of_work), +) -> GetOrganizationMnemonicCommand: + return GetOrganizationMnemonicCommand(unit_of_work=unit_of_work, logger=logger) + + +def get_get_organization_secret_keys_command( + logger: ILogger = Depends(get_logger), + unit_of_work: IUnitOfWork = Depends(get_unit_of_work), +) -> GetOrganizationSecretKeysCommand: + return GetOrganizationSecretKeysCommand(unit_of_work=unit_of_work, logger=logger) diff --git a/src/presentation/routing/__init__.py b/src/presentation/routing/__init__.py index f6b1b15..35702eb 100644 --- a/src/presentation/routing/__init__.py +++ b/src/presentation/routing/__init__.py @@ -1,3 +1,9 @@ +from fastapi import APIRouter + +from src.presentation.routing.organizations import organizations_router from src.presentation.routing.purchase_requests import purchase_requests_router -__all__ = ['purchase_requests_router'] +v1_router = APIRouter(prefix='/v1') +v1_router.include_router(organizations_router) + +__all__ = ['purchase_requests_router', 'organizations_router', 'v1_router'] diff --git a/src/presentation/routing/organizations.py b/src/presentation/routing/organizations.py new file mode 100644 index 0000000..0af5cbe --- /dev/null +++ b/src/presentation/routing/organizations.py @@ -0,0 +1,95 @@ +from fastapi import APIRouter, Depends, Query + +from src.application.commands import ( + GetOrganizationCommand, + GetOrganizationMnemonicCommand, + GetOrganizationSecretKeysCommand, + ListOrganizationWalletsCommand, + ListOrganizationsCommand, +) +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_command, + get_get_organization_mnemonic_command, + get_get_organization_secret_keys_command, + get_list_organization_wallets_command, + get_list_organizations_command, +) +from src.presentation.schemas.organization import ( + MnemonicResponse, + OrganizationListResponse, + OrganizationResponse, + SecretKeyResponse, + WalletResponse, +) +from src.presentation.serializers.organization import ( + mnemonic_to_response, + organization_to_response, + secret_key_to_response, + wallet_to_response, +) + +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.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.get('/{organization_id}/wallets', response_model=list[WalletResponse]) +async def list_organization_wallets( + organization_id: str, + auth: AdminAuthContext = Depends(require_admin_access), + command: ListOrganizationWalletsCommand = Depends(get_list_organization_wallets_command), +): + wallets = await command(organization_id) + return [wallet_to_response(w) for w in wallets] + + +@organizations_router.get('/{organization_id}/wallets/mnemonic', response_model=MnemonicResponse) +async def get_organization_mnemonic( + organization_id: str, + auth: AdminAuthContext = Depends(require_admin_role('compliance', 'superadmin')), + command: GetOrganizationMnemonicCommand = Depends(get_get_organization_mnemonic_command), +): + mnemonic = await command(organization_id) + return mnemonic_to_response(mnemonic) + + +@organizations_router.get('/{organization_id}/wallets/secret-keys', response_model=list[SecretKeyResponse]) +async def get_organization_secret_keys( + organization_id: str, + auth: AdminAuthContext = Depends(require_admin_role('compliance', 'superadmin')), + command: GetOrganizationSecretKeysCommand = Depends(get_get_organization_secret_keys_command), +): + keys = await command(organization_id) + return [ + secret_key_to_response( + chain=k.chain, + address=k.address, + derivation_path=k.derivation_path, + private_key=k.private_key, + ) + for k in keys + ] diff --git a/src/presentation/schemas/organization.py b/src/presentation/schemas/organization.py new file mode 100644 index 0000000..f9e8e4f --- /dev/null +++ b/src/presentation/schemas/organization.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel + + +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 MnemonicResponse(BaseModel): + mnemonic: str + + +class SecretKeyResponse(BaseModel): + chain: str + address: str + derivation_path: str + private_key: str diff --git a/src/presentation/serializers/organization.py b/src/presentation/serializers/organization.py new file mode 100644 index 0000000..fcb4620 --- /dev/null +++ b/src/presentation/serializers/organization.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from src.application.domain.entities.legal_entity import LegalEntityEntity +from src.application.domain.entities.organization_wallet import OrganizationWalletEntity +from src.presentation.schemas.organization import ( + MnemonicResponse, + OrganizationResponse, + SecretKeyResponse, + 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 mnemonic_to_response(mnemonic: str) -> MnemonicResponse: + return MnemonicResponse(mnemonic=mnemonic) + + +def secret_key_to_response( + *, + chain: str, + address: str, + derivation_path: str, + private_key: str, +) -> SecretKeyResponse: + return SecretKeyResponse( + chain=chain, + address=address, + derivation_path=derivation_path, + private_key=private_key, + )