Stats and error handling

This commit is contained in:
2025-07-15 13:24:50 +02:00
parent 6a5149fd4c
commit 412a84150e
13 changed files with 247 additions and 28 deletions

View File

@ -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

View File

@ -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
@ -78,7 +80,7 @@ def create_admin_app(
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."""

View File

@ -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."""

View File

@ -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."""

View File

@ -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]:

View File

@ -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

View File

@ -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

View File

@ -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
)

View File

@ -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

View File

@ -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,

View File

@ -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)

View File

@ -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)

View File

@ -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