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

Datei anzeigen

@@ -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())

106
src/audit.py Normale Datei
Datei anzeigen

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

Datei anzeigen

@@ -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"])

Datei anzeigen

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

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
# 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"])
@@ -18,7 +19,7 @@ async def get_usage_overview(admin=Depends(get_current_admin)):
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,
@@ -35,11 +36,12 @@ async def get_usage_overview(admin=Depends(get_current_admin)):
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"],
@@ -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"],
@@ -100,7 +103,6 @@ async def get_org_current_usage(org_id: int, admin=Depends(get_current_admin)):
"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"
@@ -121,10 +122,11 @@ async def get_org_current_usage(org_id: int, admin=Depends(get_current_admin)):
}
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
@@ -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,12 +156,12 @@ 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 = []
@@ -172,6 +175,10 @@ async def update_budget(license_id: int, data: dict, admin=Depends(get_current_a
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")
@@ -179,7 +186,22 @@ async def update_budget(license_id: int, data: dict, admin=Depends(get_current_a
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"},
)

Datei anzeigen

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

Datei anzeigen

@@ -31,6 +31,7 @@
<button class="nav-tab" data-section="orgs">Organisationen</button>
<button class="nav-tab" data-section="licenses">Lizenzen</button>
<button class="nav-tab" data-section="sources">Quellen</button>
<button class="nav-tab" data-section="audit">Audit-Log</button>
</nav>
<!-- Dashboard Section -->
@@ -224,6 +225,12 @@
<div class="card" style="margin-top:12px;">
<div class="card-body">
<form id="tokenBudgetForm" style="display:grid; grid-template-columns:1fr 1fr; gap:12px;">
<div style="grid-column:1/-1; padding:8px 12px; background:var(--bg-tertiary,#0f172a); border-radius:6px; border:1px solid var(--border);">
<label style="display:flex; align-items:center; gap:8px; cursor:pointer; margin:0;">
<input type="checkbox" id="editUnlimitedBudget" onchange="onUnlimitedToggle()">
<span><strong>Unbegrenztes Budget</strong> &mdash; Verbrauch wird trotzdem getrackt, aber kein Limit/Hard-Stop</span>
</label>
</div>
<div class="form-group">
<label for="editCreditsTotal">Credits-Kontingent</label>
<input type="number" id="editCreditsTotal" placeholder="z.B. 600000">
@@ -392,6 +399,48 @@
</div>
</div>
</div>
<!-- Audit-Log Section -->
<div class="section" id="sec-audit">
<div class="action-bar" style="flex-wrap:wrap;gap:8px;">
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
<select class="filter-select" id="auditFilterAction">
<option value="">Alle Aktionen</option>
</select>
<select class="filter-select" id="auditFilterResource">
<option value="">Alle Ressourcen</option>
</select>
<select class="filter-select" id="auditFilterAdmin">
<option value="">Alle Admins</option>
</select>
<input type="date" class="filter-select" id="auditFilterFrom" title="Von (Datum)">
<input type="date" class="filter-select" id="auditFilterTo" title="Bis (Datum)">
<button class="btn btn-secondary btn-small" id="auditFilterReset">Filter zuruecksetzen</button>
<span class="text-secondary" id="auditCount"></span>
</div>
<div style="display:flex;align-items:center;gap:8px;">
<button class="btn btn-secondary btn-small" id="auditPrevBtn" disabled>&larr; Zurueck</button>
<button class="btn btn-secondary btn-small" id="auditNextBtn" disabled>Weiter &rarr;</button>
</div>
</div>
<div class="card">
<div class="table-wrap">
<table>
<thead>
<tr>
<th style="width:170px;">Zeitpunkt</th>
<th style="width:120px;">Admin</th>
<th style="width:140px;">IP</th>
<th style="width:140px;">Aktion</th>
<th>Ressource</th>
<th style="width:50px;"></th>
</tr>
</thead>
<tbody id="auditTable"><tr><td colspan="6" class="text-muted">Lade...</td></tr></tbody>
</table>
</div>
</div>
</div>
</main>
<!-- Modal: New Organization -->
@@ -478,6 +527,12 @@
<div class="text-muted mt-8" style="font-size: 12px;">Trial: Standard 14 Tage, Jahreslizenz: Standard 365 Tage</div>
</div>
<div class="form-group" style="background:var(--bg-tertiary,#0f172a); padding:8px 12px; border-radius:6px; border:1px solid var(--border);">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;margin:0;">
<input type="checkbox" id="newLicUnlimited" onchange="onNewLicUnlimitedToggle()">
<span><strong>Unbegrenztes Budget</strong> &mdash; getrackt, aber kein Hard-Stop</span>
</label>
</div>
<div class="form-group">
<label for="newLicCreditsTotal">Credits-Kontingent</label>
<input type="number" id="newLicCreditsTotal" placeholder="z.B. 600000">
@@ -626,5 +681,6 @@
<script src="/static/js/app.js"></script>
<script src="/static/js/sources.js"></script>
<script src="/static/js/source-health.js"></script>
<script src="/static/js/audit.js"></script>
</body>
</html>

Datei anzeigen

@@ -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');

Datei anzeigen

@@ -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 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') : '-');
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 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');
if (percent > 80) bar.classList.add('critical');
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.toFixed(1) + '%';
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';
}
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 (!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);

229
src/static/js/audit.js Normale Datei
Datei anzeigen

@@ -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 '<table class="audit-diff"><tbody>' +
keys.map(k => `<tr><td class="diff-key">${esc(k)}</td><td>${esc(JSON.stringify(after[k]))}</td></tr>`).join("") +
'</tbody></table>';
}
if (before !== null && after === null) {
const keys = Object.keys(before).filter(k => k !== "password_hash");
return '<table class="audit-diff"><tbody>' +
keys.map(k => `<tr><td class="diff-key">${esc(k)}</td><td>${esc(JSON.stringify(before[k]))}</td></tr>`).join("") +
'</tbody></table>';
}
// UPDATE: Diff zeigen
const keys = new Set([...Object.keys(before || {}), ...Object.keys(after || {})]);
return '<table class="audit-diff"><thead><tr><th>Feld</th><th>vorher</th><th>nachher</th></tr></thead><tbody>' +
[...keys].map(k => {
const bv = (before || {})[k];
const av = (after || {})[k];
return `<tr><td class="diff-key">${esc(k)}</td><td class="diff-old">${esc(JSON.stringify(bv))}</td><td class="diff-new">${esc(JSON.stringify(av))}</td></tr>`;
}).join("") +
'</tbody></table>';
}
function renderAudit() {
const tbody = document.getElementById("auditTable");
const countEl = document.getElementById("auditCount");
if (!tbody) return;
if (auditCache.items.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="text-muted">Keine Eintraege</td></tr>';
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 += `<tr class="audit-row" onclick="toggleAuditRow(${it.id})">
<td>${formatAuditTs(it.ts)}</td>
<td>${esc(it.admin_username || "—")}</td>
<td>${esc(it.ip || "—")}</td>
<td><span class="badge badge-${badgeCls}">${esc(actLabel)}</span></td>
<td>${esc(resLabel)} ${esc(resId)}</td>
<td style="text-align:right;color:var(--text-secondary)">${arrow}</td>
</tr>`;
if (expanded) {
html += `<tr class="audit-detail-row" id="audit-detail-${it.id}"><td colspan="6">${renderAuditDiff(it.before, it.after)}</td></tr>`;
}
});
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;
}