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:
@@ -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"},
|
||||
)
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren