Akteursbezogenes KI- und Butler-Aktivitätsprotokoll #44

Merged
sascha merged 3 commits from feature/butler-ai-activity-log into main 2026-08-16 21:40:41 +02:00
Showing only changes of commit 4870dbd31d - Show all commits

36
app.py
View file

@ -9,6 +9,7 @@ from pydantic import BaseModel, Field
from fastapi import FastAPI, Request, HTTPException, Depends, Query from fastapi import FastAPI, Request, HTTPException, Depends, Query
from fastapi.responses import JSONResponse, RedirectResponse, Response, HTMLResponse from fastapi.responses import JSONResponse, RedirectResponse, Response, HTMLResponse
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from contextvars import ContextVar
log = logging.getLogger("butler") log = logging.getLogger("butler")
VERSION = "2.3.5" VERSION = "2.3.5"
@ -46,6 +47,7 @@ _load_config()
# --- Audit log --- # --- Audit log ---
_audit_log: list[dict] = [] _audit_log: list[dict] = []
_audit_actor: ContextVar[str] = ContextVar("audit_actor", default="System/API")
MAX_AUDIT = 500 MAX_AUDIT = 500
AUDIT_DB_PATH = os.environ.get("AUDIT_DB_PATH", "/data/state/audit.sqlite3") 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, method TEXT NOT NULL,
status INTEGER NOT NULL, status INTEGER NOT NULL,
detail TEXT 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 return True
except (OSError, sqlite3.Error): except (OSError, sqlite3.Error):
return False return False
@ -83,6 +89,7 @@ def _audit(endpoint: str, method: str, status: int, detail: str = "", dry_run: b
"status": status, "status": status,
"detail": _redact_audit_detail(detail), "detail": _redact_audit_detail(detail),
"dry_run": dry_run, "dry_run": dry_run,
"actor": _audit_actor.get(),
} }
_audit_log.append(entry) _audit_log.append(entry)
if len(_audit_log) > MAX_AUDIT: 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(): if _init_audit_db():
with sqlite3.connect(AUDIT_DB_PATH) as db: with sqlite3.connect(AUDIT_DB_PATH) as db:
db.execute( db.execute(
"INSERT INTO audit (ts, endpoint, method, status, detail, dry_run) VALUES (?, ?, ?, ?, ?, ?)", "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["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,)) db.execute("DELETE FROM audit WHERE id NOT IN (SELECT id FROM audit ORDER BY id DESC LIMIT ?)", (MAX_AUDIT,))
except (OSError, sqlite3.Error): except (OSError, sqlite3.Error):
@ -280,6 +287,27 @@ def _verify(request: Request):
if not csrf or not secrets.compare_digest(csrf, session["csrf"]): if not csrf or not secrets.compare_digest(csrf, session["csrf"]):
raise HTTPException(403, "Invalid CSRF token") 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: def _emby_network_identity(endpoint: str) -> dict:
"""Normalize an Emby endpoint without treating IPv6 privacy addresses as new households.""" """Normalize an Emby endpoint without treating IPv6 privacy addresses as new households."""
raw = str(endpoint or "").strip() 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: with sqlite3.connect(AUDIT_DB_PATH) as db:
db.row_factory = sqlite3.Row db.row_factory = sqlite3.Row
rows = db.execute( 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,), (limit,),
).fetchall() ).fetchall()
return [dict(row) | {"dry_run": bool(row["dry_run"])} for row in rows] return [dict(row) | {"dry_run": bool(row["dry_run"])} for row in rows]