From 4dc372814d8cbc9c6743b77c13de6cb872a12771 Mon Sep 17 00:00:00 2001 From: claude-dev Date: Sat, 2 May 2026 20:16:03 +0000 Subject: [PATCH] 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. --- migrations/migrate_2026-05-02.py | 251 +++++++++++++++++++++++++++++++ src/audit.py | 106 +++++++++++++ src/main.py | 73 ++++++++- src/models.py | 2 + src/routers/audit.py | 115 ++++++++++++++ src/routers/licenses.py | 83 +++++++--- src/routers/organizations.py | 40 +++-- src/routers/sources.py | 70 +++++++-- src/routers/token_usage.py | 80 ++++++---- src/routers/users.py | 129 ++++++++-------- src/static/css/style.css | 13 ++ src/static/dashboard.html | 56 +++++++ src/static/index.html | 5 + src/static/js/app.js | 114 +++++++++++--- src/static/js/audit.js | 229 ++++++++++++++++++++++++++++ 15 files changed, 1215 insertions(+), 151 deletions(-) create mode 100644 migrations/migrate_2026-05-02.py create mode 100644 src/audit.py create mode 100644 src/routers/audit.py create mode 100644 src/static/js/audit.js diff --git a/migrations/migrate_2026-05-02.py b/migrations/migrate_2026-05-02.py new file mode 100644 index 0000000..1c5a61f --- /dev/null +++ b/migrations/migrate_2026-05-02.py @@ -0,0 +1,251 @@ +"""Migration 2026-05-02: User-Delete-Fix + unlimited_budget + Audit/Brute-Force Tabellen. + +Aenderungen: + (A) ON DELETE SET NULL fuer incidents.created_by, magic_links.user_id, network_analyses.created_by + (B) Neue Spalte licenses.unlimited_budget (mit Backfill) + (C) Neue Tabellen portal_audit_log, portal_login_attempts + +Idempotent: jeder Schritt prueft, ob er bereits ausgefuehrt wurde. +Vorher: Backup der DB anlegen! +""" +import sqlite3 +import sys + +DB_PATH = "/home/claude-dev/osint-data/osint.db" + + +def has_on_delete_set_null(db, table, column): + """Prueft, ob die ON DELETE SET NULL Klausel bereits gesetzt ist.""" + row = db.execute( + "SELECT sql FROM sqlite_master WHERE type='table' AND name=?", (table,) + ).fetchone() + if not row: + return False + sql = row[0] + return "ON DELETE SET NULL" in sql.upper() and column in sql + + +def column_exists(db, table, column): + cols = [r[1] for r in db.execute("PRAGMA table_info(" + table + ")").fetchall()] + return column in cols + + +def table_exists(db, name): + return db.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (name,) + ).fetchone() is not None + + +def recreate_table(db, name, new_create_sql, indexes): + """Recreate-Pattern: alte Spalten in neue Tabelle kopieren.""" + print(" - Recreate " + name + "...") + cols_old = [r[1] for r in db.execute("PRAGMA table_info(" + name + ")").fetchall()] + db.execute(new_create_sql.replace("TABLE " + name, "TABLE " + name + "_new")) + cols_new = [r[1] for r in db.execute("PRAGMA table_info(" + name + "_new)").fetchall()] + common = [c for c in cols_old if c in cols_new] + cols_csv = ", ".join(common) + db.execute("INSERT INTO " + name + "_new (" + cols_csv + ") SELECT " + cols_csv + " FROM " + name) + db.execute("DROP TABLE " + name) + db.execute("ALTER TABLE " + name + "_new RENAME TO " + name) + for idx_sql in indexes: + db.execute(idx_sql) + + +def main(): + db = sqlite3.connect(DB_PATH) + db.row_factory = sqlite3.Row + + counts_before = {} + for t in ["incidents", "magic_links", "network_analyses", "licenses", + "users", "organizations", "sources"]: + counts_before[t] = db.execute("SELECT COUNT(*) FROM " + t).fetchone()[0] + print("Counts vor Migration:") + for t, n in counts_before.items(): + print(" " + t + ": " + str(n)) + print() + + db.execute("PRAGMA foreign_keys=OFF") + db.execute("BEGIN") + + try: + print("(A) ON DELETE SET NULL fuer 3 Tabellen") + + if not has_on_delete_set_null(db, "incidents", "created_by"): + recreate_table( + db, "incidents", + """CREATE TABLE incidents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + description TEXT, + status TEXT DEFAULT 'active', + refresh_mode TEXT DEFAULT 'manual', + refresh_interval INTEGER DEFAULT 15, + retention_days INTEGER DEFAULT 0, + summary TEXT, + created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + type TEXT DEFAULT 'adhoc', + sources_json TEXT, + international_sources INTEGER DEFAULT 1, + visibility TEXT DEFAULT 'public', + tenant_id INTEGER REFERENCES organizations(id), + notify_email_summary INTEGER DEFAULT 0, + notify_email_contradiction INTEGER DEFAULT 0, + notify_email_status_change INTEGER DEFAULT 0, + include_telegram INTEGER DEFAULT 0, + telegram_categories TEXT DEFAULT NULL, + category_labels TEXT, + executive_summary TEXT, + refresh_start_time TEXT, + latest_developments TEXT +)""", + [ + "CREATE INDEX idx_incidents_tenant_status ON incidents(tenant_id, status)", + ], + ) + else: + print(" - incidents: bereits ON DELETE SET NULL, skip") + + if not has_on_delete_set_null(db, "magic_links", "user_id"): + recreate_table( + db, "magic_links", + """CREATE TABLE magic_links ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT NOT NULL, + token TEXT UNIQUE NOT NULL, + code TEXT NOT NULL, + purpose TEXT NOT NULL DEFAULT 'login', + user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + is_used INTEGER DEFAULT 0, + expires_at TIMESTAMP NOT NULL, + ip_address TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +)""", + [], + ) + else: + print(" - magic_links: bereits ON DELETE SET NULL, skip") + + if not has_on_delete_set_null(db, "network_analyses", "created_by"): + recreate_table( + db, "network_analyses", + """CREATE TABLE network_analyses ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + status TEXT DEFAULT 'pending', + entity_count INTEGER DEFAULT 0, + relation_count INTEGER DEFAULT 0, + data_hash TEXT, + last_generated_at TIMESTAMP, + tenant_id INTEGER REFERENCES organizations(id), + created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +)""", + [], + ) + else: + print(" - network_analyses: bereits ON DELETE SET NULL, skip") + + print("") + print("(B) licenses.unlimited_budget Spalte") + if not column_exists(db, "licenses", "unlimited_budget"): + db.execute("ALTER TABLE licenses ADD COLUMN unlimited_budget INTEGER DEFAULT 0") + print(" - Spalte angelegt") + res = db.execute( + "UPDATE licenses SET unlimited_budget=1 " + "WHERE credits_total IS NULL OR cost_per_credit IS NULL OR cost_per_credit=0" + ) + print(" - Backfill: " + str(res.rowcount) + " Lizenzen auf unlimited gesetzt") + else: + print(" - bereits vorhanden, skip") + + print("") + print("(C) portal_audit_log + portal_login_attempts") + if not table_exists(db, "portal_audit_log"): + db.execute(""" + CREATE TABLE portal_audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + admin_id INTEGER, + admin_username TEXT, + ip TEXT, + action TEXT NOT NULL, + resource_type TEXT, + resource_id INTEGER, + before_json TEXT, + after_json TEXT + ) + """) + db.execute("CREATE INDEX idx_audit_ts ON portal_audit_log(ts DESC)") + db.execute("CREATE INDEX idx_audit_admin ON portal_audit_log(admin_id)") + db.execute("CREATE INDEX idx_audit_resource ON portal_audit_log(resource_type, resource_id)") + print(" - portal_audit_log angelegt") + else: + print(" - portal_audit_log bereits vorhanden, skip") + + if not table_exists(db, "portal_login_attempts"): + db.execute(""" + CREATE TABLE portal_login_attempts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + ip TEXT NOT NULL, + username TEXT NOT NULL, + success INTEGER NOT NULL DEFAULT 0 + ) + """) + db.execute("CREATE INDEX idx_login_lookup ON portal_login_attempts(ip, username, ts)") + print(" - portal_login_attempts angelegt") + else: + print(" - portal_login_attempts bereits vorhanden, skip") + + db.execute("COMMIT") + db.execute("PRAGMA foreign_keys=ON") + + print("") + print("=== Verifikation ===") + ok = True + for t, n in counts_before.items(): + n_after = db.execute("SELECT COUNT(*) FROM " + t).fetchone()[0] + status = "OK" if n_after == n else "MISMATCH (" + str(n) + " -> " + str(n_after) + ")" + print(" " + t + ": " + str(n_after) + " " + status) + if n_after != n: + ok = False + for t, c in [("incidents", "created_by"), ("magic_links", "user_id"), + ("network_analyses", "created_by")]: + on_del = has_on_delete_set_null(db, t, c) + print(" " + t + "." + c + " ON DELETE SET NULL: " + str(on_del)) + if not on_del: + ok = False + col_ok = column_exists(db, "licenses", "unlimited_budget") + print(" licenses.unlimited_budget: " + str(col_ok)) + if not col_ok: + ok = False + for t in ["portal_audit_log", "portal_login_attempts"]: + t_ok = table_exists(db, t) + print(" Tabelle " + t + ": " + str(t_ok)) + if not t_ok: + ok = False + + if ok: + print("") + print("Alle Checks OK") + return 0 + else: + print("") + print("FEHLER: Mindestens ein Check fehlgeschlagen") + return 1 + + except Exception as e: + db.execute("ROLLBACK") + db.execute("PRAGMA foreign_keys=ON") + print("") + print("FEHLER: " + type(e).__name__ + ": " + str(e)) + raise + + finally: + db.close() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/audit.py b/src/audit.py new file mode 100644 index 0000000..eeb3630 --- /dev/null +++ b/src/audit.py @@ -0,0 +1,106 @@ +"""Audit-Log Helper. + +Schreibt Eintraege in portal_audit_log mit before/after-JSON. +Diff-Helper filtert auf veraenderte Felder, um Speicher zu sparen. +""" +import json +import logging +from typing import Optional + +import aiosqlite +from fastapi import Request + +logger = logging.getLogger("verwaltung.audit") + + +def get_client_ip(request: Optional[Request]) -> str: + """IP aus X-Forwarded-For (Nginx) oder direkt aus Connection. + + Nginx setzt X-Forwarded-For mit der echten Client-IP. + Falls nicht vorhanden, fallback auf request.client.host. + """ + if request is None: + return "" + xff = request.headers.get("x-forwarded-for") + if xff: + # Erste IP in der Kette ist die echte Client-IP + return xff.split(",")[0].strip() + real = request.headers.get("x-real-ip") + if real: + return real.strip() + if request.client: + return request.client.host or "" + return "" + + +def diff(before: Optional[dict], after: Optional[dict]) -> Optional[dict]: + """Filtert auf veraenderte Felder. Bei CREATE/DELETE: voller Datensatz.""" + if before is None or after is None: + return None + changes = {} + keys = set(before.keys()) | set(after.keys()) + for k in keys: + bv = before.get(k) + av = after.get(k) + if bv != av: + changes[k] = {"old": bv, "new": av} + return changes if changes else None + + +def _to_json(d: Optional[dict]) -> Optional[str]: + if d is None: + return None + try: + return json.dumps(d, default=str, ensure_ascii=False) + except Exception as e: + logger.warning("audit JSON-Serialisierung fehlgeschlagen: %s", e) + return json.dumps({"_error": str(e)}) + + +async def log_action( + db: aiosqlite.Connection, + admin: Optional[dict], + ip: str, + action: str, + resource_type: Optional[str] = None, + resource_id: Optional[int] = None, + before: Optional[dict] = None, + after: Optional[dict] = None, +) -> None: + """Schreibt einen Audit-Eintrag. + + action: 'create' | 'update' | 'delete' | 'login_success' | 'login_failed' | 'login_blocked' + resource_type: 'organization' | 'user' | 'license' | 'source' | 'admin' | 'auth' + """ + admin_id = admin.get("id") if admin else None + admin_username = admin.get("username") if admin else None + + # Bei UPDATE: nur Diff speichern, sonst vollen Datensatz + if action == "update" and before is not None and after is not None: + d = diff(before, after) + if d is None: + # Keine Aenderung -> nicht loggen + return + before_json = _to_json({k: v["old"] for k, v in d.items()}) + after_json = _to_json({k: v["new"] for k, v in d.items()}) + else: + before_json = _to_json(before) + after_json = _to_json(after) + + try: + await db.execute( + """INSERT INTO portal_audit_log + (admin_id, admin_username, ip, action, resource_type, resource_id, before_json, after_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + (admin_id, admin_username, ip, action, resource_type, resource_id, before_json, after_json), + ) + await db.commit() + except Exception as e: + logger.error("audit log_action fehlgeschlagen: %s", e) + + +async def row_to_dict(db: aiosqlite.Connection, table: str, row_id: int) -> Optional[dict]: + """Holt Datensatz als Dict (fuer before/after-Snapshot).""" + cursor = await db.execute(f"SELECT * FROM {table} WHERE id = ?", (row_id,)) + row = await cursor.fetchone() + return dict(row) if row else None diff --git a/src/main.py b/src/main.py index dee5e40..bc12811 100644 --- a/src/main.py +++ b/src/main.py @@ -2,7 +2,7 @@ import logging from contextlib import asynccontextmanager -from fastapi import FastAPI, Depends, HTTPException, status +from fastapi import FastAPI, Depends, HTTPException, status, Request from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse @@ -10,7 +10,8 @@ from config import STATIC_DIR, PORT from database import db_dependency from auth import verify_password, create_token from models import LoginRequest, TokenResponse -from routers import organizations, licenses, users, dashboard, sources, token_usage +from routers import organizations, licenses, users, dashboard, sources, token_usage, audit +from audit import log_action, get_client_ip import aiosqlite @@ -20,6 +21,11 @@ logging.basicConfig( ) logger = logging.getLogger("verwaltung") +# Brute-Force-Schutz +MAX_FAILED_ATTEMPTS = 5 +BLOCK_WINDOW_MINUTES = 15 +PURGE_AFTER_HOURS = 24 + @asynccontextmanager async def lifespan(app: FastAPI): @@ -41,25 +47,84 @@ app.include_router(users.router) app.include_router(dashboard.router) app.include_router(sources.router) app.include_router(token_usage.router) +app.include_router(audit.router) # --- Login --- @app.post("/api/auth/login", response_model=TokenResponse) async def login( data: LoginRequest, + request: Request, db: aiosqlite.Connection = Depends(db_dependency), ): + ip = get_client_ip(request) + username = data.username.strip() + + # Alte Login-Versuche purgen (LRU-Style, einmal pro Anfrage) + await db.execute( + f"DELETE FROM portal_login_attempts WHERE ts < datetime('now', '-{PURGE_AFTER_HOURS} hours')" + ) + + # Brute-Force-Check: Anzahl Fehlversuche fuer (ip, username) im Zeitfenster + cursor = await db.execute( + f"""SELECT COUNT(*) AS cnt FROM portal_login_attempts + WHERE ip = ? AND username = ? AND success = 0 + AND ts > datetime('now', '-{BLOCK_WINDOW_MINUTES} minutes')""", + (ip, username), + ) + failed_count = (await cursor.fetchone())["cnt"] + + if failed_count >= MAX_FAILED_ATTEMPTS: + await log_action( + db, admin=None, ip=ip, action="login_blocked", + resource_type="auth", + after={"username": username, "failed_attempts": failed_count}, + ) + await db.commit() + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail=f"Zu viele Fehlversuche. Bitte {BLOCK_WINDOW_MINUTES} Minuten warten.", + headers={"Retry-After": str(BLOCK_WINDOW_MINUTES * 60)}, + ) + + # Auth-Pruefung cursor = await db.execute( "SELECT id, username, password_hash FROM portal_admins WHERE username = ?", - (data.username,), + (username,), ) admin = await cursor.fetchone() - if not admin or not verify_password(data.password, admin["password_hash"]): + auth_ok = bool(admin and verify_password(data.password, admin["password_hash"])) + + # Versuch in Tabelle eintragen (fuer Brute-Force-Tracking) + await db.execute( + "INSERT INTO portal_login_attempts (ip, username, success) VALUES (?, ?, ?)", + (ip, username, 1 if auth_ok else 0), + ) + await db.commit() + + if not auth_ok: + admin_dict = ( + {"id": admin["id"], "username": admin["username"]} if admin else None + ) + await log_action( + db, admin=admin_dict, ip=ip, action="login_failed", + resource_type="auth", + after={"username": username}, + ) raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Ungueltige Zugangsdaten", ) + # Erfolg + await log_action( + db, + admin={"id": admin["id"], "username": admin["username"]}, + ip=ip, + action="login_success", + resource_type="auth", + ) + token = create_token(admin["id"], admin["username"]) return TokenResponse(access_token=token, username=admin["username"]) diff --git a/src/models.py b/src/models.py index 763af9f..b9b8390 100644 --- a/src/models.py +++ b/src/models.py @@ -46,6 +46,7 @@ class LicenseCreate(BaseModel): credits_total: Optional[int] = None cost_per_credit: Optional[float] = None budget_warning_percent: Optional[int] = Field(default=80, ge=1, le=100) + unlimited_budget: bool = False class LicenseResponse(BaseModel): @@ -62,6 +63,7 @@ class LicenseResponse(BaseModel): credits_used: Optional[float] = None cost_per_credit: Optional[float] = None budget_warning_percent: Optional[int] = None + unlimited_budget: bool = False created_at: str globe_access: bool = False network_access: bool = False diff --git a/src/routers/audit.py b/src/routers/audit.py new file mode 100644 index 0000000..405c700 --- /dev/null +++ b/src/routers/audit.py @@ -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, + } diff --git a/src/routers/licenses.py b/src/routers/licenses.py index 2e15eb2..c83d427 100644 --- a/src/routers/licenses.py +++ b/src/routers/licenses.py @@ -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} diff --git a/src/routers/organizations.py b/src/routers/organizations.py index 58142a7..58a2b56 100644 --- a/src/routers/organizations.py +++ b/src/routers/organizations.py @@ -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, + ) diff --git a/src/routers/sources.py b/src/routers/sources.py index 3c06602..5f13666 100644 --- a/src/routers/sources.py +++ b/src/routers/sources.py @@ -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} diff --git a/src/routers/token_usage.py b/src/routers/token_usage.py index c586635..af02c81 100644 --- a/src/routers/token_usage.py +++ b/src/routers/token_usage.py @@ -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() diff --git a/src/routers/users.py b/src/routers/users.py index 455e886..6dab904 100644 --- a/src/routers/users.py +++ b/src/routers/users.py @@ -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"}, + ) diff --git a/src/static/css/style.css b/src/static/css/style.css index a7c54e3..989918b 100644 --- a/src/static/css/style.css +++ b/src/static/css/style.css @@ -777,3 +777,16 @@ tr:hover td { .token-budget-bar.warning { background: #e67e22; } .token-budget-bar.critical { background: #e74c3c; } @media (max-width: 768px) { .token-stats-row { grid-template-columns: repeat(2, 1fr); } } + +/* === Audit-Log === */ +.audit-row { cursor: pointer; } +.audit-row:hover { background: var(--bg-hover, rgba(255,255,255,0.03)); } +.audit-detail-row td { background: var(--bg-tertiary, #0f172a); padding: 12px 16px; } +.audit-diff { width: 100%; border-collapse: collapse; font-size: 12px; } +.audit-diff th, .audit-diff td { padding: 4px 8px; border-bottom: 1px solid var(--border); text-align: left; vertical-align: top; } +.audit-diff th { font-weight: 600; color: var(--text-secondary); } +.audit-diff .diff-key { font-weight: 600; color: var(--text-secondary); width: 180px; word-break: break-word; } +.audit-diff .diff-old { color: #e74c3c; word-break: break-word; } +.audit-diff .diff-new { color: #2ecc71; word-break: break-word; } +.token-budget-bar.over-limit { background: repeating-linear-gradient(45deg, #c0392b, #c0392b 6px, #962d22 6px, #962d22 12px); } +input[type="date"].filter-select { padding: 6px 10px; } diff --git a/src/static/dashboard.html b/src/static/dashboard.html index 228f91e..47e4317 100644 --- a/src/static/dashboard.html +++ b/src/static/dashboard.html @@ -31,6 +31,7 @@ + @@ -224,6 +225,12 @@
+
+ +
@@ -392,6 +399,48 @@
+ + +
+
+
+ + + + + + + +
+
+ + +
+
+
+
+ + + + + + + + + + + + +
ZeitpunktAdminIPAktionRessource
Lade...
+
+
+
@@ -478,6 +527,12 @@
Trial: Standard 14 Tage, Jahreslizenz: Standard 365 Tage
+
+ +
@@ -626,5 +681,6 @@ + diff --git a/src/static/index.html b/src/static/index.html index 6bc2002..90db2a6 100644 --- a/src/static/index.html +++ b/src/static/index.html @@ -52,6 +52,11 @@ }), }); + if (res.status === 429) { + const data = await res.json().catch(() => ({})); + throw new Error(data.detail || 'Zu viele Fehlversuche. Bitte 15 Minuten warten.'); + } + if (!res.ok) { const data = await res.json(); throw new Error(data.detail || 'Anmeldung fehlgeschlagen'); diff --git a/src/static/js/app.js b/src/static/js/app.js index dd79e03..95481b9 100644 --- a/src/static/js/app.js +++ b/src/static/js/app.js @@ -64,6 +64,7 @@ function setupNavTabs() { document.getElementById(`sec-${section}`).classList.add("active"); if (section === "licenses") loadExpiringLicenses(); + if (section === "audit" && typeof loadAudit === "function") loadAudit(); }); }); } @@ -489,12 +490,16 @@ function setupForms() { if (licType !== "permanent") { body.duration_days = parseInt(document.getElementById("newLicDuration").value); } - const creditsTotal = document.getElementById('newLicCreditsTotal'); - const costPerCredit = document.getElementById('newLicCostPerCredit'); - const budgetUsd = document.getElementById('newLicBudgetUsd'); - if (creditsTotal && creditsTotal.value) body.credits_total = parseInt(creditsTotal.value); - if (costPerCredit && costPerCredit.value) body.cost_per_credit = parseFloat(costPerCredit.value); - if (budgetUsd && budgetUsd.value) body.token_budget_usd = parseFloat(budgetUsd.value); + const unlimitedEl = document.getElementById('newLicUnlimited'); + body.unlimited_budget = !!(unlimitedEl && unlimitedEl.checked); + if (!body.unlimited_budget) { + const creditsTotal = document.getElementById('newLicCreditsTotal'); + const costPerCredit = document.getElementById('newLicCostPerCredit'); + const budgetUsd = document.getElementById('newLicBudgetUsd'); + if (creditsTotal && creditsTotal.value) body.credits_total = parseInt(creditsTotal.value); + if (costPerCredit && costPerCredit.value) body.cost_per_credit = parseFloat(costPerCredit.value); + if (budgetUsd && budgetUsd.value) body.token_budget_usd = parseFloat(budgetUsd.value); + } try { await API.post("/api/licenses", body); closeModal("modalNewLicense"); @@ -599,11 +604,17 @@ async function loadOrgTokenUsage(orgId) { const budget = current.budget || {}; const usage = current.usage || {}; + const unlimited = !!budget.unlimited_budget; const el = (id, val) => { const e = document.getElementById(id); if (e) e.textContent = val; }; el('tokenCreditsUsed', budget.credits_used != null ? Math.round(budget.credits_used).toLocaleString('de-DE') : '-'); - el('tokenCreditsRemaining', budget.credits_remaining != null ? budget.credits_remaining.toLocaleString('de-DE') : '-'); - el('tokenBudgetUsd', budget.token_budget_usd != null ? '$' + Number(budget.token_budget_usd).toFixed(2) : '-'); + if (unlimited) { + el('tokenCreditsRemaining', '∞ Unbegrenzt'); + el('tokenBudgetUsd', '—'); + } else { + el('tokenCreditsRemaining', budget.credits_remaining != null ? budget.credits_remaining.toLocaleString('de-DE') : '-'); + el('tokenBudgetUsd', budget.token_budget_usd != null ? '$' + Number(budget.token_budget_usd).toFixed(2) : '-'); + } el('tokenCostUsd', usage.total_cost_usd != null ? '$' + Number(usage.total_cost_usd).toFixed(2) : '-'); // Source-Split anzeigen @@ -616,14 +627,29 @@ async function loadOrgTokenUsage(orgId) { const bar = document.getElementById('tokenBudgetBar'); const percentEl = document.getElementById('tokenBudgetPercent'); - const percent = budget.credits_percent_used || 0; - if (bar) { - bar.style.width = Math.min(100, percent) + '%'; - bar.classList.remove('warning', 'critical'); - if (percent > 80) bar.classList.add('critical'); - else if (percent > 50) bar.classList.add('warning'); + const barWrap = bar ? bar.parentElement : null; + if (unlimited) { + if (bar) bar.style.width = '0%'; + if (percentEl) percentEl.textContent = 'Unbegrenzt'; + if (barWrap) barWrap.style.opacity = '0.4'; + } else { + const percent = budget.credits_percent_used || 0; + if (bar) { + bar.style.width = Math.min(100, percent) + '%'; + bar.classList.remove('warning', 'critical', 'over-limit'); + if (percent > 100) bar.classList.add('over-limit'); + else if (percent > 80) bar.classList.add('critical'); + else if (percent > 50) bar.classList.add('warning'); + } + if (percentEl) { + percentEl.textContent = percent > 100 + ? ('UEBER LIMIT (' + percent.toFixed(1) + '%)') + : (percent.toFixed(1) + '%'); + percentEl.style.color = percent > 100 ? 'var(--danger-text, #991b1b)' : ''; + percentEl.style.fontWeight = percent > 100 ? '700' : ''; + } + if (barWrap) barWrap.style.opacity = '1'; } - if (percentEl) percentEl.textContent = percent.toFixed(1) + '%'; fillBudgetForm(budget); @@ -655,6 +681,37 @@ function fillBudgetForm(budget) { el('editCostPerCredit', budget.cost_per_credit); el('editBudgetUsd', budget.token_budget_usd); el('editCreditsUsed', budget.credits_used ? Math.round(budget.credits_used) : 0); + const cb = document.getElementById('editUnlimitedBudget'); + if (cb) { + cb.checked = !!budget.unlimited_budget; + onUnlimitedToggle(); + } +} + +// Unlimited-Toggle: Felder ausgrauen wenn aktiv +function onUnlimitedToggle() { + const cb = document.getElementById('editUnlimitedBudget'); + const isUnlimited = cb && cb.checked; + ['editCreditsTotal','editCostPerCredit','editBudgetUsd'].forEach(function(id) { + const e = document.getElementById(id); + if (e) { + e.disabled = isUnlimited; + e.style.opacity = isUnlimited ? '0.4' : '1'; + } + }); +} + +// Unlimited-Toggle im Lizenz-Modal +function onNewLicUnlimitedToggle() { + const cb = document.getElementById('newLicUnlimited'); + const isUnlimited = cb && cb.checked; + ['newLicCreditsTotal','newLicCostPerCredit','newLicBudgetUsd'].forEach(function(id) { + const e = document.getElementById(id); + if (e) { + e.disabled = isUnlimited; + e.style.opacity = isUnlimited ? '0.4' : '1'; + } + }); } async function loadDashboardTokenStats() { @@ -701,19 +758,34 @@ document.addEventListener('DOMContentLoaded', function() { } var body = {}; + var unlimitedCb = document.getElementById('editUnlimitedBudget'); + var isUnlimited = !!(unlimitedCb && unlimitedCb.checked); + body.unlimited_budget = isUnlimited; + var creditsTotal = document.getElementById('editCreditsTotal'); var costPerCredit = document.getElementById('editCostPerCredit'); var budgetUsd = document.getElementById('editBudgetUsd'); var creditsUsed = document.getElementById('editCreditsUsed'); - if (creditsTotal && creditsTotal.value) body.credits_total = parseInt(creditsTotal.value); - if (costPerCredit && costPerCredit.value) body.cost_per_credit = parseFloat(costPerCredit.value); - if (budgetUsd && budgetUsd.value) body.token_budget_usd = parseFloat(budgetUsd.value); + if (!isUnlimited) { + if (creditsTotal && creditsTotal.value) body.credits_total = parseInt(creditsTotal.value); + if (costPerCredit && costPerCredit.value) body.cost_per_credit = parseFloat(costPerCredit.value); + if (budgetUsd && budgetUsd.value) body.token_budget_usd = parseFloat(budgetUsd.value); + } + // credits_used immer mitsenden (auch im Unlimited-Modus, fuer Korrekturen) if (creditsUsed && creditsUsed.value !== '') body.credits_used = parseFloat(creditsUsed.value); - await API.put('/api/token-usage/budget/' + activeLic.id, body); - if (msgEl) msgEl.textContent = 'Gespeichert!'; - setTimeout(function() { if (msgEl) msgEl.textContent = ''; }, 3000); + var result = await API.put('/api/token-usage/budget/' + activeLic.id, body); + if (msgEl) { + if (result && result.warning) { + msgEl.textContent = result.warning; + msgEl.style.color = 'var(--danger-text, #991b1b)'; + } else { + msgEl.textContent = 'Gespeichert!'; + msgEl.style.color = ''; + } + } + setTimeout(function() { if (msgEl) { msgEl.textContent = ''; msgEl.style.color = ''; } }, 5000); // Daten neu laden loadOrgTokenUsage(currentOrgId); diff --git a/src/static/js/audit.js b/src/static/js/audit.js new file mode 100644 index 0000000..9d04580 --- /dev/null +++ b/src/static/js/audit.js @@ -0,0 +1,229 @@ +/* Audit-Log Tab */ +"use strict"; + +let auditCache = { items: [], total: 0, offset: 0, limit: 200 }; +let auditDistinct = { actions: [], resource_types: [], admins: [] }; +let expandedRows = new Set(); + +const ACTION_LABELS = { + create: "Erstellt", + update: "Geändert", + delete: "Gelöscht", + login_success: "Login erfolgreich", + login_failed: "Login fehlgeschlagen", + login_blocked: "Login blockiert", +}; + +const RESOURCE_LABELS = { + organization: "Organisation", + user: "Nutzer", + license: "Lizenz", + source: "Quelle", + admin: "Admin", + auth: "Auth", +}; + +const ACTION_BADGE_CLASS = { + create: "active", + update: "trial", + delete: "inactive", + login_success: "active", + login_failed: "inactive", + login_blocked: "inactive", +}; + +document.addEventListener("DOMContentLoaded", () => { + document.querySelectorAll('.nav-tab[data-section="audit"]').forEach((tab) => { + tab.addEventListener("click", () => loadAudit()); + }); + + // Filter-Inputs verdrahten + ["auditFilterAction", "auditFilterResource", "auditFilterAdmin", + "auditFilterFrom", "auditFilterTo"].forEach((id) => { + const el = document.getElementById(id); + if (el) el.addEventListener("change", () => { auditCache.offset = 0; loadAudit(); }); + }); + + const reset = document.getElementById("auditFilterReset"); + if (reset) reset.addEventListener("click", () => { + ["auditFilterAction", "auditFilterResource", "auditFilterAdmin", + "auditFilterFrom", "auditFilterTo"].forEach((id) => { + const el = document.getElementById(id); + if (el) el.value = ""; + }); + auditCache.offset = 0; + loadAudit(); + }); + + const prev = document.getElementById("auditPrevBtn"); + const next = document.getElementById("auditNextBtn"); + if (prev) prev.addEventListener("click", () => { + auditCache.offset = Math.max(0, auditCache.offset - auditCache.limit); + loadAudit(); + }); + if (next) next.addEventListener("click", () => { + if (auditCache.offset + auditCache.limit < auditCache.total) { + auditCache.offset += auditCache.limit; + loadAudit(); + } + }); +}); + +async function loadAuditDistinct() { + try { + auditDistinct = await API.get("/api/audit-log/distinct"); + + const actSel = document.getElementById("auditFilterAction"); + if (actSel && actSel.options.length <= 1) { + auditDistinct.actions.forEach((a) => { + const opt = document.createElement("option"); + opt.value = a; + opt.textContent = ACTION_LABELS[a] || a; + actSel.appendChild(opt); + }); + } + + const resSel = document.getElementById("auditFilterResource"); + if (resSel && resSel.options.length <= 1) { + auditDistinct.resource_types.forEach((r) => { + const opt = document.createElement("option"); + opt.value = r; + opt.textContent = RESOURCE_LABELS[r] || r; + resSel.appendChild(opt); + }); + } + + const admSel = document.getElementById("auditFilterAdmin"); + if (admSel && admSel.options.length <= 1) { + auditDistinct.admins.forEach((a) => { + const opt = document.createElement("option"); + opt.value = a.id; + opt.textContent = a.username; + admSel.appendChild(opt); + }); + } + } catch (err) { + console.error("Audit-Filter laden fehlgeschlagen:", err); + } +} + +async function loadAudit() { + await loadAuditDistinct(); + const params = new URLSearchParams(); + const action = document.getElementById("auditFilterAction")?.value; + const resource = document.getElementById("auditFilterResource")?.value; + const adminId = document.getElementById("auditFilterAdmin")?.value; + const from = document.getElementById("auditFilterFrom")?.value; + const to = document.getElementById("auditFilterTo")?.value; + + if (action) params.append("action", action); + if (resource) params.append("resource_type", resource); + if (adminId) params.append("admin_id", adminId); + if (from) params.append("from_ts", from); + if (to) params.append("to_ts", to); + params.append("limit", auditCache.limit); + params.append("offset", auditCache.offset); + + try { + const data = await API.get("/api/audit-log?" + params.toString()); + auditCache.items = data.items; + auditCache.total = data.total; + auditCache.offset = data.offset; + renderAudit(); + } catch (err) { + console.error("Audit-Log laden fehlgeschlagen:", err); + } +} + +function formatAuditTs(ts) { + if (!ts) return "-"; + try { + const d = new Date(ts.replace(" ", "T") + "Z"); + return d.toLocaleString("de-DE", { + day: "2-digit", month: "2-digit", year: "numeric", + hour: "2-digit", minute: "2-digit", second: "2-digit", + }); + } catch { return ts; } +} + +function renderAuditDiff(before, after) { + if (before === null && after === null) return "—"; + if (before === null && after !== null) { + // CREATE: voller Datensatz + const keys = Object.keys(after).filter(k => k !== "password_hash"); + return '' + + keys.map(k => ``).join("") + + '
${esc(k)}${esc(JSON.stringify(after[k]))}
'; + } + if (before !== null && after === null) { + const keys = Object.keys(before).filter(k => k !== "password_hash"); + return '' + + keys.map(k => ``).join("") + + '
${esc(k)}${esc(JSON.stringify(before[k]))}
'; + } + // UPDATE: Diff zeigen + const keys = new Set([...Object.keys(before || {}), ...Object.keys(after || {})]); + return '' + + [...keys].map(k => { + const bv = (before || {})[k]; + const av = (after || {})[k]; + return ``; + }).join("") + + '
Feldvorhernachher
${esc(k)}${esc(JSON.stringify(bv))}${esc(JSON.stringify(av))}
'; +} + +function renderAudit() { + const tbody = document.getElementById("auditTable"); + const countEl = document.getElementById("auditCount"); + if (!tbody) return; + + if (auditCache.items.length === 0) { + tbody.innerHTML = 'Keine Eintraege'; + if (countEl) countEl.textContent = "0 Eintraege"; + updateAuditPaginator(); + return; + } + + let html = ""; + auditCache.items.forEach((it) => { + const actLabel = ACTION_LABELS[it.action] || it.action; + const resLabel = it.resource_type ? (RESOURCE_LABELS[it.resource_type] || it.resource_type) : "—"; + const resId = it.resource_id ? "#" + it.resource_id : ""; + const badgeCls = ACTION_BADGE_CLASS[it.action] || "trial"; + const expanded = expandedRows.has(it.id); + const arrow = expanded ? "▼" : "▶"; + + html += ` + ${formatAuditTs(it.ts)} + ${esc(it.admin_username || "—")} + ${esc(it.ip || "—")} + ${esc(actLabel)} + ${esc(resLabel)} ${esc(resId)} + ${arrow} + `; + if (expanded) { + html += `${renderAuditDiff(it.before, it.after)}`; + } + }); + tbody.innerHTML = html; + + if (countEl) { + const start = auditCache.offset + 1; + const end = Math.min(auditCache.offset + auditCache.items.length, auditCache.total); + countEl.textContent = `${start}-${end} von ${auditCache.total}`; + } + updateAuditPaginator(); +} + +function toggleAuditRow(id) { + if (expandedRows.has(id)) expandedRows.delete(id); + else expandedRows.add(id); + renderAudit(); +} + +function updateAuditPaginator() { + const prev = document.getElementById("auditPrevBtn"); + const next = document.getElementById("auditNextBtn"); + if (prev) prev.disabled = auditCache.offset <= 0; + if (next) next.disabled = auditCache.offset + auditCache.limit >= auditCache.total; +}