""" 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 async def _grant_group_role(conn, *, user_id: int, host_group_id: int, role_names, group_name: str) -> int: """Teil D Schritt 4/5: Rechte gibt es nur noch ueber Benutzergruppen -- ersetzt das fruehere direkte INSERT INTO user_hostgroup_roles bzw. den seit Schritt 5 mit HTTP 410 antwortenden Endpunkt POST /admin/roles/grant. Legt eine neue Gruppe mit user_id als einzigem Mitglied an und vergibt die angegebenen Rollen auf host_group_id. Gibt die neue user_group_id zurueck (fuer eine anschliessende Achse-B-Freigabe, siehe _grant_group_credential).""" cursor = await conn.execute("INSERT INTO user_groups (name) VALUES (?)", (group_name,)) group_id = cursor.lastrowid await conn.execute( "INSERT INTO user_group_members (user_group_id, user_id) VALUES (?, ?)", (group_id, user_id) ) for role_name in role_names: role_row = await (await conn.execute("SELECT id FROM roles WHERE name = ?", (role_name,))).fetchone() await conn.execute( "INSERT INTO group_hostgroup_roles (user_group_id, host_group_id, role_id) VALUES (?, ?, ?)", (group_id, host_group_id, role_row[0]), ) await conn.commit() return group_id async def _grant_group_credential(conn, *, user_group_id: int, kind: str, credential_id: int) -> None: """Achse B (Teil D Schritt 3+5): ohne diese Freigabe scheitert seit dem S4-Fix in Schritt 5 jede Nicht-Admin-Zuweisung eines Zugangsdatensatzes an einen Host mit 403, selbst wenn die Achse-A-Rolle (credentials_manage) vorhanden ist -- siehe app/admin/routes.py, assign_rdp_credential_to_host()/map_ssh_key_to_host().""" grant_table = { "ssh_key": "group_ssh_key_grants", "rdp_credential": "group_rdp_credential_grants", "ssh_password_credential": "group_ssh_password_credential_grants", }[kind] col = { "ssh_key": "ssh_key_id", "rdp_credential": "rdp_credential_id", "ssh_password_credential": "ssh_password_credential_id", }[kind] await conn.execute( f"INSERT INTO {grant_table} (user_group_id, {col}) VALUES (?, ?)", (user_group_id, credential_id), ) await conn.commit() # --------------------------------------------------------------------------- # 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_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) normal_user_id = await _create_user(conn, "sv_normal", "Correct-Horse-Battery-Staple-S2") await _login_full(client, "sv_normal", "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") holder_group_id = await _grant_group_role( conn, user_id=holder_id, host_group_id=hg_id, role_names=["credentials_manage"], group_name="cr-holder-team", ) # Migration 0012: das Zugangsdaten-OBJEKT selbst legt weiterhin nur ein # Admin an (analog SSH-Keys, require_admin_or_scope("rdp_credentials", # "write")) -- der Rolleninhaber darf es aber einem Host ZUWEISEN. resp = await client.post( "/admin/rdp-credentials", json={"label": "cr-test-cred", "username": "Administrator", "password": "s3hr-geheim!!"}, ) assert resp.status_code == 201, resp.text credential_id = resp.json()["id"] # Teil D Schritt 5 (S4): die Zuweisung an einen Host prueft bei einem # Nicht-Admin zusaetzlich, ob eine seiner Gruppen den Datensatz ueber # Achse B freigegeben bekommen hat -- ohne diese Zeile wuerde der # POST unten mit 403 scheitern, obwohl credentials_manage vorhanden ist. await _grant_group_credential( conn, user_group_id=holder_group_id, kind="rdp_credential", credential_id=credential_id ) # Rolleninhaber (kein Admin!) darf Zugangsdaten lesen UND zuweisen. 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.post(f"/admin/hosts/{host_id}/rdp-credentials/{credential_id}", json={}) 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 assert resp.json()["rdp_credentials_username"] == "Administrator" # 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.post(f"/admin/hosts/{host_id}/rdp-credentials/{credential_id}", json={}) 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") await _grant_group_role( conn, user_id=viewer_id, host_group_id=hg_id, role_names=["credentials_view"], group_name="cv-viewer-team", ) 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 (weder zuweisen noch entfernen). resp = await client.post(f"/admin/hosts/{host_id}/rdp-credentials/1", json={}) assert resp.status_code == 403, resp.text resp = await client.delete(f"/admin/hosts/{host_id}/rdp-credentials") 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 _grant_group_role( conn, user_id=user_id, host_group_id=hg_id, role_names=["ssh_connect", "credentials_view"], group_name="cc-user-team", ) 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