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:
@@ -1,9 +1,10 @@
|
||||
"""Lizenz-CRUD."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||
from models import LicenseCreate, LicenseResponse
|
||||
from auth import get_current_admin
|
||||
from database import db_dependency
|
||||
from audit import log_action, get_client_ip, row_to_dict
|
||||
import aiosqlite
|
||||
|
||||
router = APIRouter(prefix="/api/licenses", tags=["licenses"])
|
||||
@@ -28,21 +29,35 @@ async def list_licenses(
|
||||
@router.post("", response_model=LicenseResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_license(
|
||||
data: LicenseCreate,
|
||||
request: Request,
|
||||
admin: dict = Depends(get_current_admin),
|
||||
db: aiosqlite.Connection = Depends(db_dependency),
|
||||
):
|
||||
# Org pruefen
|
||||
cursor = await db.execute(
|
||||
"SELECT id FROM organizations WHERE id = ?", (data.organization_id,)
|
||||
)
|
||||
if not await cursor.fetchone():
|
||||
raise HTTPException(status_code=404, detail="Organisation nicht gefunden")
|
||||
|
||||
# Bestehende aktive Lizenz widerrufen
|
||||
await db.execute(
|
||||
"UPDATE licenses SET status = 'revoked' WHERE organization_id = ? AND status = 'active'",
|
||||
# Bestehende aktive Lizenz widerrufen + Snapshot fuer Audit
|
||||
cursor = await db.execute(
|
||||
"SELECT * FROM licenses WHERE organization_id = ? AND status = 'active'",
|
||||
(data.organization_id,),
|
||||
)
|
||||
revoked_lics = [dict(r) for r in await cursor.fetchall()]
|
||||
if revoked_lics:
|
||||
await db.execute(
|
||||
"UPDATE licenses SET status = 'revoked' WHERE organization_id = ? AND status = 'active'",
|
||||
(data.organization_id,),
|
||||
)
|
||||
for old_lic in revoked_lics:
|
||||
new_lic = dict(old_lic)
|
||||
new_lic["status"] = "revoked"
|
||||
await log_action(
|
||||
db, admin, get_client_ip(request),
|
||||
action="update", resource_type="license", resource_id=old_lic["id"],
|
||||
before=old_lic, after=new_lic,
|
||||
)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
valid_from = now.isoformat()
|
||||
@@ -57,49 +72,74 @@ async def create_license(
|
||||
elif data.license_type == "annual":
|
||||
valid_until = (now + timedelta(days=365)).isoformat()
|
||||
|
||||
# Bei unlimited_budget: Credits/Cost/Budget ignorieren
|
||||
if data.unlimited_budget:
|
||||
token_budget_usd = None
|
||||
credits_total = None
|
||||
cost_per_credit = None
|
||||
else:
|
||||
token_budget_usd = data.token_budget_usd
|
||||
credits_total = data.credits_total
|
||||
cost_per_credit = data.cost_per_credit
|
||||
|
||||
cursor = await db.execute(
|
||||
"""INSERT INTO licenses (organization_id, license_type, max_users, valid_from, valid_until, status,
|
||||
token_budget_usd, credits_total, cost_per_credit, budget_warning_percent)
|
||||
VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?, ?)""",
|
||||
token_budget_usd, credits_total, cost_per_credit, budget_warning_percent, unlimited_budget)
|
||||
VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?)""",
|
||||
(data.organization_id, data.license_type, data.max_users, valid_from, valid_until,
|
||||
data.token_budget_usd, data.credits_total, data.cost_per_credit, data.budget_warning_percent),
|
||||
token_budget_usd, credits_total, cost_per_credit, data.budget_warning_percent,
|
||||
1 if data.unlimited_budget else 0),
|
||||
)
|
||||
lic_id = cursor.lastrowid
|
||||
await db.commit()
|
||||
|
||||
cursor = await db.execute("SELECT * FROM licenses WHERE id = ?", (cursor.lastrowid,))
|
||||
return dict(await cursor.fetchone())
|
||||
cursor = await db.execute("SELECT * FROM licenses WHERE id = ?", (lic_id,))
|
||||
new_lic = dict(await cursor.fetchone())
|
||||
await log_action(
|
||||
db, admin, get_client_ip(request),
|
||||
action="create", resource_type="license", resource_id=lic_id,
|
||||
after=new_lic,
|
||||
)
|
||||
return new_lic
|
||||
|
||||
|
||||
@router.put("/{license_id}/revoke")
|
||||
async def revoke_license(
|
||||
license_id: int,
|
||||
request: Request,
|
||||
admin: dict = Depends(get_current_admin),
|
||||
db: aiosqlite.Connection = Depends(db_dependency),
|
||||
):
|
||||
cursor = await db.execute("SELECT * FROM licenses WHERE id = ?", (license_id,))
|
||||
lic = await cursor.fetchone()
|
||||
if not lic:
|
||||
before = await row_to_dict(db, "licenses", license_id)
|
||||
if not before:
|
||||
raise HTTPException(status_code=404, detail="Lizenz nicht gefunden")
|
||||
|
||||
await db.execute("UPDATE licenses SET status = 'revoked' WHERE id = ?", (license_id,))
|
||||
await db.commit()
|
||||
|
||||
after = await row_to_dict(db, "licenses", license_id)
|
||||
await log_action(
|
||||
db, admin, get_client_ip(request),
|
||||
action="update", resource_type="license", resource_id=license_id,
|
||||
before=before, after=after,
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.put("/{license_id}/extend")
|
||||
async def extend_license(
|
||||
license_id: int,
|
||||
request: Request,
|
||||
days: int = 365,
|
||||
admin: dict = Depends(get_current_admin),
|
||||
db: aiosqlite.Connection = Depends(db_dependency),
|
||||
):
|
||||
cursor = await db.execute("SELECT * FROM licenses WHERE id = ?", (license_id,))
|
||||
lic = await cursor.fetchone()
|
||||
if not lic:
|
||||
before = await row_to_dict(db, "licenses", license_id)
|
||||
if not before:
|
||||
raise HTTPException(status_code=404, detail="Lizenz nicht gefunden")
|
||||
|
||||
if lic["valid_until"]:
|
||||
base = datetime.fromisoformat(lic["valid_until"])
|
||||
if before.get("valid_until"):
|
||||
base = datetime.fromisoformat(before["valid_until"])
|
||||
else:
|
||||
base = datetime.now(timezone.utc)
|
||||
|
||||
@@ -109,6 +149,13 @@ async def extend_license(
|
||||
(new_until, license_id),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
after = await row_to_dict(db, "licenses", license_id)
|
||||
await log_action(
|
||||
db, admin, get_client_ip(request),
|
||||
action="update", resource_type="license", resource_id=license_id,
|
||||
before=before, after=after,
|
||||
)
|
||||
return {"ok": True, "valid_until": new_until}
|
||||
|
||||
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren