umbau 1.0

This commit is contained in:
2026-09-02 20:30:44 +02:00
parent afe6719f51
commit 5c95b21be7
77 changed files with 10733 additions and 1914 deletions

View File

@ -27,7 +27,7 @@ Tests fuer Phase 13 (diese Session -- Sammel-Feedback aus dem Live-Test):
Sitzungs-Wiedergabe (volle grafische RDP-Wiedergabe + SSH-Textwiedergabe):
GET /admin/sessions/{id}/recording/entries -- nur require_global_admin
(Mandanten-Admins bekommen 403, genau wie beim bereits bestehenden
(nicht-Admin-Benutzer bekommen 403, genau wie beim bereits bestehenden
GET /admin/sessions/{id}/recording), liefert die entschluesselten/rohen
Eintraege NICHT den Klartext einer Passphrase o.ae., sondern ausschliesslich
die bereits im Klartext aufgezeichneten Terminal-/Guacamole-Stroeme (siehe
@ -93,6 +93,25 @@ async def _setup_hostgroup_and_host(conn, *, group_name="p13-group", hostname="p
return hg_id, cursor.lastrowid
async def _grant_group_role(conn, *, user_id: int, host_group_id: int, role_names, group_name: str) -> int:
"""Teil D Schritt 4/5: siehe gleichnamige Hilfsfunktion in
tests/test_phase9.py -- direkte Rollenvergabe an einzelne Benutzer ist
entfallen, POST /admin/roles/grant antwortet seit Schritt 5 mit 410."""
cursor = await conn.execute("INSERT INTO user_groups (name) VALUES (?)", (group_name,))
group_id = cursor.lastrowid
await conn.execute(
"INSERT INTO user_group_members (user_group_id, user_id) VALUES (?, ?)", (group_id, user_id)
)
for role_name in role_names:
role_row = await (await conn.execute("SELECT id FROM roles WHERE name = ?", (role_name,))).fetchone()
await conn.execute(
"INSERT INTO group_hostgroup_roles (user_group_id, host_group_id, role_id) VALUES (?, ?, ?)",
(group_id, host_group_id, role_row[0]),
)
await conn.commit()
return group_id
# ---------------------------------------------------------------------------
# 1) RDP-Zugangsdaten: Loeschen
# ---------------------------------------------------------------------------
@ -111,7 +130,7 @@ async def test_rdp_credentials_delete(client):
# 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},
json={"label": "rd-cred", "username": "Administrator", "password": "s3hr-geheim!!"},
)
assert resp.status_code == 201, resp.text
credential_id = resp.json()["id"]
@ -167,7 +186,16 @@ async def test_ssh_password_credentials_set_and_delete(client):
assert body["ssh_password_credentials_set"] is True
assert body["ssh_password_credentials_username"] == "l4u"
# Der Klartext des Passworts wird in KEINER Antwort zurueckgegeben.
assert "password" not in json.dumps(body)
#
# Bug-Fix (FORTSETZUNG_Teil_C.md Abschnitt 3 Punkt 5): die alte Pruefung
# `assert "password" not in json.dumps(body)` war ein falscher Alarm --
# sie schlug schon auf die FELDNAMEN selbst an
# (ssh_password_credentials_set/-_username enthalten die Zeichenkette
# "password"), nicht auf einen tatsaechlichen Klartext-Leak. Was hier
# eigentlich sichergestellt werden soll, ist dass der konkrete
# Passwort-WERT ("sehr-geheimes-passwort", oben via PUT gesetzt) nicht
# im JSON auftaucht -- das ist die tatsaechliche Sicherheitseigenschaft.
assert "sehr-geheimes-passwort" not in json.dumps(body)
assert "sehr-geheimes-passwort" not in json.dumps(body)
# Erneutes Setzen ist ein Upsert (ON CONFLICT), kein Duplikat/Fehler.
@ -196,11 +224,10 @@ async def test_ssh_password_credentials_via_credentials_manage_role(client):
hg_id, host_id = await _setup_hostgroup_and_host(conn, group_name="sp2-group", hostname="sp2-host")
await _login_full(client, "sp2_admin", "Correct-Horse-Battery-Staple-P2")
resp = await client.post(
"/admin/roles/grant",
json={"user_id": holder_id, "host_group_id": hg_id, "role_names": ["credentials_manage"]},
await _grant_group_role(
conn, user_id=holder_id, host_group_id=hg_id, role_names=["credentials_manage"],
group_name="sp2-holder-team",
)
assert resp.status_code == 200, resp.text
client.cookies.clear()
await _login_full(client, "sp2_holder", "Correct-Horse-Battery-Staple-P3")
@ -242,7 +269,7 @@ async def test_ssh_key_owner_field_is_gone(client):
# siehe static/js/admin.js::refreshSshKeys()).
assert set(rows[0]) == {
"id", "label", "key_type", "created_at", "rotated_at", "expires_at",
"tenant_id", "tenant_name", "has_passphrase", "username",
"has_passphrase", "username",
} | {"username"} # (Mengen sind idempotent -- nur zur Lesbarkeit doppelt genannt)
resp = await client.put(f"/admin/ssh-keys/{key_id}", json={"label": "umbenannt", "owner_user_id": 999})
@ -314,22 +341,12 @@ async def test_session_recording_entries_requires_global_admin(client):
from app.db import get_db
conn = get_db()
# Mandanten-Admin (Tenant-gebunden ueber tenant_admins, kein globaler
# Super-Admin) darf NICHT -- dieser Endpunkt liefert vollstaendigen
# Klartext-Mitschnitt und ist bewusst auf require_global_admin
# beschraenkt (siehe Docstring des Endpunkts in app/admin/routes.py).
# Aufbau eines Mandanten-Admins wie in tests/test_tenants.py: ein
# Super-Admin legt einen Mandanten an und traegt einen (sonst nicht
# privilegierten) Benutzer als dessen Tenant-Admin ein.
super_id = await _create_user(conn, "rec_bootstrap_super", "Correct-Horse-Battery-Staple-N0", is_admin=True)
await _login_full(client, "rec_bootstrap_super", "Correct-Horse-Battery-Staple-N0")
resp = await client.post("/admin/tenants", json={"name": "Kunde Rec"})
assert resp.status_code == 201, resp.text
tenant_id = resp.json()["id"]
tenant_admin_id = await _create_user(conn, "rec_tenant_admin", "Correct-Horse-Battery-Staple-N1")
resp = await client.post(f"/admin/tenants/{tenant_id}/admins", json={"user_id": tenant_admin_id})
assert resp.status_code == 201, resp.text
client.cookies.clear()
# Ein normaler (nicht-admin) Benutzer darf NICHT -- dieser Endpunkt
# liefert vollstaendigen Klartext-Mitschnitt und ist bewusst auf
# require_global_admin beschraenkt (siehe Docstring des Endpunkts in
# app/admin/routes.py). Es gibt keine Zwischenstufe mehr (frueher:
# Mandanten-Admin) -- nur noch is_admin ja/nein.
normal_user_id = await _create_user(conn, "rec_normal_user", "Correct-Horse-Battery-Staple-N1")
hg_id, host_id = await _setup_hostgroup_and_host(conn, group_name="rec-group", hostname="rec-host")
user_id = await _create_user(conn, "rec_user", "Correct-Horse-Battery-Staple-N2")
@ -340,7 +357,7 @@ async def test_session_recording_entries_requires_global_admin(client):
session_id = cursor.lastrowid
await conn.commit()
await _login_full(client, "rec_tenant_admin", "Correct-Horse-Battery-Staple-N1")
await _login_full(client, "rec_normal_user", "Correct-Horse-Battery-Staple-N1")
resp = await client.get(f"/admin/sessions/{session_id}/recording/entries")
assert resp.status_code == 403, resp.text
@ -367,7 +384,14 @@ async def test_session_recording_entries_ssh_playback_and_audit(client):
recorder.record("input", "ls\n")
recorder.record("output", "total 0\n")
recorder.record("output", "$ ")
recorder.close()
# Bug-Fix (FORTSETZUNG_Teil_C.md Abschnitt 3 Punkt 4): SessionRecorder
# kennt nur ein async aclose() (app/recordings/recorder.py) -- ein
# synchrones close() existiert nicht (und wuerde den Hintergrund-
# Flush-Task auch nicht sauber beenden/abwarten). aclose() muss
# zwingend awaited werden, sonst ist die JSONL-Datei beim
# anschliessenden Lesen ueber /admin/sessions/.../recording/entries
# noch nicht vollstaendig geschrieben.
await recorder.aclose()
await conn.execute("UPDATE sessions SET recording_path = ? WHERE id = ?", (str(recorder.path), session_id))
await conn.commit()