feat(admin): Audit-Logging schreibender Admin-Aktionen — Plan-Schritt 5

- app/audit.py: eigener Logger "va.audit" (eigener stdout-Handler -> Journal/Log-Tab).
  log_admin_action() protokolliert Aktion + Auslöser (SSO-Name oder admin-key).
- Verdrahtet in: config_set/config_reset (PUT/DELETE /admin/config),
  llm_backend_switch (+ _rejected), gateway_restart.
- Tests: caplog prüft Audit-Zeilen für config_set und abgelehnten Backend-Switch.
- Doku §7.5: Audit-Format im Log-Tab.

Schließt den Plan (Admin-gesteuerte LLM-/Gateway-Verwaltung, Schritte 1–5) ab. 167 grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-20 18:50:55 +02:00
commit 33d7189418
4 changed files with 119 additions and 7 deletions

View file

@ -12,6 +12,7 @@ from app.admin_llm import (
restart_gateway_detached,
switch_backend,
)
from app.audit import log_admin_action
from app.auth import is_admin_user, require_admin, require_admin_or_user
from app.config import settings
from app.dependencies import get_store
@ -207,18 +208,24 @@ class BackendSwitch(BaseModel):
@router.post("/admin/llm/backend", dependencies=[Depends(require_admin)])
async def post_llm_backend(payload: BackendSwitch):
async def post_llm_backend(payload: BackendSwitch, request: Request):
"""Wechselt das LLM-Backend (Allowlist-validiert, detached). Greift voll erst,
wenn das Gateway als systemd-Dienst läuft; sonst muss es manuell neu starten."""
try:
return await switch_backend(payload.backend, payload.model)
result = await switch_backend(payload.backend, payload.model)
except LlmControlError as exc:
log_admin_action(request, "llm_backend_switch_rejected",
backend=payload.backend, model=payload.model, error=str(exc))
raise HTTPException(status_code=422, detail=str(exc))
log_admin_action(request, "llm_backend_switch",
backend=payload.backend, model=payload.model)
return result
@router.post("/admin/gateway/restart", dependencies=[Depends(require_admin)])
async def post_gateway_restart():
async def post_gateway_restart(request: Request):
"""Startet das Gateway (systemd-User-Dienst) neu — losgelöst, Self-Restart-sicher."""
log_admin_action(request, "gateway_restart")
return restart_gateway_detached()
@ -317,17 +324,19 @@ async def get_runtime_config():
@router.put("/admin/config/{key}", dependencies=[Depends(require_admin)])
async def set_runtime_config(key: str, body: ConfigValue):
async def set_runtime_config(key: str, body: ConfigValue, request: Request):
"""Setzt eine Laufzeit-Einstellung (wirkt sofort, kein Neustart nötig)."""
if key not in RUNTIME_SETTABLE:
raise HTTPException(status_code=400, detail=f"Nicht überschreibbar: {key!r}")
get_store().set_config_override(key, body.value.strip())
value = body.value.strip()
get_store().set_config_override(key, value)
invalidate_cache()
return {"key": key, "value": body.value.strip()}
log_admin_action(request, "config_set", key=key, value=value)
return {"key": key, "value": value}
@router.delete("/admin/config/{key}", dependencies=[Depends(require_admin)])
async def delete_runtime_config(key: str):
async def delete_runtime_config(key: str, request: Request):
"""Entfernt eine Laufzeit-Einstellung (fällt auf .env-Wert zurück)."""
if key not in RUNTIME_SETTABLE:
raise HTTPException(status_code=400, detail=f"Nicht überschreibbar: {key!r}")
@ -335,6 +344,7 @@ async def delete_runtime_config(key: str):
invalidate_cache()
if not deleted:
raise HTTPException(status_code=404, detail=f"Kein Override für {key!r} gesetzt.")
log_admin_action(request, "config_reset", key=key)
return {"deleted": key}

44
app/audit.py Normal file
View file

@ -0,0 +1,44 @@
"""Strukturiertes Audit-Logging für Admin-Aktionen.
Schreibt eine Zeile pro schreibender Admin-Aktion (Backend-Wechsel, Config-Änderung,
Gateway-Neustart ) mit der Identität des Auslösers. Die Zeilen landen über stdout im
systemd-Journal und sind damit live im Admin-Log-Tab sichtbar.
"""
from __future__ import annotations
import logging
import sys
# Eigener Logger mit eigenem Handler -> unabhängig von der uvicorn-Logging-Config,
# erscheint zuverlässig auf stdout (= Journal im Dienst-Betrieb).
logger = logging.getLogger("va.audit")
if not logger.handlers:
_h = logging.StreamHandler(sys.stdout)
_h.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s"))
logger.addHandler(_h)
logger.setLevel(logging.INFO)
logger.propagate = False
def admin_identity(request) -> str:
"""Ermittelt, wer die Admin-Aktion ausführt (SSO-Username oder 'admin-key')."""
try:
from app.auth import authenticate, _bearer_token
client_host = request.client.host if request.client else ""
token = _bearer_token(request.headers.get("authorization"))
user = authenticate(request.headers, client_host, token)
if user is not None and getattr(user, "external_id", None):
return user.external_id
except Exception: # pragma: no cover - Auth-Fehler nie fatal fürs Logging
pass
if request.headers.get("x-admin-key"):
return "admin-key"
return "unknown"
def log_admin_action(request, action: str, **fields) -> None:
"""Loggt eine Admin-Aktion strukturiert: action, user + freie Felder."""
who = admin_identity(request)
extra = " ".join(f"{k}={v!r}" for k, v in fields.items() if v is not None)
logger.info("ADMIN action=%s user=%s %s", action, who, extra)