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:
106
src/audit.py
Normale Datei
106
src/audit.py
Normale Datei
@@ -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
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren