diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index 78f03a6..c78be73 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -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 diff --git a/app/api/admin.py b/app/api/admin.py index 5dfd3fc..2aa500e 100644 --- a/app/api/admin.py +++ b/app/api/admin.py @@ -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} diff --git a/app/audit.py b/app/audit.py new file mode 100644 index 0000000..fa361ce --- /dev/null +++ b/app/audit.py @@ -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) diff --git a/tests/test_audit.py b/tests/test_audit.py new file mode 100644 index 0000000..06abb0a --- /dev/null +++ b/tests/test_audit.py @@ -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)