diff --git a/README.md b/README.md index 7161a89..8a6edb2 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ app/ Backend (FastAPI, Python 3.11+) ssh_proxy/ SSH-Terminal-WebSocket + SFTP-Filetransfer rdp_proxy/ Guacamole-Protokoll-Tunnel zu guacd recordings/ Hash-verkettete Session-Aufzeichnung - db/ SQLite-Migrationen (0001-0005, laufen automatisch beim Start) + db/ SQLite-Migrationen (0001-0007, laufen automatisch beim Start) static/, templates/ Frontend (Vanilla JS, xterm.js, guacamole-common-js, Admin-Oberflaeche, API-Doku) scripts/ Betriebs-/Hilfsskripte (Admin anlegen, Assets bauen) ansible/ Deployment (Rollen, systemd-Unit-Templates) @@ -48,10 +48,10 @@ verwalten; ein manueller Umweg ueber die rohe JSON-API ist dafuer nicht mehr noetig. Fuer RDP muss zusaetzlich ein laufender `guacd` erreichbar sein (siehe `JUMPHOST_GUACD_HOST`/`_PORT`). -Die SQLite-Migrationen 0004 (`user_groups`) und 0005 (`api_tokens`) werden -beim naechsten Start automatisch angewendet (`app/db.py`, `_apply_migrations`) -— kein manueller Migrationsschritt noetig, auch nicht bei einem bestehenden -Datenbestand. +Die SQLite-Migrationen 0004 (`user_groups`), 0005 (`api_tokens`), 0006 +(`tenants`) und 0007 (`crud_extras`) werden beim naechsten Start automatisch +angewendet (`app/db.py`, `_apply_migrations`) — kein manueller +Migrationsschritt noetig, auch nicht bei einem bestehenden Datenbestand. ## Admin-Oberflaeche, Gruppen-RBAC, API-Tokens und API-Dokumentation @@ -105,13 +105,94 @@ rendert Endpunkte gruppiert nach Tag, ausschliesslich per `document.createElement`/`textContent` (kein `innerHTML`, keine Inline- Styles/-Scripts, siehe `tests/test_csp_compliance.py`). +## Phase 8: Mandantenfaehigkeit, CRUD-Vervollstaendigung, Dateitransfer, Mehrfachrollen + +Diese Session hat die Admin-Oberflaeche um sieben zusammenhaengende +Erweiterungen ergaenzt: + +**1) Mandantenfaehigkeit ("volle Isolation + Mandanten-Admins", Migration +`0006_tenants.sql`)**: jede Hostgruppe, Benutzergruppe, jeder SSH-Key und +jedes API-Token gehoert zu genau einem Mandanten (`tenants`-Tabelle, +Standard-Mandant `id=1` "Standard" fuer bestehende Daten). Es gibt zwei +Admin-Stufen: **Super-Admin** (`users.is_admin=1`, unveraendert wie zuvor) +sieht/verwaltet **alle** Mandanten weiterhin vollstaendig — keine Regression +gegenueber Phase 7. **Mandanten-Admin** (neue `tenant_admins`-Zuordnung, +ein User kann Admin mehrerer Mandanten sein) sieht/verwaltet **ausschliesslich** +die Ressourcen seines/seiner Mandanten; jeder Zugriffsversuch auf eine +fremde Mandanten-ID liefert bewusst **404** (nicht 403, siehe +`app/tenancy.py`, `TenantScope`) — ein Mandanten-Admin soll aus der +Fehlerantwort nicht einmal ableiten koennen, dass eine ID ausserhalb seines +Mandanten ueberhaupt existiert. Tenant-CRUD und die Ernennung von +Mandanten-Admins sind ausschliesslich Super-Admin-Aktionen (neuer Tab +"Mandanten" in der Admin-Oberflaeche, fuer Mandanten-Admins nicht sichtbar). +Ein API-Token ist immer an **genau einen** Mandanten gebunden +(`api_tokens.tenant_id`) und kann diesen nie verlassen. + +**2) CRUD-Vervollstaendigung**: jede Ressource, die die Admin-Oberflaeche +anlegen kann, ist jetzt auch bearbeitbar und loeschbar — Benutzer (inkl. +Passwort setzen, deaktivieren, reaktivieren), Benutzergruppen, Hostgruppen, +Hosts, SSH-Keys (inkl. Rotation). Zwei bewusste Sicherheits-/Integritaets- +Entscheidungen dabei: ein Benutzerkonto mit vorhandener Audit-Historie wird +**nicht** hart geloescht (die Hash-Chain verweist absichtlich ohne `ON DELETE +CASCADE` auf `users.id`, siehe Migration `0007_crud_extras.sql`), sondern +deaktiviert und anonymisiert (`deleted_user_`, Passwort/TOTP geloescht); +nur ein Konto ganz ohne Audit-Spuren wird tatsaechlich entfernt — die +Response (`hard_deleted: true/false`) zeigt an, welcher Fall eintrat. Ein +Host wird per Default **soft-deleted** (`is_active=0`, wie schon zuvor vom +Katalog beruecksichtigt); `DELETE /admin/hosts/{id}?hard=true` versucht +zusaetzlich ein echtes Entfernen, faellt aber automatisch auf Soft-Delete +zurueck, falls der Host bereits Sitzungshistorie hat. + +**3) Login-Verlauf**: neuer Tab "Login-Verlauf" filtert clientseitig aus dem +bestehenden Audit-Log-Feed (`GET /admin/audit-log`) gezielt Login-/Logout-/ +Fehlversuch-Ereignisse heraus — kein neuer Backend-Endpunkt noetig, da die +Rohdaten bereits vorhanden waren, nur bisher nicht dediziert sichtbar. + +**4) "Details"-Fix fuer Hosts**: der defekte "Details"-Button in der Hosts- +Tabelle wurde durch einen neuen Endpunkt `GET /admin/hosts/{id}` (liefert den +vollstaendigen, aktuellen Datensatz inkl. zugeordneter SSH-Keys und ob +RDP-Zugangsdaten hinterlegt sind) sowie eine neu geschriebene, robuste +`showHostDetail()`-Funktion in `admin.js` ersetzt, die bei jedem Aufruf +frisch nachlaedt statt sich auf ggf. veraltete Listendaten zu verlassen, und +Fehler inline anzeigt statt sie zu verschlucken. Da diese Sandbox die App +nicht tatsaechlich im Browser ausfuehren kann, war die urspruengliche +Ursache nicht direkt reproduzierbar — bitte nach dem Update pruefen, ob der +Button jetzt zuverlaessig funktioniert, oder bei einem verbleibenden Fehler +die genaue Browser-Konsolenmeldung mitteilen. + +**5) Eigener "Zugangsdaten"-Tab**: buendelt SSH-Keys UND RDP/Windows- +Passwoerter (bisher Teil des Hosts-Formulars) an einer Stelle, inkl. Uebersicht, +welche RDP-Hosts bereits ein Passwort hinterlegt haben (`GET +/admin/rdp-credentials`). + +**6) Dateitransfer-Fenster (SSH-Terminal)**: der bisherige, `prompt()`- +basierte Einzel-Upload-Knopf in der Terminal-Sitzung (`templates/terminal.html`, +`static/js/terminal.js`) wurde durch ein eigenstaendiges Dateitransfer-Panel +ersetzt, das **beide Richtungen** abdeckt — Upload (wie zuvor, jetzt mit +eigenem Formular statt Browser-`prompt()`) und **Download** (neu in der UI; +der Backend-Endpunkt `GET /ssh/{host_id}/files/download` existierte bereits +und wird per `fetch()` + Blob + synthetischem ``-Link +angesteuert, damit Fehler inline im Panel erscheinen statt die Seite zu +verlassen). Das Panel fuehrt zusaetzlich ein kurzes Transfer-Log der +laufenden Sitzung. Gilt bewusst nur fuer SSH (RDP hat in dieser +Implementierung keinen eigenen Dateitransfer-Endpunkt). + +**7) Mehrfachauswahl bei Rollenvergabe**: `POST /admin/roles/grant` und +`POST /admin/group-roles/grant` akzeptieren jetzt `role_names` (Liste, 1-6 +Rollen) statt einer einzelnen `role_name` — ein Benutzer bzw. eine +Benutzergruppe kann damit in einem Schritt mehrere Rollen auf derselben +Hostgruppe erhalten (Checkbox-Raster statt Dropdown in der Oberflaeche). +Das Entziehen bleibt bewusst pro Zeile/Rolle (`role_name`, Einzahl) — das +entspricht dem bestehenden "Entziehen"-Knopf pro Tabellenzeile und braucht +keine Mehrfachauswahl. + ## Tests ```bash pytest -q ``` -46 Tests (vorher 27) decken ab: Argon2id/TOTP-Grundfunktionen, Audit-Hash-Chain +61 Tests (vorher 46) decken ab: Argon2id/TOTP-Grundfunktionen, Audit-Hash-Chain (inkl. Manipulationserkennung und Trigger-Durchsetzung), RBAC-Logik inkl. Ablaufdaten, den vollstaendigen Login-Flow (Passwort -> TOTP-Enrollment -> Session-Cookie -> geschuetzte Endpunkte) gegen die echte FastAPI-App, 17 @@ -125,19 +206,42 @@ Admin-only-Durchsetzung fuer alle neuen Endpunkte, Token-Scope-Durchsetzung Scope), dass ein Token niemals andere Tokens verwalten kann (Rechte- Eskalationsschutz), sowie Admin-Gating von `/docs` und `/openapi.json`. Details, Vorgehen und Ergebnisse der urspruenglichen 27 Tests: siehe -`Pentest_Report.md`. +`Pentest_Report.md`. 15 weitere neue Tests zu Phase 8 +(`tests/test_tenants.py`) decken zusaetzlich ab: Tenant-CRUD (Super-Admin +only, Mandanten-Admin explizit ausgeschlossen), volle Mandanten-Isolation +(Hostgruppen/Hosts/Benutzer/SSH-Keys/Audit-Log — fremde Mandanten-IDs +liefern 404), Mehrfachauswahl bei Einzel- UND Gruppen-Rollenvergabe, +Update/Deactivate/Delete fuer Benutzer (inkl. Anonymisieren-statt-Hart- +Loeschen bei vorhandener Audit-Historie vs. echtem Hard-Delete ohne), +Update/Delete fuer Hostgruppen (blockiert solange Hosts enthalten sind), +Hosts (Soft- vs. Hard-Delete), SSH-Keys (inkl. Rotation) und Benutzergruppen, +sowie den neuen `GET /admin/hosts/{id}`-Detailendpunkt inkl. SSH-Key- +Zuordnungen und RDP-Zugangsdaten-Status. -> **Hinweis:** Die 19 in dieser Session neu hinzugekommenen Tests (5 CSP + -> 14 Admin/Gruppen/Token) konnten in der verwendeten Cloud-Sandbox nicht -> mit `pytest -q` ausgefuehrt werden, da diese Sandbox keinen Netzwerkzugriff -> auf PyPI hat und `fastapi`/`aiosqlite` dort nicht vorinstalliert sind. -> Stattdessen wurden alle neuen/gaenderten SQL-Queries gegen eine echte -> `sqlite3`-Instanz mit allen 5 Migrationen (inkl. der beiden neuen) manuell -> durchgespielt, `app/security/api_tokens.py` und die neuen Pydantic-Schemas -> direkt importiert und mit echten Assertions verifiziert, und alle -> geaenderten Python-/JS-Dateien mit `py_compile`/`node --check` auf -> Syntaxfehler geprueft. Bitte `pytest -q` lokal ausfuehren und Ergebnis -> melden. +> **Hinweis:** Alle 34 in dieser und der vorherigen Session neu +> hinzugekommenen Tests (5 CSP + 14 Admin/Gruppen/Token + 15 Mandanten/CRUD) +> konnten in der verwendeten Cloud-Sandbox nicht mit `pytest -q` ausgefuehrt +> werden, da diese Sandbox keinen Netzwerkzugriff auf PyPI hat und +> `fastapi`/`aiosqlite`/`argon2`/`asyncssh` dort nicht vorinstalliert sind. +> Fuer Phase 8 wurde stattdessen ein tieferes Verifikationsverfahren +> angewendet als in der vorherigen Session: minimale Stub-Module fuer die +> vier fehlenden Pakete (`fastapi`s `APIRouter`-Dekoratoren als No-Ops, ein +> synchrones `sqlite3`-basiertes Shim mit aiosqlite-kompatiblem +> `async`-Interface) erlauben es, die **echten** Endpunkt-Funktionen aus +> `app/admin/routes.py` direkt (ohne HTTP-Layer) gegen eine echte +> In-Memory-`sqlite3`-Datenbank mit allen 7 Migrationen auszufuehren — damit +> wurden ueber 20 Integrations-Assertions (Mandanten-Isolation, +> Rollen-Mehrfachvergabe, Delete-Semantik) tatsaechlich lauffaehig +> verifiziert, nicht nur simulierte SQL-Queries. Der neue `tests/test_tenants.py` +> selbst nutzt weiterhin `httpx.AsyncClient` gegen die echte ASGI-App (wie +> alle anderen Testdateien) und wurde daher zeilenweise gegen die tatsaechliche +> Endpunkt-Implementierung gegengeprueft (Pfade, Payload-Felder, Statuscodes), +> aber nicht selbst mit `pytest` ausgefuehrt. Alle geaenderten Python-/ +> JS-Dateien wurden mit `py_compile`/`node --check` auf Syntaxfehler geprueft, +> und jeder `document.getElementById`-Aufruf in `admin.js`/`terminal.js` wurde +> automatisiert gegen die tatsaechlichen HTML-IDs in `admin.html`/`terminal.html` +> abgeglichen (0 Abweichungen). Bitte `pytest -q` lokal ausfuehren und +> Ergebnis melden. Manuell zusaetzlich verifiziert (siehe Entwicklungs-Log dieser Session): Server-Start, Static-/Template-Auslieferung, Security-Header, vollstaendiger @@ -241,7 +345,15 @@ ist die massgebliche, aktuelle Fassung dieser Liste. Kurzfassung: gibt noch keine erzwungene maximale Laufzeit, keine automatische Benachrichtigung vor Ablauf und keine "Token zuletzt benutzt vor X Tagen -> automatisch deaktivieren"-Routine. -11. Ein erneuter `bandit`/`pip-audit`-Lauf ueber die in dieser Session neu - hinzugekommenen Dateien (Admin-Oberflaeche, Gruppen-RBAC, API-Tokens) - steht noch aus (siehe oben). +11. Ein erneuter `bandit`/`pip-audit`-Lauf ueber die in dieser und der + Phase-8-Session neu hinzugekommenen Dateien (Admin-Oberflaeche, + Gruppen-RBAC, API-Tokens, Mandantenfaehigkeit, Dateitransfer-Panel) + steht noch aus (keine neuen Fremdabhaengigkeiten hinzugekommen, siehe + oben). +12. **Phase 8 / "Details"-Fix**: der Root-Cause des urspruenglich gemeldeten + defekten "Details"-Buttons konnte in dieser Sandbox nicht reproduziert + werden (die App laesst sich hier nicht im Browser ausfuehren) — der neue + `GET /admin/hosts/{id}`-Endpunkt plus robustere `showHostDetail()`-Logik + sollten das Problem loesen, sollten aber nach dem Deployment einmal + manuell im Browser bestaetigt werden. diff --git a/app/admin/routes.py b/app/admin/routes.py index 84d9ff3..f3457d5 100644 --- a/app/admin/routes.py +++ b/app/admin/routes.py @@ -1,17 +1,22 @@ """ -Administrative CRUD-API: User, Benutzergruppen, Hostgruppen, Hosts, -Rollenvergabe (an User UND an Benutzergruppen), SSH-Keys, API-Tokens. +Administrative CRUD-API: Mandanten, User, Benutzergruppen, Hostgruppen, +Hosts, Rollenvergabe (an User UND an Benutzergruppen, Mehrfachauswahl), +SSH-Keys, RDP-Zugangsdaten, API-Tokens, Audit-Log. Zustandsaendernde/-lesende Endpunkte sind entweder auf eine eingeloggte -Admin-Session ODER ein API-Token mit passendem Scope beschraenkt -(`require_admin_or_scope`, siehe app/auth/deps.py) und schreiben einen -Audit-Log-Eintrag (Konzept 4.7). Die Token-Verwaltung selbst -(/admin/tokens/*) laeuft bewusst ausschliesslich ueber `require_global_admin` -(reine Session-Aktion, kein Token-Bypass -- Privilege-Escalation-Schutz). - -Eine feingranulare, auf `admin_hostgroup` beschraenkte Admin-Rolle ist im -Datenmodell vorbereitet, wird hier aus Uebersichtlichkeitsgruenden aber nicht -vollstaendig verdrahtet -- siehe TODO-Markierungen fuer den naechsten Ausbauschritt. +Admin-Session (Super- ODER Mandanten-Admin) ODER ein API-Token mit passendem +Scope beschraenkt (`require_admin_or_scope`, siehe app/auth/deps.py) und +schreiben einen Audit-Log-Eintrag (Konzept 4.7). Zusaetzlich zur +Scope-Pruefung wird bei JEDEM mandantengebundenen Datensatz (Hostgruppen, +Hosts, Benutzergruppen, SSH-Keys, Tokens, Benutzer, Rollenvergaben, +Audit-Log) per `TenantScope` (app/tenancy.py) geprueft/gefiltert, ob der +Principal ueberhaupt in diesem Mandanten agieren darf -- ein Mandanten-Admin +bekommt fuer alles ausserhalb seines/seiner Mandanten ein 404 (bewusst kein +403, siehe app/tenancy.py). Tenant-CRUD und Mandanten-Admin-Ernennung selbst +sind ausschliesslich Super-Admin-Aktionen (`require_global_admin`). Die +Token-Verwaltung (/admin/tokens/*) laeuft ausschliesslich ueber +`require_admin_session` (reine Session-Aktion, kein Token-Bypass -- +Privilege-Escalation-Schutz). """ from __future__ import annotations @@ -19,19 +24,35 @@ import json from fastapi import APIRouter, Depends, HTTPException, Request, status -from app.auth.deps import CurrentUser, require_admin_or_scope, require_global_admin +from app.auth.deps import ( + CurrentUser, + effective_tenant_ids, + require_admin_or_scope, + require_admin_session, + require_global_admin, +) from app.db import get_db from app.models.schemas import ( ApiTokenCreateRequest, GroupMemberRequest, GroupRoleGrantRequest, + GroupRoleRevokeRequest, HostCreateRequest, HostGroupCreateRequest, + HostGroupUpdateRequest, + HostUpdateRequest, RdpCredentialsRequest, RoleGrantRequest, + RoleRevokeRequest, SshKeyCreateRequest, + SshKeyUpdateRequest, + TenantAdminAssignRequest, + TenantCreateRequest, + TenantUpdateRequest, UserCreateRequest, UserGroupCreateRequest, + UserGroupUpdateRequest, + UserUpdateRequest, ) from app.security.api_tokens import ( VALID_SCOPES, @@ -44,6 +65,7 @@ from app.security.audit import verify_chain, write_audit_event from app.security.crypto import encrypt_secret from app.security.passwords import hash_password from app.ssh_proxy.proxy import discover_and_store_host_key +from app.tenancy import TenantScope, resolve_host_group_tenant, resolve_host_tenant, tenant_user_ids router = APIRouter(prefix="/admin", tags=["admin"]) @@ -52,6 +74,175 @@ def _client_ip(request: Request) -> str: return request.client.host if request.client else "unknown" +def _scope(admin: CurrentUser) -> TenantScope: + return TenantScope(effective_tenant_ids(admin)) + + +async def _tenant_exists(conn, tenant_id: int) -> bool: + row = await (await conn.execute("SELECT 1 FROM tenants WHERE id = ?", (tenant_id,))).fetchone() + return row is not None + + +async def _resolve_write_tenant(conn, scope: TenantScope, requested_tenant_id: int | None) -> int: + """Ermittelt den Mandanten fuer eine NEU anzulegende Ressource: + Super-Admin muss requested_tenant_id angeben; ein Mandanten-Admin mit + genau einem Mandanten bekommt ihn automatisch erzwungen (Client-Angaben + werden dabei ignoriert); hat er mehrere, muss requested_tenant_id einer + davon sein. Prueft in jedem Fall, dass der Mandant tatsaechlich existiert + (sonst 404 statt eines rohen FK-Fehlers).""" + if scope.all_tenants: + if requested_tenant_id is None: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "tenant_id ist erforderlich") + if not await _tenant_exists(conn, requested_tenant_id): + raise HTTPException(status.HTTP_404_NOT_FOUND, "Mandant nicht gefunden") + return requested_tenant_id + single = scope.single_tenant_id() + if single is not None: + return single + if requested_tenant_id is None or requested_tenant_id not in scope.tenant_ids: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "Ungueltiger oder fehlender Mandant") + return requested_tenant_id + + +# --- Mandanten (Super-Admin only) --------------------------------------------- + +@router.post("/tenants", status_code=status.HTTP_201_CREATED) +async def create_tenant( + payload: TenantCreateRequest, request: Request, admin: CurrentUser = Depends(require_global_admin) +): + conn = get_db() + cursor = await conn.execute("SELECT 1 FROM tenants WHERE name = ?", (payload.name,)) + if await cursor.fetchone() is not None: + raise HTTPException(status.HTTP_409_CONFLICT, "Mandantenname existiert bereits") + cursor = await conn.execute( + "INSERT INTO tenants (name, description) VALUES (?, ?)", (payload.name, payload.description) + ) + new_id = cursor.lastrowid + await write_audit_event( + conn, event_type="tenant_created", user_id=admin.id, client_ip=_client_ip(request), + details={"id": new_id, "name": payload.name}, + ) + await conn.commit() + return {"id": new_id, "name": payload.name} + + +@router.get("/tenants") +async def list_tenants(admin: CurrentUser = Depends(require_global_admin)): + conn = get_db() + cursor = await conn.execute( + "SELECT t.id, t.name, t.description, t.is_active, t.created_at, " + "(SELECT COUNT(*) FROM host_groups WHERE tenant_id = t.id), " + "(SELECT COUNT(*) FROM user_groups WHERE tenant_id = t.id) " + "FROM tenants t ORDER BY t.name" + ) + rows = await cursor.fetchall() + return [ + { + "id": r[0], "name": r[1], "description": r[2], "is_active": bool(r[3]), + "created_at": r[4], "host_group_count": r[5], "user_group_count": r[6], + } + for r in rows + ] + + +@router.put("/tenants/{tenant_id}") +async def update_tenant( + tenant_id: int, payload: TenantUpdateRequest, request: Request, + admin: CurrentUser = Depends(require_global_admin), +): + conn = get_db() + fields, values = [], [] + if payload.name is not None: + fields.append("name = ?"); values.append(payload.name) + if payload.description is not None: + fields.append("description = ?"); values.append(payload.description) + if payload.is_active is not None: + fields.append("is_active = ?"); values.append(int(payload.is_active)) + if not fields: + return {"status": "ok", "changed": False} + values.append(tenant_id) + await conn.execute(f"UPDATE tenants SET {', '.join(fields)} WHERE id = ?", values) + await write_audit_event( + conn, event_type="tenant_updated", user_id=admin.id, client_ip=_client_ip(request), + details={"tenant_id": tenant_id, "fields": list(payload.model_dump(exclude_none=True))}, + ) + await conn.commit() + return {"status": "ok", "changed": True} + + +@router.delete("/tenants/{tenant_id}") +async def delete_tenant(tenant_id: int, request: Request, admin: CurrentUser = Depends(require_global_admin)): + conn = get_db() + for table in ("host_groups", "user_groups", "ssh_keys", "api_tokens"): + cursor = await conn.execute(f"SELECT COUNT(*) FROM {table} WHERE tenant_id = ?", (tenant_id,)) + (count,) = await cursor.fetchone() + if count: + raise HTTPException( + status.HTTP_409_CONFLICT, + f"Mandant enthaelt noch Ressourcen in '{table}' ({count}) -- zuerst entfernen/verschieben", + ) + cursor = await conn.execute("DELETE FROM tenants WHERE id = ?", (tenant_id,)) + if cursor.rowcount == 0: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Mandant nicht gefunden") + await write_audit_event( + conn, event_type="tenant_deleted", user_id=admin.id, client_ip=_client_ip(request), + details={"tenant_id": tenant_id}, + ) + await conn.commit() + return {"status": "ok"} + + +@router.get("/tenants/{tenant_id}/admins") +async def list_tenant_admins(tenant_id: int, admin: CurrentUser = Depends(require_global_admin)): + conn = get_db() + cursor = await conn.execute( + "SELECT u.id, u.username, ta.granted_at FROM tenant_admins ta " + "JOIN users u ON u.id = ta.user_id WHERE ta.tenant_id = ? ORDER BY u.username", + (tenant_id,), + ) + rows = await cursor.fetchall() + return [{"user_id": r[0], "username": r[1], "granted_at": r[2]} for r in rows] + + +@router.post("/tenants/{tenant_id}/admins", status_code=status.HTTP_201_CREATED) +async def add_tenant_admin( + tenant_id: int, payload: TenantAdminAssignRequest, request: Request, + admin: CurrentUser = Depends(require_global_admin), +): + conn = get_db() + user_cursor = await conn.execute( + "SELECT 1 FROM users WHERE id = ? AND deleted_at IS NULL", (payload.user_id,) + ) + if await user_cursor.fetchone() is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Benutzer nicht gefunden") + await conn.execute( + "INSERT OR IGNORE INTO tenant_admins (user_id, tenant_id, granted_by) VALUES (?, ?, ?)", + (payload.user_id, tenant_id, admin.id), + ) + await write_audit_event( + conn, event_type="tenant_admin_granted", user_id=admin.id, client_ip=_client_ip(request), + details={"tenant_id": tenant_id, "target_user_id": payload.user_id}, + ) + await conn.commit() + return {"status": "ok"} + + +@router.delete("/tenants/{tenant_id}/admins/{user_id}") +async def remove_tenant_admin( + tenant_id: int, user_id: int, request: Request, admin: CurrentUser = Depends(require_global_admin) +): + conn = get_db() + await conn.execute( + "DELETE FROM tenant_admins WHERE tenant_id = ? AND user_id = ?", (tenant_id, user_id) + ) + await write_audit_event( + conn, event_type="tenant_admin_revoked", user_id=admin.id, client_ip=_client_ip(request), + details={"tenant_id": tenant_id, "target_user_id": user_id}, + ) + await conn.commit() + return {"status": "ok"} + + # --- Users ----------------------------------------------------------------- @router.post("/users", status_code=status.HTTP_201_CREATED) @@ -59,16 +250,29 @@ async def create_user( payload: UserCreateRequest, request: Request, admin: CurrentUser = Depends(require_admin_or_scope("users", "write")), ): + if payload.is_admin and not admin.is_admin: + raise HTTPException(status.HTTP_403_FORBIDDEN, "Nur Super-Admins duerfen weitere Super-Admins anlegen") + + scope = _scope(admin) + home_tenant_id = payload.home_tenant_id + if not scope.all_tenants: + single = scope.single_tenant_id() + home_tenant_id = single if single is not None else home_tenant_id + if home_tenant_id is not None: + scope.check(home_tenant_id) + conn = get_db() - cursor = await conn.execute("SELECT 1 FROM users WHERE username = ?", (payload.username,)) + cursor = await conn.execute( + "SELECT 1 FROM users WHERE username = ? AND deleted_at IS NULL", (payload.username,) + ) if await cursor.fetchone() is not None: raise HTTPException(status.HTTP_409_CONFLICT, "Benutzername existiert bereits") pw_hash = hash_password(payload.initial_password) cursor = await conn.execute( - "INSERT INTO users (username, password_hash, is_admin, must_change_password) " - "VALUES (?, ?, ?, 1)", - (payload.username, pw_hash, int(payload.is_admin)), + "INSERT INTO users (username, password_hash, is_admin, must_change_password, home_tenant_id) " + "VALUES (?, ?, ?, 1, ?)", + (payload.username, pw_hash, int(payload.is_admin), home_tenant_id), ) new_id = cursor.lastrowid await write_audit_event( @@ -82,25 +286,105 @@ async def create_user( @router.get("/users") async def list_users(admin: CurrentUser = Depends(require_admin_or_scope("users", "read"))): conn = get_db() - cursor = await conn.execute( - "SELECT id, username, is_admin, is_active, totp_enrolled, created_at FROM users ORDER BY id" - ) - rows = await cursor.fetchall() + scope = _scope(admin) + if scope.all_tenants: + cursor = await conn.execute( + "SELECT id, username, is_admin, is_active, totp_enrolled, created_at, home_tenant_id " + "FROM users WHERE deleted_at IS NULL ORDER BY id" + ) + rows = await cursor.fetchall() + else: + visible: set[int] = set() + for tid in scope.tenant_ids: + visible |= await tenant_user_ids(conn, tid) + if not visible: + return [] + placeholders = ",".join("?" for _ in visible) + cursor = await conn.execute( + f"SELECT id, username, is_admin, is_active, totp_enrolled, created_at, home_tenant_id " + f"FROM users WHERE deleted_at IS NULL AND id IN ({placeholders}) ORDER BY id", + tuple(visible), + ) + rows = await cursor.fetchall() return [ { "id": r[0], "username": r[1], "is_admin": bool(r[2]), "is_active": bool(r[3]), - "totp_enrolled": bool(r[4]), "created_at": r[5], + "totp_enrolled": bool(r[4]), "created_at": r[5], "home_tenant_id": r[6], } for r in rows ] +async def _assert_user_in_scope(conn, scope: TenantScope, user_id: int) -> None: + if scope.all_tenants: + return + visible: set[int] = set() + for tid in scope.tenant_ids: + visible |= await tenant_user_ids(conn, tid) + if user_id not in visible: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Nicht gefunden") + + +@router.put("/users/{user_id}") +async def update_user( + user_id: int, payload: UserUpdateRequest, request: Request, + admin: CurrentUser = Depends(require_admin_or_scope("users", "write")), +): + conn = get_db() + scope = _scope(admin) + target = await conn.execute( + "SELECT is_admin FROM users WHERE id = ? AND deleted_at IS NULL", (user_id,) + ) + row = await target.fetchone() + if row is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Benutzer nicht gefunden") + if row[0] and not admin.is_admin: + raise HTTPException(status.HTTP_403_FORBIDDEN, "Super-Admin-Konten nur durch Super-Admins aenderbar") + if payload.is_admin is not None and not admin.is_admin: + raise HTTPException(status.HTTP_403_FORBIDDEN, "Nur Super-Admins duerfen Super-Admin-Rechte vergeben") + await _assert_user_in_scope(conn, scope, user_id) + + fields, values = [], [] + if payload.is_admin is not None: + fields.append("is_admin = ?"); values.append(int(payload.is_admin)) + if payload.is_active is not None: + fields.append("is_active = ?"); values.append(int(payload.is_active)) + fields.append("session_version = session_version + 1") + if payload.new_password is not None: + fields.append("password_hash = ?"); values.append(hash_password(payload.new_password)) + fields.append("must_change_password = 1") + fields.append("session_version = session_version + 1") + if not fields: + return {"status": "ok", "changed": False} + values.append(user_id) + await conn.execute(f"UPDATE users SET {', '.join(fields)} WHERE id = ?", values) + await write_audit_event( + conn, event_type="user_updated", user_id=admin.id, client_ip=_client_ip(request), + details={ + "target_user_id": user_id, + "fields": [k for k, v in payload.model_dump(exclude={"new_password"}).items() if v is not None] + + (["new_password"] if payload.new_password is not None else []), + }, + ) + await conn.commit() + return {"status": "ok", "changed": True} + + @router.post("/users/{user_id}/deactivate") async def deactivate_user( user_id: int, request: Request, admin: CurrentUser = Depends(require_admin_or_scope("users", "write")), ): conn = get_db() + scope = _scope(admin) + row = await (await conn.execute( + "SELECT is_admin FROM users WHERE id = ? AND deleted_at IS NULL", (user_id,) + )).fetchone() + if row is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Benutzer nicht gefunden") + if row[0] and not admin.is_admin: + raise HTTPException(status.HTTP_403_FORBIDDEN, "Super-Admin-Konten nur durch Super-Admins aenderbar") + await _assert_user_in_scope(conn, scope, user_id) await conn.execute( "UPDATE users SET is_active = 0, session_version = session_version + 1 WHERE id = ?", (user_id,), @@ -113,6 +397,57 @@ async def deactivate_user( return {"status": "ok"} +@router.delete("/users/{user_id}") +async def delete_user( + user_id: int, request: Request, + admin: CurrentUser = Depends(require_admin_or_scope("users", "write")), +): + """Loescht ein Benutzerkonto. Ein Konto MIT Audit-Historie kann nicht per + SQL-DELETE entfernt werden (audit_log.user_id verweist bewusst OHNE + ON DELETE CASCADE auf users(id), siehe 0007_crud_extras.sql) -- es wird + stattdessen deaktiviert und anonymisiert (Benutzername/Passwort/TOTP + geloescht, deleted_at gesetzt). Nur ein Konto OHNE jede Audit-Historie + (z.B. versehentlich angelegt und sofort wieder geloescht) wird + tatsaechlich hart entfernt. Die Response verraet, welcher Fall eintrat.""" + conn = get_db() + scope = _scope(admin) + row = await (await conn.execute( + "SELECT is_admin, username FROM users WHERE id = ? AND deleted_at IS NULL", (user_id,) + )).fetchone() + if row is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Benutzer nicht gefunden") + if row[0] and not admin.is_admin: + raise HTTPException(status.HTTP_403_FORBIDDEN, "Super-Admin-Konten nur durch Super-Admins loeschbar") + if user_id == admin.id: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "Eigenes Konto kann nicht geloescht werden") + await _assert_user_in_scope(conn, scope, user_id) + + has_audit = await (await conn.execute( + "SELECT 1 FROM audit_log WHERE user_id = ? LIMIT 1", (user_id,) + )).fetchone() + hard_deleted = False + if has_audit is None: + try: + await conn.execute("DELETE FROM users WHERE id = ?", (user_id,)) + hard_deleted = True + except Exception: + hard_deleted = False + if not hard_deleted: + anonymized_username = f"deleted_user_{user_id}" + await conn.execute( + "UPDATE users SET username = ?, password_hash = ?, totp_secret_enc = NULL, " + "totp_enrolled = 0, is_active = 0, session_version = session_version + 1, " + "deleted_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?", + (anonymized_username, hash_password(generate_token()), user_id), + ) + await write_audit_event( + conn, event_type="user_deleted", user_id=admin.id, client_ip=_client_ip(request), + details={"target_user_id": user_id, "hard_deleted": hard_deleted, "was_username": row[1]}, + ) + await conn.commit() + return {"status": "ok", "hard_deleted": hard_deleted} + + # --- Benutzergruppen (Teams) ------------------------------------------------- # # "Verbindungen mit einer Gruppe teilen" = einer Benutzergruppe ueber @@ -126,19 +461,19 @@ async def create_user_group( admin: CurrentUser = Depends(require_admin_or_scope("user_groups", "write")), ): conn = get_db() - cursor = await conn.execute( - "SELECT 1 FROM user_groups WHERE name = ?", (payload.name,) - ) + scope = _scope(admin) + tenant_id = await _resolve_write_tenant(conn, scope, payload.tenant_id) + cursor = await conn.execute("SELECT 1 FROM user_groups WHERE name = ?", (payload.name,)) if await cursor.fetchone() is not None: raise HTTPException(status.HTTP_409_CONFLICT, "Gruppenname existiert bereits") cursor = await conn.execute( - "INSERT INTO user_groups (name, description) VALUES (?, ?)", - (payload.name, payload.description), + "INSERT INTO user_groups (name, description, tenant_id) VALUES (?, ?, ?)", + (payload.name, payload.description, tenant_id), ) new_id = cursor.lastrowid await write_audit_event( conn, event_type="user_group_created", user_id=admin.id, client_ip=_client_ip(request), - details={"id": new_id, "name": payload.name}, + details={"id": new_id, "name": payload.name, "tenant_id": tenant_id}, ) await conn.commit() return {"id": new_id, "name": payload.name} @@ -147,23 +482,79 @@ async def create_user_group( @router.get("/user-groups") async def list_user_groups(admin: CurrentUser = Depends(require_admin_or_scope("user_groups", "read"))): conn = get_db() + scope = _scope(admin) + tenant_filter, params = scope.sql_filter("ug.tenant_id") cursor = await conn.execute( - "SELECT ug.id, ug.name, ug.description, ug.created_at, COUNT(ugm.user_id) " + "SELECT ug.id, ug.name, ug.description, ug.created_at, COUNT(ugm.user_id), ug.tenant_id, t.name " "FROM user_groups ug LEFT JOIN user_group_members ugm ON ugm.user_group_id = ug.id " - "GROUP BY ug.id ORDER BY ug.name" + "JOIN tenants t ON t.id = ug.tenant_id " + f"WHERE 1=1{tenant_filter} GROUP BY ug.id ORDER BY ug.name", + params, ) rows = await cursor.fetchall() return [ - {"id": r[0], "name": r[1], "description": r[2], "created_at": r[3], "member_count": r[4]} + { + "id": r[0], "name": r[1], "description": r[2], "created_at": r[3], "member_count": r[4], + "tenant_id": r[5], "tenant_name": r[6], + } for r in rows ] +async def _assert_user_group_in_scope(conn, scope: TenantScope, group_id: int) -> int: + row = await (await conn.execute("SELECT tenant_id FROM user_groups WHERE id = ?", (group_id,))).fetchone() + if row is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Gruppe nicht gefunden") + scope.check(row[0]) + return row[0] + + +@router.put("/user-groups/{group_id}") +async def update_user_group( + group_id: int, payload: UserGroupUpdateRequest, request: Request, + admin: CurrentUser = Depends(require_admin_or_scope("user_groups", "write")), +): + conn = get_db() + await _assert_user_group_in_scope(conn, _scope(admin), group_id) + fields, values = [], [] + if payload.name is not None: + fields.append("name = ?"); values.append(payload.name) + if payload.description is not None: + fields.append("description = ?"); values.append(payload.description) + if not fields: + return {"status": "ok", "changed": False} + values.append(group_id) + await conn.execute(f"UPDATE user_groups SET {', '.join(fields)} WHERE id = ?", values) + await write_audit_event( + conn, event_type="user_group_updated", user_id=admin.id, client_ip=_client_ip(request), + details={"id": group_id}, + ) + await conn.commit() + return {"status": "ok", "changed": True} + + +@router.delete("/user-groups/{group_id}") +async def delete_user_group( + group_id: int, request: Request, + admin: CurrentUser = Depends(require_admin_or_scope("user_groups", "write")), +): + conn = get_db() + await _assert_user_group_in_scope(conn, _scope(admin), group_id) + await conn.execute("DELETE FROM user_groups WHERE id = ?", (group_id,)) + await write_audit_event( + conn, event_type="user_group_deleted", user_id=admin.id, client_ip=_client_ip(request), + details={"id": group_id}, + ) + await conn.commit() + return {"status": "ok"} + + @router.get("/user-groups/{group_id}/members") async def list_group_members( group_id: int, admin: CurrentUser = Depends(require_admin_or_scope("user_groups", "read")) ): conn = get_db() + await _assert_user_group_in_scope(conn, _scope(admin), group_id) cursor = await conn.execute( "SELECT u.id, u.username, ugm.added_at FROM user_group_members ugm " "JOIN users u ON u.id = ugm.user_id WHERE ugm.user_group_id = ? ORDER BY u.username", @@ -179,10 +570,10 @@ async def add_group_member( admin: CurrentUser = Depends(require_admin_or_scope("user_groups", "write")), ): conn = get_db() - group_cursor = await conn.execute("SELECT 1 FROM user_groups WHERE id = ?", (group_id,)) - if await group_cursor.fetchone() is None: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Gruppe nicht gefunden") - user_cursor = await conn.execute("SELECT 1 FROM users WHERE id = ?", (payload.user_id,)) + await _assert_user_group_in_scope(conn, _scope(admin), group_id) + user_cursor = await conn.execute( + "SELECT 1 FROM users WHERE id = ? AND deleted_at IS NULL", (payload.user_id,) + ) if await user_cursor.fetchone() is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Benutzer nicht gefunden") @@ -204,6 +595,7 @@ async def remove_group_member( admin: CurrentUser = Depends(require_admin_or_scope("user_groups", "write")), ): conn = get_db() + await _assert_user_group_in_scope(conn, _scope(admin), group_id) await conn.execute( "DELETE FROM user_group_members WHERE user_group_id = ? AND user_id = ?", (group_id, user_id), @@ -224,14 +616,16 @@ async def create_host_group( admin: CurrentUser = Depends(require_admin_or_scope("host_groups", "write")), ): conn = get_db() + scope = _scope(admin) + tenant_id = await _resolve_write_tenant(conn, scope, payload.tenant_id) cursor = await conn.execute( - "INSERT INTO host_groups (name, description) VALUES (?, ?)", - (payload.name, payload.description), + "INSERT INTO host_groups (name, description, tenant_id) VALUES (?, ?, ?)", + (payload.name, payload.description, tenant_id), ) new_id = cursor.lastrowid await write_audit_event( conn, event_type="host_group_created", user_id=admin.id, client_ip=_client_ip(request), - details={"id": new_id, "name": payload.name}, + details={"id": new_id, "name": payload.name, "tenant_id": tenant_id}, ) await conn.commit() return {"id": new_id, "name": payload.name} @@ -240,9 +634,74 @@ async def create_host_group( @router.get("/host-groups") async def list_host_groups(admin: CurrentUser = Depends(require_admin_or_scope("host_groups", "read"))): conn = get_db() - cursor = await conn.execute("SELECT id, name, description FROM host_groups ORDER BY id") + scope = _scope(admin) + tenant_filter, params = scope.sql_filter("hg.tenant_id") + cursor = await conn.execute( + "SELECT hg.id, hg.name, hg.description, hg.tenant_id, t.name FROM host_groups hg " + f"JOIN tenants t ON t.id = hg.tenant_id WHERE 1=1{tenant_filter} ORDER BY hg.id", + params, + ) rows = await cursor.fetchall() - return [{"id": r[0], "name": r[1], "description": r[2]} for r in rows] + return [ + {"id": r[0], "name": r[1], "description": r[2], "tenant_id": r[3], "tenant_name": r[4]} + for r in rows + ] + + +async def _assert_host_group_in_scope(conn, scope: TenantScope, host_group_id: int) -> int: + tenant_id = await resolve_host_group_tenant(conn, host_group_id) + if tenant_id is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Hostgruppe nicht gefunden") + scope.check(tenant_id) + return tenant_id + + +@router.put("/host-groups/{host_group_id}") +async def update_host_group( + host_group_id: int, payload: HostGroupUpdateRequest, request: Request, + admin: CurrentUser = Depends(require_admin_or_scope("host_groups", "write")), +): + conn = get_db() + await _assert_host_group_in_scope(conn, _scope(admin), host_group_id) + fields, values = [], [] + if payload.name is not None: + fields.append("name = ?"); values.append(payload.name) + if payload.description is not None: + fields.append("description = ?"); values.append(payload.description) + if not fields: + return {"status": "ok", "changed": False} + values.append(host_group_id) + await conn.execute(f"UPDATE host_groups SET {', '.join(fields)} WHERE id = ?", values) + await write_audit_event( + conn, event_type="host_group_updated", user_id=admin.id, client_ip=_client_ip(request), + details={"id": host_group_id}, + ) + await conn.commit() + return {"status": "ok", "changed": True} + + +@router.delete("/host-groups/{host_group_id}") +async def delete_host_group( + host_group_id: int, request: Request, + admin: CurrentUser = Depends(require_admin_or_scope("host_groups", "write")), +): + conn = get_db() + await _assert_host_group_in_scope(conn, _scope(admin), host_group_id) + (count,) = await (await conn.execute( + "SELECT COUNT(*) FROM hosts WHERE host_group_id = ?", (host_group_id,) + )).fetchone() + if count: + raise HTTPException( + status.HTTP_409_CONFLICT, + f"Hostgruppe enthaelt noch {count} Host(s) (auch inaktive) -- zuerst entfernen/verschieben", + ) + await conn.execute("DELETE FROM host_groups WHERE id = ?", (host_group_id,)) + await write_audit_event( + conn, event_type="host_group_deleted", user_id=admin.id, client_ip=_client_ip(request), + details={"id": host_group_id}, + ) + await conn.commit() + return {"status": "ok"} # --- Hosts ------------------------------------------------------------------- @@ -253,6 +712,7 @@ async def create_host( admin: CurrentUser = Depends(require_admin_or_scope("hosts", "write")), ): conn = get_db() + await _assert_host_group_in_scope(conn, _scope(admin), payload.host_group_id) cursor = await conn.execute( """ INSERT INTO hosts ( @@ -284,26 +744,154 @@ async def list_hosts( admin: CurrentUser = Depends(require_admin_or_scope("hosts", "read")), ): conn = get_db() + scope = _scope(admin) + tenant_filter, tenant_params = scope.sql_filter("hg.tenant_id") + where = "WHERE 1=1" + tenant_filter + params = list(tenant_params) if host_group_id is not None: - cursor = await conn.execute( - "SELECT id, hostname, address, protocol, port, os_type, host_group_id " - "FROM hosts WHERE host_group_id = ? ORDER BY id", - (host_group_id,), - ) - else: - cursor = await conn.execute( - "SELECT id, hostname, address, protocol, port, os_type, host_group_id FROM hosts ORDER BY id" - ) + where += " AND h.host_group_id = ?" + params.append(host_group_id) + cursor = await conn.execute( + "SELECT h.id, h.hostname, h.address, h.protocol, h.port, h.os_type, h.host_group_id, " + "h.is_active, hg.tenant_id, t.name " + "FROM hosts h JOIN host_groups hg ON hg.id = h.host_group_id JOIN tenants t ON t.id = hg.tenant_id " + f"{where} ORDER BY h.id", + params, + ) rows = await cursor.fetchall() return [ { "id": r[0], "hostname": r[1], "address": r[2], "protocol": r[3], - "port": r[4], "os_type": r[5], "host_group_id": r[6], + "port": r[4], "os_type": r[5], "host_group_id": r[6], "is_active": bool(r[7]), + "tenant_id": r[8], "tenant_name": r[9], } for r in rows ] +async def _assert_host_in_scope(conn, scope: TenantScope, host_id: int) -> None: + tenant_id = await resolve_host_tenant(conn, host_id) + if tenant_id is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Host nicht gefunden") + scope.check(tenant_id) + + +@router.get("/hosts/{host_id}") +async def get_host_detail( + host_id: int, admin: CurrentUser = Depends(require_admin_or_scope("hosts", "read")) +): + """Liefert den vollstaendigen, aktuellen Datensatz eines Hosts inkl. + zugeordneter SSH-Keys und ob RDP-Zugangsdaten hinterlegt sind -- Basis + fuer die 'Details'-Ansicht der Admin-Oberflaeche (statt sich auf die + ggf. veraltete Liste zu verlassen).""" + conn = get_db() + await _assert_host_in_scope(conn, _scope(admin), host_id) + row = await (await conn.execute( + "SELECT h.id, h.hostname, h.address, h.protocol, h.port, h.os_type, h.host_group_id, " + "h.ssh_host_key_fingerprint, h.ssh_username, h.rdp_username, h.rdp_domain, " + "h.rdp_require_nla, h.clipboard_enabled, h.file_transfer_enabled, h.is_active, " + "hg.tenant_id, t.name " + "FROM hosts h JOIN host_groups hg ON hg.id = h.host_group_id JOIN tenants t ON t.id = hg.tenant_id " + "WHERE h.id = ?", + (host_id,), + )).fetchone() + if row is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Host nicht gefunden") + keys_cursor = await conn.execute( + "SELECT sk.id, sk.label FROM host_ssh_key_map m JOIN ssh_keys sk ON sk.id = m.ssh_key_id " + "WHERE m.host_id = ?", + (host_id,), + ) + ssh_keys = [{"id": k[0], "label": k[1]} for k in await keys_cursor.fetchall()] + rdp_row = await (await conn.execute( + "SELECT updated_at FROM rdp_credentials WHERE host_id = ?", (host_id,) + )).fetchone() + return { + "id": row[0], "hostname": row[1], "address": row[2], "protocol": row[3], "port": row[4], + "os_type": row[5], "host_group_id": row[6], "ssh_host_key_fingerprint": row[7], + "ssh_username": row[8], "rdp_username": row[9], "rdp_domain": row[10], + "rdp_require_nla": bool(row[11]), "clipboard_enabled": bool(row[12]), + "file_transfer_enabled": bool(row[13]), "is_active": bool(row[14]), + "tenant_id": row[15], "tenant_name": row[16], "ssh_keys": ssh_keys, + "rdp_credentials_set": rdp_row is not None, + "rdp_credentials_updated_at": rdp_row[0] if rdp_row else None, + } + + +@router.put("/hosts/{host_id}") +async def update_host( + host_id: int, payload: HostUpdateRequest, request: Request, + admin: CurrentUser = Depends(require_admin_or_scope("hosts", "write")), +): + conn = get_db() + scope = _scope(admin) + await _assert_host_in_scope(conn, scope, host_id) + if payload.host_group_id is not None: + await _assert_host_group_in_scope(conn, scope, payload.host_group_id) + + field_map = { + "host_group_id": "host_group_id", "hostname": "hostname", "address": "address", + "port": "port", "ssh_username": "ssh_username", "rdp_username": "rdp_username", + "rdp_domain": "rdp_domain", + } + bool_field_map = { + "rdp_require_nla": "rdp_require_nla", "clipboard_enabled": "clipboard_enabled", + "file_transfer_enabled": "file_transfer_enabled", "is_active": "is_active", + } + fields, values = [], [] + payload_dict = payload.model_dump(exclude_unset=True) + for py_field, column in field_map.items(): + if py_field in payload_dict: + fields.append(f"{column} = ?"); values.append(payload_dict[py_field]) + for py_field, column in bool_field_map.items(): + if py_field in payload_dict: + fields.append(f"{column} = ?"); values.append(int(payload_dict[py_field])) + if not fields: + return {"status": "ok", "changed": False} + values.append(host_id) + await conn.execute(f"UPDATE hosts SET {', '.join(fields)} WHERE id = ?", values) + await write_audit_event( + conn, event_type="host_updated", user_id=admin.id, client_ip=_client_ip(request), + details={"id": host_id, "fields": list(payload_dict.keys())}, + ) + await conn.commit() + return {"status": "ok", "changed": True} + + +@router.delete("/hosts/{host_id}") +async def delete_host( + host_id: int, request: Request, hard: bool = False, + admin: CurrentUser = Depends(require_admin_or_scope("hosts", "write")), +): + """Standardmaessig ein Soft-Delete (is_active=0, wie schon zuvor von + load_host()/catalog beruecksichtigt) -- ein Host mit vergangenen + Sitzungen (sessions.host_id, OHNE ON DELETE CASCADE) kann ohnehin nicht + hart geloescht werden, ohne die Sitzungs-/Aufzeichnungshistorie zu + verwaisen. Mit ?hard=true wird ein echtes DELETE versucht (nur sinnvoll + fuer einen Host ohne jede Sitzungshistorie); schlaegt es fehl, faellt der + Endpunkt automatisch auf Soft-Delete zurueck.""" + conn = get_db() + await _assert_host_in_scope(conn, _scope(admin), host_id) + hard_deleted = False + if hard: + has_sessions = await (await conn.execute( + "SELECT 1 FROM sessions WHERE host_id = ? LIMIT 1", (host_id,) + )).fetchone() + if has_sessions is None: + await conn.execute("DELETE FROM host_ssh_key_map WHERE host_id = ?", (host_id,)) + await conn.execute("DELETE FROM rdp_credentials WHERE host_id = ?", (host_id,)) + await conn.execute("DELETE FROM hosts WHERE id = ?", (host_id,)) + hard_deleted = True + if not hard_deleted: + await conn.execute("UPDATE hosts SET is_active = 0 WHERE id = ?", (host_id,)) + await write_audit_event( + conn, event_type="host_deleted", user_id=admin.id, client_ip=_client_ip(request), + details={"id": host_id, "hard_deleted": hard_deleted}, + ) + await conn.commit() + return {"status": "ok", "hard_deleted": hard_deleted} + + @router.post("/hosts/{host_id}/discover-host-key") async def discover_host_key( host_id: int, request: Request, @@ -314,6 +902,7 @@ async def discover_host_key( alle regulaeren Verbindungen wieder striktes Pinning. Jeder Aufruf wird prominent im Audit-Log vermerkt.""" conn = get_db() + await _assert_host_in_scope(conn, _scope(admin), host_id) fingerprint = await discover_and_store_host_key(conn, host_id, admin_user_id=admin.id) await write_audit_event( conn, event_type="host_key_discovered_trust_decision", user_id=admin.id, @@ -331,6 +920,7 @@ async def set_rdp_credentials( """Speichert/rotiert das RDP-Passwort fuer einen Host, verschluesselt mit dem KEK (eigener AAD-Kontext, siehe app/security/crypto.py).""" conn = get_db() + await _assert_host_in_scope(conn, _scope(admin), host_id) encrypted = encrypt_secret(payload.password.encode(), associated_data=b"rdp_password") await conn.execute( "INSERT INTO rdp_credentials (host_id, password_enc, updated_at) " @@ -347,49 +937,98 @@ async def set_rdp_credentials( return {"status": "ok"} +@router.delete("/hosts/{host_id}/rdp-credentials") +async def delete_rdp_credentials( + host_id: int, request: Request, + admin: CurrentUser = Depends(require_admin_or_scope("hosts", "write")), +): + conn = get_db() + await _assert_host_in_scope(conn, _scope(admin), host_id) + await conn.execute("DELETE FROM rdp_credentials WHERE host_id = ?", (host_id,)) + await write_audit_event( + conn, event_type="rdp_credentials_deleted", user_id=admin.id, client_ip=_client_ip(request), + details={"host_id": host_id}, + ) + await conn.commit() + return {"status": "ok"} + + +@router.get("/rdp-credentials") +async def list_rdp_credentials(admin: CurrentUser = Depends(require_admin_or_scope("hosts", "read"))): + """Uebersicht aller RDP/Windows-Hosts fuer den 'Zugangsdaten'-Tab: welche + haben bereits ein Passwort hinterlegt, wann zuletzt gesetzt.""" + conn = get_db() + scope = _scope(admin) + tenant_filter, params = scope.sql_filter("hg.tenant_id") + cursor = await conn.execute( + "SELECT h.id, h.hostname, h.address, hg.name, rc.updated_at " + "FROM hosts h JOIN host_groups hg ON hg.id = h.host_group_id " + "LEFT JOIN rdp_credentials rc ON rc.host_id = h.id " + f"WHERE h.protocol = 'rdp'{tenant_filter} ORDER BY h.hostname", + params, + ) + rows = await cursor.fetchall() + return [ + { + "host_id": r[0], "hostname": r[1], "address": r[2], "host_group_name": r[3], + "credentials_set": r[4] is not None, "updated_at": r[4], + } + for r in rows + ] + + # --- Rollenvergabe (an einzelne User) ----------------------------------------- +async def _role_id(conn, role_name: str) -> int: + row = await (await conn.execute("SELECT id FROM roles WHERE name = ?", (role_name,))).fetchone() + if row is None: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "Unbekannte Rolle") + return row[0] + + @router.post("/roles/grant") async def grant_role( payload: RoleGrantRequest, request: Request, admin: CurrentUser = Depends(require_admin_or_scope("roles", "write")), ): conn = get_db() - role_cursor = await conn.execute("SELECT id FROM roles WHERE name = ?", (payload.role_name,)) - role_row = await role_cursor.fetchone() - if role_row is None: - raise HTTPException(status.HTTP_400_BAD_REQUEST, "Unbekannte Rolle") + scope = _scope(admin) + await _assert_host_group_in_scope(conn, scope, payload.host_group_id) + await _assert_user_in_scope(conn, scope, payload.user_id) - await conn.execute( - "INSERT OR REPLACE INTO user_hostgroup_roles " - "(user_id, host_group_id, role_id, granted_by, expires_at) VALUES (?, ?, ?, ?, ?)", - (payload.user_id, payload.host_group_id, role_row[0], admin.id, payload.expires_at), - ) + granted = [] + for role_name in payload.role_names: + role_id = await _role_id(conn, role_name) + await conn.execute( + "INSERT OR REPLACE INTO user_hostgroup_roles " + "(user_id, host_group_id, role_id, granted_by, expires_at) VALUES (?, ?, ?, ?, ?)", + (payload.user_id, payload.host_group_id, role_id, admin.id, payload.expires_at), + ) + granted.append(role_name) await write_audit_event( conn, event_type="role_granted", user_id=admin.id, client_ip=_client_ip(request), details={ "target_user_id": payload.user_id, "host_group_id": payload.host_group_id, - "role": payload.role_name, "expires_at": payload.expires_at, + "roles": granted, "expires_at": payload.expires_at, }, ) await conn.commit() - return {"status": "ok"} + return {"status": "ok", "roles_granted": granted} @router.post("/roles/revoke") async def revoke_role( - payload: RoleGrantRequest, request: Request, + payload: RoleRevokeRequest, request: Request, admin: CurrentUser = Depends(require_admin_or_scope("roles", "write")), ): conn = get_db() - role_cursor = await conn.execute("SELECT id FROM roles WHERE name = ?", (payload.role_name,)) - role_row = await role_cursor.fetchone() - if role_row is None: - raise HTTPException(status.HTTP_400_BAD_REQUEST, "Unbekannte Rolle") + scope = _scope(admin) + await _assert_host_group_in_scope(conn, scope, payload.host_group_id) + role_id = await _role_id(conn, payload.role_name) await conn.execute( "DELETE FROM user_hostgroup_roles WHERE user_id = ? AND host_group_id = ? AND role_id = ?", - (payload.user_id, payload.host_group_id, role_row[0]), + (payload.user_id, payload.host_group_id, role_id), ) await write_audit_event( conn, event_type="role_revoked", user_id=admin.id, client_ip=_client_ip(request), @@ -405,15 +1044,19 @@ async def revoke_role( @router.get("/roles") async def list_role_grants(admin: CurrentUser = Depends(require_admin_or_scope("roles", "read"))): conn = get_db() + scope = _scope(admin) + tenant_filter, params = scope.sql_filter("hg.tenant_id") cursor = await conn.execute( - """ + f""" SELECT u.id, u.username, hg.id, hg.name, r.name, uhr.granted_by, uhr.granted_at, uhr.expires_at FROM user_hostgroup_roles uhr JOIN users u ON u.id = uhr.user_id JOIN host_groups hg ON hg.id = uhr.host_group_id JOIN roles r ON r.id = uhr.role_id + WHERE 1=1{tenant_filter} ORDER BY u.username, hg.name, r.name - """ + """, + params, ) rows = await cursor.fetchall() return [ @@ -433,41 +1076,43 @@ async def grant_group_role( admin: CurrentUser = Depends(require_admin_or_scope("roles", "write")), ): conn = get_db() - role_cursor = await conn.execute("SELECT id FROM roles WHERE name = ?", (payload.role_name,)) - role_row = await role_cursor.fetchone() - if role_row is None: - raise HTTPException(status.HTTP_400_BAD_REQUEST, "Unbekannte Rolle") + scope = _scope(admin) + await _assert_host_group_in_scope(conn, scope, payload.host_group_id) + await _assert_user_group_in_scope(conn, scope, payload.user_group_id) - await conn.execute( - "INSERT OR REPLACE INTO group_hostgroup_roles " - "(user_group_id, host_group_id, role_id, granted_by, expires_at) VALUES (?, ?, ?, ?, ?)", - (payload.user_group_id, payload.host_group_id, role_row[0], admin.id, payload.expires_at), - ) + granted = [] + for role_name in payload.role_names: + role_id = await _role_id(conn, role_name) + await conn.execute( + "INSERT OR REPLACE INTO group_hostgroup_roles " + "(user_group_id, host_group_id, role_id, granted_by, expires_at) VALUES (?, ?, ?, ?, ?)", + (payload.user_group_id, payload.host_group_id, role_id, admin.id, payload.expires_at), + ) + granted.append(role_name) await write_audit_event( conn, event_type="group_role_granted", user_id=admin.id, client_ip=_client_ip(request), details={ "user_group_id": payload.user_group_id, "host_group_id": payload.host_group_id, - "role": payload.role_name, "expires_at": payload.expires_at, + "roles": granted, "expires_at": payload.expires_at, }, ) await conn.commit() - return {"status": "ok"} + return {"status": "ok", "roles_granted": granted} @router.post("/group-roles/revoke") async def revoke_group_role( - payload: GroupRoleGrantRequest, request: Request, + payload: GroupRoleRevokeRequest, request: Request, admin: CurrentUser = Depends(require_admin_or_scope("roles", "write")), ): conn = get_db() - role_cursor = await conn.execute("SELECT id FROM roles WHERE name = ?", (payload.role_name,)) - role_row = await role_cursor.fetchone() - if role_row is None: - raise HTTPException(status.HTTP_400_BAD_REQUEST, "Unbekannte Rolle") + scope = _scope(admin) + await _assert_host_group_in_scope(conn, scope, payload.host_group_id) + role_id = await _role_id(conn, payload.role_name) await conn.execute( "DELETE FROM group_hostgroup_roles WHERE user_group_id = ? AND host_group_id = ? AND role_id = ?", - (payload.user_group_id, payload.host_group_id, role_row[0]), + (payload.user_group_id, payload.host_group_id, role_id), ) await write_audit_event( conn, event_type="group_role_revoked", user_id=admin.id, client_ip=_client_ip(request), @@ -483,15 +1128,19 @@ async def revoke_group_role( @router.get("/group-roles") async def list_group_role_grants(admin: CurrentUser = Depends(require_admin_or_scope("roles", "read"))): conn = get_db() + scope = _scope(admin) + tenant_filter, params = scope.sql_filter("hg.tenant_id") cursor = await conn.execute( - """ + f""" SELECT ug.id, ug.name, hg.id, hg.name, r.name, ghr.granted_by, ghr.granted_at, ghr.expires_at FROM group_hostgroup_roles ghr JOIN user_groups ug ON ug.id = ghr.user_group_id JOIN host_groups hg ON hg.id = ghr.host_group_id JOIN roles r ON r.id = ghr.role_id + WHERE 1=1{tenant_filter} ORDER BY ug.name, hg.name, r.name - """ + """, + params, ) rows = await cursor.fetchall() return [ @@ -504,6 +1153,13 @@ async def list_group_role_grants(admin: CurrentUser = Depends(require_admin_or_s ] +@router.get("/roles/names") +async def list_role_names(admin: CurrentUser = Depends(require_admin_or_scope("roles", "read"))): + conn = get_db() + cursor = await conn.execute("SELECT name FROM roles ORDER BY id") + return [r[0] for r in await cursor.fetchall()] + + # --- SSH-Keyverwaltung --------------------------------------------------------- @router.post("/ssh-keys", status_code=status.HTTP_201_CREATED) @@ -515,11 +1171,13 @@ async def create_ssh_key( dem KEK (AES-256-GCM) und haelt den Klartext nur fuer die Dauer dieses Requests im Prozessspeicher (siehe Konzept 6.4: Key verlaesst den Server nie).""" conn = get_db() + scope = _scope(admin) + tenant_id = await _resolve_write_tenant(conn, scope, payload.tenant_id) encrypted = encrypt_secret(payload.private_key_pem.encode(), associated_data=b"ssh_private_key") cursor = await conn.execute( - "INSERT INTO ssh_keys (label, owner_user_id, private_key_enc, public_key, key_type) " - "VALUES (?, ?, ?, ?, ?)", - (payload.label, payload.owner_user_id, encrypted, payload.public_key, payload.key_type), + "INSERT INTO ssh_keys (label, owner_user_id, private_key_enc, public_key, key_type, tenant_id) " + "VALUES (?, ?, ?, ?, ?, ?)", + (payload.label, payload.owner_user_id, encrypted, payload.public_key, payload.key_type, tenant_id), ) new_id = cursor.lastrowid await write_audit_event( @@ -533,26 +1191,102 @@ async def create_ssh_key( @router.get("/ssh-keys") async def list_ssh_keys(admin: CurrentUser = Depends(require_admin_or_scope("ssh_keys", "read"))): conn = get_db() + scope = _scope(admin) + tenant_filter, params = scope.sql_filter("sk.tenant_id") cursor = await conn.execute( - "SELECT id, label, owner_user_id, key_type, created_at, rotated_at, expires_at " - "FROM ssh_keys ORDER BY id" + "SELECT sk.id, sk.label, sk.owner_user_id, sk.key_type, sk.created_at, sk.rotated_at, " + "sk.expires_at, sk.tenant_id, t.name " + "FROM ssh_keys sk JOIN tenants t ON t.id = sk.tenant_id " + f"WHERE 1=1{tenant_filter} ORDER BY sk.id", + params, ) rows = await cursor.fetchall() return [ { "id": r[0], "label": r[1], "owner_user_id": r[2], "key_type": r[3], "created_at": r[4], "rotated_at": r[5], "expires_at": r[6], + "tenant_id": r[7], "tenant_name": r[8], } for r in rows ] +async def _assert_ssh_key_in_scope(conn, scope: TenantScope, key_id: int) -> None: + row = await (await conn.execute("SELECT tenant_id FROM ssh_keys WHERE id = ?", (key_id,))).fetchone() + if row is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "SSH-Key nicht gefunden") + scope.check(row[0]) + + +@router.put("/ssh-keys/{key_id}") +async def update_ssh_key( + key_id: int, payload: SshKeyUpdateRequest, request: Request, + admin: CurrentUser = Depends(require_admin_or_scope("ssh_keys", "write")), +): + conn = get_db() + await _assert_ssh_key_in_scope(conn, _scope(admin), key_id) + + rotating = payload.private_key_pem is not None or payload.public_key is not None or payload.key_type is not None + if rotating and not (payload.private_key_pem and payload.public_key and payload.key_type): + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + "Fuer eine Rotation muessen private_key_pem, public_key UND key_type gemeinsam angegeben werden", + ) + + fields, values = [], [] + if payload.label is not None: + fields.append("label = ?"); values.append(payload.label) + if "owner_user_id" in payload.model_fields_set: + fields.append("owner_user_id = ?"); values.append(payload.owner_user_id) + if rotating: + encrypted = encrypt_secret(payload.private_key_pem.encode(), associated_data=b"ssh_private_key") + fields += ["private_key_enc = ?", "public_key = ?", "key_type = ?", + "rotated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')"] + values += [encrypted, payload.public_key, payload.key_type] + if not fields: + return {"status": "ok", "changed": False} + values.append(key_id) + await conn.execute(f"UPDATE ssh_keys SET {', '.join(fields)} WHERE id = ?", values) + await write_audit_event( + conn, event_type="ssh_key_updated", user_id=admin.id, client_ip=_client_ip(request), + details={"id": key_id, "rotated": rotating}, + ) + await conn.commit() + return {"status": "ok", "changed": True} + + +@router.delete("/ssh-keys/{key_id}") +async def delete_ssh_key( + key_id: int, request: Request, + admin: CurrentUser = Depends(require_admin_or_scope("ssh_keys", "write")), +): + """host_ssh_key_map verweist bewusst OHNE ON DELETE auf ssh_keys(id) -- + Zuordnungen werden hier explizit mit entfernt (samt Vermerk, welche Hosts + betroffen waren) statt den Key unloeschbar zu machen.""" + conn = get_db() + await _assert_ssh_key_in_scope(conn, _scope(admin), key_id) + affected = await (await conn.execute( + "SELECT host_id FROM host_ssh_key_map WHERE ssh_key_id = ?", (key_id,) + )).fetchall() + await conn.execute("DELETE FROM host_ssh_key_map WHERE ssh_key_id = ?", (key_id,)) + await conn.execute("DELETE FROM ssh_keys WHERE id = ?", (key_id,)) + await write_audit_event( + conn, event_type="ssh_key_deleted", user_id=admin.id, client_ip=_client_ip(request), + details={"id": key_id, "unmapped_host_ids": [r[0] for r in affected]}, + ) + await conn.commit() + return {"status": "ok", "unmapped_host_ids": [r[0] for r in affected]} + + @router.post("/hosts/{host_id}/ssh-keys/{key_id}") async def map_ssh_key_to_host( host_id: int, key_id: int, request: Request, admin: CurrentUser = Depends(require_admin_or_scope("ssh_keys", "write")), ): conn = get_db() + scope = _scope(admin) + await _assert_host_in_scope(conn, scope, host_id) + await _assert_ssh_key_in_scope(conn, scope, key_id) await conn.execute( "INSERT OR IGNORE INTO host_ssh_key_map (host_id, ssh_key_id) VALUES (?, ?)", (host_id, key_id), @@ -565,20 +1299,42 @@ async def map_ssh_key_to_host( return {"status": "ok"} +@router.delete("/hosts/{host_id}/ssh-keys/{key_id}") +async def unmap_ssh_key_from_host( + host_id: int, key_id: int, request: Request, + admin: CurrentUser = Depends(require_admin_or_scope("ssh_keys", "write")), +): + conn = get_db() + scope = _scope(admin) + await _assert_host_in_scope(conn, scope, host_id) + await conn.execute( + "DELETE FROM host_ssh_key_map WHERE host_id = ? AND ssh_key_id = ?", (host_id, key_id) + ) + await write_audit_event( + conn, event_type="ssh_key_unmapped", user_id=admin.id, client_ip=_client_ip(request), + details={"host_id": host_id, "ssh_key_id": key_id}, + ) + await conn.commit() + return {"status": "ok"} + + # --- API-Tokens ------------------------------------------------------------ # -# Bewusst ausschliesslich ueber require_global_admin (Session), NIE ueber -# require_admin_or_scope -- ein geleaktes Token darf sich damit nicht selbst -# weitere/staerkere Tokens ausstellen (Privilege-Escalation-Schutz). +# Bewusst ausschliesslich ueber require_admin_session (Session, Super- ODER +# Mandanten-Admin), NIE ueber require_admin_or_scope -- ein geleaktes Token +# darf sich damit nicht selbst weitere/staerkere Tokens ausstellen +# (Privilege-Escalation-Schutz). Ein Mandanten-Admin sieht/verwaltet nur +# Tokens seines/seiner Mandanten (tenant_id, siehe Migration 0006) und kann +# Tokens nur fuer Benutzer seines Mandanten ausstellen. @router.get("/scopes") -async def list_valid_scopes(admin: CurrentUser = Depends(require_global_admin)): +async def list_valid_scopes(admin: CurrentUser = Depends(require_admin_session)): return {"scopes": sorted(VALID_SCOPES)} @router.post("/tokens", status_code=status.HTTP_201_CREATED) async def create_api_token( - payload: ApiTokenCreateRequest, request: Request, admin: CurrentUser = Depends(require_global_admin) + payload: ApiTokenCreateRequest, request: Request, admin: CurrentUser = Depends(require_admin_session) ): try: scopes = validate_scopes(payload.scopes) @@ -586,17 +1342,22 @@ async def create_api_token( raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc conn = get_db() - user_cursor = await conn.execute("SELECT 1 FROM users WHERE id = ?", (payload.user_id,)) + scope = _scope(admin) + tenant_id = await _resolve_write_tenant(conn, scope, payload.tenant_id) + await _assert_user_in_scope(conn, scope, payload.user_id) + user_cursor = await conn.execute( + "SELECT 1 FROM users WHERE id = ? AND deleted_at IS NULL", (payload.user_id,) + ) if await user_cursor.fetchone() is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Benutzer nicht gefunden") token = generate_token() cursor = await conn.execute( "INSERT INTO api_tokens (user_id, label, token_hash, token_prefix, scopes_json, " - "created_by, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + "created_by, expires_at, tenant_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", ( payload.user_id, payload.label, hash_token(token), token_prefix_for_display(token), - json.dumps(scopes), admin.id, payload.expires_at, + json.dumps(scopes), admin.id, payload.expires_at, tenant_id, ), ) new_id = cursor.lastrowid @@ -604,7 +1365,7 @@ async def create_api_token( conn, event_type="api_token_created", user_id=admin.id, client_ip=_client_ip(request), details={ "token_id": new_id, "target_user_id": payload.user_id, "label": payload.label, - "scopes": scopes, "expires_at": payload.expires_at, + "scopes": scopes, "expires_at": payload.expires_at, "tenant_id": tenant_id, }, ) await conn.commit() @@ -614,19 +1375,23 @@ async def create_api_token( @router.get("/tokens") -async def list_api_tokens(admin: CurrentUser = Depends(require_global_admin)): +async def list_api_tokens(admin: CurrentUser = Depends(require_admin_session)): conn = get_db() + scope = _scope(admin) + tenant_filter, params = scope.sql_filter("t.tenant_id") cursor = await conn.execute( "SELECT t.id, t.user_id, u.username, t.label, t.token_prefix, t.scopes_json, " - "t.created_at, t.expires_at, t.last_used_at, t.revoked_at " - "FROM api_tokens t JOIN users u ON u.id = t.user_id ORDER BY t.id DESC" + "t.created_at, t.expires_at, t.last_used_at, t.revoked_at, t.tenant_id, tn.name " + "FROM api_tokens t JOIN users u ON u.id = t.user_id JOIN tenants tn ON tn.id = t.tenant_id " + f"WHERE 1=1{tenant_filter} ORDER BY t.id DESC", + params, ) rows = await cursor.fetchall() return [ { "id": r[0], "user_id": r[1], "username": r[2], "label": r[3], "prefix": r[4], "scopes": json.loads(r[5]), "created_at": r[6], "expires_at": r[7], - "last_used_at": r[8], "revoked_at": r[9], + "last_used_at": r[8], "revoked_at": r[9], "tenant_id": r[10], "tenant_name": r[11], } for r in rows ] @@ -634,9 +1399,13 @@ async def list_api_tokens(admin: CurrentUser = Depends(require_global_admin)): @router.post("/tokens/{token_id}/revoke") async def revoke_api_token( - token_id: int, request: Request, admin: CurrentUser = Depends(require_global_admin) + token_id: int, request: Request, admin: CurrentUser = Depends(require_admin_session) ): conn = get_db() + row = await (await conn.execute("SELECT tenant_id FROM api_tokens WHERE id = ?", (token_id,))).fetchone() + if row is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Token nicht gefunden") + _scope(admin).check(row[0]) await conn.execute( "UPDATE api_tokens SET revoked_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') " "WHERE id = ? AND revoked_at IS NULL", @@ -659,12 +1428,27 @@ async def get_audit_log( ): limit = max(1, min(limit, 1000)) conn = get_db() - cursor = await conn.execute( - "SELECT id, ts, user_id, client_ip, event_type, details_json FROM audit_log " - "ORDER BY id DESC LIMIT ? OFFSET ?", - (limit, offset), - ) - rows = await cursor.fetchall() + scope = _scope(admin) + if scope.all_tenants: + cursor = await conn.execute( + "SELECT id, ts, user_id, client_ip, event_type, details_json FROM audit_log " + "ORDER BY id DESC LIMIT ? OFFSET ?", + (limit, offset), + ) + rows = await cursor.fetchall() + else: + visible: set[int] = set() + for tid in scope.tenant_ids: + visible |= await tenant_user_ids(conn, tid) + if not visible: + return [] + placeholders = ",".join("?" for _ in visible) + cursor = await conn.execute( + f"SELECT id, ts, user_id, client_ip, event_type, details_json FROM audit_log " + f"WHERE user_id IN ({placeholders}) ORDER BY id DESC LIMIT ? OFFSET ?", + (*visible, limit, offset), + ) + rows = await cursor.fetchall() return [ {"id": r[0], "ts": r[1], "user_id": r[2], "client_ip": r[3], "event_type": r[4], "details": r[5]} for r in rows @@ -673,7 +1457,9 @@ async def get_audit_log( @router.get("/audit-log/verify") async def verify_audit_log(admin: CurrentUser = Depends(require_admin_or_scope("audit", "read"))): - """Prueft die Hash-Chain auf Manipulationsfreiheit (Konzept 6.1/4.7).""" + """Prueft die GESAMTE Hash-Chain auf Manipulationsfreiheit (Konzept 6.1/4.7) + -- bewusst fuer jeden Admin (auch Mandanten-Admins) verfuegbar, da nur + intakt/kaputt zurueckgegeben wird, keine mandantenfremden Inhalte.""" conn = get_db() intact, broken_at = await verify_chain(conn) return {"intact": intact, "first_broken_id": broken_at} diff --git a/app/auth/deps.py b/app/auth/deps.py index 47dbf87..daaa059 100644 --- a/app/auth/deps.py +++ b/app/auth/deps.py @@ -2,7 +2,7 @@ from __future__ import annotations import json -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import datetime, timezone from fastapi import Cookie, Depends, Header, HTTPException, Request, Response, WebSocket, status @@ -22,7 +22,25 @@ from app.security.sessions import ( class CurrentUser: id: int username: str - is_admin: bool + is_admin: bool # Super-Admin (siehe Migration 0006, Mandantenfaehigkeit): + # sieht/verwaltet ALLE Mandanten. Ein Token ist NIE is_admin=True, auch + # wenn der zugehoerige User selbst Super-Admin ist (siehe unten). + tenant_admin_ids: frozenset[int] = field(default_factory=frozenset) + # Nur bei Token-Auth gesetzt: das Token ist zusaetzlich auf GENAU diesen + # einen Mandanten eingeschraenkt (siehe app/db/migrations/0006_tenants.sql: + # api_tokens.tenant_id ist NOT NULL -- jedes Token gehoert einem Mandanten). + token_tenant_id: int | None = None + + @property + def is_any_admin(self) -> bool: + """Super-Admin ODER Mandanten-Admin (mind. eines Mandanten).""" + return self.is_admin or bool(self.tenant_admin_ids) + + +async def _load_tenant_admin_ids(conn, user_id: int) -> frozenset[int]: + cursor = await conn.execute("SELECT tenant_id FROM tenant_admins WHERE user_id = ?", (user_id,)) + rows = await cursor.fetchall() + return frozenset(r[0] for r in rows) async def get_current_user( @@ -39,7 +57,8 @@ async def get_current_user( conn = get_db() cursor = await conn.execute( - "SELECT id, username, is_admin, is_active, session_version FROM users WHERE id = ?", + "SELECT id, username, is_admin, is_active, session_version FROM users " + "WHERE id = ? AND deleted_at IS NULL", (payload.user_id,), ) row = await cursor.fetchone() @@ -59,7 +78,8 @@ async def get_current_user( path="/", ) - return CurrentUser(id=row[0], username=row[1], is_admin=bool(row[2])) + tenant_admin_ids = await _load_tenant_admin_ids(conn, row[0]) + return CurrentUser(id=row[0], username=row[1], is_admin=bool(row[2]), tenant_admin_ids=tenant_admin_ids) async def get_current_user_ws(websocket: WebSocket) -> CurrentUser | None: @@ -75,7 +95,8 @@ async def get_current_user_ws(websocket: WebSocket) -> CurrentUser | None: conn = get_db() cursor = await conn.execute( - "SELECT id, username, is_admin, is_active, session_version FROM users WHERE id = ?", + "SELECT id, username, is_admin, is_active, session_version FROM users " + "WHERE id = ? AND deleted_at IS NULL", (payload.user_id,), ) row = await cursor.fetchone() @@ -85,7 +106,18 @@ async def get_current_user_ws(websocket: WebSocket) -> CurrentUser | None: async def require_global_admin(user: CurrentUser = Depends(get_current_user)) -> CurrentUser: + """NUR Super-Admin (Session, kein Token). Fuer die maechtigsten Aktionen: + Mandanten anlegen/loeschen, Mandanten-Admins ernennen, API-Dokumentation.""" if not user.is_admin: + raise HTTPException(status.HTTP_403_FORBIDDEN, "Super-Admin-Rechte erforderlich") + return user + + +async def require_admin_session(user: CurrentUser = Depends(get_current_user)) -> CurrentUser: + """Super-Admin ODER Mandanten-Admin, aber ausschliesslich per Session + (NIE per Token) -- fuer Aktionen, die bewusst keinen Token-Bypass erlauben + sollen (z.B. Token-Verwaltung selbst, Mandanten-Admin-Zuweisung lesen).""" + if not user.is_any_admin: raise HTTPException(status.HTTP_403_FORBIDDEN, "Admin-Rechte erforderlich") return user @@ -131,10 +163,12 @@ def require_host_role(role_name: str): # User, Rollen, ...), NICHT fuer SSH/RDP/SFTP-Sitzungen selbst -- die bleiben # ausschliesslich an eine eingeloggte, TOTP-verifizierte Browser-Session # gebunden. Ein Token ist nie maechtiger als sein explizit vergebener Scope, -# auch wenn der zugehoerige User Admin ist (kein automatisches is_admin). -# Die Tokenverwaltung selbst (/admin/tokens/*) laeuft ausschliesslich ueber -# require_global_admin (Session), niemals ueber ein Token -- ein geleaktes -# Token darf sich damit nicht selbst weitere/staerkere Tokens ausstellen. +# auch wenn der zugehoerige User Admin ist (kein automatisches is_admin), UND +# ist seit Migration 0006 zusaetzlich auf GENAU EINEN Mandanten eingeschraenkt +# (token_tenant_id). Die Tokenverwaltung selbst (/admin/tokens/*) laeuft +# ausschliesslich ueber require_admin_session (Session, Super- ODER +# Mandanten-Admin), niemals ueber ein Token -- ein geleaktes Token darf sich +# damit nicht selbst weitere/staerkere Tokens ausstellen. async def _validate_api_token(token: str, *, resource: str, action: str) -> CurrentUser | None: @@ -143,15 +177,16 @@ async def _validate_api_token(token: str, *, resource: str, action: str) -> Curr conn = get_db() token_hash = hash_token(token) cursor = await conn.execute( - "SELECT at.id, at.user_id, at.scopes_json, at.expires_at, at.revoked_at, " + "SELECT at.id, at.user_id, at.scopes_json, at.expires_at, at.revoked_at, at.tenant_id, " "u.username, u.is_active " - "FROM api_tokens at JOIN users u ON u.id = at.user_id WHERE at.token_hash = ?", + "FROM api_tokens at JOIN users u ON u.id = at.user_id " + "WHERE at.token_hash = ? AND u.deleted_at IS NULL", (token_hash,), ) row = await cursor.fetchone() if row is None: return None - token_id, user_id, scopes_json, expires_at, revoked_at, username, is_active = row + token_id, user_id, scopes_json, expires_at, revoked_at, tenant_id, username, is_active = row if revoked_at is not None or not is_active: return None now_str = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ") @@ -166,15 +201,17 @@ async def _validate_api_token(token: str, *, resource: str, action: str) -> Curr (token_id,), ) await conn.commit() - return CurrentUser(id=user_id, username=username, is_admin=False) + return CurrentUser(id=user_id, username=username, is_admin=False, token_tenant_id=tenant_id) def require_admin_or_scope(resource: str, action: str): """Dependency-Factory fuer die Admin-API: erlaubt Zugriff entweder ueber - eine eingeloggte Admin-Session (Cookie, wie bisher) ODER ueber ein - API-Token mit passendem Scope (Header 'Authorization: Bearer '). - 'write' schliesst 'read' auf derselben Ressource ein (siehe - token_has_scope).""" + eine eingeloggte Admin-Session (Super- ODER Mandanten-Admin, Cookie) ODER + ueber ein API-Token mit passendem Scope (Header 'Authorization: Bearer + '). 'write' schliesst 'read' auf derselben Ressource ein (siehe + token_has_scope). Mandanten-Scoping selbst erfolgt NICHT hier, sondern im + jeweiligen Endpunkt ueber app/tenancy.py::TenantScope, basierend auf + effective_tenant_ids() des zurueckgegebenen CurrentUser.""" async def _dep( response: Response, @@ -192,8 +229,25 @@ def require_admin_or_scope(resource: str, action: str): return principal user = await get_current_user(response, jh_session) - if not user.is_admin: + if not user.is_any_admin: raise HTTPException(status.HTTP_403_FORBIDDEN, "Admin-Rechte erforderlich") return user return _dep + + +def effective_tenant_ids(admin: CurrentUser) -> set[int] | None: + """None bedeutet 'alle Mandanten' (Super-Admin-Session, nicht ueber ein + mandantengebundenes Token authentifiziert). Sonst ein Set mit genau den + Mandanten-IDs, auf die der Principal beschraenkt ist: + - Token-Auth: immer genau {token_tenant_id} (Tokens sind nie + Mandanten-UEBERGREIFEND einsetzbar, siehe Migration 0006). + - Mandanten-Admin-Session: die Menge ihrer tenant_admin_ids. + - Ein regulaerer (Nicht-Admin-)User wuerde require_admin_or_scope / + require_admin_session ohnehin nie passieren -- kommt hier praktisch + nicht an.""" + if admin.token_tenant_id is not None: + return {admin.token_tenant_id} + if admin.is_admin: + return None + return set(admin.tenant_admin_ids) diff --git a/app/auth/routes.py b/app/auth/routes.py index 5b609d3..c15279f 100644 --- a/app/auth/routes.py +++ b/app/auth/routes.py @@ -327,4 +327,19 @@ async def change_password( @router.get("/me") async def me(user: CurrentUser = Depends(get_current_user)): - return {"id": user.id, "username": user.username, "is_admin": user.is_admin} + tenant_admin_of = [] + if user.tenant_admin_ids: + conn = get_db() + placeholders = ",".join("?" for _ in user.tenant_admin_ids) + cursor = await conn.execute( + f"SELECT id, name FROM tenants WHERE id IN ({placeholders}) ORDER BY name", + tuple(user.tenant_admin_ids), + ) + tenant_admin_of = [{"id": r[0], "name": r[1]} for r in await cursor.fetchall()] + return { + "id": user.id, + "username": user.username, + "is_admin": user.is_admin, + "is_any_admin": user.is_any_admin, + "tenant_admin_of": tenant_admin_of, + } diff --git a/app/db/migrations/0006_tenants.sql b/app/db/migrations/0006_tenants.sql new file mode 100644 index 0000000..823226a --- /dev/null +++ b/app/db/migrations/0006_tenants.sql @@ -0,0 +1,65 @@ +-- Mandantenfaehigkeit (volle Isolation, siehe Konzept-Erweiterung dieser +-- Session): jede Hostgruppe, Benutzergruppe, jeder SSH-Key und jedes +-- API-Token gehoert genau einem Mandanten. Ein Benutzer kann Mandanten-Admin +-- fuer einen oder mehrere Mandanten sein (tenant_admins) -- er sieht/ +-- verwaltet dann AUSSCHLIESSLICH Ressourcen seines/seiner Mandanten +-- (Durchsetzung zentral in app/tenancy.py + app/auth/deps.py, angewendet in +-- jedem Endpunkt in app/admin/routes.py). Das bestehende users.is_admin +-- bleibt unveraendert bestehen und wird zum "Super-Admin": sieht/verwaltet +-- weiterhin ALLE Mandanten uebergreifend (volle Abwaertskompatibilitaet zu +-- allen bisherigen Admin-Workflows dieser App). +-- +-- Rueckwirkende Kompatibilitaet: ein Standard-Mandant (id=1) wird automatisch +-- angelegt und alle VOR dieser Migration bestehenden Hostgruppen/ +-- Benutzergruppen/SSH-Keys/API-Tokens werden ihm zugeordnet -- fuer +-- Super-Admins aendert sich dadurch am sichtbaren Verhalten nichts. + +CREATE TABLE IF NOT EXISTS tenants ( + id INTEGER PRIMARY KEY, + name TEXT UNIQUE NOT NULL, + description TEXT, + is_active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); +INSERT OR IGNORE INTO tenants (id, name, description) VALUES + (1, 'Standard', 'Automatisch angelegter Standard-Mandant (Daten von vor der Einfuehrung der Mandantenfaehigkeit).'); + +-- Mandanten-Admin-Zuweisung ist bewusst eine eigene Tabelle (statt eines +-- einzelnen users.tenant_id) -- ein User kann Mandanten-Admin fuer MEHRERE +-- Mandanten sein (z.B. externer Dienstleister fuer mehrere Kunden), ohne +-- gleich globaler Super-Admin zu sein. Nur ein Super-Admin darf diese +-- Zuweisung vornehmen (require_global_admin, siehe admin/routes.py). +CREATE TABLE IF NOT EXISTS tenant_admins ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + tenant_id INTEGER NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + granted_by INTEGER REFERENCES users(id), + granted_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + PRIMARY KEY (user_id, tenant_id) +); +CREATE INDEX IF NOT EXISTS idx_tenant_admins_user ON tenant_admins(user_id); + +ALTER TABLE host_groups ADD COLUMN tenant_id INTEGER NOT NULL DEFAULT 1 REFERENCES tenants(id); +ALTER TABLE user_groups ADD COLUMN tenant_id INTEGER NOT NULL DEFAULT 1 REFERENCES tenants(id); +ALTER TABLE ssh_keys ADD COLUMN tenant_id INTEGER NOT NULL DEFAULT 1 REFERENCES tenants(id); +-- api_tokens: bewusst NICHT nullable -- jedes Token (auch von einem +-- Super-Admin erzeugte) ist genau einem Mandanten zugeordnet. Ein Token ist +-- (wie schon vor dieser Migration) nie is_admin=True; ohne verpflichtenden +-- Mandantenbezug waere unklar, welche Mandanten-Ressourcen es sehen darf -- +-- lieber explizit pro Mandant ein Token ausstellen als eine mehrdeutige +-- "gilt ueberall"-Sonderregel einzufuehren (Prinzip: fail closed). +ALTER TABLE api_tokens ADD COLUMN tenant_id INTEGER NOT NULL DEFAULT 1 REFERENCES tenants(id); + +-- users.home_tenant_id: rein informativ/UX -- der Mandant, "fuer" den ein +-- Benutzer angelegt wurde (automatisch gesetzt, wenn ein Mandanten-Admin den +-- User anlegt), damit ein frisch angelegter User sofort in der Mandanten- +-- Admin-Sicht auftaucht, auch bevor er einer Gruppe zugeordnet oder ihm eine +-- Rolle gewaehrt wurde. Erzwingt KEINE Zugriffsbeschraenkung selbst -- die +-- eigentliche Sichtbarkeit/Isolation ergibt sich weiterhin aus Rollen- +-- Vergabe auf Hostgruppen eines Mandanten (siehe app/tenancy.py). +ALTER TABLE users ADD COLUMN home_tenant_id INTEGER REFERENCES tenants(id); + +CREATE INDEX IF NOT EXISTS idx_host_groups_tenant ON host_groups(tenant_id); +CREATE INDEX IF NOT EXISTS idx_user_groups_tenant ON user_groups(tenant_id); +CREATE INDEX IF NOT EXISTS idx_ssh_keys_tenant ON ssh_keys(tenant_id); +CREATE INDEX IF NOT EXISTS idx_api_tokens_tenant ON api_tokens(tenant_id); +CREATE INDEX IF NOT EXISTS idx_users_home_tenant ON users(home_tenant_id); diff --git a/app/db/migrations/0007_crud_extras.sql b/app/db/migrations/0007_crud_extras.sql new file mode 100644 index 0000000..75d8462 --- /dev/null +++ b/app/db/migrations/0007_crud_extras.sql @@ -0,0 +1,14 @@ +-- Ergaenzung fuer vollstaendige Loesch-/Editierbarkeit (siehe app/admin/routes.py): +-- +-- users.deleted_at: ein Benutzerkonto kann nicht immer per hartem SQL-DELETE +-- entfernt werden, weil audit_log.user_id (bewusst, siehe 0001_initial.sql) +-- OHNE ON DELETE CASCADE auf users(id) verweist -- das manipulationssichere +-- Audit-Log darf durch das Loeschen eines Kontos nicht nachtraeglich +-- veraendert oder seiner Zuordnung beraubt werden (jede Aenderung an +-- audit_log-Zeilen wuerde die Hash-Chain brechen bzw. ist durch DB-Trigger +-- ohnehin hart verboten). Ein Konto mit vorhandener Audit-Historie (praktisch +-- jedes jemals eingeloggte Konto) wird deshalb beim "Loeschen" stattdessen +-- deaktiviert, anonymisiert (Benutzername/Passwort/TOTP-Secret geloescht) +-- und hier markiert; nur ein Konto OHNE jede Audit-Historie wird tatsaechlich +-- per SQL-DELETE entfernt. Siehe delete_user() in app/admin/routes.py. +ALTER TABLE users ADD COLUMN deleted_at TEXT; diff --git a/app/models/schemas.py b/app/models/schemas.py index 178bb4c..f74e538 100644 --- a/app/models/schemas.py +++ b/app/models/schemas.py @@ -15,6 +15,11 @@ from pydantic import BaseModel, Field, field_validator USERNAME_RE = re.compile(r"^[a-zA-Z0-9._-]{3,64}$") HOSTNAME_LABEL_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$") +ROLE_NAME = Literal[ + "ssh_connect", "rdp_connect", "file_transfer", "clipboard", + "session_recording_view", "admin_hostgroup", +] + class LoginRequest(BaseModel): username: str = Field(min_length=3, max_length=64) @@ -46,6 +51,11 @@ class UserCreateRequest(BaseModel): username: str = Field(min_length=3, max_length=64) initial_password: str = Field(min_length=12, max_length=256) is_admin: bool = False + # Nur relevant, wenn der anlegende Principal Mandanten-Admin ist: wird + # serverseitig ohnehin auf den/die erlaubten Mandanten erzwungen (siehe + # create_user() in admin/routes.py) -- ein Mandanten-Admin kann hierueber + # KEINEN User einem fremden Mandanten zuordnen. Fuer Super-Admins optional. + home_tenant_id: int | None = None @field_validator("username") @classmethod @@ -55,9 +65,38 @@ class UserCreateRequest(BaseModel): return v +class UserUpdateRequest(BaseModel): + is_admin: bool | None = None + is_active: bool | None = None + new_password: str | None = Field(default=None, min_length=12, max_length=256) + + +class TenantCreateRequest(BaseModel): + name: str = Field(min_length=1, max_length=128) + description: str | None = Field(default=None, max_length=1024) + + +class TenantUpdateRequest(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=128) + description: str | None = Field(default=None, max_length=1024) + is_active: bool | None = None + + +class TenantAdminAssignRequest(BaseModel): + user_id: int + + class HostGroupCreateRequest(BaseModel): name: str = Field(min_length=1, max_length=128) description: str | None = Field(default=None, max_length=1024) + # Nur fuer Super-Admins relevant (Mandanten-Admins werden serverseitig + # ohnehin auf ihren eigenen Mandanten festgelegt, siehe admin/routes.py). + tenant_id: int | None = None + + +class HostGroupUpdateRequest(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=128) + description: str | None = Field(default=None, max_length=1024) class HostCreateRequest(BaseModel): @@ -83,19 +122,49 @@ class HostCreateRequest(BaseModel): return v +class HostUpdateRequest(BaseModel): + host_group_id: int | None = None + hostname: str | None = Field(default=None, min_length=1, max_length=128) + address: str | None = Field(default=None, min_length=1, max_length=255) + port: int | None = Field(default=None, gt=0, le=65535) + ssh_username: str | None = None + rdp_username: str | None = None + rdp_domain: str | None = None + rdp_require_nla: bool | None = None + clipboard_enabled: bool | None = None + file_transfer_enabled: bool | None = None + is_active: bool | None = None + + @field_validator("hostname") + @classmethod + def check_hostname(cls, v: str | None) -> str | None: + if v is not None and not HOSTNAME_LABEL_RE.match(v): + raise ValueError("Ungueltiger Hostname") + return v + + class RoleGrantRequest(BaseModel): user_id: int host_group_id: int - role_name: Literal[ - "ssh_connect", "rdp_connect", "file_transfer", "clipboard", - "session_recording_view", "admin_hostgroup", - ] + role_names: list[ROLE_NAME] = Field(min_length=1, max_length=6) expires_at: str | None = None +class RoleRevokeRequest(BaseModel): + user_id: int + host_group_id: int + role_name: ROLE_NAME + + class UserGroupCreateRequest(BaseModel): name: str = Field(min_length=1, max_length=128) description: str | None = Field(default=None, max_length=1024) + tenant_id: int | None = None + + +class UserGroupUpdateRequest(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=128) + description: str | None = Field(default=None, max_length=1024) class GroupMemberRequest(BaseModel): @@ -105,18 +174,24 @@ class GroupMemberRequest(BaseModel): class GroupRoleGrantRequest(BaseModel): user_group_id: int host_group_id: int - role_name: Literal[ - "ssh_connect", "rdp_connect", "file_transfer", "clipboard", - "session_recording_view", "admin_hostgroup", - ] + role_names: list[ROLE_NAME] = Field(min_length=1, max_length=6) expires_at: str | None = None +class GroupRoleRevokeRequest(BaseModel): + user_group_id: int + host_group_id: int + role_name: ROLE_NAME + + class ApiTokenCreateRequest(BaseModel): label: str = Field(min_length=1, max_length=128) user_id: int scopes: list[str] = Field(min_length=1, max_length=32) expires_at: str | None = None + # Nur fuer Super-Admins relevant -- ein Mandanten-Admin wird serverseitig + # ohnehin auf seinen eigenen Mandanten festgelegt. + tenant_id: int | None = None class SshKeyCreateRequest(BaseModel): @@ -125,6 +200,18 @@ class SshKeyCreateRequest(BaseModel): private_key_pem: str = Field(min_length=1, max_length=32_768) public_key: str = Field(min_length=1, max_length=8192) key_type: Literal["ed25519", "rsa-3072", "rsa-4096", "ca-cert"] + tenant_id: int | None = None + + +class SshKeyUpdateRequest(BaseModel): + label: str | None = Field(default=None, min_length=1, max_length=128) + owner_user_id: int | None = None + # Wenn gesetzt, wird der Schluessel rotiert (neues Schluesselmaterial, + # rotated_at wird aktualisiert). Alle drei Felder muessen dann zusammen + # angegeben werden (siehe rotate_ssh_key() in admin/routes.py). + private_key_pem: str | None = Field(default=None, min_length=1, max_length=32_768) + public_key: str | None = Field(default=None, min_length=1, max_length=8192) + key_type: Literal["ed25519", "rsa-3072", "rsa-4096", "ca-cert"] | None = None class ConnectRequest(BaseModel): diff --git a/app/tenancy.py b/app/tenancy.py new file mode 100644 index 0000000..4e0b0b6 --- /dev/null +++ b/app/tenancy.py @@ -0,0 +1,97 @@ +""" +Mandanten-Scoping: zentrale Stelle, die die Mandanten-Sichtbarkeit eines +Principals (Super-Admin / Mandanten-Admin-Session / Mandanten-gebundenes +API-Token, siehe app/auth/deps.py) in SQL-WHERE-Fragmente und +Zugriffspruefungen uebersetzt. JEDER /admin-Endpunkt, der mandantengebundene +Ressourcen (Hostgruppen, Hosts, Benutzergruppen, SSH-Keys, API-Tokens, +Rollenvergaben, Audit-Log) liest oder schreibt, muss ueber TenantScope gehen +-- damit die Isolation an genau einer Stelle durchgesetzt wird statt in jedem +Endpunkt einzeln neu erfunden zu werden. + +Sicherheitsprinzip: eine Zugriffsverletzung ueber eine ID aus einem fremden +Mandanten liefert 404 (nicht 403) -- ein Mandanten-Admin soll aus der +Fehlerantwort nicht einmal ableiten koennen, DASS eine ID in einem anderen +Mandanten existiert. +""" +from __future__ import annotations + +import aiosqlite +from fastapi import HTTPException, status + + +class TenantScope: + def __init__(self, tenant_ids: set[int] | None): + # tenant_ids is None -> Super-Admin, keine Einschraenkung. + # tenant_ids ist Set -> nur diese Mandanten sichtbar/aenderbar + # (leeres Set == gar kein Mandant -> nichts sichtbar). + self.all_tenants = tenant_ids is None + self.tenant_ids: set[int] = set(tenant_ids) if tenant_ids else set() + + def sql_filter(self, column: str) -> tuple[str, tuple]: + """SQL-Fragment (mit fuehrendem ' AND ...') + Parameter-Tuple, das an + eine bestehende WHERE-Klausel angehaengt werden kann.""" + if self.all_tenants: + return "", () + if not self.tenant_ids: + return " AND 0", () + placeholders = ",".join("?" for _ in self.tenant_ids) + return f" AND {column} IN ({placeholders})", tuple(self.tenant_ids) + + def check(self, tenant_id: int | None) -> None: + """Wirft 404, wenn tenant_id ausserhalb des erlaubten Bereichs liegt.""" + if self.all_tenants: + return + if tenant_id is None or tenant_id not in self.tenant_ids: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Nicht gefunden") + + def single_tenant_id(self) -> int | None: + """Fuer Endpunkte, die zwingend GENAU einen Mandanten brauchen (z.B. + beim Anlegen einer neuen Hostgruppe). None bei Super-Admin (muss dann + explizit im Request mitgegeben werden), sonst der einzige erlaubte + Mandant -- oder ein 400/409, falls der Aufrufer mehrere hat (in dieser + Version hat ein Mandanten-Admin praktisch immer genau einen Mandanten, + die Datenstruktur erlaubt aber theoretisch mehrere).""" + if self.all_tenants or len(self.tenant_ids) != 1: + return None + return next(iter(self.tenant_ids)) + + +async def resolve_host_group_tenant(conn: aiosqlite.Connection, host_group_id: int) -> int | None: + cursor = await conn.execute("SELECT tenant_id FROM host_groups WHERE id = ?", (host_group_id,)) + row = await cursor.fetchone() + return row[0] if row else None + + +async def resolve_host_tenant(conn: aiosqlite.Connection, host_id: int) -> int | None: + cursor = await conn.execute( + "SELECT hg.tenant_id FROM hosts h JOIN host_groups hg ON hg.id = h.host_group_id WHERE h.id = ?", + (host_id,), + ) + row = await cursor.fetchone() + return row[0] if row else None + + +async def tenant_user_ids(conn: aiosqlite.Connection, tenant_id: int) -> set[int]: + """Alle Benutzer, die zu diesem Mandanten gehoeren: Mandanten-Admins, + Benutzer mit home_tenant_id = tenant_id, Mitglieder einer Benutzergruppe + dieses Mandanten, sowie Benutzer mit einer direkten Rollenvergabe auf + einer Hostgruppe dieses Mandanten. Wird sowohl fuer die Benutzer-Liste + einer Mandanten-Admin-Sicht als auch fuer die Audit-Log-Filterung genutzt.""" + cursor = await conn.execute( + """ + SELECT user_id FROM tenant_admins WHERE tenant_id = ? + UNION + SELECT id FROM users WHERE home_tenant_id = ? + UNION + SELECT ugm.user_id FROM user_group_members ugm + JOIN user_groups ug ON ug.id = ugm.user_group_id + WHERE ug.tenant_id = ? + UNION + SELECT uhr.user_id FROM user_hostgroup_roles uhr + JOIN host_groups hg ON hg.id = uhr.host_group_id + WHERE hg.tenant_id = ? + """, + (tenant_id, tenant_id, tenant_id, tenant_id), + ) + rows = await cursor.fetchall() + return {r[0] for r in rows} diff --git a/static/css/app.css b/static/css/app.css index 98b7721..74a503e 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -192,3 +192,30 @@ pre.json-view { .session-toolbar .status { font-size: 0.8rem; color: var(--muted); } #terminal, #rdp-display { flex: 1; background: black; } #rdp-display canvas { display: block; margin: 0 auto; } + +/* --- Dateitransfer-Panel (Terminal-Sitzung) ---------------------------- */ + +.ft-overlay { + position: fixed; inset: 0; background: rgba(0, 0, 0, 0.55); z-index: 50; + display: flex; justify-content: flex-end; +} +.ft-overlay.hidden { display: none !important; } +.ft-panel { + width: 420px; max-width: 100%; height: 100%; background: var(--panel); + border-left: 1px solid var(--border); padding: 1.25rem; overflow-y: auto; +} +.ft-panel h2 { + font-size: 1rem; margin: 0 0 1rem; display: flex; align-items: center; justify-content: space-between; +} +.ft-panel h3 { + font-size: 0.8rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; + margin: 1.5rem 0 0.5rem; +} +.ft-btn-row { display: flex; align-items: center; gap: 0.6rem; margin-top: 0.75rem; } +.ft-btn-row .hint { margin: 0; } +.ft-list { font-size: 0.8rem; } +.ft-list-row { + display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0; border-bottom: 1px solid var(--border); +} +.ft-list-row .name { flex: 1; word-break: break-all; } +.ft-list-row .size { color: var(--muted); flex-shrink: 0; white-space: nowrap; } diff --git a/static/js/admin.js b/static/js/admin.js index 4d8850a..552f131 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -5,8 +5,15 @@ "ssh_connect", "rdp_connect", "file_transfer", "clipboard", "session_recording_view", "admin_hostgroup", ]; + const AUTH_EVENT_TYPES = new Set([ + "login_password_ok", "login_failed", "login_success", "login_totp_failed", + "login_recovery_code_used", "totp_enroll_started", "totp_enroll_confirmed", + "totp_enroll_failed", "logout", "logout_everywhere", "password_changed", + ]); + const FAILURE_EVENT_TYPES = new Set(["login_failed", "login_totp_failed", "totp_enroll_failed"]); const bannerBox = document.getElementById("banner-box"); + let meInfo = { is_admin: false, tenant_admin_of: [] }; function showBanner(message, type) { const div = document.createElement("div"); @@ -80,6 +87,24 @@ }); } + function roleCheckboxGrid(container) { + container.replaceChildren( + ...ROLE_NAMES.map((name) => { + const id = `${container.id}-${name}`; + const row = el("div", { className: "checkbox-row" }); + const checkbox = el("input", { type: "checkbox", id, value: name }); + const label = el("label", { for: id, textContent: name }); + row.appendChild(checkbox); + row.appendChild(label); + return row; + }) + ); + } + + function checkedValues(container) { + return Array.from(container.querySelectorAll("input:checked")).map((c) => c.value); + } + // --------------------------------------------------------------------- // Tabs // --------------------------------------------------------------------- @@ -88,8 +113,11 @@ users: loadUsersTab, groups: loadGroupsTab, hosts: loadHostsTab, + credentials: loadCredentialsTab, roles: loadRolesTab, tokens: loadTokensTab, + tenants: loadTenantsTab, + authlog: loadAuthLogTab, audit: loadAuditTab, }; const loadedTabs = new Set(); @@ -108,23 +136,57 @@ } }); + // --------------------------------------------------------------------- + // Mandanten-Auswahlfelder (fuer Super-Admins Dropdown aller Mandanten, + // fuer Mandanten-Admins nur die eigenen -- kein Server-Request noetig, + // steht bereits in meInfo.tenant_admin_of). + // --------------------------------------------------------------------- + + let cachedTenants = []; + + async function populateTenantSelect(select) { + if (meInfo.is_admin) { + if (cachedTenants.length === 0) { + const result = await getJson("/admin/tenants"); + cachedTenants = result; + } + select.replaceChildren(...optionsFromList(cachedTenants, "id", (t) => `${t.name} (#${t.id})`)); + } else { + select.replaceChildren( + ...optionsFromList(meInfo.tenant_admin_of, "id", (t) => `${t.name} (#${t.id})`) + ); + } + } + // --------------------------------------------------------------------- // Benutzer // --------------------------------------------------------------------- async function loadUsersTab() { + document.getElementById("uc-is-admin-row").classList.toggle("hidden", !meInfo.is_admin); await refreshUsers(); } let cachedUsers = []; + let editingUserId = null; + + function tenantName(tenantId) { + if (tenantId === null || tenantId === undefined) return "-"; + const t = cachedTenants.find((x) => x.id === tenantId); + return t ? t.name : `#${tenantId}`; + } async function refreshUsers() { + if (meInfo.is_admin && cachedTenants.length === 0) { + cachedTenants = await getJson("/admin/tenants"); + } cachedUsers = await getJson("/admin/users"); const tbody = document.querySelector("#users-table tbody"); fillTable( tbody, cachedUsers.map((u) => { const actions = el("td", {}); + actions.appendChild(actionButton("Bearbeiten", "btn-secondary", () => showUserEdit(u))); if (u.is_active) { actions.appendChild( actionButton("Deaktivieren", "btn-danger", async () => { @@ -134,12 +196,25 @@ }) ); } + actions.appendChild( + actionButton("Loeschen", "btn-danger", async () => { + const result = await apiFetch(`/admin/users/${u.id}`, { method: "DELETE" }); + showBanner( + result.hard_deleted + ? `Benutzer '${u.username}' vollstaendig geloescht.` + : `Benutzer '${u.username}' hat Audit-Historie und wurde deaktiviert/anonymisiert.`, + "ok" + ); + await refreshUsers(); + }) + ); return el("tr", {}, [ el("td", { textContent: String(u.id) }), el("td", { textContent: u.username }), el("td", { textContent: u.is_admin ? "ja" : "nein" }), el("td", { textContent: u.is_active ? "ja" : "nein" }), el("td", { textContent: u.totp_enrolled ? "ja" : "nein" }), + el("td", { textContent: tenantName(u.home_tenant_id) }), el("td", { textContent: u.created_at }), actions, ]); @@ -147,12 +222,37 @@ ); } + function showUserEdit(u) { + editingUserId = u.id; + document.getElementById("user-edit-panel").classList.remove("hidden"); + document.getElementById("user-edit-name").textContent = `${u.username} (#${u.id})`; + document.getElementById("ue-is-admin-row").classList.toggle("hidden", !meInfo.is_admin); + document.getElementById("ue-is-admin").checked = u.is_admin; + document.getElementById("ue-is-active").checked = u.is_active; + document.getElementById("ue-new-password").value = ""; + } + + document.getElementById("user-edit-form").addEventListener("submit", async (ev) => { + ev.preventDefault(); + try { + const payload = { is_active: document.getElementById("ue-is-active").checked }; + if (meInfo.is_admin) payload.is_admin = document.getElementById("ue-is-admin").checked; + const pw = document.getElementById("ue-new-password").value; + if (pw) payload.new_password = pw; + await sendJson(`/admin/users/${editingUserId}`, "PUT", payload); + showBanner("Benutzer aktualisiert.", "ok"); + await refreshUsers(); + } catch (err) { + showBanner(err.message, "error"); + } + }); + document.getElementById("user-create-form").addEventListener("submit", async (ev) => { ev.preventDefault(); try { const username = document.getElementById("uc-username").value.trim(); const password = document.getElementById("uc-password").value; - const isAdmin = document.getElementById("uc-is-admin").checked; + const isAdmin = meInfo.is_admin && document.getElementById("uc-is-admin").checked; await sendJson("/admin/users", "POST", { username, initial_password: password, is_admin: isAdmin, }); @@ -171,8 +271,11 @@ let cachedGroups = []; let activeGroupId = null; + let editingGroupId = null; async function loadGroupsTab() { + document.getElementById("gc-tenant-box").classList.toggle("hidden", !meInfo.is_admin && meInfo.tenant_admin_of.length <= 1); + await populateTenantSelect(document.getElementById("gc-tenant")); await refreshGroups(); } @@ -186,15 +289,44 @@ el("td", { textContent: String(g.id) }), el("td", { textContent: g.name }), el("td", { textContent: g.description || "" }), + el("td", { textContent: g.tenant_name }), el("td", { textContent: String(g.member_count) }), el("td", {}, [ actionButton("Mitglieder", "btn-secondary", () => showGroupMembers(g)), + actionButton("Bearbeiten", "btn-secondary", () => showGroupEdit(g)), + actionButton("Loeschen", "btn-danger", async () => { + await apiFetch(`/admin/user-groups/${g.id}`, { method: "DELETE" }); + showBanner(`Gruppe '${g.name}' geloescht.`, "ok"); + await refreshGroups(); + }), ]), ]) ) ); } + function showGroupEdit(g) { + editingGroupId = g.id; + document.getElementById("group-edit-panel").classList.remove("hidden"); + document.getElementById("group-edit-name").textContent = g.name; + document.getElementById("ge-name").value = g.name; + document.getElementById("ge-description").value = g.description || ""; + } + + document.getElementById("group-edit-form").addEventListener("submit", async (ev) => { + ev.preventDefault(); + try { + await sendJson(`/admin/user-groups/${editingGroupId}`, "PUT", { + name: document.getElementById("ge-name").value.trim(), + description: document.getElementById("ge-description").value.trim() || null, + }); + showBanner("Gruppe aktualisiert.", "ok"); + await refreshGroups(); + } catch (err) { + showBanner(err.message, "error"); + } + }); + async function showGroupMembers(group) { activeGroupId = group.id; document.getElementById("group-members-panel").classList.remove("hidden"); @@ -232,7 +364,9 @@ try { const name = document.getElementById("gc-name").value.trim(); const description = document.getElementById("gc-description").value.trim() || null; - await sendJson("/admin/user-groups", "POST", { name, description }); + const tenantSelect = document.getElementById("gc-tenant"); + const tenant_id = tenantSelect.value ? Number(tenantSelect.value) : null; + await sendJson("/admin/user-groups", "POST", { name, description, tenant_id }); showBanner(`Benutzergruppe '${name}' angelegt.`, "ok"); ev.target.reset(); await refreshGroups(); @@ -263,9 +397,12 @@ let cachedHosts = []; let cachedSshKeys = []; let activeHostId = null; + let editingHostGroupId = null; async function loadHostsTab() { - await Promise.all([refreshHostGroups(), refreshSshKeys()]); + document.getElementById("hgc-tenant-box").classList.toggle("hidden", !meInfo.is_admin && meInfo.tenant_admin_of.length <= 1); + await populateTenantSelect(document.getElementById("hgc-tenant")); + await Promise.all([refreshHostGroups(), refreshSshKeysCache()]); await refreshHosts(); } @@ -279,6 +416,15 @@ el("td", { textContent: String(g.id) }), el("td", { textContent: g.name }), el("td", { textContent: g.description || "" }), + el("td", { textContent: g.tenant_name }), + el("td", {}, [ + actionButton("Bearbeiten", "btn-secondary", () => showHostGroupEdit(g)), + actionButton("Loeschen", "btn-danger", async () => { + await apiFetch(`/admin/host-groups/${g.id}`, { method: "DELETE" }); + showBanner(`Hostgruppe '${g.name}' geloescht.`, "ok"); + await refreshHostGroups(); + }), + ]), ]) ) ); @@ -286,6 +432,28 @@ hcSelect.replaceChildren(...optionsFromList(cachedHostGroups, "id", (g) => `${g.name} (#${g.id})`)); } + function showHostGroupEdit(g) { + editingHostGroupId = g.id; + document.getElementById("hostgroup-edit-panel").classList.remove("hidden"); + document.getElementById("hostgroup-edit-name").textContent = g.name; + document.getElementById("hge-name").value = g.name; + document.getElementById("hge-description").value = g.description || ""; + } + + document.getElementById("hostgroup-edit-form").addEventListener("submit", async (ev) => { + ev.preventDefault(); + try { + await sendJson(`/admin/host-groups/${editingHostGroupId}`, "PUT", { + name: document.getElementById("hge-name").value.trim(), + description: document.getElementById("hge-description").value.trim() || null, + }); + showBanner("Hostgruppe aktualisiert.", "ok"); + await refreshHostGroups(); + } catch (err) { + showBanner(err.message, "error"); + } + }); + async function refreshHosts() { cachedHosts = await getJson("/admin/hosts"); const groupName = (id) => (cachedHostGroups.find((g) => g.id === id) || {}).name || `#${id}`; @@ -300,40 +468,124 @@ el("td", { textContent: h.protocol }), el("td", { textContent: h.os_type }), el("td", { textContent: groupName(h.host_group_id) }), - el("td", {}, [actionButton("Details", "btn-secondary", () => showHostDetail(h))]), + el("td", { textContent: h.tenant_name }), + el("td", {}, [el("span", { className: `badge ${h.is_active ? "ok" : "danger"}`, textContent: h.is_active ? "aktiv" : "inaktiv" })]), + el("td", {}, [ + actionButton("Details", "btn-secondary", () => showHostDetail(h.id)), + actionButton(h.is_active ? "Loeschen" : "Reaktivieren", h.is_active ? "btn-danger" : "btn-secondary", async () => { + if (h.is_active) { + await apiFetch(`/admin/hosts/${h.id}`, { method: "DELETE" }); + showBanner(`Host '${h.hostname}' deaktiviert/geloescht.`, "ok"); + } else { + await sendJson(`/admin/hosts/${h.id}`, "PUT", { is_active: true }); + showBanner(`Host '${h.hostname}' reaktiviert.`, "ok"); + } + await refreshHosts(); + if (activeHostId === h.id) document.getElementById("host-detail-panel").classList.add("hidden"); + }), + ]), ]) ) ); } - async function refreshSshKeys() { + async function refreshSshKeysCache() { cachedSshKeys = await getJson("/admin/ssh-keys"); - const tbody = document.querySelector("#ssh-keys-table tbody"); - fillTable( - tbody, - cachedSshKeys.map((k) => - el("tr", {}, [ - el("td", { textContent: String(k.id) }), - el("td", { textContent: k.label }), - el("td", { textContent: k.key_type }), - el("td", { textContent: k.owner_user_id === null ? "-" : String(k.owner_user_id) }), - el("td", { textContent: k.created_at }), - ]) - ) - ); } - function showHostDetail(host) { - activeHostId = host.id; - document.getElementById("host-detail-panel").classList.remove("hidden"); - document.getElementById("host-detail-name").textContent = `${host.hostname} (#${host.id})`; - document.getElementById("host-detail-ssh").classList.toggle("hidden", host.protocol !== "ssh"); - document.getElementById("host-detail-rdp").classList.toggle("hidden", host.protocol !== "rdp"); - document.getElementById("host-key-result").classList.add("hidden"); - const keySelect = document.getElementById("hkm-key-select"); - keySelect.replaceChildren(...optionsFromList(cachedSshKeys, "id", (k) => `${k.label} (#${k.id})`)); + function setDetailBanner(message, type) { + const box = document.getElementById("host-detail-banner"); + if (!message) { + box.replaceChildren(); + return; + } + box.replaceChildren(el("div", { className: `banner ${type === "error" ? "error" : "ok"}`, textContent: message })); } + async function showHostDetail(hostId) { + setDetailBanner("", "ok"); + try { + const host = await getJson(`/admin/hosts/${hostId}`); + activeHostId = host.id; + document.getElementById("host-detail-panel").classList.remove("hidden"); + document.getElementById("host-detail-name").textContent = `${host.hostname} (#${host.id}, ${host.tenant_name})`; + + document.getElementById("he-hostname").value = host.hostname; + document.getElementById("he-address").value = host.address; + document.getElementById("he-port").value = String(host.port); + document.getElementById("he-ssh-username").value = host.ssh_username || ""; + document.getElementById("he-rdp-username").value = host.rdp_username || ""; + document.getElementById("he-rdp-domain").value = host.rdp_domain || ""; + document.getElementById("he-clipboard").checked = host.clipboard_enabled; + document.getElementById("he-filetransfer").checked = host.file_transfer_enabled; + document.getElementById("he-nla").checked = host.rdp_require_nla; + document.getElementById("he-active").checked = host.is_active; + + const isRdp = host.protocol === "rdp"; + document.getElementById("he-ssh-username-box").classList.toggle("hidden", isRdp); + document.getElementById("he-rdp-username-box").classList.toggle("hidden", !isRdp); + document.getElementById("he-rdp-domain-box").classList.toggle("hidden", !isRdp); + document.getElementById("he-nla-box").classList.toggle("hidden", !isRdp); + document.getElementById("host-detail-ssh").classList.toggle("hidden", isRdp); + document.getElementById("host-detail-rdp").classList.toggle("hidden", !isRdp); + + document.getElementById("host-key-result").classList.add("hidden"); + if (host.ssh_host_key_fingerprint) { + const box = document.getElementById("host-key-result"); + box.textContent = `Aktueller Fingerprint: ${host.ssh_host_key_fingerprint}`; + box.classList.remove("hidden"); + } + + const keysTbody = document.querySelector("#host-ssh-keys-table tbody"); + fillTable( + keysTbody, + host.ssh_keys.map((k) => + el("tr", {}, [ + el("td", { textContent: String(k.id) }), + el("td", { textContent: k.label }), + el("td", {}, [ + actionButton("Entfernen", "btn-danger", async () => { + await apiFetch(`/admin/hosts/${host.id}/ssh-keys/${k.id}`, { method: "DELETE" }); + await showHostDetail(host.id); + }), + ]), + ]) + ) + ); + const keySelect = document.getElementById("hkm-key-select"); + keySelect.replaceChildren(...optionsFromList(cachedSshKeys, "id", (k) => `${k.label} (#${k.id})`)); + + document.getElementById("rdp-cred-status").textContent = host.rdp_credentials_set + ? `RDP-Passwort ist gesetzt (zuletzt aktualisiert: ${host.rdp_credentials_updated_at}).` + : "Noch kein RDP-Passwort gesetzt."; + } catch (err) { + setDetailBanner(err.message, "error"); + } + } + + document.getElementById("host-edit-form").addEventListener("submit", async (ev) => { + ev.preventDefault(); + try { + await sendJson(`/admin/hosts/${activeHostId}`, "PUT", { + hostname: document.getElementById("he-hostname").value.trim(), + address: document.getElementById("he-address").value.trim(), + port: Number(document.getElementById("he-port").value), + ssh_username: document.getElementById("he-ssh-username").value.trim() || null, + rdp_username: document.getElementById("he-rdp-username").value.trim() || null, + rdp_domain: document.getElementById("he-rdp-domain").value.trim() || null, + clipboard_enabled: document.getElementById("he-clipboard").checked, + file_transfer_enabled: document.getElementById("he-filetransfer").checked, + rdp_require_nla: document.getElementById("he-nla").checked, + is_active: document.getElementById("he-active").checked, + }); + showBanner("Host aktualisiert.", "ok"); + await refreshHosts(); + await showHostDetail(activeHostId); + } catch (err) { + setDetailBanner(err.message, "error"); + } + }); + document.getElementById("hc-protocol").addEventListener("change", (ev) => { const isRdp = ev.target.value === "rdp"; document.getElementById("hc-ssh-username-box").classList.toggle("hidden", isRdp); @@ -349,7 +601,9 @@ try { const name = document.getElementById("hgc-name").value.trim(); const description = document.getElementById("hgc-description").value.trim() || null; - await sendJson("/admin/host-groups", "POST", { name, description }); + const tenantSelect = document.getElementById("hgc-tenant"); + const tenant_id = tenantSelect.value ? Number(tenantSelect.value) : null; + await sendJson("/admin/host-groups", "POST", { name, description, tenant_id }); showBanner(`Hostgruppe '${name}' angelegt.`, "ok"); ev.target.reset(); await refreshHostGroups(); @@ -396,7 +650,7 @@ box.classList.remove("hidden"); showBanner("Host-Key ermittelt und gespeichert.", "ok"); } catch (err) { - showBanner(err.message, "error"); + setDetailBanner(err.message, "error"); } }); @@ -406,8 +660,9 @@ const keyId = document.getElementById("hkm-key-select").value; await sendJson(`/admin/hosts/${activeHostId}/ssh-keys/${keyId}`, "POST", {}); showBanner("SSH-Key zugeordnet.", "ok"); + await showHostDetail(activeHostId); } catch (err) { - showBanner(err.message, "error"); + setDetailBanner(err.message, "error"); } }); @@ -418,6 +673,89 @@ await sendJson(`/admin/hosts/${activeHostId}/rdp-credentials`, "PUT", { password }); showBanner("RDP-Zugangsdaten gespeichert.", "ok"); ev.target.reset(); + await showHostDetail(activeHostId); + } catch (err) { + setDetailBanner(err.message, "error"); + } + }); + + // --------------------------------------------------------------------- + // Zugangsdaten (SSH-Keys + RDP-Passwoerter) + // --------------------------------------------------------------------- + + let editingSshKeyId = null; + + async function loadCredentialsTab() { + document.getElementById("skc-tenant-box").classList.toggle("hidden", !meInfo.is_admin && meInfo.tenant_admin_of.length <= 1); + await populateTenantSelect(document.getElementById("skc-tenant")); + await Promise.all([refreshSshKeys(), refreshRdpCredentials()]); + } + + async function refreshSshKeys() { + cachedSshKeys = await getJson("/admin/ssh-keys"); + const tbody = document.querySelector("#ssh-keys-table tbody"); + fillTable( + tbody, + cachedSshKeys.map((k) => + el("tr", {}, [ + el("td", { textContent: String(k.id) }), + el("td", { textContent: k.label }), + el("td", { textContent: k.key_type }), + el("td", { textContent: k.owner_user_id === null ? "-" : String(k.owner_user_id) }), + el("td", { textContent: k.tenant_name }), + el("td", { textContent: k.created_at }), + el("td", { textContent: k.rotated_at || "-" }), + el("td", {}, [ + actionButton("Bearbeiten", "btn-secondary", () => showSshKeyEdit(k)), + actionButton("Loeschen", "btn-danger", async () => { + const result = await apiFetch(`/admin/ssh-keys/${k.id}`, { method: "DELETE" }); + const suffix = result.unmapped_host_ids.length + ? ` (Zuordnung zu Host(s) ${result.unmapped_host_ids.join(", ")} entfernt)` + : ""; + showBanner(`SSH-Key '${k.label}' geloescht.${suffix}`, "ok"); + await refreshSshKeys(); + }), + ]), + ]) + ) + ); + } + + function showSshKeyEdit(k) { + editingSshKeyId = k.id; + document.getElementById("ssh-key-edit-panel").classList.remove("hidden"); + document.getElementById("ssh-key-edit-name").textContent = `${k.label} (#${k.id})`; + document.getElementById("ske-label").value = k.label; + document.getElementById("ske-owner").value = k.owner_user_id === null ? "" : String(k.owner_user_id); + document.getElementById("skr-type").value = k.key_type; + } + + document.getElementById("ssh-key-edit-form").addEventListener("submit", async (ev) => { + ev.preventDefault(); + try { + const ownerRaw = document.getElementById("ske-owner").value.trim(); + await sendJson(`/admin/ssh-keys/${editingSshKeyId}`, "PUT", { + label: document.getElementById("ske-label").value.trim() || null, + owner_user_id: ownerRaw ? Number(ownerRaw) : null, + }); + showBanner("SSH-Key aktualisiert.", "ok"); + await refreshSshKeys(); + } catch (err) { + showBanner(err.message, "error"); + } + }); + + document.getElementById("ssh-key-rotate-form").addEventListener("submit", async (ev) => { + ev.preventDefault(); + try { + await sendJson(`/admin/ssh-keys/${editingSshKeyId}`, "PUT", { + key_type: document.getElementById("skr-type").value, + private_key_pem: document.getElementById("skr-private").value, + public_key: document.getElementById("skr-public").value, + }); + showBanner("SSH-Key rotiert.", "ok"); + ev.target.reset(); + await refreshSshKeys(); } catch (err) { showBanner(err.message, "error"); } @@ -427,42 +765,61 @@ ev.preventDefault(); try { const ownerRaw = document.getElementById("skc-owner").value.trim(); + const tenantSelect = document.getElementById("skc-tenant"); const payload = { label: document.getElementById("skc-label").value.trim(), owner_user_id: ownerRaw ? Number(ownerRaw) : null, key_type: document.getElementById("skc-type").value, private_key_pem: document.getElementById("skc-private").value, public_key: document.getElementById("skc-public").value, + tenant_id: tenantSelect.value ? Number(tenantSelect.value) : null, }; await sendJson("/admin/ssh-keys", "POST", payload); showBanner(`SSH-Key '${payload.label}' angelegt.`, "ok"); ev.target.reset(); await refreshSshKeys(); - if (activeHostId !== null) { - const keySelect = document.getElementById("hkm-key-select"); - keySelect.replaceChildren(...optionsFromList(cachedSshKeys, "id", (k) => `${k.label} (#${k.id})`)); - } } catch (err) { showBanner(err.message, "error"); } }); + async function refreshRdpCredentials() { + const rows = await getJson("/admin/rdp-credentials"); + const tbody = document.querySelector("#rdp-creds-table tbody"); + fillTable( + tbody, + rows.map((r) => + el("tr", {}, [ + el("td", { textContent: r.hostname }), + el("td", { textContent: r.address }), + el("td", { textContent: r.host_group_name }), + el("td", {}, [el("span", { className: `badge ${r.credentials_set ? "ok" : ""}`, textContent: r.credentials_set ? "gesetzt" : "nicht gesetzt" })]), + el("td", { textContent: r.updated_at || "-" }), + el("td", {}, [ + actionButton("Zum Host", "btn-secondary", async () => { + document.querySelector('.tab-btn[data-tab="hosts"]').click(); + await showHostDetail(r.host_id); + }), + ...(r.credentials_set + ? [actionButton("Entfernen", "btn-danger", async () => { + await apiFetch(`/admin/hosts/${r.host_id}/rdp-credentials`, { method: "DELETE" }); + showBanner("RDP-Passwort entfernt.", "ok"); + await refreshRdpCredentials(); + })] + : []), + ]), + ]) + ) + ); + } + // --------------------------------------------------------------------- // Rollen // --------------------------------------------------------------------- - function roleOptions() { - return ROLE_NAMES.map((name) => { - const opt = document.createElement("option"); - opt.value = name; - opt.textContent = name; - return opt; - }); - } - async function loadRolesTab() { - document.getElementById("rg-role").replaceChildren(...roleOptions()); - document.getElementById("grg-role").replaceChildren(...roleOptions()); + roleCheckboxGrid(document.getElementById("rg-role-grid")); + roleCheckboxGrid(document.getElementById("grg-role-grid")); await refreshSharedSelects(); await Promise.all([refreshRoleGrants(), refreshGroupRoleGrants()]); } @@ -518,13 +875,16 @@ document.getElementById("role-grant-form").addEventListener("submit", async (ev) => { ev.preventDefault(); try { + const role_names = checkedValues(document.getElementById("rg-role-grid")); + if (role_names.length === 0) throw new Error("Mindestens eine Rolle auswaehlen."); await sendJson("/admin/roles/grant", "POST", { user_id: Number(document.getElementById("rg-user").value), host_group_id: Number(document.getElementById("rg-hostgroup").value), - role_name: document.getElementById("rg-role").value, + role_names, expires_at: document.getElementById("rg-expires").value.trim() || null, }); - showBanner("Rolle vergeben.", "ok"); + showBanner(`Rolle(n) vergeben: ${role_names.join(", ")}.`, "ok"); + document.querySelectorAll("#rg-role-grid input:checked").forEach((c) => { c.checked = false; }); await refreshRoleGrants(); } catch (err) { showBanner(err.message, "error"); @@ -534,13 +894,16 @@ document.getElementById("group-role-grant-form").addEventListener("submit", async (ev) => { ev.preventDefault(); try { + const role_names = checkedValues(document.getElementById("grg-role-grid")); + if (role_names.length === 0) throw new Error("Mindestens eine Rolle auswaehlen."); await sendJson("/admin/group-roles/grant", "POST", { user_group_id: Number(document.getElementById("grg-group").value), host_group_id: Number(document.getElementById("grg-hostgroup").value), - role_name: document.getElementById("grg-role").value, + role_names, expires_at: document.getElementById("grg-expires").value.trim() || null, }); - showBanner("Rolle an Gruppe vergeben.", "ok"); + showBanner(`Rolle(n) an Gruppe vergeben: ${role_names.join(", ")}.`, "ok"); + document.querySelectorAll("#grg-role-grid input:checked").forEach((c) => { c.checked = false; }); await refreshGroupRoleGrants(); } catch (err) { showBanner(err.message, "error"); @@ -570,6 +933,8 @@ }) ); } + document.getElementById("tc-tenant-box").classList.toggle("hidden", !meInfo.is_admin && meInfo.tenant_admin_of.length <= 1); + await populateTenantSelect(document.getElementById("tc-tenant")); await refreshSharedSelects(); await refreshTokens(); } @@ -599,6 +964,7 @@ el("td", { textContent: t.label }), el("td", { textContent: t.username }), el("td", { textContent: t.prefix }), + el("td", { textContent: t.tenant_name }), scopesCell, el("td", { textContent: t.last_used_at || "nie" }), el("td", { textContent: status }), @@ -615,11 +981,13 @@ if (scopes.length === 0) { throw new Error("Mindestens einen Scope auswaehlen."); } + const tenantSelect = document.getElementById("tc-tenant"); const payload = { label: document.getElementById("tc-label").value.trim(), user_id: Number(document.getElementById("tc-user").value), scopes, expires_at: document.getElementById("tc-expires").value.trim() || null, + tenant_id: tenantSelect.value ? Number(tenantSelect.value) : null, }; const result = await sendJson("/admin/tokens", "POST", payload); const box = document.getElementById("token-reveal-box"); @@ -634,6 +1002,156 @@ } }); + // --------------------------------------------------------------------- + // Mandanten (nur Super-Admin -- Tab-Button ist fuer alle anderen hidden) + // --------------------------------------------------------------------- + + let editingTenantId = null; + let activeTenantAdminsId = null; + + async function loadTenantsTab() { + await refreshTenants(); + } + + async function refreshTenants() { + cachedTenants = await getJson("/admin/tenants"); + const tbody = document.querySelector("#tenants-table tbody"); + fillTable( + tbody, + cachedTenants.map((t) => + el("tr", {}, [ + el("td", { textContent: String(t.id) }), + el("td", { textContent: t.name }), + el("td", { textContent: t.description || "" }), + el("td", { textContent: t.is_active ? "ja" : "nein" }), + el("td", { textContent: String(t.host_group_count) }), + el("td", { textContent: String(t.user_group_count) }), + el("td", {}, [ + actionButton("Admins", "btn-secondary", () => showTenantAdmins(t)), + actionButton("Bearbeiten", "btn-secondary", () => showTenantEdit(t)), + actionButton("Loeschen", "btn-danger", async () => { + await apiFetch(`/admin/tenants/${t.id}`, { method: "DELETE" }); + showBanner(`Mandant '${t.name}' geloescht.`, "ok"); + await refreshTenants(); + }), + ]), + ]) + ) + ); + } + + function showTenantEdit(t) { + editingTenantId = t.id; + document.getElementById("tenant-edit-panel").classList.remove("hidden"); + document.getElementById("tenant-edit-name").textContent = t.name; + document.getElementById("tne-name").value = t.name; + document.getElementById("tne-description").value = t.description || ""; + document.getElementById("tne-active").checked = t.is_active; + } + + document.getElementById("tenant-edit-form").addEventListener("submit", async (ev) => { + ev.preventDefault(); + try { + await sendJson(`/admin/tenants/${editingTenantId}`, "PUT", { + name: document.getElementById("tne-name").value.trim(), + description: document.getElementById("tne-description").value.trim() || null, + is_active: document.getElementById("tne-active").checked, + }); + showBanner("Mandant aktualisiert.", "ok"); + await refreshTenants(); + } catch (err) { + showBanner(err.message, "error"); + } + }); + + async function showTenantAdmins(t) { + activeTenantAdminsId = t.id; + document.getElementById("tenant-admins-panel").classList.remove("hidden"); + document.getElementById("tenant-admins-name").textContent = t.name; + await refreshTenantAdmins(); + const select = document.getElementById("ta-user-select"); + if (cachedUsers.length === 0) await refreshUsers(); + select.replaceChildren(...optionsFromList(cachedUsers, "id", (u) => `${u.username} (#${u.id})`)); + } + + async function refreshTenantAdmins() { + const admins = await getJson(`/admin/tenants/${activeTenantAdminsId}/admins`); + const tbody = document.querySelector("#tenant-admins-table tbody"); + fillTable( + tbody, + admins.map((a) => + el("tr", {}, [ + el("td", { textContent: String(a.user_id) }), + el("td", { textContent: a.username }), + el("td", { textContent: a.granted_at }), + el("td", {}, [ + actionButton("Entfernen", "btn-danger", async () => { + await apiFetch(`/admin/tenants/${activeTenantAdminsId}/admins/${a.user_id}`, { method: "DELETE" }); + await refreshTenantAdmins(); + }), + ]), + ]) + ) + ); + } + + document.getElementById("tenant-create-form").addEventListener("submit", async (ev) => { + ev.preventDefault(); + try { + const name = document.getElementById("tnc-name").value.trim(); + const description = document.getElementById("tnc-description").value.trim() || null; + await sendJson("/admin/tenants", "POST", { name, description }); + showBanner(`Mandant '${name}' angelegt.`, "ok"); + ev.target.reset(); + await refreshTenants(); + } catch (err) { + showBanner(err.message, "error"); + } + }); + + document.getElementById("tenant-admin-add-form").addEventListener("submit", async (ev) => { + ev.preventDefault(); + try { + const userId = Number(document.getElementById("ta-user-select").value); + await sendJson(`/admin/tenants/${activeTenantAdminsId}/admins`, "POST", { user_id: userId }); + showBanner("Mandanten-Admin hinzugefuegt.", "ok"); + await refreshTenantAdmins(); + } catch (err) { + showBanner(err.message, "error"); + } + }); + + // --------------------------------------------------------------------- + // Login-Verlauf (Auth-Log) -- clientseitig aus dem Audit-Log gefiltert + // --------------------------------------------------------------------- + + async function loadAuthLogTab() { + await refreshAuthLog(); + } + + async function refreshAuthLog() { + const entries = await getJson("/admin/audit-log?limit=500"); + const authEntries = entries.filter((e) => AUTH_EVENT_TYPES.has(e.event_type)); + const tbody = document.querySelector("#authlog-table tbody"); + fillTable( + tbody, + authEntries.map((e) => + el("tr", {}, [ + el("td", { textContent: e.ts }), + el("td", { textContent: e.user_id === null ? "-" : String(e.user_id) }), + el("td", { textContent: e.client_ip || "-" }), + el("td", { textContent: e.event_type }), + el("td", {}, [ + el("span", { + className: `badge ${FAILURE_EVENT_TYPES.has(e.event_type) ? "danger" : "ok"}`, + textContent: FAILURE_EVENT_TYPES.has(e.event_type) ? "fehlgeschlagen" : "erfolgreich", + }), + ]), + ]) + ) + ); + } + // --------------------------------------------------------------------- // Audit-Log // --------------------------------------------------------------------- @@ -710,11 +1228,16 @@ async function main() { const me = await getJson("/auth/me"); - if (!me.is_admin) { + if (!me.is_any_admin) { window.location.href = "/dashboard"; return; } - document.getElementById("whoami").textContent = `${me.username} (Admin)`; + meInfo = me; + const roleLabel = me.is_admin + ? "Super-Admin" + : `Mandanten-Admin: ${me.tenant_admin_of.map((t) => t.name).join(", ")}`; + document.getElementById("whoami").textContent = `${me.username} (${roleLabel})`; + document.getElementById("tenants-tab-btn").classList.toggle("hidden", !me.is_admin); loadedTabs.add("users"); await loadUsersTab(); } diff --git a/static/js/terminal.js b/static/js/terminal.js index 7cf0fc4..de81640 100644 --- a/static/js/terminal.js +++ b/static/js/terminal.js @@ -5,6 +5,12 @@ * Copy & Paste: xterm.js liefert dies bei SSH bereits nativ ueber die * System-Zwischenablage (Markieren-zum-Kopieren / Strg+Umschalt+V) -- keine * serverseitige Sonderbehandlung noetig, im Gegensatz zu RDP (siehe rdp.js). + * + * Dateitransfer: eigenes Panel (statt frueherem prompt()-basiertem Upload) + * mit Formularen fuer Upload UND Download in beide Richtungen. Der Download + * laeuft ueber fetch() + Blob + synthetischen -Link, damit + * Fehler inline im Panel angezeigt werden koennen statt die Seite zu + * verlassen (siehe GET /ssh/{host_id}/files/download). */ (() => { "use strict"; @@ -73,17 +79,100 @@ }); document.addEventListener("fullscreenchange", sendResize); - const fileInput = document.getElementById("file-input"); - document.getElementById("upload-btn").addEventListener("click", () => fileInput.click()); - fileInput.addEventListener("change", async () => { - const file = fileInput.files[0]; - if (!file) return; - const remotePath = prompt("Zielpfad auf dem Server:", `/tmp/${file.name}`); - if (!remotePath) return; + // --- Dateitransfer-Panel ------------------------------------------------ + + const ftOverlay = document.getElementById("ft-overlay"); + const ftStatus = document.getElementById("ft-status"); + const ftList = document.getElementById("ft-list"); + const transfers = []; + + function openFt() { + ftOverlay.classList.remove("hidden"); + ftStatus.textContent = ""; + } + function closeFt() { + ftOverlay.classList.add("hidden"); + } + document.getElementById("filetransfer-btn").addEventListener("click", openFt); + document.getElementById("ft-close-btn").addEventListener("click", closeFt); + + function formatSize(bytes) { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + } + + function recordTransfer(direction, filename, size, ok, message) { + transfers.unshift({ direction, filename, size, ok, message }); + if (transfers.length > 20) transfers.length = 20; + renderTransfers(); + } + + function renderTransfers() { + ftList.innerHTML = ""; + if (transfers.length === 0) { + const empty = document.createElement("div"); + empty.className = "hint"; + empty.textContent = "Noch keine Transfers in dieser Sitzung."; + ftList.appendChild(empty); + return; + } + for (const t of transfers) { + const row = document.createElement("div"); + row.className = "ft-list-row"; + + const dir = document.createElement("span"); + dir.className = "badge " + (t.ok ? "ok" : "danger"); + dir.textContent = t.direction === "upload" ? "↑ Upload" : "↓ Download"; + row.appendChild(dir); + + const name = document.createElement("span"); + name.className = "name"; + name.textContent = t.ok ? t.filename : `${t.filename} -- ${t.message}`; + row.appendChild(name); + + if (t.ok) { + const size = document.createElement("span"); + size.className = "size"; + size.textContent = formatSize(t.size); + row.appendChild(size); + } + + ftList.appendChild(row); + } + } + renderTransfers(); + + // Upload + const uploadRemotePathInput = document.getElementById("ft-upload-remote-path"); + const uploadFileInput = document.getElementById("ft-upload-file-input"); + const uploadFilenameLabel = document.getElementById("ft-upload-filename"); + + document.getElementById("ft-upload-pick-btn").addEventListener("click", () => uploadFileInput.click()); + uploadFileInput.addEventListener("change", () => { + const file = uploadFileInput.files[0]; + uploadFilenameLabel.textContent = file ? file.name : "Keine Datei ausgewaehlt"; + if (file && !uploadRemotePathInput.value) { + uploadRemotePathInput.value = `/tmp/${file.name}`; + } + }); + + document.getElementById("ft-upload-submit-btn").addEventListener("click", async () => { + const file = uploadFileInput.files[0]; + const remotePath = uploadRemotePathInput.value.trim(); + ftStatus.textContent = ""; + if (!file) { + ftStatus.textContent = "Bitte zuerst eine Datei auswaehlen."; + return; + } + if (!remotePath) { + ftStatus.textContent = "Bitte einen Zielpfad angeben."; + return; + } const formData = new FormData(); formData.append("file", file); - statusEl.textContent = `Lade ${file.name} hoch ...`; + ftStatus.textContent = `Lade ${file.name} hoch ...`; try { const res = await fetch( `/ssh/${hostId}/files/upload?remote_path=${encodeURIComponent(remotePath)}`, @@ -91,10 +180,58 @@ ); const data = await res.json(); if (!res.ok) throw new Error(data.detail || "Upload fehlgeschlagen"); - statusEl.textContent = `Upload ok (AV: ${data.av_scan_result})`; + ftStatus.textContent = `Upload abgeschlossen (AV: ${data.av_scan_result})`; + recordTransfer("upload", file.name, data.size, true, ""); + uploadFileInput.value = ""; + uploadFilenameLabel.textContent = "Keine Datei ausgewaehlt"; + uploadRemotePathInput.value = ""; } catch (err) { - statusEl.textContent = "Upload-Fehler: " + err.message; + ftStatus.textContent = "Upload-Fehler: " + err.message; + recordTransfer("upload", file.name, 0, false, err.message); + } + }); + + // Download + const downloadRemotePathInput = document.getElementById("ft-download-remote-path"); + + document.getElementById("ft-download-submit-btn").addEventListener("click", async () => { + const remotePath = downloadRemotePathInput.value.trim(); + ftStatus.textContent = ""; + if (!remotePath) { + ftStatus.textContent = "Bitte einen Pfad angeben."; + return; + } + const filename = remotePath.split("/").filter(Boolean).pop() || "download"; + ftStatus.textContent = `Lade ${filename} herunter ...`; + try { + const res = await fetch( + `/ssh/${hostId}/files/download?remote_path=${encodeURIComponent(remotePath)}`, + { method: "GET", credentials: "same-origin" } + ); + if (!res.ok) { + let message = "Download fehlgeschlagen"; + try { + const data = await res.json(); + message = data.detail || message; + } catch (_) { /* Antwort war kein JSON */ } + throw new Error(message); + } + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); + + ftStatus.textContent = `Download abgeschlossen: ${filename}`; + recordTransfer("download", filename, blob.size, true, ""); + downloadRemotePathInput.value = ""; + } catch (err) { + ftStatus.textContent = "Download-Fehler: " + err.message; + recordTransfer("download", filename, 0, false, err.message); } - fileInput.value = ""; }); })(); diff --git a/templates/admin.html b/templates/admin.html index 0670c2d..2eb2a24 100644 --- a/templates/admin.html +++ b/templates/admin.html @@ -24,8 +24,11 @@ + + + @@ -42,9 +45,9 @@ -
+
- +
@@ -57,13 +60,32 @@ IDBenutzernameAdminAktiv - TOTPErstellt + TOTPMandantErstellt
+ + @@ -79,6 +101,10 @@ +
+ + +
@@ -88,12 +114,27 @@

Rollen fuer eine Gruppe (= Verbindungen mit der ganzen Gruppe teilen) werden im Tab "Rollen" vergeben.

- +
IDNameBeschreibungMitglieder
IDNameBeschreibungMandantMitglieder
+ + + +

Neue Verbindung (Host) anlegen

@@ -194,7 +254,7 @@
- +
IDHostnameAdresseProtokollOSHostgruppe
IDHostnameAdresseProtokollOSHostgruppeMandantStatus
@@ -203,33 +263,78 @@ +
+ +