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:
parent
b2576fd465
commit
33d7189418
4 changed files with 119 additions and 7 deletions
|
|
@ -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.
|
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
|
## 8. Gedächtnis und Erinnerungen
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ from app.admin_llm import (
|
||||||
restart_gateway_detached,
|
restart_gateway_detached,
|
||||||
switch_backend,
|
switch_backend,
|
||||||
)
|
)
|
||||||
|
from app.audit import log_admin_action
|
||||||
from app.auth import is_admin_user, require_admin, require_admin_or_user
|
from app.auth import is_admin_user, require_admin, require_admin_or_user
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.dependencies import get_store
|
from app.dependencies import get_store
|
||||||
|
|
@ -207,18 +208,24 @@ class BackendSwitch(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
@router.post("/admin/llm/backend", dependencies=[Depends(require_admin)])
|
@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,
|
"""Wechselt das LLM-Backend (Allowlist-validiert, detached). Greift voll erst,
|
||||||
wenn das Gateway als systemd-Dienst läuft; sonst muss es manuell neu starten."""
|
wenn das Gateway als systemd-Dienst läuft; sonst muss es manuell neu starten."""
|
||||||
try:
|
try:
|
||||||
return await switch_backend(payload.backend, payload.model)
|
result = await switch_backend(payload.backend, payload.model)
|
||||||
except LlmControlError as exc:
|
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))
|
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)])
|
@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."""
|
"""Startet das Gateway (systemd-User-Dienst) neu — losgelöst, Self-Restart-sicher."""
|
||||||
|
log_admin_action(request, "gateway_restart")
|
||||||
return restart_gateway_detached()
|
return restart_gateway_detached()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -317,17 +324,19 @@ async def get_runtime_config():
|
||||||
|
|
||||||
|
|
||||||
@router.put("/admin/config/{key}", dependencies=[Depends(require_admin)])
|
@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)."""
|
"""Setzt eine Laufzeit-Einstellung (wirkt sofort, kein Neustart nötig)."""
|
||||||
if key not in RUNTIME_SETTABLE:
|
if key not in RUNTIME_SETTABLE:
|
||||||
raise HTTPException(status_code=400, detail=f"Nicht überschreibbar: {key!r}")
|
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()
|
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)])
|
@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)."""
|
"""Entfernt eine Laufzeit-Einstellung (fällt auf .env-Wert zurück)."""
|
||||||
if key not in RUNTIME_SETTABLE:
|
if key not in RUNTIME_SETTABLE:
|
||||||
raise HTTPException(status_code=400, detail=f"Nicht überschreibbar: {key!r}")
|
raise HTTPException(status_code=400, detail=f"Nicht überschreibbar: {key!r}")
|
||||||
|
|
@ -335,6 +344,7 @@ async def delete_runtime_config(key: str):
|
||||||
invalidate_cache()
|
invalidate_cache()
|
||||||
if not deleted:
|
if not deleted:
|
||||||
raise HTTPException(status_code=404, detail=f"Kein Override für {key!r} gesetzt.")
|
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}
|
return {"deleted": key}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
44
app/audit.py
Normal file
44
app/audit.py
Normal 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
44
tests/test_audit.py
Normal 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)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue