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

115
src/routers/audit.py Normale Datei
Datei anzeigen

@@ -0,0 +1,115 @@
"""Audit-Log Read-only Endpoint."""
import json
import logging
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from auth import get_current_admin
from database import db_dependency
import aiosqlite
logger = logging.getLogger("verwaltung.audit_router")
router = APIRouter(prefix="/api/audit-log", tags=["audit"])
def _parse_json(s: Optional[str]):
if not s:
return None
try:
return json.loads(s)
except Exception:
return s
@router.get("")
async def list_audit(
action: Optional[str] = None,
resource_type: Optional[str] = None,
admin_id: Optional[int] = None,
from_ts: Optional[str] = None,
to_ts: Optional[str] = None,
limit: int = 200,
offset: int = 0,
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Audit-Log-Eintraege auflisten mit Filter + Pagination."""
if limit < 1 or limit > 1000:
limit = 200
if offset < 0:
offset = 0
where = []
params = []
if action:
where.append("action = ?")
params.append(action)
if resource_type:
where.append("resource_type = ?")
params.append(resource_type)
if admin_id is not None:
where.append("admin_id = ?")
params.append(admin_id)
if from_ts:
where.append("ts >= ?")
params.append(from_ts)
if to_ts:
where.append("ts <= ?")
params.append(to_ts)
where_sql = ("WHERE " + " AND ".join(where)) if where else ""
# Total count fuer Pagination
cursor = await db.execute(f"SELECT COUNT(*) AS cnt FROM portal_audit_log {where_sql}", params)
total = (await cursor.fetchone())["cnt"]
# Daten
cursor = await db.execute(
f"""SELECT id, ts, admin_id, admin_username, ip, action,
resource_type, resource_id, before_json, after_json
FROM portal_audit_log {where_sql}
ORDER BY ts DESC, id DESC
LIMIT ? OFFSET ?""",
params + [limit, offset],
)
rows = await cursor.fetchall()
items = []
for r in rows:
items.append({
"id": r["id"],
"ts": r["ts"],
"admin_id": r["admin_id"],
"admin_username": r["admin_username"],
"ip": r["ip"],
"action": r["action"],
"resource_type": r["resource_type"],
"resource_id": r["resource_id"],
"before": _parse_json(r["before_json"]),
"after": _parse_json(r["after_json"]),
})
return {
"items": items,
"total": total,
"limit": limit,
"offset": offset,
}
@router.get("/distinct")
async def get_distinct_values(
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Distinct-Werte fuer Filter-Dropdowns."""
cursor = await db.execute("SELECT DISTINCT action FROM portal_audit_log ORDER BY action")
actions = [r["action"] for r in await cursor.fetchall()]
cursor = await db.execute("SELECT DISTINCT resource_type FROM portal_audit_log WHERE resource_type IS NOT NULL ORDER BY resource_type")
resource_types = [r["resource_type"] for r in await cursor.fetchall()]
cursor = await db.execute("SELECT DISTINCT admin_id, admin_username FROM portal_audit_log WHERE admin_id IS NOT NULL ORDER BY admin_username")
admins = [{"id": r["admin_id"], "username": r["admin_username"]} for r in await cursor.fetchall()]
return {
"actions": actions,
"resource_types": resource_types,
"admins": admins,
}

Datei anzeigen

@@ -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}

Datei anzeigen

