Audit-Log + Brute-Force-Schutz + unlimited_budget + User-Delete-Fix

- Schema-Migration: ON DELETE SET NULL fuer incidents.created_by, magic_links.user_id,
  network_analyses.created_by (behebt 500er beim User-Loeschen). Neue Spalte
  licenses.unlimited_budget. Neue Tabellen portal_audit_log, portal_login_attempts.
- Audit-Log: alle CREATE/UPDATE/DELETE auf Org/User/Lizenz/Quelle + Login-Events
  werden mit before/after-Diff in portal_audit_log geschrieben.
- Brute-Force-Schutz: 5 Fehlversuche pro IP+Username/15min -> 429 mit Retry-After.
- Token-Budget: expliziter Schalter unlimited_budget pro Lizenz. UI zeigt ehrlich
  >100%-Verbrauch (kein Math.min mehr) und ungebremste Anzeige bei unlimited.
- Neuer Audit-Log Tab mit Filter (Aktion/Ressource/Admin/Zeitraum) und Pagination.
Dieser Commit ist enthalten in:
claude-dev
2026-05-02 20:16:03 +00:00
Ursprung 0da66fb585
Commit 4dc372814d
15 geänderte Dateien mit 1215 neuen und 151 gelöschten Zeilen

106
src/audit.py Normale Datei
Datei anzeigen

@@ -0,0 +1,106 @@
"""Audit-Log Helper.
Schreibt Eintraege in portal_audit_log mit before/after-JSON.
Diff-Helper filtert auf veraenderte Felder, um Speicher zu sparen.
"""
import json
import logging
from typing import Optional
import aiosqlite
from fastapi import Request
logger = logging.getLogger("verwaltung.audit")
def get_client_ip(request: Optional[Request]) -> str:
"""IP aus X-Forwarded-For (Nginx) oder direkt aus Connection.
Nginx setzt X-Forwarded-For mit der echten Client-IP.
Falls nicht vorhanden, fallback auf request.client.host.
"""
if request is None:
return ""
xff = request.headers.get("x-forwarded-for")
if xff:
# Erste IP in der Kette ist die echte Client-IP
return xff.split(",")[0].strip()
real = request.headers.get("x-real-ip")
if real:
return real.strip()
if request.client:
return request.client.host or ""
return ""
def diff(before: Optional[dict], after: Optional[dict]) -> Optional[dict]:
"""Filtert auf veraenderte Felder. Bei CREATE/DELETE: voller Datensatz."""
if before is None or after is None:
return None
changes = {}
keys = set(before.keys()) | set(after.keys())
for k in keys:
bv = before.get(k)
av = after.get(k)
if bv != av:
changes[k] = {"old": bv, "new": av}
return changes if changes else None
def _to_json(d: Optional[dict]) -> Optional[str]:
if d is None:
return None
try:
return json.dumps(d, default=str, ensure_ascii=False)
except Exception as e:
logger.warning("audit JSON-Serialisierung fehlgeschlagen: %s", e)
return json.dumps({"_error": str(e)})
async def log_action(
db: aiosqlite.Connection,
admin: Optional[dict],
ip: str,
action: str,
resource_type: Optional[str] = None,
resource_id: Optional[int] = None,
before: Optional[dict] = None,
after: Optional[dict] = None,
) -> None:
"""Schreibt einen Audit-Eintrag.
action: 'create' | 'update' | 'delete' | 'login_success' | 'login_failed' | 'login_blocked'
resource_type: 'organization' | 'user' | 'license' | 'source' | 'admin' | 'auth'
"""
admin_id = admin.get("id") if admin else None
admin_username = admin.get("username") if admin else None
# Bei UPDATE: nur Diff speichern, sonst vollen Datensatz
if action == "update" and before is not None and after is not None:
d = diff(before, after)
if d is None:
# Keine Aenderung -> nicht loggen
return
before_json = _to_json({k: v["old"] for k, v in d.items()})
after_json = _to_json({k: v["new"] for k, v in d.items()})
else:
before_json = _to_json(before)
after_json = _to_json(after)
try:
await db.execute(
"""INSERT INTO portal_audit_log
(admin_id, admin_username, ip, action, resource_type, resource_id, before_json, after_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(admin_id, admin_username, ip, action, resource_type, resource_id, before_json, after_json),
)
await db.commit()
except Exception as e:
logger.error("audit log_action fehlgeschlagen: %s", e)
async def row_to_dict(db: aiosqlite.Connection, table: str, row_id: int) -> Optional[dict]:
"""Holt Datensatz als Dict (fuer before/after-Snapshot)."""
cursor = await db.execute(f"SELECT * FROM {table} WHERE id = ?", (row_id,))
row = await cursor.fetchone()
return dict(row) if row else None