diff --git a/app/admin/routes.py b/app/admin/routes.py index cd44cf4..3309043 100644 --- a/app/admin/routes.py +++ b/app/admin/routes.py @@ -44,7 +44,8 @@ from app.models.schemas import ( HostGroupCreateRequest, HostGroupUpdateRequest, HostUpdateRequest, - RdpCredentialsRequest, + RdpCredentialCreateRequest, + RdpCredentialUpdateRequest, RoleGrantRequest, RoleRevokeRequest, SshKeyCreateRequest, @@ -819,8 +820,14 @@ async def get_host_detail( (host_id,), ) ssh_keys = [{"id": k[0], "label": k[1], "username": k[2]} for k in await keys_cursor.fetchall()] + # Migration 0012: das zugewiesene RDP-Zugangsdaten-Objekt (falls + # vorhanden) statt eines 1:1-Datensatzes am Host -- siehe + # host_rdp_credential_map. rdp_row = await (await conn.execute( - "SELECT updated_at, username, domain FROM rdp_credentials WHERE host_id = ?", (host_id,) + "SELECT rc.id, rc.label, rc.username, rc.domain, COALESCE(rc.rotated_at, rc.created_at) " + "FROM host_rdp_credential_map m JOIN rdp_credentials rc ON rc.id = m.rdp_credential_id " + "WHERE m.host_id = ?", + (host_id,), )).fetchone() ssh_pw_row = await (await conn.execute( "SELECT updated_at, username FROM ssh_password_credentials WHERE host_id = ?", (host_id,) @@ -834,11 +841,13 @@ async def get_host_detail( "tenant_id": row[15], "tenant_name": row[16], "rdp_ignore_cert": bool(row[17]), "ssh_keys": ssh_keys, "rdp_credentials_set": rdp_row is not None, - "rdp_credentials_updated_at": rdp_row[0] if rdp_row else None, + "rdp_credentials_id": rdp_row[0] if rdp_row else None, + "rdp_credentials_label": rdp_row[1] if rdp_row else None, # Benutzername/Domaene gehoeren seit Migration 0010 zu den - # Zugangsdaten; sie werden hier nur zur Anzeige mitgeliefert. - "rdp_credentials_username": rdp_row[1] if rdp_row else None, - "rdp_credentials_domain": rdp_row[2] if rdp_row else None, + # Zugangsdaten (jetzt: dem zugewiesenen Objekt); nur zur Anzeige. + "rdp_credentials_username": rdp_row[2] if rdp_row else None, + "rdp_credentials_domain": rdp_row[3] if rdp_row else None, + "rdp_credentials_updated_at": rdp_row[4] if rdp_row else None, # SSH-Passwort als Alternative zum Schluessel (Migration 0011). "ssh_password_credentials_set": ssh_pw_row is not None, "ssh_password_credentials_updated_at": ssh_pw_row[0] if ssh_pw_row else None, @@ -908,7 +917,20 @@ async def delete_host( )).fetchone() if has_sessions is None: await conn.execute("DELETE FROM host_ssh_key_map WHERE host_id = ?", (host_id,)) - await conn.execute("DELETE FROM rdp_credentials WHERE host_id = ?", (host_id,)) + # Migration 0012: rdp_credentials hat seit der Umstellung auf + # wiederverwendbare Zugangsdaten-Objekte KEINE host_id-Spalte + # mehr -- nur noch die Zuordnungstabelle referenziert den Host + # (mit ON DELETE CASCADE, dieser Aufruf ist also strenggenommen + # redundant, aber explizit wie die anderen Zeilen hier gehalten). + await conn.execute("DELETE FROM host_rdp_credential_map WHERE host_id = ?", (host_id,)) + # Nebenbefund beim Anpassen dieser Stelle: ssh_password_credentials + # (Migration 0011) fehlte hier komplett -- ohne ON DELETE CASCADE + # haette ein harter Loeschversuch mit gesetztem SSH-Passwort bei + # aktivem foreign_keys=ON (app/db.py) mit einem FK-Fehler gescheitert + # (dann automatisch auf Soft-Delete zurueckgefallen, siehe unten -- + # also kein sichtbarer 500er, aber ein hartes Loeschen war fuer + # solche Hosts faktisch nie moeglich). + await conn.execute("DELETE FROM ssh_password_credentials WHERE host_id = ?", (host_id,)) await conn.execute("DELETE FROM hosts WHERE id = ?", (host_id,)) hard_deleted = True if not hard_deleted: @@ -934,7 +956,17 @@ async def discover_host_key( 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.""" + auf 502 (Verbindung fehlgeschlagen) bzw. 400 (kein SSH-Host) gemappt. + + Bugfix 2: das (asyncssh.Error, OSError)-except in proxy.py deckte nicht + jede Art von Fehlschlag beim Key-Exchange ab (z.B. asyncio.TimeoutError + vor Python 3.11 -- kein OSError), sodass der 500er trotz obigem Fix + weiterhin auftrat. proxy.py faengt den externen Aufruf jetzt breiter ab; + zusaetzlich hier ein Catch-all als zweite Verteidigungslinie, damit ein + verbleibender unerwarteter Fehler (z.B. in load_host oder beim + Audit-Log-Schreiben) wenigstens mit vollem Traceback geloggt wird statt + als nackte 500 ohne jede Spur zu verschwinden -- analog zum + Exception-Catch-all in terminal_ws.py/ws_tunnel.py (Phase 9).""" conn = get_db() await _assert_host_in_scope(conn, _scope(admin), host_id) try: @@ -946,6 +978,13 @@ async def discover_host_key( status.HTTP_502_BAD_GATEWAY, f"Host-Key konnte nicht ermittelt werden -- Ziel nicht erreichbar: {exc.reason}", ) from exc + except Exception as exc: + logger.exception("Unerwarteter Fehler bei Host-Key-Ermittlung fuer Host %s", host_id) + raise HTTPException( + status.HTTP_502_BAD_GATEWAY, + f"Host-Key konnte nicht ermittelt werden -- unerwarteter Fehler: " + f"{type(exc).__name__}: {exc}", + ) 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}, @@ -954,61 +993,61 @@ async def discover_host_key( return {"host_id": host_id, "fingerprint": fingerprint} -@router.put("/hosts/{host_id}/rdp-credentials") -async def set_rdp_credentials( - host_id: int, payload: RdpCredentialsRequest, request: Request, +@router.post("/hosts/{host_id}/rdp-credentials/{credential_id}") +async def assign_rdp_credential_to_host( + host_id: int, credential_id: int, request: Request, admin: CurrentUser = Depends( - require_admin_scope_or_host_role("hosts", "write", ("credentials_manage",)) + require_admin_scope_or_host_role("rdp_credentials", "write", ("credentials_manage",)) ), ): - """Speichert/rotiert das RDP-Passwort fuer einen Host, verschluesselt mit - 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').""" + """Weist einem Host EIN bereits bestehendes RDP-Zugangsdaten-Objekt zu + (Migration 0012) -- ersetzt eine zuvor zugewiesene Zuordnung, falls + vorhanden (INSERT OR REPLACE, host_id ist Primaerschluessel der + Zuordnungstabelle). Das Anlegen des Zugangsdaten-Objekts selbst passiert + NICHT mehr hier, sondern ueber POST /admin/rdp-credentials (Reiter + "Zugangsdaten") -- exakt dieselbe Trennung wie bei SSH-Keys + (map_ssh_key_to_host).""" conn = get_db() if admin.is_any_admin: - await _assert_host_in_scope(conn, _scope(admin), host_id) - username = (payload.username or "").strip() or None - if username is None: - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - "Bitte den Windows-Benutzernamen mit angeben -- er gehoert zu den " - "Zugangsdaten und wird fuer die Anmeldung am Ziel gebraucht.", - ) - domain = (payload.domain or "").strip() or None - encrypted = encrypt_secret(payload.password.encode(), associated_data=b"rdp_password") + scope = _scope(admin) + await _assert_host_in_scope(conn, scope, host_id) + await _assert_rdp_credential_in_scope(conn, scope, credential_id) + else: + cred_row = await ( + await conn.execute("SELECT 1 FROM rdp_credentials WHERE id = ?", (credential_id,)) + ).fetchone() + if cred_row is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "RDP-Zugangsdaten nicht gefunden") await conn.execute( - "INSERT INTO rdp_credentials (host_id, password_enc, username, domain, updated_at) " - "VALUES (?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ','now')) " - "ON CONFLICT(host_id) DO UPDATE SET password_enc = excluded.password_enc, " - "username = excluded.username, domain = excluded.domain, " - "updated_at = excluded.updated_at", - (host_id, encrypted, username, domain), + "INSERT INTO host_rdp_credential_map (host_id, rdp_credential_id) VALUES (?, ?) " + "ON CONFLICT(host_id) DO UPDATE SET rdp_credential_id = excluded.rdp_credential_id", + (host_id, credential_id), ) await write_audit_event( - conn, event_type="rdp_credentials_set", user_id=admin.id, client_ip=_client_ip(request), - # Benutzername/Domaene sind keine Geheimnisse (das Passwort schon) und - # gehoeren ins Audit-Log. - details={"host_id": host_id, "username": username, "domain": domain}, + conn, event_type="rdp_credential_mapped", user_id=admin.id, client_ip=_client_ip(request), + details={"host_id": host_id, "rdp_credential_id": credential_id}, ) await conn.commit() return {"status": "ok"} @router.delete("/hosts/{host_id}/rdp-credentials") -async def delete_rdp_credentials( +async def unassign_rdp_credential_from_host( host_id: int, request: Request, admin: CurrentUser = Depends( - require_admin_scope_or_host_role("hosts", "write", ("credentials_manage",)) + require_admin_scope_or_host_role("rdp_credentials", "write", ("credentials_manage",)) ), ): + """Entfernt NUR die Zuordnung zu diesem Host -- das Zugangsdaten-Objekt + selbst bleibt bestehen und kann weiterhin anderen Hosts zugewiesen sein + bzw. spaeter erneut zugewiesen werden. Loeschen des Objekts selbst: + DELETE /admin/rdp-credentials/{id}.""" conn = get_db() 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 conn.execute("DELETE FROM host_rdp_credential_map WHERE host_id = ?", (host_id,)) await write_audit_event( - conn, event_type="rdp_credentials_deleted", user_id=admin.id, client_ip=_client_ip(request), + conn, event_type="rdp_credential_unmapped", user_id=admin.id, client_ip=_client_ip(request), details={"host_id": host_id}, ) await conn.commit() @@ -1095,7 +1134,10 @@ async def get_host_credentials( ) ssh_keys = [{"id": k[0], "label": k[1], "username": k[2]} for k in await keys_cursor.fetchall()] rdp_row = await (await conn.execute( - "SELECT updated_at, username, domain FROM rdp_credentials WHERE host_id = ?", (host_id,) + "SELECT rc.id, rc.label, rc.username, rc.domain, COALESCE(rc.rotated_at, rc.created_at) " + "FROM host_rdp_credential_map m JOIN rdp_credentials rc ON rc.id = m.rdp_credential_id " + "WHERE m.host_id = ?", + (host_id,), )).fetchone() ssh_pw_row = await (await conn.execute( "SELECT updated_at, username FROM ssh_password_credentials WHERE host_id = ?", (host_id,) @@ -1103,40 +1145,150 @@ async def get_host_credentials( 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, - "rdp_credentials_username": rdp_row[1] if rdp_row else None, - "rdp_credentials_domain": rdp_row[2] if rdp_row else None, + "rdp_credentials_id": rdp_row[0] if rdp_row else None, + "rdp_credentials_label": rdp_row[1] if rdp_row else None, + "rdp_credentials_username": rdp_row[2] if rdp_row else None, + "rdp_credentials_domain": rdp_row[3] if rdp_row else None, + "rdp_credentials_updated_at": rdp_row[4] if rdp_row else None, "ssh_password_credentials_set": ssh_pw_row is not None, "ssh_password_credentials_updated_at": ssh_pw_row[0] if ssh_pw_row else None, "ssh_password_credentials_username": ssh_pw_row[1] if ssh_pw_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 - haben bereits ein Passwort hinterlegt, wann zuletzt gesetzt.""" +# --- RDP-Zugangsdatenverwaltung (Migration 0012) ------------------------------ +# +# Eigenstaendige, wiederverwendbare Objekte -- strukturell und in der +# Endpunktaufteilung bewusst identisch zur SSH-Keyverwaltung weiter unten +# (create/list/update/delete + Zuordnung/Entfernung am Host), damit sich +# beide Zugangsdaten-Arten im Reiter "Zugangsdaten" gleich bedienen. + +@router.post("/rdp-credentials", status_code=status.HTTP_201_CREATED) +async def create_rdp_credential( + payload: RdpCredentialCreateRequest, request: Request, + admin: CurrentUser = Depends(require_admin_or_scope("rdp_credentials", "write")), +): conn = get_db() scope = _scope(admin) - tenant_filter, params = scope.sql_filter("hg.tenant_id") + tenant_id = await _resolve_write_tenant(conn, scope, payload.tenant_id) + encrypted = encrypt_secret(payload.password.encode(), associated_data=b"rdp_password") + domain = (payload.domain or "").strip() or None cursor = await conn.execute( - "SELECT h.id, h.hostname, h.address, hg.name, rc.updated_at, rc.username, rc.domain " - "FROM hosts h JOIN host_groups hg ON hg.id = h.host_group_id " - "LEFT JOIN rdp_credentials rc ON rc.host_id = h.id " - f"WHERE h.protocol = 'rdp'{tenant_filter} ORDER BY h.hostname", + "INSERT INTO rdp_credentials (label, username, domain, password_enc, tenant_id) " + "VALUES (?, ?, ?, ?, ?)", + (payload.label, payload.username.strip(), domain, encrypted, tenant_id), + ) + new_id = cursor.lastrowid + await write_audit_event( + conn, event_type="rdp_credential_created", user_id=admin.id, client_ip=_client_ip(request), + details={"id": new_id, "label": payload.label, "username": payload.username, "domain": domain}, + ) + await conn.commit() + return {"id": new_id, "label": payload.label} + + +@router.get("/rdp-credentials") +async def list_rdp_credentials(admin: CurrentUser = Depends(require_admin_or_scope("rdp_credentials", "read"))): + """Alle RDP-Zugangsdaten-Objekte fuer den 'Zugangsdaten'-Tab, inklusive + der Hosts, denen das jeweilige Objekt aktuell zugewiesen ist -- ein + Objekt kann mehreren Hosts zugewiesen sein (siehe Migration 0012).""" + conn = get_db() + scope = _scope(admin) + tenant_filter, params = scope.sql_filter("rc.tenant_id") + cursor = await conn.execute( + "SELECT rc.id, rc.label, rc.username, rc.domain, rc.tenant_id, t.name, " + "rc.created_at, rc.rotated_at " + "FROM rdp_credentials rc JOIN tenants t ON t.id = rc.tenant_id " + f"WHERE 1=1{tenant_filter} ORDER BY rc.id", params, ) rows = await cursor.fetchall() + hosts_cursor = await conn.execute( + "SELECT m.rdp_credential_id, h.id, h.hostname FROM host_rdp_credential_map m " + "JOIN hosts h ON h.id = m.host_id" + ) + assigned: dict[int, list[dict]] = {} + for cred_id, host_id, hostname in await hosts_cursor.fetchall(): + assigned.setdefault(cred_id, []).append({"id": host_id, "hostname": hostname}) return [ { - "host_id": r[0], "hostname": r[1], "address": r[2], "host_group_name": r[3], - "credentials_set": r[4] is not None, "updated_at": r[4], - "username": r[5], "domain": r[6], + "id": r[0], "label": r[1], "username": r[2], "domain": r[3], + "tenant_id": r[4], "tenant_name": r[5], + "created_at": r[6], "rotated_at": r[7], + "assigned_hosts": assigned.get(r[0], []), } for r in rows ] +async def _assert_rdp_credential_in_scope(conn, scope: TenantScope, credential_id: int) -> None: + row = await ( + await conn.execute("SELECT tenant_id FROM rdp_credentials WHERE id = ?", (credential_id,)) + ).fetchone() + if row is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "RDP-Zugangsdaten nicht gefunden") + scope.check(row[0]) + + +@router.put("/rdp-credentials/{credential_id}") +async def update_rdp_credential( + credential_id: int, payload: RdpCredentialUpdateRequest, request: Request, + admin: CurrentUser = Depends(require_admin_or_scope("rdp_credentials", "write")), +): + conn = get_db() + await _assert_rdp_credential_in_scope(conn, _scope(admin), credential_id) + + fields, values = [], [] + if payload.label is not None: + fields.append("label = ?"); values.append(payload.label) + if payload.username is not None: + fields.append("username = ?"); values.append(payload.username.strip()) + if "domain" in payload.model_fields_set: + fields.append("domain = ?"); values.append((payload.domain or "").strip() or None) + rotating = payload.password is not None + if rotating: + fields += ["password_enc = ?", "rotated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')"] + values.append(encrypt_secret(payload.password.encode(), associated_data=b"rdp_password")) + if not fields: + return {"status": "ok", "changed": False} + values.append(credential_id) + await conn.execute(f"UPDATE rdp_credentials SET {', '.join(fields)} WHERE id = ?", values) + await write_audit_event( + conn, event_type="rdp_credential_updated", user_id=admin.id, client_ip=_client_ip(request), + details={ + "id": credential_id, "rotated": rotating, + "label_changed": payload.label is not None, + "username_changed": payload.username is not None, + "domain_changed": "domain" in payload.model_fields_set, + }, + ) + await conn.commit() + return {"status": "ok", "changed": True} + + +@router.delete("/rdp-credentials/{credential_id}") +async def delete_rdp_credential( + credential_id: int, request: Request, + admin: CurrentUser = Depends(require_admin_or_scope("rdp_credentials", "write")), +): + """host_rdp_credential_map verweist bewusst OHNE ON DELETE auf + rdp_credentials(id) -- Zuordnungen werden hier explizit mit entfernt + (samt Vermerk, welche Hosts betroffen waren), analog delete_ssh_key.""" + conn = get_db() + await _assert_rdp_credential_in_scope(conn, _scope(admin), credential_id) + affected = await (await conn.execute( + "SELECT host_id FROM host_rdp_credential_map WHERE rdp_credential_id = ?", (credential_id,) + )).fetchall() + await conn.execute("DELETE FROM host_rdp_credential_map WHERE rdp_credential_id = ?", (credential_id,)) + await conn.execute("DELETE FROM rdp_credentials WHERE id = ?", (credential_id,)) + await write_audit_event( + conn, event_type="rdp_credential_deleted", user_id=admin.id, client_ip=_client_ip(request), + details={"id": credential_id, "unmapped_host_ids": [r[0] for r in affected]}, + ) + await conn.commit() + return {"status": "ok", "unmapped_host_ids": [r[0] for r in affected]} + + # --- Rollenvergabe (an einzelne User) ----------------------------------------- async def _role_id(conn, role_name: str) -> int: diff --git a/app/db/migrations/0012_rdp_credential_sets.sql b/app/db/migrations/0012_rdp_credential_sets.sql new file mode 100644 index 0000000..9fa4c7a --- /dev/null +++ b/app/db/migrations/0012_rdp_credential_sets.sql @@ -0,0 +1,63 @@ +-- Migration 0012: RDP-Zugangsdaten als eigenstaendige, wiederverwendbare +-- Objekte -- analog zu ssh_keys/host_ssh_key_map, und aus demselben Grund: +-- bisher gab es GENAU einen RDP-Zugangsdatensatz pro Host +-- (rdp_credentials.host_id war PRIMARY KEY), angelegt direkt im +-- Serverformular ("Server" -> Host-Detail -> "RDP-Zugangsdaten"). Auf +-- ausdruecklichen Wunsch des Users: "RDP-Zugangsdaten auch unter +-- Zugangsdaten UND dem Server zuweisbar, nicht im Serverobjekt zu +-- erstellen" -- ein Zugangsdatensatz (Label/Benutzer/Domaene/Passwort) +-- laesst sich jetzt EINMAL im Reiter "Zugangsdaten" anlegen und danach +-- MEHREREN Hosts zuweisen, genau wie ein SSH-Key. + +-- Alte 1:1-Tabelle bleibt als Datenquelle fuer die Uebernahme unten +-- erhalten, wird aber umbenannt, damit der Name "rdp_credentials" frei wird +-- fuer das neue, eigenstaendige Objekt (Konvention dieses Projekts: Altes +-- bleibt lesbar erhalten statt geloescht zu werden, siehe z.B. +-- hosts.ssh_username/rdp_username in Migration 0010). +ALTER TABLE rdp_credentials RENAME TO rdp_credentials_legacy; + +CREATE TABLE IF NOT EXISTS rdp_credentials ( + id INTEGER PRIMARY KEY, + label TEXT NOT NULL, + username TEXT NOT NULL, + domain TEXT, + password_enc BLOB NOT NULL, + tenant_id INTEGER NOT NULL REFERENCES tenants(id), + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + rotated_at TEXT +); +CREATE INDEX IF NOT EXISTS idx_rdp_credentials_tenant ON rdp_credentials(tenant_id); + +-- Genau EIN zugewiesener Zugangsdatensatz je Host (PRIMARY KEY = host_id) -- +-- aber derselbe Zugangsdatensatz darf in mehreren Zeilen (= mehreren Hosts) +-- auftauchen. Das ist die eigentliche Wiederverwendbarkeit, die diese +-- Migration einfuehrt. +CREATE TABLE IF NOT EXISTS host_rdp_credential_map ( + host_id INTEGER PRIMARY KEY REFERENCES hosts(id) ON DELETE CASCADE, + rdp_credential_id INTEGER NOT NULL REFERENCES rdp_credentials(id) +); + +-- Datenuebernahme: jeder bisherige 1:1-Datensatz wird zu einem eigenen +-- Zugangsdaten-Objekt (Label aus dem Hostnamen abgeleitet, da die alte +-- Tabelle keinen eigenen Namen kennt) und dem jeweiligen Host zugeordnet. +-- Die Korrelation zwischen den beiden folgenden INSERTs laeuft bewusst ueber +-- password_enc statt ueber eine temporaere ID-Spalte: jede Verschluesselung +-- verwendet einen frischen Zufalls-Nonce (siehe app/security/crypto.py), +-- zwei Zeilen der Alttabelle koennen also nie denselben password_enc-Wert +-- haben -- der Ruecksprung von rdp_credentials auf rdp_credentials_legacy +-- ist damit eindeutig. +INSERT INTO rdp_credentials (label, username, domain, password_enc, tenant_id, created_at) +SELECT 'Migriert: ' || h.hostname, + COALESCE(NULLIF(TRIM(rcl.username), ''), '(kein Benutzername)'), + rcl.domain, + rcl.password_enc, + COALESCE(hg.tenant_id, 1), + rcl.updated_at + FROM rdp_credentials_legacy rcl + JOIN hosts h ON h.id = rcl.host_id + LEFT JOIN host_groups hg ON hg.id = h.host_group_id; + +INSERT INTO host_rdp_credential_map (host_id, rdp_credential_id) +SELECT rcl.host_id, rc.id + FROM rdp_credentials_legacy rcl + JOIN rdp_credentials rc ON rc.password_enc = rcl.password_enc; diff --git a/app/models/schemas.py b/app/models/schemas.py index 912045d..31a824a 100644 --- a/app/models/schemas.py +++ b/app/models/schemas.py @@ -255,12 +255,30 @@ class ConnectRequest(BaseModel): host_id: int -class RdpCredentialsRequest(BaseModel): - password: str = Field(min_length=1, max_length=512) - # Benutzername und (optional) Domaene der Windows-Anmeldung. Seit - # Migration 0010 Teil der Zugangsdaten statt des Hosts. - username: str | None = Field(default=None, max_length=128) +class RdpCredentialCreateRequest(BaseModel): + """Migration 0012: RDP-Zugangsdaten sind ein eigenstaendiges, + wiederverwendbares Objekt (analog SshKeyCreateRequest), das im Reiter + 'Zugangsdaten' angelegt und danach beliebigen Hosts zugewiesen wird -- + NICHT mehr direkt im Serverformular erzeugt.""" + label: str = Field(min_length=1, max_length=128) + username: str = Field(min_length=1, max_length=128) domain: str | None = Field(default=None, max_length=128) + password: str = Field(min_length=1, max_length=512) + tenant_id: int | None = None + + +class RdpCredentialUpdateRequest(BaseModel): + """Label/Benutzername/Domaene sind jederzeit aenderbar. Eine Rotation des + Passworts ist bewusst ein SEPARATER Aufruf mit nur 'password' gesetzt + (siehe update_rdp_credential in admin/routes.py) -- analog zur + getrennten Passphrase-Aenderung bei SSH-Keys, damit ein Label-Update + nicht versehentlich das Passwort mitanfasst.""" + label: str | None = Field(default=None, min_length=1, max_length=128) + username: str | None = Field(default=None, min_length=1, max_length=128) + # Wird nur beachtet, wenn das Feld im Request vorkommt: Weglassen laesst + # die Domaene unveraendert, explizites null/"" entfernt sie. + domain: str | None = Field(default=None, max_length=128) + password: str | None = Field(default=None, min_length=1, max_length=512) class SshPasswordCredentialsRequest(BaseModel): diff --git a/app/rdp_proxy/ws_tunnel.py b/app/rdp_proxy/ws_tunnel.py index f556c6a..31d6016 100644 --- a/app/rdp_proxy/ws_tunnel.py +++ b/app/rdp_proxy/ws_tunnel.py @@ -176,9 +176,13 @@ async def rdp_tunnel( # username/domain stehen seit Migration 0010 bei den Zugangsdaten und nicht # mehr am Host -- der Benutzername gehoert zur Anmeldung, nicht zur - # Beschreibung des Zielsystems. + # Beschreibung des Zielsystems. Seit Migration 0012 sind RDP-Zugangsdaten + # zudem ein eigenstaendiges, wiederverwendbares Objekt (analog SSH-Keys), + # das einem Host ueber host_rdp_credential_map zugewiesen wird, statt + # 1:1 am Host zu haengen. cred_cursor = await conn.execute( - "SELECT password_enc, username, domain FROM rdp_credentials WHERE host_id = ?", + "SELECT rc.password_enc, rc.username, rc.domain FROM host_rdp_credential_map m " + "JOIN rdp_credentials rc ON rc.id = m.rdp_credential_id WHERE m.host_id = ?", (host_id,), ) cred_row = await cred_cursor.fetchone() diff --git a/app/security/api_tokens.py b/app/security/api_tokens.py index 0803596..f406bac 100644 --- a/app/security/api_tokens.py +++ b/app/security/api_tokens.py @@ -22,6 +22,11 @@ SCOPE_RESOURCES = ( "host_groups", "hosts", "ssh_keys", + # Migration 0012: RDP-Zugangsdaten sind jetzt eigenstaendige, + # wiederverwendbare Objekte (analog ssh_keys) statt eines 1:1-Datensatzes + # am Host -- verdienen deshalb aus demselben Grund wie ssh_keys einen + # eigenen Scope statt ueber "hosts" mitzulaufen. + "rdp_credentials", "roles", "user_groups", "audit", diff --git a/app/ssh_proxy/proxy.py b/app/ssh_proxy/proxy.py index f996b51..a96e195 100644 --- a/app/ssh_proxy/proxy.py +++ b/app/ssh_proxy/proxy.py @@ -404,7 +404,14 @@ async def _verified_host_key( observed_key = await asyncssh.get_server_host_key( host["address"], port=host["port"], connect_timeout=10 ) - except (asyncssh.Error, OSError) as exc: + except Exception as exc: + # Bewusst dieselbe Verbreiterung wie in discover_and_store_host_key(): + # (asyncssh.Error, OSError) allein fing nicht jede Art von Fehlschlag + # dieses externen Aufrufs ab (z.B. asyncio.TimeoutError vor Python + # 3.11, kein OSError). Ein hier unbehandelter Fehler wuerde ungefiltert + # bis in terminal_ws.py/sftp.py durchreichen -- SSH_SETUP_ERRORS + # erkennt HostKeyDiscoveryError dort explizit und zeigt eine saubere + # deutsche Meldung statt eines generischen Sitzungsabbruchs. raise HostKeyDiscoveryError(host["id"], str(exc)) from exc if observed_key is None: raise HostKeyDiscoveryError(host["id"], "Das Ziel hat keinen Host-Key gesendet") @@ -526,7 +533,22 @@ async def discover_and_store_host_key( Jetzt wird asyncssh.get_server_host_key() verwendet: das fuehrt nur den Key-Exchange durch, versucht gar keine Anmeldung und liefert den Schluessel direkt zurueck. Ein Auth-Fehler kann die Ermittlung damit - nicht mehr verhindern.""" + nicht mehr verhindern. + + Bugfix 2 (weiterhin gemeldeter 500er nach obigem Fix): das + except-(asyncssh.Error, OSError) fing NICHT jede Art von Fehlschlag ab, + die asyncssh.get_server_host_key() tatsaechlich werfen kann -- reproduziert + mit einer eigenstaendigen Exception-Klasse, wie sie z.B. asyncio.TimeoutError + vor Python 3.11 war (Exception-Unterklasse, KEIN OSError) oder wie sie bei + einem Abbruch waehrend des Key-Exchange auftreten kann (z.B. ein ValueError + aus der Paketdekodierung). So ein Fehler ritt bislang unbehandelt durch bis + zu FastAPI -> HTTP 500 ohne jede Meldung, obwohl es sich in der Sache um + exakt denselben Fall handelt wie ein OSError (Ziel nicht erreichbar/ + Protokollfehler waehrend der Ermittlung). Jetzt wird deshalb bewusst JEDE + Ausnahme aus diesem einzelnen, klar abgegrenzten externen Aufruf abgefangen + (asyncio.CancelledError ist als BaseException davon ohnehin nicht + betroffen -- dieselbe Ueberlegung wie beim Exception-Catch-all der + SSH/RDP-WS-Routen, siehe terminal_ws.py/ws_tunnel.py).""" host = await load_host(conn, host_id) if host["protocol"] != "ssh": raise HostNotConfiguredError("Host-Key-Ermittlung ist nur fuer SSH-Ziele moeglich") @@ -535,7 +557,7 @@ async def discover_and_store_host_key( key = await asyncssh.get_server_host_key( host["address"], port=host["port"], connect_timeout=10 ) - except (asyncssh.Error, OSError) as exc: + except Exception as exc: logger.warning("Host-Key-Ermittlung fuer Host %s fehlgeschlagen: %s", host_id, exc) raise HostKeyDiscoveryError(host_id, str(exc)) from exc if key is None: diff --git a/static/js/admin.js b/static/js/admin.js index 87cee5c..a1df2cb 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -399,13 +399,14 @@ let cachedHostGroups = []; let cachedHosts = []; let cachedSshKeys = []; + let cachedRdpCredentials = []; let activeHostId = null; let editingHostGroupId = null; async function loadHostsTab() { document.getElementById("hgc-tenant-box").classList.toggle("hidden", !meInfo.is_admin && meInfo.tenant_admin_of.length <= 1); await populateTenantSelect(document.getElementById("hgc-tenant")); - await Promise.all([refreshHostGroups(), refreshSshKeysCache()]); + await Promise.all([refreshHostGroups(), refreshSshKeysCache(), refreshRdpCredentialsCache()]); await refreshHosts(); } @@ -496,6 +497,10 @@ cachedSshKeys = await getJson("/admin/ssh-keys"); } + async function refreshRdpCredentialsCache() { + cachedRdpCredentials = await getJson("/admin/rdp-credentials"); + } + function setDetailBanner(message, type) { const box = document.getElementById("host-detail-banner"); if (!message) { @@ -512,9 +517,9 @@ return `Anmeldung als "${host.rdp_credentials_username}"${domain} (aus den RDP-Zugangsdaten unten).`; } if (host.rdp_username) { - return `Noch kein Benutzername in den Zugangsdaten -- es wird der alte Wert am Host verwendet ("${host.rdp_username}"). Bitte unten bei den RDP-Zugangsdaten neu setzen.`; + return `Noch kein Zugangsdaten-Objekt zugewiesen -- es wird der alte Wert am Host verwendet ("${host.rdp_username}"). Bitte unten RDP-Zugangsdaten zuweisen.`; } - return "Kein Windows-Benutzername hinterlegt -- unten bei den RDP-Zugangsdaten eintragen, sonst schlaegt die Anmeldung fehl."; + return "Kein Windows-Benutzername hinterlegt -- unten RDP-Zugangsdaten zuweisen (oder im Reiter \"Zugangsdaten\" zuerst anlegen), sonst schlaegt die Anmeldung fehl."; } const named = (host.ssh_keys || []).filter((k) => k.username); if (named.length) { @@ -590,10 +595,11 @@ document.getElementById("hspw-username").value = host.ssh_password_credentials_username || ""; document.getElementById("rdp-cred-status").textContent = host.rdp_credentials_set - ? `RDP-Zugangsdaten gesetzt fuer "${host.rdp_credentials_username || "(kein Benutzername)"}" (zuletzt aktualisiert: ${host.rdp_credentials_updated_at}).` - : "Noch keine RDP-Zugangsdaten gesetzt."; - document.getElementById("hrc-username").value = host.rdp_credentials_username || host.rdp_username || ""; - document.getElementById("hrc-domain").value = host.rdp_credentials_domain || host.rdp_domain || ""; + ? `Zugewiesen: "${host.rdp_credentials_label}" -- Anmeldung als "${host.rdp_credentials_username || "(kein Benutzername)"}" (zuletzt aktualisiert: ${host.rdp_credentials_updated_at}).` + : "Noch keine RDP-Zugangsdaten zugewiesen."; + const rdpSelect = document.getElementById("hrcm-select"); + rdpSelect.replaceChildren(...optionsFromList(cachedRdpCredentials, "id", (c) => `${c.label} (${c.username}${c.domain ? "@" + c.domain : ""}, #${c.id})`)); + if (host.rdp_credentials_id) rdpSelect.value = String(host.rdp_credentials_id); } catch (err) { setDetailBanner(err.message, "error"); } @@ -697,25 +703,26 @@ } }); - document.getElementById("host-rdp-cred-form").addEventListener("submit", async (ev) => { + document.getElementById("host-rdp-cred-map-form").addEventListener("submit", async (ev) => { ev.preventDefault(); try { - const password = document.getElementById("hrc-password").value; - const username = document.getElementById("hrc-username").value.trim(); - const domain = document.getElementById("hrc-domain").value.trim() || null; - await sendJson(`/admin/hosts/${activeHostId}/rdp-credentials`, "PUT", { password, username, domain }); - showBanner("RDP-Zugangsdaten gespeichert.", "ok"); - ev.target.reset(); + const credentialId = document.getElementById("hrcm-select").value; + if (!credentialId) { + showBanner('Bitte zuerst im Reiter "Zugangsdaten" ein RDP-Zugangsdaten-Objekt anlegen.', "error"); + return; + } + await sendJson(`/admin/hosts/${activeHostId}/rdp-credentials/${credentialId}`, "POST", {}); + showBanner("RDP-Zugangsdaten zugewiesen.", "ok"); await showHostDetail(activeHostId); } catch (err) { setDetailBanner(err.message, "error"); } }); - document.getElementById("hrc-delete-btn").addEventListener("click", async () => { + document.getElementById("hrc-unassign-btn").addEventListener("click", async () => { try { await apiFetch(`/admin/hosts/${activeHostId}/rdp-credentials`, { method: "DELETE" }); - showBanner("RDP-Zugangsdaten geloescht.", "ok"); + showBanner("Zuordnung entfernt.", "ok"); await showHostDetail(activeHostId); } catch (err) { setDetailBanner(err.message, "error"); @@ -754,7 +761,11 @@ async function loadCredentialsTab() { document.getElementById("skc-tenant-box").classList.toggle("hidden", !meInfo.is_admin && meInfo.tenant_admin_of.length <= 1); - await populateTenantSelect(document.getElementById("skc-tenant")); + document.getElementById("rcc-tenant-box").classList.toggle("hidden", !meInfo.is_admin && meInfo.tenant_admin_of.length <= 1); + await Promise.all([ + populateTenantSelect(document.getElementById("skc-tenant")), + populateTenantSelect(document.getElementById("rcc-tenant")), + ]); await Promise.all([refreshSshKeys(), refreshRdpCredentials()]); } @@ -908,37 +919,102 @@ } }); + let editingRdpCredentialId = null; + async function refreshRdpCredentials() { - const rows = await getJson("/admin/rdp-credentials"); + cachedRdpCredentials = await getJson("/admin/rdp-credentials"); const tbody = document.querySelector("#rdp-creds-table tbody"); fillTable( tbody, - rows.map((r) => + cachedRdpCredentials.map((r) => el("tr", {}, [ - el("td", { textContent: r.hostname }), - el("td", { textContent: r.address }), - el("td", { textContent: r.host_group_name }), + el("td", { textContent: String(r.id) }), + el("td", { textContent: r.label }), el("td", { textContent: r.username || "-" }), - el("td", {}, [el("span", { className: `badge ${r.credentials_set ? "ok" : ""}`, textContent: r.credentials_set ? "gesetzt" : "nicht gesetzt" })]), - el("td", { textContent: r.updated_at || "-" }), + el("td", { textContent: r.domain || "-" }), + el("td", { textContent: r.tenant_name }), + el("td", { + textContent: r.assigned_hosts.length + ? r.assigned_hosts.map((h) => h.hostname).join(", ") + : "(keinem Server zugewiesen)", + }), + el("td", { textContent: r.rotated_at || r.created_at }), el("td", {}, [ - actionButton("Zum Host", "btn-secondary", async () => { - document.querySelector('.tab-btn[data-tab="servers"]').click(); - await showHostDetail(r.host_id); + actionButton("Bearbeiten", "btn-secondary", () => showRdpCredentialEdit(r)), + actionButton("Loeschen", "btn-danger", async () => { + const result = await apiFetch(`/admin/rdp-credentials/${r.id}`, { method: "DELETE" }); + const suffix = result.unmapped_host_ids.length + ? ` (Zuordnung zu Host(s) ${result.unmapped_host_ids.join(", ")} entfernt)` + : ""; + showBanner(`RDP-Zugangsdaten '${r.label}' geloescht.${suffix}`, "ok"); + await refreshRdpCredentials(); }), - ...(r.credentials_set - ? [actionButton("Entfernen", "btn-danger", async () => { - await apiFetch(`/admin/hosts/${r.host_id}/rdp-credentials`, { method: "DELETE" }); - showBanner("RDP-Passwort entfernt.", "ok"); - await refreshRdpCredentials(); - })] - : []), ]), ]) ) ); } + function showRdpCredentialEdit(r) { + editingRdpCredentialId = r.id; + document.getElementById("rdp-cred-edit-panel").classList.remove("hidden"); + document.getElementById("rdp-cred-edit-name").textContent = `${r.label} (#${r.id})`; + document.getElementById("rce-label").value = r.label; + document.getElementById("rce-username").value = r.username || ""; + document.getElementById("rce-domain").value = r.domain || ""; + document.getElementById("rcr-password").value = ""; + } + + document.getElementById("rdp-cred-edit-form").addEventListener("submit", async (ev) => { + ev.preventDefault(); + try { + await sendJson(`/admin/rdp-credentials/${editingRdpCredentialId}`, "PUT", { + label: document.getElementById("rce-label").value.trim() || null, + username: document.getElementById("rce-username").value.trim() || null, + // Leeres Feld -> null entfernt die Domaene bewusst (Feld-vorhanden- + // vs-null-Semantik wie bei SSH-Keys). + domain: document.getElementById("rce-domain").value.trim() || null, + }); + showBanner("RDP-Zugangsdaten aktualisiert.", "ok"); + await refreshRdpCredentials(); + } catch (err) { + showBanner(err.message, "error"); + } + }); + + document.getElementById("rdp-cred-rotate-form").addEventListener("submit", async (ev) => { + ev.preventDefault(); + try { + const password = document.getElementById("rcr-password").value; + await sendJson(`/admin/rdp-credentials/${editingRdpCredentialId}`, "PUT", { password }); + showBanner("Neues Passwort gespeichert.", "ok"); + document.getElementById("rcr-password").value = ""; + await refreshRdpCredentials(); + } catch (err) { + showBanner(err.message, "error"); + } + }); + + document.getElementById("rdp-cred-create-form").addEventListener("submit", async (ev) => { + ev.preventDefault(); + try { + const tenantSelect = document.getElementById("rcc-tenant"); + const payload = { + label: document.getElementById("rcc-label").value.trim(), + username: document.getElementById("rcc-username").value.trim(), + domain: document.getElementById("rcc-domain").value.trim() || null, + password: document.getElementById("rcc-password").value, + tenant_id: tenantSelect.value ? Number(tenantSelect.value) : null, + }; + await sendJson("/admin/rdp-credentials", "POST", payload); + showBanner(`RDP-Zugangsdaten '${payload.label}' angelegt.`, "ok"); + ev.target.reset(); + await refreshRdpCredentials(); + } catch (err) { + showBanner(err.message, "error"); + } + }); + // --------------------------------------------------------------------- // Rollen // --------------------------------------------------------------------- diff --git a/templates/admin.html b/templates/admin.html index db5105f..480c415 100644 --- a/templates/admin.html +++ b/templates/admin.html @@ -325,23 +325,15 @@
RDP-Zugangsdaten sind eigenstaendige, wiederverwendbare Objekte -- angelegt und bearbeitet im Reiter "Zugangsdaten". Hier wird nur zugewiesen, welches Objekt fuer diesen Server gilt.
-Uebersicht aller RDP-Hosts. Passwoerter werden AES-256-GCM-verschluesselt gespeichert und sind nach dem Setzen nicht mehr im Klartext abrufbar.
+Eigenstaendiges, wiederverwendbares Objekt (analog einem SSH-Key) -- kann nach dem Anlegen im Reiter "Server" beliebigen Hosts zugewiesen werden, statt es je Server neu einzutippen.
+ +| Host | Adresse | Hostgruppe | Benutzer | Status | Zuletzt gesetzt | ||
|---|---|---|---|---|---|---|---|
| ID | Label | Benutzer | Domaene | Mandant | Zugewiesene Server | Zuletzt rotiert |