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
|
||||
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
|
||||
|
||||
```bash
|
||||
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.
|
||||
Ablaufdaten, den vollstaendigen Login-Flow (Passwort -> TOTP-Enrollment ->
|
||||
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),
|
||||
Hosts (Soft- vs. Hard-Delete), SSH-Keys (inkl. Rotation) und Benutzergruppen,
|
||||
sowie den neuen `GET /admin/hosts/{id}`-Detailendpunkt inkl. SSH-Key-
|
||||
Zuordnungen und RDP-Zugangsdaten-Status.
|
||||
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
|
||||
> hinzugekommenen Tests (5 CSP + 14 Admin/Gruppen/Token + 15 Mandanten/CRUD)
|
||||
> konnten in der verwendeten Cloud-Sandbox nicht mit `pytest -q` ausgefuehrt
|
||||
> werden, da diese Sandbox keinen Netzwerkzugriff auf PyPI hat und
|
||||
> `fastapi`/`aiosqlite`/`argon2`/`asyncssh` dort nicht vorinstalliert sind.
|
||||
> **Hinweis:** Alle 41 in dieser und den beiden vorherigen Sessions neu
|
||||
> hinzugekommenen Tests (5 CSP + 14 Admin/Gruppen/Token + 15 Mandanten/CRUD +
|
||||
> 7 Phase 9) konnten in der verwendeten Cloud-Sandbox nicht mit `pytest -q`
|
||||
> ausgefuehrt werden, da diese Sandbox keinen Netzwerkzugriff auf PyPI hat und
|
||||
> `fastapi`/`aiosqlite`/`argon2`/`asyncssh` dort nicht vorinstalliert sind
|
||||
> (auch nicht ueber die Geraete-Bruecke zum lokalen Rechner erreichbar).
|
||||
> Fuer Phase 8 wurde stattdessen ein tieferes Verifikationsverfahren
|
||||
> angewendet als in der vorherigen Session: minimale Stub-Module fuer die
|
||||
> vier fehlenden Pakete (`fastapi`s `APIRouter`-Dekoratoren als No-Ops, ein
|
||||
@ -236,12 +312,22 @@ Zuordnungen und RDP-Zugangsdaten-Status.
|
||||
> selbst nutzt weiterhin `httpx.AsyncClient` gegen die echte ASGI-App (wie
|
||||
> alle anderen Testdateien) und wurde daher zeilenweise gegen die tatsaechliche
|
||||
> Endpunkt-Implementierung gegengeprueft (Pfade, Payload-Felder, Statuscodes),
|
||||
> aber nicht selbst mit `pytest` ausgefuehrt. Alle geaenderten Python-/
|
||||
> JS-Dateien wurden mit `py_compile`/`node --check` auf Syntaxfehler geprueft,
|
||||
> und jeder `document.getElementById`-Aufruf in `admin.js`/`terminal.js` wurde
|
||||
> automatisiert gegen die tatsaechlichen HTML-IDs in `admin.html`/`terminal.html`
|
||||
> abgeglichen (0 Abweichungen). Bitte `pytest -q` lokal ausfuehren und
|
||||
> Ergebnis melden.
|
||||
> aber nicht selbst mit `pytest` ausgefuehrt. Fuer Phase 9 zusaetzlich: die
|
||||
> reparierte Kontrollfluss-Logik von `discover_and_store_host_key()` wurde
|
||||
> mit einem eigenstaendigen Fake-`asyncssh`-Nachbau isoliert durchgespielt
|
||||
> (alle drei Faelle: Verbindungsfehler vor Key-Exchange, Auth-Fehler NACH
|
||||
> Key-Exchange = der eigentliche Bug, voller Erfolg), Migration
|
||||
> `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):
|
||||
Server-Start, Static-/Template-Auslieferung, Security-Header, vollstaendiger
|
||||
|
||||
@ -21,13 +21,17 @@ Privilege-Escalation-Schutz).
|
||||
from __future__ import annotations
|
||||
|
||||
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 (
|
||||
CurrentUser,
|
||||
effective_tenant_ids,
|
||||
get_current_user_ws,
|
||||
require_admin_or_scope,
|
||||
require_admin_scope_or_host_role,
|
||||
require_admin_session,
|
||||
require_global_admin,
|
||||
)
|
||||
@ -54,6 +58,7 @@ from app.models.schemas import (
|
||||
UserGroupUpdateRequest,
|
||||
UserUpdateRequest,
|
||||
)
|
||||
from app.security import active_sessions, log_stream
|
||||
from app.security.api_tokens import (
|
||||
VALID_SCOPES,
|
||||
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.crypto import encrypt_secret
|
||||
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
|
||||
|
||||
logger = logging.getLogger("jumphost.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
|
||||
erfassen (bewusste Trust-Entscheidung, siehe Konzept 4.2). Danach gilt fuer
|
||||
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()
|
||||
await _assert_host_in_scope(conn, _scope(admin), host_id)
|
||||
fingerprint = await discover_and_store_host_key(conn, host_id, admin_user_id=admin.id)
|
||||
try:
|
||||
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(
|
||||
conn, event_type="host_key_discovered_trust_decision", user_id=admin.id,
|
||||
client_ip=_client_ip(request), details={"host_id": host_id, "fingerprint": fingerprint},
|
||||
@ -915,12 +936,18 @@ async def discover_host_key(
|
||||
@router.put("/hosts/{host_id}/rdp-credentials")
|
||||
async def set_rdp_credentials(
|
||||
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
|
||||
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()
|
||||
await _assert_host_in_scope(conn, _scope(admin), host_id)
|
||||
if admin.is_any_admin:
|
||||
await _assert_host_in_scope(conn, _scope(admin), host_id)
|
||||
encrypted = encrypt_secret(payload.password.encode(), associated_data=b"rdp_password")
|
||||
await conn.execute(
|
||||
"INSERT INTO rdp_credentials (host_id, password_enc, updated_at) "
|
||||
@ -940,10 +967,13 @@ async def set_rdp_credentials(
|
||||
@router.delete("/hosts/{host_id}/rdp-credentials")
|
||||
async def delete_rdp_credentials(
|
||||
host_id: int, request: Request,
|
||||
admin: CurrentUser = Depends(require_admin_or_scope("hosts", "write")),
|
||||
admin: CurrentUser = Depends(
|
||||
require_admin_scope_or_host_role("hosts", "write", ("credentials_manage",))
|
||||
),
|
||||
):
|
||||
conn = get_db()
|
||||
await _assert_host_in_scope(conn, _scope(admin), host_id)
|
||||
if admin.is_any_admin:
|
||||
await _assert_host_in_scope(conn, _scope(admin), host_id)
|
||||
await conn.execute("DELETE FROM rdp_credentials WHERE host_id = ?", (host_id,))
|
||||
await write_audit_event(
|
||||
conn, event_type="rdp_credentials_deleted", user_id=admin.id, client_ip=_client_ip(request),
|
||||
@ -953,6 +983,42 @@ async def delete_rdp_credentials(
|
||||
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")
|
||||
async def list_rdp_credentials(admin: CurrentUser = Depends(require_admin_or_scope("hosts", "read"))):
|
||||
"""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}")
|
||||
async def map_ssh_key_to_host(
|
||||
host_id: int, key_id: int, request: Request,
|
||||
admin: CurrentUser = Depends(require_admin_or_scope("ssh_keys", "write")),
|
||||
admin: CurrentUser = Depends(
|
||||
require_admin_scope_or_host_role("ssh_keys", "write", ("credentials_manage",))
|
||||
),
|
||||
):
|
||||
conn = get_db()
|
||||
scope = _scope(admin)
|
||||
await _assert_host_in_scope(conn, scope, host_id)
|
||||
await _assert_ssh_key_in_scope(conn, scope, key_id)
|
||||
if admin.is_any_admin:
|
||||
scope = _scope(admin)
|
||||
await _assert_host_in_scope(conn, scope, host_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(
|
||||
"INSERT OR IGNORE INTO host_ssh_key_map (host_id, ssh_key_id) VALUES (?, ?)",
|
||||
(host_id, key_id),
|
||||
@ -1302,11 +1375,13 @@ async def map_ssh_key_to_host(
|
||||
@router.delete("/hosts/{host_id}/ssh-keys/{key_id}")
|
||||
async def unmap_ssh_key_from_host(
|
||||
host_id: int, key_id: int, request: Request,
|
||||
admin: CurrentUser = Depends(require_admin_or_scope("ssh_keys", "write")),
|
||||
admin: CurrentUser = Depends(
|
||||
require_admin_scope_or_host_role("ssh_keys", "write", ("credentials_manage",))
|
||||
),
|
||||
):
|
||||
conn = get_db()
|
||||
scope = _scope(admin)
|
||||
await _assert_host_in_scope(conn, scope, host_id)
|
||||
if admin.is_any_admin:
|
||||
await _assert_host_in_scope(conn, _scope(admin), host_id)
|
||||
await conn.execute(
|
||||
"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()
|
||||
intact, broken_at = await verify_chain(conn)
|
||||
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)
|
||||
|
||||
|
||||
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):
|
||||
"""Dependency-Factory fuer die Admin-API: erlaubt Zugriff entweder ueber
|
||||
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:
|
||||
h["can_connect"] = True
|
||||
h["can_file_transfer"] = True
|
||||
h["can_view_credentials"] = True
|
||||
return hosts
|
||||
|
||||
cursor = await conn.execute(
|
||||
@ -82,7 +83,26 @@ async def my_hosts(user: CurrentUser = Depends(get_current_user)):
|
||||
(user.id, user.id),
|
||||
)
|
||||
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:
|
||||
h["can_connect"] = True
|
||||
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
|
||||
|
||||
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.db import close_db, init_db
|
||||
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.terminal_ws import router as ssh_ws_router
|
||||
|
||||
@ -27,6 +28,10 @@ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name
|
||||
|
||||
@asynccontextmanager
|
||||
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()
|
||||
yield
|
||||
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[
|
||||
"ssh_connect", "rdp_connect", "file_transfer", "clipboard",
|
||||
"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):
|
||||
user_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
|
||||
|
||||
|
||||
@ -174,7 +181,7 @@ class GroupMemberRequest(BaseModel):
|
||||
class GroupRoleGrantRequest(BaseModel):
|
||||
user_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
|
||||
|
||||
|
||||
|
||||
@ -19,6 +19,7 @@ from app.config import settings
|
||||
from app.db import get_db
|
||||
from app.rbac import user_has_role_for_host
|
||||
from app.recordings.recorder import SessionRecorder
|
||||
from app.security import active_sessions
|
||||
from app.security.audit import write_audit_event
|
||||
from app.security.crypto import decrypt_secret
|
||||
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},
|
||||
)
|
||||
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"
|
||||
tunnel = None
|
||||
@ -125,6 +131,7 @@ async def rdp_tunnel(
|
||||
guacd_host=settings.guacd_host, guacd_port=settings.guacd_port,
|
||||
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))
|
||||
tasks = [
|
||||
asyncio.create_task(_guacd_to_ws(tunnel, websocket, recorder)),
|
||||
@ -144,13 +151,20 @@ async def rdp_tunnel(
|
||||
except (GuacamoleProtocolError, ConnectionError, OSError) as exc:
|
||||
logger.warning("RDP-Sessionfehler (session_id=%s): %s", session_id, exc)
|
||||
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:
|
||||
active_sessions.unregister(session_id)
|
||||
del password # Klartext-Passwort so schnell wie moeglich freigeben
|
||||
for task in tasks:
|
||||
task.cancel()
|
||||
if tunnel:
|
||||
await tunnel.close()
|
||||
recorder.close()
|
||||
logger.debug("RDP-Sitzung %s beendet: reason=%s", session_id, end_reason)
|
||||
await conn.execute(
|
||||
"UPDATE sessions SET ended_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), end_reason = ? "
|
||||
"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}")
|
||||
|
||||
|
||||
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):
|
||||
"""Erzwingt Strict Host Key Checking gegen einen fest hinterlegten
|
||||
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
|
||||
und in der DB zu hinterlegen. Nur ueber einen dedizierten, admin-only
|
||||
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)
|
||||
if host["protocol"] != "ssh":
|
||||
raise HostNotConfiguredError("Host-Key-Ermittlung ist nur fuer SSH-Ziele moeglich")
|
||||
|
||||
client = _PinnedHostKeyClient(None, discovery_mode=True)
|
||||
connection = await asyncssh.connect(
|
||||
host["address"], port=host["port"], username=host["ssh_username"],
|
||||
known_hosts=None, client_factory=lambda: client, connect_timeout=10,
|
||||
)
|
||||
connection.close()
|
||||
connection = None
|
||||
try:
|
||||
connection = await asyncssh.connect(
|
||||
host["address"], port=host["port"], username=host["ssh_username"],
|
||||
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()
|
||||
|
||||
fingerprint = client.observed_fingerprint
|
||||
await conn.execute(
|
||||
"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.rbac import user_has_role_for_host
|
||||
from app.recordings.recorder import SessionRecorder
|
||||
from app.security import active_sessions
|
||||
from app.security.audit import write_audit_event
|
||||
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},
|
||||
)
|
||||
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"
|
||||
ssh_conn = None
|
||||
@ -90,6 +96,7 @@ async def ssh_terminal(websocket: WebSocket, host_id: int):
|
||||
pump_task = None
|
||||
try:
|
||||
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")
|
||||
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)
|
||||
except HostNotConfiguredError:
|
||||
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:
|
||||
active_sessions.unregister(session_id)
|
||||
if pump_task:
|
||||
pump_task.cancel()
|
||||
if process:
|
||||
@ -128,6 +141,7 @@ async def ssh_terminal(websocket: WebSocket, host_id: int):
|
||||
if ssh_conn:
|
||||
ssh_conn.close()
|
||||
recorder.close()
|
||||
logger.debug("SSH-Sitzung %s beendet: reason=%s", session_id, end_reason)
|
||||
await conn.execute(
|
||||
"UPDATE sessions SET ended_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), end_reason = ? "
|
||||
"WHERE id = ?",
|
||||
|
||||
@ -166,6 +166,11 @@ pre.json-view {
|
||||
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;
|
||||
}
|
||||
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 {
|
||||
display: flex; align-items: center; gap: 0.6rem; padding: 0.5rem 0; cursor: pointer;
|
||||
border-bottom: 1px solid var(--border);
|
||||
|
||||
@ -3,14 +3,8 @@
|
||||
|
||||
const ROLE_NAMES = [
|
||||
"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");
|
||||
let meInfo = { is_admin: false, tenant_admin_of: [] };
|
||||
@ -112,15 +106,18 @@
|
||||
const tabLoaders = {
|
||||
users: loadUsersTab,
|
||||
groups: loadGroupsTab,
|
||||
hosts: loadHostsTab,
|
||||
hostgroups: loadHostsTab,
|
||||
servers: loadHostsTab,
|
||||
credentials: loadCredentialsTab,
|
||||
roles: loadRolesTab,
|
||||
tokens: loadTokensTab,
|
||||
tenants: loadTenantsTab,
|
||||
authlog: loadAuthLogTab,
|
||||
sessions: loadSessionsTab,
|
||||
connlog: loadConnLogTab,
|
||||
audit: loadAuditTab,
|
||||
};
|
||||
const loadedTabs = new Set();
|
||||
let currentTab = null;
|
||||
|
||||
document.getElementById("tabs").addEventListener("click", (ev) => {
|
||||
const btn = ev.target.closest(".tab-btn");
|
||||
@ -130,9 +127,15 @@
|
||||
document.querySelectorAll(".tab-panel").forEach((panel) => {
|
||||
panel.classList.toggle("hidden", panel.id !== `tab-${tab}`);
|
||||
});
|
||||
if (currentTab === "connlog" && tab !== "connlog") disconnectLogStream();
|
||||
currentTab = tab;
|
||||
if (!loadedTabs.has(tab)) {
|
||||
loadedTabs.add(tab);
|
||||
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", {}, [
|
||||
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);
|
||||
}),
|
||||
...(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() {
|
||||
await refreshAuthLog();
|
||||
let sessionsActiveOnly = true;
|
||||
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() {
|
||||
const entries = await getJson("/admin/audit-log?limit=500");
|
||||
const authEntries = entries.filter((e) => AUTH_EVENT_TYPES.has(e.event_type));
|
||||
const tbody = document.querySelector("#authlog-table tbody");
|
||||
document.getElementById("sessions-active-only").addEventListener("change", (ev) => {
|
||||
sessionsActiveOnly = ev.target.checked;
|
||||
refreshSessions().catch((err) => showBanner(err.message, "error"));
|
||||
});
|
||||
|
||||
async function refreshSessions() {
|
||||
const rows = await getJson(`/admin/sessions?active_only=${sessionsActiveOnly}&limit=300`);
|
||||
const tbody = document.querySelector("#sessions-table tbody");
|
||||
fillTable(
|
||||
tbody,
|
||||
authEntries.map((e) =>
|
||||
el("tr", {}, [
|
||||
el("td", { textContent: e.ts }),
|
||||
el("td", { textContent: e.user_id === null ? "-" : String(e.user_id) }),
|
||||
el("td", { textContent: e.client_ip || "-" }),
|
||||
el("td", { textContent: e.event_type }),
|
||||
rows.map((s) => {
|
||||
const actions = el("td", {});
|
||||
if (s.is_active && s.killable) {
|
||||
actions.appendChild(
|
||||
actionButton("Beenden", "btn-danger", async () => {
|
||||
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("span", {
|
||||
className: `badge ${FAILURE_EVENT_TYPES.has(e.event_type) ? "danger" : "ok"}`,
|
||||
textContent: FAILURE_EVENT_TYPES.has(e.event_type) ? "fehlgeschlagen" : "erfolgreich",
|
||||
className: `badge ${s.is_active ? "ok" : ""}`,
|
||||
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
|
||||
// ---------------------------------------------------------------------
|
||||
@ -1238,6 +1356,8 @@
|
||||
: `Mandanten-Admin: ${me.tenant_admin_of.map((t) => t.name).join(", ")}`;
|
||||
document.getElementById("whoami").textContent = `${me.username} (${roleLabel})`;
|
||||
document.getElementById("tenants-tab-btn").classList.toggle("hidden", !me.is_admin);
|
||||
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");
|
||||
await loadUsersTab();
|
||||
}
|
||||
|
||||
@ -10,6 +10,30 @@
|
||||
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) {
|
||||
const div = document.createElement("div");
|
||||
div.className = "host-card";
|
||||
@ -32,6 +56,17 @@
|
||||
connect.textContent = "Verbinden";
|
||||
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);
|
||||
return div;
|
||||
}
|
||||
|
||||
@ -23,12 +23,14 @@
|
||||
<div class="tabs" id="tabs">
|
||||
<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="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="roles">Rollen</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" 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>
|
||||
</div>
|
||||
|
||||
@ -154,8 +156,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================= Hosts & Verbindungen ================= -->
|
||||
<div class="tab-panel hidden" id="tab-hosts">
|
||||
<!-- ================= Hostgruppen ================= -->
|
||||
<div class="tab-panel hidden" id="tab-hostgroups">
|
||||
<div class="panel">
|
||||
<h2>Neue Hostgruppe anlegen</h2>
|
||||
<form id="hostgroup-create-form" class="form-grid">
|
||||
@ -196,7 +198,10 @@
|
||||
<button type="submit">Speichern</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================= Server ================= -->
|
||||
<div class="tab-panel hidden" id="tab-servers">
|
||||
<div class="panel">
|
||||
<h2>Neue Verbindung (Host) anlegen</h2>
|
||||
<form id="host-create-form" class="form-grid">
|
||||
@ -593,20 +598,42 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================= Login-Verlauf ================= -->
|
||||
<div class="tab-panel hidden" id="tab-authlog">
|
||||
<!-- ================= Sessions (nur Super-Admin) ================= -->
|
||||
<div class="tab-panel hidden" id="tab-sessions">
|
||||
<div class="panel">
|
||||
<h2>Login-Verlauf</h2>
|
||||
<p class="hint">Authentifizierungs-Ereignisse (Login/Logout/TOTP/Passwortwechsel), gefiltert aus dem Audit-Log dieses/dieser Mandanten.</p>
|
||||
<h2>Sitzungen</h2>
|
||||
<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">
|
||||
<table class="data-table" id="authlog-table">
|
||||
<thead><tr><th>Zeit</th><th>Benutzer</th><th>IP</th><th>Ereignis</th><th>Status</th></tr></thead>
|
||||
<table class="data-table" id="sessions-table">
|
||||
<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>
|
||||
</table>
|
||||
</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 ================= -->
|
||||
<div class="tab-panel hidden" id="tab-audit">
|
||||
<div class="panel">
|
||||
|
||||
@ -15,7 +15,10 @@
|
||||
<button id="logout-btn">Abmelden</button>
|
||||
</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>
|
||||
</body>
|
||||
|
||||
@ -84,7 +84,7 @@ async def test_group_role_grant_gives_catalog_access_without_individual_grant(cl
|
||||
|
||||
resp = await client.post(
|
||||
"/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
|
||||
|
||||
@ -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(
|
||||
"/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.
|
||||
@ -142,7 +142,7 @@ async def test_non_admin_cannot_manage_user_groups_or_group_roles(client):
|
||||
assert resp.status_code == 403
|
||||
resp = await client.post(
|
||||
"/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
|
||||
|
||||
|
||||
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