diff --git a/app/api/audit/adapter.py b/app/api/audit/adapter.py index f06e24231..e7a39665d 100644 --- a/app/api/audit/adapter.py +++ b/app/api/audit/adapter.py @@ -4,19 +4,11 @@ License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE """ -from typing import ParamSpec, TypeVar - -from fastapi import status - from api.base_adapter import BaseAdapter from ldap_protocol.policies.audit.dataclasses import ( AuditDestinationDTO, AuditPolicyDTO, ) -from ldap_protocol.policies.audit.exception import ( - AuditAlreadyExistsError, - AuditNotFoundError, -) from ldap_protocol.policies.audit.schemas import ( AuditDestinationResponse, AuditDestinationSchemaRequest, @@ -25,18 +17,10 @@ ) from ldap_protocol.policies.audit.service import AuditService -P = ParamSpec("P") -R = TypeVar("R") - class AuditPoliciesAdapter(BaseAdapter[AuditService]): """Adapter for audit policies.""" - _exceptions_map: dict[type[Exception], int] = { - AuditNotFoundError: status.HTTP_404_NOT_FOUND, - AuditAlreadyExistsError: status.HTTP_409_CONFLICT, - } - async def get_policies(self) -> list[AuditPolicyResponse]: """Get all audit policies.""" return [ diff --git a/app/api/audit/router.py b/app/api/audit/router.py index bbafe770a..4a328e2ef 100644 --- a/app/api/audit/router.py +++ b/app/api/audit/router.py @@ -5,10 +5,21 @@ """ from dishka import FromDishka -from dishka.integrations.fastapi import DishkaRoute -from fastapi import APIRouter, Depends, status +from fastapi import Depends, status +from fastapi_error_map.routing import ErrorAwareRouter +from fastapi_error_map.rules import rule from api.auth.utils import verify_auth +from api.error_routing import ( + ERROR_MAP_TYPE, + DishkaErrorAwareRoute, + DomainErrorTranslator, +) +from enums import DomainCodes +from ldap_protocol.policies.audit.exception import ( + AuditAlreadyExistsError, + AuditNotFoundError, +) from ldap_protocol.policies.audit.schemas import ( AuditDestinationResponse, AuditDestinationSchemaRequest, @@ -18,15 +29,29 @@ from .adapter import AuditPoliciesAdapter -audit_router = APIRouter( +translator = DomainErrorTranslator(DomainCodes.AUDIT) + + +error_map: ERROR_MAP_TYPE = { + AuditNotFoundError: rule( + status=status.HTTP_400_BAD_REQUEST, + translator=translator, + ), + AuditAlreadyExistsError: rule( + status=status.HTTP_400_BAD_REQUEST, + translator=translator, + ), +} + +audit_router = ErrorAwareRouter( prefix="/audit", tags=["Audit policy"], dependencies=[Depends(verify_auth)], - route_class=DishkaRoute, + route_class=DishkaErrorAwareRoute, ) -@audit_router.get("/policies") +@audit_router.get("/policies", error_map=error_map) async def get_audit_policies( audit_adapter: FromDishka[AuditPoliciesAdapter], ) -> list[AuditPolicyResponse]: @@ -34,7 +59,7 @@ async def get_audit_policies( return await audit_adapter.get_policies() -@audit_router.put("/policy/{policy_id}") +@audit_router.put("/policy/{policy_id}", error_map=error_map) async def update_audit_policy( policy_id: int, policy_data: AuditPolicySchemaRequest, @@ -44,7 +69,7 @@ async def update_audit_policy( return await audit_adapter.update_policy(policy_id, policy_data) -@audit_router.get("/destinations") +@audit_router.get("/destinations", error_map=error_map) async def get_audit_destinations( audit_adapter: FromDishka[AuditPoliciesAdapter], ) -> list[AuditDestinationResponse]: @@ -52,7 +77,11 @@ async def get_audit_destinations( return await audit_adapter.get_destinations() -@audit_router.post("/destination", status_code=status.HTTP_201_CREATED) +@audit_router.post( + "/destination", + status_code=status.HTTP_201_CREATED, + error_map=error_map, +) async def create_audit_destination( destination_data: AuditDestinationSchemaRequest, audit_adapter: FromDishka[AuditPoliciesAdapter], @@ -61,7 +90,7 @@ async def create_audit_destination( return await audit_adapter.create_destination(destination_data) -@audit_router.delete("/destination/{destination_id}") +@audit_router.delete("/destination/{destination_id}", error_map=error_map) async def delete_audit_destination( destination_id: int, audit_adapter: FromDishka[AuditPoliciesAdapter], @@ -70,7 +99,7 @@ async def delete_audit_destination( await audit_adapter.delete_destination(destination_id) -@audit_router.put("/destination/{destination_id}") +@audit_router.put("/destination/{destination_id}", error_map=error_map) async def update_audit_destination( destination_id: int, destination_data: AuditDestinationSchemaRequest, diff --git a/app/api/auth/adapters/auth.py b/app/api/auth/adapters/auth.py index 5b121b58e..50ed85ee7 100644 --- a/app/api/auth/adapters/auth.py +++ b/app/api/auth/adapters/auth.py @@ -7,32 +7,17 @@ from ipaddress import IPv4Address, IPv6Address from adaptix.conversion import get_converter -from fastapi import Request, status +from fastapi import Request from api.base_adapter import BaseAdapter from ldap_protocol.auth import AuthManager from ldap_protocol.auth.dto import SetupDTO -from ldap_protocol.auth.exceptions.mfa import ( - MFAAPIError, - MFAConnectError, - MFARequiredError, - MissingMFACredentialsError, -) from ldap_protocol.auth.schemas import ( MFAChallengeResponse, OAuth2Form, SetupRequest, ) from ldap_protocol.dialogue import UserSchema -from ldap_protocol.identity.exceptions import ( - AlreadyConfiguredError, - AuthValidationError, - LoginFailedError, - PasswordPolicyError, - UnauthorizedError, - UserNotFoundError, -) -from ldap_protocol.kerberos.exceptions import KRBAPIChangePasswordError _convert_request_to_dto = get_converter(SetupRequest, SetupDTO) @@ -40,21 +25,6 @@ class AuthFastAPIAdapter(BaseAdapter[AuthManager]): """Adapter for using IdentityManager with FastAPI.""" - _exceptions_map: dict[type[Exception], int] = { - UnauthorizedError: status.HTTP_401_UNAUTHORIZED, - LoginFailedError: status.HTTP_403_FORBIDDEN, - MFARequiredError: status.HTTP_426_UPGRADE_REQUIRED, - PasswordPolicyError: status.HTTP_422_UNPROCESSABLE_ENTITY, - AuthValidationError: status.HTTP_422_UNPROCESSABLE_ENTITY, - PermissionError: status.HTTP_403_FORBIDDEN, - UserNotFoundError: status.HTTP_404_NOT_FOUND, - KRBAPIChangePasswordError: status.HTTP_424_FAILED_DEPENDENCY, - AlreadyConfiguredError: status.HTTP_423_LOCKED, - MissingMFACredentialsError: status.HTTP_403_FORBIDDEN, - MFAAPIError: status.HTTP_406_NOT_ACCEPTABLE, - MFAConnectError: status.HTTP_406_NOT_ACCEPTABLE, - } - async def login( self, form: OAuth2Form, diff --git a/app/api/auth/adapters/mfa.py b/app/api/auth/adapters/mfa.py index 96011271f..9fa3b4a02 100644 --- a/app/api/auth/adapters/mfa.py +++ b/app/api/auth/adapters/mfa.py @@ -11,16 +11,7 @@ from api.base_adapter import BaseAdapter from ldap_protocol.auth import MFAManager -from ldap_protocol.auth.exceptions.mfa import ( - ForbiddenError, - InvalidCredentialsError, - MFAAPIError, - MFAConnectError, - MFATokenError, - MissingMFACredentialsError, - NetworkPolicyError, - NotFoundError, -) +from ldap_protocol.auth.exceptions.mfa import MFATokenError from ldap_protocol.auth.schemas import MFACreateRequest, MFAGetResponse from ldap_protocol.multifactor import MFA_HTTP_Creds, MFA_LDAP_Creds @@ -28,16 +19,6 @@ class MFAFastAPIAdapter(BaseAdapter[MFAManager]): """Adapter for using MFAManager with FastAPI.""" - _exceptions_map: dict[type[Exception], int] = { - MissingMFACredentialsError: status.HTTP_403_FORBIDDEN, - NetworkPolicyError: status.HTTP_403_FORBIDDEN, - ForbiddenError: status.HTTP_403_FORBIDDEN, - InvalidCredentialsError: status.HTTP_422_UNPROCESSABLE_ENTITY, - NotFoundError: status.HTTP_404_NOT_FOUND, - MFAAPIError: status.HTTP_406_NOT_ACCEPTABLE, - MFAConnectError: status.HTTP_406_NOT_ACCEPTABLE, - } - async def setup_mfa(self, mfa: MFACreateRequest) -> bool: """Create or update MFA keys. diff --git a/app/api/auth/adapters/session_gateway.py b/app/api/auth/adapters/session_gateway.py index 2b02bf35e..165c8db74 100644 --- a/app/api/auth/adapters/session_gateway.py +++ b/app/api/auth/adapters/session_gateway.py @@ -5,8 +5,6 @@ from ipaddress import IPv4Address, IPv6Address from typing import Literal, ParamSpec, TypeVar -from fastapi import status - from api.base_adapter import BaseAdapter from ldap_protocol.session_storage import SessionRepository @@ -54,9 +52,9 @@ class UserSessionsResponseSchema: class SessionFastAPIGateway(BaseAdapter[SessionRepository]): """Base class for session storage.""" - _exceptions_map: dict[type[Exception], int] = { - LookupError: status.HTTP_404_NOT_FOUND, - } + def __init__(self, repository: SessionRepository) -> None: + """Initialize the session gateway with a repository.""" + self._service = repository async def get_user_sessions( self, diff --git a/app/api/auth/router_auth.py b/app/api/auth/router_auth.py index 28d06a9db..ae8df7bfd 100644 --- a/app/api/auth/router_auth.py +++ b/app/api/auth/router_auth.py @@ -8,25 +8,111 @@ from typing import Annotated from dishka import FromDishka -from dishka.integrations.fastapi import DishkaRoute -from fastapi import APIRouter, Body, Depends, Request, Response, status +from fastapi import Body, Depends, Request, Response, status +from fastapi_error_map.routing import ErrorAwareRouter +from fastapi_error_map.rules import rule from api.auth.adapters import AuthFastAPIAdapter from api.auth.utils import get_ip_from_request, get_user_agent_from_request +from api.error_routing import ( + ERROR_MAP_TYPE, + DishkaErrorAwareRoute, + DomainErrorTranslator, +) +from enums import DomainCodes +from ldap_protocol.auth.exceptions.mfa import ( + MFAAPIError, + MFAConnectError, + MFARequiredError, + MissingMFACredentialsError, +) from ldap_protocol.auth.schemas import ( MFAChallengeResponse, OAuth2Form, SetupRequest, ) from ldap_protocol.dialogue import UserSchema +from ldap_protocol.identity.exceptions import ( + AlreadyConfiguredError, + AuthValidationError, + ForbiddenError, + LoginFailedError, + PasswordPolicyError, + UnauthorizedError, + UserNotFoundError, +) +from ldap_protocol.kerberos.exceptions import KRBAPIChangePasswordError from ldap_protocol.session_storage import SessionStorage from .utils import verify_auth -auth_router = APIRouter(prefix="/auth", tags=["Auth"], route_class=DishkaRoute) +translator = DomainErrorTranslator(DomainCodes.AUTH) + + +error_map: ERROR_MAP_TYPE = { + UnauthorizedError: rule( + status=status.HTTP_401_UNAUTHORIZED, + translator=translator, + ), + AlreadyConfiguredError: rule( + status=status.HTTP_400_BAD_REQUEST, + translator=translator, + ), + ForbiddenError: rule( + status=status.HTTP_400_BAD_REQUEST, + translator=translator, + ), + LoginFailedError: rule( + status=status.HTTP_400_BAD_REQUEST, + translator=translator, + ), + PasswordPolicyError: rule( + status=status.HTTP_422_UNPROCESSABLE_ENTITY, + translator=translator, + ), + UserNotFoundError: rule( + status=status.HTTP_400_BAD_REQUEST, + translator=translator, + ), + AuthValidationError: rule( + status=status.HTTP_422_UNPROCESSABLE_ENTITY, + translator=translator, + ), + MFARequiredError: rule( + status=status.HTTP_400_BAD_REQUEST, + translator=translator, + ), + MissingMFACredentialsError: rule( + status=status.HTTP_400_BAD_REQUEST, + translator=translator, + ), + MFAAPIError: rule( + status=status.HTTP_400_BAD_REQUEST, + translator=translator, + ), + MFAConnectError: rule( + status=status.HTTP_400_BAD_REQUEST, + translator=translator, + ), + PermissionError: rule( + status=status.HTTP_400_BAD_REQUEST, + translator=translator, + ), + KRBAPIChangePasswordError: rule( + status=status.HTTP_400_BAD_REQUEST, + translator=translator, + ), +} + + +auth_router = ErrorAwareRouter( + prefix="/auth", + tags=["Auth"], + route_class=DishkaErrorAwareRoute, +) -@auth_router.post("/") +@auth_router.post("/", error_map=error_map) async def login( form: Annotated[OAuth2Form, Depends()], request: Request, @@ -62,7 +148,7 @@ async def login( ) -@auth_router.get("/me") +@auth_router.get("/me", error_map=error_map) async def users_me( identity_adapter: FromDishka[AuthFastAPIAdapter], ) -> UserSchema: @@ -75,7 +161,11 @@ async def users_me( return await identity_adapter.get_current_user() -@auth_router.delete("/", response_class=Response) +@auth_router.delete( + "/", + response_class=Response, + error_map=error_map, +) async def logout( response: Response, storage: FromDishka[SessionStorage], @@ -97,6 +187,7 @@ async def logout( "/user/password", status_code=200, dependencies=[Depends(verify_auth)], + error_map=error_map, ) async def password_reset( auth_manager: FromDishka[AuthFastAPIAdapter], @@ -121,7 +212,7 @@ async def password_reset( await auth_manager.reset_password(identity, new_password, old_password) -@auth_router.get("/setup") +@auth_router.get("/setup", error_map=error_map) async def check_setup( auth_manager: FromDishka[AuthFastAPIAdapter], ) -> bool: @@ -137,6 +228,7 @@ async def check_setup( "/setup", status_code=status.HTTP_200_OK, responses={423: {"detail": "Locked"}}, + error_map=error_map, ) async def first_setup( request: SetupRequest, diff --git a/app/api/auth/router_mfa.py b/app/api/auth/router_mfa.py index 57daeb214..18424c8ca 100644 --- a/app/api/auth/router_mfa.py +++ b/app/api/auth/router_mfa.py @@ -8,10 +8,10 @@ from typing import Annotated, Literal from dishka import FromDishka -from dishka.integrations.fastapi import DishkaRoute from fastapi import Depends, Form, status from fastapi.responses import RedirectResponse -from fastapi.routing import APIRouter +from fastapi_error_map.routing import ErrorAwareRouter +from fastapi_error_map.rules import rule from api.auth.adapters import MFAFastAPIAdapter from api.auth.utils import ( @@ -19,13 +19,62 @@ get_user_agent_from_request, verify_auth, ) +from api.error_routing import ( + ERROR_MAP_TYPE, + DishkaErrorAwareRoute, + DomainErrorTranslator, +) +from enums import DomainCodes +from ldap_protocol.auth.exceptions.mfa import ( + ForbiddenError, + InvalidCredentialsError, + MFAAPIError, + MFAConnectError, + MissingMFACredentialsError, + NetworkPolicyError, + NotFoundError, +) from ldap_protocol.auth.schemas import MFACreateRequest, MFAGetResponse from ldap_protocol.multifactor import MFA_HTTP_Creds, MFA_LDAP_Creds -mfa_router = APIRouter( +translator = DomainErrorTranslator(DomainCodes.MFA) + + +error_map: ERROR_MAP_TYPE = { + MFAAPIError: rule( + status=status.HTTP_400_BAD_REQUEST, + translator=translator, + ), + MFAConnectError: rule( + status=status.HTTP_400_BAD_REQUEST, + translator=translator, + ), + MissingMFACredentialsError: rule( + status=status.HTTP_400_BAD_REQUEST, + translator=translator, + ), + NetworkPolicyError: rule( + status=status.HTTP_400_BAD_REQUEST, + translator=translator, + ), + ForbiddenError: rule( + status=status.HTTP_400_BAD_REQUEST, + translator=translator, + ), + InvalidCredentialsError: rule( + status=status.HTTP_422_UNPROCESSABLE_ENTITY, + translator=translator, + ), + NotFoundError: rule( + status=status.HTTP_400_BAD_REQUEST, + translator=translator, + ), +} + +mfa_router = ErrorAwareRouter( prefix="/multifactor", tags=["Multifactor"], - route_class=DishkaRoute, + route_class=DishkaErrorAwareRoute, ) @@ -33,6 +82,7 @@ "/setup", status_code=status.HTTP_201_CREATED, dependencies=[Depends(verify_auth)], + error_map=error_map, ) async def setup_mfa( mfa: MFACreateRequest, @@ -51,6 +101,7 @@ async def setup_mfa( @mfa_router.delete( "/keys", dependencies=[Depends(verify_auth)], + error_map=error_map, ) async def remove_mfa( scope: Literal["ldap", "http"], @@ -60,7 +111,11 @@ async def remove_mfa( await mfa_manager.remove_mfa(scope) -@mfa_router.post("/get", dependencies=[Depends(verify_auth)]) +@mfa_router.post( + "/get", + dependencies=[Depends(verify_auth)], + error_map=error_map, +) async def get_mfa( mfa_creds: FromDishka[MFA_HTTP_Creds], mfa_creds_ldap: FromDishka[MFA_LDAP_Creds], @@ -74,7 +129,12 @@ async def get_mfa( return await mfa_manager.get_mfa(mfa_creds, mfa_creds_ldap) -@mfa_router.post("/create", name="callback_mfa", include_in_schema=True) +@mfa_router.post( + "/create", + name="callback_mfa", + include_in_schema=True, + error_map=error_map, +) async def callback_mfa( access_token: Annotated[ str, diff --git a/app/api/auth/session_router.py b/app/api/auth/session_router.py index 3958b9a8c..43da97542 100644 --- a/app/api/auth/session_router.py +++ b/app/api/auth/session_router.py @@ -1,9 +1,17 @@ """Session router for handling user sessions.""" from dishka import FromDishka -from dishka.integrations.fastapi import DishkaRoute from fastapi import Depends, status -from fastapi.routing import APIRouter +from fastapi_error_map.routing import ErrorAwareRouter +from fastapi_error_map.rules import rule + +from api.error_routing import ( + ERROR_MAP_TYPE, + DishkaErrorAwareRoute, + DomainErrorTranslator, +) +from enums import DomainCodes +from ldap_protocol.session_storage.exceptions import SessionUserNotFoundError from .adapters.session_gateway import ( SessionContentResponseSchema, @@ -11,15 +19,25 @@ ) from .utils import verify_auth -session_router = APIRouter( +translator = DomainErrorTranslator(DomainCodes.SESSION) + + +error_map: ERROR_MAP_TYPE = { + SessionUserNotFoundError: rule( + status=status.HTTP_400_BAD_REQUEST, + translator=translator, + ), +} + +session_router = ErrorAwareRouter( prefix="/sessions", tags=["Session"], - route_class=DishkaRoute, + route_class=DishkaErrorAwareRoute, dependencies=[Depends(verify_auth)], ) -@session_router.get("/{upn}") +@session_router.get("/{upn}", error_map=error_map) async def get_user_session( upn: str, gateway: FromDishka[SessionFastAPIGateway], @@ -28,7 +46,11 @@ async def get_user_session( return await gateway.get_user_sessions(upn) -@session_router.delete("/{upn}", status_code=status.HTTP_204_NO_CONTENT) +@session_router.delete( + "/{upn}", + status_code=status.HTTP_204_NO_CONTENT, + error_map=error_map, +) async def delete_user_sessions( upn: str, gateway: FromDishka[SessionFastAPIGateway], @@ -40,6 +62,7 @@ async def delete_user_sessions( @session_router.delete( "/session/{session_id}", status_code=status.HTTP_204_NO_CONTENT, + error_map=error_map, ) async def delete_session( session_id: str, diff --git a/app/api/auth/utils.py b/app/api/auth/utils.py index f0461f7c5..0557267cd 100644 --- a/app/api/auth/utils.py +++ b/app/api/auth/utils.py @@ -43,7 +43,7 @@ def get_ip_from_request(request: Request) -> IPv4Address | IPv6Address: client_ip = forwarded_for.split(",")[0] else: if request.client is None: - raise HTTPException(status.HTTP_403_FORBIDDEN) + raise HTTPException(status.HTTP_400_BAD_REQUEST) client_ip = request.client.host return ip_address(client_ip) diff --git a/app/api/base_adapter.py b/app/api/base_adapter.py index e9f359374..991669195 100644 --- a/app/api/base_adapter.py +++ b/app/api/base_adapter.py @@ -4,16 +4,10 @@ License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE """ -from asyncio import iscoroutinefunction -from functools import wraps -from typing import Awaitable, Callable, NoReturn, ParamSpec, Protocol, TypeVar - -from fastapi import HTTPException, status -from loguru import logger +from typing import ParamSpec, Protocol, TypeVar from abstract_service import AbstractService from authorization_provider_protocol import AuthorizationProviderProtocol -from ldap_protocol.permissions_checker import AuthorizationError _P = ParamSpec("_P") _R = TypeVar("_R") @@ -23,7 +17,6 @@ class BaseAdapter(Protocol[_T]): """Abstract Adapter interface.""" - _exceptions_map: dict[type[Exception], int] _service: _T def __init__( @@ -34,66 +27,3 @@ def __init__( """Set service.""" self._service = service self._service.set_permissions_checker(perm_checker) - - def __new__( - cls, - *_: tuple, - **__: dict, - ) -> "BaseAdapter[_T]": - """Wrap all public methods with try catch for _exceptions_map.""" - instance = super().__new__(cls) - - def wrap_sync(func: Callable[_P, _R]) -> Callable[_P, _R]: - @wraps(func) - def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R: - try: - return func(*args, **kwargs) - except Exception as err: - instance._reraise(err) - - return wrapper - - def wrap_async( - func: Callable[_P, Awaitable[_R]], - ) -> Callable[_P, Awaitable[_R]]: - @wraps(func) - async def awrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R: - try: - return await func(*args, **kwargs) - except Exception as err: - instance._reraise(err) - - return awrapper - - for name in dir(instance): - if name.startswith("_"): - continue - - attr = getattr(instance, name) - - if not callable(attr): - continue - - if iscoroutinefunction(attr): - wrapped = wrap_async(attr) - else: - wrapped = wrap_sync(attr) - - setattr(instance, name, wrapped) - - return instance - - def _reraise(self, exc: Exception) -> NoReturn: - """Reraise exception with mapped HTTPException.""" - exceptions_map = self._exceptions_map | { - AuthorizationError: status.HTTP_403_FORBIDDEN, - } - code = exceptions_map.get(type(exc)) - logger.debug(f"Reraising exception {exc} with code {code}") - if code is None: - raise - - raise HTTPException( - status_code=code, - detail=str(exc), - ) from exc diff --git a/app/api/dhcp/adapter.py b/app/api/dhcp/adapter.py index f69f4aebc..d063ad144 100644 --- a/app/api/dhcp/adapter.py +++ b/app/api/dhcp/adapter.py @@ -6,27 +6,18 @@ from ipaddress import IPv4Address -from fastapi import status - from api.base_adapter import BaseAdapter from ldap_protocol.dhcp import ( AbstractDHCPManager, - DHCPAPIError, DHCPChangeStateSchemaRequest, - DHCPEntryAddError, - DHCPEntryDeleteError, - DHCPEntryNotFoundError, - DHCPEntryUpdateError, DHCPLeaseSchemaRequest, DHCPLeaseSchemaResponse, DHCPLeaseToReservationErrorResponse, - DHCPOperationError, DHCPReservationSchemaRequest, DHCPReservationSchemaResponse, DHCPStateSchemaResponse, DHCPSubnetSchemaAddRequest, DHCPSubnetSchemaResponse, - DHCPValidatonError, ) from ldap_protocol.dhcp.dataclasses import ( DHCPLease, @@ -40,16 +31,6 @@ class DHCPAdapter(BaseAdapter[AbstractDHCPManager]): """Adapter for DHCP management using KeaDHCPManager.""" - _exceptions_map: dict[type[Exception], int] = { - DHCPEntryNotFoundError: status.HTTP_404_NOT_FOUND, - DHCPEntryDeleteError: status.HTTP_409_CONFLICT, - DHCPEntryAddError: status.HTTP_409_CONFLICT, - DHCPEntryUpdateError: status.HTTP_409_CONFLICT, - DHCPAPIError: status.HTTP_400_BAD_REQUEST, - DHCPValidatonError: status.HTTP_422_UNPROCESSABLE_ENTITY, - DHCPOperationError: status.HTTP_400_BAD_REQUEST, - } - async def create_subnet( self, subnet_data: DHCPSubnetSchemaAddRequest, diff --git a/app/api/dhcp/router.py b/app/api/dhcp/router.py index 2790f12cc..053241809 100644 --- a/app/api/dhcp/router.py +++ b/app/api/dhcp/router.py @@ -7,10 +7,26 @@ from ipaddress import IPv4Address from dishka import FromDishka -from dishka.integrations.fastapi import DishkaRoute -from fastapi import APIRouter, Depends, status +from fastapi import Depends, status +from fastapi_error_map.routing import ErrorAwareRouter +from fastapi_error_map.rules import rule from api.auth.utils import verify_auth +from api.error_routing import ( + ERROR_MAP_TYPE, + DishkaErrorAwareRoute, + DomainErrorTranslator, +) +from enums import DomainCodes +from ldap_protocol.dhcp.exceptions import ( + DHCPAPIError, + DHCPEntryAddError, + DHCPEntryDeleteError, + DHCPEntryNotFoundError, + DHCPEntryUpdateError, + DHCPOperationError, + DHCPValidatonError, +) from ldap_protocol.dhcp.schemas import ( DHCPChangeStateSchemaRequest, DHCPLeaseSchemaRequest, @@ -25,15 +41,53 @@ from .adapter import DHCPAdapter -dhcp_router = APIRouter( +translator = DomainErrorTranslator(DomainCodes.DHCP) + + +error_map: ERROR_MAP_TYPE = { + DHCPEntryNotFoundError: rule( + status=status.HTTP_400_BAD_REQUEST, + translator=translator, + ), + DHCPEntryDeleteError: rule( + status=status.HTTP_400_BAD_REQUEST, + translator=translator, + ), + DHCPEntryAddError: rule( + status=status.HTTP_400_BAD_REQUEST, + translator=translator, + ), + DHCPEntryUpdateError: rule( + status=status.HTTP_400_BAD_REQUEST, + translator=translator, + ), + DHCPAPIError: rule( + status=status.HTTP_400_BAD_REQUEST, + translator=translator, + ), + DHCPValidatonError: rule( + status=status.HTTP_422_UNPROCESSABLE_ENTITY, + translator=translator, + ), + DHCPOperationError: rule( + status=status.HTTP_400_BAD_REQUEST, + translator=translator, + ), +} + +dhcp_router = ErrorAwareRouter( prefix="/dhcp", tags=["DHCP"], dependencies=[Depends(verify_auth)], - route_class=DishkaRoute, + route_class=DishkaErrorAwareRoute, ) -@dhcp_router.post("/service/change_state", status_code=status.HTTP_200_OK) +@dhcp_router.post( + "/service/change_state", + status_code=status.HTTP_200_OK, + error_map=error_map, +) async def setup_dhcp( state_data: DHCPChangeStateSchemaRequest, dhcp_adapter: FromDishka[DHCPAdapter], @@ -42,7 +96,7 @@ async def setup_dhcp( await dhcp_adapter.change_state(state_data) -@dhcp_router.get("/service/state") +@dhcp_router.get("/service/state", error_map=error_map) async def get_dhcp_state( dhcp_adapter: FromDishka[DHCPAdapter], ) -> DHCPStateSchemaResponse: @@ -50,7 +104,11 @@ async def get_dhcp_state( return await dhcp_adapter.get_state() -@dhcp_router.post("/subnet", status_code=status.HTTP_201_CREATED) +@dhcp_router.post( + "/subnet", + status_code=status.HTTP_201_CREATED, + error_map=error_map, +) async def create_dhcp_subnet( subnet_data: DHCPSubnetSchemaAddRequest, dhcp_adapter: FromDishka[DHCPAdapter], @@ -59,7 +117,7 @@ async def create_dhcp_subnet( await dhcp_adapter.create_subnet(subnet_data) -@dhcp_router.get("/subnets") +@dhcp_router.get("/subnets", error_map=error_map) async def get_dhcp_subnets( dhcp_adapter: FromDishka[DHCPAdapter], ) -> list[DHCPSubnetSchemaResponse]: @@ -67,7 +125,7 @@ async def get_dhcp_subnets( return await dhcp_adapter.get_subnets() -@dhcp_router.put("/subnet/{subnet_id}") +@dhcp_router.put("/subnet/{subnet_id}", error_map=error_map) async def update_dhcp_subnet( subnet_id: int, subnet_data: DHCPSubnetSchemaAddRequest, @@ -77,7 +135,7 @@ async def update_dhcp_subnet( await dhcp_adapter.update_subnet(subnet_id, subnet_data) -@dhcp_router.delete("/subnet/{subnet_id}") +@dhcp_router.delete("/subnet/{subnet_id}", error_map=error_map) async def delete_dhcp_subnet( subnet_id: int, dhcp_adapter: FromDishka[DHCPAdapter], @@ -86,7 +144,11 @@ async def delete_dhcp_subnet( await dhcp_adapter.delete_subnet(subnet_id) -@dhcp_router.post("/lease", status_code=status.HTTP_201_CREATED) +@dhcp_router.post( + "/lease", + status_code=status.HTTP_201_CREATED, + error_map=error_map, +) async def create_dhcp_lease( lease_data: DHCPLeaseSchemaRequest, dhcp_adapter: FromDishka[DHCPAdapter], @@ -95,7 +157,7 @@ async def create_dhcp_lease( await dhcp_adapter.create_lease(lease_data) -@dhcp_router.get("/lease/{subnet_id}") +@dhcp_router.get("/lease/{subnet_id}", error_map=error_map) async def get_dhcp_leases( subnet_id: int, dhcp_adapter: FromDishka[DHCPAdapter], @@ -104,7 +166,7 @@ async def get_dhcp_leases( return await dhcp_adapter.list_active_leases(subnet_id) -@dhcp_router.get("/lease/") +@dhcp_router.get("/lease/", error_map=error_map) async def find_dhcp_lease( dhcp_adapter: FromDishka[DHCPAdapter], mac_address: str | None = None, @@ -114,7 +176,7 @@ async def find_dhcp_lease( return await dhcp_adapter.find_lease(mac_address, hostname) -@dhcp_router.delete("/lease/{ip_address}") +@dhcp_router.delete("/lease/{ip_address}", error_map=error_map) async def delete_dhcp_lease( ip_address: IPv4Address, dhcp_adapter: FromDishka[DHCPAdapter], @@ -123,7 +185,7 @@ async def delete_dhcp_lease( await dhcp_adapter.release_lease(ip_address) -@dhcp_router.patch("/lease/to_reservation") +@dhcp_router.patch("/lease/to_reservation", error_map=error_map) async def lease_to_reservation( data: list[DHCPReservationSchemaRequest], dhcp_adapter: FromDishka[DHCPAdapter], @@ -132,7 +194,11 @@ async def lease_to_reservation( return await dhcp_adapter.lease_to_reservation(data) -@dhcp_router.post("/reservation", status_code=status.HTTP_201_CREATED) +@dhcp_router.post( + "/reservation", + status_code=status.HTTP_201_CREATED, + error_map=error_map, +) async def create_dhcp_reservation( reservation_data: DHCPReservationSchemaRequest, dhcp_adapter: FromDishka[DHCPAdapter], @@ -141,7 +207,7 @@ async def create_dhcp_reservation( await dhcp_adapter.add_reservation(reservation_data) -@dhcp_router.get("/reservation/{subnet_id}") +@dhcp_router.get("/reservation/{subnet_id}", error_map=error_map) async def get_dhcp_reservation( subnet_id: int, dhcp_adapter: FromDishka[DHCPAdapter], @@ -150,7 +216,7 @@ async def get_dhcp_reservation( return await dhcp_adapter.get_reservations(subnet_id) -@dhcp_router.put("/reservation") +@dhcp_router.put("/reservation", error_map=error_map) async def update_dhcp_reservation( data: DHCPReservationSchemaRequest, dhcp_adapter: FromDishka[DHCPAdapter], @@ -159,7 +225,7 @@ async def update_dhcp_reservation( await dhcp_adapter.update_reservation(data) -@dhcp_router.delete("/reservation") +@dhcp_router.delete("/reservation", error_map=error_map) async def delete_dhcp_reservation( mac_address: str, ip_address: IPv4Address, diff --git a/app/api/error_routing.py b/app/api/error_routing.py new file mode 100644 index 000000000..805132fb7 --- /dev/null +++ b/app/api/error_routing.py @@ -0,0 +1,56 @@ +"""Error routing. + +Copyright (c) 2025 MultiFactor +License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE +""" + +from dataclasses import dataclass +from enum import IntEnum + +from dishka.integrations.fastapi import DishkaRoute +from fastapi_error_map.routing import ErrorAwareRoute +from fastapi_error_map.rules import Rule +from fastapi_error_map.translators import ErrorTranslator + +from enums import DomainCodes +from errors import BaseDomainException + +ERROR_MAP_TYPE = dict[type[Exception], int | Rule] | None + + +@dataclass +class ErrorResponse: + """Error response.""" + + detail: str + domain_code: DomainCodes + error_code: IntEnum + + +class DishkaErrorAwareRoute(ErrorAwareRoute, DishkaRoute): + """Route class that combines ErrorAwareRoute and DishkaRoute.""" + + +class DomainErrorTranslator(ErrorTranslator[ErrorResponse]): + """DNS error translator.""" + + domain_code: DomainCodes + + def __init__(self, domain_code: DomainCodes) -> None: + """Initialize error translator.""" + self.domain_code = domain_code + + @property + def error_response_model_cls(self) -> type[ErrorResponse]: + return ErrorResponse + + def from_error(self, err: Exception) -> ErrorResponse: + """Translate exception to error response.""" + if not isinstance(err, BaseDomainException): + raise TypeError(f"Expected BaseDomainException, got {type(err)}") + + return ErrorResponse( + detail=str(err), + domain_code=self.domain_code, + error_code=err.code, + ) diff --git a/app/api/exception_handlers.py b/app/api/exception_handlers.py index 510c68c8f..eabda8d4b 100644 --- a/app/api/exception_handlers.py +++ b/app/api/exception_handlers.py @@ -24,30 +24,11 @@ def handle_db_connect_error( raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE) -async def handle_dns_error( +async def handle_auth_error( request: Request, # noqa: ARG001 exc: Exception, ) -> NoReturn: - """Handle EmptyLabel exception.""" - logger.critical("DNS manager error: {}", exc) - raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE) - - -async def handle_dns_api_error( - request: Request, # noqa: ARG001 - exc: Exception, -) -> NoReturn: - """Handle DNS API error.""" - logger.critical("DNS API error: {}", exc) - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=str(exc)) - - -async def handle_not_implemented_error( - request: Request, # noqa: ARG001 - exc: Exception, # noqa: ARG001 -) -> NoReturn: - """Handle Not Implemented error.""" - raise HTTPException( - status_code=status.HTTP_501_NOT_IMPLEMENTED, - detail="This feature is supported with selfhosted DNS server.", - ) + """Handle Auth error.""" + # fastapi-error-map doesn't handle exceptions from dependencie, + # (get_ldap_session) потому ловим так + raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail=str(exc)) diff --git a/app/api/ldap_schema/__init__.py b/app/api/ldap_schema/__init__.py index 6deb0c2fc..a0314c320 100644 --- a/app/api/ldap_schema/__init__.py +++ b/app/api/ldap_schema/__init__.py @@ -7,10 +7,28 @@ from typing import Annotated from annotated_types import Len -from dishka.integrations.fastapi import DishkaRoute -from fastapi import APIRouter, Body, Depends +from fastapi import Body, Depends, status +from fastapi_error_map.routing import ErrorAwareRouter +from fastapi_error_map.rules import rule from api.auth.utils import verify_auth +from api.error_routing import ( + ERROR_MAP_TYPE, + DishkaErrorAwareRoute, + DomainErrorTranslator, +) +from enums import DomainCodes +from ldap_protocol.ldap_schema.exceptions import ( + AttributeTypeAlreadyExistsError, + AttributeTypeCantModifyError, + AttributeTypeNotFoundError, + EntityTypeAlreadyExistsError, + EntityTypeCantModifyError, + EntityTypeNotFoundError, + ObjectClassAlreadyExistsError, + ObjectClassCantModifyError, + ObjectClassNotFoundError, +) LimitedListType = Annotated[ list[str], @@ -18,9 +36,51 @@ Body(embed=True), ] -ldap_schema_router = APIRouter( +translator = DomainErrorTranslator(DomainCodes.LDAP_SCHEMA) + + +error_map: ERROR_MAP_TYPE = { + AttributeTypeAlreadyExistsError: rule( + status=status.HTTP_400_BAD_REQUEST, + translator=translator, + ), + AttributeTypeNotFoundError: rule( + status=status.HTTP_400_BAD_REQUEST, + translator=translator, + ), + AttributeTypeCantModifyError: rule( + status=status.HTTP_400_BAD_REQUEST, + translator=translator, + ), + ObjectClassAlreadyExistsError: rule( + status=status.HTTP_400_BAD_REQUEST, + translator=translator, + ), + ObjectClassNotFoundError: rule( + status=status.HTTP_400_BAD_REQUEST, + translator=translator, + ), + ObjectClassCantModifyError: rule( + status=status.HTTP_400_BAD_REQUEST, + translator=translator, + ), + EntityTypeAlreadyExistsError: rule( + status=status.HTTP_400_BAD_REQUEST, + translator=translator, + ), + EntityTypeNotFoundError: rule( + status=status.HTTP_400_BAD_REQUEST, + translator=translator, + ), + EntityTypeCantModifyError: rule( + status=status.HTTP_400_BAD_REQUEST, + translator=translator, + ), +} + +ldap_schema_router = ErrorAwareRouter( prefix="/schema", tags=["Schema"], dependencies=[Depends(verify_auth)], - route_class=DishkaRoute, + route_class=DishkaErrorAwareRoute, ) diff --git a/app/api/ldap_schema/adapters/attribute_type.py b/app/api/ldap_schema/adapters/attribute_type.py index d803ce8ff..ad1ea6516 100644 --- a/app/api/ldap_schema/adapters/attribute_type.py +++ b/app/api/ldap_schema/adapters/attribute_type.py @@ -12,7 +12,6 @@ get_converter, link_function, ) -from fastapi import status from api.base_adapter import BaseAdapter from api.ldap_schema.adapters.base_ldap_schema_adapter import ( @@ -32,11 +31,6 @@ DEFAULT_ATTRIBUTE_TYPE_SYNTAX, ) from ldap_protocol.ldap_schema.dto import AttributeTypeDTO -from ldap_protocol.ldap_schema.exceptions import ( - AttributeTypeAlreadyExistsError, - AttributeTypeCantModifyError, - AttributeTypeNotFoundError, -) def _convert_update_uschema_to_dto( @@ -97,9 +91,3 @@ class AttributeTypeFastAPIAdapter( _converter_to_dto = staticmethod(_convert_schema_to_dto) _converter_to_schema = staticmethod(_convert_dto_to_schema) _converter_update_sch_to_dto = staticmethod(_convert_update_uschema_to_dto) - - _exceptions_map: dict[type[Exception], int] = { - AttributeTypeAlreadyExistsError: status.HTTP_409_CONFLICT, - AttributeTypeNotFoundError: status.HTTP_404_NOT_FOUND, - AttributeTypeCantModifyError: status.HTTP_403_FORBIDDEN, - } diff --git a/app/api/ldap_schema/adapters/entity_type.py b/app/api/ldap_schema/adapters/entity_type.py index 4d56b37ed..03199b634 100644 --- a/app/api/ldap_schema/adapters/entity_type.py +++ b/app/api/ldap_schema/adapters/entity_type.py @@ -5,7 +5,6 @@ """ from adaptix.conversion import get_converter -from fastapi import status from api.base_adapter import BaseAdapter from api.ldap_schema.adapters.base_ldap_schema_adapter import ( @@ -19,11 +18,6 @@ from ldap_protocol.ldap_schema.constants import DEFAULT_ENTITY_TYPE_IS_SYSTEM from ldap_protocol.ldap_schema.dto import EntityTypeDTO from ldap_protocol.ldap_schema.entity_type_use_case import EntityTypeUseCase -from ldap_protocol.ldap_schema.exceptions import ( - EntityTypeCantModifyError, - EntityTypeNotFoundError, - ObjectClassNotFoundError, -) def _convert_update_chema_to_dto( @@ -66,12 +60,6 @@ class LDAPEntityTypeFastAPIAdapter( _converter_to_schema = staticmethod(_convert_dto_to_schema) _converter_update_sch_to_dto = staticmethod(_convert_update_chema_to_dto) - _exceptions_map: dict[type[Exception], int] = { - EntityTypeNotFoundError: status.HTTP_404_NOT_FOUND, - EntityTypeCantModifyError: status.HTTP_403_FORBIDDEN, - ObjectClassNotFoundError: status.HTTP_404_NOT_FOUND, - } - async def get_entity_type_attributes(self, name: str) -> list[str]: """Get all attribute names for an Entity Type. diff --git a/app/api/ldap_schema/adapters/object_class.py b/app/api/ldap_schema/adapters/object_class.py index e2f77a5e5..7c0199a88 100644 --- a/app/api/ldap_schema/adapters/object_class.py +++ b/app/api/ldap_schema/adapters/object_class.py @@ -6,7 +6,6 @@ from adaptix import P from adaptix.conversion import get_converter, link_function -from fastapi import status from api.base_adapter import BaseAdapter from api.ldap_schema.adapters.base_ldap_schema_adapter import ( @@ -20,11 +19,6 @@ from enums import KindType from ldap_protocol.ldap_schema.constants import DEFAULT_OBJECT_CLASS_IS_SYSTEM from ldap_protocol.ldap_schema.dto import AttributeTypeDTO, ObjectClassDTO -from ldap_protocol.ldap_schema.exceptions import ( - ObjectClassAlreadyExistsError, - ObjectClassCantModifyError, - ObjectClassNotFoundError, -) from ldap_protocol.ldap_schema.object_class_use_case import ObjectClassUseCase @@ -96,9 +90,3 @@ class ObjectClassFastAPIAdapter( _converter_to_dto = staticmethod(_convert_schema_to_dto) _converter_to_schema = staticmethod(_convert_dto_to_schema) _converter_update_sch_to_dto = staticmethod(_convert_update_schema_to_dto) - - _exceptions_map: dict[type[Exception], int] = { - ObjectClassAlreadyExistsError: status.HTTP_409_CONFLICT, - ObjectClassNotFoundError: status.HTTP_404_NOT_FOUND, - ObjectClassCantModifyError: status.HTTP_403_FORBIDDEN, - } diff --git a/app/api/ldap_schema/attribute_type_router.py b/app/api/ldap_schema/attribute_type_router.py index 0029218f1..5a2f1f368 100644 --- a/app/api/ldap_schema/attribute_type_router.py +++ b/app/api/ldap_schema/attribute_type_router.py @@ -9,7 +9,7 @@ from dishka.integrations.fastapi import FromDishka from fastapi import Query, status -from api.ldap_schema import LimitedListType, ldap_schema_router +from api.ldap_schema import LimitedListType, error_map, ldap_schema_router from api.ldap_schema.adapters.attribute_type import AttributeTypeFastAPIAdapter from api.ldap_schema.schema import ( AttributeTypePaginationSchema, @@ -22,6 +22,7 @@ @ldap_schema_router.post( "/attribute_type", status_code=status.HTTP_201_CREATED, + error_map=error_map, ) async def create_one_attribute_type( request_data: AttributeTypeSchema[None], @@ -31,7 +32,10 @@ async def create_one_attribute_type( await adapter.create(request_data) -@ldap_schema_router.get("/attribute_type/{attribute_type_name}") +@ldap_schema_router.get( + "/attribute_type/{attribute_type_name}", + error_map=error_map, +) async def get_one_attribute_type( attribute_type_name: str, adapter: FromDishka[AttributeTypeFastAPIAdapter], @@ -40,7 +44,10 @@ async def get_one_attribute_type( return await adapter.get(attribute_type_name) -@ldap_schema_router.get("/attribute_types") +@ldap_schema_router.get( + "/attribute_types", + error_map=error_map, +) async def get_list_attribute_types_with_pagination( adapter: FromDishka[AttributeTypeFastAPIAdapter], params: Annotated[PaginationParams, Query()], @@ -49,7 +56,10 @@ async def get_list_attribute_types_with_pagination( return await adapter.get_list_paginated(params) -@ldap_schema_router.patch("/attribute_type/{attribute_type_name}") +@ldap_schema_router.patch( + "/attribute_type/{attribute_type_name}", + error_map=error_map, +) async def modify_one_attribute_type( attribute_type_name: str, request_data: AttributeTypeUpdateSchema, @@ -59,7 +69,10 @@ async def modify_one_attribute_type( await adapter.update(name=attribute_type_name, data=request_data) -@ldap_schema_router.post("/attribute_types/delete") +@ldap_schema_router.post( + "/attribute_types/delete", + error_map=error_map, +) async def delete_bulk_attribute_types( attribute_types_names: LimitedListType, adapter: FromDishka[AttributeTypeFastAPIAdapter], diff --git a/app/api/ldap_schema/entity_type_router.py b/app/api/ldap_schema/entity_type_router.py index db6b3607b..31de91616 100644 --- a/app/api/ldap_schema/entity_type_router.py +++ b/app/api/ldap_schema/entity_type_router.py @@ -9,7 +9,7 @@ from dishka.integrations.fastapi import FromDishka from fastapi import Query, status -from api.ldap_schema import LimitedListType +from api.ldap_schema import LimitedListType, error_map from api.ldap_schema.adapters.entity_type import LDAPEntityTypeFastAPIAdapter from api.ldap_schema.object_class_router import ldap_schema_router from api.ldap_schema.schema import ( @@ -20,7 +20,11 @@ from ldap_protocol.utils.pagination import PaginationParams -@ldap_schema_router.post("/entity_type", status_code=status.HTTP_201_CREATED) +@ldap_schema_router.post( + "/entity_type", + status_code=status.HTTP_201_CREATED, + error_map=error_map, +) async def create_one_entity_type( request_data: EntityTypeSchema[None], adapter: FromDishka[LDAPEntityTypeFastAPIAdapter], @@ -29,7 +33,7 @@ async def create_one_entity_type( await adapter.create(request_data) -@ldap_schema_router.get("/entity_type/{entity_type_name}") +@ldap_schema_router.get("/entity_type/{entity_type_name}", error_map=error_map) async def get_one_entity_type( entity_type_name: str, adapter: FromDishka[LDAPEntityTypeFastAPIAdapter], @@ -38,7 +42,7 @@ async def get_one_entity_type( return await adapter.get(entity_type_name) -@ldap_schema_router.get("/entity_types") +@ldap_schema_router.get("/entity_types", error_map=error_map) async def get_list_entity_types_with_pagination( adapter: FromDishka[LDAPEntityTypeFastAPIAdapter], params: Annotated[PaginationParams, Query()], @@ -47,7 +51,10 @@ async def get_list_entity_types_with_pagination( return await adapter.get_list_paginated(params=params) -@ldap_schema_router.get("/entity_type/{entity_type_name}/attrs") +@ldap_schema_router.get( + "/entity_type/{entity_type_name}/attrs", + error_map=error_map, +) async def get_entity_type_attributes( entity_type_name: str, adapter: FromDishka[LDAPEntityTypeFastAPIAdapter], @@ -56,7 +63,10 @@ async def get_entity_type_attributes( return await adapter.get_entity_type_attributes(entity_type_name) -@ldap_schema_router.patch("/entity_type/{entity_type_name}") +@ldap_schema_router.patch( + "/entity_type/{entity_type_name}", + error_map=error_map, +) async def modify_one_entity_type( entity_type_name: str, request_data: EntityTypeUpdateSchema, @@ -66,7 +76,7 @@ async def modify_one_entity_type( await adapter.update(name=entity_type_name, data=request_data) -@ldap_schema_router.post("/entity_type/delete") +@ldap_schema_router.post("/entity_type/delete", error_map=error_map) async def delete_bulk_entity_types( entity_type_names: LimitedListType, adapter: FromDishka[LDAPEntityTypeFastAPIAdapter], diff --git a/app/api/ldap_schema/object_class_router.py b/app/api/ldap_schema/object_class_router.py index a658161bf..a351f3b33 100644 --- a/app/api/ldap_schema/object_class_router.py +++ b/app/api/ldap_schema/object_class_router.py @@ -9,7 +9,7 @@ from dishka.integrations.fastapi import FromDishka from fastapi import Query, status -from api.ldap_schema import LimitedListType +from api.ldap_schema import LimitedListType, error_map from api.ldap_schema.adapters.object_class import ObjectClassFastAPIAdapter from api.ldap_schema.attribute_type_router import ldap_schema_router from api.ldap_schema.schema import ( @@ -20,7 +20,11 @@ from ldap_protocol.utils.pagination import PaginationParams -@ldap_schema_router.post("/object_class", status_code=status.HTTP_201_CREATED) +@ldap_schema_router.post( + "/object_class", + status_code=status.HTTP_201_CREATED, + error_map=error_map, +) async def create_one_object_class( request_data: ObjectClassSchema[None], adapter: FromDishka[ObjectClassFastAPIAdapter], @@ -29,7 +33,10 @@ async def create_one_object_class( await adapter.create(request_data) -@ldap_schema_router.get("/object_class/{object_class_name}") +@ldap_schema_router.get( + "/object_class/{object_class_name}", + error_map=error_map, +) async def get_one_object_class( object_class_name: str, adapter: FromDishka[ObjectClassFastAPIAdapter], @@ -38,7 +45,7 @@ async def get_one_object_class( return await adapter.get(object_class_name) -@ldap_schema_router.get("/object_classes") +@ldap_schema_router.get("/object_classes", error_map=error_map) async def get_list_object_classes_with_pagination( adapter: FromDishka[ObjectClassFastAPIAdapter], params: Annotated[PaginationParams, Query()], @@ -47,7 +54,10 @@ async def get_list_object_classes_with_pagination( return await adapter.get_list_paginated(params=params) -@ldap_schema_router.patch("/object_class/{object_class_name}") +@ldap_schema_router.patch( + "/object_class/{object_class_name}", + error_map=error_map, +) async def modify_one_object_class( object_class_name: str, request_data: ObjectClassUpdateSchema, @@ -57,7 +67,7 @@ async def modify_one_object_class( await adapter.update(object_class_name, request_data) -@ldap_schema_router.post("/object_class/delete") +@ldap_schema_router.post("/object_class/delete", error_map=error_map) async def delete_bulk_object_classes( object_classes_names: LimitedListType, adapter: FromDishka[ObjectClassFastAPIAdapter], diff --git a/app/api/main/adapters/dns.py b/app/api/main/adapters/dns.py index 9044c009c..352099fad 100644 --- a/app/api/main/adapters/dns.py +++ b/app/api/main/adapters/dns.py @@ -4,9 +4,6 @@ License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE """ -from fastapi import status - -import ldap_protocol.dns.exceptions as dns_exc from api.base_adapter import BaseAdapter from api.main.schema import ( DNSServiceForwardZoneCheckRequest, @@ -32,17 +29,6 @@ class DNSFastAPIAdapter(BaseAdapter[DNSUseCase]): """DNS adapter.""" - _exceptions_map = { - dns_exc.DNSSetupError: status.HTTP_424_FAILED_DEPENDENCY, - dns_exc.DNSRecordCreateError: status.HTTP_400_BAD_REQUEST, - dns_exc.DNSRecordUpdateError: status.HTTP_400_BAD_REQUEST, - dns_exc.DNSRecordDeleteError: status.HTTP_400_BAD_REQUEST, - dns_exc.DNSZoneCreateError: status.HTTP_400_BAD_REQUEST, - dns_exc.DNSZoneUpdateError: status.HTTP_400_BAD_REQUEST, - dns_exc.DNSZoneDeleteError: status.HTTP_400_BAD_REQUEST, - dns_exc.DNSUpdateServerOptionsError: status.HTTP_400_BAD_REQUEST, - } - async def create_record( self, data: DNSServiceRecordCreateRequest, diff --git a/app/api/main/adapters/kerberos.py b/app/api/main/adapters/kerberos.py index 9fbe5bbd7..1bbe252e2 100644 --- a/app/api/main/adapters/kerberos.py +++ b/app/api/main/adapters/kerberos.py @@ -4,9 +4,9 @@ License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE """ -from typing import Any, AsyncGenerator, ParamSpec, TypeVar +from typing import Any, AsyncGenerator -from fastapi import Request, Response, status +from fastapi import Request, Response from fastapi.responses import StreamingResponse from pydantic import SecretStr from starlette.background import BackgroundTask @@ -15,31 +15,13 @@ from api.main.schema import KerberosSetupRequest from ldap_protocol.dialogue import LDAPSession, UserSchema from ldap_protocol.kerberos import KerberosState -from ldap_protocol.kerberos.exceptions import ( - KerberosBaseDnNotFoundError, - KerberosConflictError, - KerberosDependencyError, - KerberosNotFoundError, - KerberosUnavailableError, -) from ldap_protocol.kerberos.service import KerberosService from ldap_protocol.ldap_requests.contexts import LDAPAddRequestContext -P = ParamSpec("P") -R = TypeVar("R") - class KerberosFastAPIAdapter(BaseAdapter[KerberosService]): """Adapter for using KerberosService with FastAPI and background tasks.""" - _exceptions_map: dict[type[Exception], int] = { - KerberosBaseDnNotFoundError: status.HTTP_503_SERVICE_UNAVAILABLE, - KerberosConflictError: status.HTTP_409_CONFLICT, - KerberosDependencyError: status.HTTP_424_FAILED_DEPENDENCY, - KerberosNotFoundError: status.HTTP_404_NOT_FOUND, - KerberosUnavailableError: status.HTTP_503_SERVICE_UNAVAILABLE, - } - async def setup_krb_catalogue( self, mail: str, diff --git a/app/api/main/dns_router.py b/app/api/main/dns_router.py index bac6a4915..d93382512 100644 --- a/app/api/main/dns_router.py +++ b/app/api/main/dns_router.py @@ -5,11 +5,18 @@ """ from dishka import FromDishka -from dishka.integrations.fastapi import DishkaRoute -from fastapi import Depends -from fastapi.routing import APIRouter +from dns.exception import DNSException +from fastapi import Depends, status +from fastapi_error_map import rule +from fastapi_error_map.routing import ErrorAwareRouter +import ldap_protocol.dns.exceptions as dns_exc from api.auth.utils import verify_auth +from api.error_routing import ( + ERROR_MAP_TYPE, + DishkaErrorAwareRoute, + DomainErrorTranslator, +) from api.main.adapters.dns import DNSFastAPIAdapter from api.main.schema import ( DNSServiceForwardZoneCheckRequest, @@ -22,6 +29,7 @@ DNSServiceZoneDeleteRequest, DNSServiceZoneUpdateRequest, ) +from enums import DomainCodes from ldap_protocol.dns import ( DNSForwardServerStatus, DNSForwardZone, @@ -30,15 +38,65 @@ DNSZone, ) -dns_router = APIRouter( +translator = DomainErrorTranslator(DomainCodes.DNS) + + +error_map: ERROR_MAP_TYPE = { + dns_exc.DNSSetupError: rule( + status=status.HTTP_422_UNPROCESSABLE_ENTITY, + translator=translator, + ), + dns_exc.DNSRecordCreateError: rule( + status=status.HTTP_400_BAD_REQUEST, + translator=translator, + ), + dns_exc.DNSRecordUpdateError: rule( + status=status.HTTP_400_BAD_REQUEST, + translator=translator, + ), + dns_exc.DNSRecordDeleteError: rule( + status=status.HTTP_400_BAD_REQUEST, + translator=translator, + ), + dns_exc.DNSZoneCreateError: rule( + status=status.HTTP_400_BAD_REQUEST, + translator=translator, + ), + dns_exc.DNSZoneUpdateError: rule( + status=status.HTTP_400_BAD_REQUEST, + translator=translator, + ), + dns_exc.DNSZoneDeleteError: rule( + status=status.HTTP_400_BAD_REQUEST, + translator=translator, + ), + dns_exc.DNSUpdateServerOptionsError: rule( + status=status.HTTP_400_BAD_REQUEST, + translator=translator, + ), + DNSException: rule( + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + translator=translator, + ), + dns_exc.DNSConnectionError: rule( + status=status.HTTP_400_BAD_REQUEST, + translator=translator, + ), + dns_exc.DNSNotImplementedError: rule( + status=status.HTTP_400_BAD_REQUEST, + translator=translator, + ), +} + +dns_router = ErrorAwareRouter( prefix="/dns", tags=["DNS_SERVICE"], dependencies=[Depends(verify_auth)], - route_class=DishkaRoute, + route_class=DishkaErrorAwareRoute, ) -@dns_router.post("/record") +@dns_router.post("/record", error_map=error_map) async def create_record( data: DNSServiceRecordCreateRequest, adapter: FromDishka[DNSFastAPIAdapter], @@ -47,7 +105,7 @@ async def create_record( await adapter.create_record(data) -@dns_router.delete("/record") +@dns_router.delete("/record", error_map=error_map) async def delete_single_record( data: DNSServiceRecordDeleteRequest, adapter: FromDishka[DNSFastAPIAdapter], @@ -56,7 +114,7 @@ async def delete_single_record( await adapter.delete_record(data) -@dns_router.patch("/record") +@dns_router.patch("/record", error_map=error_map) async def update_record( data: DNSServiceRecordUpdateRequest, adapter: FromDishka[DNSFastAPIAdapter], @@ -65,7 +123,7 @@ async def update_record( await adapter.update_record(data) -@dns_router.get("/record") +@dns_router.get("/record", error_map=error_map) async def get_all_records( adapter: FromDishka[DNSFastAPIAdapter], ) -> list[DNSRecords]: @@ -73,7 +131,7 @@ async def get_all_records( return await adapter.get_all_records() -@dns_router.get("/status") +@dns_router.get("/status", error_map=error_map) async def get_dns_status( adapter: FromDishka[DNSFastAPIAdapter], ) -> dict[str, str | None]: @@ -81,7 +139,7 @@ async def get_dns_status( return await adapter.get_dns_status() -@dns_router.post("/setup") +@dns_router.post("/setup", error_map=error_map) async def setup_dns( data: DNSServiceSetupRequest, adapter: FromDishka[DNSFastAPIAdapter], @@ -90,7 +148,7 @@ async def setup_dns( await adapter.setup_dns(data) -@dns_router.get("/zone") +@dns_router.get("/zone", error_map=error_map) async def get_dns_zone( adapter: FromDishka[DNSFastAPIAdapter], ) -> list[DNSZone]: @@ -98,7 +156,7 @@ async def get_dns_zone( return await adapter.get_dns_zone() -@dns_router.get("/zone/forward") +@dns_router.get("/zone/forward", error_map=error_map) async def get_forward_dns_zones( adapter: FromDishka[DNSFastAPIAdapter], ) -> list[DNSForwardZone]: @@ -106,7 +164,12 @@ async def get_forward_dns_zones( return await adapter.get_forward_dns_zones() -@dns_router.post("/zone") +@dns_router.post( + "/zone", + error_map=error_map, + warn_on_unmapped=False, + default_client_error_translator=translator, +) async def create_zone( data: DNSServiceZoneCreateRequest, adapter: FromDishka[DNSFastAPIAdapter], @@ -115,7 +178,7 @@ async def create_zone( await adapter.create_zone(data) -@dns_router.patch("/zone") +@dns_router.patch("/zone", error_map=error_map) async def update_zone( data: DNSServiceZoneUpdateRequest, adapter: FromDishka[DNSFastAPIAdapter], @@ -124,7 +187,7 @@ async def update_zone( await adapter.update_zone(data) -@dns_router.delete("/zone") +@dns_router.delete("/zone", error_map=error_map) async def delete_zone( data: DNSServiceZoneDeleteRequest, adapter: FromDishka[DNSFastAPIAdapter], @@ -133,7 +196,7 @@ async def delete_zone( await adapter.delete_zone(data) -@dns_router.post("/forward_check") +@dns_router.post("/forward_check", error_map=error_map) async def check_dns_forward_zone( data: DNSServiceForwardZoneCheckRequest, adapter: FromDishka[DNSFastAPIAdapter], @@ -142,7 +205,7 @@ async def check_dns_forward_zone( return await adapter.check_dns_forward_zone(data) -@dns_router.get("/zone/reload/") +@dns_router.get("/zone/reload/", error_map=error_map) async def reload_zone( data: DNSServiceReloadZoneRequest, adapter: FromDishka[DNSFastAPIAdapter], diff --git a/app/api/main/krb5_router.py b/app/api/main/krb5_router.py index cdec9a002..31826464f 100644 --- a/app/api/main/krb5_router.py +++ b/app/api/main/krb5_router.py @@ -8,28 +8,67 @@ from annotated_types import Len from dishka import FromDishka -from dishka.integrations.fastapi import DishkaRoute -from fastapi import Body, Request, Response +from fastapi import Body, Request, Response, status from fastapi.params import Depends from fastapi.responses import StreamingResponse -from fastapi.routing import APIRouter +from fastapi_error_map.routing import ErrorAwareRouter +from fastapi_error_map.rules import rule from pydantic import SecretStr from api.auth.adapters.auth import AuthFastAPIAdapter from api.auth.utils import verify_auth +from api.error_routing import ( + ERROR_MAP_TYPE, + DishkaErrorAwareRoute, + DomainErrorTranslator, +) from api.main.adapters.kerberos import KerberosFastAPIAdapter from api.main.schema import KerberosSetupRequest +from enums import DomainCodes from ldap_protocol.dialogue import LDAPSession from ldap_protocol.kerberos import KerberosState +from ldap_protocol.kerberos.exceptions import ( + KerberosBaseDnNotFoundError, + KerberosConflictError, + KerberosDependencyError, + KerberosNotFoundError, + KerberosUnavailableError, +) from ldap_protocol.ldap_requests.contexts import LDAPAddRequestContext from ldap_protocol.utils.const import EmailStr from .utils import get_ldap_session -krb5_router = APIRouter( +translator = DomainErrorTranslator(DomainCodes.KERBEROS) + + +error_map: ERROR_MAP_TYPE = { + KerberosBaseDnNotFoundError: rule( + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + translator=translator, + ), + KerberosConflictError: rule( + status=status.HTTP_400_BAD_REQUEST, + translator=translator, + ), + KerberosDependencyError: rule( + status=status.HTTP_400_BAD_REQUEST, + translator=translator, + ), + KerberosNotFoundError: rule( + status=status.HTTP_400_BAD_REQUEST, + translator=translator, + ), + KerberosUnavailableError: rule( + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + translator=translator, + ), +} + +krb5_router = ErrorAwareRouter( prefix="/kerberos", tags=["KRB5 API"], - route_class=DishkaRoute, + route_class=DishkaErrorAwareRoute, ) KERBEROS_POLICY_NAME = "Kerberos Access Policy" @@ -37,6 +76,7 @@ @krb5_router.post( "/setup/tree", response_class=Response, + error_map=error_map, dependencies=[Depends(verify_auth)], ) async def setup_krb_catalogue( @@ -61,7 +101,7 @@ async def setup_krb_catalogue( ) -@krb5_router.post("/setup", response_class=Response) +@krb5_router.post("/setup", response_class=Response, error_map=error_map) async def setup_kdc( data: KerberosSetupRequest, identity_adapter: FromDishka[AuthFastAPIAdapter], @@ -92,7 +132,11 @@ async def setup_kdc( ] -@krb5_router.post("/ktadd", dependencies=[Depends(verify_auth)]) +@krb5_router.post( + "/ktadd", + dependencies=[Depends(verify_auth)], + error_map=error_map, +) async def ktadd( names: Annotated[LIMITED_LIST, Body()], kerberos_adapter: FromDishka[KerberosFastAPIAdapter], @@ -105,7 +149,11 @@ async def ktadd( return await kerberos_adapter.ktadd(names) -@krb5_router.get("/status", dependencies=[Depends(verify_auth)]) +@krb5_router.get( + "/status", + dependencies=[Depends(verify_auth)], + error_map=error_map, +) async def get_krb_status( kerberos_adapter: FromDishka[KerberosFastAPIAdapter], ) -> KerberosState: @@ -118,7 +166,11 @@ async def get_krb_status( return await kerberos_adapter.get_status() -@krb5_router.post("/principal/add", dependencies=[Depends(verify_auth)]) +@krb5_router.post( + "/principal/add", + dependencies=[Depends(verify_auth)], + error_map=error_map, +) async def add_principal( primary: Annotated[LIMITED_STR, Body()], instance: Annotated[LIMITED_STR, Body()], @@ -137,6 +189,7 @@ async def add_principal( @krb5_router.patch( "/principal/rename", dependencies=[Depends(verify_auth)], + error_map=error_map, ) async def rename_principal( principal_name: Annotated[LIMITED_STR, Body()], @@ -160,6 +213,7 @@ async def rename_principal( @krb5_router.patch( "/principal/reset", dependencies=[Depends(verify_auth)], + error_map=error_map, ) async def reset_principal_pw( principal_name: Annotated[LIMITED_STR, Body()], @@ -180,6 +234,7 @@ async def reset_principal_pw( @krb5_router.delete( "/principal/delete", dependencies=[Depends(verify_auth)], + error_map=error_map, ) async def delete_principal( principal_name: Annotated[LIMITED_STR, Body(embed=True)], diff --git a/app/api/main/router.py b/app/api/main/router.py index 197e2cad2..f4df578e8 100644 --- a/app/api/main/router.py +++ b/app/api/main/router.py @@ -5,12 +5,19 @@ """ from dishka import FromDishka -from dishka.integrations.fastapi import DishkaRoute -from fastapi import Depends, HTTPException, Request -from fastapi.routing import APIRouter +from fastapi import Depends, HTTPException, Request, status +from fastapi_error_map.routing import ErrorAwareRouter +from fastapi_error_map.rules import rule from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession +from api.error_routing import ( + ERROR_MAP_TYPE, + DishkaErrorAwareRoute, + DomainErrorTranslator, +) +from enums import DomainCodes +from ldap_protocol.identity.exceptions import UnauthorizedError from ldap_protocol.ldap_requests import ( AddRequest, DeleteRequest, @@ -28,15 +35,25 @@ ) from .utils import get_ldap_session -entry_router = APIRouter( +translator = DomainErrorTranslator(DomainCodes.LDAP) + + +error_map: ERROR_MAP_TYPE = { + UnauthorizedError: rule( + status=status.HTTP_401_UNAUTHORIZED, + translator=translator, + ), +} + +entry_router = ErrorAwareRouter( prefix="/entry", tags=["LDAP API"], - route_class=DishkaRoute, + route_class=DishkaErrorAwareRoute, dependencies=[Depends(get_ldap_session)], ) -@entry_router.post("/search") +@entry_router.post("/search", error_map=error_map) async def search( request: SearchRequest, req: Request, @@ -55,7 +72,7 @@ async def search( ) -@entry_router.post("/add") +@entry_router.post("/add", error_map=error_map) async def add( request: AddRequest, req: Request, @@ -64,7 +81,7 @@ async def add( return await request.handle_api(req.state.dishka_container) -@entry_router.patch("/update") +@entry_router.patch("/update", error_map=error_map) async def modify( request: ModifyRequest, req: Request, @@ -73,7 +90,7 @@ async def modify( return await request.handle_api(req.state.dishka_container) -@entry_router.patch("/update_many") +@entry_router.patch("/update_many", error_map=error_map) async def modify_many( requests: list[ModifyRequest], req: Request, @@ -85,7 +102,7 @@ async def modify_many( return results -@entry_router.put("/update/dn") +@entry_router.put("/update/dn", error_map=error_map) async def modify_dn( request: ModifyDNRequest, req: Request, @@ -94,7 +111,7 @@ async def modify_dn( return await request.handle_api(req.state.dishka_container) -@entry_router.delete("/delete") +@entry_router.delete("/delete", error_map=error_map) async def delete( request: DeleteRequest, req: Request, @@ -103,7 +120,7 @@ async def delete( return await request.handle_api(req.state.dishka_container) -@entry_router.post("/delete_many") +@entry_router.post("/delete_many", error_map=error_map) async def delete_many( requests: list[DeleteRequest], req: Request, diff --git a/app/api/network/adapters/network.py b/app/api/network/adapters/network.py index 74313944d..c478545c0 100644 --- a/app/api/network/adapters/network.py +++ b/app/api/network/adapters/network.py @@ -23,11 +23,6 @@ NetworkPolicyDTO, NetworkPolicyUpdateDTO, ) -from ldap_protocol.policies.network.exceptions import ( - LastActivePolicyError, - NetworkPolicyAlreadyExistsError, - NetworkPolicyNotFoundError, -) from ldap_protocol.policies.network.use_cases import NetworkPolicyUseCase @@ -68,12 +63,6 @@ def _convert_raw(dto: NetworkPolicyDTO[int]) -> list[str | dict]: class NetworkPolicyFastAPIAdapter(BaseAdapter[NetworkPolicyUseCase]): """Network policy adapter.""" - _exceptions_map: dict[type[Exception], int] = { - NetworkPolicyAlreadyExistsError: status.HTTP_422_UNPROCESSABLE_ENTITY, - LastActivePolicyError: status.HTTP_422_UNPROCESSABLE_ENTITY, - NetworkPolicyNotFoundError: status.HTTP_404_NOT_FOUND, - } - async def create(self, policy: Policy) -> PolicyResponse: """Create network policy.""" policy_dto = await self._service.create( diff --git a/app/api/network/router.py b/app/api/network/router.py index 554148f59..bc65ed858 100644 --- a/app/api/network/router.py +++ b/app/api/network/router.py @@ -5,14 +5,25 @@ """ from dishka import FromDishka -from dishka.integrations.fastapi import DishkaRoute from fastapi import Request, status from fastapi.params import Depends from fastapi.responses import RedirectResponse -from fastapi.routing import APIRouter +from fastapi_error_map.routing import ErrorAwareRouter +from fastapi_error_map.rules import rule from api.auth.utils import verify_auth +from api.error_routing import ( + ERROR_MAP_TYPE, + DishkaErrorAwareRoute, + DomainErrorTranslator, +) from api.network.adapters.network import NetworkPolicyFastAPIAdapter +from enums import DomainCodes +from ldap_protocol.policies.network.exceptions import ( + LastActivePolicyError, + NetworkPolicyAlreadyExistsError, + NetworkPolicyNotFoundError, +) from .schema import ( Policy, @@ -22,15 +33,38 @@ SwapResponse, ) -network_router = APIRouter( +translator = DomainErrorTranslator(DomainCodes.NETWORK) + + +error_map: ERROR_MAP_TYPE = { + NetworkPolicyAlreadyExistsError: rule( + status=status.HTTP_422_UNPROCESSABLE_ENTITY, + translator=translator, + ), + NetworkPolicyNotFoundError: rule( + status=status.HTTP_400_BAD_REQUEST, + translator=translator, + ), + LastActivePolicyError: rule( + status=status.HTTP_422_UNPROCESSABLE_ENTITY, + translator=translator, + ), +} + + +network_router = ErrorAwareRouter( prefix="/policy", tags=["Network policy"], - route_class=DishkaRoute, + route_class=DishkaErrorAwareRoute, dependencies=[Depends(verify_auth)], ) -@network_router.post("", status_code=status.HTTP_201_CREATED) +@network_router.post( + "", + status_code=status.HTTP_201_CREATED, + error_map=error_map, +) async def add_network_policy( policy: Policy, adapter: FromDishka[NetworkPolicyFastAPIAdapter], @@ -46,7 +80,7 @@ async def add_network_policy( return await adapter.create(policy) -@network_router.get("", name="policy") +@network_router.get("", name="policy", error_map=error_map) async def get_list_network_policies( adapter: FromDishka[NetworkPolicyFastAPIAdapter], ) -> list[PolicyResponse]: @@ -62,6 +96,7 @@ async def get_list_network_policies( "/{policy_id}", response_class=RedirectResponse, status_code=status.HTTP_303_SEE_OTHER, + error_map=error_map, ) async def delete_network_policy( policy_id: int, @@ -79,7 +114,7 @@ async def delete_network_policy( return await adapter.delete(request, policy_id) # type: ignore -@network_router.patch("/{policy_id}") +@network_router.patch("/{policy_id}", error_map=error_map) async def switch_network_policy( policy_id: int, adapter: FromDishka[NetworkPolicyFastAPIAdapter], @@ -98,7 +133,7 @@ async def switch_network_policy( return await adapter.switch_network_policy(policy_id) -@network_router.put("") +@network_router.put("", error_map=error_map) async def update_network_policy( request: PolicyUpdate, adapter: FromDishka[NetworkPolicyFastAPIAdapter], @@ -115,7 +150,7 @@ async def update_network_policy( return await adapter.update(request) -@network_router.post("/swap") +@network_router.post("/swap", error_map=error_map) async def swap_network_policy( swap: SwapRequest, adapter: FromDishka[NetworkPolicyFastAPIAdapter], diff --git a/app/api/password_policy/adapter.py b/app/api/password_policy/adapter.py index 4fae20e5d..63ce9ce3d 100644 --- a/app/api/password_policy/adapter.py +++ b/app/api/password_policy/adapter.py @@ -7,22 +7,14 @@ import io from adaptix.conversion import get_converter -from fastapi import UploadFile, status +from fastapi import UploadFile from fastapi.responses import StreamingResponse from api.base_adapter import BaseAdapter from api.password_policy.schemas import PasswordPolicySchema, PriorityT from ldap_protocol.policies.password.dataclasses import PasswordPolicyDTO from ldap_protocol.policies.password.exceptions import ( - PasswordBanWordFileHasDuplicatesError, PasswordBanWordWrongFileExtensionError, - PasswordPolicyAgeDaysError, - PasswordPolicyAlreadyExistsError, - PasswordPolicyBaseDnNotFoundError, - PasswordPolicyCantChangeDefaultDomainError, - PasswordPolicyDirIsNotUserError, - PasswordPolicyNotFoundError, - PasswordPolicyPriorityError, ) from ldap_protocol.policies.password.use_cases import ( PasswordBanWordUseCases, @@ -39,16 +31,6 @@ class PasswordPolicyFastAPIAdapter(BaseAdapter[PasswordPolicyUseCases]): """Adapter for password policies.""" - _exceptions_map: dict[type[Exception], int] = { - PasswordPolicyBaseDnNotFoundError: status.HTTP_404_NOT_FOUND, - PasswordPolicyNotFoundError: status.HTTP_404_NOT_FOUND, - PasswordPolicyDirIsNotUserError: status.HTTP_404_NOT_FOUND, - PasswordPolicyAlreadyExistsError: status.HTTP_409_CONFLICT, - PasswordPolicyCantChangeDefaultDomainError: status.HTTP_400_BAD_REQUEST, # noqa: E501 - PasswordPolicyPriorityError: status.HTTP_400_BAD_REQUEST, - PasswordPolicyAgeDaysError: status.HTTP_400_BAD_REQUEST, - } - async def get_all(self) -> list[PasswordPolicySchema[int]]: """Get all Password Policies.""" dtos = await self._service.get_all() @@ -86,11 +68,6 @@ async def reset_domain_policy_to_default_config(self) -> None: class PasswordBanWordsFastAPIAdapter(BaseAdapter[PasswordBanWordUseCases]): """Adapter for password ban words.""" - _exceptions_map: dict[type[Exception], int] = { - PasswordBanWordWrongFileExtensionError: status.HTTP_400_BAD_REQUEST, - PasswordBanWordFileHasDuplicatesError: status.HTTP_409_CONFLICT, - } - async def upload_ban_words_txt(self, file: UploadFile) -> None: if ( file diff --git a/app/api/password_policy/error_utils.py b/app/api/password_policy/error_utils.py new file mode 100644 index 000000000..201f2b823 --- /dev/null +++ b/app/api/password_policy/error_utils.py @@ -0,0 +1,64 @@ +"""Password policy error utils. + +Copyright (c) 2025 MultiFactor +License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE +""" + +from fastapi import status +from fastapi_error_map.rules import rule + +from api.error_routing import ERROR_MAP_TYPE, DomainErrorTranslator +from enums import DomainCodes +from ldap_protocol.permissions_checker import AuthorizationError +from ldap_protocol.policies.password.exceptions import ( + PasswordBanWordWrongFileExtensionError, + PasswordPolicyAgeDaysError, + PasswordPolicyAlreadyExistsError, + PasswordPolicyBaseDnNotFoundError, + PasswordPolicyCantChangeDefaultDomainError, + PasswordPolicyDirIsNotUserError, + PasswordPolicyNotFoundError, + PasswordPolicyPriorityError, +) + +translator = DomainErrorTranslator(DomainCodes.PASSWORD_POLICY) + + +error_map: ERROR_MAP_TYPE = { + PasswordPolicyBaseDnNotFoundError: rule( + status=status.HTTP_400_BAD_REQUEST, + translator=translator, + ), + PasswordPolicyNotFoundError: rule( + status=status.HTTP_400_BAD_REQUEST, + translator=translator, + ), + PasswordPolicyDirIsNotUserError: rule( + status=status.HTTP_400_BAD_REQUEST, + translator=translator, + ), + PasswordPolicyAlreadyExistsError: rule( + status=status.HTTP_400_BAD_REQUEST, + translator=translator, + ), + PasswordPolicyCantChangeDefaultDomainError: rule( + status=status.HTTP_400_BAD_REQUEST, + translator=translator, + ), + PasswordPolicyPriorityError: rule( + status=status.HTTP_400_BAD_REQUEST, + translator=translator, + ), + PasswordPolicyAgeDaysError: rule( + status=status.HTTP_400_BAD_REQUEST, + translator=translator, + ), + PasswordBanWordWrongFileExtensionError: rule( + status=status.HTTP_400_BAD_REQUEST, + translator=translator, + ), + AuthorizationError: rule( + status=status.HTTP_401_UNAUTHORIZED, + translator=translator, + ), +} diff --git a/app/api/password_policy/password_ban_word_router.py b/app/api/password_policy/password_ban_word_router.py index a774fd6eb..a0c06a04e 100644 --- a/app/api/password_policy/password_ban_word_router.py +++ b/app/api/password_policy/password_ban_word_router.py @@ -5,24 +5,27 @@ """ from dishka import FromDishka -from dishka.integrations.fastapi import DishkaRoute -from fastapi import APIRouter, Depends, UploadFile, status +from fastapi import Depends, UploadFile, status from fastapi.responses import StreamingResponse +from fastapi_error_map.routing import ErrorAwareRouter from api.auth.utils import verify_auth +from api.error_routing import DishkaErrorAwareRoute from api.password_policy.adapter import PasswordBanWordsFastAPIAdapter +from api.password_policy.error_utils import error_map -password_ban_word_router = APIRouter( +password_ban_word_router = ErrorAwareRouter( prefix="/password_ban_word", tags=["Password Ban Word"], dependencies=[Depends(verify_auth)], - route_class=DishkaRoute, + route_class=DishkaErrorAwareRoute, ) @password_ban_word_router.post( "/upload_txt", status_code=status.HTTP_201_CREATED, + error_map=error_map, ) async def upload_ban_words_txt( file: UploadFile, @@ -43,6 +46,7 @@ async def upload_ban_words_txt( "/download_txt", response_class=StreamingResponse, status_code=status.HTTP_200_OK, + error_map=error_map, ) async def download_ban_words_txt( password_ban_word_adapter: FromDishka[PasswordBanWordsFastAPIAdapter], diff --git a/app/api/password_policy/password_policy_router.py b/app/api/password_policy/password_policy_router.py index f74025d86..812777ecd 100644 --- a/app/api/password_policy/password_policy_router.py +++ b/app/api/password_policy/password_policy_router.py @@ -5,25 +5,27 @@ """ from dishka import FromDishka -from dishka.integrations.fastapi import DishkaRoute -from fastapi import APIRouter, Depends +from fastapi import Depends +from fastapi_error_map.routing import ErrorAwareRouter from api.auth.utils import verify_auth +from api.error_routing import DishkaErrorAwareRoute from api.password_policy.adapter import PasswordPolicyFastAPIAdapter +from api.password_policy.error_utils import error_map from api.password_policy.schemas import PasswordPolicySchema from ldap_protocol.utils.const import GRANT_DN_STRING from .schemas import PriorityT -password_policy_router = APIRouter( +password_policy_router = ErrorAwareRouter( prefix="/password-policy", dependencies=[Depends(verify_auth)], tags=["Password Policy"], - route_class=DishkaRoute, + route_class=DishkaErrorAwareRoute, ) -@password_policy_router.get("/all") +@password_policy_router.get("/all", error_map=error_map) async def get_all( adapter: FromDishka[PasswordPolicyFastAPIAdapter], ) -> list[PasswordPolicySchema[int]]: @@ -31,7 +33,7 @@ async def get_all( return await adapter.get_all() -@password_policy_router.get("/{id_}") +@password_policy_router.get("/{id_}", error_map=error_map) async def get( id_: int, adapter: FromDishka[PasswordPolicyFastAPIAdapter], @@ -40,7 +42,7 @@ async def get( return await adapter.get(id_) -@password_policy_router.get("/by_dir_path_dn/{path_dn}") +@password_policy_router.get("/by_dir_path_dn/{path_dn}", error_map=error_map) async def get_password_policy_by_dir_path_dn( path_dn: GRANT_DN_STRING, adapter: FromDishka[PasswordPolicyFastAPIAdapter], @@ -49,7 +51,7 @@ async def get_password_policy_by_dir_path_dn( return await adapter.get_password_policy_by_dir_path_dn(path_dn) -@password_policy_router.put("/{id_}") +@password_policy_router.put("/{id_}", error_map=error_map) async def update( id_: int, policy: PasswordPolicySchema[PriorityT], @@ -59,7 +61,7 @@ async def update( await adapter.update(id_, policy) -@password_policy_router.put("/reset/domain_policy") +@password_policy_router.put("/reset/domain_policy", error_map=error_map) async def reset_domain_policy_to_default_config( adapter: FromDishka[PasswordPolicyFastAPIAdapter], ) -> None: diff --git a/app/api/shadow/adapter.py b/app/api/shadow/adapter.py index 51b4ac271..3062a0bb4 100644 --- a/app/api/shadow/adapter.py +++ b/app/api/shadow/adapter.py @@ -5,34 +5,14 @@ """ from ipaddress import IPv4Address -from typing import ParamSpec, TypeVar - -from fastapi import status from api.base_adapter import BaseAdapter from ldap_protocol.auth import AuthManager, MFAManager -from ldap_protocol.auth.exceptions.mfa import ( - AuthenticationError, - InvalidCredentialsError, - NetworkPolicyError, -) -from ldap_protocol.identity.exceptions import PasswordPolicyError - -P = ParamSpec("P") -R = TypeVar("R") class ShadowAdapter(BaseAdapter): """Adapter for shadow api with FastAPI.""" - _exceptions_map: dict[type[Exception], int] = { - InvalidCredentialsError: status.HTTP_404_NOT_FOUND, - NetworkPolicyError: status.HTTP_403_FORBIDDEN, - AuthenticationError: status.HTTP_401_UNAUTHORIZED, - PasswordPolicyError: status.HTTP_422_UNPROCESSABLE_ENTITY, - PermissionError: status.HTTP_403_FORBIDDEN, - } - def __init__( self, mfa_manager: MFAManager, diff --git a/app/api/shadow/router.py b/app/api/shadow/router.py index cb844a27a..ee8938a18 100644 --- a/app/api/shadow/router.py +++ b/app/api/shadow/router.py @@ -8,18 +8,56 @@ from typing import Annotated from dishka import FromDishka -from dishka.integrations.fastapi import DishkaRoute -from fastapi import APIRouter, Body +from fastapi import Body, status +from fastapi_error_map.routing import ErrorAwareRouter +from fastapi_error_map.rules import rule +from api.error_routing import ( + ERROR_MAP_TYPE, + DishkaErrorAwareRoute, + DomainErrorTranslator, +) +from enums import DomainCodes +from ldap_protocol.auth.exceptions.mfa import ( + AuthenticationError, + InvalidCredentialsError, + NetworkPolicyError, +) +from ldap_protocol.policies.password.exceptions import PasswordPolicyError from ldap_protocol.rootdse.dto import DomainControllerInfo from ldap_protocol.rootdse.reader import DCInfoReader from .adapter import ShadowAdapter -shadow_router = APIRouter(route_class=DishkaRoute) +translator = DomainErrorTranslator(DomainCodes.SHADOW) -@shadow_router.post("/mfa/push") +error_map: ERROR_MAP_TYPE = { + InvalidCredentialsError: rule( + status=status.HTTP_400_BAD_REQUEST, + translator=translator, + ), + NetworkPolicyError: rule( + status=status.HTTP_400_BAD_REQUEST, + translator=translator, + ), + AuthenticationError: rule( + status=status.HTTP_401_UNAUTHORIZED, + translator=translator, + ), + PasswordPolicyError: rule( + status=status.HTTP_422_UNPROCESSABLE_ENTITY, + translator=translator, + ), + PermissionError: rule( + status=status.HTTP_400_BAD_REQUEST, + translator=translator, + ), +} +shadow_router = ErrorAwareRouter(route_class=DishkaErrorAwareRoute) + + +@shadow_router.post("/mfa/push", error_map=error_map) async def proxy_request( principal: Annotated[str, Body(embed=True)], ip: Annotated[IPv4Address, Body(embed=True)], @@ -29,7 +67,7 @@ async def proxy_request( return await adapter.proxy_request(principal, ip) -@shadow_router.post("/sync/password") +@shadow_router.post("/sync/password", error_map=error_map) async def change_password( principal: Annotated[str, Body(embed=True)], new_password: Annotated[str, Body(embed=True)], diff --git a/app/enums.py b/app/enums.py index fb95e3a2f..7ec52e2d6 100644 --- a/app/enums.py +++ b/app/enums.py @@ -198,3 +198,22 @@ def combine( permissions: Iterable[AuthorizationRules], ) -> AuthorizationRules: return reduce(or_, permissions, AuthorizationRules(0)) + + +class DomainCodes(IntEnum): + """Error code parts.""" + + AUDIT = 1 + AUTH = 2 + SESSION = 3 + DNS = 4 + GENERAL = 5 + KERBEROS = 6 + LDAP = 7 + MFA = 8 + NETWORK = 9 + PASSWORD_POLICY = 10 + ROLES = 11 + DHCP = 12 + LDAP_SCHEMA = 13 + SHADOW = 14 diff --git a/app/errors/__init__.py b/app/errors/__init__.py new file mode 100644 index 000000000..ef8e2bde4 --- /dev/null +++ b/app/errors/__init__.py @@ -0,0 +1,11 @@ +"""Errors package. + +Copyright (c) 2025 MultiFactor +License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE +""" + +from .base import BaseDomainException + +__all__ = [ + "BaseDomainException", +] diff --git a/app/errors/base.py b/app/errors/base.py new file mode 100644 index 000000000..435aa339c --- /dev/null +++ b/app/errors/base.py @@ -0,0 +1,20 @@ +"""Errors base. + +Copyright (c) 2025 MultiFactor +License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE +""" + +from enum import IntEnum + + +class BaseDomainException(Exception): # noqa N818 + """Base exception.""" + + code: IntEnum + + def __init_subclass__(cls) -> None: + """Initialize subclass.""" + super().__init_subclass__() + + if not hasattr(cls, "code"): + raise AttributeError("code must be set") diff --git a/app/ldap_protocol/auth/exceptions/__init__.py b/app/ldap_protocol/auth/exceptions/__init__.py index 2f47c8dcb..3fba3e212 100644 --- a/app/ldap_protocol/auth/exceptions/__init__.py +++ b/app/ldap_protocol/auth/exceptions/__init__.py @@ -5,6 +5,7 @@ """ from .mfa import ( + AuthenticationError, InvalidCredentialsError, MFARequiredError, MFATokenError, @@ -20,4 +21,5 @@ "InvalidCredentialsError", "NetworkPolicyError", "NotFoundError", + "AuthenticationError", ] diff --git a/app/ldap_protocol/auth/exceptions/mfa.py b/app/ldap_protocol/auth/exceptions/mfa.py index 53d1dfd58..144f752b3 100644 --- a/app/ldap_protocol/auth/exceptions/mfa.py +++ b/app/ldap_protocol/auth/exceptions/mfa.py @@ -4,46 +4,88 @@ License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE """ +from enum import IntEnum -class MFAIdentityError(Exception): +from errors import BaseDomainException + + +class ErrorCodes(IntEnum): + """Error codes.""" + + BASE_ERROR = 0 + FORBIDDEN_ERROR = 1 + MFA_REQUIRED_ERROR = 2 + MFA_TOKEN_ERROR = 3 + MFA_API_ERROR = 4 + MFA_CONNECT_ERROR = 5 + MISSING_MFA_CREDENTIALS_ERROR = 6 + INVALID_CREDENTIALS_ERROR = 7 + NETWORK_POLICY_ERROR = 8 + NOT_FOUND_ERROR = 9 + AUTHENTICATION_ERROR = 10 + + +class MFAError(BaseDomainException): """Base exception for MFA identity-related errors.""" + code: ErrorCodes = ErrorCodes.BASE_ERROR + -class ForbiddenError(MFAIdentityError): +class ForbiddenError(MFAError): """Raised when an action is forbidden.""" + code = ErrorCodes.FORBIDDEN_ERROR -class MFARequiredError(MFAIdentityError): + +class MFARequiredError(MFAError): """Raised when MFA is required for authentication.""" + code = ErrorCodes.MFA_REQUIRED_ERROR + -class MFATokenError(MFAIdentityError): +class MFATokenError(MFAError): """Raised when an MFA token is invalid or missing.""" + code = ErrorCodes.MFA_TOKEN_ERROR -class MFAAPIError(MFAIdentityError): + +class MFAAPIError(MFAError): """Raised when an MFA API error occurs.""" + code = ErrorCodes.MFA_API_ERROR + -class MFAConnectError(MFAIdentityError): +class MFAConnectError(MFAError): """Raised when an MFA connect error occurs.""" + code = ErrorCodes.MFA_CONNECT_ERROR -class MissingMFACredentialsError(MFAIdentityError): + +class MissingMFACredentialsError(MFAError): """Raised when MFA credentials are missing or not configured.""" + code = ErrorCodes.MISSING_MFA_CREDENTIALS_ERROR + -class InvalidCredentialsError(MFAIdentityError): +class InvalidCredentialsError(MFAError): """Raised when provided credentials are invalid.""" + code = ErrorCodes.INVALID_CREDENTIALS_ERROR -class NetworkPolicyError(MFAIdentityError): + +class NetworkPolicyError(MFAError): """Raised when a network policy violation occurs.""" + code = ErrorCodes.NETWORK_POLICY_ERROR + -class NotFoundError(MFAIdentityError): +class NotFoundError(MFAError): """Raised when a required resource is not found user, MFA config.""" + code = ErrorCodes.NOT_FOUND_ERROR -class AuthenticationError(MFAIdentityError): + +class AuthenticationError(MFAError): """Raised when an authentication attempt fails.""" + + code = ErrorCodes.AUTHENTICATION_ERROR diff --git a/app/ldap_protocol/dhcp/exceptions.py b/app/ldap_protocol/dhcp/exceptions.py index ce77e867c..4b29a4514 100644 --- a/app/ldap_protocol/dhcp/exceptions.py +++ b/app/ldap_protocol/dhcp/exceptions.py @@ -4,46 +4,88 @@ License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE """ +from enum import IntEnum -class DHCPError(Exception): +from errors import BaseDomainException + + +class ErrorCodes(IntEnum): + """Error codes.""" + + BASE_ERROR = 0 + DHCP_API_ERROR = 1 + DHCP_VALIDATION_ERROR = 2 + DHCP_CONNECTION_ERROR = 3 + DHCP_OPERATION_ERROR = 4 + DHCP_ENTRY_ADD_ERROR = 5 + DHCP_ENTRY_NOT_FOUND_ERROR = 6 + DHCP_ENTRY_DELETE_ERROR = 7 + DHCP_ENTRY_UPDATE_ERROR = 8 + DHCP_CONFLICT_ERROR = 9 + DHCP_UNSUPPORTED_ERROR = 10 + + +class DHCPError(BaseDomainException): """DHCP base exception.""" + code: ErrorCodes = ErrorCodes.BASE_ERROR + class DHCPAPIError(DHCPError): """DHCP API error.""" + code = ErrorCodes.DHCP_API_ERROR + class DHCPValidatonError(DHCPError): """DHCP validation error.""" + code = ErrorCodes.DHCP_VALIDATION_ERROR + class DHCPConnectionError(ConnectionError): """DHCP connection error.""" + code = ErrorCodes.DHCP_CONNECTION_ERROR + class DHCPOperationError(DHCPError): """DHCP operation error.""" + code = ErrorCodes.DHCP_OPERATION_ERROR + class DHCPEntryAddError(DHCPError): """DHCP entry addition error.""" + code = ErrorCodes.DHCP_ENTRY_ADD_ERROR + class DHCPEntryNotFoundError(DHCPError): """DHCP entry not found error.""" + code = ErrorCodes.DHCP_ENTRY_NOT_FOUND_ERROR + class DHCPEntryDeleteError(DHCPError): """DHCP entry deletion error.""" + code = ErrorCodes.DHCP_ENTRY_DELETE_ERROR + class DHCPEntryUpdateError(DHCPError): """DHCP entry update error.""" + code = ErrorCodes.DHCP_ENTRY_UPDATE_ERROR + class DHCPConflictError(DHCPError): """DHCP conflict error.""" + code = ErrorCodes.DHCP_CONFLICT_ERROR + class DHCPUnsupportedError(DHCPError): """DHCP unsupported error.""" + + code = ErrorCodes.DHCP_UNSUPPORTED_ERROR diff --git a/app/ldap_protocol/dns/__init__.py b/app/ldap_protocol/dns/__init__.py index 235fc83f9..f9c97fba7 100644 --- a/app/ldap_protocol/dns/__init__.py +++ b/app/ldap_protocol/dns/__init__.py @@ -3,8 +3,6 @@ DNS_MANAGER_STATE_NAME, DNS_MANAGER_ZONE_NAME, AbstractDNSManager, - DNSConnectionError, - DNSError, DNSForwardServerStatus, DNSForwardZone, DNSManagerSettings, @@ -19,6 +17,7 @@ DNSZoneType, ) from .dns_gateway import DNSStateGateway +from .exceptions import DNSConnectionError, DNSError from .remote import RemoteDNSManager from .selfhosted import SelfHostedDNSManager from .stub import StubDNSManager diff --git a/app/ldap_protocol/dns/base.py b/app/ldap_protocol/dns/base.py index 275167b60..01fe71c8c 100644 --- a/app/ldap_protocol/dns/base.py +++ b/app/ldap_protocol/dns/base.py @@ -46,14 +46,6 @@ class DNSForwarderServerStatus(StrEnum): NOT_FOUND = "not found" -class DNSConnectionError(ConnectionError): - """API Error.""" - - -class DNSError(Exception): - """DNS Error.""" - - class DNSNotImplementedError(NotImplementedError): """API Not Implemented Error.""" diff --git a/app/ldap_protocol/dns/exceptions.py b/app/ldap_protocol/dns/exceptions.py index e7474e1a3..5b9da9f5e 100644 --- a/app/ldap_protocol/dns/exceptions.py +++ b/app/ldap_protocol/dns/exceptions.py @@ -4,38 +4,88 @@ License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE """ +from enum import IntEnum -class DNSError(Exception): +from errors import BaseDomainException + + +class ErrorCodes(IntEnum): + """Error codes.""" + + BASE_ERROR = 0 + DNS_SETUP_ERROR = 1 + DNS_RECORD_CREATE_ERROR = 2 + DNS_RECORD_UPDATE_ERROR = 3 + DNS_RECORD_DELETE_ERROR = 4 + DNS_ZONE_CREATE_ERROR = 5 + DNS_ZONE_UPDATE_ERROR = 6 + DNS_ZONE_DELETE_ERROR = 7 + DNS_UPDATE_SERVER_OPTIONS_ERROR = 8 + DNS_CONNECTION_ERROR = 9 + DNS_NOT_IMPLEMENTED_ERROR = 10 + + +class DNSError(BaseDomainException): """DNS Error.""" + code: ErrorCodes = ErrorCodes.BASE_ERROR + class DNSSetupError(DNSError): """DNS setup error.""" + code = ErrorCodes.DNS_SETUP_ERROR + class DNSRecordCreateError(DNSError): """DNS record create error.""" + code = ErrorCodes.DNS_RECORD_CREATE_ERROR + class DNSRecordUpdateError(DNSError): """DNS record update error.""" + code = ErrorCodes.DNS_RECORD_UPDATE_ERROR + class DNSRecordDeleteError(DNSError): """DNS record delete error.""" + code = ErrorCodes.DNS_RECORD_DELETE_ERROR + class DNSZoneCreateError(DNSError): """DNS zone create error.""" + code = ErrorCodes.DNS_ZONE_CREATE_ERROR + class DNSZoneUpdateError(DNSError): """DNS zone update error.""" + code = ErrorCodes.DNS_ZONE_UPDATE_ERROR + class DNSZoneDeleteError(DNSError): """DNS zone delete error.""" + code = ErrorCodes.DNS_ZONE_DELETE_ERROR + class DNSUpdateServerOptionsError(DNSError): """DNS update server options error.""" + + code = ErrorCodes.DNS_UPDATE_SERVER_OPTIONS_ERROR + + +class DNSConnectionError(DNSError): + """DNS connection error.""" + + code = ErrorCodes.DNS_CONNECTION_ERROR + + +class DNSNotImplementedError(DNSError): + """DNS not implemented error.""" + + code = ErrorCodes.DNS_NOT_IMPLEMENTED_ERROR diff --git a/app/ldap_protocol/dns/remote.py b/app/ldap_protocol/dns/remote.py index a44c69c0e..1c2cb25fd 100644 --- a/app/ldap_protocol/dns/remote.py +++ b/app/ldap_protocol/dns/remote.py @@ -15,7 +15,8 @@ from dns.update import Update from dns.zone import Zone -from .base import AbstractDNSManager, DNSConnectionError, DNSRecord, DNSRecords +from .base import AbstractDNSManager, DNSRecord, DNSRecords +from .exceptions import DNSConnectionError from .utils import logger_wraps diff --git a/app/ldap_protocol/dns/utils.py b/app/ldap_protocol/dns/utils.py index 005d9f5d4..9adc21fe9 100644 --- a/app/ldap_protocol/dns/utils.py +++ b/app/ldap_protocol/dns/utils.py @@ -9,7 +9,8 @@ from dns.asyncresolver import Resolver as AsyncResolver -from .base import DNSConnectionError, log +from .base import log +from .exceptions import DNSConnectionError def logger_wraps(is_stub: bool = False) -> Callable: diff --git a/app/ldap_protocol/identity/exceptions.py b/app/ldap_protocol/identity/exceptions.py index ebd5274a9..568ec1f7f 100644 --- a/app/ldap_protocol/identity/exceptions.py +++ b/app/ldap_protocol/identity/exceptions.py @@ -4,34 +4,74 @@ License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE """ +from enum import IntEnum -class IdentityError(Exception): +from api.error_routing import BaseDomainException + + +class ErrorCodes(IntEnum): + """Identity error codes.""" + + BASE_ERROR = 0 + UNAUTHORIZED_ERROR = 1 + ALREADY_CONFIGURED_ERROR = 2 + FORBIDDEN_ERROR = 3 + LOGIN_FAILED_ERROR = 4 + PASSWORD_POLICY_ERROR = 5 + USER_NOT_FOUND_ERROR = 6 + AUTH_VALIDATION_ERROR = 7 + AUTHORIZATION_ERROR = 8 + + +class AuthError(BaseDomainException): """Base exception for authentication identity-related errors.""" + code: ErrorCodes = ErrorCodes.BASE_ERROR + -class UnauthorizedError(IdentityError): +class UnauthorizedError(AuthError): """Raised when authentication fails due to invalid credentials.""" + code = ErrorCodes.UNAUTHORIZED_ERROR -class AlreadyConfiguredError(IdentityError): + +class AlreadyConfiguredError(AuthError): """Raised when setup is attempted but already performed.""" + code = ErrorCodes.ALREADY_CONFIGURED_ERROR + -class ForbiddenError(IdentityError): +class ForbiddenError(AuthError): """Raised when access is forbidden due to policy or group membership.""" + code = ErrorCodes.FORBIDDEN_ERROR -class LoginFailedError(IdentityError): + +class LoginFailedError(AuthError): """Raised when login fails for reasons other than invalid credentials.""" + code = ErrorCodes.LOGIN_FAILED_ERROR + -class PasswordPolicyError(IdentityError): +class PasswordPolicyError(AuthError): """Raised when a password does not meet policy requirements.""" + code = ErrorCodes.PASSWORD_POLICY_ERROR -class UserNotFoundError(IdentityError): + +class UserNotFoundError(AuthError): """Raised when a user is not found in the system.""" + code = ErrorCodes.USER_NOT_FOUND_ERROR + -class AuthValidationError(IdentityError): +class AuthValidationError(AuthError): """Raised when there is a validation error during authentication.""" + + code = ErrorCodes.AUTH_VALIDATION_ERROR + + +class AuthorizationError(AuthError): + """Authorization error.""" + + code = ErrorCodes.AUTHORIZATION_ERROR diff --git a/app/ldap_protocol/identity/utils.py b/app/ldap_protocol/identity/utils.py new file mode 100644 index 000000000..844c0f4bf --- /dev/null +++ b/app/ldap_protocol/identity/utils.py @@ -0,0 +1,63 @@ +"""Identity utility functions for authentication and user management. + +Copyright (c) 2024 MultiFactor +License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE +""" + +from ipaddress import IPv4Address, IPv6Address, ip_address + +from fastapi import HTTPException, Request, status +from sqlalchemy.ext.asyncio import AsyncSession + +from entities import User +from ldap_protocol.utils.queries import get_user +from password_utils import PasswordUtils + + +async def authenticate_user( + session: AsyncSession, + username: str, + password: str, + password_utils: PasswordUtils, +) -> User | None: + """Get user and verify password. + + :param AsyncSession session: sa session + :param str username: any str + :param str password: any str + :return User | None: User model (pydantic). + """ + user = await get_user(session, username) + + if not user or not user.password or not password: + return None + if not password_utils.verify_password(password, user.password): + return None + return user + + +def get_ip_from_request(request: Request) -> IPv4Address | IPv6Address: + """Get IP address from request. + + :param Request request: The incoming request object. + :return IPv4Address | None: The IP address or None. + """ + forwarded_for = request.headers.get("X-Forwarded-For") + if forwarded_for: + client_ip = forwarded_for.split(",")[0] + else: + if request.client is None: + raise HTTPException(status.HTTP_400_BAD_REQUEST) + client_ip = request.client.host + + return ip_address(client_ip) + + +def get_user_agent_from_request(request: Request) -> str: + """Get user agent from request. + + :param Request request: The incoming request object. + :return str: The user agent header. + """ + user_agent_header = request.headers.get("User-Agent") + return user_agent_header if user_agent_header else "" diff --git a/app/ldap_protocol/kerberos/exceptions.py b/app/ldap_protocol/kerberos/exceptions.py index d98ca1bda..735149eff 100644 --- a/app/ldap_protocol/kerberos/exceptions.py +++ b/app/ldap_protocol/kerberos/exceptions.py @@ -4,86 +4,159 @@ License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE """ - -class KerberosError(Exception): +from enum import IntEnum + +from api.error_routing import BaseDomainException + + +class ErrorCodes(IntEnum): + """Error codes.""" + + BASE_ERROR = 0 + KERBEROS_BASE_DN_NOT_FOUND_ERROR = 1 + KERBEROS_CONFLICT_ERROR = 2 + KERBEROS_NOT_FOUND_ERROR = 3 + KERBEROS_DEPENDENCY_ERROR = 4 + KERBEROS_UNAVAILABLE_ERROR = 5 + KERBEROS_API_ERROR = 6 + KERBEROS_API_CONFLICT_ERROR = 7 + KERBEROS_API_NOT_FOUND_ERROR = 8 + KERBEROS_API_DEPENDENCY_ERROR = 9 + KERBEROS_API_UNAVAILABLE_ERROR = 10 + KERBEROS_API_SETUP_CONFIGS_ERROR = 11 + KERBEROS_API_SETUP_STASH_ERROR = 12 + KERBEROS_API_SETUP_TREE_ERROR = 13 + KERBEROS_API_PRINCIPAL_NOT_FOUND_ERROR = 14 + KERBEROS_API_ADD_PRINCIPAL_ERROR = 15 + KERBEROS_API_GET_PRINCIPAL_ERROR = 16 + KERBEROS_API_DELETE_PRINCIPAL_ERROR = 17 + KERBEROS_API_CHANGE_PASSWORD_ERROR = 18 + KERBEROS_API_RENAME_PRINCIPAL_ERROR = 19 + KERBEROS_API_LOCK_PRINCIPAL_ERROR = 20 + KERBEROS_API_FORCE_PASSWORD_CHANGE_ERROR = 21 + KERBEROS_API_STATUS_NOT_FOUND_ERROR = 22 + KERBEROS_API_CONNECTION_ERROR = 23 + + +class KerberosError(BaseDomainException): """Base exception for authentication kerberos-related errors.""" + code: ErrorCodes = ErrorCodes.BASE_ERROR + class KerberosConflictError(KerberosError): """Raised when a conflict occurs.""" + code = ErrorCodes.KERBEROS_CONFLICT_ERROR + class KerberosNotFoundError(KerberosError): """Raised when a resource is not found.""" + code = ErrorCodes.KERBEROS_NOT_FOUND_ERROR + class KerberosDependencyError(KerberosError): """Raised when a dependency fails.""" + code = ErrorCodes.KERBEROS_DEPENDENCY_ERROR + class KerberosUnavailableError(KerberosError): """Raised when the service is unavailable.""" + code = ErrorCodes.KERBEROS_UNAVAILABLE_ERROR + class KerberosBaseDnNotFoundError(KerberosError): """Raised when no base DN is found in the LDAP directory.""" + code = ErrorCodes.KERBEROS_BASE_DN_NOT_FOUND_ERROR -class KRBAPIError(Exception): + +class KRBAPIError(KerberosError): """API Error.""" class KRBAPIConflictError(KRBAPIError): """Conflict error.""" + code = ErrorCodes.KERBEROS_API_CONFLICT_ERROR + class KRBAPISetupConfigsError(KRBAPIError): """Setup configs error.""" + code = ErrorCodes.KERBEROS_API_SETUP_CONFIGS_ERROR + class KRBAPISetupStashError(KRBAPIError): """Setup stash error.""" + code = ErrorCodes.KERBEROS_API_SETUP_STASH_ERROR + class KRBAPISetupTreeError(KRBAPIError): """Setup tree error.""" + code = ErrorCodes.KERBEROS_API_SETUP_TREE_ERROR + class KRBAPIPrincipalNotFoundError(KRBAPIError): """Principal not found error.""" + code = ErrorCodes.KERBEROS_API_PRINCIPAL_NOT_FOUND_ERROR + class KRBAPIAddPrincipalError(KRBAPIError): """Add principal error.""" + code = ErrorCodes.KERBEROS_API_ADD_PRINCIPAL_ERROR + class KRBAPIGetPrincipalError(KRBAPIError): """Get principal error.""" + code = ErrorCodes.KERBEROS_API_GET_PRINCIPAL_ERROR + class KRBAPIDeletePrincipalError(KRBAPIError): """Delete principal error.""" + code = ErrorCodes.KERBEROS_API_DELETE_PRINCIPAL_ERROR + class KRBAPIChangePasswordError(KRBAPIError): """Change password error.""" + code = ErrorCodes.KERBEROS_API_CHANGE_PASSWORD_ERROR + class KRBAPIRenamePrincipalError(KRBAPIError): """Rename principal error.""" + code = ErrorCodes.KERBEROS_API_RENAME_PRINCIPAL_ERROR + class KRBAPILockPrincipalError(KRBAPIError): """Lock principal error.""" + code = ErrorCodes.KERBEROS_API_LOCK_PRINCIPAL_ERROR + class KRBAPIForcePasswordChangeError(KRBAPIError): """Force password change error.""" + code = ErrorCodes.KERBEROS_API_FORCE_PASSWORD_CHANGE_ERROR + class KRBAPIStatusNotFoundError(KRBAPIError): """Status not found error.""" + code = ErrorCodes.KERBEROS_API_STATUS_NOT_FOUND_ERROR + class KRBAPIConnectionError(KRBAPIError): """Connection error.""" + + code = ErrorCodes.KERBEROS_API_CONNECTION_ERROR diff --git a/app/ldap_protocol/ldap_schema/exceptions.py b/app/ldap_protocol/ldap_schema/exceptions.py index de6ab4544..02a9d43f3 100644 --- a/app/ldap_protocol/ldap_schema/exceptions.py +++ b/app/ldap_protocol/ldap_schema/exceptions.py @@ -4,50 +4,81 @@ License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE """ +from enum import IntEnum + +from errors import BaseDomainException + + +class ErrorCodes(IntEnum): + """Error codes for LDAP Schema operations.""" + + BASE_ERROR = 0 + ATTRIBUTE_TYPE_NOT_FOUND_ERROR = 1 + ATTRIBUTE_TYPE_CANT_MODIFY_ERROR = 2 + ATTRIBUTE_TYPE_ALREADY_EXISTS_ERROR = 3 + OBJECT_CLASS_NOT_FOUND_ERROR = 4 + OBJECT_CLASS_CANT_MODIFY_ERROR = 5 + OBJECT_CLASS_ALREADY_EXISTS_ERROR = 6 + ENTITY_TYPE_NOT_FOUND_ERROR = 7 + ENTITY_TYPE_CANT_MODIFY_ERROR = 8 + ENTITY_TYPE_ALREADY_EXISTS_ERROR = 9 -class AttributeTypeError(Exception): - """Raised when an attribute type is not found.""" +class LdapSchemaError(BaseDomainException): + """Raised when an LDAP Schema error occurs.""" -class AttributeTypeNotFoundError(AttributeTypeError): + code: ErrorCodes = ErrorCodes.BASE_ERROR + + +class AttributeTypeNotFoundError(LdapSchemaError): """Raised when an attribute type is not found.""" + code = ErrorCodes.ATTRIBUTE_TYPE_NOT_FOUND_ERROR -class AttributeTypeCantModifyError(AttributeTypeError): + +class AttributeTypeCantModifyError(LdapSchemaError): """Raised when an attribute type cannot be modified.""" + code = ErrorCodes.ATTRIBUTE_TYPE_CANT_MODIFY_ERROR -class AttributeTypeAlreadyExistsError(AttributeTypeError): - """Raised when an attribute type already exists.""" +class AttributeTypeAlreadyExistsError(LdapSchemaError): + """Raised when an attribute type already exists.""" -class ObjectClassTypeError(Exception): - """Raised when an object class type is not found.""" + code = ErrorCodes.ATTRIBUTE_TYPE_ALREADY_EXISTS_ERROR -class ObjectClassNotFoundError(ObjectClassTypeError): +class ObjectClassNotFoundError(LdapSchemaError): """Raised when an object class is not found.""" + code = ErrorCodes.OBJECT_CLASS_NOT_FOUND_ERROR + -class ObjectClassCantModifyError(ObjectClassTypeError): +class ObjectClassCantModifyError(LdapSchemaError): """Raised when an object class cannot be modified.""" + code = ErrorCodes.OBJECT_CLASS_CANT_MODIFY_ERROR -class ObjectClassAlreadyExistsError(ObjectClassTypeError): - """Raised when an object class already exists.""" +class ObjectClassAlreadyExistsError(LdapSchemaError): + """Raised when an object class already exists.""" -class EntityTypeTypeError(Exception): - """Raised when an entity type is not found.""" + code = ErrorCodes.OBJECT_CLASS_ALREADY_EXISTS_ERROR -class EntityTypeNotFoundError(EntityTypeTypeError): +class EntityTypeNotFoundError(LdapSchemaError): """Raised when an entity type is not found.""" + code = ErrorCodes.ENTITY_TYPE_NOT_FOUND_ERROR + -class EntityTypeCantModifyError(EntityTypeTypeError): +class EntityTypeCantModifyError(LdapSchemaError): """Raised when an entity type cannot be modified.""" + code = ErrorCodes.ENTITY_TYPE_CANT_MODIFY_ERROR -class EntityTypeAlreadyExistsError(EntityTypeTypeError): + +class EntityTypeAlreadyExistsError(LdapSchemaError): """Raised when an entity type already exists.""" + + code = ErrorCodes.ENTITY_TYPE_ALREADY_EXISTS_ERROR diff --git a/app/ldap_protocol/permissions_checker.py b/app/ldap_protocol/permissions_checker.py index be5a752c3..e41ae3cbf 100644 --- a/app/ldap_protocol/permissions_checker.py +++ b/app/ldap_protocol/permissions_checker.py @@ -5,15 +5,12 @@ from enums import AuthorizationRules from ldap_protocol.identity import IdentityProvider +from ldap_protocol.identity.exceptions import AuthorizationError _P = ParamSpec("_P") _R = TypeVar("_R") -class AuthorizationError(Exception): - """Authorization error.""" - - class AuthorizationProvider: """API permissions checker.""" diff --git a/app/ldap_protocol/policies/audit/exception.py b/app/ldap_protocol/policies/audit/exception.py index c32647dd5..7f9ca4d3a 100644 --- a/app/ldap_protocol/policies/audit/exception.py +++ b/app/ldap_protocol/policies/audit/exception.py @@ -4,10 +4,32 @@ License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE """ +from enum import IntEnum -class AuditNotFoundError(Exception): +from errors import BaseDomainException + + +class ErrorCodes(IntEnum): + """Error codes.""" + + BASE_ERROR = 0 + AUDIT_NOT_FOUND_ERROR = 1 + AUDIT_ALREADY_EXISTS_ERROR = 2 + + +class AuditError(BaseDomainException): + """Audit error.""" + + code: ErrorCodes = ErrorCodes.BASE_ERROR + + +class AuditNotFoundError(AuditError): """Exception raised when an audit model is not found.""" + code = ErrorCodes.AUDIT_NOT_FOUND_ERROR -class AuditAlreadyExistsError(Exception): + +class AuditAlreadyExistsError(AuditError): """Exception raised when an audit model already exists.""" + + code = ErrorCodes.AUDIT_ALREADY_EXISTS_ERROR diff --git a/app/ldap_protocol/policies/audit/monitor.py b/app/ldap_protocol/policies/audit/monitor.py index 0719c4149..5ce08d0ab 100644 --- a/app/ldap_protocol/policies/audit/monitor.py +++ b/app/ldap_protocol/policies/audit/monitor.py @@ -24,6 +24,7 @@ ) from ldap_protocol.auth.schemas import OAuth2Form from ldap_protocol.identity.exceptions import ( + AuthorizationError, AuthValidationError, LoginFailedError, PasswordPolicyError, @@ -33,7 +34,6 @@ from ldap_protocol.kerberos.exceptions import KRBAPIChangePasswordError from ldap_protocol.multifactor import MFA_HTTP_Creds from ldap_protocol.objects import OperationEvent -from ldap_protocol.permissions_checker import AuthorizationError from ldap_protocol.policies.audit.audit_use_case import AuditUseCase from ldap_protocol.policies.audit.events.factory import ( RawAuditEventBuilderRedis, diff --git a/app/ldap_protocol/policies/network/exceptions.py b/app/ldap_protocol/policies/network/exceptions.py index ea63f3c2a..81ff561e8 100644 --- a/app/ldap_protocol/policies/network/exceptions.py +++ b/app/ldap_protocol/policies/network/exceptions.py @@ -4,18 +4,39 @@ License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE """ +from enum import IntEnum -class NetworkPolicyError(Exception): +from api.error_routing import BaseDomainException + + +class ErrorCodes(IntEnum): + """Error codes.""" + + BASE_ERROR = 0 + NETWORK_POLICY_ALREADY_EXISTS_ERROR = 1 + NETWORK_POLICY_NOT_FOUND_ERROR = 2 + LAST_ACTIVE_POLICY_ERROR = 3 + + +class NetworkPolicyError(BaseDomainException): """Network policy error.""" + code: ErrorCodes = ErrorCodes.BASE_ERROR + class NetworkPolicyAlreadyExistsError(NetworkPolicyError): """Network policy already exists error.""" + code = ErrorCodes.NETWORK_POLICY_ALREADY_EXISTS_ERROR + class NetworkPolicyNotFoundError(NetworkPolicyError): """Network policy not found error.""" + code = ErrorCodes.NETWORK_POLICY_NOT_FOUND_ERROR + class LastActivePolicyError(NetworkPolicyError): """Last active policy error.""" + + code = ErrorCodes.LAST_ACTIVE_POLICY_ERROR diff --git a/app/ldap_protocol/policies/password/exceptions.py b/app/ldap_protocol/policies/password/exceptions.py index f3a5f0414..e8c707765 100644 --- a/app/ldap_protocol/policies/password/exceptions.py +++ b/app/ldap_protocol/policies/password/exceptions.py @@ -4,50 +4,96 @@ License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE """ +from enum import IntEnum -class PasswordPolicyBaseError(Exception): +from api.error_routing import BaseDomainException + + +class ErrorCodes(IntEnum): + """Error codes.""" + + BASE_ERROR = 0 + PASSWORD_POLICY_ALREADY_EXISTS_ERROR = 1 + PASSWORD_POLICY_NOT_FOUND_ERROR = 2 + PASSWORD_POLICY_DIR_IS_NOT_USER_ERROR = 3 + PASSWORD_POLICY_BASE_DN_NOT_FOUND_ERROR = 4 + PASSWORD_POLICY_CANT_CHANGE_DEFAULT_DOMAIN_ERROR = 5 + PASSWORD_POLICY_PRIORITY_ERROR = 6 + PASSWORD_POLICY_AGE_DAYS_ERROR = 7 + + PASSWORD_BAN_WORD_ERROR = 8 + PASSWORD_BAN_WORD_FILE_HAS_DUPLICATES_ERROR = 9 + PASSWORD_BAN_WORD_TOO_LONG_ERROR = 10 + PASSWORD_BAN_WORD_WRONG_FILE_EXTENSION_ERROR = 11 + + +class PasswordPolicyError(BaseDomainException): """Base exception class for Password Policy service errors.""" + code: ErrorCodes = ErrorCodes.BASE_ERROR + -class PasswordPolicyAlreadyExistsError(PasswordPolicyBaseError): +class PasswordPolicyAlreadyExistsError(PasswordPolicyError): """Exception raised when a Password Policy already exists.""" + code = ErrorCodes.PASSWORD_POLICY_ALREADY_EXISTS_ERROR -class PasswordPolicyNotFoundError(PasswordPolicyBaseError): + +class PasswordPolicyNotFoundError(PasswordPolicyError): """Exception raised when a Password Policy not found.""" + code = ErrorCodes.PASSWORD_POLICY_NOT_FOUND_ERROR + -class PasswordPolicyDirIsNotUserError(PasswordPolicyBaseError): +class PasswordPolicyDirIsNotUserError(PasswordPolicyError): """Exception raised when the directory is not a user.""" + code = ErrorCodes.PASSWORD_POLICY_DIR_IS_NOT_USER_ERROR -class PasswordPolicyBaseDnNotFoundError(PasswordPolicyBaseError): + +class PasswordPolicyBaseDnNotFoundError(PasswordPolicyError): """Exception raised when a Base DN not found.""" + code = ErrorCodes.PASSWORD_POLICY_BASE_DN_NOT_FOUND_ERROR + -class PasswordPolicyCantChangeDefaultDomainError(PasswordPolicyBaseError): +class PasswordPolicyCantChangeDefaultDomainError(PasswordPolicyError): """Cannot change the name of the default domain Password Policy.""" + code = ErrorCodes.PASSWORD_POLICY_CANT_CHANGE_DEFAULT_DOMAIN_ERROR + -class PasswordPolicyPriorityError(PasswordPolicyBaseError): +class PasswordPolicyPriorityError(PasswordPolicyError): """Exception raised when there is a priority error.""" + code = ErrorCodes.PASSWORD_POLICY_PRIORITY_ERROR -class PasswordPolicyAgeDaysError(PasswordPolicyBaseError): + +class PasswordPolicyAgeDaysError(PasswordPolicyError): """Exception raised when the age days are invalid.""" + code = ErrorCodes.PASSWORD_POLICY_AGE_DAYS_ERROR + -class PasswordBanWordError(Exception): +class PasswordBanWordError(PasswordPolicyError): """Base exception class for password policy service errors.""" + code = ErrorCodes.PASSWORD_BAN_WORD_ERROR + class PasswordBanWordFileHasDuplicatesError(PasswordBanWordError): """Exception raised when a ban word already exists.""" + code = ErrorCodes.PASSWORD_BAN_WORD_FILE_HAS_DUPLICATES_ERROR + class PasswordBanWordTooLongError(PasswordBanWordError): """Exception raised when a ban word too long.""" + code = ErrorCodes.PASSWORD_BAN_WORD_TOO_LONG_ERROR + class PasswordBanWordWrongFileExtensionError(PasswordBanWordError): """Exception raised when a ban words file has wrong extension.""" + + code = ErrorCodes.PASSWORD_BAN_WORD_WRONG_FILE_EXTENSION_ERROR diff --git a/app/ldap_protocol/session_storage/exceptions.py b/app/ldap_protocol/session_storage/exceptions.py index fcbf820fe..76a453997 100644 --- a/app/ldap_protocol/session_storage/exceptions.py +++ b/app/ldap_protocol/session_storage/exceptions.py @@ -4,30 +4,67 @@ License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE """ +from enum import IntEnum -class SessionStorageError(Exception): +from errors import BaseDomainException + + +class ErrorCodes(IntEnum): + """Error codes.""" + + BASE_ERROR = 0 + INVALID_KEY_ERROR = 1 + MISSING_DATA_ERROR = 2 + INVALID_IP_ERROR = 3 + INVALID_USER_AGENT_ERROR = 4 + INVALID_SIGNATURE_ERROR = 5 + INVALID_DATA_ERROR = 6 + USER_NOT_FOUND_ERROR = 7 + + +class SessionStorageError(BaseDomainException): """Session storage error.""" + code: ErrorCodes = ErrorCodes.BASE_ERROR + class SessionStorageInvalidKeyError(SessionStorageError): """Session storage invalid key error.""" + code = ErrorCodes.INVALID_KEY_ERROR + class SessionStorageMissingDataError(SessionStorageError): """Session storage missing data error.""" + code = ErrorCodes.MISSING_DATA_ERROR + class SessionStorageInvalidIpError(SessionStorageError): """Session storage invalid ip error.""" + code = ErrorCodes.INVALID_IP_ERROR + class SessionStorageInvalidUserAgentError(SessionStorageError): """Session storage invalid user agent error.""" + code = ErrorCodes.INVALID_USER_AGENT_ERROR + class SessionStorageInvalidSignatureError(SessionStorageError): """Session storage invalid signature error.""" + code = ErrorCodes.INVALID_SIGNATURE_ERROR + class SessionStorageInvalidDataError(SessionStorageError): """Session storage invalid data error.""" + + code = ErrorCodes.INVALID_DATA_ERROR + + +class SessionUserNotFoundError(SessionStorageError): + """Session storage user not found error.""" + + code = ErrorCodes.USER_NOT_FOUND_ERROR diff --git a/app/ldap_protocol/session_storage/repository.py b/app/ldap_protocol/session_storage/repository.py index ccb842980..84366faee 100644 --- a/app/ldap_protocol/session_storage/repository.py +++ b/app/ldap_protocol/session_storage/repository.py @@ -12,6 +12,7 @@ from enums import AuthorizationRules from ldap_protocol.utils.queries import get_user, set_user_logon_attrs +from .exceptions import SessionUserNotFoundError from .redis import SessionStorage @@ -102,7 +103,7 @@ async def get_user_sessions( user = await get_user(self.session, upn) if not user: - raise LookupError("User not found.") + raise SessionUserNotFoundError("User not found.") sessions = await self.storage.get_user_sessions(user.id) @@ -121,7 +122,7 @@ async def clear_user_sessions(self, identity: str | User) -> None: ) if not user: - raise LookupError("User not found.") + raise SessionUserNotFoundError("User not found.") await self.storage.clear_user_sessions(user.id) diff --git a/app/multidirectory.py b/app/multidirectory.py index f88537105..613dce878 100644 --- a/app/multidirectory.py +++ b/app/multidirectory.py @@ -15,7 +15,6 @@ from alembic.config import Config, command from dishka import Scope, make_async_container from dishka.integrations.fastapi import setup_dishka -from dns.exception import DNSException from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from loguru import logger @@ -36,12 +35,7 @@ session_router, shadow_router, ) -from api.exception_handlers import ( - handle_db_connect_error, - handle_dns_api_error, - handle_dns_error, - handle_not_implemented_error, -) +from api.exception_handlers import handle_auth_error, handle_db_connect_error from api.middlewares import proc_time_header_middleware, set_key_middleware from config import Settings from extra.dump_acme_certs import dump_acme_cert @@ -54,11 +48,7 @@ MFAProvider, ) from ldap_protocol.dependency import resolve_deps -from ldap_protocol.dns import ( - DNSConnectionError, - DNSError, - DNSNotImplementedError, -) +from ldap_protocol.identity.exceptions import UnauthorizedError from ldap_protocol.policies.audit.events.handler import AuditEventHandler from ldap_protocol.policies.audit.events.sender import AuditEventSenderManager from ldap_protocol.server import PoolClientHandler @@ -108,14 +98,7 @@ def _create_basic_app(settings: Settings) -> FastAPI: app.middleware("http")(set_key_middleware) app.add_exception_handler(sa_exc.TimeoutError, handle_db_connect_error) app.add_exception_handler(sa_exc.InterfaceError, handle_db_connect_error) - app.add_exception_handler(DNSException, handle_dns_error) - app.add_exception_handler(DNSConnectionError, handle_dns_error) - app.add_exception_handler(DNSError, handle_dns_api_error) - app.add_exception_handler( - DNSNotImplementedError, - handle_not_implemented_error, - ) - + app.add_exception_handler(UnauthorizedError, handle_auth_error) return app diff --git a/pyproject.toml b/pyproject.toml index d50c41bf7..45573c966 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ dependencies = [ "dishka>=1.6.0", "dnspython>=2.7.0", "fastapi>=0.115.0", + "fastapi-error-map>=0.9.8", "gssapi>=1.9.0", "httpx>=0.28.1", "jinja2>=3.1.4", @@ -217,6 +218,7 @@ known-first-party = [ "schedule", "extra", "enums", + "errors", ] known-third-party = [ "alembic", # https://github.com/astral-sh/ruff/issues/10519 diff --git a/tests/test_api/test_auth/test_identity_provider.py b/tests/test_api/test_auth/test_identity_provider.py index 4fff16484..788da08e5 100644 --- a/tests/test_api/test_auth/test_identity_provider.py +++ b/tests/test_api/test_auth/test_identity_provider.py @@ -13,7 +13,7 @@ make_async_container, provide, ) -from fastapi import HTTPException, status +from fastapi import status from httpx import AsyncClient from starlette.requests import Request @@ -21,7 +21,7 @@ from config import Settings from ldap_protocol.dialogue import UserSchema from ldap_protocol.identity import IdentityProvider -from ldap_protocol.identity.exceptions import UnauthorizedError +from ldap_protocol.identity.exceptions import ErrorCodes, UnauthorizedError from ldap_protocol.identity.provider_gateway import IdentityProviderGateway from ldap_protocol.session_storage.base import SessionStorage from ldap_protocol.session_storage.exceptions import ( @@ -113,10 +113,7 @@ async def invalid_user_provider( ) as cont: provider = await cont.get(IdentityProvider) provider.get_user_id = AsyncMock( # type: ignore - side_effect=HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Could not validate credentials", - ), + side_effect=UnauthorizedError(ErrorCodes.UNAUTHORIZED_ERROR), ) yield provider diff --git a/tests/test_api/test_auth/test_router.py b/tests/test_api/test_auth/test_router.py index 30c852578..26c0e4523 100644 --- a/tests/test_api/test_auth/test_router.py +++ b/tests/test_api/test_auth/test_router.py @@ -495,7 +495,7 @@ async def test_auth_disabled_user( }, ) - assert response.status_code == 403 + assert response.status_code == 400 @pytest.mark.asyncio diff --git a/tests/test_api/test_auth/test_sessions.py b/tests/test_api/test_auth/test_sessions.py index a52692197..59b11208c 100644 --- a/tests/test_api/test_auth/test_sessions.py +++ b/tests/test_api/test_auth/test_sessions.py @@ -155,7 +155,7 @@ async def test_session_api_get( assert storage_data[k]["sign"] == data["sign"] response = await http_client.get(f"sessions/{creds.un}123") - assert response.status_code == 404 + assert response.status_code == 400 assert response.json()["detail"] == "User not found." @@ -175,7 +175,7 @@ async def test_session_api_delete( assert len(storage_data) == 1 response = await http_client.delete(f"sessions/{creds.un}123") - assert response.status_code == 404 + assert response.status_code == 400 response = await http_client.delete(f"sessions/{creds.un}") assert response.status_code == 204 diff --git a/tests/test_api/test_dhcp/test_router.py b/tests/test_api/test_dhcp/test_router.py index b912310c4..fbb739816 100644 --- a/tests/test_api/test_dhcp/test_router.py +++ b/tests/test_api/test_dhcp/test_router.py @@ -166,7 +166,7 @@ async def test_create_subnet_api_error( json=sample_subnet_data, ) - assert response.status_code == status.HTTP_409_CONFLICT + assert response.status_code == status.HTTP_400_BAD_REQUEST assert "Subnet already exists" in response.json()["detail"] @@ -235,7 +235,7 @@ async def test_update_subnet_not_found( json=sample_subnet_data, ) - assert response.status_code == status.HTTP_404_NOT_FOUND + assert response.status_code == status.HTTP_400_BAD_REQUEST @pytest.mark.asyncio @@ -262,7 +262,7 @@ async def test_delete_subnet_not_found( response = await http_client.delete("/dhcp/subnet/999") - assert response.status_code == status.HTTP_404_NOT_FOUND + assert response.status_code == status.HTTP_400_BAD_REQUEST @pytest.mark.asyncio @@ -316,7 +316,7 @@ async def test_create_lease_api_error( json=sample_lease_data, ) - assert response.status_code == status.HTTP_409_CONFLICT + assert response.status_code == status.HTTP_400_BAD_REQUEST assert "IP already in use" in response.json()["detail"] @@ -451,7 +451,7 @@ async def test_delete_lease_not_found( response = await http_client.delete("/dhcp/lease/192.168.1.128") - assert response.status_code == status.HTTP_404_NOT_FOUND + assert response.status_code == status.HTTP_400_BAD_REQUEST @pytest.mark.asyncio @@ -505,7 +505,7 @@ async def test_create_reservation_api_error( json=sample_reservation_data, ) - assert response.status_code == status.HTTP_409_CONFLICT + assert response.status_code == status.HTTP_400_BAD_REQUEST assert "IP already reserved" in response.json()["detail"] @@ -587,7 +587,7 @@ async def test_delete_reservation_not_found( }, ) - assert response.status_code == status.HTTP_404_NOT_FOUND + assert response.status_code == status.HTTP_400_BAD_REQUEST @pytest.mark.asyncio @@ -666,7 +666,7 @@ async def test_lease_to_reservation_not_found( json=[sample_reservation_data], ) - assert response.status_code == status.HTTP_404_NOT_FOUND + assert response.status_code == status.HTTP_400_BAD_REQUEST @pytest.mark.asyncio @@ -720,4 +720,4 @@ async def test_update_reservation_not_found( json=sample_reservation_data, ) - assert response.status_code == status.HTTP_404_NOT_FOUND + assert response.status_code == status.HTTP_400_BAD_REQUEST diff --git a/tests/test_api/test_ldap_schema/test_attribute_type_router.py b/tests/test_api/test_ldap_schema/test_attribute_type_router.py index 763c34514..bc9018948 100644 --- a/tests/test_api/test_ldap_schema/test_attribute_type_router.py +++ b/tests/test_api/test_ldap_schema/test_attribute_type_router.py @@ -73,7 +73,7 @@ async def test_create_attribute_type_conflict_when_already_exists( "/schema/attribute_type", json=schema.model_dump(), ) - assert response.status_code == status.HTTP_409_CONFLICT + assert response.status_code == status.HTTP_400_BAD_REQUEST @pytest.mark.asyncio @@ -110,7 +110,7 @@ async def test_modify_one_attribute_type_raise_404( "/schema/attribute_type/testAttributeType12345", json=schema.model_dump(), ) - assert response.status_code == status.HTTP_404_NOT_FOUND + assert response.status_code == status.HTTP_400_BAD_REQUEST @pytest.mark.parametrize( @@ -176,4 +176,4 @@ async def test_delete_bulk_attribute_types( response = await http_client.get( f"/schema/attribute_type/{attribute_type_name}", ) - assert response.status_code == status.HTTP_404_NOT_FOUND + assert response.status_code == status.HTTP_400_BAD_REQUEST diff --git a/tests/test_api/test_ldap_schema/test_attribute_type_router_datasets.py b/tests/test_api/test_ldap_schema/test_attribute_type_router_datasets.py index f4085e44e..bcfea7210 100644 --- a/tests/test_api/test_ldap_schema/test_attribute_type_router_datasets.py +++ b/tests/test_api/test_ldap_schema/test_attribute_type_router_datasets.py @@ -41,7 +41,7 @@ "no_user_modification": False, "is_included_anr": False, }, - "status_code": status.HTTP_404_NOT_FOUND, + "status_code": status.HTTP_400_BAD_REQUEST, }, { "attribute_type_name": "testAttributeType2", diff --git a/tests/test_api/test_ldap_schema/test_entity_type_router.py b/tests/test_api/test_ldap_schema/test_entity_type_router.py index c72d8c991..f95d4897b 100644 --- a/tests/test_api/test_ldap_schema/test_entity_type_router.py +++ b/tests/test_api/test_ldap_schema/test_entity_type_router.py @@ -60,7 +60,7 @@ async def test_create_one_entity_type_value_400( "is_system": False, }, ) - assert response.status_code == status.HTTP_404_NOT_FOUND + assert response.status_code == status.HTTP_400_BAD_REQUEST @pytest.mark.asyncio @@ -153,14 +153,14 @@ async def test_modify_entity_type_with_duplicate_data( f"/schema/entity_type/{update_entity}", json=update_data, ) - assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.status_code == status.HTTP_400_BAD_REQUEST update_entity, update_data = new_statements["duplicate_name"] response = await http_client.patch( f"/schema/entity_type/{update_entity}", json=update_data, ) - assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.status_code == status.HTTP_400_BAD_REQUEST @pytest.mark.parametrize( @@ -223,7 +223,7 @@ async def test_modify_primary_entity_type_name( "object_class_names": entity_type_data["object_class_names"], }, ) - assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.status_code == status.HTTP_400_BAD_REQUEST response = await http_client.get( f"/schema/entity_type/{entity_type_data['name']}", @@ -267,7 +267,7 @@ async def test_delete_bulk_entries( response = await http_client.get( f"/schema/entity_type/{entity_type_name}", ) - assert response.status_code == status.HTTP_404_NOT_FOUND + assert response.status_code == status.HTTP_400_BAD_REQUEST @pytest.mark.asyncio diff --git a/tests/test_api/test_ldap_schema/test_object_class_router.py b/tests/test_api/test_ldap_schema/test_object_class_router.py index e371fea40..30b6d915f 100644 --- a/tests/test_api/test_ldap_schema/test_object_class_router.py +++ b/tests/test_api/test_ldap_schema/test_object_class_router.py @@ -87,7 +87,7 @@ async def test_create_object_class_type_conflict_when_already_exists( "/schema/object_class", json=dataset["object_class"], ) - assert response.status_code == status.HTTP_409_CONFLICT + assert response.status_code == status.HTTP_400_BAD_REQUEST @pytest.mark.asyncio @@ -108,7 +108,7 @@ async def test_modify_system_object_class(http_client: AsyncClient) -> None: f"/schema/object_class/{object_class_name}", json=request_data.model_dump(), ) - assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.status_code == status.HTTP_400_BAD_REQUEST break else: pytest.fail("No system object class") @@ -203,7 +203,7 @@ async def test_delete_bulk_object_classes( response = await http_client.get( f"/schema/object_class/{object_class_name}", ) - assert response.status_code == status.HTTP_404_NOT_FOUND + assert response.status_code == status.HTTP_400_BAD_REQUEST @pytest.mark.parametrize( diff --git a/tests/test_api/test_main/test_kadmin.py b/tests/test_api/test_main/test_kadmin.py index 0bb9c1879..b5b06d096 100644 --- a/tests/test_api/test_main/test_kadmin.py +++ b/tests/test_api/test_main/test_kadmin.py @@ -125,7 +125,7 @@ async def test_tree_collision(http_client: AsyncClient) -> None: }, ) - assert response.status_code == status.HTTP_409_CONFLICT + assert response.status_code == status.HTTP_400_BAD_REQUEST @pytest.mark.asyncio @@ -228,21 +228,21 @@ async def test_ktadd( @pytest.mark.asyncio @pytest.mark.usefixtures("session") -async def test_ktadd_404( +async def test_ktadd_400( http_client: AsyncClient, kadmin: AbstractKadmin, ) -> None: """Test ktadd failure. - :param AsyncClient http_client: http cl - :param LDAPSession ldap_session: ldap + :param AsyncClient http_client: http client + :param AbstractKadmin kadmin: kadmin """ kadmin.ktadd.side_effect = KRBAPIPrincipalNotFoundError() # type: ignore names = ["test1", "test2"] response = await http_client.post("/kerberos/ktadd", json=names) - assert response.status_code == status.HTTP_404_NOT_FOUND + assert response.status_code == status.HTTP_400_BAD_REQUEST @pytest.mark.asyncio @@ -528,4 +528,4 @@ async def test_update_password( "old_password": "password", }, ) - assert response.status_code == status.HTTP_424_FAILED_DEPENDENCY + assert response.status_code == status.HTTP_400_BAD_REQUEST diff --git a/tests/test_api/test_main/test_router/test_login.py b/tests/test_api/test_main/test_router/test_login.py index 216e8448b..e83f1cb64 100644 --- a/tests/test_api/test_main/test_router/test_login.py +++ b/tests/test_api/test_main/test_router/test_login.py @@ -53,7 +53,7 @@ async def test_api_auth_after_change_account_exp( }, ) - assert auth.status_code == status.HTTP_403_FORBIDDEN + assert auth.status_code == status.HTTP_400_BAD_REQUEST await http_client.patch( "/entry/update", diff --git a/tests/test_api/test_network/test_router.py b/tests/test_api/test_network/test_router.py index 82471448f..9155ff65d 100644 --- a/tests/test_api/test_network/test_router.py +++ b/tests/test_api/test_network/test_router.py @@ -329,12 +329,12 @@ async def test_404(http_client: AsyncClient) -> None: response = await http_client.delete( f"/policy/{some_id}", ) - assert response.status_code == 404 + assert response.status_code == status.HTTP_400_BAD_REQUEST response = await http_client.patch( f"/policy/{some_id}", ) - assert response.status_code == 404 + assert response.status_code == status.HTTP_400_BAD_REQUEST response = await http_client.put( "/policy", @@ -343,7 +343,7 @@ async def test_404(http_client: AsyncClient) -> None: "name": "123", }, ) - assert response.status_code == 404 + assert response.status_code == status.HTTP_400_BAD_REQUEST @pytest.mark.asyncio diff --git a/tests/test_api/test_password_policy/test_password_policy_router.py b/tests/test_api/test_password_policy/test_password_policy_router.py index 1ea090026..0e3dbba8c 100644 --- a/tests/test_api/test_password_policy/test_password_policy_router.py +++ b/tests/test_api/test_password_policy/test_password_policy_router.py @@ -22,7 +22,7 @@ async def test_get_all_with_error( ) -> None: """Test get all Password Policy endpoint.""" response = await http_client_with_login_perm.get("/password-policy/all") - assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.status_code == status.HTTP_401_UNAUTHORIZED # NOTE to password_use_cases.get_all returned Mock, not wrapper password_use_cases._perm_checker = None # noqa: SLF001 @@ -50,7 +50,7 @@ async def test_get_with_error( ) -> None: """Test get one Password Policy endpoint.""" response = await http_client_with_login_perm.get("/password-policy/1") - assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.status_code == status.HTTP_401_UNAUTHORIZED # NOTE to password_use_cases.get_all returned Mock, not wrapper password_use_cases._perm_checker = None # noqa: SLF001 @@ -81,7 +81,7 @@ async def test_get_password_policy_by_dir_path_dn_with_error( response = await http_client_with_login_perm.get( f"/password-policy/by_dir_path_dn/{path}", ) - assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.status_code == status.HTTP_401_UNAUTHORIZED # NOTE to password_use_cases.get_all returned Mock, not wrapper password_use_cases._perm_checker = None # noqa: SLF001 @@ -136,7 +136,7 @@ async def test_update_with_error( "/password-policy/1", json=schema.model_dump(), ) - assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.status_code == status.HTTP_401_UNAUTHORIZED # NOTE to password_use_cases.get_all returned Mock, not wrapper password_use_cases._perm_checker = None # noqa: SLF001 @@ -152,7 +152,7 @@ async def test_reset_domain_policy_to_default_config_with_error( response = await http_client_with_login_perm.put( "/password-policy/reset/domain_policy", ) - assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.status_code == status.HTTP_401_UNAUTHORIZED # NOTE to password_use_cases.get_all returned Mock, not wrapper password_use_cases._perm_checker = None # noqa: SLF001 diff --git a/tests/test_api/test_shadow/test_router.py b/tests/test_api/test_shadow/test_router.py index 764404d42..815d19798 100644 --- a/tests/test_api/test_shadow/test_router.py +++ b/tests/test_api/test_shadow/test_router.py @@ -28,7 +28,7 @@ async def test_shadow_api_non_existent_user(http_client: AsyncClient) -> None: ).model_dump(), ) - assert response.status_code == status.HTTP_404_NOT_FOUND + assert response.status_code == status.HTTP_400_BAD_REQUEST @pytest.mark.asyncio @@ -46,7 +46,7 @@ async def test_shadow_api_without_network_policies( json=adding_mfa_user_and_group, ) - assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.status_code == status.HTTP_400_BAD_REQUEST @pytest.mark.asyncio @@ -66,7 +66,7 @@ async def test_shadow_api_without_kerberos_protocol( json=adding_mfa_user_and_group, ) - assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.status_code == status.HTTP_400_BAD_REQUEST @pytest.mark.asyncio diff --git a/uv.lock b/uv.lock index c0207e4fa..85838a5a8 100644 --- a/uv.lock +++ b/uv.lock @@ -304,6 +304,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/45/7c/97d033faf771c9fe960c7b51eb78ab266bfa64cbc917601978963f0c3c7b/fastapi-0.118.2-py3-none-any.whl", hash = "sha256:d1f842612e6a305f95abe784b7f8d3215477742e7c67a16fccd20bd79db68150", size = 97954, upload-time = "2025-10-08T14:52:16.166Z" }, ] +[[package]] +name = "fastapi-error-map" +version = "0.9.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fastapi" }, + { name = "orjson" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/84/9a/81aefff01594bfced5afdfb6c93de02a0f28fccc562f28c6bd721d7876a8/fastapi_error_map-0.9.8.tar.gz", hash = "sha256:894f6884598e4dd8b6c76cae59dee1522813ac3799ba0231b05465193c752f93", size = 386418, upload-time = "2025-11-02T01:58:06.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/07/850dc161f16d79ec86f61e90e1dda2bc95c70c462b2b3fb0e46455b1dc98/fastapi_error_map-0.9.8-py3-none-any.whl", hash = "sha256:1d54a5a40b4a7c8653266f0c3f1f3d6be4729e19fd6ec34c29addc85d3e27b58", size = 20462, upload-time = "2025-11-02T01:58:04.545Z" }, +] + [[package]] name = "fastapi-sqlalchemy-monitor" version = "1.1.3" @@ -532,6 +545,7 @@ dependencies = [ { name = "dishka" }, { name = "dnspython" }, { name = "fastapi" }, + { name = "fastapi-error-map" }, { name = "gssapi" }, { name = "httpx" }, { name = "jinja2" }, @@ -585,6 +599,7 @@ requires-dist = [ { name = "dishka", specifier = ">=1.6.0" }, { name = "dnspython", specifier = ">=2.7.0" }, { name = "fastapi", specifier = ">=0.115.0" }, + { name = "fastapi-error-map", specifier = ">=0.9.8" }, { name = "gssapi", specifier = ">=1.9.0" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "jinja2", specifier = ">=3.1.4" }, @@ -654,6 +669,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] +[[package]] +name = "orjson" +version = "3.11.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c6/fe/ed708782d6709cc60eb4c2d8a361a440661f74134675c72990f2c48c785f/orjson-3.11.4.tar.gz", hash = "sha256:39485f4ab4c9b30a3943cfe99e1a213c4776fb69e8abd68f66b83d5a0b0fdc6d", size = 5945188, upload-time = "2025-10-24T15:50:38.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/15/c52aa7112006b0f3d6180386c3a46ae057f932ab3425bc6f6ac50431cca1/orjson-3.11.4-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:2d6737d0e616a6e053c8b4acc9eccea6b6cce078533666f32d140e4f85002534", size = 243525, upload-time = "2025-10-24T15:49:29.737Z" }, + { url = "https://files.pythonhosted.org/packages/ec/38/05340734c33b933fd114f161f25a04e651b0c7c33ab95e9416ade5cb44b8/orjson-3.11.4-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:afb14052690aa328cc118a8e09f07c651d301a72e44920b887c519b313d892ff", size = 128871, upload-time = "2025-10-24T15:49:31.109Z" }, + { url = "https://files.pythonhosted.org/packages/55/b9/ae8d34899ff0c012039b5a7cb96a389b2476e917733294e498586b45472d/orjson-3.11.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38aa9e65c591febb1b0aed8da4d469eba239d434c218562df179885c94e1a3ad", size = 130055, upload-time = "2025-10-24T15:49:33.382Z" }, + { url = "https://files.pythonhosted.org/packages/33/aa/6346dd5073730451bee3681d901e3c337e7ec17342fb79659ec9794fc023/orjson-3.11.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f2cf4dfaf9163b0728d061bebc1e08631875c51cd30bf47cb9e3293bfbd7dcd5", size = 129061, upload-time = "2025-10-24T15:49:34.935Z" }, + { url = "https://files.pythonhosted.org/packages/39/e4/8eea51598f66a6c853c380979912d17ec510e8e66b280d968602e680b942/orjson-3.11.4-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:89216ff3dfdde0e4070932e126320a1752c9d9a758d6a32ec54b3b9334991a6a", size = 136541, upload-time = "2025-10-24T15:49:36.923Z" }, + { url = "https://files.pythonhosted.org/packages/9a/47/cb8c654fa9adcc60e99580e17c32b9e633290e6239a99efa6b885aba9dbc/orjson-3.11.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9daa26ca8e97fae0ce8aa5d80606ef8f7914e9b129b6b5df9104266f764ce436", size = 137535, upload-time = "2025-10-24T15:49:38.307Z" }, + { url = "https://files.pythonhosted.org/packages/43/92/04b8cc5c2b729f3437ee013ce14a60ab3d3001465d95c184758f19362f23/orjson-3.11.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c8b2769dc31883c44a9cd126560327767f848eb95f99c36c9932f51090bfce9", size = 136703, upload-time = "2025-10-24T15:49:40.795Z" }, + { url = "https://files.pythonhosted.org/packages/aa/fd/d0733fcb9086b8be4ebcfcda2d0312865d17d0d9884378b7cffb29d0763f/orjson-3.11.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1469d254b9884f984026bd9b0fa5bbab477a4bfe558bba6848086f6d43eb5e73", size = 136293, upload-time = "2025-10-24T15:49:42.347Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d7/3c5514e806837c210492d72ae30ccf050ce3f940f45bf085bab272699ef4/orjson-3.11.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:68e44722541983614e37117209a194e8c3ad07838ccb3127d96863c95ec7f1e0", size = 140131, upload-time = "2025-10-24T15:49:43.638Z" }, + { url = "https://files.pythonhosted.org/packages/9c/dd/ba9d32a53207babf65bd510ac4d0faaa818bd0df9a9c6f472fe7c254f2e3/orjson-3.11.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8e7805fda9672c12be2f22ae124dcd7b03928d6c197544fe12174b86553f3196", size = 406164, upload-time = "2025-10-24T15:49:45.498Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f9/f68ad68f4af7c7bde57cd514eaa2c785e500477a8bc8f834838eb696a685/orjson-3.11.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:04b69c14615fb4434ab867bf6f38b2d649f6f300af30a6705397e895f7aec67a", size = 149859, upload-time = "2025-10-24T15:49:46.981Z" }, + { url = "https://files.pythonhosted.org/packages/b6/d2/7f847761d0c26818395b3d6b21fb6bc2305d94612a35b0a30eae65a22728/orjson-3.11.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:639c3735b8ae7f970066930e58cf0ed39a852d417c24acd4a25fc0b3da3c39a6", size = 139926, upload-time = "2025-10-24T15:49:48.321Z" }, + { url = "https://files.pythonhosted.org/packages/9f/37/acd14b12dc62db9a0e1d12386271b8661faae270b22492580d5258808975/orjson-3.11.4-cp313-cp313-win32.whl", hash = "sha256:6c13879c0d2964335491463302a6ca5ad98105fc5db3565499dcb80b1b4bd839", size = 136007, upload-time = "2025-10-24T15:49:49.938Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a9/967be009ddf0a1fffd7a67de9c36656b28c763659ef91352acc02cbe364c/orjson-3.11.4-cp313-cp313-win_amd64.whl", hash = "sha256:09bf242a4af98732db9f9a1ec57ca2604848e16f132e3f72edfd3c5c96de009a", size = 131314, upload-time = "2025-10-24T15:49:51.248Z" }, + { url = "https://files.pythonhosted.org/packages/cb/db/399abd6950fbd94ce125cb8cd1a968def95174792e127b0642781e040ed4/orjson-3.11.4-cp313-cp313-win_arm64.whl", hash = "sha256:a85f0adf63319d6c1ba06fb0dbf997fced64a01179cf17939a6caca662bf92de", size = 126152, upload-time = "2025-10-24T15:49:52.922Z" }, +] + [[package]] name = "packaging" version = "25.0"