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:
115
src/routers/audit.py
Normale Datei
115
src/routers/audit.py
Normale Datei
@@ -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,
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"},
|
||||
)
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren