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

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,