connectiopn fix round 2 1
This commit is contained in:
@ -44,7 +44,8 @@ from app.models.schemas import (
|
|||||||
HostGroupCreateRequest,
|
HostGroupCreateRequest,
|
||||||
HostGroupUpdateRequest,
|
HostGroupUpdateRequest,
|
||||||
HostUpdateRequest,
|
HostUpdateRequest,
|
||||||
RdpCredentialsRequest,
|
RdpCredentialCreateRequest,
|
||||||
|
RdpCredentialUpdateRequest,
|
||||||
RoleGrantRequest,
|
RoleGrantRequest,
|
||||||
RoleRevokeRequest,
|
RoleRevokeRequest,
|
||||||
SshKeyCreateRequest,
|
SshKeyCreateRequest,
|
||||||
@ -819,8 +820,14 @@ async def get_host_detail(
|
|||||||
(host_id,),
|
(host_id,),
|
||||||
)
|
)
|
||||||
ssh_keys = [{"id": k[0], "label": k[1], "username": k[2]} for k in await keys_cursor.fetchall()]
|
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(
|
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()
|
)).fetchone()
|
||||||
ssh_pw_row = await (await conn.execute(
|
ssh_pw_row = await (await conn.execute(
|
||||||
"SELECT updated_at, username FROM ssh_password_credentials WHERE host_id = ?", (host_id,)
|
"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]),
|
"tenant_id": row[15], "tenant_name": row[16], "rdp_ignore_cert": bool(row[17]),
|
||||||
"ssh_keys": ssh_keys,
|
"ssh_keys": ssh_keys,
|
||||||
"rdp_credentials_set": rdp_row is not None,
|
"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
|
# Benutzername/Domaene gehoeren seit Migration 0010 zu den
|
||||||
# Zugangsdaten; sie werden hier nur zur Anzeige mitgeliefert.
|
# Zugangsdaten (jetzt: dem zugewiesenen Objekt); nur zur Anzeige.
|
||||||
"rdp_credentials_username": rdp_row[1] if rdp_row else None,
|
"rdp_credentials_username": rdp_row[2] if rdp_row else None,
|
||||||
"rdp_credentials_domain": 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-Passwort als Alternative zum Schluessel (Migration 0011).
|
||||||
"ssh_password_credentials_set": ssh_pw_row is not 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_updated_at": ssh_pw_row[0] if ssh_pw_row else None,
|
||||||
@ -908,7 +917,20 @@ async def delete_host(
|
|||||||
)).fetchone()
|
)).fetchone()
|
||||||
if has_sessions is None:
|
if has_sessions is None:
|
||||||
await conn.execute("DELETE FROM host_ssh_key_map WHERE host_id = ?", (host_id,))
|
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,))
|
await conn.execute("DELETE FROM hosts WHERE id = ?", (host_id,))
|
||||||
hard_deleted = True
|
hard_deleted = True
|
||||||
if not hard_deleted:
|
if not hard_deleted:
|
||||||
@ -934,7 +956,17 @@ async def discover_host_key(
|
|||||||
Bugfix: discover_and_store_host_key() konnte bisher ein unbehandeltes
|
Bugfix: discover_and_store_host_key() konnte bisher ein unbehandeltes
|
||||||
asyncssh/OSError durchreichen -> FastAPI antwortete mit 500 statt einer
|
asyncssh/OSError durchreichen -> FastAPI antwortete mit 500 statt einer
|
||||||
verwertbaren Fehlermeldung (siehe app/ssh_proxy/proxy.py). Jetzt sauber
|
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()
|
conn = get_db()
|
||||||
await _assert_host_in_scope(conn, _scope(admin), host_id)
|
await _assert_host_in_scope(conn, _scope(admin), host_id)
|
||||||
try:
|
try:
|
||||||
@ -946,6 +978,13 @@ async def discover_host_key(
|
|||||||
status.HTTP_502_BAD_GATEWAY,
|
status.HTTP_502_BAD_GATEWAY,
|
||||||
f"Host-Key konnte nicht ermittelt werden -- Ziel nicht erreichbar: {exc.reason}",
|
f"Host-Key konnte nicht ermittelt werden -- Ziel nicht erreichbar: {exc.reason}",
|
||||||
) from exc
|
) 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(
|
await write_audit_event(
|
||||||
conn, event_type="host_key_discovered_trust_decision", user_id=admin.id,
|
conn, event_type="host_key_discovered_trust_decision", user_id=admin.id,
|
||||||
client_ip=_client_ip(request), details={"host_id": host_id, "fingerprint": fingerprint},
|
client_ip=_client_ip(request), details={"host_id": host_id, "fingerprint": fingerprint},
|
||||||
@ -954,61 +993,61 @@ async def discover_host_key(
|
|||||||
return {"host_id": host_id, "fingerprint": fingerprint}
|
return {"host_id": host_id, "fingerprint": fingerprint}
|
||||||
|
|
||||||
|
|
||||||
@router.put("/hosts/{host_id}/rdp-credentials")
|
@router.post("/hosts/{host_id}/rdp-credentials/{credential_id}")
|
||||||
async def set_rdp_credentials(
|
async def assign_rdp_credential_to_host(
|
||||||
host_id: int, payload: RdpCredentialsRequest, request: Request,
|
host_id: int, credential_id: int, request: Request,
|
||||||
admin: CurrentUser = Depends(
|
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
|
"""Weist einem Host EIN bereits bestehendes RDP-Zugangsdaten-Objekt zu
|
||||||
dem KEK (eigener AAD-Kontext, siehe app/security/crypto.py). Zugriff:
|
(Migration 0012) -- ersetzt eine zuvor zugewiesene Zuordnung, falls
|
||||||
Admin/Mandanten-Admin/Token ODER ein Nicht-Admin mit Rolle
|
vorhanden (INSERT OR REPLACE, host_id ist Primaerschluessel der
|
||||||
'credentials_manage' auf der Hostgruppe dieses Hosts (siehe RBAC-
|
Zuordnungstabelle). Das Anlegen des Zugangsdaten-Objekts selbst passiert
|
||||||
Erweiterung 'Credentials ins RBAC-Modell')."""
|
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()
|
conn = get_db()
|
||||||
if admin.is_any_admin:
|
if admin.is_any_admin:
|
||||||
await _assert_host_in_scope(conn, _scope(admin), host_id)
|
scope = _scope(admin)
|
||||||
username = (payload.username or "").strip() or None
|
await _assert_host_in_scope(conn, scope, host_id)
|
||||||
if username is None:
|
await _assert_rdp_credential_in_scope(conn, scope, credential_id)
|
||||||
raise HTTPException(
|
else:
|
||||||
status.HTTP_400_BAD_REQUEST,
|
cred_row = await (
|
||||||
"Bitte den Windows-Benutzernamen mit angeben -- er gehoert zu den "
|
await conn.execute("SELECT 1 FROM rdp_credentials WHERE id = ?", (credential_id,))
|
||||||
"Zugangsdaten und wird fuer die Anmeldung am Ziel gebraucht.",
|
).fetchone()
|
||||||
)
|
if cred_row is None:
|
||||||
domain = (payload.domain or "").strip() or None
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "RDP-Zugangsdaten nicht gefunden")
|
||||||
encrypted = encrypt_secret(payload.password.encode(), associated_data=b"rdp_password")
|
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
"INSERT INTO rdp_credentials (host_id, password_enc, username, domain, updated_at) "
|
"INSERT INTO host_rdp_credential_map (host_id, rdp_credential_id) VALUES (?, ?) "
|
||||||
"VALUES (?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ','now')) "
|
"ON CONFLICT(host_id) DO UPDATE SET rdp_credential_id = excluded.rdp_credential_id",
|
||||||
"ON CONFLICT(host_id) DO UPDATE SET password_enc = excluded.password_enc, "
|
(host_id, credential_id),
|
||||||
"username = excluded.username, domain = excluded.domain, "
|
|
||||||
"updated_at = excluded.updated_at",
|
|
||||||
(host_id, encrypted, username, domain),
|
|
||||||
)
|
)
|
||||||
await write_audit_event(
|
await write_audit_event(
|
||||||
conn, event_type="rdp_credentials_set", user_id=admin.id, client_ip=_client_ip(request),
|
conn, event_type="rdp_credential_mapped", user_id=admin.id, client_ip=_client_ip(request),
|
||||||
# Benutzername/Domaene sind keine Geheimnisse (das Passwort schon) und
|
details={"host_id": host_id, "rdp_credential_id": credential_id},
|
||||||
# gehoeren ins Audit-Log.
|
|
||||||
details={"host_id": host_id, "username": username, "domain": domain},
|
|
||||||
)
|
)
|
||||||
await conn.commit()
|
await conn.commit()
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/hosts/{host_id}/rdp-credentials")
|
@router.delete("/hosts/{host_id}/rdp-credentials")
|
||||||
async def delete_rdp_credentials(
|
async def unassign_rdp_credential_from_host(
|
||||||
host_id: int, request: Request,
|
host_id: int, request: Request,
|
||||||
admin: CurrentUser = Depends(
|
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()
|
conn = get_db()
|
||||||
if admin.is_any_admin:
|
if admin.is_any_admin:
|
||||||
await _assert_host_in_scope(conn, _scope(admin), host_id)
|
await _assert_host_in_scope(conn, _scope(admin), host_id)
|
||||||
await conn.execute("DELETE FROM rdp_credentials WHERE host_id = ?", (host_id,))
|
await conn.execute("DELETE FROM host_rdp_credential_map WHERE host_id = ?", (host_id,))
|
||||||
await write_audit_event(
|
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},
|
details={"host_id": host_id},
|
||||||
)
|
)
|
||||||
await conn.commit()
|
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()]
|
ssh_keys = [{"id": k[0], "label": k[1], "username": k[2]} for k in await keys_cursor.fetchall()]
|
||||||
rdp_row = await (await conn.execute(
|
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()
|
)).fetchone()
|
||||||
ssh_pw_row = await (await conn.execute(
|
ssh_pw_row = await (await conn.execute(
|
||||||
"SELECT updated_at, username FROM ssh_password_credentials WHERE host_id = ?", (host_id,)
|
"SELECT updated_at, username FROM ssh_password_credentials WHERE host_id = ?", (host_id,)
|
||||||
@ -1103,40 +1145,150 @@ async def get_host_credentials(
|
|||||||
return {
|
return {
|
||||||
"host_id": host_id, "ssh_keys": ssh_keys,
|
"host_id": host_id, "ssh_keys": ssh_keys,
|
||||||
"rdp_credentials_set": rdp_row is not None,
|
"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_username": rdp_row[1] if rdp_row else None,
|
"rdp_credentials_label": rdp_row[1] if rdp_row else None,
|
||||||
"rdp_credentials_domain": rdp_row[2] 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_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_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,
|
"ssh_password_credentials_username": ssh_pw_row[1] if ssh_pw_row else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/rdp-credentials")
|
# --- RDP-Zugangsdatenverwaltung (Migration 0012) ------------------------------
|
||||||
async def list_rdp_credentials(admin: CurrentUser = Depends(require_admin_or_scope("hosts", "read"))):
|
#
|
||||||
"""Uebersicht aller RDP/Windows-Hosts fuer den 'Zugangsdaten'-Tab: welche
|
# Eigenstaendige, wiederverwendbare Objekte -- strukturell und in der
|
||||||
haben bereits ein Passwort hinterlegt, wann zuletzt gesetzt."""
|
# 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()
|
conn = get_db()
|
||||||
scope = _scope(admin)
|
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(
|
cursor = await conn.execute(
|
||||||
"SELECT h.id, h.hostname, h.address, hg.name, rc.updated_at, rc.username, rc.domain "
|
"INSERT INTO rdp_credentials (label, username, domain, password_enc, tenant_id) "
|
||||||
"FROM hosts h JOIN host_groups hg ON hg.id = h.host_group_id "
|
"VALUES (?, ?, ?, ?, ?)",
|
||||||
"LEFT JOIN rdp_credentials rc ON rc.host_id = h.id "
|
(payload.label, payload.username.strip(), domain, encrypted, tenant_id),
|
||||||
f"WHERE h.protocol = 'rdp'{tenant_filter} ORDER BY h.hostname",
|
)
|
||||||
|
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,
|
params,
|
||||||
)
|
)
|
||||||
rows = await cursor.fetchall()
|
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 [
|
return [
|
||||||
{
|
{
|
||||||
"host_id": r[0], "hostname": r[1], "address": r[2], "host_group_name": r[3],
|
"id": r[0], "label": r[1], "username": r[2], "domain": r[3],
|
||||||
"credentials_set": r[4] is not None, "updated_at": r[4],
|
"tenant_id": r[4], "tenant_name": r[5],
|
||||||
"username": r[5], "domain": r[6],
|
"created_at": r[6], "rotated_at": r[7],
|
||||||
|
"assigned_hosts": assigned.get(r[0], []),
|
||||||
}
|
}
|
||||||
for r in rows
|
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) -----------------------------------------
|
# --- Rollenvergabe (an einzelne User) -----------------------------------------
|
||||||
|
|
||||||
async def _role_id(conn, role_name: str) -> int:
|
async def _role_id(conn, role_name: str) -> int:
|
||||||
|
|||||||
63
app/db/migrations/0012_rdp_credential_sets.sql
Normal file
63
app/db/migrations/0012_rdp_credential_sets.sql
Normal file
@ -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;
|
||||||
@ -255,12 +255,30 @@ class ConnectRequest(BaseModel):
|
|||||||
host_id: int
|
host_id: int
|
||||||
|
|
||||||
|
|
||||||
class RdpCredentialsRequest(BaseModel):
|
class RdpCredentialCreateRequest(BaseModel):
|
||||||
password: str = Field(min_length=1, max_length=512)
|
"""Migration 0012: RDP-Zugangsdaten sind ein eigenstaendiges,
|
||||||
# Benutzername und (optional) Domaene der Windows-Anmeldung. Seit
|
wiederverwendbares Objekt (analog SshKeyCreateRequest), das im Reiter
|
||||||
# Migration 0010 Teil der Zugangsdaten statt des Hosts.
|
'Zugangsdaten' angelegt und danach beliebigen Hosts zugewiesen wird --
|
||||||
username: str | None = Field(default=None, max_length=128)
|
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)
|
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):
|
class SshPasswordCredentialsRequest(BaseModel):
|
||||||
|
|||||||
@ -176,9 +176,13 @@ async def rdp_tunnel(
|
|||||||
|
|
||||||
# username/domain stehen seit Migration 0010 bei den Zugangsdaten und nicht
|
# username/domain stehen seit Migration 0010 bei den Zugangsdaten und nicht
|
||||||
# mehr am Host -- der Benutzername gehoert zur Anmeldung, nicht zur
|
# 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(
|
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,),
|
(host_id,),
|
||||||
)
|
)
|
||||||
cred_row = await cred_cursor.fetchone()
|
cred_row = await cred_cursor.fetchone()
|
||||||
|
|||||||
@ -22,6 +22,11 @@ SCOPE_RESOURCES = (
|
|||||||
"host_groups",
|
"host_groups",
|
||||||
"hosts",
|
"hosts",
|
||||||
"ssh_keys",
|
"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",
|
"roles",
|
||||||
"user_groups",
|
"user_groups",
|
||||||
"audit",
|
"audit",
|
||||||
|
|||||||
@ -404,7 +404,14 @@ async def _verified_host_key(
|
|||||||
observed_key = await asyncssh.get_server_host_key(
|
observed_key = await asyncssh.get_server_host_key(
|
||||||
host["address"], port=host["port"], connect_timeout=10
|
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
|
raise HostKeyDiscoveryError(host["id"], str(exc)) from exc
|
||||||
if observed_key is None:
|
if observed_key is None:
|
||||||
raise HostKeyDiscoveryError(host["id"], "Das Ziel hat keinen Host-Key gesendet")
|
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
|
Jetzt wird asyncssh.get_server_host_key() verwendet: das fuehrt nur den
|
||||||
Key-Exchange durch, versucht gar keine Anmeldung und liefert den
|
Key-Exchange durch, versucht gar keine Anmeldung und liefert den
|
||||||
Schluessel direkt zurueck. Ein Auth-Fehler kann die Ermittlung damit
|
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)
|
host = await load_host(conn, host_id)
|
||||||
if host["protocol"] != "ssh":
|
if host["protocol"] != "ssh":
|
||||||
raise HostNotConfiguredError("Host-Key-Ermittlung ist nur fuer SSH-Ziele moeglich")
|
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(
|
key = await asyncssh.get_server_host_key(
|
||||||
host["address"], port=host["port"], connect_timeout=10
|
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)
|
logger.warning("Host-Key-Ermittlung fuer Host %s fehlgeschlagen: %s", host_id, exc)
|
||||||
raise HostKeyDiscoveryError(host_id, str(exc)) from exc
|
raise HostKeyDiscoveryError(host_id, str(exc)) from exc
|
||||||
if key is None:
|
if key is None:
|
||||||
|
|||||||
@ -399,13 +399,14 @@
|
|||||||
let cachedHostGroups = [];
|
let cachedHostGroups = [];
|
||||||
let cachedHosts = [];
|
let cachedHosts = [];
|
||||||
let cachedSshKeys = [];
|
let cachedSshKeys = [];
|
||||||
|
let cachedRdpCredentials = [];
|
||||||
let activeHostId = null;
|
let activeHostId = null;
|
||||||
let editingHostGroupId = null;
|
let editingHostGroupId = null;
|
||||||
|
|
||||||
async function loadHostsTab() {
|
async function loadHostsTab() {
|
||||||
document.getElementById("hgc-tenant-box").classList.toggle("hidden", !meInfo.is_admin && meInfo.tenant_admin_of.length <= 1);
|
document.getElementById("hgc-tenant-box").classList.toggle("hidden", !meInfo.is_admin && meInfo.tenant_admin_of.length <= 1);
|
||||||
await populateTenantSelect(document.getElementById("hgc-tenant"));
|
await populateTenantSelect(document.getElementById("hgc-tenant"));
|
||||||
await Promise.all([refreshHostGroups(), refreshSshKeysCache()]);
|
await Promise.all([refreshHostGroups(), refreshSshKeysCache(), refreshRdpCredentialsCache()]);
|
||||||
await refreshHosts();
|
await refreshHosts();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -496,6 +497,10 @@
|
|||||||
cachedSshKeys = await getJson("/admin/ssh-keys");
|
cachedSshKeys = await getJson("/admin/ssh-keys");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshRdpCredentialsCache() {
|
||||||
|
cachedRdpCredentials = await getJson("/admin/rdp-credentials");
|
||||||
|
}
|
||||||
|
|
||||||
function setDetailBanner(message, type) {
|
function setDetailBanner(message, type) {
|
||||||
const box = document.getElementById("host-detail-banner");
|
const box = document.getElementById("host-detail-banner");
|
||||||
if (!message) {
|
if (!message) {
|
||||||
@ -512,9 +517,9 @@
|
|||||||
return `Anmeldung als "${host.rdp_credentials_username}"${domain} (aus den RDP-Zugangsdaten unten).`;
|
return `Anmeldung als "${host.rdp_credentials_username}"${domain} (aus den RDP-Zugangsdaten unten).`;
|
||||||
}
|
}
|
||||||
if (host.rdp_username) {
|
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);
|
const named = (host.ssh_keys || []).filter((k) => k.username);
|
||||||
if (named.length) {
|
if (named.length) {
|
||||||
@ -590,10 +595,11 @@
|
|||||||
document.getElementById("hspw-username").value = host.ssh_password_credentials_username || "";
|
document.getElementById("hspw-username").value = host.ssh_password_credentials_username || "";
|
||||||
|
|
||||||
document.getElementById("rdp-cred-status").textContent = host.rdp_credentials_set
|
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}).`
|
? `Zugewiesen: "${host.rdp_credentials_label}" -- Anmeldung als "${host.rdp_credentials_username || "(kein Benutzername)"}" (zuletzt aktualisiert: ${host.rdp_credentials_updated_at}).`
|
||||||
: "Noch keine RDP-Zugangsdaten gesetzt.";
|
: "Noch keine RDP-Zugangsdaten zugewiesen.";
|
||||||
document.getElementById("hrc-username").value = host.rdp_credentials_username || host.rdp_username || "";
|
const rdpSelect = document.getElementById("hrcm-select");
|
||||||
document.getElementById("hrc-domain").value = host.rdp_credentials_domain || host.rdp_domain || "";
|
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) {
|
} catch (err) {
|
||||||
setDetailBanner(err.message, "error");
|
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();
|
ev.preventDefault();
|
||||||
try {
|
try {
|
||||||
const password = document.getElementById("hrc-password").value;
|
const credentialId = document.getElementById("hrcm-select").value;
|
||||||
const username = document.getElementById("hrc-username").value.trim();
|
if (!credentialId) {
|
||||||
const domain = document.getElementById("hrc-domain").value.trim() || null;
|
showBanner('Bitte zuerst im Reiter "Zugangsdaten" ein RDP-Zugangsdaten-Objekt anlegen.', "error");
|
||||||
await sendJson(`/admin/hosts/${activeHostId}/rdp-credentials`, "PUT", { password, username, domain });
|
return;
|
||||||
showBanner("RDP-Zugangsdaten gespeichert.", "ok");
|
}
|
||||||
ev.target.reset();
|
await sendJson(`/admin/hosts/${activeHostId}/rdp-credentials/${credentialId}`, "POST", {});
|
||||||
|
showBanner("RDP-Zugangsdaten zugewiesen.", "ok");
|
||||||
await showHostDetail(activeHostId);
|
await showHostDetail(activeHostId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setDetailBanner(err.message, "error");
|
setDetailBanner(err.message, "error");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById("hrc-delete-btn").addEventListener("click", async () => {
|
document.getElementById("hrc-unassign-btn").addEventListener("click", async () => {
|
||||||
try {
|
try {
|
||||||
await apiFetch(`/admin/hosts/${activeHostId}/rdp-credentials`, { method: "DELETE" });
|
await apiFetch(`/admin/hosts/${activeHostId}/rdp-credentials`, { method: "DELETE" });
|
||||||
showBanner("RDP-Zugangsdaten geloescht.", "ok");
|
showBanner("Zuordnung entfernt.", "ok");
|
||||||
await showHostDetail(activeHostId);
|
await showHostDetail(activeHostId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setDetailBanner(err.message, "error");
|
setDetailBanner(err.message, "error");
|
||||||
@ -754,7 +761,11 @@
|
|||||||
|
|
||||||
async function loadCredentialsTab() {
|
async function loadCredentialsTab() {
|
||||||
document.getElementById("skc-tenant-box").classList.toggle("hidden", !meInfo.is_admin && meInfo.tenant_admin_of.length <= 1);
|
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()]);
|
await Promise.all([refreshSshKeys(), refreshRdpCredentials()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -908,37 +919,102 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let editingRdpCredentialId = null;
|
||||||
|
|
||||||
async function refreshRdpCredentials() {
|
async function refreshRdpCredentials() {
|
||||||
const rows = await getJson("/admin/rdp-credentials");
|
cachedRdpCredentials = await getJson("/admin/rdp-credentials");
|
||||||
const tbody = document.querySelector("#rdp-creds-table tbody");
|
const tbody = document.querySelector("#rdp-creds-table tbody");
|
||||||
fillTable(
|
fillTable(
|
||||||
tbody,
|
tbody,
|
||||||
rows.map((r) =>
|
cachedRdpCredentials.map((r) =>
|
||||||
el("tr", {}, [
|
el("tr", {}, [
|
||||||
el("td", { textContent: r.hostname }),
|
el("td", { textContent: String(r.id) }),
|
||||||
el("td", { textContent: r.address }),
|
el("td", { textContent: r.label }),
|
||||||
el("td", { textContent: r.host_group_name }),
|
|
||||||
el("td", { textContent: r.username || "-" }),
|
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.domain || "-" }),
|
||||||
el("td", { textContent: r.updated_at || "-" }),
|
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", {}, [
|
el("td", {}, [
|
||||||
actionButton("Zum Host", "btn-secondary", async () => {
|
actionButton("Bearbeiten", "btn-secondary", () => showRdpCredentialEdit(r)),
|
||||||
document.querySelector('.tab-btn[data-tab="servers"]').click();
|
actionButton("Loeschen", "btn-danger", async () => {
|
||||||
await showHostDetail(r.host_id);
|
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
|
// Rollen
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|||||||
@ -325,23 +325,15 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="host-detail-rdp" class="hidden">
|
<div id="host-detail-rdp" class="hidden">
|
||||||
<h3>RDP-Zugangsdaten</h3>
|
<h3>RDP-Zugangsdaten</h3>
|
||||||
|
<p class="hint">RDP-Zugangsdaten sind eigenstaendige, wiederverwendbare Objekte -- angelegt und bearbeitet im Reiter "Zugangsdaten". Hier wird nur zugewiesen, welches Objekt fuer diesen Server gilt.</p>
|
||||||
<p class="hint" id="rdp-cred-status"></p>
|
<p class="hint" id="rdp-cred-status"></p>
|
||||||
<form id="host-rdp-cred-form" class="form-grid">
|
<form id="host-rdp-cred-map-form" class="form-grid">
|
||||||
<div>
|
<div>
|
||||||
<label for="hrc-username">Windows-Benutzername</label>
|
<label for="hrcm-select">RDP-Zugangsdaten zuweisen</label>
|
||||||
<input type="text" id="hrc-username" required maxlength="128" autocomplete="off">
|
<select id="hrcm-select" required></select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<button type="submit">Zuweisen</button>
|
||||||
<label for="hrc-domain">Domaene (optional)</label>
|
<button type="button" class="btn-danger" id="hrc-unassign-btn">Zuordnung entfernen</button>
|
||||||
<input type="text" id="hrc-domain" maxlength="128" autocomplete="off">
|
|
||||||
</div>
|
|
||||||
<div class="full-width">
|
|
||||||
<label for="hrc-password">RDP-Passwort setzen/aendern</label>
|
|
||||||
<input type="text" id="hrc-password" required maxlength="512">
|
|
||||||
</div>
|
|
||||||
<p class="hint full-width">Benutzername, Domaene und Passwort gehoeren zusammen und werden gemeinsam gespeichert. Das Passwort wird AES-256-GCM-verschluesselt abgelegt und nie wieder ausgegeben; beim Aendern bitte alle drei Felder ausfuellen.</p>
|
|
||||||
<button type="submit">Speichern</button>
|
|
||||||
<button type="button" class="btn-danger" id="hrc-delete-btn">Loeschen</button>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -456,15 +448,68 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<h2>RDP/Windows-Passwoerter</h2>
|
<h2>Neue RDP-Zugangsdaten anlegen</h2>
|
||||||
<p class="hint">Uebersicht aller RDP-Hosts. Passwoerter werden AES-256-GCM-verschluesselt gespeichert und sind nach dem Setzen nicht mehr im Klartext abrufbar.</p>
|
<p class="hint">Eigenstaendiges, wiederverwendbares Objekt (analog einem SSH-Key) -- kann nach dem Anlegen im Reiter "Server" beliebigen Hosts zugewiesen werden, statt es je Server neu einzutippen.</p>
|
||||||
|
<form id="rdp-cred-create-form" class="form-grid">
|
||||||
|
<div>
|
||||||
|
<label for="rcc-label">Label</label>
|
||||||
|
<input type="text" id="rcc-label" required maxlength="128">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="rcc-username">Windows-Benutzername</label>
|
||||||
|
<input type="text" id="rcc-username" required maxlength="128" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="rcc-domain">Domaene (optional)</label>
|
||||||
|
<input type="text" id="rcc-domain" maxlength="128" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div id="rcc-tenant-box">
|
||||||
|
<label for="rcc-tenant">Mandant</label>
|
||||||
|
<select id="rcc-tenant" required></select>
|
||||||
|
</div>
|
||||||
|
<div class="full-width">
|
||||||
|
<label for="rcc-password">Passwort</label>
|
||||||
|
<input type="text" id="rcc-password" required maxlength="512">
|
||||||
|
</div>
|
||||||
|
<p class="hint full-width">Das Passwort wird AES-256-GCM-verschluesselt gespeichert und ist nach dem Anlegen nicht mehr im Klartext abrufbar.</p>
|
||||||
|
<button type="submit">Anlegen</button>
|
||||||
|
</form>
|
||||||
|
<h3>Vorhandene RDP-Zugangsdaten</h3>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table class="data-table" id="rdp-creds-table">
|
<table class="data-table" id="rdp-creds-table">
|
||||||
<thead><tr><th>Host</th><th>Adresse</th><th>Hostgruppe</th><th>Benutzer</th><th>Status</th><th>Zuletzt gesetzt</th><th></th></tr></thead>
|
<thead><tr><th>ID</th><th>Label</th><th>Benutzer</th><th>Domaene</th><th>Mandant</th><th>Zugewiesene Server</th><th>Zuletzt rotiert</th><th></th></tr></thead>
|
||||||
<tbody></tbody>
|
<tbody></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="panel hidden" id="rdp-cred-edit-panel">
|
||||||
|
<h2>RDP-Zugangsdaten bearbeiten: <span id="rdp-cred-edit-name"></span></h2>
|
||||||
|
<form id="rdp-cred-edit-form" class="form-grid">
|
||||||
|
<div>
|
||||||
|
<label for="rce-label">Label</label>
|
||||||
|
<input type="text" id="rce-label" maxlength="128">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="rce-username">Windows-Benutzername</label>
|
||||||
|
<input type="text" id="rce-username" maxlength="128" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="rce-domain">Domaene</label>
|
||||||
|
<input type="text" id="rce-domain" maxlength="128" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<button type="submit">Speichern (Label/Benutzer/Domaene)</button>
|
||||||
|
</form>
|
||||||
|
<h3>Passwort setzen/aendern</h3>
|
||||||
|
<p class="hint">Rotiert NUR das Passwort dieses Eintrags -- es entsteht KEIN neuer Eintrag, alle zugewiesenen Hosts nutzen danach automatisch das neue Passwort.</p>
|
||||||
|
<form id="rdp-cred-rotate-form" class="form-grid">
|
||||||
|
<div class="full-width">
|
||||||
|
<label for="rcr-password">Neues Passwort</label>
|
||||||
|
<input type="text" id="rcr-password" required maxlength="512">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-secondary">Neues Passwort speichern</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ================= Rollen ================= -->
|
<!-- ================= Rollen ================= -->
|
||||||
|
|||||||
@ -106,13 +106,23 @@ async def test_rdp_credentials_delete(client):
|
|||||||
hg_id, host_id = await _setup_hostgroup_and_host(conn, group_name="rd-group", hostname="rd-host", protocol="rdp")
|
hg_id, host_id = await _setup_hostgroup_and_host(conn, group_name="rd-group", hostname="rd-host", protocol="rdp")
|
||||||
await _login_full(client, "rd_admin", "Correct-Horse-Battery-Staple-R1")
|
await _login_full(client, "rd_admin", "Correct-Horse-Battery-Staple-R1")
|
||||||
|
|
||||||
resp = await client.put(f"/admin/hosts/{host_id}/rdp-credentials",
|
# Migration 0012: das Zugangsdaten-Objekt wird eigenstaendig angelegt und
|
||||||
json={"username": "Administrator", "password": "s3hr-geheim!!"})
|
# dann dem Host zugewiesen (POST .../rdp-credentials/{credential_id}),
|
||||||
|
# statt direkt am Host mit Passwort erzeugt zu werden.
|
||||||
|
resp = await client.post(
|
||||||
|
"/admin/rdp-credentials",
|
||||||
|
json={"label": "rd-cred", "username": "Administrator", "password": "s3hr-geheim!!", "tenant_id": 1},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
credential_id = resp.json()["id"]
|
||||||
|
resp = await client.post(f"/admin/hosts/{host_id}/rdp-credentials/{credential_id}", json={})
|
||||||
assert resp.status_code == 200, resp.text
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
resp = await client.get(f"/admin/hosts/{host_id}/credentials")
|
resp = await client.get(f"/admin/hosts/{host_id}/credentials")
|
||||||
assert resp.json()["rdp_credentials_set"] is True
|
assert resp.json()["rdp_credentials_set"] is True
|
||||||
|
|
||||||
|
# Loeschen HIER entfernt nur die Zuordnung zum Host -- das Objekt selbst
|
||||||
|
# bleibt bestehen (siehe unassign_rdp_credential_from_host).
|
||||||
resp = await client.delete(f"/admin/hosts/{host_id}/rdp-credentials")
|
resp = await client.delete(f"/admin/hosts/{host_id}/rdp-credentials")
|
||||||
assert resp.status_code == 200, resp.text
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
|||||||
@ -211,7 +211,17 @@ async def test_credentials_manage_role_grants_non_admin_write_access(client):
|
|||||||
)
|
)
|
||||||
assert resp.status_code == 200, resp.text
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
# Rolleninhaber (kein Admin!) darf Zugangsdaten lesen UND setzen.
|
# 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!!", "tenant_id": 1},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
credential_id = resp.json()["id"]
|
||||||
|
|
||||||
|
# Rolleninhaber (kein Admin!) darf Zugangsdaten lesen UND zuweisen.
|
||||||
client.cookies.clear()
|
client.cookies.clear()
|
||||||
await _login_full(client, "cr_holder", "Correct-Horse-Battery-Staple-C2")
|
await _login_full(client, "cr_holder", "Correct-Horse-Battery-Staple-C2")
|
||||||
|
|
||||||
@ -219,12 +229,7 @@ async def test_credentials_manage_role_grants_non_admin_write_access(client):
|
|||||||
assert resp.status_code == 200, resp.text
|
assert resp.status_code == 200, resp.text
|
||||||
assert resp.json()["rdp_credentials_set"] is False
|
assert resp.json()["rdp_credentials_set"] is False
|
||||||
|
|
||||||
# Benutzername gehoert seit Migration 0010 zu den Zugangsdaten und ist
|
resp = await client.post(f"/admin/hosts/{host_id}/rdp-credentials/{credential_id}", json={})
|
||||||
# beim Setzen Pflicht (siehe tests/test_phase12.py).
|
|
||||||
resp = await client.put(
|
|
||||||
f"/admin/hosts/{host_id}/rdp-credentials",
|
|
||||||
json={"password": "s3hr-geheim!!", "username": "Administrator"},
|
|
||||||
)
|
|
||||||
assert resp.status_code == 200, resp.text
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
resp = await client.get(f"/admin/hosts/{host_id}/credentials")
|
resp = await client.get(f"/admin/hosts/{host_id}/credentials")
|
||||||
@ -237,7 +242,7 @@ async def test_credentials_manage_role_grants_non_admin_write_access(client):
|
|||||||
await _login_full(client, "cr_other", "Correct-Horse-Battery-Staple-C3")
|
await _login_full(client, "cr_other", "Correct-Horse-Battery-Staple-C3")
|
||||||
resp = await client.get(f"/admin/hosts/{host_id}/credentials")
|
resp = await client.get(f"/admin/hosts/{host_id}/credentials")
|
||||||
assert resp.status_code == 403, resp.text
|
assert resp.status_code == 403, resp.text
|
||||||
resp = await client.put(f"/admin/hosts/{host_id}/rdp-credentials", json={"password": "andere-1234"})
|
resp = await client.post(f"/admin/hosts/{host_id}/rdp-credentials/{credential_id}", json={})
|
||||||
assert resp.status_code == 403, resp.text
|
assert resp.status_code == 403, resp.text
|
||||||
|
|
||||||
|
|
||||||
@ -263,8 +268,10 @@ async def test_credentials_view_role_is_read_only(client):
|
|||||||
resp = await client.get(f"/admin/hosts/{host_id}/credentials")
|
resp = await client.get(f"/admin/hosts/{host_id}/credentials")
|
||||||
assert resp.status_code == 200, resp.text
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
# 'credentials_view' allein darf NICHT schreiben.
|
# 'credentials_view' allein darf NICHT schreiben (weder zuweisen noch entfernen).
|
||||||
resp = await client.put(f"/admin/hosts/{host_id}/rdp-credentials", json={"password": "nope-12345"})
|
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
|
assert resp.status_code == 403, resp.text
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -605,10 +605,16 @@ async def test_host_detail_endpoint_includes_ssh_keys_and_rdp_flag(client):
|
|||||||
resp = await client.post(f"/admin/hosts/{host_id}/ssh-keys/{key_id}")
|
resp = await client.post(f"/admin/hosts/{host_id}/ssh-keys/{key_id}")
|
||||||
assert resp.status_code == 200, resp.text
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
resp = await client.put(
|
# Migration 0012: RDP-Zugangsdaten sind ein eigenstaendiges Objekt, das
|
||||||
f"/admin/hosts/{host_id}/rdp-credentials",
|
# separat angelegt und dann dem Host zugewiesen wird (wie ein SSH-Key).
|
||||||
json={"password": "Correct-Horse-Battery-Staple-O2", "username": "Administrator"},
|
resp = await client.post(
|
||||||
|
"/admin/rdp-credentials",
|
||||||
|
json={"label": "detail-cred", "username": "Administrator",
|
||||||
|
"password": "Correct-Horse-Battery-Staple-O2", "tenant_id": 1},
|
||||||
)
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
credential_id = resp.json()["id"]
|
||||||
|
resp = await client.post(f"/admin/hosts/{host_id}/rdp-credentials/{credential_id}", json={})
|
||||||
assert resp.status_code == 200, resp.text
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
resp = await client.get(f"/admin/hosts/{host_id}")
|
resp = await client.get(f"/admin/hosts/{host_id}")
|
||||||
@ -617,11 +623,12 @@ async def test_host_detail_endpoint_includes_ssh_keys_and_rdp_flag(client):
|
|||||||
assert data["hostname"] == "win-srv"
|
assert data["hostname"] == "win-srv"
|
||||||
assert any(k["id"] == key_id for k in data["ssh_keys"])
|
assert any(k["id"] == key_id for k in data["ssh_keys"])
|
||||||
assert data["rdp_credentials_set"] is True
|
assert data["rdp_credentials_set"] is True
|
||||||
|
assert data["rdp_credentials_id"] == credential_id
|
||||||
|
|
||||||
resp = await client.get("/admin/rdp-credentials")
|
resp = await client.get("/admin/rdp-credentials")
|
||||||
assert resp.status_code == 200, resp.text
|
assert resp.status_code == 200, resp.text
|
||||||
row = next(r for r in resp.json() if r["host_id"] == host_id)
|
row = next(r for r in resp.json() if r["id"] == credential_id)
|
||||||
assert row["credentials_set"] is True
|
assert any(h["id"] == host_id for h in row["assigned_hosts"])
|
||||||
|
|
||||||
resp = await client.get("/admin/hosts/424242")
|
resp = await client.get("/admin/hosts/424242")
|
||||||
assert resp.status_code == 404
|
assert resp.status_code == 404
|
||||||
|
|||||||
Reference in New Issue
Block a user