- 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.
107 Zeilen
3.5 KiB
Python
107 Zeilen
3.5 KiB
Python
"""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
|