@@ -1,9 +1,10 @@
"""Organisations-CRUD."""
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, status, Request
from models import OrgCreate, OrgUpdate, OrgResponse
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/orgs", tags=["organizations"])
@@ -40,10 +41,10 @@ async def list_organizations(
@router.post("", response_model=OrgResponse, status_code=status.HTTP_201_CREATED)
async def create_organization(
data: OrgCreate,
request: Request,
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
):
# Slug-Duplikat pruefen
cursor = await db.execute("SELECT id FROM organizations WHERE slug = ?", (data.slug,))
if await cursor.fetchone():
raise HTTPException(status_code=400, detail="Slug bereits vergeben")
@@ -53,10 +54,17 @@ async def create_organization(
"INSERT INTO organizations (name, slug, is_active, created_at, updated_at) VALUES (?, ?, 1, ?, ?)",
(data.name, data.slug, now, now),
)
org_id = cursor.lastrowid
await db.commit()
cursor = await db.execute("SELECT * FROM organizations WHERE id = ?", (cursor.lastrowid,))
return await _enrich_org(db, await cursor.fetchone())
cursor = await db.execute("SELECT * FROM organizations WHERE id = ?", (org_id,))
new_row_obj = await cursor.fetchone()
await log_action(
db, admin, get_client_ip(request),
action="create", resource_type="organization", resource_id=org_id,
after=dict(new_row_obj),
)
return await _enrich_org(db, new_row_obj)
@router.get("/{org_id}", response_model=OrgResponse)
@@ -76,12 +84,12 @@ async def get_organization(
async def update_organization(
org_id: int,
data: OrgUpdate,
request: Request,
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
):
cursor = await db.execute("SELECT * FROM organizations WHERE id = ?", (org_id,))
row = await cursor.fetchone()
if not row:
before = await row_to_dict(db, "organizations", org_id)
if not before:
raise HTTPException(status_code=404, detail="Organisation nicht gefunden")
updates = {}
@@ -97,6 +105,13 @@ async def update_organization(
await db.execute(f"UPDATE organizations SET {set_clause} WHERE id = ?", values)
await db.commit()
after = await row_to_dict(db, "organizations", org_id)
await log_action(
db, admin, get_client_ip(request),
action="update", resource_type="organization", resource_id=org_id,
before=before, after=after,
)
cursor = await db.execute("SELECT * FROM organizations WHERE id = ?", (org_id,))
return await _enrich_org(db, await cursor.fetchone())
@@ -104,13 +119,18 @@ async def update_organization(
@router.delete("/{org_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_organization(
org_id: int,
request: Request,
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
):
cursor = await db.execute("SELECT * FROM organizations WHERE id = ?", (org_id,))
if not await cursor.fetchone():
before = await row_to_dict(db, "organizations", org_id)
if not before:
raise HTTPException(status_code=404, detail="Organisation nicht gefunden")
# Kaskadierendes Loeschen
await db.execute("DELETE FROM organizations WHERE id = ?", (org_id,))
await db.commit()
await log_action(
db, admin, get_client_ip(request),
action="delete", resource_type="organization", resource_id=org_id,
before=before,
)

Datei anzeigen

@@ -6,12 +6,13 @@ import logging
# Monitor-Source-Rules verfÃÆÃ¼gbar machen
sys.path.insert(0, "/home/claude-dev/AegisSight-Monitor/src")
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, status, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from typing import Optional
from auth import get_current_admin
from database import db_dependency
from audit import log_action, get_client_ip, row_to_dict
import aiosqlite
sys.path.insert(0, os.path.join('/home/claude-dev/AegisSight-Monitor/src'))
@@ -65,6 +66,7 @@ async def list_global_sources(
@router.post("/global", status_code=201)
async def create_global_source(
data: GlobalSourceCreate,
request: Request,
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
):
@@ -86,16 +88,24 @@ async def create_global_source(
VALUES (?, ?, ?, ?, ?, ?, ?, 'system', NULL)""",
(data.name, data.url, data.domain, data.source_type, data.category, data.status, data.notes),
)
src_id = cursor.lastrowid
await db.commit()
cursor = await db.execute("SELECT * FROM sources WHERE id = ?", (cursor.lastrowid,))
return dict(await cursor.fetchone())
cursor = await db.execute("SELECT * FROM sources WHERE id = ?", (src_id,))
new_src = dict(await cursor.fetchone())
await log_action(
db, admin, get_client_ip(request),
action="create", resource_type="source", resource_id=src_id,
after=new_src,
)
return new_src
@router.put("/global/{source_id}")
async def update_global_source(
source_id: int,
data: GlobalSourceUpdate,
request: Request,
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
):
@@ -106,13 +116,14 @@ async def update_global_source(
row = await cursor.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Grundquelle nicht gefunden")
before = dict(row)
updates = {}
for field, value in data.model_dump(exclude_none=True).items():
updates[field] = value
if not updates:
return dict(row)
return before
set_clause = ", ".join(f"{k} = ?" for k in updates)
values = list(updates.values()) + [source_id]
@@ -120,24 +131,38 @@ async def update_global_source(
await db.commit()
cursor = await db.execute("SELECT * FROM sources WHERE id = ?", (source_id,))
return dict(await cursor.fetchone())
after = dict(await cursor.fetchone())
await log_action(
db, admin, get_client_ip(request),
action="update", resource_type="source", resource_id=source_id,
before=before, after=after,
)
return after
@router.delete("/global/{source_id}", status_code=204)
async def delete_global_source(
source_id: int,
request: Request,
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Grundquelle loeschen."""
cursor = await db.execute(
"SELECT id FROM sources WHERE id = ? AND tenant_id IS NULL", (source_id,)
"SELECT * FROM sources WHERE id = ? AND tenant_id IS NULL", (source_id,)
)
if not await cursor.fetchone():
row = await cursor.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Grundquelle nicht gefunden")
before = dict(row)
await db.execute("DELETE FROM sources WHERE id = ?", (source_id,))
await db.commit()
await log_action(
db, admin, get_client_ip(request),
action="delete", resource_type="source", resource_id=source_id,
before=before,
)
@router.get("/tenant")
@@ -159,6 +184,7 @@ async def list_tenant_sources(
@router.post("/tenant/{source_id}/promote")
async def promote_to_global(
source_id: int,
request: Request,
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
):
@@ -169,6 +195,7 @@ async def promote_to_global(
raise HTTPException(status_code=404, detail="Quelle nicht gefunden")
if row["tenant_id"] is None:
raise HTTPException(status_code=400, detail="Bereits eine Grundquelle")
before = dict(row)
if row["url"]:
cursor = await db.execute(
@@ -185,7 +212,13 @@ async def promote_to_global(
await db.commit()
cursor = await db.execute("SELECT * FROM sources WHERE id = ?", (source_id,))
return dict(await cursor.fetchone())
after = dict(await cursor.fetchone())
await log_action(
db, admin, get_client_ip(request),
action="update", resource_type="source", resource_id=source_id,
before=before, after=after,
)
return after
@@ -267,6 +300,7 @@ async def discover_source_endpoint(
@router.post("/discover/add")
async def add_discovered_sources(
feeds: list[dict],
request: Request,
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
):
@@ -282,6 +316,7 @@ async def add_discovered_sources(
added = 0
skipped = 0
added_ids = []
for feed in feeds:
if not feed.get("url"):
continue
@@ -290,11 +325,12 @@ async def add_discovered_sources(
continue
domain = feed.get("domain", "")
await db.execute(
cur = await db.execute(
"""INSERT INTO sources (name, url, domain, source_type, category, status, added_by, tenant_id)
VALUES (?, ?, ?, 'rss_feed', ?, 'active', 'system', NULL)""",
(feed["name"], feed["url"], domain, feed.get("category", "sonstige")),
)
added_ids.append(cur.lastrowid)
existing_urls.add(feed["url"])
added += 1
@@ -315,6 +351,13 @@ async def add_discovered_sources(
added += 1
await db.commit()
if added_ids:
await log_action(
db, admin, get_client_ip(request),
action="create", resource_type="source",
after={"discovered_add": {"count": added, "ids": added_ids,
"domain": feeds[0].get("domain") if feeds else None}},
)
return {"added": added, "skipped": skipped}
@@ -394,6 +437,7 @@ class SuggestionAction(BaseModel):
async def update_suggestion(
suggestion_id: int,
action: SuggestionAction,
request: Request,
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
):
@@ -478,6 +522,14 @@ async def update_suggestion(
(new_status, suggestion_id),
)
await db.commit()
await log_action(
db, admin, get_client_ip(request),
action="update", resource_type="source",
resource_id=suggestion.get("source_id"),
before={"suggestion_id": suggestion_id, "status": "pending"},
after={"suggestion_id": suggestion_id, "status": new_status,
"result_action": result_action},
)
return {"status": new_status, "action": result_action}

Datei anzeigen

@@ -1,9 +1,10 @@
"""Token-Usage & Budget-Verwaltung."""
import logging
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Request
from auth import get_current_admin
from database import get_db
from audit import log_action, get_client_ip, row_to_dict
logger = logging.getLogger("verwaltung.token_usage")
router = APIRouter(prefix="/api/token-usage", tags=["Token-Usage"])
@@ -15,10 +16,10 @@ async def get_usage_overview(admin=Depends(get_current_admin)):
db = await get_db()
try:
cursor = await db.execute("""
SELECT
SELECT
o.id, o.name, o.slug,
l.credits_total, l.credits_used, l.cost_per_credit,
l.token_budget_usd, l.budget_warning_percent,
l.token_budget_usd, l.budget_warning_percent, l.unlimited_budget,
COALESCE(SUM(r.total_cost_usd), 0) as total_cost,
COALESCE(SUM(r.input_tokens), 0) as total_input_tokens,
COALESCE(SUM(r.output_tokens), 0) as total_output_tokens,
@@ -30,17 +31,18 @@ async def get_usage_overview(admin=Depends(get_current_admin)):
GROUP BY o.id
""")
rows = await cursor.fetchall()
result = []
for row in rows:
credits_total = row["credits_total"] or 0
credits_used = row["credits_used"] or 0
credits_remaining = max(0, int(credits_total - credits_used)) if credits_total else None
percent_used = round((credits_used / credits_total) * 100, 1) if credits_total and credits_total > 0 else None
unlimited = bool(row["unlimited_budget"])
credits_remaining = None if unlimited else (max(0, int(credits_total - credits_used)) if credits_total else None)
percent_used = None if unlimited else (round((credits_used / credits_total) * 100, 1) if credits_total and credits_total > 0 else None)
budget_usd = row["token_budget_usd"]
cost = row["total_cost"]
budget_percent = round((cost / budget_usd) * 100, 1) if budget_usd and budget_usd > 0 else None
budget_percent = None if unlimited else (round((cost / budget_usd) * 100, 1) if budget_usd and budget_usd > 0 else None)
result.append({
"org_id": row["id"],
"org_name": row["name"],
@@ -53,6 +55,7 @@ async def get_usage_overview(admin=Depends(get_current_admin)):
"total_cost_usd": round(cost, 2),
"budget_percent_used": budget_percent,
"budget_warning_percent": row["budget_warning_percent"] or 80,
"unlimited_budget": unlimited,
"total_input_tokens": row["total_input_tokens"],
"total_output_tokens": row["total_output_tokens"],
"total_api_calls": row["total_api_calls"],
@@ -73,7 +76,7 @@ async def get_org_usage(org_id: int, admin=Depends(get_current_admin)):
"SELECT * FROM token_usage_monthly WHERE organization_id = ? ORDER BY year_month DESC",
(org_id,))
rows = await cursor.fetchall()
return [{
"year_month": row["year_month"],
"source": row["source"] if "source" in row.keys() else "monitor",
@@ -95,12 +98,11 @@ async def get_org_current_usage(org_id: int, admin=Depends(get_current_admin)):
db = await get_db()
try:
year_month = datetime.now().strftime("%Y-%m")
cursor = await db.execute(
"SELECT * FROM token_usage_monthly WHERE organization_id = ? AND year_month = ?",
(org_id, year_month))
usage_rows = await cursor.fetchall()
# Summe ueber alle Sources
usage = {
"input_tokens": sum(r["input_tokens"] for r in usage_rows),
"output_tokens": sum(r["output_tokens"] for r in usage_rows),
@@ -108,7 +110,6 @@ async def get_org_current_usage(org_id: int, admin=Depends(get_current_admin)):
"api_calls": sum(r["api_calls"] for r in usage_rows),
"refresh_count": sum(r["refresh_count"] for r in usage_rows),
}
# Per-Source Aufschluesselung
usage_by_source = {}
for r in usage_rows:
src = r["source"] if "source" in r.keys() else "monitor"
@@ -119,15 +120,16 @@ async def get_org_current_usage(org_id: int, admin=Depends(get_current_admin)):
"api_calls": r["api_calls"],
"refresh_count": r["refresh_count"],
}
cursor = await db.execute(
"SELECT credits_total, credits_used, cost_per_credit, token_budget_usd, budget_warning_percent FROM licenses WHERE organization_id = ? AND status = 'active' ORDER BY id DESC LIMIT 1",
"SELECT credits_total, credits_used, cost_per_credit, token_budget_usd, budget_warning_percent, unlimited_budget FROM licenses WHERE organization_id = ? AND status = 'active' ORDER BY id DESC LIMIT 1",
(org_id,))
lic = await cursor.fetchone()
unlimited = bool(lic["unlimited_budget"]) if lic else False
credits_total = lic["credits_total"] if lic else None
credits_used = lic["credits_used"] if lic else 0
return {
"year_month": year_month,
"usage": {
@@ -139,10 +141,11 @@ async def get_org_current_usage(org_id: int, admin=Depends(get_current_admin)):
},
"usage_by_source": usage_by_source,
"budget": {
"unlimited_budget": unlimited,
"credits_total": credits_total,
"credits_used": round(credits_used, 1) if credits_used else 0,
"credits_remaining": max(0, int(credits_total - credits_used)) if credits_total else None,
"credits_percent_used": round((credits_used / credits_total) * 100, 1) if credits_total and credits_total > 0 else None,
"credits_remaining": None if unlimited else (max(0, int(credits_total - credits_used)) if credits_total else None),
"credits_percent_used": None if unlimited else (round((credits_used / credits_total) * 100, 1) if credits_total and credits_total > 0 else None),
"token_budget_usd": lic["token_budget_usd"] if lic else None,
"cost_per_credit": lic["cost_per_credit"] if lic else None,
"budget_warning_percent": lic["budget_warning_percent"] if lic else 80,
@@ -153,33 +156,52 @@ async def get_org_current_usage(org_id: int, admin=Depends(get_current_admin)):
@router.put("/budget/{license_id}")
async def update_budget(license_id: int, data: dict, admin=Depends(get_current_admin)):
"""Budget einer Lizenz setzen/ändern."""
async def update_budget(license_id: int, data: dict, request: Request, admin=Depends(get_current_admin)):
"""Budget einer Lizenz setzen/aendern."""
db = await get_db()
try:
cursor = await db.execute("SELECT id FROM licenses WHERE id = ?", (license_id,))
if not await cursor.fetchone():
before = await row_to_dict(db, "licenses", license_id)
if not before:
raise HTTPException(status_code=404, detail="Lizenz nicht gefunden")
fields = []
values = []
for key in ("token_budget_usd", "credits_total", "cost_per_credit", "budget_warning_percent"):
if key in data:
fields.append(f"{key} = ?")
values.append(data[key])
if "credits_used" in data:
fields.append("credits_used = ?")
values.append(data["credits_used"])
if "unlimited_budget" in data:
fields.append("unlimited_budget = ?")
values.append(1 if data["unlimited_budget"] else 0)
if not fields:
raise HTTPException(status_code=400, detail="Keine Felder zum Aktualisieren")
values.append(license_id)
await db.execute(f"UPDATE licenses SET {', '.join(fields)} WHERE id = ?", values)
await db.commit()
logger.info(f"Budget für Lizenz {license_id} aktualisiert: {data}")
return {"ok": True}
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,
)
# Konsistenz-Hinweis
warning = None
if after and not after.get("unlimited_budget"):
cpc = after.get("cost_per_credit")
ct = after.get("credits_total")
if (not cpc or cpc == 0) and (not ct or ct == 0):
warning = "Achtung: cost_per_credit und credits_total sind leer/0 - Budget wird nicht getrackt. Bitte 'Unbegrenzt' aktivieren oder gueltige Werte eintragen."
logger.info(f"Budget fuer Lizenz {license_id} aktualisiert: {data}")
return {"ok": True, "warning": warning}
finally:
await db.close()

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"},
)