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}