From 4870dbd31d936bb429012a6e282b32d06d33f8e4 Mon Sep 17 00:00:00 2001 From: sascha Date: Sun, 16 Aug 2026 21:40:26 +0200 Subject: [PATCH] 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]