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.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]