From 4870dbd31d936bb429012a6e282b32d06d33f8e4 Mon Sep 17 00:00:00 2001 From: sascha Date: Sun, 16 Aug 2026 21:40:26 +0200 Subject: [PATCH 1/3] Add actor-aware Butler activity log (app.py) --- app.py | 36 ++++++++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) 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] From ef4fd59197efdfd712591dcf0c3961f64a25899a Mon Sep 17 00:00:00 2001 From: sascha Date: Sun, 16 Aug 2026 21:40:27 +0200 Subject: [PATCH 2/3] Add actor-aware Butler activity log (ui.html) --- ui.html | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/ui.html b/ui.html index f837d9f..7b8c45b 100644 --- a/ui.html +++ b/ui.html @@ -76,8 +76,8 @@
-
Persistente Historie

Audit

Redigierte Butler-Aktionen aus SQLite.

-
Letzte Aktionen
ZeitMethodeEndpointStatusDry-runDetail
Wird geladen …
+
KI- und API-Protokoll

Butler-Aktivitäten

Wer hat wann welche Butler-Funktion aufgerufen? Sensible Werte bleiben redigiert.

+
Letzte Aktivitäten
ZeitAkteurMethodeEndpointStatusDry-runDetail
Wird geladen …
@@ -93,7 +93,7 @@ From 6620384dc45ab9997e116415f152cb90760c5601 Mon Sep 17 00:00:00 2001 From: sascha Date: Sun, 16 Aug 2026 21:40:28 +0200 Subject: [PATCH 3/3] Add actor-aware Butler activity log (tests/test_app.py) --- tests/test_app.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) 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 'Akteur' in response.text + assert 'id="audit-actor"' in response.text + + def test_wireguard_status_returns_redacted_live_state(monkeypatch): payload = { "interface": "wg0",