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

@ -1805,6 +1805,20 @@ Zeigt den systemd-Journal-Log des `voice-assistant.service` live im Browser:
Der Log hilft, Fehler zu diagnostizieren ohne SSH-Zugang.
**Audit:** Schreibende Admin-Aktionen werden mit Auslöser protokolliert und erscheinen
hier live, z. B.:
```
ADMIN action=config_set user=dschlueter key='local_llm_top_p' value='0.7'
ADMIN action=llm_backend_switch user=dschlueter backend='ollama' model='gemma3:latest'
ADMIN action=gateway_restart user=admin-key
```
Protokolliert werden u. a. `config_set` / `config_reset` (Laufzeit-Einstellungen),
`llm_backend_switch` (+ `_rejected` bei Allowlist-Verstoß) und `gateway_restart`.
`user` ist der SSO-Name bzw. `admin-key` bei Zugriff per `ADMIN_API_KEY`.
> Sichtbar im Log-Tab nur im **Dienst-Betrieb** (Journal). Im Vordergrund-Betrieb
> (`make run`) erscheinen die Audit-Zeilen im Terminal.
---
## 8. Gedächtnis und Erinnerungen

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)

44
tests/test_audit.py Normal file
View file

@ -0,0 +1,44 @@
"""Tests für das Audit-Logging der Admin-Aktionen."""
import logging
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.config import settings
from app.runtime_config import invalidate_cache
client = TestClient(app)
ADMIN = "test-admin-key"
ADM_HDR = {"X-Admin-Key": ADMIN}
@pytest.fixture(autouse=True)
def _setup(monkeypatch):
monkeypatch.setattr(settings, "admin_api_key", ADMIN)
monkeypatch.setattr(settings, "auth_enabled", False)
invalidate_cache()
yield
invalidate_cache()
def test_config_set_is_audited(caplog):
with caplog.at_level(logging.INFO, logger="va.audit"):
r = client.put("/api/admin/config/local_llm_top_p",
json={"value": "0.7"}, headers=ADM_HDR)
assert r.status_code == 200
line = "\n".join(caplog.messages)
assert "action=config_set" in line
assert "local_llm_top_p" in line
assert "user=admin-key" in line
# aufräumen
client.delete("/api/admin/config/local_llm_top_p", headers=ADM_HDR)
def test_backend_switch_rejection_is_audited(caplog):
with caplog.at_level(logging.INFO, logger="va.audit"):
r = client.post("/api/admin/llm/backend",
json={"backend": "boese"}, headers=ADM_HDR)
assert r.status_code == 422
assert "action=llm_backend_switch_rejected" in "\n".join(caplog.messages)