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

Datei anzeigen

@@ -2,10 +2,11 @@
import secrets
import string
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, status, Request
from models import UserCreate, UserResponse
from auth import get_current_admin
from database import db_dependency
from audit import log_action, get_client_ip, row_to_dict
from config import MAGIC_LINK_BASE_URL, MAGIC_LINK_EXPIRE_MINUTES
import aiosqlite
@@ -31,6 +32,7 @@ async def list_users(
@router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
async def create_user(
data: UserCreate,
request: Request,
org_id: int = None,
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
@@ -40,13 +42,11 @@ async def create_user(
email = data.email.lower().strip()
# Org pruefen
cursor = await db.execute("SELECT id, name FROM organizations WHERE id = ?", (org_id,))
org = await cursor.fetchone()
if not org:
raise HTTPException(status_code=404, detail="Organisation nicht gefunden")
# Nutzer-Limit pruefen
cursor = await db.execute(
"SELECT max_users FROM licenses WHERE organization_id = ? AND status = 'active' ORDER BY created_at DESC LIMIT 1",
(org_id,),
@@ -61,7 +61,6 @@ async def create_user(
if current >= lic["max_users"]:
raise HTTPException(status_code=400, detail=f"Nutzer-Limit erreicht ({current}/{lic['max_users']})")
# E-Mail-Duplikat
cursor = await db.execute("SELECT id FROM users WHERE LOWER(email) = ?", (email,))
if await cursor.fetchone():
raise HTTPException(status_code=400, detail="E-Mail bereits vergeben")
@@ -75,7 +74,6 @@ async def create_user(
)
user_id = cursor.lastrowid
# Magic Link fuer Einladung erstellen
token = secrets.token_urlsafe(48)
code = ''.join(secrets.choice(string.digits) for _ in range(6))
expires_at = (datetime.now(timezone.utc) + timedelta(hours=48)).isoformat()
@@ -87,7 +85,6 @@ async def create_user(
)
await db.commit()
# Einladungs-E-Mail senden
try:
from email_utils.sender import send_email
from email_utils.templates import invite_email
@@ -95,47 +92,61 @@ async def create_user(
subject, html = invite_email(username, org["name"], code, link)
await send_email(email, subject, html)
except Exception:
pass # E-Mail-Fehler nicht fatal
pass
cursor = await db.execute("SELECT * FROM users WHERE id = ?", (user_id,))
return dict(await cursor.fetchone())
new_user = dict(await cursor.fetchone())
await log_action(
db, admin, get_client_ip(request),
action="create", resource_type="user", resource_id=user_id,
after={k: v for k, v in new_user.items() if k != "password_hash"},
)
return new_user
async def _toggle_field(db, request, admin, user_id: int, field: str, value: int):
"""Hilfsfunktion: ein Feld aktualisieren + Audit."""
before = await row_to_dict(db, "users", user_id)
if not before:
raise HTTPException(status_code=404, detail="Nutzer nicht gefunden")
await db.execute(f"UPDATE users SET {field} = ? WHERE id = ?", (value, user_id))
await db.commit()
after = await row_to_dict(db, "users", user_id)
await log_action(
db, admin, get_client_ip(request),
action="update", resource_type="user", resource_id=user_id,
before={field: before.get(field)},
after={field: after.get(field)},
)
return after
@router.put("/{user_id}/deactivate")
async def deactivate_user(
user_id: int,
request: Request,
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
):
cursor = await db.execute("SELECT id FROM users WHERE id = ?", (user_id,))
if not await cursor.fetchone():
raise HTTPException(status_code=404, detail="Nutzer nicht gefunden")
await db.execute("UPDATE users SET is_active = 0 WHERE id = ?", (user_id,))
await db.commit()
await _toggle_field(db, request, admin, user_id, "is_active", 0)
return {"ok": True}
@router.put("/{user_id}/activate")
async def activate_user(
user_id: int,
request: Request,
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
):
cursor = await db.execute("SELECT id FROM users WHERE id = ?", (user_id,))
if not await cursor.fetchone():
raise HTTPException(status_code=404, detail="Nutzer nicht gefunden")
await db.execute("UPDATE users SET is_active = 1 WHERE id = ?", (user_id,))
await db.commit()
await _toggle_field(db, request, admin, user_id, "is_active", 1)
return {"ok": True}
@router.put("/{user_id}/globe-access")
async def toggle_globe_access(
user_id: int,
request: Request,
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
):
@@ -143,48 +154,15 @@ async def toggle_globe_access(
row = await cursor.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Nutzer nicht gefunden")
new_val = 0 if row[1] else 1
await db.execute("UPDATE users SET globe_access = ? WHERE id = ?", (new_val, user_id))
await db.commit()
await _toggle_field(db, request, admin, user_id, "globe_access", new_val)
return {"ok": True, "globe_access": bool(new_val)}
@router.put("/{user_id}/role")
async def change_role(
user_id: int,
role: str = "member",
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
):
if role not in ("org_admin", "member"):
raise HTTPException(status_code=400, detail="Ungueltige Rolle")
cursor = await db.execute("SELECT id FROM users WHERE id = ?", (user_id,))
if not await cursor.fetchone():
raise HTTPException(status_code=404, detail="Nutzer nicht gefunden")
await db.execute("UPDATE users SET role = ? WHERE id = ?", (role, user_id))
await db.commit()
return {"ok": True}
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user(
user_id: int,
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
):
cursor = await db.execute("SELECT id FROM users WHERE id = ?", (user_id,))
if not await cursor.fetchone():
raise HTTPException(status_code=404, detail="Nutzer nicht gefunden")
await db.execute("DELETE FROM users WHERE id = ?", (user_id,))
await db.commit()
@router.put("/{user_id}/network-access")
async def toggle_network_access(
user_id: int,
request: Request,
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
):
@@ -192,8 +170,39 @@ async def toggle_network_access(
row = await cursor.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Nutzer nicht gefunden")
new_val = 0 if row[1] else 1
await db.execute("UPDATE users SET network_access = ? WHERE id = ?", (new_val, user_id))
await db.commit()
await _toggle_field(db, request, admin, user_id, "network_access", new_val)
return {"ok": True, "network_access": bool(new_val)}
@router.put("/{user_id}/role")
async def change_role(
user_id: int,
request: Request,
role: str = "member",
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
):
if role not in ("org_admin", "member"):
raise HTTPException(status_code=400, detail="Ungueltige Rolle")
await _toggle_field(db, request, admin, user_id, "role", role)
return {"ok": True}
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user(
user_id: int,
request: Request,
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
):
before = await row_to_dict(db, "users", user_id)
if not before:
raise HTTPException(status_code=404, detail="Nutzer nicht gefunden")
await db.execute("DELETE FROM users WHERE id = ?", (user_id,))
await db.commit()
await log_action(
db, admin, get_client_ip(request),
action="delete", resource_type="user", resource_id=user_id,
before={k: v for k, v in before.items() if k != "password_hash"},
)