Dateien
AegisSight-Monitor-Verwaltung/src/routers/licenses.py
claude-dev 4dc372814d 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.
2026-05-02 20:16:03 +00:00

179 Zeilen
6.2 KiB
Python

"""Lizenz-CRUD."""
from datetime import datetime, timedelta, timezone
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"])
@router.get("", response_model=list[LicenseResponse])
async def list_licenses(
org_id: int = None,
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
):
if org_id:
cursor = await db.execute(
"SELECT * FROM licenses WHERE organization_id = ? ORDER BY created_at DESC",
(org_id,),
)
else:
cursor = await db.execute("SELECT * FROM licenses ORDER BY created_at DESC")
return [dict(row) for row in await cursor.fetchall()]
@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),
):
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 + 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()
valid_until = None
if data.license_type == "permanent":
valid_until = None
elif data.duration_days:
valid_until = (now + timedelta(days=data.duration_days)).isoformat()
elif data.license_type == "trial":
valid_until = (now + timedelta(days=14)).isoformat()
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, unlimited_budget)
VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?)""",
(data.organization_id, data.license_type, data.max_users, valid_from, valid_until,
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 = ?", (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),
):
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),
):
before = await row_to_dict(db, "licenses", license_id)
if not before:
raise HTTPException(status_code=404, detail="Lizenz nicht gefunden")
if before.get("valid_until"):
base = datetime.fromisoformat(before["valid_until"])
else:
base = datetime.now(timezone.utc)
new_until = (base + timedelta(days=days)).isoformat()
await db.execute(
"UPDATE licenses SET valid_until = ?, status = 'active' WHERE id = ?",
(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}
@router.get("/expiring")
async def get_expiring_licenses(
days: int = 30,
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Lizenzen die in den naechsten X Tagen ablaufen."""
cursor = await db.execute(
"""SELECT l.*, o.name as org_name FROM licenses l
JOIN organizations o ON o.id = l.organization_id
WHERE l.status = 'active'
AND l.valid_until IS NOT NULL
AND l.valid_until < datetime('now', '+' || ? || ' days')
ORDER BY l.valid_until""",
(days,),
)
return [dict(row) for row in await cursor.fetchall()]