feat: add endpoints for orgs
This commit is contained in:
@@ -6,6 +6,8 @@ requires-python = "==3.12.*"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"apscheduler==3.11.2",
|
"apscheduler==3.11.2",
|
||||||
"asyncpg==0.31.0",
|
"asyncpg==0.31.0",
|
||||||
|
"bip-utils>=2.9.3",
|
||||||
|
"cryptography>=44.0.0",
|
||||||
"dotenv==0.9.9",
|
"dotenv==0.9.9",
|
||||||
"fastapi==0.128.7",
|
"fastapi==0.128.7",
|
||||||
"granian==2.6.1",
|
"granian==2.6.1",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from typing import Protocol, runtime_checkable
|
|||||||
from src.application.abstractions.repositories import (
|
from src.application.abstractions.repositories import (
|
||||||
IUserRepository,
|
IUserRepository,
|
||||||
ILegalEntityRepository,
|
ILegalEntityRepository,
|
||||||
|
IOrganizationWalletRepository,
|
||||||
IPurchaseRequestRepository,
|
IPurchaseRequestRepository,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -21,5 +22,8 @@ class IUnitOfWork(Protocol):
|
|||||||
@property
|
@property
|
||||||
def legal_entity_repository(self) -> ILegalEntityRepository: ...
|
def legal_entity_repository(self) -> ILegalEntityRepository: ...
|
||||||
|
|
||||||
|
@property
|
||||||
|
def organization_wallet_repository(self) -> IOrganizationWalletRepository: ...
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def purchase_request_repository(self) -> IPurchaseRequestRepository: ...
|
def purchase_request_repository(self) -> IPurchaseRequestRepository: ...
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
from src.application.abstractions.repositories.i_user_repository import IUserRepository
|
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_legal_entity_repository import ILegalEntityRepository
|
||||||
from src.application.abstractions.repositories.i_purchase_request_repository import IPurchaseRequestRepository
|
from src.application.abstractions.repositories.i_purchase_request_repository import IPurchaseRequestRepository
|
||||||
|
from src.application.abstractions.repositories.i_organization_wallet_repository import IOrganizationWalletRepository
|
||||||
|
|||||||
@@ -7,3 +7,15 @@ class ILegalEntityRepository(ABC):
|
|||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def get_by_user_id(self, user_id: str) -> LegalEntityEntity | None:
|
async def get_by_user_id(self, user_id: str) -> LegalEntityEntity | None:
|
||||||
raise NotImplementedError
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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 (
|
from src.application.commands.purchase_request_commands import (
|
||||||
CreatePurchaseRequestCommand,
|
CreatePurchaseRequestCommand,
|
||||||
GetPurchaseRequestCommand,
|
GetPurchaseRequestCommand,
|
||||||
|
|||||||
28
src/application/commands/organization_commands.py
Normal file
28
src/application/commands/organization_commands.py
Normal file
@@ -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)
|
||||||
64
src/application/commands/organization_wallet_commands.py
Normal file
64
src/application/commands/organization_wallet_commands.py
Normal file
@@ -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
|
||||||
@@ -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
|
from src.application.domain.dto.keys import JwtPublicKey, JwtPublicKeySet
|
||||||
@@ -4,7 +4,8 @@ from pydantic import BaseModel
|
|||||||
class AccessTokenPayload(BaseModel):
|
class AccessTokenPayload(BaseModel):
|
||||||
sub: str
|
sub: str
|
||||||
type: str
|
type: str
|
||||||
sid: str
|
sid: str | None = None
|
||||||
|
role: str | None = None
|
||||||
iat: int
|
iat: int
|
||||||
nbf: int
|
nbf: int
|
||||||
exp: int
|
exp: int
|
||||||
@@ -15,4 +16,9 @@ class AccessTokenPayload(BaseModel):
|
|||||||
class AuthContext(BaseModel):
|
class AuthContext(BaseModel):
|
||||||
user_id: str
|
user_id: str
|
||||||
sid: str
|
sid: str
|
||||||
token: AccessTokenPayload
|
token: AccessTokenPayload
|
||||||
|
|
||||||
|
|
||||||
|
class AdminAuthContext(BaseModel):
|
||||||
|
admin_user_id: str
|
||||||
|
role: str
|
||||||
@@ -22,3 +22,7 @@ class LegalEntityEntity:
|
|||||||
contact_phone: str | None = None
|
contact_phone: str | None = None
|
||||||
kyc_verified: bool = True
|
kyc_verified: bool = True
|
||||||
kyc_verified_at: datetime | None = None
|
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
|
||||||
|
|||||||
14
src/application/domain/entities/organization_wallet.py
Normal file
14
src/application/domain/entities/organization_wallet.py
Normal file
@@ -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
|
||||||
@@ -39,6 +39,7 @@ class Settings(BaseSettings):
|
|||||||
VAULT_DOCS_SECRET_PATH: str = 'docs'
|
VAULT_DOCS_SECRET_PATH: str = 'docs'
|
||||||
VAULT_JWT_KID_PATH: str = 'jwt/kid'
|
VAULT_JWT_KID_PATH: str = 'jwt/kid'
|
||||||
VAULT_JWT_KIDS_PREFIX: str = 'jwt/kids'
|
VAULT_JWT_KIDS_PREFIX: str = 'jwt/kids'
|
||||||
|
VAULT_CRYPTO_MASTER_KEY_PATH: str = 'crypto'
|
||||||
VAULT_S3_SECRET_PATH: str = 's3/avatars'
|
VAULT_S3_SECRET_PATH: str = 's3/avatars'
|
||||||
|
|
||||||
DATABASE_URL_DIRECT: str | None = Field(default=None, validation_alias='DATABASE_URL')
|
DATABASE_URL_DIRECT: str | None = Field(default=None, validation_alias='DATABASE_URL')
|
||||||
|
|||||||
0
src/infrastructure/crypto/__init__.py
Normal file
0
src/infrastructure/crypto/__init__.py
Normal file
189
src/infrastructure/crypto/wallet_crypto.py
Normal file
189
src/infrastructure/crypto/wallet_crypto.py
Normal file
@@ -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
|
||||||
@@ -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.user import UserModel
|
||||||
from src.infrastructure.database.models.legal_entity import LegalEntityModel
|
from src.infrastructure.database.models.legal_entity import LegalEntityModel
|
||||||
from src.infrastructure.database.models.purchase_request import PurchaseRequestModel
|
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']
|
||||||
|
|||||||
28
src/infrastructure/database/models/organization_wallet.py
Normal file
28
src/infrastructure/database/models/organization_wallet.py
Normal 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(),
|
||||||
|
)
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
from src.infrastructure.database.repositories.user_repository import UserRepository
|
from src.infrastructure.database.repositories.user_repository import UserRepository
|
||||||
from src.infrastructure.database.repositories.legal_entity_repository import LegalEntityRepository
|
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.purchase_request_repository import PurchaseRequestRepository
|
||||||
|
from src.infrastructure.database.repositories.organization_wallet_repository import OrganizationWalletRepository
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
|
||||||
from src.application.abstractions.repositories.i_legal_entity_repository import ILegalEntityRepository
|
from src.application.abstractions.repositories.i_legal_entity_repository import ILegalEntityRepository
|
||||||
from src.application.contracts import ILogger
|
from src.application.contracts import ILogger
|
||||||
from src.application.domain.entities.legal_entity import LegalEntityEntity
|
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
|
from src.infrastructure.database.models.legal_entity import LegalEntityModel
|
||||||
|
|
||||||
|
|
||||||
@@ -34,6 +34,10 @@ class LegalEntityRepository(ILegalEntityRepository):
|
|||||||
contact_phone=model.contact_phone,
|
contact_phone=model.contact_phone,
|
||||||
kyc_verified=model.kyc_verified,
|
kyc_verified=model.kyc_verified,
|
||||||
kyc_verified_at=model.kyc_verified_at,
|
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:
|
async def get_by_user_id(self, user_id: str) -> LegalEntityEntity | None:
|
||||||
@@ -47,3 +51,40 @@ class LegalEntityRepository(ILegalEntityRepository):
|
|||||||
except SQLAlchemyError as exc:
|
except SQLAlchemyError as exc:
|
||||||
self._logger.exception(str(exc))
|
self._logger.exception(str(exc))
|
||||||
raise InternalException(message=f'Database error: {exc}') from 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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -3,12 +3,14 @@ from src.application.abstractions import IUnitOfWork
|
|||||||
from src.application.abstractions.repositories import (
|
from src.application.abstractions.repositories import (
|
||||||
IUserRepository,
|
IUserRepository,
|
||||||
ILegalEntityRepository,
|
ILegalEntityRepository,
|
||||||
|
IOrganizationWalletRepository,
|
||||||
IPurchaseRequestRepository,
|
IPurchaseRequestRepository,
|
||||||
)
|
)
|
||||||
from src.application.contracts import ILogger
|
from src.application.contracts import ILogger
|
||||||
from src.infrastructure.database.repositories import (
|
from src.infrastructure.database.repositories import (
|
||||||
UserRepository,
|
UserRepository,
|
||||||
LegalEntityRepository,
|
LegalEntityRepository,
|
||||||
|
OrganizationWalletRepository,
|
||||||
PurchaseRequestRepository,
|
PurchaseRequestRepository,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -19,6 +21,7 @@ class UnitOfWork(IUnitOfWork):
|
|||||||
self._session: AsyncSession = None
|
self._session: AsyncSession = None
|
||||||
self._user_repository: IUserRepository = None
|
self._user_repository: IUserRepository = None
|
||||||
self._legal_entity_repository: ILegalEntityRepository = None
|
self._legal_entity_repository: ILegalEntityRepository = None
|
||||||
|
self._organization_wallet_repository: IOrganizationWalletRepository = None
|
||||||
self._purchase_request_repository: IPurchaseRequestRepository = None
|
self._purchase_request_repository: IPurchaseRequestRepository = None
|
||||||
self._logger: ILogger = logger
|
self._logger: ILogger = logger
|
||||||
|
|
||||||
@@ -26,6 +29,7 @@ class UnitOfWork(IUnitOfWork):
|
|||||||
self._logger.debug('UnitOfWork enter')
|
self._logger.debug('UnitOfWork enter')
|
||||||
self._user_repository = None
|
self._user_repository = None
|
||||||
self._legal_entity_repository = None
|
self._legal_entity_repository = None
|
||||||
|
self._organization_wallet_repository = None
|
||||||
self._purchase_request_repository = None
|
self._purchase_request_repository = None
|
||||||
self._session = self.session_factory()
|
self._session = self.session_factory()
|
||||||
return self
|
return self
|
||||||
@@ -54,6 +58,14 @@ class UnitOfWork(IUnitOfWork):
|
|||||||
self._legal_entity_repository = LegalEntityRepository(session=self._session, logger=self._logger)
|
self._legal_entity_repository = LegalEntityRepository(session=self._session, logger=self._logger)
|
||||||
return self._legal_entity_repository
|
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
|
@property
|
||||||
def purchase_request_repository(self) -> IPurchaseRequestRepository:
|
def purchase_request_repository(self) -> IPurchaseRequestRepository:
|
||||||
if self._purchase_request_repository is None:
|
if self._purchase_request_repository is None:
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ class JwtService(IJwtService):
|
|||||||
return AccessTokenPayload(
|
return AccessTokenPayload(
|
||||||
sub=str(payload['sub']),
|
sub=str(payload['sub']),
|
||||||
type='access',
|
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']),
|
iat=int(payload['iat']),
|
||||||
nbf=int(payload['nbf']),
|
nbf=int(payload['nbf']),
|
||||||
exp=int(payload['exp']),
|
exp=int(payload['exp']),
|
||||||
@@ -83,9 +84,9 @@ class JwtService(IJwtService):
|
|||||||
options=options,
|
options=options,
|
||||||
)
|
)
|
||||||
|
|
||||||
if 'sid' not in payload:
|
if 'sid' not in payload and 'role' not in payload:
|
||||||
self._logger.warning(f'JWT missing sid claim kid={kid}')
|
self._logger.warning(f'JWT missing sid or role claim kid={kid}')
|
||||||
raise ApplicationException(status_code=401, message='Missing token claim: sid')
|
raise ApplicationException(status_code=401, message='Missing token claim: sid or role')
|
||||||
|
|
||||||
if 'type' not in payload:
|
if 'type' not in payload:
|
||||||
self._logger.warning(f'JWT missing type claim kid={kid}')
|
self._logger.warning(f'JWT missing type claim kid={kid}')
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from src.infrastructure.vault.client import VaultClient
|
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.keys import JwtKeyStore
|
||||||
from src.infrastructure.vault.scheduler import start_jwt_keys_scheduler
|
from src.infrastructure.vault.scheduler import start_jwt_keys_scheduler
|
||||||
@@ -2,6 +2,26 @@ from __future__ import annotations
|
|||||||
import hvac
|
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:
|
def create_hvac_client(*, url: str, token: str, timeout: int = 5) -> hvac.Client:
|
||||||
client = hvac.Client(url=url, token=token, timeout=timeout)
|
client = hvac.Client(url=url, token=token, timeout=timeout)
|
||||||
if not client.is_authenticated():
|
if not client.is_authenticated():
|
||||||
|
|||||||
14
src/main.py
14
src/main.py
@@ -22,7 +22,8 @@ from src.presentation.handlers import (
|
|||||||
validation_exception_handler,
|
validation_exception_handler,
|
||||||
)
|
)
|
||||||
from src.presentation.middleware import TraceIDMiddleware, SecurityHeadersMiddleware
|
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()
|
security = HTTPBasic()
|
||||||
|
|
||||||
@@ -66,6 +67,16 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
|
|
||||||
app.state.jwt_key_store = jwt_store
|
app.state.jwt_key_store = jwt_store
|
||||||
app.state.jwt_keys_scheduler = jwt_scheduler
|
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
|
yield
|
||||||
await app.state.redis.aclose()
|
await app.state.redis.aclose()
|
||||||
logger.info(f'B2B service instance ended with id {instance_id}')
|
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(ApplicationException, application_exception_handler)
|
||||||
app.add_exception_handler(Exception, unhandled_exception_handler)
|
app.add_exception_handler(Exception, unhandled_exception_handler)
|
||||||
|
|
||||||
|
app.include_router(v1_router)
|
||||||
app.include_router(purchase_requests_router)
|
app.include_router(purchase_requests_router)
|
||||||
|
|
||||||
app.add_middleware(TraceIDMiddleware, logger=logger)
|
app.add_middleware(TraceIDMiddleware, logger=logger)
|
||||||
|
|||||||
47
src/presentation/decorators/admin_auth.py
Normal file
47
src/presentation/decorators/admin_auth.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
from fastapi import Depends, Request
|
||||||
|
from fastapi.security.utils import get_authorization_scheme_param
|
||||||
|
|
||||||
|
from src.application.contracts import IJwtService
|
||||||
|
from src.application.domain.dto import AdminAuthContext
|
||||||
|
from src.application.domain.exceptions import 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
|
||||||
@@ -33,4 +33,7 @@ async def require_access_token(
|
|||||||
if payload.type != 'access':
|
if payload.type != 'access':
|
||||||
raise UnauthorizedException(message='Invalid token type')
|
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)
|
return AuthContext(user_id=payload.sub, sid=payload.sid, token=payload)
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ from fastapi import Depends
|
|||||||
from src.application.abstractions import IUnitOfWork
|
from src.application.abstractions import IUnitOfWork
|
||||||
from src.application.commands import (
|
from src.application.commands import (
|
||||||
CreatePurchaseRequestCommand,
|
CreatePurchaseRequestCommand,
|
||||||
|
GetOrganizationCommand,
|
||||||
|
GetOrganizationMnemonicCommand,
|
||||||
|
GetOrganizationSecretKeysCommand,
|
||||||
GetPurchaseRequestCommand,
|
GetPurchaseRequestCommand,
|
||||||
|
ListOrganizationWalletsCommand,
|
||||||
|
ListOrganizationsCommand,
|
||||||
ListPurchaseRequestsCommand,
|
ListPurchaseRequestsCommand,
|
||||||
)
|
)
|
||||||
from src.application.contracts import ILogger
|
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),
|
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
|
||||||
) -> GetPurchaseRequestCommand:
|
) -> GetPurchaseRequestCommand:
|
||||||
return GetPurchaseRequestCommand(unit_of_work=unit_of_work, logger=logger)
|
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)
|
||||||
|
|||||||
@@ -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
|
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']
|
||||||
|
|||||||
95
src/presentation/routing/organizations.py
Normal file
95
src/presentation/routing/organizations.py
Normal file
@@ -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
|
||||||
|
]
|
||||||
51
src/presentation/schemas/organization.py
Normal file
51
src/presentation/schemas/organization.py
Normal file
@@ -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
|
||||||
63
src/presentation/serializers/organization.py
Normal file
63
src/presentation/serializers/organization.py
Normal file
@@ -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,
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user