diff --git a/pyproject.toml b/pyproject.toml index 12b404c..d86c26a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,7 +42,7 @@ unsafe-fixes = true [tool.ruff.lint] select = ["ALL"] -ignore = ["INP001", "ARG001"] +ignore = ["INP001", "ARG001", "PLR0911"] [tool.pyright] include = ["src"] diff --git a/src/app/__main__.py b/src/app/__main__.py index 920cbaa..dc0a432 100644 --- a/src/app/__main__.py +++ b/src/app/__main__.py @@ -10,14 +10,20 @@ from fastapi import FastAPI, status from fastapi.staticfiles import StaticFiles from slowapi.errors import RateLimitExceeded +from sqlalchemy.exc import IntegrityError, SQLAlchemyError from app import api, frontend, shared from app.api import docs from app.container import Container +from app.monitoring.scheduler import WorkerSchedulerError from app.shared import config from app.shared.exc_handlers import ( + general_exception_handler, + integrity_error_handler, not_found_handler, rate_limit_exception_handler, + sqlalchemy_error_handler, + worker_scheduler_error_handler, ) from app.shared.log_filters import HealthCheckFilter @@ -66,6 +72,10 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[Any, Any]: app.add_exception_handler(status.HTTP_404_NOT_FOUND, not_found_handler) app.add_exception_handler(RateLimitExceeded, rate_limit_exception_handler) +app.add_exception_handler(IntegrityError, integrity_error_handler) +app.add_exception_handler(SQLAlchemyError, sqlalchemy_error_handler) +app.add_exception_handler(WorkerSchedulerError, worker_scheduler_error_handler) +app.add_exception_handler(Exception, general_exception_handler) api.middlewares.setup_middlewares(app) frontend.middlewares.setup_middlewares(app) diff --git a/src/app/api/middlewares/authentication.py b/src/app/api/middlewares/authentication.py index aa3e747..225c3f6 100644 --- a/src/app/api/middlewares/authentication.py +++ b/src/app/api/middlewares/authentication.py @@ -5,6 +5,7 @@ import logging from typing import TYPE_CHECKING, ClassVar +from fastapi import status from fastapi.responses import JSONResponse from app.shared import config @@ -45,7 +46,7 @@ async def dispatch( if not self._verify_token(request): return JSONResponse( content={"detail": "Unauthorized"}, - status_code=401, + status_code=status.HTTP_401_UNAUTHORIZED, ) return await call_next(request) diff --git a/src/app/api/models/monitor.py b/src/app/api/models/monitor.py index a0b8394..d7e782b 100644 --- a/src/app/api/models/monitor.py +++ b/src/app/api/models/monitor.py @@ -64,7 +64,7 @@ class MonitorRequest(BaseModel): @model_validator(mode="before") @classmethod def validate(cls, values: dict) -> dict: - """Validate HTTP endpoint.""" + """Validate monitor configuration.""" monitor_type = values.get("type") if monitor_type == MonitorType.HTTP: diff --git a/src/app/api/models/validators/http.py b/src/app/api/models/validators/http.py index 67102b1..805bb49 100644 --- a/src/app/api/models/validators/http.py +++ b/src/app/api/models/validators/http.py @@ -2,7 +2,10 @@ from urllib.parse import urlparse -from fastapi import HTTPException +from fastapi import HTTPException, status + +HTTP_ERROR_MIN = 100 +HTTP_ERROR_MAX = 599 def _validate_endpoint(endpoint: str) -> None: @@ -11,17 +14,20 @@ def _validate_endpoint(endpoint: str) -> None: parsed = urlparse(endpoint) if parsed.scheme not in ["http", "https"]: - raise HTTPException(status_code=400, detail="Invalid URL scheme") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid URL scheme", + ) if not parsed.netloc: raise HTTPException( - status_code=400, + status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid endpoint: missing domain", ) except ValueError as e: raise HTTPException( - status_code=400, + status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid endpoint URL format", ) from e @@ -31,13 +37,56 @@ def _validate_method(method: str) -> None: allowed_methods = ["GET", "POST", "PUT", "DELETE"] if method.upper() not in allowed_methods: raise HTTPException( - status_code=400, + status_code=status.HTTP_400_BAD_REQUEST, detail=( f"Invalid HTTP method. Allowed: {', '.join(allowed_methods)}", ), ) +def _validate_codes(values: dict) -> None: + """Validate HTTP codes.""" + expected_response_code = values.get("expected_response_code") + if expected_response_code is not None and not ( + HTTP_ERROR_MIN <= int(expected_response_code) <= HTTP_ERROR_MAX + ): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Expected response code must be between 100 and 599", + ) + + latency_threshold_ms = values.get("latency_threshold_ms") + if latency_threshold_ms is not None and int(latency_threshold_ms) <= 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Latency threshold must be greater than 0", + ) + + error_mapping = values.get("error_mapping") + if error_mapping: + invalid_codes = [] + + for code in error_mapping: + try: + code_int = int(code) + + except (TypeError, ValueError): + invalid_codes.append(code) + continue + + if code_int < HTTP_ERROR_MIN or code_int > HTTP_ERROR_MAX: + invalid_codes.append(code) + + if invalid_codes: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=( + "Error mapping keys must be HTTP status codes between" + f" 100 and 599; invalid: {invalid_codes}" + ), + ) + + def validate_http_monitor(values: dict) -> None: """Validate HTTP monitor configuration.""" endpoint = values.get("endpoint") @@ -45,15 +94,16 @@ def validate_http_monitor(values: dict) -> None: if not endpoint: raise HTTPException( - status_code=400, + status_code=status.HTTP_400_BAD_REQUEST, detail="Missing endpoint for HTTP monitor", ) if not method: raise HTTPException( - status_code=400, + status_code=status.HTTP_400_BAD_REQUEST, detail="Missing method for HTTP monitor", ) _validate_endpoint(endpoint) _validate_method(method) + _validate_codes(values) diff --git a/src/app/api/v1/admin/auth.py b/src/app/api/v1/admin/auth.py index d786c96..52375de 100644 --- a/src/app/api/v1/admin/auth.py +++ b/src/app/api/v1/admin/auth.py @@ -120,10 +120,10 @@ async def logout( ) -> LogoutResponse: """Admin logout.""" response.delete_cookie( - key="token", + key=config.cookie.key, httponly=True, - samesite="none" if config.app.is_production else "lax", - secure=config.app.is_production, + samesite="lax", + secure=config.app.https, ) return LogoutResponse( diff --git a/src/app/api/v1/admin/group.py b/src/app/api/v1/admin/group.py index 55e03f1..cd7fccc 100644 --- a/src/app/api/v1/admin/group.py +++ b/src/app/api/v1/admin/group.py @@ -130,6 +130,14 @@ async def create_group( ) -> MonitorsGroupResponse: """Create a specific group.""" async with uow_factory() as uow: + existing_group = await uow.groups.find_by_name(create_request.name) + + if existing_group: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Group with this name already exists", + ) + group = await uow.groups.save( MonitorGroupModel(name=create_request.name), ) @@ -178,6 +186,15 @@ async def update_group( detail="Group not found", ) + if group.name != update_request.name: + existing_group = await uow.groups.find_by_name(update_request.name) + + if existing_group: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Group with this name already exists", + ) + group.name = update_request.name group = await uow.groups.save(group) @@ -222,6 +239,14 @@ async def delete_group( detail="Group not found", ) + monitors_in_group = await uow.monitors.find_by_group_id(group_id) + + if monitors_in_group: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cannot delete group with assigned monitors", + ) + group.is_deleted = True await uow.groups.save(group) diff --git a/src/app/api/v1/admin/monitor.py b/src/app/api/v1/admin/monitor.py index 5388106..d4026fa 100644 --- a/src/app/api/v1/admin/monitor.py +++ b/src/app/api/v1/admin/monitor.py @@ -161,6 +161,14 @@ async def create_monitor( ) -> MonitorResponse: """Create a new monitor.""" async with uow_factory() as uow: + existing_monitor = await uow.monitors.find_by_name(create_request.name) + + if existing_monitor: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Monitor with this name already exists", + ) + if create_request.group_id: group = await uow.groups.find_by_id(create_request.group_id) if not group: @@ -225,6 +233,17 @@ async def update_monitor( detail="Monitor not found", ) + if monitor.name != update_request.name: + existing_monitor = await uow.monitors.find_by_name( + update_request.name, + ) + + if existing_monitor: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Monitor with this name already exists", + ) + if ( update_request.group_id and monitor.group_id != update_request.group_id diff --git a/src/app/frontend/middlewares/authentication.py b/src/app/frontend/middlewares/authentication.py index 3aa034b..9cb9030 100644 --- a/src/app/frontend/middlewares/authentication.py +++ b/src/app/frontend/middlewares/authentication.py @@ -5,6 +5,7 @@ import logging from typing import TYPE_CHECKING, ClassVar +from fastapi import status from fastapi.responses import RedirectResponse from app.shared import config @@ -50,7 +51,7 @@ async def dispatch( if not self._verify_token(request): return RedirectResponse( url=f"/{config.admin.safe_path}/login", - status_code=302, + status_code=status.HTTP_302_FOUND, ) return await call_next(request) diff --git a/src/app/monitoring/workers/http.py b/src/app/monitoring/workers/http.py index b25cae5..23539bd 100644 --- a/src/app/monitoring/workers/http.py +++ b/src/app/monitoring/workers/http.py @@ -1,5 +1,6 @@ """HTTP worker for endpoint monitoring.""" +import json import logging import httpx @@ -97,7 +98,11 @@ async def _execute_request(self, client: httpx.AsyncClient) -> Response: kwargs = {} if self._config.request_body: - kwargs["json"] = self._config.request_body + try: + kwargs["json"] = json.loads(self._config.request_body) + + except json.JSONDecodeError: + kwargs["data"] = self._config.request_body response = await client.request( method, diff --git a/src/app/repositories/group.py b/src/app/repositories/group.py index c1883d9..9880768 100644 --- a/src/app/repositories/group.py +++ b/src/app/repositories/group.py @@ -39,6 +39,16 @@ async def find_by_id( result = await self._session.execute(stmt) return result.scalar_one_or_none() + async def find_by_name(self, name: str) -> MonitorGroupModel | None: + """Find a group by name.""" + stmt = select(MonitorGroupModel).where( + MonitorGroupModel.name == name, + MonitorGroupModel.is_deleted == False, # noqa: E712 + ) + + result = await self._session.execute(stmt) + return result.scalar_one_or_none() + async def find_all(self) -> list[MonitorGroupModel]: """Find all monitors.""" result = await self._session.execute( diff --git a/src/app/repositories/monitor.py b/src/app/repositories/monitor.py index a33e8a4..05cce7d 100644 --- a/src/app/repositories/monitor.py +++ b/src/app/repositories/monitor.py @@ -39,6 +39,16 @@ async def find_by_id( result = await self._session.execute(stmt) return result.scalar_one_or_none() + async def find_by_name(self, name: str) -> MonitorModel | None: + """Find a monitor by name.""" + stmt = select(MonitorModel).where( + MonitorModel.name == name, + MonitorModel.is_deleted == False, # noqa: E712 + ) + + result = await self._session.execute(stmt) + return result.scalar_one_or_none() + async def find_by_group_id(self, group_id: UUID) -> list[MonitorModel]: """Find monitors by group ID.""" result = await self._session.execute( diff --git a/src/app/shared/exc_handlers.py b/src/app/shared/exc_handlers.py index 64a1279..fc215c3 100644 --- a/src/app/shared/exc_handlers.py +++ b/src/app/shared/exc_handlers.py @@ -1,7 +1,8 @@ -"""Frontend handlers.""" +"""Exception handlers.""" from __future__ import annotations +import logging from typing import TYPE_CHECKING from dependency_injector.wiring import Provide, inject @@ -9,12 +10,18 @@ from fastapi.exceptions import HTTPException from fastapi.responses import HTMLResponse, JSONResponse from slowapi.errors import RateLimitExceeded +from sqlalchemy.exc import IntegrityError from app.container import Container +from app.monitoring.scheduler import ( + UnsupportedMonitorTypeError, +) if TYPE_CHECKING: from fastapi.templating import Jinja2Templates +logger = logging.getLogger(__name__) + @inject async def not_found_handler( @@ -47,7 +54,7 @@ async def rate_limit_exception_handler( """Rate limit handler.""" if isinstance(exc, RateLimitExceeded): return JSONResponse( - status_code=429, + status_code=status.HTTP_429_TOO_MANY_REQUESTS, content={ "error": "Too many requests", "detail": f"Rate limit exceeded: {exc.detail}", @@ -55,6 +62,124 @@ async def rate_limit_exception_handler( ) return JSONResponse( - status_code=500, + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content={"error": "Internal server error"}, ) + + +async def integrity_error_handler( + request: Request, + exc: Exception, +) -> JSONResponse: + """Handle database integrity errors (unique constraints, foreign keys).""" + if not isinstance(exc, IntegrityError): + return JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content={ + "error": "Bad request", + "detail": "Database constraint violation", + }, + ) + + error_msg = str(exc.orig) if hasattr(exc, "orig") else str(exc) + + logger.warning( + "Database integrity error on %s: %s", + request.url.path, + error_msg, + ) + + if "unique" in error_msg.lower() or "duplicate" in error_msg.lower(): + return JSONResponse( + status_code=status.HTTP_409_CONFLICT, + content={ + "error": "Conflict", + "detail": "A resource with this name already exists", + }, + ) + + if "foreign key" in error_msg.lower(): + return JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content={ + "error": "Bad request", + "detail": "Referenced resource does not exist", + }, + ) + + return JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content={ + "error": "Bad request", + "detail": "Database constraint violation", + }, + ) + + +async def sqlalchemy_error_handler( + request: Request, + exc: Exception, +) -> JSONResponse: + """Handle general SQLAlchemy errors.""" + logger.exception( + "Database error on %s: %s", + request.url.path, + exc, + ) + + return JSONResponse( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + content={ + "error": "Service unavailable", + "detail": "Database error occurred", + }, + ) + + +async def worker_scheduler_error_handler( + request: Request, + exc: Exception, +) -> JSONResponse: + """Handle worker scheduler errors.""" + logger.exception( + "Worker scheduler error on %s: %s", + request.url.path, + exc, + ) + + if isinstance(exc, UnsupportedMonitorTypeError): + return JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content={ + "error": "Bad request", + "detail": f"Unsupported monitor type: {exc}", + }, + ) + + return JSONResponse( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + content={ + "error": "Service unavailable", + "detail": "Monitoring service error occurred", + }, + ) + + +async def general_exception_handler( + request: Request, + exc: Exception, +) -> JSONResponse: + """Handle unhandled exceptions.""" + logger.exception( + "Unhandled exception on %s: %s", + request.url.path, + exc, + ) + + return JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content={ + "error": "Internal server error", + "detail": "An unexpected error occurred", + }, + ) diff --git a/src/app/shared/jwt_utils.py b/src/app/shared/jwt_utils.py index 97f568d..7ae3140 100644 --- a/src/app/shared/jwt_utils.py +++ b/src/app/shared/jwt_utils.py @@ -43,4 +43,11 @@ def verify_auth_token(token: str) -> dict: token, config.jwt.secret, algorithms=[config.jwt.algorithm], + issuer=config.jwt.issuer, + options={ + "verify_iss": True, + "verify_exp": True, + "verify_nbf": True, + "require": ["exp", "iat", "iss", "sub"], + }, ) diff --git a/src/app/shared/middlewares/authentication.py b/src/app/shared/middlewares/authentication.py index eac0afd..958daf6 100644 --- a/src/app/shared/middlewares/authentication.py +++ b/src/app/shared/middlewares/authentication.py @@ -9,10 +9,13 @@ from jwt.exceptions import ( DecodeError, ExpiredSignatureError, + ImmatureSignatureError, + InvalidIssuerError, InvalidTokenError, ) from starlette.middleware.base import BaseHTTPMiddleware +from app.shared import config from app.shared.jwt_utils import verify_auth_token if TYPE_CHECKING: @@ -29,22 +32,42 @@ def __init__(self, app: ASGIApp) -> None: """Initialize authentication middleware.""" super().__init__(app) - def _verify_token(self, request: Request) -> bool: + def _verify_token( + self, + request: Request, + ) -> bool: """Verify and extract token data.""" - token = request.cookies.get("token") + token = request.cookies.get(config.cookie.key) + if not token: logger.debug("Missing token for %s", request.url.path) return False try: data = verify_auth_token(token) - except (ExpiredSignatureError, DecodeError, InvalidTokenError) as e: + + except ExpiredSignatureError: + logger.debug("Token expired for path: %s", request.url.path) + return False + + except InvalidIssuerError: + logger.debug("Invalid token issuer for path: %s", request.url.path) + return False + + except ImmatureSignatureError: + logger.debug("Token not yet valid for path: %s", request.url.path) + return False + + except (DecodeError, InvalidTokenError) as e: logger.debug("Token validation failed: %s", e) return False sub = data.get("sub") if not sub or not isinstance(sub, str): - logger.debug("Invalid or missing user_id in token for path: %s") + logger.debug( + "Invalid or missing 'sub' claim in token for path: %s", + request.url.path, + ) return False return True