more admin stuff 2
This commit is contained in:
112
README.md
112
README.md
@ -186,13 +186,72 @@ Das Entziehen bleibt bewusst pro Zeile/Rolle (`role_name`, Einzahl) — das
|
|||||||
entspricht dem bestehenden "Entziehen"-Knopf pro Tabellenzeile und braucht
|
entspricht dem bestehenden "Entziehen"-Knopf pro Tabellenzeile und braucht
|
||||||
keine Mehrfachauswahl.
|
keine Mehrfachauswahl.
|
||||||
|
|
||||||
|
## Phase 9: Host-Key-Bugfix, Sessionview, Live-Verbindungslog, Credentials-RBAC
|
||||||
|
|
||||||
|
Sechs zusammenhaengende Punkte aus einer erneuten nummerierten Anfrage, in
|
||||||
|
derselben Session direkt nach Phase 8 umgesetzt:
|
||||||
|
|
||||||
|
**1) Bugfix "Host Key ermitteln" (Fehler 500)**: `discover_and_store_host_key()`
|
||||||
|
(`app/ssh_proxy/proxy.py`) verband sich zwar erfolgreich fuer den Key-Exchange,
|
||||||
|
liess aber den danach IMMER folgenden Authentifizierungsfehler (kein
|
||||||
|
Client-Key fuer die reine Discovery hinterlegt) unbehandelt durchreichen —
|
||||||
|
das riss als unbehandelte Exception bis zu FastAPI durch und fuehrte praktisch
|
||||||
|
bei jedem Klick auf "Host-Key ermitteln" zu einem 500. Fix: der waehrend des
|
||||||
|
Key-Exchange bereits erfasste Fingerprint gilt jetzt trotz des danach
|
||||||
|
erwarteten Auth-Fehlers als Erfolg; nur ein echter Verbindungsfehler VOR dem
|
||||||
|
Key-Exchange (Host nicht erreichbar, DNS, Timeout) liefert jetzt sauber 502
|
||||||
|
statt 500 (neue `HostKeyDiscoveryError`).
|
||||||
|
|
||||||
|
**2) Login-Verlauf entfernt**: der in Phase 8 hinzugefuegte Tab "Login-Verlauf"
|
||||||
|
wurde aus der Admin-Oberflaeche entfernt (Login-/Logout-/Fehlversuch-Ereignisse
|
||||||
|
bleiben weiterhin vollstaendig im Audit-Log-Tab sichtbar, dort war die
|
||||||
|
Information ohnehin redundant vorhanden).
|
||||||
|
|
||||||
|
**3) Verbindungslog (Live-Tail, inkl. Debug)**: neuer Tab "Verbindungslog" +
|
||||||
|
WebSocket `GET /admin/ws/logs` streamen die Anwendungslogs dieses
|
||||||
|
Server-Prozesses live in den Browser — inkl. DEBUG-Detail zum SSH/RDP-
|
||||||
|
Verbindungsaufbau (`jumphost.*`-Logger werden beim Start auf DEBUG gesetzt,
|
||||||
|
siehe `app/security/log_stream.py`). Bewusst ein reiner In-Memory-Ring-Buffer
|
||||||
|
(letzte 1000 Zeilen) + Pub/Sub ohne DB-Persistenz — ein Live-Tail wie
|
||||||
|
`journalctl -f`, kein durchsuchbares Archiv. Nur fuer Super-Admins sichtbar/
|
||||||
|
erreichbar, da die Logs mandantenuebergreifend technische Details preisgeben
|
||||||
|
koennen.
|
||||||
|
|
||||||
|
**4) Sessionview-Dashboard (nur Super-Admin)**: neuer Tab "Sessions" +
|
||||||
|
`GET /admin/sessions` (aktive + historische Sitzungen ueber alle Mandanten),
|
||||||
|
`POST /admin/sessions/{id}/terminate` (zwangsweises Trennen einer laufenden
|
||||||
|
Sitzung) und `GET /admin/sessions/{id}/recording` (Integritaetspruefung der
|
||||||
|
Aufzeichnung). "Beenden" nutzt eine neue prozesslokale Registry
|
||||||
|
(`app/security/active_sessions.py`) + `asyncio.Task.cancel()` auf die
|
||||||
|
WebSocket-Route der Sitzung, was den bestehenden Cleanup-Pfad (DB-Update,
|
||||||
|
Audit-Log-Eintrag, WebSocket schliessen) unveraendert durchlaufen laesst —
|
||||||
|
funktioniert nur fuer Sitzungen auf demselben Server-Prozess (`killable`-Feld
|
||||||
|
in der Response zeigt das an, statt einen Beenden-Versuch fehlschlagen zu
|
||||||
|
lassen).
|
||||||
|
|
||||||
|
**5) Hostgruppen/Server im Menue getrennt**: der bisherige kombinierte Tab
|
||||||
|
"Hosts & Verbindungen" wurde in zwei eigene Tabs "Hostgruppen" und "Server"
|
||||||
|
aufgeteilt (gleicher Ladepfad/gleiche Formulare, nur die Navigation ist jetzt
|
||||||
|
getrennt).
|
||||||
|
|
||||||
|
**6) Credentials ins RBAC-Modell**: zwei neue Rollen `credentials_view`/
|
||||||
|
`credentials_manage` (Migration `0008_credentials_roles.sql`), die — wie
|
||||||
|
`ssh_connect`/`rdp_connect`/`file_transfer` — pro Hostgruppe an einzelne User
|
||||||
|
oder Benutzergruppen vergeben werden koennen. Damit koennen auch NICHT-Admins
|
||||||
|
gezielt Zugangsdaten (RDP-Passwort setzen/entfernen, SSH-Key-Zuordnung, nie
|
||||||
|
der Klartext selbst) fuer Hosts "ihrer" Hostgruppe verwalten, ohne Admin oder
|
||||||
|
Mandanten-Admin sein zu muessen (neue Dependency
|
||||||
|
`require_admin_scope_or_host_role` in `app/auth/deps.py`, neuer Endpunkt
|
||||||
|
`GET /admin/hosts/{id}/credentials`, Dashboard zeigt einen "Zugangsdaten"-
|
||||||
|
Knopf bei Hosts mit dieser Rolle).
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pytest -q
|
pytest -q
|
||||||
```
|
```
|
||||||
|
|
||||||
61 Tests (vorher 46) decken ab: Argon2id/TOTP-Grundfunktionen, Audit-Hash-Chain
|
68 Tests (vorher 61) decken ab: Argon2id/TOTP-Grundfunktionen, Audit-Hash-Chain
|
||||||
(inkl. Manipulationserkennung und Trigger-Durchsetzung), RBAC-Logik inkl.
|
(inkl. Manipulationserkennung und Trigger-Durchsetzung), RBAC-Logik inkl.
|
||||||
Ablaufdaten, den vollstaendigen Login-Flow (Passwort -> TOTP-Enrollment ->
|
Ablaufdaten, den vollstaendigen Login-Flow (Passwort -> TOTP-Enrollment ->
|
||||||
Session-Cookie -> geschuetzte Endpunkte) gegen die echte FastAPI-App, 17
|
Session-Cookie -> geschuetzte Endpunkte) gegen die echte FastAPI-App, 17
|
||||||
@ -216,13 +275,30 @@ Loeschen bei vorhandener Audit-Historie vs. echtem Hard-Delete ohne),
|
|||||||
Update/Delete fuer Hostgruppen (blockiert solange Hosts enthalten sind),
|
Update/Delete fuer Hostgruppen (blockiert solange Hosts enthalten sind),
|
||||||
Hosts (Soft- vs. Hard-Delete), SSH-Keys (inkl. Rotation) und Benutzergruppen,
|
Hosts (Soft- vs. Hard-Delete), SSH-Keys (inkl. Rotation) und Benutzergruppen,
|
||||||
sowie den neuen `GET /admin/hosts/{id}`-Detailendpunkt inkl. SSH-Key-
|
sowie den neuen `GET /admin/hosts/{id}`-Detailendpunkt inkl. SSH-Key-
|
||||||
Zuordnungen und RDP-Zugangsdaten-Status.
|
Zuordnungen und RDP-Zugangsdaten-Status. 7 weitere neue Tests zu Phase 9
|
||||||
|
(`tests/test_phase9.py`) decken zusaetzlich ab: dass "Host-Key ermitteln" bei
|
||||||
|
einem echten Verbindungsfehler 502 statt 500 liefert und bei einem Auth-Fehler
|
||||||
|
NACH erfolgreichem Key-Exchange weiterhin als Erfolg gilt; dass
|
||||||
|
`/admin/sessions*` ausschliesslich Super-Admins erlaubt ist (Mandanten-Admin
|
||||||
|
= 403) und `terminate`/`recording` fuer nicht (mehr) laufende bzw. nicht
|
||||||
|
aufgezeichnete Sitzungen sauber 409/404 statt einen internen Fehler liefern;
|
||||||
|
dass die neuen Rollen `credentials_view`/`credentials_manage` Nicht-Admins
|
||||||
|
gezielten Lese- bzw. Lese+Schreib-Zugriff auf Zugangsdaten geben (und ein User
|
||||||
|
ganz ohne diese Rolle weiterhin 403 bekommt), inkl. des neuen
|
||||||
|
`can_view_credentials`-Felds in `GET /catalog/hosts`. Nebenbei beim
|
||||||
|
Gegenpruefen entdeckt und mitkorrigiert: drei Tests in
|
||||||
|
`tests/test_admin_groups_tokens.py` sendeten noch das VOR Phase 8 gueltige
|
||||||
|
Payload-Feld `role_name` (Einzahl) an `/admin/group-roles/grant`, das
|
||||||
|
`GroupRoleGrantRequest` seit der Umstellung auf Mehrfachauswahl aber gar nicht
|
||||||
|
mehr kennt (erwartet `role_names`, eine Liste) — ein beim Phase-8-Umbau
|
||||||
|
liegen gebliebener Regressionsfehler, jetzt auf `role_names` korrigiert.
|
||||||
|
|
||||||
> **Hinweis:** Alle 34 in dieser und der vorherigen Session neu
|
> **Hinweis:** Alle 41 in dieser und den beiden vorherigen Sessions neu
|
||||||
> hinzugekommenen Tests (5 CSP + 14 Admin/Gruppen/Token + 15 Mandanten/CRUD)
|
> hinzugekommenen Tests (5 CSP + 14 Admin/Gruppen/Token + 15 Mandanten/CRUD +
|
||||||
> konnten in der verwendeten Cloud-Sandbox nicht mit `pytest -q` ausgefuehrt
|
> 7 Phase 9) konnten in der verwendeten Cloud-Sandbox nicht mit `pytest -q`
|
||||||
> werden, da diese Sandbox keinen Netzwerkzugriff auf PyPI hat und
|
> ausgefuehrt werden, da diese Sandbox keinen Netzwerkzugriff auf PyPI hat und
|
||||||
> `fastapi`/`aiosqlite`/`argon2`/`asyncssh` dort nicht vorinstalliert sind.
|
> `fastapi`/`aiosqlite`/`argon2`/`asyncssh` dort nicht vorinstalliert sind
|
||||||
|
> (auch nicht ueber die Geraete-Bruecke zum lokalen Rechner erreichbar).
|
||||||
> Fuer Phase 8 wurde stattdessen ein tieferes Verifikationsverfahren
|
> Fuer Phase 8 wurde stattdessen ein tieferes Verifikationsverfahren
|
||||||
> angewendet als in der vorherigen Session: minimale Stub-Module fuer die
|
> angewendet als in der vorherigen Session: minimale Stub-Module fuer die
|
||||||
> vier fehlenden Pakete (`fastapi`s `APIRouter`-Dekoratoren als No-Ops, ein
|
> vier fehlenden Pakete (`fastapi`s `APIRouter`-Dekoratoren als No-Ops, ein
|
||||||
@ -236,12 +312,22 @@ Zuordnungen und RDP-Zugangsdaten-Status.
|
|||||||
> selbst nutzt weiterhin `httpx.AsyncClient` gegen die echte ASGI-App (wie
|
> selbst nutzt weiterhin `httpx.AsyncClient` gegen die echte ASGI-App (wie
|
||||||
> alle anderen Testdateien) und wurde daher zeilenweise gegen die tatsaechliche
|
> alle anderen Testdateien) und wurde daher zeilenweise gegen die tatsaechliche
|
||||||
> Endpunkt-Implementierung gegengeprueft (Pfade, Payload-Felder, Statuscodes),
|
> Endpunkt-Implementierung gegengeprueft (Pfade, Payload-Felder, Statuscodes),
|
||||||
> aber nicht selbst mit `pytest` ausgefuehrt. Alle geaenderten Python-/
|
> aber nicht selbst mit `pytest` ausgefuehrt. Fuer Phase 9 zusaetzlich: die
|
||||||
> JS-Dateien wurden mit `py_compile`/`node --check` auf Syntaxfehler geprueft,
|
> reparierte Kontrollfluss-Logik von `discover_and_store_host_key()` wurde
|
||||||
> und jeder `document.getElementById`-Aufruf in `admin.js`/`terminal.js` wurde
|
> mit einem eigenstaendigen Fake-`asyncssh`-Nachbau isoliert durchgespielt
|
||||||
> automatisiert gegen die tatsaechlichen HTML-IDs in `admin.html`/`terminal.html`
|
> (alle drei Faelle: Verbindungsfehler vor Key-Exchange, Auth-Fehler NACH
|
||||||
> abgeglichen (0 Abweichungen). Bitte `pytest -q` lokal ausfuehren und
|
> Key-Exchange = der eigentliche Bug, voller Erfolg), Migration
|
||||||
> Ergebnis melden.
|
> `0008_credentials_roles.sql` wurde sowohl gegen eine leere als auch gegen
|
||||||
|
> eine bereits mit 0001-0007 befuellte In-Memory-DB angewendet (Lehre aus dem
|
||||||
|
> Migrations-Vorfall in Phase 8, siehe unten), und die neuen, rein
|
||||||
|
> In-Process-Module `app/security/log_stream.py` und
|
||||||
|
> `app/security/active_sessions.py` (kein DB-/HTTP-Bezug) wurden direkt
|
||||||
|
> importiert und gegen echtes `asyncio`/`logging` ausgefuehrt statt nur
|
||||||
|
> gelesen. Alle geaenderten Python-/JS-Dateien wurden mit
|
||||||
|
> `py_compile`/`node --check` auf Syntaxfehler geprueft, und jeder
|
||||||
|
> `document.getElementById`-Aufruf in `admin.js` wurde automatisiert gegen
|
||||||
|
> die tatsaechlichen HTML-IDs in `admin.html` abgeglichen (0 Abweichungen).
|
||||||
|
> Bitte `pytest -q` lokal ausfuehren und Ergebnis melden.
|
||||||
|
|
||||||
Manuell zusaetzlich verifiziert (siehe Entwicklungs-Log dieser Session):
|
Manuell zusaetzlich verifiziert (siehe Entwicklungs-Log dieser Session):
|
||||||
Server-Start, Static-/Template-Auslieferung, Security-Header, vollstaendiger
|
Server-Start, Static-/Template-Auslieferung, Security-Header, vollstaendiger
|
||||||
|
|||||||
@ -21,13 +21,17 @@ Privilege-Escalation-Schutz).
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect, status
|
||||||
|
|
||||||
from app.auth.deps import (
|
from app.auth.deps import (
|
||||||
CurrentUser,
|
CurrentUser,
|
||||||
effective_tenant_ids,
|
effective_tenant_ids,
|
||||||
|
get_current_user_ws,
|
||||||
require_admin_or_scope,
|
require_admin_or_scope,
|
||||||
|
require_admin_scope_or_host_role,
|
||||||
require_admin_session,
|
require_admin_session,
|
||||||
require_global_admin,
|
require_global_admin,
|
||||||
)
|
)
|
||||||
@ -54,6 +58,7 @@ from app.models.schemas import (
|
|||||||
UserGroupUpdateRequest,
|
UserGroupUpdateRequest,
|
||||||
UserUpdateRequest,
|
UserUpdateRequest,
|
||||||
)
|
)
|
||||||
|
from app.security import active_sessions, log_stream
|
||||||
from app.security.api_tokens import (
|
from app.security.api_tokens import (
|
||||||
VALID_SCOPES,
|
VALID_SCOPES,
|
||||||
generate_token,
|
generate_token,
|
||||||
@ -64,9 +69,12 @@ from app.security.api_tokens import (
|
|||||||
from app.security.audit import verify_chain, write_audit_event
|
from app.security.audit import verify_chain, write_audit_event
|
||||||
from app.security.crypto import encrypt_secret
|
from app.security.crypto import encrypt_secret
|
||||||
from app.security.passwords import hash_password
|
from app.security.passwords import hash_password
|
||||||
from app.ssh_proxy.proxy import discover_and_store_host_key
|
from app.recordings.recorder import verify_recording
|
||||||
|
from app.ssh_proxy.proxy import HostKeyDiscoveryError, HostNotConfiguredError, discover_and_store_host_key
|
||||||
from app.tenancy import TenantScope, resolve_host_group_tenant, resolve_host_tenant, tenant_user_ids
|
from app.tenancy import TenantScope, resolve_host_group_tenant, resolve_host_tenant, tenant_user_ids
|
||||||
|
|
||||||
|
logger = logging.getLogger("jumphost.admin")
|
||||||
|
|
||||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||||
|
|
||||||
|
|
||||||
@ -900,10 +908,23 @@ async def discover_host_key(
|
|||||||
"""ACHTUNG: Verbindet einmalig OHNE Host-Key-Pruefung, um den Fingerprint zu
|
"""ACHTUNG: Verbindet einmalig OHNE Host-Key-Pruefung, um den Fingerprint zu
|
||||||
erfassen (bewusste Trust-Entscheidung, siehe Konzept 4.2). Danach gilt fuer
|
erfassen (bewusste Trust-Entscheidung, siehe Konzept 4.2). Danach gilt fuer
|
||||||
alle regulaeren Verbindungen wieder striktes Pinning. Jeder Aufruf wird
|
alle regulaeren Verbindungen wieder striktes Pinning. Jeder Aufruf wird
|
||||||
prominent im Audit-Log vermerkt."""
|
prominent im Audit-Log vermerkt.
|
||||||
|
|
||||||
|
Bugfix: discover_and_store_host_key() konnte bisher ein unbehandeltes
|
||||||
|
asyncssh/OSError durchreichen -> FastAPI antwortete mit 500 statt einer
|
||||||
|
verwertbaren Fehlermeldung (siehe app/ssh_proxy/proxy.py). Jetzt sauber
|
||||||
|
auf 502 (Verbindung fehlgeschlagen) bzw. 400 (kein SSH-Host) gemappt."""
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
await _assert_host_in_scope(conn, _scope(admin), host_id)
|
await _assert_host_in_scope(conn, _scope(admin), host_id)
|
||||||
|
try:
|
||||||
fingerprint = await discover_and_store_host_key(conn, host_id, admin_user_id=admin.id)
|
fingerprint = await discover_and_store_host_key(conn, host_id, admin_user_id=admin.id)
|
||||||
|
except HostNotConfiguredError as exc:
|
||||||
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
|
||||||
|
except HostKeyDiscoveryError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_502_BAD_GATEWAY,
|
||||||
|
f"Host-Key konnte nicht ermittelt werden -- Ziel nicht erreichbar: {exc.reason}",
|
||||||
|
) from exc
|
||||||
await write_audit_event(
|
await write_audit_event(
|
||||||
conn, event_type="host_key_discovered_trust_decision", user_id=admin.id,
|
conn, event_type="host_key_discovered_trust_decision", user_id=admin.id,
|
||||||
client_ip=_client_ip(request), details={"host_id": host_id, "fingerprint": fingerprint},
|
client_ip=_client_ip(request), details={"host_id": host_id, "fingerprint": fingerprint},
|
||||||
@ -915,11 +936,17 @@ async def discover_host_key(
|
|||||||
@router.put("/hosts/{host_id}/rdp-credentials")
|
@router.put("/hosts/{host_id}/rdp-credentials")
|
||||||
async def set_rdp_credentials(
|
async def set_rdp_credentials(
|
||||||
host_id: int, payload: RdpCredentialsRequest, request: Request,
|
host_id: int, payload: RdpCredentialsRequest, request: Request,
|
||||||
admin: CurrentUser = Depends(require_admin_or_scope("hosts", "write")),
|
admin: CurrentUser = Depends(
|
||||||
|
require_admin_scope_or_host_role("hosts", "write", ("credentials_manage",))
|
||||||
|
),
|
||||||
):
|
):
|
||||||
"""Speichert/rotiert das RDP-Passwort fuer einen Host, verschluesselt mit
|
"""Speichert/rotiert das RDP-Passwort fuer einen Host, verschluesselt mit
|
||||||
dem KEK (eigener AAD-Kontext, siehe app/security/crypto.py)."""
|
dem KEK (eigener AAD-Kontext, siehe app/security/crypto.py). Zugriff:
|
||||||
|
Admin/Mandanten-Admin/Token ODER ein Nicht-Admin mit Rolle
|
||||||
|
'credentials_manage' auf der Hostgruppe dieses Hosts (siehe RBAC-
|
||||||
|
Erweiterung 'Credentials ins RBAC-Modell')."""
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
|
if admin.is_any_admin:
|
||||||
await _assert_host_in_scope(conn, _scope(admin), host_id)
|
await _assert_host_in_scope(conn, _scope(admin), host_id)
|
||||||
encrypted = encrypt_secret(payload.password.encode(), associated_data=b"rdp_password")
|
encrypted = encrypt_secret(payload.password.encode(), associated_data=b"rdp_password")
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
@ -940,9 +967,12 @@ async def set_rdp_credentials(
|
|||||||
@router.delete("/hosts/{host_id}/rdp-credentials")
|
@router.delete("/hosts/{host_id}/rdp-credentials")
|
||||||
async def delete_rdp_credentials(
|
async def delete_rdp_credentials(
|
||||||
host_id: int, request: Request,
|
host_id: int, request: Request,
|
||||||
admin: CurrentUser = Depends(require_admin_or_scope("hosts", "write")),
|
admin: CurrentUser = Depends(
|
||||||
|
require_admin_scope_or_host_role("hosts", "write", ("credentials_manage",))
|
||||||
|
),
|
||||||
):
|
):
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
|
if admin.is_any_admin:
|
||||||
await _assert_host_in_scope(conn, _scope(admin), host_id)
|
await _assert_host_in_scope(conn, _scope(admin), host_id)
|
||||||
await conn.execute("DELETE FROM rdp_credentials WHERE host_id = ?", (host_id,))
|
await conn.execute("DELETE FROM rdp_credentials WHERE host_id = ?", (host_id,))
|
||||||
await write_audit_event(
|
await write_audit_event(
|
||||||
@ -953,6 +983,42 @@ async def delete_rdp_credentials(
|
|||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/hosts/{host_id}/credentials")
|
||||||
|
async def get_host_credentials(
|
||||||
|
host_id: int,
|
||||||
|
admin: CurrentUser = Depends(
|
||||||
|
require_admin_scope_or_host_role("hosts", "read", ("credentials_view", "credentials_manage"))
|
||||||
|
),
|
||||||
|
):
|
||||||
|
"""Zugangsdaten-Uebersicht fuer EINEN Host (zugeordnete SSH-Keys, ob ein
|
||||||
|
RDP-Passwort gesetzt ist -- niemals der Klartext selbst, siehe Konzept
|
||||||
|
6.4). Im Unterschied zu GET /admin/hosts/{id} (voller Datensatz,
|
||||||
|
admin-only) ist dieser schlanke Endpunkt bewusst auch fuer Nicht-Admins
|
||||||
|
mit Rolle 'credentials_view'/'credentials_manage' erreichbar (RBAC-
|
||||||
|
Erweiterung 'Credentials ins RBAC-Modell')."""
|
||||||
|
conn = get_db()
|
||||||
|
if admin.is_any_admin:
|
||||||
|
await _assert_host_in_scope(conn, _scope(admin), host_id)
|
||||||
|
else:
|
||||||
|
host_row = await (await conn.execute("SELECT 1 FROM hosts WHERE id = ?", (host_id,))).fetchone()
|
||||||
|
if host_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 {
|
||||||
|
"host_id": host_id, "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.get("/rdp-credentials")
|
@router.get("/rdp-credentials")
|
||||||
async def list_rdp_credentials(admin: CurrentUser = Depends(require_admin_or_scope("hosts", "read"))):
|
async def list_rdp_credentials(admin: CurrentUser = Depends(require_admin_or_scope("hosts", "read"))):
|
||||||
"""Uebersicht aller RDP/Windows-Hosts fuer den 'Zugangsdaten'-Tab: welche
|
"""Uebersicht aller RDP/Windows-Hosts fuer den 'Zugangsdaten'-Tab: welche
|
||||||
@ -1281,12 +1347,19 @@ async def delete_ssh_key(
|
|||||||
@router.post("/hosts/{host_id}/ssh-keys/{key_id}")
|
@router.post("/hosts/{host_id}/ssh-keys/{key_id}")
|
||||||
async def map_ssh_key_to_host(
|
async def map_ssh_key_to_host(
|
||||||
host_id: int, key_id: int, request: Request,
|
host_id: int, key_id: int, request: Request,
|
||||||
admin: CurrentUser = Depends(require_admin_or_scope("ssh_keys", "write")),
|
admin: CurrentUser = Depends(
|
||||||
|
require_admin_scope_or_host_role("ssh_keys", "write", ("credentials_manage",))
|
||||||
|
),
|
||||||
):
|
):
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
|
if admin.is_any_admin:
|
||||||
scope = _scope(admin)
|
scope = _scope(admin)
|
||||||
await _assert_host_in_scope(conn, scope, host_id)
|
await _assert_host_in_scope(conn, scope, host_id)
|
||||||
await _assert_ssh_key_in_scope(conn, scope, key_id)
|
await _assert_ssh_key_in_scope(conn, scope, key_id)
|
||||||
|
else:
|
||||||
|
key_row = await (await conn.execute("SELECT 1 FROM ssh_keys WHERE id = ?", (key_id,))).fetchone()
|
||||||
|
if key_row is None:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "SSH-Key nicht gefunden")
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
"INSERT OR IGNORE INTO host_ssh_key_map (host_id, ssh_key_id) VALUES (?, ?)",
|
"INSERT OR IGNORE INTO host_ssh_key_map (host_id, ssh_key_id) VALUES (?, ?)",
|
||||||
(host_id, key_id),
|
(host_id, key_id),
|
||||||
@ -1302,11 +1375,13 @@ async def map_ssh_key_to_host(
|
|||||||
@router.delete("/hosts/{host_id}/ssh-keys/{key_id}")
|
@router.delete("/hosts/{host_id}/ssh-keys/{key_id}")
|
||||||
async def unmap_ssh_key_from_host(
|
async def unmap_ssh_key_from_host(
|
||||||
host_id: int, key_id: int, request: Request,
|
host_id: int, key_id: int, request: Request,
|
||||||
admin: CurrentUser = Depends(require_admin_or_scope("ssh_keys", "write")),
|
admin: CurrentUser = Depends(
|
||||||
|
require_admin_scope_or_host_role("ssh_keys", "write", ("credentials_manage",))
|
||||||
|
),
|
||||||
):
|
):
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
scope = _scope(admin)
|
if admin.is_any_admin:
|
||||||
await _assert_host_in_scope(conn, scope, host_id)
|
await _assert_host_in_scope(conn, _scope(admin), host_id)
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
"DELETE FROM host_ssh_key_map WHERE host_id = ? AND ssh_key_id = ?", (host_id, key_id)
|
"DELETE FROM host_ssh_key_map WHERE host_id = ? AND ssh_key_id = ?", (host_id, key_id)
|
||||||
)
|
)
|
||||||
@ -1463,3 +1538,130 @@ async def verify_audit_log(admin: CurrentUser = Depends(require_admin_or_scope("
|
|||||||
conn = get_db()
|
conn = get_db()
|
||||||
intact, broken_at = await verify_chain(conn)
|
intact, broken_at = await verify_chain(conn)
|
||||||
return {"intact": intact, "first_broken_id": broken_at}
|
return {"intact": intact, "first_broken_id": broken_at}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Sessionview (nur Super-Admin) --------------------------------------------
|
||||||
|
#
|
||||||
|
# Zeigt ALLE Sitzungen ueber alle Mandanten hinweg -- bewusst require_global_
|
||||||
|
# admin (kein Mandanten-Admin, kein Token), da Sitzungsdaten (Client-IP,
|
||||||
|
# genutzter Host) ueber Mandantengrenzen hinweg sichtbar waeren. 'Beenden'
|
||||||
|
# nutzt app/security/active_sessions.py (siehe dort fuer die Prozess-lokale
|
||||||
|
# Registry und die genaue Beenden-Semantik per asyncio.Task.cancel()).
|
||||||
|
|
||||||
|
@router.get("/sessions")
|
||||||
|
async def list_sessions(
|
||||||
|
active_only: bool = False, limit: int = 200, offset: int = 0,
|
||||||
|
admin: CurrentUser = Depends(require_global_admin),
|
||||||
|
):
|
||||||
|
limit = max(1, min(limit, 1000))
|
||||||
|
conn = get_db()
|
||||||
|
where = "WHERE s.ended_at IS NULL" if active_only else ""
|
||||||
|
cursor = await conn.execute(
|
||||||
|
f"""
|
||||||
|
SELECT s.id, s.user_id, u.username, s.host_id, h.hostname, hg.name,
|
||||||
|
s.protocol, s.started_at, s.ended_at, s.client_ip, s.end_reason,
|
||||||
|
s.recording_path
|
||||||
|
FROM sessions s
|
||||||
|
JOIN users u ON u.id = s.user_id
|
||||||
|
JOIN hosts h ON h.id = s.host_id
|
||||||
|
JOIN host_groups hg ON hg.id = h.host_group_id
|
||||||
|
{where}
|
||||||
|
ORDER BY s.started_at DESC LIMIT ? OFFSET ?
|
||||||
|
""",
|
||||||
|
(limit, offset),
|
||||||
|
)
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
active_ids = active_sessions.all_ids()
|
||||||
|
result = []
|
||||||
|
for r in rows:
|
||||||
|
recording_path = r[11]
|
||||||
|
result.append({
|
||||||
|
"id": r[0], "user_id": r[1], "username": r[2], "host_id": r[3],
|
||||||
|
"hostname": r[4], "host_group_name": r[5], "protocol": r[6],
|
||||||
|
"started_at": r[7], "ended_at": r[8], "client_ip": r[9], "end_reason": r[10],
|
||||||
|
"is_active": r[8] is None,
|
||||||
|
"killable": r[0] in active_ids,
|
||||||
|
"has_recording": bool(recording_path) and Path(recording_path).exists(),
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sessions/{session_id}/terminate")
|
||||||
|
async def terminate_session(
|
||||||
|
session_id: int, request: Request, admin: CurrentUser = Depends(require_global_admin),
|
||||||
|
):
|
||||||
|
conn = get_db()
|
||||||
|
row = await (await conn.execute(
|
||||||
|
"SELECT ended_at, user_id, host_id FROM sessions WHERE id = ?", (session_id,)
|
||||||
|
)).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Sitzung nicht gefunden")
|
||||||
|
if row[0] is not None:
|
||||||
|
raise HTTPException(status.HTTP_409_CONFLICT, "Sitzung ist bereits beendet")
|
||||||
|
entry = active_sessions.get(session_id)
|
||||||
|
if entry is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_409_CONFLICT,
|
||||||
|
"Sitzung laeuft nicht auf diesem Server-Prozess (evtl. anderer Worker) "
|
||||||
|
"und kann von hier aus nicht beendet werden",
|
||||||
|
)
|
||||||
|
entry.task.cancel()
|
||||||
|
await write_audit_event(
|
||||||
|
conn, event_type="session_terminated_by_admin", user_id=admin.id, client_ip=_client_ip(request),
|
||||||
|
details={"session_id": session_id, "target_user_id": row[1], "host_id": row[2]},
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sessions/{session_id}/recording")
|
||||||
|
async def get_session_recording(session_id: int, admin: CurrentUser = Depends(require_global_admin)):
|
||||||
|
"""Prueft und liefert die Hash-verkettete Sitzungsaufzeichnung (siehe
|
||||||
|
app/recordings/recorder.py) -- bewusst nur Metadaten + Integritaetsstatus,
|
||||||
|
kein vollstaendiger Tastatur-/Bildschirm-Dump in der Response (Konzept
|
||||||
|
6.5: Aufzeichnungen sind hochsensibel)."""
|
||||||
|
conn = get_db()
|
||||||
|
row = await (await conn.execute(
|
||||||
|
"SELECT recording_path FROM sessions WHERE id = ?", (session_id,)
|
||||||
|
)).fetchone()
|
||||||
|
if row is None or not row[0]:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Keine Aufzeichnung fuer diese Sitzung vorhanden")
|
||||||
|
path = Path(row[0])
|
||||||
|
if not path.exists():
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Aufzeichnungsdatei nicht (mehr) vorhanden")
|
||||||
|
try:
|
||||||
|
verified = verify_recording(path)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Aufzeichnung %s konnte nicht gelesen/verifiziert werden", session_id)
|
||||||
|
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Aufzeichnung konnte nicht gelesen werden")
|
||||||
|
with open(path, encoding="utf-8") as fh:
|
||||||
|
entry_count = sum(1 for line in fh if line.strip())
|
||||||
|
return {"session_id": session_id, "verified": verified, "entry_count": entry_count}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Verbindungslog (nur Super-Admin, Live-Tail) ------------------------------
|
||||||
|
#
|
||||||
|
# Reiner Live-Tail der Anwendungslogs dieses Server-Prozesses (siehe
|
||||||
|
# app/security/log_stream.py) -- kein durchsuchbares Archiv, keine
|
||||||
|
# Persistenz. Nur Super-Admin (require_global_admin-Semantik), da die Logs
|
||||||
|
# ueber alle Mandanten hinweg technische Details preisgeben koennen.
|
||||||
|
|
||||||
|
@router.websocket("/ws/logs")
|
||||||
|
async def stream_logs(websocket: WebSocket):
|
||||||
|
user = await get_current_user_ws(websocket)
|
||||||
|
if user is None or not user.is_admin:
|
||||||
|
await websocket.close(code=4403)
|
||||||
|
return
|
||||||
|
|
||||||
|
await websocket.accept()
|
||||||
|
queue = log_stream.subscribe()
|
||||||
|
try:
|
||||||
|
for line in log_stream.recent_lines():
|
||||||
|
await websocket.send_json({"type": "line", "line": line})
|
||||||
|
while True:
|
||||||
|
line = await queue.get()
|
||||||
|
await websocket.send_json({"type": "line", "line": line})
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
log_stream.unsubscribe(queue)
|
||||||
|
|||||||
@ -204,6 +204,43 @@ async def _validate_api_token(token: str, *, resource: str, action: str) -> Curr
|
|||||||
return CurrentUser(id=user_id, username=username, is_admin=False, token_tenant_id=tenant_id)
|
return CurrentUser(id=user_id, username=username, is_admin=False, token_tenant_id=tenant_id)
|
||||||
|
|
||||||
|
|
||||||
|
def require_admin_scope_or_host_role(resource: str, action: str, role_names: tuple[str, ...]):
|
||||||
|
"""Wie require_admin_or_scope, erlaubt zusaetzlich einen regulaeren
|
||||||
|
(Nicht-Admin-)User per Session, wenn er fuer den per Pfadparameter
|
||||||
|
'host_id' angegebenen Host mindestens eine der uebergebenen Hostgruppen-
|
||||||
|
Rollen besitzt (siehe RBAC-Erweiterung 'Credentials ins RBAC-Modell':
|
||||||
|
Rollen 'credentials_view'/'credentials_manage', app/models/schemas.py).
|
||||||
|
Token-Auth bleibt unveraendert ausschliesslich ueber den Admin-Scope
|
||||||
|
moeglich -- kein Token traegt je eine Hostgruppen-Rolle."""
|
||||||
|
|
||||||
|
async def _dep(
|
||||||
|
host_id: int,
|
||||||
|
response: Response,
|
||||||
|
jh_session: str | None = Cookie(default=None, alias=SESSION_COOKIE_NAME),
|
||||||
|
authorization: str | None = Header(default=None),
|
||||||
|
) -> CurrentUser:
|
||||||
|
if authorization and authorization.startswith("Bearer "):
|
||||||
|
token = authorization.removeprefix("Bearer ").strip()
|
||||||
|
principal = await _validate_api_token(token, resource=resource, action=action)
|
||||||
|
if principal is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_401_UNAUTHORIZED,
|
||||||
|
"Ungueltiges, abgelaufenes oder nicht ausreichend berechtigtes Token",
|
||||||
|
)
|
||||||
|
return principal
|
||||||
|
|
||||||
|
user = await get_current_user(response, jh_session)
|
||||||
|
if user.is_any_admin:
|
||||||
|
return user
|
||||||
|
conn = get_db()
|
||||||
|
for role_name in role_names:
|
||||||
|
if await user_has_role_for_host(conn, user_id=user.id, host_id=host_id, role_name=role_name):
|
||||||
|
return user
|
||||||
|
raise HTTPException(status.HTTP_403_FORBIDDEN, "Keine Berechtigung fuer diese Zugangsdaten")
|
||||||
|
|
||||||
|
return _dep
|
||||||
|
|
||||||
|
|
||||||
def require_admin_or_scope(resource: str, action: str):
|
def require_admin_or_scope(resource: str, action: str):
|
||||||
"""Dependency-Factory fuer die Admin-API: erlaubt Zugriff entweder ueber
|
"""Dependency-Factory fuer die Admin-API: erlaubt Zugriff entweder ueber
|
||||||
eine eingeloggte Admin-Session (Super- ODER Mandanten-Admin, Cookie) ODER
|
eine eingeloggte Admin-Session (Super- ODER Mandanten-Admin, Cookie) ODER
|
||||||
|
|||||||
@ -30,6 +30,7 @@ async def my_hosts(user: CurrentUser = Depends(get_current_user)):
|
|||||||
for h in hosts:
|
for h in hosts:
|
||||||
h["can_connect"] = True
|
h["can_connect"] = True
|
||||||
h["can_file_transfer"] = True
|
h["can_file_transfer"] = True
|
||||||
|
h["can_view_credentials"] = True
|
||||||
return hosts
|
return hosts
|
||||||
|
|
||||||
cursor = await conn.execute(
|
cursor = await conn.execute(
|
||||||
@ -82,7 +83,26 @@ async def my_hosts(user: CurrentUser = Depends(get_current_user)):
|
|||||||
(user.id, user.id),
|
(user.id, user.id),
|
||||||
)
|
)
|
||||||
ft_groups = {row[0] for row in await ft_cursor.fetchall()}
|
ft_groups = {row[0] for row in await ft_cursor.fetchall()}
|
||||||
|
|
||||||
|
cred_cursor = await conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT uhr.host_group_id FROM user_hostgroup_roles uhr
|
||||||
|
JOIN roles r ON r.id = uhr.role_id
|
||||||
|
WHERE uhr.user_id = ? AND r.name IN ('credentials_view', 'credentials_manage')
|
||||||
|
AND (uhr.expires_at IS NULL OR uhr.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||||
|
UNION
|
||||||
|
SELECT ghr.host_group_id FROM group_hostgroup_roles ghr
|
||||||
|
JOIN roles r ON r.id = ghr.role_id
|
||||||
|
JOIN user_group_members ugm ON ugm.user_group_id = ghr.user_group_id
|
||||||
|
WHERE ugm.user_id = ? AND r.name IN ('credentials_view', 'credentials_manage')
|
||||||
|
AND (ghr.expires_at IS NULL OR ghr.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||||
|
""",
|
||||||
|
(user.id, user.id),
|
||||||
|
)
|
||||||
|
cred_groups = {row[0] for row in await cred_cursor.fetchall()}
|
||||||
|
|
||||||
for h in hosts:
|
for h in hosts:
|
||||||
h["can_connect"] = True
|
h["can_connect"] = True
|
||||||
h["can_file_transfer"] = h["host_group_id"] in ft_groups and bool(h["file_transfer_enabled"])
|
h["can_file_transfer"] = h["host_group_id"] in ft_groups and bool(h["file_transfer_enabled"])
|
||||||
|
h["can_view_credentials"] = h["host_group_id"] in cred_groups
|
||||||
return hosts
|
return hosts
|
||||||
|
|||||||
19
app/db/migrations/0008_credentials_roles.sql
Normal file
19
app/db/migrations/0008_credentials_roles.sql
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
-- RBAC-Erweiterung "Credentials ins RBAC-Modell aufnehmen": zwei neue Rollen,
|
||||||
|
-- die -- wie ssh_connect/rdp_connect/file_transfer/clipboard -- pro
|
||||||
|
-- Hostgruppe an einzelne User oder Benutzergruppen vergeben werden koennen
|
||||||
|
-- (user_hostgroup_roles / group_hostgroup_roles, unveraendert). Damit koennen
|
||||||
|
-- auch Nicht-Admins gezielt Zugriff auf Zugangsdaten (SSH-Keys, RDP-Passwoerter)
|
||||||
|
-- der Hosts "ihrer" Hostgruppe bekommen, ohne globaler oder Mandanten-Admin
|
||||||
|
-- sein zu muessen. Durchsetzung: app/auth/deps.py::require_admin_scope_or_host_role,
|
||||||
|
-- verwendet in app/admin/routes.py fuer die RDP-Credential- und SSH-Key-
|
||||||
|
-- Zuordnungs-Endpunkte sowie den neuen GET /admin/hosts/{id}/credentials.
|
||||||
|
--
|
||||||
|
-- credentials_view: darf sehen, OB/wann ein RDP-Passwort gesetzt ist und
|
||||||
|
-- welche SSH-Keys einem Host zugeordnet sind (NIE den
|
||||||
|
-- Klartext selbst -- der wird ueber KEINEN Endpunkt
|
||||||
|
-- jemals im Klartext zurueckgegeben, siehe Konzept 6.4).
|
||||||
|
-- credentials_manage: wie credentials_view, zusaetzlich RDP-Passwort setzen/
|
||||||
|
-- loeschen und SSH-Keys einem Host zuordnen/entfernen.
|
||||||
|
INSERT OR IGNORE INTO roles (id, name) VALUES
|
||||||
|
(7, 'credentials_view'),
|
||||||
|
(8, 'credentials_manage');
|
||||||
@ -19,6 +19,7 @@ from app.auth.routes import router as auth_router
|
|||||||
from app.catalog.routes import router as catalog_router
|
from app.catalog.routes import router as catalog_router
|
||||||
from app.db import close_db, init_db
|
from app.db import close_db, init_db
|
||||||
from app.rdp_proxy.ws_tunnel import router as rdp_ws_router
|
from app.rdp_proxy.ws_tunnel import router as rdp_ws_router
|
||||||
|
from app.security import log_stream
|
||||||
from app.ssh_proxy.sftp import router as sftp_router
|
from app.ssh_proxy.sftp import router as sftp_router
|
||||||
from app.ssh_proxy.terminal_ws import router as ssh_ws_router
|
from app.ssh_proxy.terminal_ws import router as ssh_ws_router
|
||||||
|
|
||||||
@ -27,6 +28,10 @@ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name
|
|||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
|
# Ring-Buffer-Handler fuers Live-'Verbindungslog' (Admin-only, siehe
|
||||||
|
# app/security/log_stream.py) -- MUSS vor init_db() installiert werden,
|
||||||
|
# damit auch fruehe Startmeldungen im Puffer landen.
|
||||||
|
log_stream.install()
|
||||||
await init_db()
|
await init_db()
|
||||||
yield
|
yield
|
||||||
await close_db()
|
await close_db()
|
||||||
|
|||||||
@ -18,6 +18,13 @@ HOSTNAME_LABEL_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$")
|
|||||||
ROLE_NAME = Literal[
|
ROLE_NAME = Literal[
|
||||||
"ssh_connect", "rdp_connect", "file_transfer", "clipboard",
|
"ssh_connect", "rdp_connect", "file_transfer", "clipboard",
|
||||||
"session_recording_view", "admin_hostgroup",
|
"session_recording_view", "admin_hostgroup",
|
||||||
|
# Hinzugefuegt fuer RBAC-Erweiterung 'Credentials ins RBAC-Modell' (Migration
|
||||||
|
# 0008_credentials_roles.sql): erlaubt Nicht-Admins mit dieser Rolle auf
|
||||||
|
# einer Hostgruppe gezielten Zugriff auf Zugangsdaten (SSH-Keys, RDP-
|
||||||
|
# Passwoerter) der Hosts dieser Gruppe, siehe app/auth/deps.py::
|
||||||
|
# require_admin_scope_or_host_role und admin/routes.py (rdp-credentials,
|
||||||
|
# ssh-keys-Zuordnung, GET /admin/hosts/{id}/credentials).
|
||||||
|
"credentials_view", "credentials_manage",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@ -146,7 +153,7 @@ class HostUpdateRequest(BaseModel):
|
|||||||
class RoleGrantRequest(BaseModel):
|
class RoleGrantRequest(BaseModel):
|
||||||
user_id: int
|
user_id: int
|
||||||
host_group_id: int
|
host_group_id: int
|
||||||
role_names: list[ROLE_NAME] = Field(min_length=1, max_length=6)
|
role_names: list[ROLE_NAME] = Field(min_length=1, max_length=8)
|
||||||
expires_at: str | None = None
|
expires_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@ -174,7 +181,7 @@ class GroupMemberRequest(BaseModel):
|
|||||||
class GroupRoleGrantRequest(BaseModel):
|
class GroupRoleGrantRequest(BaseModel):
|
||||||
user_group_id: int
|
user_group_id: int
|
||||||
host_group_id: int
|
host_group_id: int
|
||||||
role_names: list[ROLE_NAME] = Field(min_length=1, max_length=6)
|
role_names: list[ROLE_NAME] = Field(min_length=1, max_length=8)
|
||||||
expires_at: str | None = None
|
expires_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -19,6 +19,7 @@ from app.config import settings
|
|||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.rbac import user_has_role_for_host
|
from app.rbac import user_has_role_for_host
|
||||||
from app.recordings.recorder import SessionRecorder
|
from app.recordings.recorder import SessionRecorder
|
||||||
|
from app.security import active_sessions
|
||||||
from app.security.audit import write_audit_event
|
from app.security.audit import write_audit_event
|
||||||
from app.security.crypto import decrypt_secret
|
from app.security.crypto import decrypt_secret
|
||||||
from app.rdp_proxy.guacd_client import (
|
from app.rdp_proxy.guacd_client import (
|
||||||
@ -115,6 +116,11 @@ async def rdp_tunnel(
|
|||||||
details={"host_id": host_id, "hostname": host["hostname"], "session_id": session_id},
|
details={"host_id": host_id, "hostname": host["hostname"], "session_id": session_id},
|
||||||
)
|
)
|
||||||
await conn.commit()
|
await conn.commit()
|
||||||
|
logger.debug(
|
||||||
|
"RDP-Sitzung %s gestartet: user=%s host=%s (%s:%s) client_ip=%s",
|
||||||
|
session_id, user.username, host["hostname"], host["address"], host["port"], client_ip,
|
||||||
|
)
|
||||||
|
active_sessions.register(session_id, asyncio.current_task())
|
||||||
|
|
||||||
end_reason = "logout"
|
end_reason = "logout"
|
||||||
tunnel = None
|
tunnel = None
|
||||||
@ -125,6 +131,7 @@ async def rdp_tunnel(
|
|||||||
guacd_host=settings.guacd_host, guacd_port=settings.guacd_port,
|
guacd_host=settings.guacd_host, guacd_port=settings.guacd_port,
|
||||||
protocol="rdp", params=params, screen_width=width, screen_height=height, dpi=dpi,
|
protocol="rdp", params=params, screen_width=width, screen_height=height, dpi=dpi,
|
||||||
)
|
)
|
||||||
|
logger.debug("RDP-Sitzung %s: guacd-Tunnel zu %s aufgebaut", session_id, host["hostname"])
|
||||||
clipboard_enabled = bool(host.get("clipboard_enabled", True))
|
clipboard_enabled = bool(host.get("clipboard_enabled", True))
|
||||||
tasks = [
|
tasks = [
|
||||||
asyncio.create_task(_guacd_to_ws(tunnel, websocket, recorder)),
|
asyncio.create_task(_guacd_to_ws(tunnel, websocket, recorder)),
|
||||||
@ -144,13 +151,20 @@ async def rdp_tunnel(
|
|||||||
except (GuacamoleProtocolError, ConnectionError, OSError) as exc:
|
except (GuacamoleProtocolError, ConnectionError, OSError) as exc:
|
||||||
logger.warning("RDP-Sessionfehler (session_id=%s): %s", session_id, exc)
|
logger.warning("RDP-Sessionfehler (session_id=%s): %s", session_id, exc)
|
||||||
end_reason = "error"
|
end_reason = "error"
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
# Zwangs-Beendigung durch einen Superadmin ueber die Sessionview
|
||||||
|
# (POST /admin/sessions/{id}/terminate, siehe app/security/active_sessions.py).
|
||||||
|
end_reason = "terminated_by_admin"
|
||||||
|
raise
|
||||||
finally:
|
finally:
|
||||||
|
active_sessions.unregister(session_id)
|
||||||
del password # Klartext-Passwort so schnell wie moeglich freigeben
|
del password # Klartext-Passwort so schnell wie moeglich freigeben
|
||||||
for task in tasks:
|
for task in tasks:
|
||||||
task.cancel()
|
task.cancel()
|
||||||
if tunnel:
|
if tunnel:
|
||||||
await tunnel.close()
|
await tunnel.close()
|
||||||
recorder.close()
|
recorder.close()
|
||||||
|
logger.debug("RDP-Sitzung %s beendet: reason=%s", session_id, end_reason)
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
"UPDATE sessions SET ended_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), end_reason = ? "
|
"UPDATE sessions SET ended_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), end_reason = ? "
|
||||||
"WHERE id = ?",
|
"WHERE id = ?",
|
||||||
|
|||||||
46
app/security/active_sessions.py
Normal file
46
app/security/active_sessions.py
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
"""
|
||||||
|
In-Memory-Registry der laufenden SSH/RDP-Sitzungen dieses Server-Prozesses.
|
||||||
|
|
||||||
|
Wird von app/ssh_proxy/terminal_ws.py und app/rdp_proxy/ws_tunnel.py beim
|
||||||
|
Sitzungsstart befuellt (Referenz auf den eigenen asyncio.Task) und beim
|
||||||
|
Sitzungsende wieder entfernt. Basis fuer die Superadmin-'Sessionview'
|
||||||
|
(app/admin/routes.py: GET /admin/sessions, POST /admin/sessions/{id}/terminate)
|
||||||
|
-- 'Beenden' funktioniert per asyncio.Task.cancel(), was den regulaeren
|
||||||
|
finally-Cleanup-Pfad der jeweiligen WS-Route ausloest (DB-Update, Audit-
|
||||||
|
Log-Eintrag, WebSocket schliessen), genau wie bei einem normalen Logout.
|
||||||
|
|
||||||
|
Bewusst rein prozesslokal (kein Redis/DB-Backing): bei einem einzelnen
|
||||||
|
uvicorn-Worker (Standard-Deployment dieses Projekts, siehe ansible/) sieht
|
||||||
|
und beendet jeder Superadmin-Request alle laufenden Sitzungen. Bei mehreren
|
||||||
|
Worker-Prozessen sind nur die Sitzungen DIESES Workers 'killable' -- die
|
||||||
|
Sessionview zeigt das ueber das 'killable'-Feld pro Sitzung an, statt einen
|
||||||
|
Beenden-Versuch fehlschlagen zu lassen."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ActiveSession:
|
||||||
|
session_id: int
|
||||||
|
task: asyncio.Task
|
||||||
|
|
||||||
|
|
||||||
|
_active: dict[int, ActiveSession] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def register(session_id: int, task: asyncio.Task) -> None:
|
||||||
|
_active[session_id] = ActiveSession(session_id=session_id, task=task)
|
||||||
|
|
||||||
|
|
||||||
|
def unregister(session_id: int) -> None:
|
||||||
|
_active.pop(session_id, None)
|
||||||
|
|
||||||
|
|
||||||
|
def get(session_id: int) -> ActiveSession | None:
|
||||||
|
return _active.get(session_id)
|
||||||
|
|
||||||
|
|
||||||
|
def all_ids() -> set[int]:
|
||||||
|
return set(_active.keys())
|
||||||
71
app/security/log_stream.py
Normal file
71
app/security/log_stream.py
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
"""
|
||||||
|
Ring-Buffer + Pub/Sub fuer das Live-'Verbindungslog' im Admin-Bereich.
|
||||||
|
|
||||||
|
Haengt sich als zusaetzlicher logging.Handler an den Root-Logger, haelt die
|
||||||
|
letzten LOG-Zeilen im Prozessspeicher (fuer den initialen Replay beim
|
||||||
|
Oeffnen des Tabs) und verteilt jede neue Zeile an alle aktuell verbundenen
|
||||||
|
WebSocket-Abonnenten (siehe app/admin/routes.py, WS /admin/ws/logs).
|
||||||
|
|
||||||
|
Bewusst KEINE Persistenz in der DB -- das ist ein reiner Live-Tail (wie
|
||||||
|
'journalctl -f'), kein durchsuchbares Archiv. Rein prozesslokal: bei mehreren
|
||||||
|
Worker-Prozessen sieht ein Abonnent nur die Logs des Worker-Prozesses, der
|
||||||
|
seine WebSocket-Verbindung bedient.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from collections import deque
|
||||||
|
|
||||||
|
MAX_BUFFER_LINES = 1000
|
||||||
|
_FORMATTER = logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s")
|
||||||
|
|
||||||
|
_buffer: deque[str] = deque(maxlen=MAX_BUFFER_LINES)
|
||||||
|
_subscribers: set[asyncio.Queue[str]] = set()
|
||||||
|
_installed = False
|
||||||
|
|
||||||
|
|
||||||
|
class _BroadcastHandler(logging.Handler):
|
||||||
|
def emit(self, record: logging.LogRecord) -> None:
|
||||||
|
try:
|
||||||
|
line = _FORMATTER.format(record)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
_buffer.append(line)
|
||||||
|
for queue in list(_subscribers):
|
||||||
|
try:
|
||||||
|
queue.put_nowait(line)
|
||||||
|
except asyncio.QueueFull:
|
||||||
|
# Langsamer/verschwundener Abonnent -- lieber eine Zeile
|
||||||
|
# verlieren als das Event-Loop-Logging blockieren.
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def install() -> None:
|
||||||
|
"""Idempotent -- an app.main::lifespan gebunden. Haengt den Handler an
|
||||||
|
den Root-Logger (sieht alles, was propagiert) und hebt den Level des
|
||||||
|
eigenen App-Namespace 'jumphost' auf DEBUG an, damit die Verbindungs-
|
||||||
|
Debug-Meldungen (SSH/RDP-Handshake etc.) ueberhaupt erzeugt werden, ohne
|
||||||
|
Drittanbieter-Logger (uvicorn, asyncssh) unnoetig auf DEBUG zu stellen."""
|
||||||
|
global _installed
|
||||||
|
if _installed:
|
||||||
|
return
|
||||||
|
handler = _BroadcastHandler()
|
||||||
|
handler.setLevel(logging.DEBUG)
|
||||||
|
logging.getLogger().addHandler(handler)
|
||||||
|
logging.getLogger("jumphost").setLevel(logging.DEBUG)
|
||||||
|
_installed = True
|
||||||
|
|
||||||
|
|
||||||
|
def subscribe() -> asyncio.Queue[str]:
|
||||||
|
queue: asyncio.Queue[str] = asyncio.Queue(maxsize=500)
|
||||||
|
_subscribers.add(queue)
|
||||||
|
return queue
|
||||||
|
|
||||||
|
|
||||||
|
def unsubscribe(queue: asyncio.Queue[str]) -> None:
|
||||||
|
_subscribers.discard(queue)
|
||||||
|
|
||||||
|
|
||||||
|
def recent_lines() -> list[str]:
|
||||||
|
return list(_buffer)
|
||||||
@ -30,6 +30,20 @@ class HostKeyMismatchError(Exception):
|
|||||||
super().__init__(f"Host-Key-Mismatch: erwartet={expected!r} beobachtet={observed!r}")
|
super().__init__(f"Host-Key-Mismatch: erwartet={expected!r} beobachtet={observed!r}")
|
||||||
|
|
||||||
|
|
||||||
|
class HostKeyDiscoveryError(Exception):
|
||||||
|
"""Wird geworfen, wenn beim Host-Key-Discovery-Versuch (siehe
|
||||||
|
discover_and_store_host_key) ueberhaupt KEIN Fingerprint beobachtet
|
||||||
|
werden konnte (TCP/DNS/Timeout-Fehler VOR dem SSH-Key-Exchange) -- im
|
||||||
|
Unterschied zu einem erwarteten Auth-Fehler NACH dem KEX (siehe dort).
|
||||||
|
Der admin-only Endpunkt (admin/routes.py) faengt dies ab und liefert
|
||||||
|
eine saubere 502 statt eines unbehandelten 500."""
|
||||||
|
|
||||||
|
def __init__(self, host_id: int, reason: str) -> None:
|
||||||
|
self.host_id = host_id
|
||||||
|
self.reason = reason
|
||||||
|
super().__init__(f"Host-Key-Ermittlung fuer Host {host_id} fehlgeschlagen: {reason}")
|
||||||
|
|
||||||
|
|
||||||
class _PinnedHostKeyClient(asyncssh.SSHClient):
|
class _PinnedHostKeyClient(asyncssh.SSHClient):
|
||||||
"""Erzwingt Strict Host Key Checking gegen einen fest hinterlegten
|
"""Erzwingt Strict Host Key Checking gegen einen fest hinterlegten
|
||||||
SHA-256-Fingerprint. Kein automatisches Trust-on-First-Use (TOFU)."""
|
SHA-256-Fingerprint. Kein automatisches Trust-on-First-Use (TOFU)."""
|
||||||
@ -114,14 +128,45 @@ async def discover_and_store_host_key(
|
|||||||
"""Verbindet EINMALIG ohne Pinning, um den Host-Key-Fingerprint zu erfassen
|
"""Verbindet EINMALIG ohne Pinning, um den Host-Key-Fingerprint zu erfassen
|
||||||
und in der DB zu hinterlegen. Nur ueber einen dedizierten, admin-only
|
und in der DB zu hinterlegen. Nur ueber einen dedizierten, admin-only
|
||||||
Endpunkt aufrufbar -- jeder Aufruf ist eine bewusste Vertrauensentscheidung
|
Endpunkt aufrufbar -- jeder Aufruf ist eine bewusste Vertrauensentscheidung
|
||||||
und wird im Audit-Log als solche vermerkt (siehe admin/routes.py)."""
|
und wird im Audit-Log als solche vermerkt (siehe admin/routes.py).
|
||||||
|
|
||||||
|
Bugfix (Fehler 500 bei 'Host-Key ermitteln'): asyncssh.connect() fuehrt
|
||||||
|
nach dem Key-Exchange (bei dem validate_host_public_key() den Fingerprint
|
||||||
|
bereits erfasst) automatisch die Authentifizierung durch -- ohne
|
||||||
|
hinterlegten Client-Key/Passwort schlaegt die IMMER fehl (asyncssh.Error,
|
||||||
|
z.B. PermissionDenied), obwohl der Fingerprint laengst bekannt ist. Diese
|
||||||
|
fuer die reine Discovery irrelevante Auth-Fehlermeldung wurde bisher NICHT
|
||||||
|
abgefangen und riss als unbehandelte Exception bis zu FastAPI durch ->
|
||||||
|
500 Internal Server Error bei praktisch jedem Aufruf. Fix: Verbindungs-
|
||||||
|
fehler abfangen; wenn dabei bereits ein Fingerprint beobachtet wurde, gilt
|
||||||
|
die Discovery als erfolgreich. Nur wenn wirklich KEIN Fingerprint erfasst
|
||||||
|
wurde (Host nicht erreichbar, DNS-Fehler, Timeout -- also ein Fehler VOR
|
||||||
|
dem Key-Exchange), ist es ein echter Fehler (HostKeyDiscoveryError)."""
|
||||||
host = await load_host(conn, host_id)
|
host = await load_host(conn, host_id)
|
||||||
|
if host["protocol"] != "ssh":
|
||||||
|
raise HostNotConfiguredError("Host-Key-Ermittlung ist nur fuer SSH-Ziele moeglich")
|
||||||
|
|
||||||
client = _PinnedHostKeyClient(None, discovery_mode=True)
|
client = _PinnedHostKeyClient(None, discovery_mode=True)
|
||||||
|
connection = None
|
||||||
|
try:
|
||||||
connection = await asyncssh.connect(
|
connection = await asyncssh.connect(
|
||||||
host["address"], port=host["port"], username=host["ssh_username"],
|
host["address"], port=host["port"], username=host["ssh_username"],
|
||||||
known_hosts=None, client_factory=lambda: client, connect_timeout=10,
|
known_hosts=None, client_factory=lambda: client, connect_timeout=10,
|
||||||
)
|
)
|
||||||
|
except (asyncssh.Error, OSError) as exc:
|
||||||
|
if client.observed_fingerprint is None:
|
||||||
|
logger.warning("Host-Key-Ermittlung fuer Host %s fehlgeschlagen: %s", host_id, exc)
|
||||||
|
raise HostKeyDiscoveryError(host_id, str(exc)) from exc
|
||||||
|
# Fingerprint wurde bereits waehrend des Key-Exchange erfasst -- der
|
||||||
|
# anschliessende Auth-Fehler ist fuer die Discovery unschaedlich.
|
||||||
|
logger.info(
|
||||||
|
"Host-Key fuer Host %s erfasst (Auth-Phase erwartungsgemaess fehlgeschlagen: %s)",
|
||||||
|
host_id, exc,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
if connection is not None:
|
||||||
connection.close()
|
connection.close()
|
||||||
|
|
||||||
fingerprint = client.observed_fingerprint
|
fingerprint = client.observed_fingerprint
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
"UPDATE hosts SET ssh_host_key_fingerprint = ? WHERE id = ?", (fingerprint, host_id)
|
"UPDATE hosts SET ssh_host_key_fingerprint = ? WHERE id = ?", (fingerprint, host_id)
|
||||||
|
|||||||
@ -21,6 +21,7 @@ from app.auth.deps import get_current_user_ws
|
|||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.rbac import user_has_role_for_host
|
from app.rbac import user_has_role_for_host
|
||||||
from app.recordings.recorder import SessionRecorder
|
from app.recordings.recorder import SessionRecorder
|
||||||
|
from app.security import active_sessions
|
||||||
from app.security.audit import write_audit_event
|
from app.security.audit import write_audit_event
|
||||||
from app.ssh_proxy.proxy import HostNotConfiguredError, connect_to_host, load_host
|
from app.ssh_proxy.proxy import HostNotConfiguredError, connect_to_host, load_host
|
||||||
|
|
||||||
@ -83,6 +84,11 @@ async def ssh_terminal(websocket: WebSocket, host_id: int):
|
|||||||
details={"host_id": host_id, "hostname": host["hostname"], "session_id": session_id},
|
details={"host_id": host_id, "hostname": host["hostname"], "session_id": session_id},
|
||||||
)
|
)
|
||||||
await conn.commit()
|
await conn.commit()
|
||||||
|
logger.debug(
|
||||||
|
"SSH-Sitzung %s gestartet: user=%s host=%s (%s:%s) client_ip=%s",
|
||||||
|
session_id, user.username, host["hostname"], host["address"], host["port"], client_ip,
|
||||||
|
)
|
||||||
|
active_sessions.register(session_id, asyncio.current_task())
|
||||||
|
|
||||||
end_reason = "logout"
|
end_reason = "logout"
|
||||||
ssh_conn = None
|
ssh_conn = None
|
||||||
@ -90,6 +96,7 @@ async def ssh_terminal(websocket: WebSocket, host_id: int):
|
|||||||
pump_task = None
|
pump_task = None
|
||||||
try:
|
try:
|
||||||
ssh_conn = await connect_to_host(conn, host_id)
|
ssh_conn = await connect_to_host(conn, host_id)
|
||||||
|
logger.debug("SSH-Sitzung %s: Verbindung zu %s hergestellt", session_id, host["hostname"])
|
||||||
process = await ssh_conn.create_process(term_type="xterm-256color")
|
process = await ssh_conn.create_process(term_type="xterm-256color")
|
||||||
pump_task = asyncio.create_task(_pump_ssh_to_ws(process, websocket, recorder))
|
pump_task = asyncio.create_task(_pump_ssh_to_ws(process, websocket, recorder))
|
||||||
|
|
||||||
@ -120,7 +127,13 @@ async def ssh_terminal(websocket: WebSocket, host_id: int):
|
|||||||
logger.debug("Fehlermeldung konnte nicht mehr an Client gesendet werden", exc_info=True)
|
logger.debug("Fehlermeldung konnte nicht mehr an Client gesendet werden", exc_info=True)
|
||||||
except HostNotConfiguredError:
|
except HostNotConfiguredError:
|
||||||
end_reason = "error"
|
end_reason = "error"
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
# Zwangs-Beendigung durch einen Superadmin ueber die Sessionview
|
||||||
|
# (POST /admin/sessions/{id}/terminate, siehe app/security/active_sessions.py).
|
||||||
|
end_reason = "terminated_by_admin"
|
||||||
|
raise
|
||||||
finally:
|
finally:
|
||||||
|
active_sessions.unregister(session_id)
|
||||||
if pump_task:
|
if pump_task:
|
||||||
pump_task.cancel()
|
pump_task.cancel()
|
||||||
if process:
|
if process:
|
||||||
@ -128,6 +141,7 @@ async def ssh_terminal(websocket: WebSocket, host_id: int):
|
|||||||
if ssh_conn:
|
if ssh_conn:
|
||||||
ssh_conn.close()
|
ssh_conn.close()
|
||||||
recorder.close()
|
recorder.close()
|
||||||
|
logger.debug("SSH-Sitzung %s beendet: reason=%s", session_id, end_reason)
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
"UPDATE sessions SET ended_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), end_reason = ? "
|
"UPDATE sessions SET ended_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), end_reason = ? "
|
||||||
"WHERE id = ?",
|
"WHERE id = ?",
|
||||||
|
|||||||
@ -166,6 +166,11 @@ pre.json-view {
|
|||||||
background: #0f1218; border: 1px solid var(--border); border-radius: 6px; padding: 0.75rem;
|
background: #0f1218; border: 1px solid var(--border); border-radius: 6px; padding: 0.75rem;
|
||||||
font-size: 0.8rem; overflow-x: auto; white-space: pre-wrap; word-break: break-word;
|
font-size: 0.8rem; overflow-x: auto; white-space: pre-wrap; word-break: break-word;
|
||||||
}
|
}
|
||||||
|
pre.log-view {
|
||||||
|
background: #0f1218; border: 1px solid var(--border); border-radius: 6px; padding: 0.75rem;
|
||||||
|
font-size: 0.78rem; line-height: 1.4; overflow: auto; white-space: pre-wrap; word-break: break-word;
|
||||||
|
max-height: 60vh; margin-top: 0.5rem;
|
||||||
|
}
|
||||||
.endpoint-row {
|
.endpoint-row {
|
||||||
display: flex; align-items: center; gap: 0.6rem; padding: 0.5rem 0; cursor: pointer;
|
display: flex; align-items: center; gap: 0.6rem; padding: 0.5rem 0; cursor: pointer;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
|
|||||||
@ -3,14 +3,8 @@
|
|||||||
|
|
||||||
const ROLE_NAMES = [
|
const ROLE_NAMES = [
|
||||||
"ssh_connect", "rdp_connect", "file_transfer", "clipboard",
|
"ssh_connect", "rdp_connect", "file_transfer", "clipboard",
|
||||||
"session_recording_view", "admin_hostgroup",
|
"session_recording_view", "admin_hostgroup", "credentials_view", "credentials_manage",
|
||||||
];
|
];
|
||||||
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");
|
const bannerBox = document.getElementById("banner-box");
|
||||||
let meInfo = { is_admin: false, tenant_admin_of: [] };
|
let meInfo = { is_admin: false, tenant_admin_of: [] };
|
||||||
@ -112,15 +106,18 @@
|
|||||||
const tabLoaders = {
|
const tabLoaders = {
|
||||||
users: loadUsersTab,
|
users: loadUsersTab,
|
||||||
groups: loadGroupsTab,
|
groups: loadGroupsTab,
|
||||||
hosts: loadHostsTab,
|
hostgroups: loadHostsTab,
|
||||||
|
servers: loadHostsTab,
|
||||||
credentials: loadCredentialsTab,
|
credentials: loadCredentialsTab,
|
||||||
roles: loadRolesTab,
|
roles: loadRolesTab,
|
||||||
tokens: loadTokensTab,
|
tokens: loadTokensTab,
|
||||||
tenants: loadTenantsTab,
|
tenants: loadTenantsTab,
|
||||||
authlog: loadAuthLogTab,
|
sessions: loadSessionsTab,
|
||||||
|
connlog: loadConnLogTab,
|
||||||
audit: loadAuditTab,
|
audit: loadAuditTab,
|
||||||
};
|
};
|
||||||
const loadedTabs = new Set();
|
const loadedTabs = new Set();
|
||||||
|
let currentTab = null;
|
||||||
|
|
||||||
document.getElementById("tabs").addEventListener("click", (ev) => {
|
document.getElementById("tabs").addEventListener("click", (ev) => {
|
||||||
const btn = ev.target.closest(".tab-btn");
|
const btn = ev.target.closest(".tab-btn");
|
||||||
@ -130,9 +127,15 @@
|
|||||||
document.querySelectorAll(".tab-panel").forEach((panel) => {
|
document.querySelectorAll(".tab-panel").forEach((panel) => {
|
||||||
panel.classList.toggle("hidden", panel.id !== `tab-${tab}`);
|
panel.classList.toggle("hidden", panel.id !== `tab-${tab}`);
|
||||||
});
|
});
|
||||||
|
if (currentTab === "connlog" && tab !== "connlog") disconnectLogStream();
|
||||||
|
currentTab = tab;
|
||||||
if (!loadedTabs.has(tab)) {
|
if (!loadedTabs.has(tab)) {
|
||||||
loadedTabs.add(tab);
|
loadedTabs.add(tab);
|
||||||
tabLoaders[tab]().catch((err) => showBanner(err.message, "error"));
|
tabLoaders[tab]().catch((err) => showBanner(err.message, "error"));
|
||||||
|
} else if (tab === "connlog") {
|
||||||
|
connectLogStream();
|
||||||
|
} else if (tab === "sessions") {
|
||||||
|
refreshSessions().catch((err) => showBanner(err.message, "error"));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -797,7 +800,7 @@
|
|||||||
el("td", { textContent: r.updated_at || "-" }),
|
el("td", { textContent: r.updated_at || "-" }),
|
||||||
el("td", {}, [
|
el("td", {}, [
|
||||||
actionButton("Zum Host", "btn-secondary", async () => {
|
actionButton("Zum Host", "btn-secondary", async () => {
|
||||||
document.querySelector('.tab-btn[data-tab="hosts"]').click();
|
document.querySelector('.tab-btn[data-tab="servers"]').click();
|
||||||
await showHostDetail(r.host_id);
|
await showHostDetail(r.host_id);
|
||||||
}),
|
}),
|
||||||
...(r.credentials_set
|
...(r.credentials_set
|
||||||
@ -1122,36 +1125,151 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
// Login-Verlauf (Auth-Log) -- clientseitig aus dem Audit-Log gefiltert
|
// Sessions (nur Super-Admin) -- aktive + historische Sitzungen, Beenden,
|
||||||
|
// Link zur Aufzeichnung.
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
async function loadAuthLogTab() {
|
let sessionsActiveOnly = true;
|
||||||
await refreshAuthLog();
|
let sessionsAutoTimer = null;
|
||||||
|
|
||||||
|
async function loadSessionsTab() {
|
||||||
|
document.getElementById("sessions-active-only").checked = sessionsActiveOnly;
|
||||||
|
await refreshSessions();
|
||||||
|
if (sessionsAutoTimer === null) {
|
||||||
|
sessionsAutoTimer = window.setInterval(() => {
|
||||||
|
if (currentTab === "sessions") refreshSessions().catch(() => {});
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshAuthLog() {
|
document.getElementById("sessions-active-only").addEventListener("change", (ev) => {
|
||||||
const entries = await getJson("/admin/audit-log?limit=500");
|
sessionsActiveOnly = ev.target.checked;
|
||||||
const authEntries = entries.filter((e) => AUTH_EVENT_TYPES.has(e.event_type));
|
refreshSessions().catch((err) => showBanner(err.message, "error"));
|
||||||
const tbody = document.querySelector("#authlog-table tbody");
|
});
|
||||||
|
|
||||||
|
async function refreshSessions() {
|
||||||
|
const rows = await getJson(`/admin/sessions?active_only=${sessionsActiveOnly}&limit=300`);
|
||||||
|
const tbody = document.querySelector("#sessions-table tbody");
|
||||||
fillTable(
|
fillTable(
|
||||||
tbody,
|
tbody,
|
||||||
authEntries.map((e) =>
|
rows.map((s) => {
|
||||||
el("tr", {}, [
|
const actions = el("td", {});
|
||||||
el("td", { textContent: e.ts }),
|
if (s.is_active && s.killable) {
|
||||||
el("td", { textContent: e.user_id === null ? "-" : String(e.user_id) }),
|
actions.appendChild(
|
||||||
el("td", { textContent: e.client_ip || "-" }),
|
actionButton("Beenden", "btn-danger", async () => {
|
||||||
el("td", { textContent: e.event_type }),
|
await sendJson(`/admin/sessions/${s.id}/terminate`, "POST", {});
|
||||||
|
showBanner(`Sitzung #${s.id} beendet.`, "ok");
|
||||||
|
await refreshSessions();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else if (s.is_active) {
|
||||||
|
actions.appendChild(el("span", { className: "hint", textContent: "anderer Prozess" }));
|
||||||
|
}
|
||||||
|
if (s.has_recording) {
|
||||||
|
actions.appendChild(
|
||||||
|
actionButton("Aufzeichnung", "btn-secondary", () => showSessionRecording(s.id))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return el("tr", {}, [
|
||||||
|
el("td", { textContent: String(s.id) }),
|
||||||
|
el("td", { textContent: s.username }),
|
||||||
|
el("td", { textContent: `${s.hostname} (${s.host_group_name})` }),
|
||||||
|
el("td", { textContent: s.protocol.toUpperCase() }),
|
||||||
|
el("td", { textContent: s.started_at }),
|
||||||
el("td", {}, [
|
el("td", {}, [
|
||||||
el("span", {
|
el("span", {
|
||||||
className: `badge ${FAILURE_EVENT_TYPES.has(e.event_type) ? "danger" : "ok"}`,
|
className: `badge ${s.is_active ? "ok" : ""}`,
|
||||||
textContent: FAILURE_EVENT_TYPES.has(e.event_type) ? "fehlgeschlagen" : "erfolgreich",
|
textContent: s.is_active ? "aktiv" : (s.end_reason || "beendet"),
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
])
|
el("td", { textContent: s.client_ip }),
|
||||||
)
|
actions,
|
||||||
|
]);
|
||||||
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function showSessionRecording(sessionId) {
|
||||||
|
try {
|
||||||
|
const result = await getJson(`/admin/sessions/${sessionId}/recording`);
|
||||||
|
const box = document.getElementById("session-recording-box");
|
||||||
|
box.classList.remove("hidden");
|
||||||
|
box.className = `banner ${result.verified ? "ok" : "error"}`;
|
||||||
|
box.textContent = result.verified
|
||||||
|
? `Aufzeichnung #${sessionId}: Integritaet OK, ${result.entry_count} Eintraege.`
|
||||||
|
: `Aufzeichnung #${sessionId}: WARNUNG -- Hash-Kette gebrochen, moeglicherweise manipuliert!`;
|
||||||
|
} catch (err) {
|
||||||
|
showBanner(err.message, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Verbindungslog (nur Super-Admin) -- Live-Tail der Anwendungslogs
|
||||||
|
// (inkl. Debug fuer SSH/RDP-Verbindungsaufbau) per WebSocket.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
let logSocket = null;
|
||||||
|
let logLines = [];
|
||||||
|
const LOG_MAX_LINES = 2000;
|
||||||
|
|
||||||
|
function connectLogStream() {
|
||||||
|
if (logSocket && (logSocket.readyState === WebSocket.OPEN || logSocket.readyState === WebSocket.CONNECTING)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
logSocket = new WebSocket(`${proto}//${window.location.host}/admin/ws/logs`);
|
||||||
|
setConnLogStatus("verbinde...");
|
||||||
|
logSocket.addEventListener("open", () => setConnLogStatus("verbunden"));
|
||||||
|
logSocket.addEventListener("close", () => setConnLogStatus("getrennt"));
|
||||||
|
logSocket.addEventListener("error", () => setConnLogStatus("Fehler"));
|
||||||
|
logSocket.addEventListener("message", (ev) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(ev.data);
|
||||||
|
if (msg.type === "line") appendLogLine(msg.line);
|
||||||
|
} catch (_err) {
|
||||||
|
// ungueltige Nachricht ignorieren
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnectLogStream() {
|
||||||
|
if (logSocket) {
|
||||||
|
logSocket.close();
|
||||||
|
logSocket = null;
|
||||||
|
}
|
||||||
|
setConnLogStatus("getrennt");
|
||||||
|
}
|
||||||
|
|
||||||
|
function setConnLogStatus(text) {
|
||||||
|
const el2 = document.getElementById("connlog-status");
|
||||||
|
if (el2) el2.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendLogLine(line) {
|
||||||
|
logLines.push(line);
|
||||||
|
if (logLines.length > LOG_MAX_LINES) logLines = logLines.slice(-LOG_MAX_LINES);
|
||||||
|
renderConnLog();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderConnLog() {
|
||||||
|
const pre = document.getElementById("connlog-output");
|
||||||
|
if (!pre) return;
|
||||||
|
const filter = document.getElementById("connlog-filter").value.trim().toLowerCase();
|
||||||
|
const filtered = filter ? logLines.filter((l) => l.toLowerCase().includes(filter)) : logLines;
|
||||||
|
const wasAtBottom = pre.scrollTop + pre.clientHeight >= pre.scrollHeight - 20;
|
||||||
|
pre.textContent = filtered.join("\n");
|
||||||
|
if (wasAtBottom) pre.scrollTop = pre.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadConnLogTab() {
|
||||||
|
connectLogStream();
|
||||||
|
document.getElementById("connlog-filter").addEventListener("input", renderConnLog);
|
||||||
|
document.getElementById("connlog-clear-btn").addEventListener("click", () => {
|
||||||
|
logLines = [];
|
||||||
|
renderConnLog();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
// Audit-Log
|
// Audit-Log
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
@ -1238,6 +1356,8 @@
|
|||||||
: `Mandanten-Admin: ${me.tenant_admin_of.map((t) => t.name).join(", ")}`;
|
: `Mandanten-Admin: ${me.tenant_admin_of.map((t) => t.name).join(", ")}`;
|
||||||
document.getElementById("whoami").textContent = `${me.username} (${roleLabel})`;
|
document.getElementById("whoami").textContent = `${me.username} (${roleLabel})`;
|
||||||
document.getElementById("tenants-tab-btn").classList.toggle("hidden", !me.is_admin);
|
document.getElementById("tenants-tab-btn").classList.toggle("hidden", !me.is_admin);
|
||||||
|
document.getElementById("sessions-tab-btn").classList.toggle("hidden", !me.is_admin);
|
||||||
|
document.getElementById("connlog-tab-btn").classList.toggle("hidden", !me.is_admin);
|
||||||
loadedTabs.add("users");
|
loadedTabs.add("users");
|
||||||
await loadUsersTab();
|
await loadUsersTab();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,30 @@
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function showCredentials(host) {
|
||||||
|
const box = document.getElementById("credentials-box");
|
||||||
|
box.className = "reveal-box";
|
||||||
|
box.textContent = `Lade Zugangsdaten fuer ${host.hostname} ...`;
|
||||||
|
try {
|
||||||
|
const info = await getJson(`/admin/hosts/${host.id}/credentials`);
|
||||||
|
const lines = [`Zugangsdaten fuer ${host.hostname}:`];
|
||||||
|
lines.push(
|
||||||
|
info.ssh_keys.length
|
||||||
|
? `SSH-Keys: ${info.ssh_keys.map((k) => k.label).join(", ")}`
|
||||||
|
: "SSH-Keys: keine zugeordnet"
|
||||||
|
);
|
||||||
|
lines.push(
|
||||||
|
info.rdp_credentials_set
|
||||||
|
? `RDP-Passwort gesetzt (zuletzt aktualisiert: ${info.rdp_credentials_updated_at}).`
|
||||||
|
: "RDP-Passwort: nicht gesetzt."
|
||||||
|
);
|
||||||
|
box.textContent = lines.join("\n");
|
||||||
|
} catch (err) {
|
||||||
|
box.className = "banner error";
|
||||||
|
box.textContent = `Zugangsdaten konnten nicht geladen werden: ${err.message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function hostCard(host) {
|
function hostCard(host) {
|
||||||
const div = document.createElement("div");
|
const div = document.createElement("div");
|
||||||
div.className = "host-card";
|
div.className = "host-card";
|
||||||
@ -32,6 +56,17 @@
|
|||||||
connect.textContent = "Verbinden";
|
connect.textContent = "Verbinden";
|
||||||
actions.appendChild(connect);
|
actions.appendChild(connect);
|
||||||
|
|
||||||
|
if (host.can_view_credentials) {
|
||||||
|
const credBtn = document.createElement("button");
|
||||||
|
credBtn.type = "button";
|
||||||
|
credBtn.textContent = "Zugangsdaten";
|
||||||
|
credBtn.addEventListener("click", () => {
|
||||||
|
document.getElementById("credentials-box").classList.remove("hidden");
|
||||||
|
showCredentials(host);
|
||||||
|
});
|
||||||
|
actions.appendChild(credBtn);
|
||||||
|
}
|
||||||
|
|
||||||
div.appendChild(actions);
|
div.appendChild(actions);
|
||||||
return div;
|
return div;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,12 +23,14 @@
|
|||||||
<div class="tabs" id="tabs">
|
<div class="tabs" id="tabs">
|
||||||
<button type="button" class="tab-btn active" data-tab="users">Benutzer</button>
|
<button type="button" class="tab-btn active" data-tab="users">Benutzer</button>
|
||||||
<button type="button" class="tab-btn" data-tab="groups">Benutzergruppen</button>
|
<button type="button" class="tab-btn" data-tab="groups">Benutzergruppen</button>
|
||||||
<button type="button" class="tab-btn" data-tab="hosts">Hosts & Verbindungen</button>
|
<button type="button" class="tab-btn" data-tab="hostgroups">Hostgruppen</button>
|
||||||
|
<button type="button" class="tab-btn" data-tab="servers">Server</button>
|
||||||
<button type="button" class="tab-btn" data-tab="credentials">Zugangsdaten</button>
|
<button type="button" class="tab-btn" data-tab="credentials">Zugangsdaten</button>
|
||||||
<button type="button" class="tab-btn" data-tab="roles">Rollen</button>
|
<button type="button" class="tab-btn" data-tab="roles">Rollen</button>
|
||||||
<button type="button" class="tab-btn" data-tab="tokens">API-Tokens</button>
|
<button type="button" class="tab-btn" data-tab="tokens">API-Tokens</button>
|
||||||
<button type="button" class="tab-btn hidden" data-tab="tenants" id="tenants-tab-btn">Mandanten</button>
|
<button type="button" class="tab-btn hidden" data-tab="tenants" id="tenants-tab-btn">Mandanten</button>
|
||||||
<button type="button" class="tab-btn" data-tab="authlog">Login-Verlauf</button>
|
<button type="button" class="tab-btn hidden" data-tab="sessions" id="sessions-tab-btn">Sessions</button>
|
||||||
|
<button type="button" class="tab-btn hidden" data-tab="connlog" id="connlog-tab-btn">Verbindungslog</button>
|
||||||
<button type="button" class="tab-btn" data-tab="audit">Audit-Log</button>
|
<button type="button" class="tab-btn" data-tab="audit">Audit-Log</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -154,8 +156,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ================= Hosts & Verbindungen ================= -->
|
<!-- ================= Hostgruppen ================= -->
|
||||||
<div class="tab-panel hidden" id="tab-hosts">
|
<div class="tab-panel hidden" id="tab-hostgroups">
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<h2>Neue Hostgruppe anlegen</h2>
|
<h2>Neue Hostgruppe anlegen</h2>
|
||||||
<form id="hostgroup-create-form" class="form-grid">
|
<form id="hostgroup-create-form" class="form-grid">
|
||||||
@ -196,7 +198,10 @@
|
|||||||
<button type="submit">Speichern</button>
|
<button type="submit">Speichern</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ================= Server ================= -->
|
||||||
|
<div class="tab-panel hidden" id="tab-servers">
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<h2>Neue Verbindung (Host) anlegen</h2>
|
<h2>Neue Verbindung (Host) anlegen</h2>
|
||||||
<form id="host-create-form" class="form-grid">
|
<form id="host-create-form" class="form-grid">
|
||||||
@ -593,20 +598,42 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ================= Login-Verlauf ================= -->
|
<!-- ================= Sessions (nur Super-Admin) ================= -->
|
||||||
<div class="tab-panel hidden" id="tab-authlog">
|
<div class="tab-panel hidden" id="tab-sessions">
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<h2>Login-Verlauf</h2>
|
<h2>Sitzungen</h2>
|
||||||
<p class="hint">Authentifizierungs-Ereignisse (Login/Logout/TOTP/Passwortwechsel), gefiltert aus dem Audit-Log dieses/dieser Mandanten.</p>
|
<p class="hint">Aktive und historische SSH/RDP-Sitzungen. 'Beenden' trennt eine laufende Sitzung zwangsweise -- funktioniert nur, wenn die Sitzung auf demselben Server-Prozess laeuft, der diese Anfrage bearbeitet.</p>
|
||||||
|
<div class="checkbox-row">
|
||||||
|
<input type="checkbox" id="sessions-active-only" checked>
|
||||||
|
<label for="sessions-active-only">Nur aktive Sitzungen</label>
|
||||||
|
</div>
|
||||||
|
<div id="session-recording-box" class="hidden"></div>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table class="data-table" id="authlog-table">
|
<table class="data-table" id="sessions-table">
|
||||||
<thead><tr><th>Zeit</th><th>Benutzer</th><th>IP</th><th>Ereignis</th><th>Status</th></tr></thead>
|
<thead><tr><th>ID</th><th>Benutzer</th><th>Host</th><th>Protokoll</th><th>Start</th><th>Status</th><th>Client-IP</th><th></th></tr></thead>
|
||||||
<tbody></tbody>
|
<tbody></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ================= Verbindungslog (nur Super-Admin) ================= -->
|
||||||
|
<div class="tab-panel hidden" id="tab-connlog">
|
||||||
|
<div class="panel">
|
||||||
|
<h2>Verbindungslog (Live)</h2>
|
||||||
|
<p class="hint">Live-Mitschnitt der Anwendungslogs dieses Server-Prozesses, inkl. Debug-Details zu SSH/RDP-Verbindungsaufbau. Rein prozesslokal (kein Verlauf vor dem Oeffnen dieses Tabs) und nur fuer Super-Admins sichtbar.</p>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div>
|
||||||
|
<label for="connlog-filter">Filter (Client-seitig)</label>
|
||||||
|
<input type="text" id="connlog-filter" placeholder="z.B. Hostname oder 'ERROR'">
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn-secondary" id="connlog-clear-btn">Leeren</button>
|
||||||
|
</div>
|
||||||
|
<p class="hint">Status: <span id="connlog-status">getrennt</span></p>
|
||||||
|
<pre id="connlog-output" class="log-view"></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ================= Audit-Log ================= -->
|
<!-- ================= Audit-Log ================= -->
|
||||||
<div class="tab-panel hidden" id="tab-audit">
|
<div class="tab-panel hidden" id="tab-audit">
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
|
|||||||
@ -15,7 +15,10 @@
|
|||||||
<button id="logout-btn">Abmelden</button>
|
<button id="logout-btn">Abmelden</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="container" id="groups"></div>
|
<div class="container">
|
||||||
|
<div id="credentials-box" class="hidden"></div>
|
||||||
|
<div id="groups"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="/static/js/dashboard.js"></script>
|
<script src="/static/js/dashboard.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@ -84,7 +84,7 @@ async def test_group_role_grant_gives_catalog_access_without_individual_grant(cl
|
|||||||
|
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
"/admin/group-roles/grant",
|
"/admin/group-roles/grant",
|
||||||
json={"user_group_id": group_id, "host_group_id": hg_id, "role_name": "ssh_connect"},
|
json={"user_group_id": group_id, "host_group_id": hg_id, "role_names": ["ssh_connect"]},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200, resp.text
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
@ -114,7 +114,7 @@ async def test_removing_group_member_revokes_inherited_catalog_access(client):
|
|||||||
await client.post(f"/admin/user-groups/{group_id}/members", json={"user_id": member_id})
|
await client.post(f"/admin/user-groups/{group_id}/members", json={"user_id": member_id})
|
||||||
await client.post(
|
await client.post(
|
||||||
"/admin/group-roles/grant",
|
"/admin/group-roles/grant",
|
||||||
json={"user_group_id": group_id, "host_group_id": hg_id, "role_name": "ssh_connect"},
|
json={"user_group_id": group_id, "host_group_id": hg_id, "role_names": ["ssh_connect"]},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Mitgliedschaft wieder entfernen, BEVOR sich der User einloggt.
|
# Mitgliedschaft wieder entfernen, BEVOR sich der User einloggt.
|
||||||
@ -142,7 +142,7 @@ async def test_non_admin_cannot_manage_user_groups_or_group_roles(client):
|
|||||||
assert resp.status_code == 403
|
assert resp.status_code == 403
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
"/admin/group-roles/grant",
|
"/admin/group-roles/grant",
|
||||||
json={"user_group_id": 1, "host_group_id": 1, "role_name": "ssh_connect"},
|
json={"user_group_id": 1, "host_group_id": 1, "role_names": ["ssh_connect"]},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 403
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|||||||
286
tests/test_phase9.py
Normal file
286
tests/test_phase9.py
Normal file
@ -0,0 +1,286 @@
|
|||||||
|
"""
|
||||||
|
Tests fuer die in dieser Session ("Phase 9") umgesetzten Punkte:
|
||||||
|
|
||||||
|
1) Bugfix "Host-Key ermitteln" 500 -> discover_and_store_host_key()
|
||||||
|
fehlgeschlagene Verbindungen wurden bisher NICHT abgefangen (siehe
|
||||||
|
app/ssh_proxy/proxy.py); der Endpunkt muss jetzt 502 (echter
|
||||||
|
Verbindungsfehler) statt eines unbehandelten 500 liefern, und bei einem
|
||||||
|
Fingerprint trotz Auth-Fehler NACH dem Key-Exchange weiterhin 200.
|
||||||
|
(Punkt 2 "Login-Verlauf entfernen" und Punkt 5 "Hostgruppen/Server im
|
||||||
|
Menue trennen" sind reine Admin-UI-Aenderungen ohne eigenen Endpunkt --
|
||||||
|
dafuer siehe templates/admin.html + static/js/admin.js, keine
|
||||||
|
Backend-Tests noetig/moeglich.)
|
||||||
|
3) Verbindungslog (Live-Tail) -- app/security/log_stream.py ist reine
|
||||||
|
In-Process-Logik (Ring-Buffer + Pub/Sub) ohne DB-/HTTP-Abhaengigkeit und
|
||||||
|
wird NICHT hier, sondern eigenstaendig verifiziert (siehe
|
||||||
|
verify_migrations.py/verify_hostkey_fix.py-Analoga aus der
|
||||||
|
Sandbox-Verifikation dieser Session); ein WebSocket-Test wuerde einen
|
||||||
|
echten ASGI-WS-Client benoetigen, den dieses Testsetup (httpx
|
||||||
|
ASGITransport, kein WS-Support) nicht bietet.
|
||||||
|
4) Superadmin-'Sessionview': GET /admin/sessions, POST
|
||||||
|
/admin/sessions/{id}/terminate, GET /admin/sessions/{id}/recording --
|
||||||
|
alle require_global_admin (auch Mandanten-Admins muessen 403 bekommen).
|
||||||
|
6) "Credentials ins RBAC-Modell": neue Rollen credentials_view/
|
||||||
|
credentials_manage (Migration 0008) erlauben NICHT-Admins gezielten
|
||||||
|
Zugriff auf Zugangsdaten-Endpunkte fuer Hosts ihrer Hostgruppe.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
async def _create_user(conn, username: str, password: str, *, is_admin: bool = False) -> int:
|
||||||
|
from app.security.passwords import hash_password
|
||||||
|
|
||||||
|
cursor = await conn.execute(
|
||||||
|
"INSERT INTO users (username, password_hash, is_admin, must_change_password) "
|
||||||
|
"VALUES (?, ?, ?, 0)",
|
||||||
|
(username, hash_password(password), int(is_admin)),
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
async def _login_full(client, username: str, password: str) -> str:
|
||||||
|
import pyotp
|
||||||
|
|
||||||
|
resp = await client.post("/auth/login", json={"username": username, "password": password})
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
pending = resp.json()["pending_token"]
|
||||||
|
resp = await client.post("/auth/totp/enroll/start", json={"pending_token": pending})
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
provisioning_uri = resp.json()["provisioning_uri"]
|
||||||
|
secret = dict(part.split("=") for part in provisioning_uri.split("?", 1)[1].split("&"))["secret"]
|
||||||
|
code = pyotp.TOTP(secret).now()
|
||||||
|
resp = await client.post("/auth/totp/enroll/confirm", json={"pending_token": pending, "code": code})
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
return resp.cookies.get("jh_session")
|
||||||
|
|
||||||
|
|
||||||
|
async def _setup_hostgroup_and_host(conn, *, group_name="p9-group", hostname="p9-host"):
|
||||||
|
cursor = await conn.execute("INSERT INTO host_groups (name) VALUES (?)", (group_name,))
|
||||||
|
hg_id = cursor.lastrowid
|
||||||
|
cursor = await conn.execute(
|
||||||
|
"INSERT INTO hosts (host_group_id, hostname, address, protocol, port, os_type) "
|
||||||
|
"VALUES (?, ?, '10.9.0.1', 'ssh', 22, 'linux')",
|
||||||
|
(hg_id, hostname),
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
return hg_id, cursor.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1) Host-Key ermitteln: kein 500 mehr bei Verbindungsfehlern
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_discover_host_key_connection_failure_returns_502_not_500(client, monkeypatch):
|
||||||
|
from app.db import get_db
|
||||||
|
import app.admin.routes as admin_routes
|
||||||
|
from app.ssh_proxy.proxy import HostKeyDiscoveryError
|
||||||
|
|
||||||
|
conn = get_db()
|
||||||
|
await _create_user(conn, "hk_admin", "Correct-Horse-Battery-Staple-K1", is_admin=True)
|
||||||
|
hg_id, host_id = await _setup_hostgroup_and_host(conn, group_name="hk-group", hostname="hk-host")
|
||||||
|
await _login_full(client, "hk_admin", "Correct-Horse-Battery-Staple-K1")
|
||||||
|
|
||||||
|
async def _boom(conn, host_id, *, admin_user_id):
|
||||||
|
raise HostKeyDiscoveryError(host_id, "Connection refused")
|
||||||
|
|
||||||
|
monkeypatch.setattr(admin_routes, "discover_and_store_host_key", _boom)
|
||||||
|
|
||||||
|
resp = await client.post(f"/admin/hosts/{host_id}/discover-host-key", json={})
|
||||||
|
# Vorher: unbehandelte Exception -> 500. Jetzt: sauber gemappt auf 502.
|
||||||
|
assert resp.status_code == 502, resp.text
|
||||||
|
assert "Connection refused" in resp.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_discover_host_key_success_after_auth_failure_past_kex(client, monkeypatch):
|
||||||
|
"""Simuliert den eigentlichen Bug-Fall: Key-Exchange erfolgreich (Fingerprint
|
||||||
|
erfasst), Authentifizierung schlaegt DANACH fehl -- muss trotzdem als
|
||||||
|
Erfolg gemeldet werden (siehe verify_hostkey_fix.py fuer die isolierte
|
||||||
|
Kontrollfluss-Verifikation der proxy.py-Logik selbst)."""
|
||||||
|
from app.db import get_db
|
||||||
|
import app.admin.routes as admin_routes
|
||||||
|
|
||||||
|
conn = get_db()
|
||||||
|
await _create_user(conn, "hk_admin2", "Correct-Horse-Battery-Staple-K2", is_admin=True)
|
||||||
|
hg_id, host_id = await _setup_hostgroup_and_host(conn, group_name="hk-group2", hostname="hk-host2")
|
||||||
|
await _login_full(client, "hk_admin2", "Correct-Horse-Battery-Staple-K2")
|
||||||
|
|
||||||
|
async def _fake_discover(conn, host_id, *, admin_user_id):
|
||||||
|
return "SHA256:fake-fingerprint-after-auth-failure"
|
||||||
|
|
||||||
|
monkeypatch.setattr(admin_routes, "discover_and_store_host_key", _fake_discover)
|
||||||
|
|
||||||
|
resp = await client.post(f"/admin/hosts/{host_id}/discover-host-key", json={})
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json()["fingerprint"] == "SHA256:fake-fingerprint-after-auth-failure"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 4) Sessionview (nur Super-Admin)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sessions_list_requires_global_admin_not_tenant_admin(client):
|
||||||
|
from app.db import get_db
|
||||||
|
|
||||||
|
conn = get_db()
|
||||||
|
superadmin_id = await _create_user(conn, "sv_super", "Correct-Horse-Battery-Staple-S1", is_admin=True)
|
||||||
|
tenant_admin_id = await _create_user(conn, "sv_tenant", "Correct-Horse-Battery-Staple-S2")
|
||||||
|
await conn.execute(
|
||||||
|
"INSERT INTO tenant_admins (user_id, tenant_id) VALUES (?, 1)", (tenant_admin_id,)
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
|
await _login_full(client, "sv_tenant", "Correct-Horse-Battery-Staple-S2")
|
||||||
|
resp = await client.get("/admin/sessions")
|
||||||
|
assert resp.status_code == 403, resp.text
|
||||||
|
|
||||||
|
client.cookies.clear()
|
||||||
|
await _login_full(client, "sv_super", "Correct-Horse-Battery-Staple-S1")
|
||||||
|
resp = await client.get("/admin/sessions")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json() == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sessions_list_and_terminate_and_recording(client):
|
||||||
|
from app.db import get_db
|
||||||
|
|
||||||
|
conn = get_db()
|
||||||
|
admin_id = await _create_user(conn, "sv_super2", "Correct-Horse-Battery-Staple-S3", is_admin=True)
|
||||||
|
user_id = await _create_user(conn, "sv_user", "Correct-Horse-Battery-Staple-S4")
|
||||||
|
hg_id, host_id = await _setup_hostgroup_and_host(conn, group_name="sv-group", hostname="sv-host")
|
||||||
|
|
||||||
|
cursor = await conn.execute(
|
||||||
|
"INSERT INTO sessions (user_id, host_id, protocol, client_ip) VALUES (?, ?, 'ssh', '9.9.9.9')",
|
||||||
|
(user_id, host_id),
|
||||||
|
)
|
||||||
|
session_id = cursor.lastrowid
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
|
await _login_full(client, "sv_super2", "Correct-Horse-Battery-Staple-S3")
|
||||||
|
|
||||||
|
resp = await client.get("/admin/sessions?active_only=true")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
rows = resp.json()
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0]["id"] == session_id
|
||||||
|
assert rows[0]["username"] == "sv_user"
|
||||||
|
assert rows[0]["hostname"] == "sv-host"
|
||||||
|
assert rows[0]["is_active"] is True
|
||||||
|
# Diese Sitzung wurde nur direkt in der DB angelegt (kein echter
|
||||||
|
# laufender WS-Task) -> nicht in app/security/active_sessions.py
|
||||||
|
# registriert -> darf NICHT als 'killable' gemeldet werden.
|
||||||
|
assert rows[0]["killable"] is False
|
||||||
|
assert rows[0]["has_recording"] is False
|
||||||
|
|
||||||
|
# 'Beenden' muss sauber 409 liefern statt eine KeyError/AttributeError zu
|
||||||
|
# werfen, wenn die Sitzung nicht (mehr) auf diesem Prozess laeuft.
|
||||||
|
resp = await client.post(f"/admin/sessions/{session_id}/terminate", json={})
|
||||||
|
assert resp.status_code == 409, resp.text
|
||||||
|
|
||||||
|
resp = await client.get(f"/admin/sessions/{session_id}/recording")
|
||||||
|
assert resp.status_code == 404, resp.text
|
||||||
|
|
||||||
|
resp = await client.post("/admin/sessions/999999/terminate", json={})
|
||||||
|
assert resp.status_code == 404, resp.text
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 6) Credentials ins RBAC-Modell
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_credentials_manage_role_grants_non_admin_write_access(client):
|
||||||
|
from app.db import get_db
|
||||||
|
|
||||||
|
conn = get_db()
|
||||||
|
admin_id = await _create_user(conn, "cr_admin", "Correct-Horse-Battery-Staple-C1", is_admin=True)
|
||||||
|
holder_id = await _create_user(conn, "cr_holder", "Correct-Horse-Battery-Staple-C2")
|
||||||
|
other_id = await _create_user(conn, "cr_other", "Correct-Horse-Battery-Staple-C3")
|
||||||
|
hg_id, host_id = await _setup_hostgroup_and_host(conn, group_name="cr-group", hostname="cr-host")
|
||||||
|
|
||||||
|
await _login_full(client, "cr_admin", "Correct-Horse-Battery-Staple-C1")
|
||||||
|
resp = await client.post(
|
||||||
|
"/admin/roles/grant",
|
||||||
|
json={"user_id": holder_id, "host_group_id": hg_id, "role_names": ["credentials_manage"]},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
# Rolleninhaber (kein Admin!) darf Zugangsdaten lesen UND setzen.
|
||||||
|
client.cookies.clear()
|
||||||
|
await _login_full(client, "cr_holder", "Correct-Horse-Battery-Staple-C2")
|
||||||
|
|
||||||
|
resp = await client.get(f"/admin/hosts/{host_id}/credentials")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json()["rdp_credentials_set"] is False
|
||||||
|
|
||||||
|
resp = await client.put(f"/admin/hosts/{host_id}/rdp-credentials", json={"password": "s3hr-geheim!!"})
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
resp = await client.get(f"/admin/hosts/{host_id}/credentials")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json()["rdp_credentials_set"] is True
|
||||||
|
|
||||||
|
# Ein User OHNE diese Rolle bleibt weiterhin ausgesperrt.
|
||||||
|
client.cookies.clear()
|
||||||
|
await _login_full(client, "cr_other", "Correct-Horse-Battery-Staple-C3")
|
||||||
|
resp = await client.get(f"/admin/hosts/{host_id}/credentials")
|
||||||
|
assert resp.status_code == 403, resp.text
|
||||||
|
resp = await client.put(f"/admin/hosts/{host_id}/rdp-credentials", json={"password": "andere-1234"})
|
||||||
|
assert resp.status_code == 403, resp.text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_credentials_view_role_is_read_only(client):
|
||||||
|
from app.db import get_db
|
||||||
|
|
||||||
|
conn = get_db()
|
||||||
|
await _create_user(conn, "cv_admin", "Correct-Horse-Battery-Staple-C4", is_admin=True)
|
||||||
|
viewer_id = await _create_user(conn, "cv_viewer", "Correct-Horse-Battery-Staple-C5")
|
||||||
|
hg_id, host_id = await _setup_hostgroup_and_host(conn, group_name="cv-group", hostname="cv-host")
|
||||||
|
|
||||||
|
await _login_full(client, "cv_admin", "Correct-Horse-Battery-Staple-C4")
|
||||||
|
resp = await client.post(
|
||||||
|
"/admin/roles/grant",
|
||||||
|
json={"user_id": viewer_id, "host_group_id": hg_id, "role_names": ["credentials_view"]},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
client.cookies.clear()
|
||||||
|
await _login_full(client, "cv_viewer", "Correct-Horse-Battery-Staple-C5")
|
||||||
|
|
||||||
|
resp = await client.get(f"/admin/hosts/{host_id}/credentials")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
# 'credentials_view' allein darf NICHT schreiben.
|
||||||
|
resp = await client.put(f"/admin/hosts/{host_id}/rdp-credentials", json={"password": "nope-12345"})
|
||||||
|
assert resp.status_code == 403, resp.text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_catalog_hosts_reports_can_view_credentials_flag(client):
|
||||||
|
from app.db import get_db
|
||||||
|
|
||||||
|
conn = get_db()
|
||||||
|
await _create_user(conn, "cc_admin", "Correct-Horse-Battery-Staple-C6", is_admin=True)
|
||||||
|
user_id = await _create_user(conn, "cc_user", "Correct-Horse-Battery-Staple-C7")
|
||||||
|
hg_id, host_id = await _setup_hostgroup_and_host(conn, group_name="cc-group", hostname="cc-host")
|
||||||
|
|
||||||
|
await _login_full(client, "cc_admin", "Correct-Horse-Battery-Staple-C6")
|
||||||
|
await client.post(
|
||||||
|
"/admin/roles/grant",
|
||||||
|
json={"user_id": user_id, "host_group_id": hg_id, "role_names": ["ssh_connect", "credentials_view"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
client.cookies.clear()
|
||||||
|
await _login_full(client, "cc_user", "Correct-Horse-Battery-Staple-C7")
|
||||||
|
resp = await client.get("/catalog/hosts")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
hosts = resp.json()
|
||||||
|
assert len(hosts) == 1
|
||||||
|
assert hosts[0]["can_view_credentials"] is True
|
||||||
Reference in New Issue
Block a user