From 412a84150e5671a30b7e69138c9d80f094c9d6ca Mon Sep 17 00:00:00 2001 From: Allan Eising Date: Tue, 15 Jul 2025 13:24:50 +0200 Subject: [PATCH] Stats and error handling --- .../sshecret_admin/api/endpoints/clients.py | 9 +- .../src/sshecret_admin/core/app.py | 24 +++- .../sshecret_admin/services/admin_backend.py | 27 ++++- .../src/sshecret_admin/services/models.py | 24 ++-- .../sshecret_admin/services/secret_manager.py | 13 ++- .../api/clients/operations.py | 2 +- .../sshecret_backend/api/clients/router.py | 9 ++ .../src/sshecret_backend/api/common.py | 26 ++++- .../src/sshecret_backend/api/schemas.py | 8 ++ .../api/secrets/operations.py | 1 + src/sshecret/backend/api.py | 22 +++- src/sshecret/backend/exceptions.py | 103 ++++++++++++++++++ src/sshecret/backend/models.py | 7 ++ 13 files changed, 247 insertions(+), 28 deletions(-) diff --git a/packages/sshecret-admin/src/sshecret_admin/api/endpoints/clients.py b/packages/sshecret-admin/src/sshecret_admin/api/endpoints/clients.py index e202dda..a9c25bd 100644 --- a/packages/sshecret-admin/src/sshecret_admin/api/endpoints/clients.py +++ b/packages/sshecret-admin/src/sshecret_admin/api/endpoints/clients.py @@ -18,7 +18,7 @@ from sshecret_admin.services.models import ( ) from sshecret.backend.identifiers import ClientIdParam, FlexID, KeySpec -from sshecret.backend.models import ClientQueryResult, ClientReference, FilterType +from sshecret.backend.models import ClientQueryResult, ClientReference, FilterType, SystemStats LOG = logging.getLogger(__name__) @@ -210,4 +210,11 @@ def create_router(dependencies: AdminDependencies) -> APIRouter: """Add secret to a client.""" await admin.create_client_secret(_id(id), secret_name) + @app.get("/stats") + async def get_system_stats( + admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)], + ) -> SystemStats: + """Get system stats.""" + return await admin.get_system_stats() + return app diff --git a/packages/sshecret-admin/src/sshecret_admin/core/app.py b/packages/sshecret-admin/src/sshecret_admin/core/app.py index a662648..35854d3 100644 --- a/packages/sshecret-admin/src/sshecret_admin/core/app.py +++ b/packages/sshecret-admin/src/sshecret_admin/core/app.py @@ -25,6 +25,8 @@ from sshecret_admin.core.db import setup_database from sshecret_admin.frontend.exceptions import RedirectException from sshecret_admin.services.secret_manager import setup_private_key +from sshecret.backend.exceptions import BackendError, BackendValidationError + from .dependencies import BaseDependencies from .settings import AdminServerSettings @@ -72,13 +74,13 @@ def create_admin_app( app = FastAPI(lifespan=lifespan) app.add_middleware(SessionMiddleware, secret_key=settings.secret_key) - origins = [ settings.frontend_origin ] + origins = [settings.frontend_origin] app.add_middleware( CORSMiddleware, allow_origins=origins, allow_credentials=True, allow_methods=["*"], - allow_headers=["*"] + allow_headers=["*"], ) @app.exception_handler(RequestValidationError) @@ -90,6 +92,24 @@ def create_admin_app( content=jsonable_encoder({"detail": exc.errors(), "body": exc.body}), ) + @app.exception_handler(BackendValidationError) + async def validation_backend_validation_exception_handler( + request: Request, exc: BackendValidationError + ): + return JSONResponse( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + content=jsonable_encoder({"detail": exc.errors()}), + ) + + @app.exception_handler(BackendError) + async def validation_backend_exception_handler( + request: Request, exc: BackendValidationError + ): + return JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content=jsonable_encoder({"detail": [str(exc)]}), + ) + @app.exception_handler(RedirectException) async def redirect_handler(request: Request, exc: RedirectException) -> Response: """Handle redirect exceptions.""" diff --git a/packages/sshecret-admin/src/sshecret_admin/services/admin_backend.py b/packages/sshecret-admin/src/sshecret_admin/services/admin_backend.py index 0da22f2..6213f55 100644 --- a/packages/sshecret-admin/src/sshecret_admin/services/admin_backend.py +++ b/packages/sshecret-admin/src/sshecret_admin/services/admin_backend.py @@ -17,8 +17,14 @@ from sshecret.backend import ( Operation, SubSystem, ) +from sshecret.backend.exceptions import BackendError, BackendValidationError from sshecret.backend.identifiers import KeySpec -from sshecret.backend.models import ClientQueryResult, ClientReference, DetailedSecrets +from sshecret.backend.models import ( + ClientQueryResult, + ClientReference, + DetailedSecrets, + SystemStats, +) from sshecret.backend.api import AuditAPI from sshecret.crypto import encrypt_string, load_public_key @@ -209,7 +215,14 @@ class AdminBackend: return await self._create_client(name, public_key, description, sources) except ClientManagementError: raise + except BackendValidationError as e: + LOG.error("Validation error: %s", e, exc_info=True) + raise e + + except BackendError: + raise except Exception as e: + LOG.error("Exception: %s", e, exc_info=True) raise BackendUnavailableError() from e async def _update_client_public_key( @@ -518,9 +531,9 @@ class AdminBackend: secret: str | None = None async with self.secrets_manager() as password_manager: secret = await password_manager.get_secret(name) - secret_group: str | None = None + secret_group: GroupReference | None = None if secret: - secret_group = await password_manager.get_entry_group(name) + secret_group = await password_manager.get_entry_group_info(name) secret_view = SecretView(name=name, secret=secret, group=secret_group) @@ -535,6 +548,10 @@ class AdminBackend: for ref in secret_mapping.clients ] + if not secret_mapping and not secret_group: + # This secret is effectively deleted. + return None + return secret_view async def delete_secret(self, name: str) -> None: @@ -653,6 +670,10 @@ class AdminBackend: except Exception as e: raise BackendUnavailableError() from e + async def get_system_stats(self) -> SystemStats: + """Get system stats.""" + return await self.backend.get_system_stats() + @property def audit(self) -> AuditAPI: """Resolve audit API.""" diff --git a/packages/sshecret-admin/src/sshecret_admin/services/models.py b/packages/sshecret-admin/src/sshecret_admin/services/models.py index 9329f3e..9c04c6d 100644 --- a/packages/sshecret-admin/src/sshecret_admin/services/models.py +++ b/packages/sshecret-admin/src/sshecret_admin/services/models.py @@ -32,12 +32,23 @@ class SecretListView(BaseModel): clients: list[str] = Field(default_factory=list) # Clients that have access to it. +class GroupReference(BaseModel): + """Reference to a group. + + This will be used for references to parent groups to avoid circular + references. + """ + + group_name: str + path: str + + class SecretView(BaseModel): """Model containing a secret, including its clear-text value.""" name: str secret: str | None - group: str | None = None + group: GroupReference | None = None clients: list[ClientReference] = Field( default_factory=list ) # Clients that have access to it. @@ -147,17 +158,6 @@ class SecretClientMapping(BaseModel): clients: list[ClientReference] = Field(default_factory=list) -class GroupReference(BaseModel): - """Reference to a group. - - This will be used for references to parent groups to avoid circular - references. - """ - - group_name: str - path: str - - class ClientSecretGroup(BaseModel): """Client secrets grouped.""" diff --git a/packages/sshecret-admin/src/sshecret_admin/services/secret_manager.py b/packages/sshecret-admin/src/sshecret_admin/services/secret_manager.py index 6c73803..d3f0960 100644 --- a/packages/sshecret-admin/src/sshecret_admin/services/secret_manager.py +++ b/packages/sshecret-admin/src/sshecret_admin/services/secret_manager.py @@ -32,7 +32,7 @@ from sshecret_admin.auth import PasswordDB from sshecret_admin.auth.models import Group, ManagedSecret from sshecret_admin.core.db import DatabaseSessionManager from sshecret_admin.core.settings import AdminServerSettings -from sshecret_admin.services.models import SecretGroup +from sshecret_admin.services.models import GroupReference, SecretGroup KEY_FILENAME = "sshecret-admin-key" @@ -430,6 +430,17 @@ class AsyncSecretContext: return entry.group.name return None + async def get_entry_group_info(self, entry_name: str) -> GroupReference | None: + """Get group of entry, with path.""" + entry = await self._get_entry(entry_name) + if not entry: + raise InvalidSecretNameError("Invalid secret name or secret not found.") + if not entry.group: + return None + group = await self._get_group_by_id(entry.group.id) + group_tree = await self._build_group_tree(group) + return GroupReference(group_name=entry.group.name, path=group_tree.path) + async def _get_groups( self, pattern: str | None = None, regex: bool = True, root_groups: bool = False ) -> list[Group]: diff --git a/packages/sshecret-backend/src/sshecret_backend/api/clients/operations.py b/packages/sshecret-backend/src/sshecret_backend/api/clients/operations.py index 083a9ef..96f21f0 100644 --- a/packages/sshecret-backend/src/sshecret_backend/api/clients/operations.py +++ b/packages/sshecret-backend/src/sshecret_backend/api/clients/operations.py @@ -124,7 +124,7 @@ class ClientOperations: existing_id = await self.get_client_id(FlexID.name(create_model.name)) if existing_id: raise HTTPException( - status_code=400, detail="Error: A client already exists with this name." + status_code=400, detail="A client already exists with this name.", headers={"X-Model-Field": "name"} ) deleted_id = await resolve_client_id( self.session, create_model.name, include_deleted=True diff --git a/packages/sshecret-backend/src/sshecret_backend/api/clients/router.py b/packages/sshecret-backend/src/sshecret_backend/api/clients/router.py index d986829..4e59e84 100644 --- a/packages/sshecret-backend/src/sshecret_backend/api/clients/router.py +++ b/packages/sshecret-backend/src/sshecret_backend/api/clients/router.py @@ -8,6 +8,8 @@ from typing import Annotated from fastapi import APIRouter, Depends, Query, Request from sqlalchemy.ext.asyncio import AsyncSession +from sshecret_backend.api.common import get_system_stats +from sshecret_backend.api.schemas import SystemStats from sshecret_backend.types import AsyncDBSessionDep from sshecret_backend.api.clients.schemas import ( ClientCreate, @@ -149,4 +151,11 @@ def create_client_router(get_db_session: AsyncDBSessionDep) -> APIRouter: client_op = ClientOperations(session, request) return await client_op.update_client_policies(client_id, policy_update) + @router.get("/stats") + async def get_stats( + session: Annotated[AsyncSession, Depends(get_db_session)], + ) -> SystemStats: + """Get system stats.""" + return await get_system_stats(session) + return router diff --git a/packages/sshecret-backend/src/sshecret_backend/api/common.py b/packages/sshecret-backend/src/sshecret_backend/api/common.py index 8070d92..228cd60 100644 --- a/packages/sshecret-backend/src/sshecret_backend/api/common.py +++ b/packages/sshecret-backend/src/sshecret_backend/api/common.py @@ -6,11 +6,12 @@ import uuid from dataclasses import dataclass, field import bcrypt -from sqlalchemy import Select +from sqlalchemy import Select, distinct, func from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select from sqlalchemy.orm import selectinload -from sshecret_backend.models import Client, ClientAccessPolicy +from sshecret_backend.api.schemas import SystemStats +from sshecret_backend.models import AuditLog, Client, ClientAccessPolicy, ClientSecret LOG = logging.getLogger(__name__) RE_UUID = re.compile( @@ -137,3 +138,24 @@ async def create_new_client_version( await session.flush() await refresh_client(session, new_client) return new_client + + +async def get_system_stats(session: AsyncSession) -> SystemStats: + """Get system stats.""" + client_count_expr = ( + select(func.count("*")) + .select_from(Client) + .where(Client.is_deleted.is_not(True)) + .where(Client.is_active.is_not(False)) + .where(Client.is_system.is_not(True)) + ) + client_count = (await session.scalars(client_count_expr)).one() + secret_count_expr = select(func.count(distinct(ClientSecret.name))).where( + ClientSecret.deleted.is_not(True) + ) + secret_count = (await session.scalars(secret_count_expr)).one() + audit_count_expr = select(func.count("*")).select_from(AuditLog) + audit_count = (await session.scalars(audit_count_expr)).one() + return SystemStats( + clients=client_count, secrets=secret_count, audit_events=audit_count + ) diff --git a/packages/sshecret-backend/src/sshecret_backend/api/schemas.py b/packages/sshecret-backend/src/sshecret_backend/api/schemas.py index 201ac47..1be313a 100644 --- a/packages/sshecret-backend/src/sshecret_backend/api/schemas.py +++ b/packages/sshecret-backend/src/sshecret_backend/api/schemas.py @@ -7,3 +7,11 @@ class BodyValue(BaseModel): """A generic model with just a value parameter.""" value: str + + +class SystemStats(BaseModel): + """Generic system stats.""" + + clients: int + secrets: int + audit_events: int diff --git a/packages/sshecret-backend/src/sshecret_backend/api/secrets/operations.py b/packages/sshecret-backend/src/sshecret_backend/api/secrets/operations.py index fd68a29..c70a316 100644 --- a/packages/sshecret-backend/src/sshecret_backend/api/secrets/operations.py +++ b/packages/sshecret-backend/src/sshecret_backend/api/secrets/operations.py @@ -146,6 +146,7 @@ class ClientSecretOperations: raise HTTPException( status_code=400, detail="Cannot add a secret. A different secret with the same name already exists.", + headers={"X-Model-Field": "name"}, ) secret = ClientSecret( name=name, diff --git a/src/sshecret/backend/api.py b/src/sshecret/backend/api.py index 030da46..145c397 100644 --- a/src/sshecret/backend/api.py +++ b/src/sshecret/backend/api.py @@ -24,8 +24,9 @@ from .models import ( Operation, Secret, SubSystem, + SystemStats, ) -from .exceptions import BackendValidationError, BackendConnectionError +from .exceptions import BackendError, BackendValidationError, BackendConnectionError, HttpErrorItem, get_error_message, handle_exception, handle_validation_error from .identifiers import KeySpec from .utils import validate_public_key @@ -135,9 +136,10 @@ class BaseBackend: LOG.debug("Handling response with status_code %s", response.status_code) if response.status_code == 422: LOG.error("Validation error from backend:\n%s", response.text) - raise BackendValidationError(response.text) + error = handle_validation_error(response) + raise error if response.status_code != 404 and str(response.status_code).startswith("4"): - raise BackendConnectionError("Error from backend: %s", response.text) + handle_exception(response) return response @@ -315,7 +317,8 @@ class SshecretBackend(BaseBackend): ) -> None: """Register a new client.""" if not validate_public_key(public_key): - raise BackendValidationError("Error: Invalid public key format.") + error: HttpErrorItem = { "loc": [ "body", "public_key" ], "msg": "Invalid public key format", "type": "None" } + raise BackendValidationError(errors=[error]) data = { "name": name, "public_key": public_key, @@ -328,7 +331,8 @@ class SshecretBackend(BaseBackend): async def create_system_client(self, name: str, public_key: str) -> Client: """Create system client.""" if not validate_public_key(public_key): - raise BackendValidationError("Error: Invalid public key format.") + error: HttpErrorItem = { "loc": [ "body", "public_key" ], "msg": "Invalid public key format", "type": "None" } + raise BackendValidationError(errors=[error]) data = { "name": name, @@ -387,7 +391,7 @@ class SshecretBackend(BaseBackend): raise BackendConnectionError() from e if results.status_code != 200: output = results.text - raise BackendConnectionError(f"Error from backend:\n{output}") + raise BackendError(f"Error from backend:\n{output}") query_results = ClientQueryResult.model_validate(results.json()) return query_results @@ -522,6 +526,12 @@ class SshecretBackend(BaseBackend): return DetailedSecrets.model_validate(response.json()) + async def get_system_stats(self) -> SystemStats: + """Get system stats.""" + path = "/api/v1/stats" + response = await self._get(path) + return SystemStats.model_validate(response.json()) + def audit(self, subsystem: SubSystem) -> AuditAPI: """Create the audit API.""" audit = AuditAPI(self._backend_url, self._api_token, subsystem) diff --git a/src/sshecret/backend/exceptions.py b/src/sshecret/backend/exceptions.py index f2e8691..2d3a1e5 100644 --- a/src/sshecret/backend/exceptions.py +++ b/src/sshecret/backend/exceptions.py @@ -1,8 +1,111 @@ """Exceptions.""" +import logging +from collections.abc import Sequence +from typing import Any, TypeGuard, TypedDict + +from pydantic_core import ErrorDetails + +from httpx import Response + +LOG = logging.getLogger(__name__) + + +class BackendError(Exception): + """Generic backend error.""" + + +class HttpErrorItem(TypedDict): + """Interface for the 'item' type of the http error class.""" + + loc: Sequence[str | int] + msg: str + type: str + + +class HttpError(TypedDict): + """Interface of the http error class.""" + + detail: Sequence[HttpErrorItem] + + class BackendValidationError(Exception): """Validation error.""" + def __init__(self, errors: Sequence[HttpErrorItem]) -> None: + """Backend validation error. + + Modelled after fastapi's RequestValidationException. + """ + self._errors: Sequence[HttpErrorItem] = errors + + def errors(self) -> Sequence[HttpErrorItem]: + """Get errors.""" + return self._errors + class BackendConnectionError(Exception): """Could not connect to backend server.""" + + +def is_error_details(instance: Any) -> TypeGuard[ErrorDetails]: + """Confirm that something is an error detail.""" + if "type" in instance and "loc" in instance: + return True + return False + + +def is_http_error(instance: Any) -> TypeGuard[HttpError]: + """Check if object is a http error.""" + if not isinstance(instance, dict): + return False + details = instance.get("detail") + if not isinstance(details, list): + return False + if all([is_error_details(detail) for detail in details]): + return True + return False + + +def handle_validation_error( + response: Response, +) -> BackendValidationError | BackendError: + """Convert a 422 response into a backend validation error.""" + try: + body = response.json() + if not (is_http_error(body)): + LOG.error("Unknown validation error type: %s", response.text) + return BackendError("Unhandled validation error from backend.") + return BackendValidationError(body["detail"]) + + except: + LOG.error("Unknown validation error type: %s", response.text) + return BackendError("Unhandled validation error from backend.") + + +def get_error_message(response: Response) -> str: + """Get error message.""" + error_message = "Unknown error" + if ( + content_type := response.headers.get("Content-Type") + ) and content_type == "application/json": + body = response.json() + if detail := body.get("detail"): + error_message = str(detail) + elif response.text: + error_message = response.text + + return error_message + + +def handle_exception(response: Response) -> None: + """Handle exceptions.""" + error_message = get_error_message(response) + if error_field := response.headers.get("X-Model-Field"): + field_error: HttpErrorItem = { + "loc": ["body", str(error_field)], + "msg": error_message, + "type": "None", + } + raise BackendValidationError(errors=[field_error]) + raise BackendError(error_message) diff --git a/src/sshecret/backend/models.py b/src/sshecret/backend/models.py index c5a037c..a84ebec 100644 --- a/src/sshecret/backend/models.py +++ b/src/sshecret/backend/models.py @@ -181,3 +181,10 @@ class AuditListResult(BaseModel): results: list[AuditLog] total: int remaining: int + +class SystemStats(BaseModel): + """Generic system stats.""" + + clients: int + secrets: int + audit_events: int