more admin stuff 2

This commit is contained in:
2026-08-20 17:10:19 +02:00
parent e8216b14e9
commit cd5957cbd2
19 changed files with 1120 additions and 78 deletions

112
README.md
View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@ -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 = ?",

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

View 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)

View File

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

View File

@ -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 = ?",

View File

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

View File

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

View File

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

View File

@ -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 &amp; 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">

View File

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

View File

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