| Zeit | Methode | Endpoint | Status | Dry-run | Detail |
|---|---|---|---|---|---|
| Wird geladen … | |||||
diff --git a/app.py b/app.py index bac1a05..5171de7 100644 --- a/app.py +++ b/app.py @@ -9,6 +9,7 @@ from pydantic import BaseModel, Field from fastapi import FastAPI, Request, HTTPException, Depends, Query from fastapi.responses import JSONResponse, RedirectResponse, Response, HTMLResponse from contextlib import asynccontextmanager +from contextvars import ContextVar log = logging.getLogger("butler") VERSION = "2.3.5" @@ -46,6 +47,7 @@ _load_config() # --- Audit log --- _audit_log: list[dict] = [] +_audit_actor: ContextVar[str] = ContextVar("audit_actor", default="System/API") MAX_AUDIT = 500 AUDIT_DB_PATH = os.environ.get("AUDIT_DB_PATH", "/data/state/audit.sqlite3") @@ -69,8 +71,12 @@ def _init_audit_db() -> bool: method TEXT NOT NULL, status INTEGER NOT NULL, detail TEXT NOT NULL, - dry_run INTEGER NOT NULL + dry_run INTEGER NOT NULL, + actor TEXT NOT NULL DEFAULT 'Legacy/API' )""") + columns = {row[1] for row in db.execute("PRAGMA table_info(audit)")} + if "actor" not in columns: + db.execute("ALTER TABLE audit ADD COLUMN actor TEXT NOT NULL DEFAULT 'Legacy/API'") return True except (OSError, sqlite3.Error): return False @@ -83,6 +89,7 @@ def _audit(endpoint: str, method: str, status: int, detail: str = "", dry_run: b "status": status, "detail": _redact_audit_detail(detail), "dry_run": dry_run, + "actor": _audit_actor.get(), } _audit_log.append(entry) if len(_audit_log) > MAX_AUDIT: @@ -91,8 +98,8 @@ def _audit(endpoint: str, method: str, status: int, detail: str = "", dry_run: b if _init_audit_db(): with sqlite3.connect(AUDIT_DB_PATH) as db: db.execute( - "INSERT INTO audit (ts, endpoint, method, status, detail, dry_run) VALUES (?, ?, ?, ?, ?, ?)", - (entry["ts"], entry["endpoint"], entry["method"], entry["status"], entry["detail"], int(entry["dry_run"])), + "INSERT INTO audit (ts, endpoint, method, status, detail, dry_run, actor) VALUES (?, ?, ?, ?, ?, ?, ?)", + (entry["ts"], entry["endpoint"], entry["method"], entry["status"], entry["detail"], int(entry["dry_run"]), entry["actor"]), ) db.execute("DELETE FROM audit WHERE id NOT IN (SELECT id FROM audit ORDER BY id DESC LIMIT ?)", (MAX_AUDIT,)) except (OSError, sqlite3.Error): @@ -280,6 +287,27 @@ def _verify(request: Request): if not csrf or not secrets.compare_digest(csrf, session["csrf"]): raise HTTPException(403, "Invalid CSRF token") + +def _clean_audit_actor(value: str) -> str: + cleaned = re.sub(r"[^\w .@/\-]", "", str(value or ""))[:40].strip() + return cleaned or "KI/API" + + +@app.middleware("http") +async def audit_actor_context(request: Request, call_next): + if request.headers.get("authorization", "").startswith("Bearer "): + actor = _clean_audit_actor(request.headers.get("x-butler-actor", "KI/API")) + elif _ui_session(request): + actor = "Weboberfläche" + else: + actor = "System/Öffentlich" + token = _audit_actor.set(actor) + try: + return await call_next(request) + finally: + _audit_actor.reset(token) + + def _emby_network_identity(endpoint: str) -> dict: """Normalize an Emby endpoint without treating IPv6 privacy addresses as new households.""" raw = str(endpoint or "").strip() @@ -830,7 +858,7 @@ async def audit(_=Depends(_verify), limit: int = Query(50, le=MAX_AUDIT)): with sqlite3.connect(AUDIT_DB_PATH) as db: db.row_factory = sqlite3.Row rows = db.execute( - "SELECT ts, endpoint, method, status, detail, dry_run FROM audit ORDER BY id DESC LIMIT ?", + "SELECT ts, endpoint, method, status, detail, dry_run, actor FROM audit ORDER BY id DESC LIMIT ?", (limit,), ).fetchall() return [dict(row) | {"dry_run": bool(row["dry_run"])} for row in rows] diff --git a/tests/test_app.py b/tests/test_app.py index a8d4645..5c6b2d7 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -266,6 +266,39 @@ def test_audit_persists_and_redacts_secrets(tmp_path, monkeypatch): assert entry["detail"].count("[REDACTED]") == 3 +def test_audit_records_ai_and_web_ui_actor(tmp_path, monkeypatch): + db = tmp_path / "audit.sqlite3" + monkeypatch.setattr(app, "AUDIT_DB_PATH", str(db)) + + async def empty_status(): + return {} + + monkeypatch.setattr(app, "_collect_service_status", empty_status) + app._ui_sessions.clear() + with TestClient(app.app) as client: + ai = client.get( + "/status", + headers={"Authorization": "Bearer test-token", "X-Butler-Actor": "Trulla"}, + ) + assert ai.status_code == 200 + + client.headers.pop("Authorization", None) + client.get("/ui/session") + web = client.get("/status") + assert web.status_code == 200 + + entries = client.get("/audit").json() + + assert [item["actor"] for item in entries[:2]] == ["Weboberfläche", "Trulla"] + + +def test_ui_audit_has_actor_column_and_filter(): + with TestClient(app.app) as client: + response = client.get("/ui") + assert '
Redigierte Butler-Aktionen aus SQLite.
| Zeit | Methode | Endpoint | Status | Dry-run | Detail |
|---|---|---|---|---|---|
| Wird geladen … | |||||
Wer hat wann welche Butler-Funktion aufgerufen? Sensible Werte bleiben redigiert.
| Zeit | Akteur | Methode | Endpoint | Status | Dry-run | Detail |
|---|---|---|---|---|---|---|
| Wird geladen … | ||||||