From 11113fd5f6a4b013f7f3f3d27f44f79daceb60fd Mon Sep 17 00:00:00 2001 From: Code With Me Date: Fri, 20 Mar 2026 15:26:18 +0300 Subject: [PATCH] feat: Introduce B2B API key management and refactor database session dependency injection across services. --- app/core/database.py | 5 ++ app/core/security.py | 25 ++++++- app/services/inventory/deps.py | 3 - app/services/inventory/routes.py | 27 ++++--- app/services/user/models.py | 19 +++++ app/services/user/routes.py | 64 ++++++++++++++--- app/services/user/schemas.py | 20 ++++++ app/services/user/service.py | 64 ++++++++++++++++- app/shared/deps.py | 5 +- .../versions/58fabaa5d620_add_b2b_api_keys.py | 70 +++++++++++++++++++ 10 files changed, 267 insertions(+), 35 deletions(-) create mode 100644 migrations/versions/58fabaa5d620_add_b2b_api_keys.py diff --git a/app/core/database.py b/app/core/database.py index 3f7194a..bd991ca 100644 --- a/app/core/database.py +++ b/app/core/database.py @@ -1,5 +1,7 @@ from collections.abc import AsyncGenerator +from typing import Annotated +from fastapi import Depends from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.orm import DeclarativeBase @@ -25,3 +27,6 @@ class Base(DeclarativeBase): async def get_session() -> AsyncGenerator[AsyncSession, None]: async with async_session_factory() as session: yield session + + +SessionDep = Annotated[AsyncSession, Depends(get_session)] diff --git a/app/core/security.py b/app/core/security.py index 49d4570..c2bce2f 100644 --- a/app/core/security.py +++ b/app/core/security.py @@ -1,17 +1,38 @@ import asyncio from datetime import UTC, datetime, timedelta -from typing import Any +from typing import Annotated, Any from fastapi import Depends +from fastapi.security import APIKeyHeader from jose import jwt from passlib.context import CryptContext from app.core.config import settings -from app.core.exceptions import PermissionDeniedError +from app.core.database import SessionDep +from app.core.exceptions import CredentialsError, PermissionDeniedError from app.services.user.models import User, UserRole from app.shared.deps import get_current_user pwd_context = CryptContext(schemes=['bcrypt'], deprecated='auto') +header_scheme = APIKeyHeader(name='X-API-Key', auto_error=False) + + +async def get_b2b_partner_by_api_key( + api_key: Annotated[str | None, Depends(header_scheme)], session: SessionDep +) -> User: + from app.services.user.service import UserService + + if not api_key: + raise CredentialsError('API key is required') + key_obj = await UserService.authenticate_api_key_b2b_partner(session, api_key) + if not key_obj: + raise CredentialsError('Invalid API key') + user = key_obj.user + if not user.is_active: + raise CredentialsError('User is not active') + if user.role not in (UserRole.USER_B2B, UserRole.SELLER_B2B): + raise CredentialsError('Not a B2B partner account') + return user def verify_password_sync(plain_password: str, hashed_password: str) -> bool: diff --git a/app/services/inventory/deps.py b/app/services/inventory/deps.py index 2ed89bb..c5d4009 100644 --- a/app/services/inventory/deps.py +++ b/app/services/inventory/deps.py @@ -1,9 +1,7 @@ from typing import Annotated from fastapi import Depends -from sqlalchemy.ext.asyncio import AsyncSession -from app.core.database import get_session from app.services.inventory.service import InventoryAdminService, InventoryService @@ -19,4 +17,3 @@ async def get_inventory_admin_service() -> InventoryAdminService: InventoryAdminServiceDep = Annotated[ InventoryAdminService, Depends(get_inventory_admin_service) ] -SessionDep = Annotated[AsyncSession, Depends(get_session)] diff --git a/app/services/inventory/routes.py b/app/services/inventory/routes.py index 27d36c0..7cd53e3 100644 --- a/app/services/inventory/routes.py +++ b/app/services/inventory/routes.py @@ -2,9 +2,8 @@ from uuid import UUID from fastapi import APIRouter, Depends, Header, Query, Request, status -from sqlalchemy.ext.asyncio import AsyncSession -from app.core.database import get_session +from app.core.database import SessionDep from app.core.security import RoleChecker, UserRole from app.services.inventory.models import ProductStatus from app.services.inventory.rate_limit import check_rate_limit @@ -52,7 +51,7 @@ @router_v1.get('/', response_model=list[ProductRead]) async def get_active_products( - session: Annotated[AsyncSession, Depends(get_session)], + session: SessionDep, service: Annotated[InventoryService, Depends(get_inventory_service)], skip: int = 0, limit: int = 50, @@ -69,7 +68,7 @@ async def get_active_products( @router_v1.post('/', response_model=ProductRead, status_code=status.HTTP_201_CREATED) async def create_product( product_data: ProductCreate, - session: Annotated[AsyncSession, Depends(get_session)], + session: SessionDep, current_user: Annotated[User, SELLER_DEPENDENCY], service: Annotated[InventoryService, Depends(get_inventory_service)], ) -> ProductRead: @@ -85,7 +84,7 @@ async def create_product( @router_v1.patch('/{product_id}/activate', response_model=ProductRead) async def activate_product( product_id: UUID, - session: Annotated[AsyncSession, Depends(get_session)], + session: SessionDep, current_user: Annotated[User, ADMIN_DEPENDENCY], service: Annotated[InventoryAdminService, Depends(get_inventory_admin_service)], ) -> ProductRead: @@ -102,7 +101,7 @@ async def activate_product( async def update_product( product_id: UUID, product_data: ProductUpdate, - session: Annotated[AsyncSession, Depends(get_session)], + session: SessionDep, current_user: Annotated[User, ADMIN_AND_SELLER_DEPENDENCY], service: Annotated[InventoryService, Depends(get_inventory_service)], ) -> ProductRead: @@ -118,7 +117,7 @@ async def update_product( @router_v1.delete('/{product_id}', status_code=status.HTTP_204_NO_CONTENT) async def delete_product( product_id: UUID, - session: Annotated[AsyncSession, Depends(get_session)], + session: SessionDep, current_user: Annotated[User, ADMIN_DEPENDENCY], service: Annotated[InventoryService, Depends(get_inventory_service)], ) -> None: @@ -132,7 +131,7 @@ async def delete_product( @router_v1.get('/{product_id}', response_model=ProductRead) async def get_product( product_id: UUID, - session: Annotated[AsyncSession, Depends(get_session)], + session: SessionDep, service: Annotated[InventoryService, Depends(get_inventory_service)], ) -> ProductRead: product = await service.get_product( @@ -147,10 +146,10 @@ async def get_product( async def reservation_data( request: Request, reservation_data: ReservationCreate, + session: SessionDep, + current_user: Annotated[User, Depends(get_current_user)], service: Annotated[InventoryService, Depends(get_inventory_service)], x_idempotency_key: str = Header(...), - session: AsyncSession = Depends(get_session), - current_user: User = Depends(get_current_user), ) -> ReservationResponse: await check_rate_limit( rate_limit_script=request.app.state.rate_limit_script, @@ -169,7 +168,7 @@ async def reservation_data( @router_v1.post('/{product_id}/submit', response_model=ProductRead) async def submit_for_moderation( product_id: UUID, - session: Annotated[AsyncSession, Depends(get_session)], + session: SessionDep, current_user: Annotated[User, SELLER_DEPENDENCY], service: Annotated[InventoryService, Depends(get_inventory_service)], ) -> ProductRead: @@ -184,7 +183,7 @@ async def submit_for_moderation( @router_v1.post('/{product_id}/approve', response_model=ProductRead) async def approve_product( product_id: UUID, - session: Annotated[AsyncSession, Depends(get_session)], + session: SessionDep, current_user: Annotated[User, ADMIN_DEPENDENCY], service: Annotated[InventoryAdminService, Depends(get_inventory_admin_service)], ) -> ProductRead: @@ -199,7 +198,7 @@ async def approve_product( @router_v1.post('/{product_id}/reject', response_model=ProductRead) async def reject_product( product_id: UUID, - session: Annotated[AsyncSession, Depends(get_session)], + session: SessionDep, current_user: Annotated[User, ADMIN_DEPENDENCY], service: Annotated[InventoryAdminService, Depends(get_inventory_admin_service)], reason: str = Query(...), @@ -216,7 +215,7 @@ async def reject_product( @router_v1.post('/{product_id}/claim', response_model=ProductRead) async def claim_for_moderation( product_id: UUID, - session: Annotated[AsyncSession, Depends(get_session)], + session: SessionDep, current_user: Annotated[User, ADMIN_DEPENDENCY], service: Annotated[InventoryAdminService, Depends(get_inventory_admin_service)], ) -> ProductRead: diff --git a/app/services/user/models.py b/app/services/user/models.py index a9b6e53..4f62679 100644 --- a/app/services/user/models.py +++ b/app/services/user/models.py @@ -34,6 +34,9 @@ class User(Base): role: Mapped[UserRole] = mapped_column(SQLEnum(UserRole), default=UserRole.USER) created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) refresh_tokens: Mapped[list['RefreshToken']] = relationship(back_populates='user') + api_keys_b2b_partners: Mapped[list['APIKeyB2BPartner']] = relationship( + back_populates='user' + ) class RefreshToken(Base): @@ -48,3 +51,19 @@ class RefreshToken(Base): expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) user: Mapped[User] = relationship(back_populates='refresh_tokens') + + +class APIKeyB2BPartner(Base): + __tablename__ = 'api_keys_b2b_partners' + id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4) + user_id: Mapped[UUID] = mapped_column( + ForeignKey('users.id', ondelete='CASCADE'), nullable=False, index=True + ) + name: Mapped[str] = mapped_column(String(100), nullable=False) + key_prefix: Mapped[str] = mapped_column(String(12), nullable=False, index=True) + hashed_key: Mapped[str] = mapped_column(String(), nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + last_used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + user: Mapped[User] = relationship(back_populates='api_keys_b2b_partners') diff --git a/app/services/user/routes.py b/app/services/user/routes.py index b5da5f4..0d77cfc 100644 --- a/app/services/user/routes.py +++ b/app/services/user/routes.py @@ -1,24 +1,32 @@ +from http import HTTPStatus from typing import Annotated +from uuid import UUID from fastapi import APIRouter, Depends, status from fastapi.security import OAuth2PasswordRequestForm -from sqlalchemy.ext.asyncio import AsyncSession -from app.core.database import get_session +from app.core.database import SessionDep from app.core.exceptions import CredentialsError -from app.core.security import create_access_token -from app.services.user.models import User -from app.services.user.schemas import RefreshTokenRequest, Token, UserCreate, UserRead +from app.core.security import RoleChecker, create_access_token +from app.services.user.models import User, UserRole +from app.services.user.schemas import ( + APIKeyCreate, + APIKeyRead, + APIKeyWithSecret, + RefreshTokenRequest, + Token, + UserCreate, + UserRead, +) from app.services.user.service import UserService from app.shared.deps import get_current_user router_v1 = APIRouter() +B2B_PARTNER_DEP = Depends(RoleChecker([UserRole.USER_B2B, UserRole.SELLER_B2B])) @router_v1.post('/users', status_code=status.HTTP_201_CREATED) -async def create_user( - user_create: UserCreate, session: Annotated[AsyncSession, Depends(get_session)] -) -> UserRead: +async def create_user(user_create: UserCreate, session: SessionDep) -> UserRead: user = await UserService.create_user(session, user_create) return UserRead.model_validate(user) @@ -26,7 +34,7 @@ async def create_user( @router_v1.post('/auth/token') async def login( form_data: Annotated[OAuth2PasswordRequestForm, Depends()], - session: Annotated[AsyncSession, Depends(get_session)], + session: SessionDep, ) -> Token: user = await UserService.authenticate_user( session, form_data.username, form_data.password @@ -43,7 +51,7 @@ async def login( @router_v1.post('/auth/refresh') async def refresh_token( request_data: RefreshTokenRequest, - session: Annotated[AsyncSession, Depends(get_session)], + session: SessionDep, ) -> Token: user = await UserService.refresh_access_token(session, request_data.refresh_token) access_token = create_access_token(data={'sub': str(user.email)}) @@ -58,3 +66,39 @@ async def read_user_me( current_user: Annotated[User, Depends(get_current_user)], ) -> UserRead: return UserRead.model_validate(current_user) + + +@router_v1.post( + '/users/me/api-keys', + response_model=APIKeyWithSecret, + status_code=HTTPStatus.CREATED, +) +async def create_api_key_b2b_partner( + api_key_create: APIKeyCreate, + current_user: Annotated[User, B2B_PARTNER_DEP], + session: SessionDep, +) -> APIKeyWithSecret: + api_key, raw_key = await UserService.create_api_key_b2b_partner( + session, current_user.id, api_key_create.name + ) + data = APIKeyRead.model_validate(api_key).model_dump() + return APIKeyWithSecret(**data, raw_key=raw_key) + + +@router_v1.get('/users/me/api-keys', response_model=list[APIKeyRead]) +async def get_api_keys_b2b_partners( + current_user: Annotated[User, B2B_PARTNER_DEP], +) -> list[APIKeyRead]: + return [ + APIKeyRead.model_validate(api_key) + for api_key in current_user.api_keys_b2b_partners + ] + + +@router_v1.delete('/users/me/api-keys/{key_id}', status_code=status.HTTP_204_NO_CONTENT) +async def delete_api_key_b2b_partner( + key_id: UUID, + current_user: Annotated[User, B2B_PARTNER_DEP], + session: SessionDep, +) -> None: + await UserService.delete_api_key_b2b_partner(session, current_user.id, key_id) diff --git a/app/services/user/schemas.py b/app/services/user/schemas.py index 87d97b3..cdc2620 100644 --- a/app/services/user/schemas.py +++ b/app/services/user/schemas.py @@ -1,3 +1,4 @@ +from datetime import datetime from uuid import UUID from pydantic import BaseModel, ConfigDict, EmailStr @@ -25,3 +26,22 @@ class Token(BaseModel): class RefreshTokenRequest(BaseModel): refresh_token: str + + +class APIKeyCreate(BaseModel): + name: str + + +class APIKeyRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: UUID + name: str + key_prefix: str + is_active: bool + created_at: datetime + expires_at: datetime | None = None + last_used_at: datetime | None = None + + +class APIKeyWithSecret(APIKeyRead): + raw_key: str diff --git a/app/services/user/service.py b/app/services/user/service.py index c4f1a1a..2f85fc3 100644 --- a/app/services/user/service.py +++ b/app/services/user/service.py @@ -1,5 +1,5 @@ import secrets -from datetime import datetime, timedelta +from datetime import UTC, datetime, timedelta from uuid import UUID from sqlalchemy import select @@ -7,12 +7,13 @@ from sqlalchemy.orm import joinedload from app.core.config import settings -from app.core.exceptions import CredentialsError, UserAlreadyExists +from app.core.exceptions import CredentialsError, NotFoundError, UserAlreadyExists from app.core.security import get_password_hash, verify_password -from app.services.user.models import RefreshToken, User +from app.services.user.models import APIKeyB2BPartner, RefreshToken, User from app.services.user.schemas import UserCreate URLSAFE_PARAM = 32 +KEY_LENGTH_PREFIX = 12 class UserService: @@ -67,3 +68,60 @@ async def refresh_access_token(session: AsyncSession, refresh_token: str) -> Use await session.delete(token_obj) await session.commit() return user + + @staticmethod + async def create_api_key_b2b_partner( + session: AsyncSession, user_id: UUID, name: str + ) -> tuple[APIKeyB2BPartner, str]: + raw_key = secrets.token_urlsafe(URLSAFE_PARAM) + key_prefix = raw_key[:KEY_LENGTH_PREFIX] + hashed_key = await get_password_hash(raw_key) + create_api_key_b2b_partner = APIKeyB2BPartner( + user_id=user_id, + name=name, + key_prefix=key_prefix, + hashed_key=hashed_key, + ) + session.add(create_api_key_b2b_partner) + await session.commit() + await session.refresh(create_api_key_b2b_partner) + return create_api_key_b2b_partner, raw_key + + @staticmethod + async def authenticate_api_key_b2b_partner( + session: AsyncSession, raw_key: str + ) -> APIKeyB2BPartner | None: + key_prefix = raw_key[:KEY_LENGTH_PREFIX] + result = await session.execute( + select(APIKeyB2BPartner) + .options(joinedload(APIKeyB2BPartner.user)) + .where( + APIKeyB2BPartner.key_prefix == key_prefix, + APIKeyB2BPartner.is_active, + ) + ) + api_key = result.scalar_one_or_none() + if api_key and await verify_password(raw_key, api_key.hashed_key): + api_key.last_used_at = datetime.now(UTC) + await session.commit() + return api_key + return None + + @staticmethod + async def delete_api_key_b2b_partner( + session: AsyncSession, + user_id: UUID, + key_id: UUID, + ) -> None: + result = await session.execute( + select(APIKeyB2BPartner).where( + APIKeyB2BPartner.user_id == user_id, + APIKeyB2BPartner.id == key_id, + ) + ) + api_key = result.scalar_one_or_none() + if not api_key: + raise NotFoundError() + await session.delete(api_key) + await session.commit() + return None diff --git a/app/shared/deps.py b/app/shared/deps.py index fd3342d..9d70e49 100644 --- a/app/shared/deps.py +++ b/app/shared/deps.py @@ -4,10 +4,9 @@ from fastapi.security import OAuth2PasswordBearer from jose import JWTError, jwt from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession from app.core.config import settings -from app.core.database import get_session +from app.core.database import SessionDep from app.core.exceptions import CredentialsError from app.services.user.models import User @@ -16,7 +15,7 @@ async def get_current_user( token: Annotated[str, Depends(oauth2_scheme)], - session: Annotated[AsyncSession, Depends(get_session)], + session: SessionDep, ) -> User: try: payload = jwt.decode( diff --git a/migrations/versions/58fabaa5d620_add_b2b_api_keys.py b/migrations/versions/58fabaa5d620_add_b2b_api_keys.py new file mode 100644 index 0000000..1bc5c83 --- /dev/null +++ b/migrations/versions/58fabaa5d620_add_b2b_api_keys.py @@ -0,0 +1,70 @@ +"""add_b2b_api_keys + +Revision ID: 58fabaa5d620 +Revises: df8560c163db +Create Date: 2026-03-20 13:37:29.323173 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = '58fabaa5d620' +down_revision: str | Sequence[str] | None = 'df8560c163db' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + 'api_keys_b2b_partners', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('user_id', sa.Uuid(), nullable=False), + sa.Column('name', sa.String(length=100), nullable=False), + sa.Column('key_prefix', sa.String(length=12), nullable=False), + sa.Column('hashed_key', sa.String(), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column( + 'created_at', + sa.DateTime(), + server_default=sa.text('now()'), + nullable=False, + ), + sa.Column('expires_at', sa.DateTime(), nullable=True), + sa.Column('last_used_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + ) + op.create_index( + op.f('ix_api_keys_b2b_partners_key_prefix'), + 'api_keys_b2b_partners', + ['key_prefix'], + unique=False, + ) + op.create_index( + op.f('ix_api_keys_b2b_partners_user_id'), + 'api_keys_b2b_partners', + ['user_id'], + unique=False, + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index( + op.f('ix_api_keys_b2b_partners_user_id'), + table_name='api_keys_b2b_partners', + ) + op.drop_index( + op.f('ix_api_keys_b2b_partners_key_prefix'), + table_name='api_keys_b2b_partners', + ) + op.drop_table('api_keys_b2b_partners') + # ### end Alembic commands ###