umbau 1.0
This commit is contained in:
@ -1,23 +1,21 @@
|
||||
"""
|
||||
Tests fuer Mandantenfaehigkeit (volle Isolation + Mandanten-Admins, siehe
|
||||
app/tenancy.py, app/db/migrations/0006_tenants.sql) sowie fuer die
|
||||
CRUD-Vervollstaendigung (Edit/Delete fuer alles, was die Admin-API anlegt,
|
||||
siehe 0007_crud_extras.sql) und die Mehrfachauswahl bei Rollenvergaben
|
||||
(role_names: list statt role_name: str).
|
||||
Tests fuer die CRUD-Vervollstaendigung (Edit/Delete fuer alles, was die
|
||||
Admin-API anlegt, siehe 0007_crud_extras.sql) und die Mehrfachauswahl bei
|
||||
Rollenvergaben (role_names: list statt role_name: str).
|
||||
|
||||
Vormals (bis Teil C des Umsetzungsauftrags) enthielt diese Datei zusaetzlich
|
||||
Tests fuer die inzwischen vollstaendig entfernte Mandantenfaehigkeit
|
||||
(app/tenancy.py, 0006_tenants.sql) -- siehe app/db/migrations/
|
||||
0014_drop_tenants.sql fuer den Rueckbau. Diese Datei heisst deshalb nicht
|
||||
mehr test_tenants.py.
|
||||
|
||||
Deckt ab:
|
||||
1) Tenant-CRUD ist ausschliesslich Super-Admin-Aktion.
|
||||
2) Ein Mandanten-Admin sieht/aendert NUR Ressourcen seines eigenen
|
||||
Mandanten -- Zugriff auf eine fremde Mandanten-ID liefert 404 (nicht 403,
|
||||
bewusst um Existenz nicht zu verraten, siehe app/tenancy.py).
|
||||
3) Der Super-Admin (Alt-Verhalten, Mandanten-uebergreifend) bleibt
|
||||
vollstaendig funktionsfaehig -- keine Regression durch Mandantenfaehigkeit.
|
||||
4) Mehrfachauswahl bei Rollenvergabe (role_names) funktioniert fuer
|
||||
1) Mehrfachauswahl bei Rollenvergabe (role_names) funktioniert fuer
|
||||
Einzel-User UND Benutzergruppen.
|
||||
5) Update/Delete-Endpunkte fuer User, Hostgruppen, Hosts, SSH-Keys,
|
||||
2) Update/Delete-Endpunkte fuer User, Hostgruppen, Hosts, SSH-Keys,
|
||||
Benutzergruppen funktionieren wie vorgesehen (inkl. Anonymisieren statt
|
||||
Hard-Delete bei vorhandener Audit-Historie, Soft-Delete bei Hosts).
|
||||
6) GET /admin/hosts/{id} (Grundlage fuer den "Details"-Fix) liefert den
|
||||
3) GET /admin/hosts/{id} (Grundlage fuer den "Details"-Fix) liefert den
|
||||
vollstaendigen aktuellen Datensatz inkl. SSH-Key-Zuordnungen.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@ -35,8 +33,8 @@ def _key_pem() -> str:
|
||||
Seit Phase 10 prueft POST/PUT /admin/ssh-keys das Material sofort gegen
|
||||
asyncssh (damit ein unbrauchbarer Schluessel nicht erst beim ersten
|
||||
Verbindungsversuch auffaellt). Platzhalter wie "PEM" werden deshalb --
|
||||
voellig korrekt -- mit HTTP 400 abgelehnt; diese Tests hier pruefen aber
|
||||
Mandantenisolation und CRUD und brauchen einen gueltigen Schluessel.
|
||||
voellig korrekt -- mit HTTP 400 abgelehnt; diese Tests hier pruefen CRUD
|
||||
und brauchen einen gueltigen Schluessel.
|
||||
"""
|
||||
return ed25519.Ed25519PrivateKey.generate().private_bytes(
|
||||
serialization.Encoding.PEM,
|
||||
@ -45,13 +43,13 @@ def _key_pem() -> str:
|
||||
).decode()
|
||||
|
||||
|
||||
async def _create_user(conn, username: str, password: str, *, is_admin: bool = False, home_tenant_id=None) -> int:
|
||||
async def _create_user(conn, username: str, password: str, *, is_admin: bool = False) -> int:
|
||||
from app.security.passwords import hash_password
|
||||
|
||||
cursor = await conn.execute(
|
||||
"INSERT INTO users (username, password_hash, is_admin, must_change_password, home_tenant_id) "
|
||||
"VALUES (?, ?, ?, 0, ?)",
|
||||
(username, hash_password(password), int(is_admin), home_tenant_id),
|
||||
"INSERT INTO users (username, password_hash, is_admin, must_change_password) "
|
||||
"VALUES (?, ?, ?, 0)",
|
||||
(username, hash_password(password), int(is_admin)),
|
||||
)
|
||||
await conn.commit()
|
||||
return cursor.lastrowid
|
||||
@ -71,9 +69,9 @@ async def _login_full(client, username: str, password: str) -> str:
|
||||
return resp.cookies.get("jh_session")
|
||||
|
||||
|
||||
async def _setup_hostgroup_and_host(conn, *, tenant_id=1, group_name="team-a", hostname="srv-a"):
|
||||
async def _setup_hostgroup_and_host(conn, *, group_name="team-a", hostname="srv-a"):
|
||||
cursor = await conn.execute(
|
||||
"INSERT INTO host_groups (name, tenant_id) VALUES (?, ?)", (group_name, tenant_id)
|
||||
"INSERT INTO host_groups (name) VALUES (?)", (group_name,)
|
||||
)
|
||||
hg_id = cursor.lastrowid
|
||||
cursor = await conn.execute(
|
||||
@ -86,252 +84,19 @@ async def _setup_hostgroup_and_host(conn, *, tenant_id=1, group_name="team-a", h
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1) Tenant-CRUD: Super-Admin only
|
||||
# 1) Mehrfachauswahl bei Rollenvergabe (role_names)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tenant_crud_requires_super_admin(client):
|
||||
from app.db import get_db
|
||||
|
||||
conn = get_db()
|
||||
await _create_user(conn, "sa1", "Correct-Horse-Battery-Staple-A1", is_admin=True)
|
||||
await _login_full(client, "sa1", "Correct-Horse-Battery-Staple-A1")
|
||||
|
||||
resp = await client.post("/admin/tenants", json={"name": "Kunde A", "description": "Erster MSP-Kunde"})
|
||||
assert resp.status_code == 201, resp.text
|
||||
tenant_id = resp.json()["id"]
|
||||
|
||||
resp = await client.get("/admin/tenants")
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert any(t["id"] == tenant_id for t in resp.json())
|
||||
|
||||
resp = await client.put(f"/admin/tenants/{tenant_id}", json={"description": "Aktualisiert"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["changed"] is True
|
||||
|
||||
# Nicht-Admin darf nicht mal lesen.
|
||||
client.cookies.clear()
|
||||
await _create_user(conn, "plain_t", "Correct-Horse-Battery-Staple-A2")
|
||||
await _login_full(client, "plain_t", "Correct-Horse-Battery-Staple-A2")
|
||||
resp = await client.get("/admin/tenants")
|
||||
assert resp.status_code == 403
|
||||
resp = await client.post("/admin/tenants", json={"name": "sollte nicht klappen"})
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tenant_admin_cannot_perform_tenant_crud(client):
|
||||
"""Ein Mandanten-Admin ist is_any_admin=True, aber NICHT is_admin -- Tenant-
|
||||
CRUD haengt an require_global_admin (is_admin), nicht require_admin_session."""
|
||||
from app.db import get_db
|
||||
|
||||
conn = get_db()
|
||||
sa_id = await _create_user(conn, "sa2", "Correct-Horse-Battery-Staple-B1", is_admin=True)
|
||||
await _login_full(client, "sa2", "Correct-Horse-Battery-Staple-B1")
|
||||
resp = await client.post("/admin/tenants", json={"name": "Kunde B"})
|
||||
tenant_id = resp.json()["id"]
|
||||
|
||||
ta_id = await _create_user(conn, "ta_user", "Correct-Horse-Battery-Staple-B2")
|
||||
resp = await client.post(f"/admin/tenants/{tenant_id}/admins", json={"user_id": ta_id})
|
||||
assert resp.status_code == 201, resp.text
|
||||
|
||||
client.cookies.clear()
|
||||
await _login_full(client, "ta_user", "Correct-Horse-Battery-Staple-B2")
|
||||
|
||||
resp = await client.get("/admin/tenants")
|
||||
assert resp.status_code == 403
|
||||
resp = await client.post("/admin/tenants", json={"name": "sollte nicht klappen"})
|
||||
assert resp.status_code == 403
|
||||
resp = await client.post(f"/admin/tenants/{tenant_id}/admins", json={"user_id": sa_id})
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tenant_delete_blocked_while_resources_exist(client):
|
||||
from app.db import get_db
|
||||
|
||||
conn = get_db()
|
||||
await _create_user(conn, "sa3", "Correct-Horse-Battery-Staple-C1", is_admin=True)
|
||||
await _login_full(client, "sa3", "Correct-Horse-Battery-Staple-C1")
|
||||
resp = await client.post("/admin/tenants", json={"name": "Kunde C"})
|
||||
tenant_id = resp.json()["id"]
|
||||
|
||||
resp = await client.post(
|
||||
"/admin/host-groups", json={"name": "c-team", "tenant_id": tenant_id}
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
hg_id = resp.json()["id"]
|
||||
|
||||
resp = await client.delete(f"/admin/tenants/{tenant_id}")
|
||||
assert resp.status_code == 409, resp.text
|
||||
|
||||
resp = await client.delete(f"/admin/host-groups/{hg_id}")
|
||||
assert resp.status_code == 200, resp.text
|
||||
resp = await client.delete(f"/admin/tenants/{tenant_id}")
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2) Volle Isolation: Mandanten-Admin sieht/aendert nur den eigenen Mandanten
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tenant_admin_isolated_from_other_tenants_hostgroups_and_hosts(client):
|
||||
from app.db import get_db
|
||||
|
||||
conn = get_db()
|
||||
await _create_user(conn, "sa4", "Correct-Horse-Battery-Staple-D1", is_admin=True)
|
||||
await _login_full(client, "sa4", "Correct-Horse-Battery-Staple-D1")
|
||||
|
||||
resp = await client.post("/admin/tenants", json={"name": "Kunde D"})
|
||||
tenant_d = resp.json()["id"]
|
||||
|
||||
# Ressourcen im Standard-Mandanten (id=1) UND im neuen Mandanten D anlegen.
|
||||
resp = await client.post("/admin/host-groups", json={"name": "std-team", "tenant_id": 1})
|
||||
hg_std = resp.json()["id"]
|
||||
resp = await client.post("/admin/host-groups", json={"name": "d-team", "tenant_id": tenant_d})
|
||||
hg_d = resp.json()["id"]
|
||||
|
||||
resp = await client.post(
|
||||
"/admin/hosts",
|
||||
json={
|
||||
"host_group_id": hg_d, "hostname": "d-srv1", "address": "10.1.0.1",
|
||||
"protocol": "ssh", "port": 22, "os_type": "linux",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
host_d = resp.json()["id"]
|
||||
|
||||
ta_id = await _create_user(conn, "ta_d", "Correct-Horse-Battery-Staple-D2")
|
||||
resp = await client.post(f"/admin/tenants/{tenant_d}/admins", json={"user_id": ta_id})
|
||||
assert resp.status_code == 201, resp.text
|
||||
|
||||
client.cookies.clear()
|
||||
await _login_full(client, "ta_d", "Correct-Horse-Battery-Staple-D2")
|
||||
|
||||
# Host-Gruppen-Liste: nur der eigene Mandant ist sichtbar.
|
||||
resp = await client.get("/admin/host-groups")
|
||||
assert resp.status_code == 200, resp.text
|
||||
ids = {hg["id"] for hg in resp.json()}
|
||||
assert hg_d in ids
|
||||
assert hg_std not in ids
|
||||
|
||||
# Direkter Zugriff auf eine fremde Hostgruppen-ID -> 404 (nicht 403).
|
||||
resp = await client.put(f"/admin/host-groups/{hg_std}", json={"name": "hack"})
|
||||
assert resp.status_code == 404, resp.text
|
||||
resp = await client.delete(f"/admin/host-groups/{hg_std}")
|
||||
assert resp.status_code == 404, resp.text
|
||||
|
||||
# Eigener Host ist sichtbar und aenderbar.
|
||||
resp = await client.get(f"/admin/hosts/{host_d}")
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["hostname"] == "d-srv1"
|
||||
|
||||
# Ein Host im Standard-Mandanten (existiert nicht -- wird nicht angelegt,
|
||||
# aber ID 999999 simuliert "fremde/unbekannte ID" -> ebenfalls 404).
|
||||
resp = await client.get("/admin/hosts/999999")
|
||||
assert resp.status_code == 404
|
||||
|
||||
# Anlegen einer Hostgruppe OHNE tenant_id wird automatisch auf den
|
||||
# eigenen (einzigen) Mandanten erzwungen, Client-Angaben eines fremden
|
||||
# Mandanten werden ignoriert/abgelehnt.
|
||||
resp = await client.post("/admin/host-groups", json={"name": "d-team-2"})
|
||||
assert resp.status_code == 201, resp.text
|
||||
resp = await client.get("/admin/host-groups")
|
||||
new_ids = {hg["id"]: hg["tenant_id"] for hg in resp.json()}
|
||||
assert new_ids[resp.json()[-1]["id"]] == tenant_d
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tenant_admin_isolated_from_other_tenants_users_and_ssh_keys(client):
|
||||
from app.db import get_db
|
||||
|
||||
conn = get_db()
|
||||
await _create_user(conn, "sa5", "Correct-Horse-Battery-Staple-E1", is_admin=True)
|
||||
await _login_full(client, "sa5", "Correct-Horse-Battery-Staple-E1")
|
||||
|
||||
resp = await client.post("/admin/tenants", json={"name": "Kunde E"})
|
||||
tenant_e = resp.json()["id"]
|
||||
|
||||
ta_id = await _create_user(conn, "ta_e", "Correct-Horse-Battery-Staple-E2")
|
||||
resp = await client.post(f"/admin/tenants/{tenant_e}/admins", json={"user_id": ta_id})
|
||||
assert resp.status_code == 201, resp.text
|
||||
|
||||
# Ein User im Standard-Mandanten (home_tenant_id=1), unsichtbar fuer ta_e.
|
||||
std_user_id = await _create_user(conn, "std_user", "Correct-Horse-Battery-Staple-E3", home_tenant_id=1)
|
||||
|
||||
resp = await client.post(
|
||||
"/admin/ssh-keys",
|
||||
json={
|
||||
"label": "std-key", "private_key_pem": _key_pem(), "public_key": "PUB",
|
||||
"key_type": "ed25519", "tenant_id": 1,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
std_key_id = resp.json()["id"]
|
||||
|
||||
client.cookies.clear()
|
||||
await _login_full(client, "ta_e", "Correct-Horse-Battery-Staple-E2")
|
||||
|
||||
resp = await client.get("/admin/users")
|
||||
assert resp.status_code == 200, resp.text
|
||||
user_ids = {u["id"] for u in resp.json()}
|
||||
assert ta_id in user_ids
|
||||
assert std_user_id not in user_ids
|
||||
|
||||
resp = await client.put(f"/admin/users/{std_user_id}", json={"is_active": False})
|
||||
assert resp.status_code == 404, resp.text
|
||||
|
||||
resp = await client.get("/admin/ssh-keys")
|
||||
key_ids = {k["id"] for k in resp.json()}
|
||||
assert std_key_id not in key_ids
|
||||
|
||||
resp = await client.put(f"/admin/ssh-keys/{std_key_id}", json={"label": "hack"})
|
||||
assert resp.status_code == 404, resp.text
|
||||
|
||||
# Eigenen SSH-Key anlegen -- ohne tenant_id automatisch auf E erzwungen.
|
||||
resp = await client.post(
|
||||
"/admin/ssh-keys",
|
||||
json={"label": "e-key", "private_key_pem": _key_pem(), "public_key": "PUB2", "key_type": "ed25519"},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
e_key_id = resp.json()["id"]
|
||||
resp = await client.get("/admin/ssh-keys")
|
||||
assert e_key_id in {k["id"] for k in resp.json()}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tenant_admin_isolated_audit_log(client):
|
||||
from app.db import get_db
|
||||
|
||||
conn = get_db()
|
||||
await _create_user(conn, "sa6", "Correct-Horse-Battery-Staple-F1", is_admin=True)
|
||||
await _login_full(client, "sa6", "Correct-Horse-Battery-Staple-F1")
|
||||
|
||||
resp = await client.post("/admin/tenants", json={"name": "Kunde F"})
|
||||
tenant_f = resp.json()["id"]
|
||||
ta_id = await _create_user(conn, "ta_f", "Correct-Horse-Battery-Staple-F2")
|
||||
await client.post(f"/admin/tenants/{tenant_f}/admins", json={"user_id": ta_id})
|
||||
|
||||
client.cookies.clear()
|
||||
await _login_full(client, "ta_f", "Correct-Horse-Battery-Staple-F2")
|
||||
# Eigene Aktion erzeugt einen Audit-Eintrag mit user_id=ta_id.
|
||||
resp = await client.post("/admin/host-groups", json={"name": "f-team"})
|
||||
assert resp.status_code == 201, resp.text
|
||||
|
||||
resp = await client.get("/admin/audit-log")
|
||||
assert resp.status_code == 200, resp.text
|
||||
entries = resp.json()
|
||||
assert len(entries) >= 1
|
||||
assert all(e["user_id"] == ta_id for e in entries), "Mandanten-Admin darf nur eigene Aktionen sehen"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3) Mehrfachauswahl bei Rollenvergabe (role_names)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multi_role_grant_for_individual_user(client):
|
||||
async def test_direct_multi_role_grant_for_individual_user_is_retired(client):
|
||||
"""Teil D Schritt 5: Direktvergabe an einzelne Benutzer (auch mit
|
||||
Mehrfachauswahl -- vormals test_multi_role_grant_for_individual_user,
|
||||
siehe FORTSETZUNG_Teil_D.md Abschnitt 1c) ist ersatzlos entfallen. Diese
|
||||
Faehigkeit existiert nur noch fuer Benutzergruppen, siehe
|
||||
test_multi_role_grant_for_user_group() unten -- deshalb hier bewusst
|
||||
KEIN Ersatztest fuer den Einzel-User-Pfad, sondern die Bestaetigung,
|
||||
dass er tatsaechlich abgeschaltet ist (statt z.B. nur zu vergessen und
|
||||
fuer immer gruen zu bleiben, obwohl der Pfad gar nicht mehr existiert)."""
|
||||
from app.db import get_db
|
||||
|
||||
conn = get_db()
|
||||
@ -347,23 +112,21 @@ async def test_multi_role_grant_for_individual_user(client):
|
||||
"role_names": ["ssh_connect", "file_transfer"],
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert set(resp.json()["roles_granted"]) == {"ssh_connect", "file_transfer"}
|
||||
assert resp.status_code == 410, resp.text
|
||||
|
||||
resp = await client.get("/admin/roles")
|
||||
assert resp.status_code == 200, resp.text
|
||||
granted_roles = {r["role_name"] for r in resp.json() if r["user_id"] == member_id}
|
||||
assert {"ssh_connect", "file_transfer"} <= granted_roles
|
||||
|
||||
# Einzelne Rolle wieder entziehen -- die andere bleibt bestehen.
|
||||
resp = await client.post(
|
||||
"/admin/roles/revoke",
|
||||
json={"user_id": member_id, "host_group_id": hg_id, "role_name": "file_transfer"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.status_code == 410, resp.text
|
||||
|
||||
# GET /admin/roles (die abgeleitete Effektiv-Sicht, Schritt 5) zeigt
|
||||
# entsprechend keine Rolle fuer member_id -- der Grant-Versuch oben ist
|
||||
# tatsaechlich folgenlos geblieben, nicht nur mit 410 quittiert worden.
|
||||
resp = await client.get("/admin/roles")
|
||||
remaining = {r["role_name"] for r in resp.json() if r["user_id"] == member_id}
|
||||
assert remaining == {"ssh_connect"}
|
||||
assert resp.status_code == 200, resp.text
|
||||
granted_roles = {r["role_name"] for r in resp.json() if r["user_id"] == member_id}
|
||||
assert granted_roles == set()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@ -394,7 +157,7 @@ async def test_multi_role_grant_for_user_group(client):
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4) CRUD-Vervollstaendigung: Edit/Delete ueberall
|
||||
# 2) CRUD-Vervollstaendigung: Edit/Delete ueberall
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@ -575,7 +338,7 @@ async def test_user_group_update_and_delete(client):
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5) GET /admin/hosts/{id} -- Grundlage fuer den "Details"-Fix
|
||||
# 3) GET /admin/hosts/{id} -- Grundlage fuer den "Details"-Fix
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@ -610,7 +373,7 @@ async def test_host_detail_endpoint_includes_ssh_keys_and_rdp_flag(client):
|
||||
resp = await client.post(
|
||||
"/admin/rdp-credentials",
|
||||
json={"label": "detail-cred", "username": "Administrator",
|
||||
"password": "Correct-Horse-Battery-Staple-O2", "tenant_id": 1},
|
||||
"password": "Correct-Horse-Battery-Staple-O2"},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
credential_id = resp.json()["id"]
|
||||
@ -1,3 +1,5 @@
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
import aiosqlite
|
||||
|
||||
@ -50,6 +52,37 @@ async def test_tamper_detected_after_direct_update():
|
||||
await conn.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chain_intact_after_concurrent_writes():
|
||||
"""E1 (Umsetzungsauftrag_Sonnet5.md Teil E.1): mehrere gleichzeitig
|
||||
laufende Aufrufe (z.B. Sitzungsstart/-ende zweier Benutzer im selben
|
||||
Moment) duerfen den prev_hash niemals doppelt vergeben -- sonst bricht
|
||||
die Kette dauerhaft ab (audit_log erlaubt kein UPDATE/DELETE)."""
|
||||
conn = await _fresh_db()
|
||||
|
||||
n = 40
|
||||
|
||||
async def _write(i: int) -> None:
|
||||
await write_audit_event(
|
||||
conn,
|
||||
event_type="test_event",
|
||||
user_id=None,
|
||||
client_ip="127.0.0.1",
|
||||
details={"i": i},
|
||||
)
|
||||
|
||||
await asyncio.gather(*(_write(i) for i in range(n)))
|
||||
|
||||
cursor = await conn.execute("SELECT COUNT(*) FROM audit_log")
|
||||
(count,) = await cursor.fetchone()
|
||||
assert count == n
|
||||
|
||||
intact, broken_at = await verify_chain(conn)
|
||||
assert intact is True
|
||||
assert broken_at is None
|
||||
await conn.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_append_only_trigger_blocks_update():
|
||||
conn = await _fresh_db()
|
||||
|
||||
@ -67,7 +67,7 @@ def _assert_no_inline_script(html: str, page: str) -> None:
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"path",
|
||||
["/", "/dashboard", "/terminal/1", "/rdp/1", "/admin"],
|
||||
["/", "/dashboard", "/terminal/1", "/rdp/1", "/admin", "/workspace"],
|
||||
)
|
||||
async def test_rendered_pages_contain_no_inline_style_or_script(client, path):
|
||||
resp = await client.get(path)
|
||||
@ -132,7 +132,9 @@ async def test_login_page_hidden_sections_use_css_class_not_inline_style(client)
|
||||
@pytest.mark.parametrize("path,expected_host_id", [("/terminal/42", "42"), ("/rdp/7", "7")])
|
||||
async def test_session_pages_expose_host_id_via_data_attribute(client, path, expected_host_id):
|
||||
"""host_id muss CSP-konform (kein Inline-<script>) an das Frontend-JS
|
||||
uebergeben werden -- ueber data-host-id auf #session-shell."""
|
||||
uebergeben werden -- ueber data-host-id auf #session-container (F1,
|
||||
Umsetzungsauftrag_Sonnet5.md Teil F.3.1: static/js/terminal.js bzw. rdp.js
|
||||
lesen es von dort und bauen #session-shell danach selbst dynamisch)."""
|
||||
resp = await client.get(path)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert f'data-host-id="{expected_host_id}"' in resp.text
|
||||
|
||||
@ -190,9 +190,36 @@ async def test_password_change_invalidates_old_session_everywhere(client):
|
||||
|
||||
# Altes Cookie (vor dem Passwortwechsel ausgestellt) erneut einspielen --
|
||||
# muss durch die session_version-Pruefung invalidiert sein (Konzept 6.2).
|
||||
client.cookies.set("jh_session", old_cookie)
|
||||
resp = await client.get("/auth/me")
|
||||
assert resp.status_code == 401
|
||||
#
|
||||
# Bewusst NICHT ueber client.cookies.set(...)/cookies=<...> auf dem
|
||||
# GETEILTEN `client`-Fixture: dessen Jar traegt an dieser Stelle bereits
|
||||
# die NEUE, gueltige Sitzung aus der change-password-Antwort oben.
|
||||
# client.cookies.set() kann je nach httpx-Version einen zweiten,
|
||||
# mehrdeutigen Jar-Eintrag statt eines Ersatzes erzeugen: der Request
|
||||
# wuerde dann weiterhin (auch) die noch gueltige NEUE Sitzung
|
||||
# mitschicken. Der per-Request-Parameter `cookies=` ist fuer genau
|
||||
# diesen Fall (Ueberschreiben einer bereits im Jar vorhandenen Cookie)
|
||||
# KEINE zuverlaessige Abhilfe: httpx warnt selbst per
|
||||
# DeprecationWarning, dass das Zusammenspiel mit einem bereits
|
||||
# gesetzten Jar-Eintrag "ambiguous" ist -- ein eigener Testlauf hat das
|
||||
# bestaetigt (der alte Cookie wurde NICHT wirksam, der Request nutzte
|
||||
# weiterhin die neue Sitzung aus dem Jar, Test schlug trotz korrektem
|
||||
# Server faelschlich fehl). Ein voellig frischer, jar-loser Client hat
|
||||
# dagegen zuverlaessig funktioniert (siehe FORTSETZUNG_Teil_C.md
|
||||
# Abschnitt 3 Punkt 6 fuer die vollstaendige Untersuchung) -- deshalb
|
||||
# hier bewusst ein zweiter, eigener AsyncClient nur fuer diesen einen
|
||||
# Replay-Request, unabhaengig vom Jar-Zustand des Fixture-Clients.
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from app.main import app as fastapi_app
|
||||
|
||||
async with AsyncClient(transport=ASGITransport(app=fastapi_app), base_url="https://testserver") as replay_client:
|
||||
# .set() auf dem LEEREN Jar dieses frischen Clients ist eindeutig
|
||||
# (kein vorhandener Eintrag, der ueberschrieben werden muesste) --
|
||||
# folgt damit auch httpx' eigener Empfehlung in der oben erklaerten
|
||||
# DeprecationWarning ("Set cookies directly on the client instance").
|
||||
replay_client.cookies.set("jh_session", old_cookie)
|
||||
resp = await replay_client.get("/auth/me")
|
||||
assert resp.status_code == 401, resp.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@ -266,10 +293,15 @@ async def test_user_cannot_access_host_outside_granted_hostgroup(client):
|
||||
"INSERT INTO hosts (id, host_group_id, hostname, address, protocol, port, os_type, "
|
||||
"file_transfer_enabled) VALUES (2, 2, 'b01', '10.0.0.2', 'ssh', 22, 'linux', 1)"
|
||||
)
|
||||
# judy bekommt file_transfer NUR auf Hostgruppe 1 (role_id 3 = file_transfer, siehe Migration 0001).
|
||||
# judy bekommt file_transfer NUR auf Hostgruppe 1 (role_id 3 = file_transfer,
|
||||
# siehe Migration 0001) -- seit Teil D Schritt 4 ausschliesslich ueber
|
||||
# eine Benutzergruppe moeglich (direkte Vergabe an einzelne Benutzer ist
|
||||
# entfallen, siehe app/rbac.py-Modul-Docstring).
|
||||
await conn.execute("INSERT INTO user_groups (id, name) VALUES (101, 'judy-team')")
|
||||
await conn.execute("INSERT INTO user_group_members (user_group_id, user_id) VALUES (101, ?)", (user_id,))
|
||||
await conn.execute(
|
||||
"INSERT INTO user_hostgroup_roles (user_id, host_group_id, role_id, granted_by) VALUES (?, 1, 3, ?)",
|
||||
(user_id, admin_id),
|
||||
"INSERT INTO group_hostgroup_roles (user_group_id, host_group_id, role_id, granted_by) VALUES (101, 1, 3, ?)",
|
||||
(admin_id,),
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
@ -308,10 +340,15 @@ async def test_role_on_one_hostgroup_does_not_grant_different_permission_type(cl
|
||||
"INSERT INTO hosts (id, host_group_id, hostname, address, protocol, port, os_type, "
|
||||
"file_transfer_enabled) VALUES (1, 1, 'c01', '10.0.0.3', 'ssh', 22, 'linux', 1)"
|
||||
)
|
||||
# role_id 1 = ssh_connect (siehe Migration 0001) -- explizit KEIN file_transfer.
|
||||
# role_id 1 = ssh_connect (siehe Migration 0001) -- explizit KEIN
|
||||
# file_transfer. Seit Teil D Schritt 4 ausschliesslich ueber eine
|
||||
# Benutzergruppe moeglich (siehe Kommentar in
|
||||
# test_user_cannot_access_host_outside_granted_hostgroup oben).
|
||||
await conn.execute("INSERT INTO user_groups (id, name) VALUES (102, 'mallory-team')")
|
||||
await conn.execute("INSERT INTO user_group_members (user_group_id, user_id) VALUES (102, ?)", (user_id,))
|
||||
await conn.execute(
|
||||
"INSERT INTO user_hostgroup_roles (user_id, host_group_id, role_id, granted_by) VALUES (?, 1, 1, ?)",
|
||||
(user_id, admin_id),
|
||||
"INSERT INTO group_hostgroup_roles (user_group_id, host_group_id, role_id, granted_by) VALUES (102, 1, 1, ?)",
|
||||
(admin_id,),
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
|
||||
@ -186,6 +186,14 @@ async def test_load_private_key_for_host_uses_stored_passphrase(client):
|
||||
from app.security.crypto import encrypt_secret
|
||||
|
||||
conn = get_db()
|
||||
from app.security.passwords import hash_password
|
||||
|
||||
cursor = await conn.execute(
|
||||
"INSERT INTO users (username, password_hash) VALUES ('p10-user', ?)",
|
||||
(hash_password("Correct-Horse-Battery-Staple-P10"),),
|
||||
)
|
||||
user_id = cursor.lastrowid
|
||||
|
||||
cursor = await conn.execute("INSERT INTO host_groups (name) VALUES ('p10-group')")
|
||||
hg_id = cursor.lastrowid
|
||||
cursor = await conn.execute(
|
||||
@ -205,17 +213,31 @@ async def test_load_private_key_for_host_uses_stored_passphrase(client):
|
||||
await conn.execute(
|
||||
"INSERT INTO host_ssh_key_map (host_id, ssh_key_id) VALUES (?, ?)", (host_id, key_id)
|
||||
)
|
||||
|
||||
# Teil D Schritt 4 (Achse B): load_private_key_for_host() loest seither
|
||||
# NICHT mehr blind ueber den Host auf, sondern nur noch fuer einen
|
||||
# Benutzer, dessen Gruppe den Schluessel ueber group_ssh_key_grants
|
||||
# freigegeben bekommen hat (siehe Docstring von
|
||||
# load_ssh_key_credential_for_host in app/ssh_proxy/proxy.py).
|
||||
cursor = await conn.execute("INSERT INTO user_groups (name) VALUES ('p10-team')")
|
||||
group_id = cursor.lastrowid
|
||||
await conn.execute(
|
||||
"INSERT INTO user_group_members (user_group_id, user_id) VALUES (?, ?)", (group_id, user_id)
|
||||
)
|
||||
await conn.execute(
|
||||
"INSERT INTO group_ssh_key_grants (user_group_id, ssh_key_id) VALUES (?, ?)", (group_id, key_id)
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
# Mit hinterlegter Passphrase laedt der Schluessel.
|
||||
assert await load_private_key_for_host(conn, host_id) is not None
|
||||
assert await load_private_key_for_host(conn, host_id, user_id=user_id) is not None
|
||||
|
||||
# Ohne sie: klare Meldung statt eines nach aussen durchschlagenden
|
||||
# KeyImportError (das war der gemeldete Abbruch ohne Fehlermeldung).
|
||||
await conn.execute("UPDATE ssh_keys SET passphrase_enc = NULL WHERE id = ?", (key_id,))
|
||||
await conn.commit()
|
||||
with pytest.raises(PrivateKeyUnusableError) as excinfo:
|
||||
await load_private_key_for_host(conn, host_id)
|
||||
await load_private_key_for_host(conn, host_id, user_id=user_id)
|
||||
assert "passphrasegeschuetzt" in str(excinfo.value).lower()
|
||||
|
||||
|
||||
@ -257,25 +279,33 @@ def test_build_rdp_params_passes_username_and_cert_policy():
|
||||
"rdp_domain": "CORP", "rdp_require_nla": True, "clipboard_enabled": True,
|
||||
"rdp_ignore_cert": True,
|
||||
}
|
||||
params = build_rdp_params(host, "geheim")
|
||||
# Bug-Fix (FORTSETZUNG_Teil_C.md Abschnitt 3 Punkt 2): build_rdp_params()
|
||||
# verlangt inzwischen session_id als Pflicht-Keyword-Argument (echte
|
||||
# Signaturerweiterung, app/rdp_proxy/guacd_client.py) -- fuer diese Tests
|
||||
# ist der konkrete Wert irrelevant, ein beliebiger int reicht.
|
||||
params = build_rdp_params(host, "geheim", session_id=1)
|
||||
assert params["username"] == "Administrator"
|
||||
assert params["domain"] == "CORP"
|
||||
assert params["security"] == "nla"
|
||||
assert params["ignore-cert"] == "true"
|
||||
assert params["disable-copy"] == "false"
|
||||
|
||||
strict = build_rdp_params(dict(host, rdp_ignore_cert=False, clipboard_enabled=False), "geheim")
|
||||
strict = build_rdp_params(
|
||||
dict(host, rdp_ignore_cert=False, clipboard_enabled=False), "geheim", session_id=1,
|
||||
)
|
||||
assert strict["ignore-cert"] == "false"
|
||||
assert strict["disable-copy"] == "true" and strict["disable-paste"] == "true"
|
||||
|
||||
# Kein Benutzername -> klare Meldung statt stiller Fehlanmeldung am Ziel
|
||||
with pytest.raises(GuacamoleProtocolError):
|
||||
build_rdp_params(dict(host, rdp_username=""), "geheim")
|
||||
build_rdp_params(dict(host, rdp_username=""), "geheim", session_id=1)
|
||||
|
||||
# Unvollstaendiger Hostdatensatz (der alte load_host()-Zustand)
|
||||
with pytest.raises(GuacamoleProtocolError):
|
||||
build_rdp_params({k: host[k] for k in ("id", "hostname", "address", "port",
|
||||
"file_transfer_enabled")}, "geheim")
|
||||
build_rdp_params(
|
||||
{k: host[k] for k in ("id", "hostname", "address", "port", "file_transfer_enabled")},
|
||||
"geheim", session_id=1,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@ -96,12 +96,33 @@ def _plain_key_pem() -> str:
|
||||
).decode()
|
||||
|
||||
|
||||
# Teil D Schritt 4 (Achse B): connect_to_host()/load_private_key_for_host()
|
||||
# loesen Zugangsdaten seither benutzerabhaengig auf -- _make_db() seedet
|
||||
# daher immer einen Benutzer (TEST_USER_ID) samt Gruppe, und bei with_key=True
|
||||
# zusaetzlich eine group_ssh_key_grants-Freigabe dieser Gruppe fuer den
|
||||
# angelegten Schluessel (ohne die wuerde resolve_credential_for_user_on_host()
|
||||
# keinen Treffer finden, siehe app/rbac.py).
|
||||
TEST_USER_ID = 1
|
||||
TEST_USER_GROUP_ID = 1
|
||||
|
||||
|
||||
def _make_db(tmp_path, *, host_username=None, key_username="l4u", fingerprint=FINGERPRINT,
|
||||
host_key=PUBLIC_KEY, with_key=True) -> str:
|
||||
path = str(tmp_path / "jumphost.sqlite3")
|
||||
db = sqlite3.connect(path)
|
||||
_apply_migrations(db)
|
||||
db.execute("INSERT INTO host_groups (id, name, tenant_id) VALUES (1, 'gruppe', 1)")
|
||||
db.execute(
|
||||
"INSERT INTO users (id, username, password_hash) VALUES (?, 'p12-user', 'x')",
|
||||
(TEST_USER_ID,),
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO user_groups (id, name) VALUES (?, 'p12-team')", (TEST_USER_GROUP_ID,),
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO user_group_members (user_group_id, user_id) VALUES (?, ?)",
|
||||
(TEST_USER_GROUP_ID, TEST_USER_ID),
|
||||
)
|
||||
db.execute("INSERT INTO host_groups (id, name) VALUES (1, 'gruppe')")
|
||||
db.execute(
|
||||
"INSERT INTO hosts (id, host_group_id, hostname, address, protocol, port, os_type, "
|
||||
"ssh_host_key_fingerprint, ssh_host_key, ssh_username) "
|
||||
@ -110,11 +131,15 @@ def _make_db(tmp_path, *, host_username=None, key_username="l4u", fingerprint=FI
|
||||
)
|
||||
if with_key:
|
||||
db.execute(
|
||||
"INSERT INTO ssh_keys (id, label, private_key_enc, public_key, key_type, tenant_id, username) "
|
||||
"VALUES (1, 'testkey', ?, 'ssh-ed25519 AAAA', 'ed25519', 1, ?)",
|
||||
"INSERT INTO ssh_keys (id, label, private_key_enc, public_key, key_type, username) "
|
||||
"VALUES (1, 'testkey', ?, 'ssh-ed25519 AAAA', 'ed25519', ?)",
|
||||
(encrypt_secret(_plain_key_pem().encode(), associated_data=b"ssh_private_key"), key_username),
|
||||
)
|
||||
db.execute("INSERT INTO host_ssh_key_map (host_id, ssh_key_id) VALUES (1, 1)")
|
||||
db.execute(
|
||||
"INSERT INTO group_ssh_key_grants (user_group_id, ssh_key_id) VALUES (?, 1)",
|
||||
(TEST_USER_GROUP_ID,),
|
||||
)
|
||||
db.commit()
|
||||
db.close()
|
||||
return path
|
||||
@ -206,7 +231,7 @@ async def test_verbindung_ohne_hinterlegten_hostkey_wird_abgelehnt(tmp_path, mon
|
||||
monkeypatch.setattr(asyncssh, "connect", _fake_connect)
|
||||
conn = FakeConnection(_make_db(tmp_path, fingerprint=None, host_key=None))
|
||||
with pytest.raises(HostKeyNotPinnedError):
|
||||
await connect_to_host(conn, 1)
|
||||
await connect_to_host(conn, 1, user_id=TEST_USER_ID)
|
||||
|
||||
|
||||
async def test_abweichender_hostkey_bricht_vor_der_anmeldung_ab(tmp_path, monkeypatch):
|
||||
@ -227,7 +252,7 @@ async def test_abweichender_hostkey_bricht_vor_der_anmeldung_ab(tmp_path, monkey
|
||||
|
||||
conn = FakeConnection(_make_db(tmp_path))
|
||||
with pytest.raises(HostKeyMismatchError) as excinfo:
|
||||
await connect_to_host(conn, 1)
|
||||
await connect_to_host(conn, 1, user_id=TEST_USER_ID)
|
||||
assert excinfo.value.expected == FINGERPRINT
|
||||
assert versuche == []
|
||||
|
||||
@ -249,7 +274,7 @@ async def test_verbindung_nutzt_benutzernamen_der_zugangsdaten(tmp_path, monkeyp
|
||||
monkeypatch.setattr(asyncssh, "get_server_host_key", _fake_get_host_key, raising=False)
|
||||
|
||||
conn = FakeConnection(_make_db(tmp_path, host_username="alterWertAmHost", key_username="l4u"))
|
||||
await connect_to_host(conn, 1)
|
||||
await connect_to_host(conn, 1, user_id=TEST_USER_ID)
|
||||
# Der Name aus den Zugangsdaten gewinnt gegen den Altwert am Host.
|
||||
assert aufrufe["username"] == "l4u"
|
||||
|
||||
@ -270,7 +295,7 @@ async def test_hostkey_wechsel_nach_der_pruefung_beendet_die_sitzung(tmp_path, m
|
||||
|
||||
conn = FakeConnection(_make_db(tmp_path))
|
||||
with pytest.raises(HostKeyMismatchError):
|
||||
await connect_to_host(conn, 1)
|
||||
await connect_to_host(conn, 1, user_id=TEST_USER_ID)
|
||||
assert verbindung.aborted is True
|
||||
|
||||
|
||||
@ -290,7 +315,7 @@ async def test_altbestand_ohne_gespeicherten_hostkey_wird_nachgetragen(tmp_path,
|
||||
monkeypatch.setattr(asyncssh, "get_server_host_key", _fake_get_host_key, raising=False)
|
||||
|
||||
conn = FakeConnection(_make_db(tmp_path, host_key=None))
|
||||
await connect_to_host(conn, 1)
|
||||
await connect_to_host(conn, 1, user_id=TEST_USER_ID)
|
||||
(stored,) = conn.raw.execute("SELECT ssh_host_key FROM hosts WHERE id = 1").fetchone()
|
||||
assert stored == PUBLIC_KEY
|
||||
|
||||
@ -323,9 +348,13 @@ def _rdp_host(**overrides):
|
||||
|
||||
|
||||
def test_rdp_params_nehmen_benutzernamen_der_zugangsdaten():
|
||||
# Bug-Fix (FORTSETZUNG_Teil_C.md Abschnitt 3 Punkt 2): build_rdp_params()
|
||||
# verlangt inzwischen session_id als Pflicht-Keyword-Argument (echte
|
||||
# Signaturerweiterung, app/rdp_proxy/guacd_client.py) -- fuer diese Tests
|
||||
# ist der konkrete Wert irrelevant, ein beliebiger int reicht.
|
||||
params = build_rdp_params(
|
||||
_rdp_host(rdp_username="alt", rdp_domain="ALTEDOMAENE"),
|
||||
"geheim", username="Administrator", domain="CONTOSO",
|
||||
"geheim", username="Administrator", domain="CONTOSO", session_id=1,
|
||||
)
|
||||
assert params["username"] == "Administrator"
|
||||
assert params["domain"] == "CONTOSO"
|
||||
@ -333,20 +362,22 @@ def test_rdp_params_nehmen_benutzernamen_der_zugangsdaten():
|
||||
|
||||
|
||||
def test_rdp_params_fallback_auf_altwert_am_host():
|
||||
params = build_rdp_params(_rdp_host(rdp_username="alt", rdp_domain="D"), "geheim")
|
||||
params = build_rdp_params(
|
||||
_rdp_host(rdp_username="alt", rdp_domain="D"), "geheim", session_id=1,
|
||||
)
|
||||
assert (params["username"], params["domain"]) == ("alt", "D")
|
||||
|
||||
|
||||
def test_rdp_params_ohne_benutzernamen_meldet_zugangsdaten():
|
||||
with pytest.raises(GuacamoleProtocolError) as excinfo:
|
||||
build_rdp_params(_rdp_host(), "geheim")
|
||||
build_rdp_params(_rdp_host(), "geheim", session_id=1)
|
||||
assert "Zugangsdaten" in str(excinfo.value)
|
||||
|
||||
|
||||
def test_rdp_params_brauchen_die_hostspalte_nicht_mehr():
|
||||
"""Ein Hostdatensatz ohne rdp_username ist kein Fehler mehr -- der Name
|
||||
kommt jetzt von woanders."""
|
||||
params = build_rdp_params(_rdp_host(), "geheim", username="svc")
|
||||
params = build_rdp_params(_rdp_host(), "geheim", username="svc", session_id=1)
|
||||
assert params["username"] == "svc"
|
||||
|
||||
|
||||
@ -362,7 +393,7 @@ def _pre_0010_db(tmp_path) -> sqlite3.Connection:
|
||||
|
||||
def test_migration_uebernimmt_eindeutige_ssh_benutzernamen(tmp_path):
|
||||
db = _pre_0010_db(tmp_path)
|
||||
db.execute("INSERT INTO host_groups (id, name, tenant_id) VALUES (1, 'g', 1)")
|
||||
db.execute("INSERT INTO host_groups (id, name) VALUES (1, 'g')")
|
||||
for host_id in (1, 2):
|
||||
db.execute(
|
||||
"INSERT INTO hosts (id, host_group_id, hostname, address, protocol, port, os_type, ssh_username) "
|
||||
@ -370,8 +401,8 @@ def test_migration_uebernimmt_eindeutige_ssh_benutzernamen(tmp_path):
|
||||
(host_id,),
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO ssh_keys (id, label, private_key_enc, public_key, key_type, tenant_id) "
|
||||
"VALUES (1, 'k', X'00', 'pub', 'ed25519', 1)"
|
||||
"INSERT INTO ssh_keys (id, label, private_key_enc, public_key, key_type) "
|
||||
"VALUES (1, 'k', X'00', 'pub', 'ed25519')"
|
||||
)
|
||||
db.execute("INSERT INTO host_ssh_key_map (host_id, ssh_key_id) VALUES (1, 1), (2, 1)")
|
||||
db.commit()
|
||||
@ -386,7 +417,7 @@ def test_migration_raet_nicht_bei_mehrdeutigen_benutzernamen(tmp_path):
|
||||
jede automatische Wahl geraten -- also bleibt das Feld leer und der
|
||||
Fallback greift weiter."""
|
||||
db = _pre_0010_db(tmp_path)
|
||||
db.execute("INSERT INTO host_groups (id, name, tenant_id) VALUES (1, 'g', 1)")
|
||||
db.execute("INSERT INTO host_groups (id, name) VALUES (1, 'g')")
|
||||
for host_id, name in ((1, "root"), (2, "l4u")):
|
||||
db.execute(
|
||||
"INSERT INTO hosts (id, host_group_id, hostname, address, protocol, port, os_type, ssh_username) "
|
||||
@ -394,8 +425,8 @@ def test_migration_raet_nicht_bei_mehrdeutigen_benutzernamen(tmp_path):
|
||||
(host_id, name),
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO ssh_keys (id, label, private_key_enc, public_key, key_type, tenant_id) "
|
||||
"VALUES (1, 'k', X'00', 'pub', 'ed25519', 1)"
|
||||
"INSERT INTO ssh_keys (id, label, private_key_enc, public_key, key_type) "
|
||||
"VALUES (1, 'k', X'00', 'pub', 'ed25519')"
|
||||
)
|
||||
db.execute("INSERT INTO host_ssh_key_map (host_id, ssh_key_id) VALUES (1, 1), (2, 1)")
|
||||
db.commit()
|
||||
@ -407,7 +438,7 @@ def test_migration_raet_nicht_bei_mehrdeutigen_benutzernamen(tmp_path):
|
||||
|
||||
def test_migration_uebernimmt_rdp_benutzer_und_domaene(tmp_path):
|
||||
db = _pre_0010_db(tmp_path)
|
||||
db.execute("INSERT INTO host_groups (id, name, tenant_id) VALUES (1, 'g', 1)")
|
||||
db.execute("INSERT INTO host_groups (id, name) VALUES (1, 'g')")
|
||||
db.execute(
|
||||
"INSERT INTO hosts (id, host_group_id, hostname, address, protocol, port, os_type, "
|
||||
"rdp_username, rdp_domain) "
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -36,6 +36,9 @@ Drei voneinander unabhaengige Bugs/Features, gemeldet in derselben Session:
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io # Bug-Fix Punkt 1: UploadFile(file, *, filename=...) braucht ein
|
||||
# dateiaehnliches Objekt als erstes Argument, keine rohen bytes
|
||||
# (siehe UploadFile-Konstruktionen unten).
|
||||
|
||||
import asyncssh
|
||||
import pytest
|
||||
@ -53,6 +56,17 @@ class _FakeUser:
|
||||
self.username = username
|
||||
|
||||
|
||||
def _fake_request() -> "sftp_module.Request":
|
||||
"""Bug-Fix (FORTSETZUNG_Teil_C.md Abschnitt 3 Punkt 1): starlette.Request
|
||||
verlangt seit jeher ein `scope`-Dict als Pflichtargument (KEIN API-Drift
|
||||
der Anwendung -- die Tests riefen schlicht Request() ohne Argumente auf).
|
||||
Minimaler, aber vollstaendiger ASGI-HTTP-Scope: `client` wird von
|
||||
sftp.py's _client_ip()-Helfer gelesen (request.client.host), `headers`
|
||||
muss als Liste vorhanden sein (Starlette baut Request.headers direkt
|
||||
daraus, KeyError bei fehlendem Key)."""
|
||||
return sftp_module.Request({"type": "http", "client": ("127.0.0.1", 12345), "headers": []})
|
||||
|
||||
|
||||
class _FakeConn:
|
||||
"""Minimaler aiosqlite-Ersatz fuer diese Tests -- genug fuer die
|
||||
INSERT/UPDATE/SELECT-Aufrufe aus sftp.py/terminal_ws.py, ohne echte
|
||||
@ -118,13 +132,17 @@ async def test_upload_erfolgreicher_transfer_wirft_keine_typeerror(monkeypatch):
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
async def fake_connect_to_host(conn, host_id):
|
||||
async def fake_connect_to_host(conn, host_id, *, user_id=None):
|
||||
# Bug-Fix (FORTSETZUNG_Teil_C.md Abschnitt 3 Punkt 1): connect_to_host()
|
||||
# verlangt inzwischen user_id als Keyword-Argument (echte Signatur-
|
||||
# erweiterung, app/ssh_proxy/proxy.py) -- dieser Fake muss es zumindest
|
||||
# entgegennehmen koennen, auch wenn er es hier nicht auswertet.
|
||||
return _OkSshConn()
|
||||
|
||||
monkeypatch.setattr(sftp_module, "connect_to_host", fake_connect_to_host)
|
||||
|
||||
request = sftp_module.Request()
|
||||
upload = sftp_module.UploadFile("test.txt", b"hallo welt")
|
||||
request = _fake_request()
|
||||
upload = sftp_module.UploadFile(io.BytesIO(b"hallo welt"), filename="test.txt")
|
||||
|
||||
result = await sftp_module.upload_file(
|
||||
host_id=1, request=request, remote_path="/tmp/test.txt", file=upload,
|
||||
@ -144,13 +162,29 @@ async def test_upload_asyncssh_error_wird_zu_http_exception_400(monkeypatch):
|
||||
fake_conn = _FakeConn()
|
||||
monkeypatch.setattr(sftp_module, "get_db", lambda: fake_conn)
|
||||
|
||||
async def fake_connect_to_host(conn, host_id):
|
||||
raise asyncssh.Error(reason="Connection refused")
|
||||
async def fake_connect_to_host(conn, host_id, *, user_id=None):
|
||||
# Bug-Fix (FORTSETZUNG_Teil_C.md Abschnitt 3 Punkt 1): connect_to_host()
|
||||
# verlangt inzwischen user_id als Keyword-Argument (echte Signatur-
|
||||
# erweiterung, app/ssh_proxy/proxy.py) -- dieser Fake muss es zumindest
|
||||
# entgegennehmen koennen, auch wenn er es hier nicht auswertet.
|
||||
# Weiterer Drift (echte asyncssh-Signatur via inspect.signature
|
||||
# bestaetigt): asyncssh.Error.__init__(self, code: int, reason: str,
|
||||
# lang: str = 'en-US') verlangt `code` als PFLICHT-Positionsargument,
|
||||
# nicht nur `reason`. Der alte Fake-Aufruf asyncssh.Error(reason=...)
|
||||
# loeste selbst einen TypeError aus, BEVOR die eigentlich zu testende
|
||||
# except asyncssh.Error-Behandlung in sftp.py ueberhaupt erreicht wurde
|
||||
# -- der TypeError fiel unter das allgemeine except Exception-
|
||||
# Auffangnetz und ergab faelschlich 500 statt 400. Der konkrete
|
||||
# code-Wert ist fuer den Test irrelevant (nur die Klasse asyncssh.Error
|
||||
# zaehlt fuer den except-Zweig in sftp.py); DISC_CONNECTION_LOST
|
||||
# ist eine echte asyncssh-Konstante (asyncssh.DISC_CONNECTION_LOST) und
|
||||
# passt inhaltlich.
|
||||
raise asyncssh.Error(asyncssh.DISC_CONNECTION_LOST, "Connection refused")
|
||||
|
||||
monkeypatch.setattr(sftp_module, "connect_to_host", fake_connect_to_host)
|
||||
|
||||
request = sftp_module.Request()
|
||||
upload = sftp_module.UploadFile("test.txt", b"hallo welt")
|
||||
request = _fake_request()
|
||||
upload = sftp_module.UploadFile(io.BytesIO(b"hallo welt"), filename="test.txt")
|
||||
|
||||
with pytest.raises(HTTPException) as excinfo:
|
||||
await sftp_module.upload_file(
|
||||
@ -184,13 +218,17 @@ async def test_upload_sftp_permission_denied_wird_zu_http_exception_400(monkeypa
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
async def fake_connect_to_host(conn, host_id):
|
||||
async def fake_connect_to_host(conn, host_id, *, user_id=None):
|
||||
# Bug-Fix (FORTSETZUNG_Teil_C.md Abschnitt 3 Punkt 1): connect_to_host()
|
||||
# verlangt inzwischen user_id als Keyword-Argument (echte Signatur-
|
||||
# erweiterung, app/ssh_proxy/proxy.py) -- dieser Fake muss es zumindest
|
||||
# entgegennehmen koennen, auch wenn er es hier nicht auswertet.
|
||||
return _SshConn()
|
||||
|
||||
monkeypatch.setattr(sftp_module, "connect_to_host", fake_connect_to_host)
|
||||
|
||||
request = sftp_module.Request()
|
||||
upload = sftp_module.UploadFile("test.txt", b"hallo welt")
|
||||
request = _fake_request()
|
||||
upload = sftp_module.UploadFile(io.BytesIO(b"hallo welt"), filename="test.txt")
|
||||
|
||||
with pytest.raises(HTTPException) as excinfo:
|
||||
await sftp_module.upload_file(
|
||||
@ -207,13 +245,17 @@ async def test_upload_unerwarteter_fehler_wird_zu_http_exception_500_nicht_unbeh
|
||||
fake_conn = _FakeConn()
|
||||
monkeypatch.setattr(sftp_module, "get_db", lambda: fake_conn)
|
||||
|
||||
async def fake_connect_to_host(conn, host_id):
|
||||
async def fake_connect_to_host(conn, host_id, *, user_id=None):
|
||||
# Bug-Fix (FORTSETZUNG_Teil_C.md Abschnitt 3 Punkt 1): connect_to_host()
|
||||
# verlangt inzwischen user_id als Keyword-Argument (echte Signatur-
|
||||
# erweiterung, app/ssh_proxy/proxy.py) -- dieser Fake muss es zumindest
|
||||
# entgegennehmen koennen, auch wenn er es hier nicht auswertet.
|
||||
raise AttributeError("simuliert einen unverwandten kuenftigen Bug")
|
||||
|
||||
monkeypatch.setattr(sftp_module, "connect_to_host", fake_connect_to_host)
|
||||
|
||||
request = sftp_module.Request()
|
||||
upload = sftp_module.UploadFile("test.txt", b"hallo welt")
|
||||
request = _fake_request()
|
||||
upload = sftp_module.UploadFile(io.BytesIO(b"hallo welt"), filename="test.txt")
|
||||
|
||||
with pytest.raises(HTTPException) as excinfo:
|
||||
await sftp_module.upload_file(
|
||||
@ -234,28 +276,37 @@ async def test_download_datei_zu_gross_bleibt_413_und_wird_nicht_zu_500(monkeypa
|
||||
size = sftp_module.MAX_UPLOAD_BYTES + 1
|
||||
|
||||
class _HugeSftp:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *a):
|
||||
return False
|
||||
"""Bug-Fix (FORTSETZUNG_Teil_C.md Abschnitt 3 Punkt 1): download_file()
|
||||
nutzt `sftp = await ssh_conn.start_sftp_client()` (KEIN `async with`,
|
||||
anders als upload_file() -- reales asyncssh erlaubt beides ueber
|
||||
seinen @async_context_manager-Dekorator) und ruft im Fehlerfall
|
||||
`sftp.exit()` SYNCHRON auf (kein await, echte asyncssh-API). Der
|
||||
vorherige Fake hatte weder ein passendes async start_sftp_client()
|
||||
noch ueberhaupt eine exit()-Methode."""
|
||||
|
||||
async def stat(self, path):
|
||||
return _Stat()
|
||||
|
||||
def exit(self):
|
||||
pass
|
||||
|
||||
class _SshConn:
|
||||
def start_sftp_client(self):
|
||||
async def start_sftp_client(self):
|
||||
return _HugeSftp()
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
async def fake_connect_to_host(conn, host_id):
|
||||
async def fake_connect_to_host(conn, host_id, *, user_id=None):
|
||||
# Bug-Fix (FORTSETZUNG_Teil_C.md Abschnitt 3 Punkt 1): connect_to_host()
|
||||
# verlangt inzwischen user_id als Keyword-Argument (echte Signatur-
|
||||
# erweiterung, app/ssh_proxy/proxy.py) -- dieser Fake muss es zumindest
|
||||
# entgegennehmen koennen, auch wenn er es hier nicht auswertet.
|
||||
return _SshConn()
|
||||
|
||||
monkeypatch.setattr(sftp_module, "connect_to_host", fake_connect_to_host)
|
||||
|
||||
request = sftp_module.Request()
|
||||
request = _fake_request()
|
||||
with pytest.raises(HTTPException) as excinfo:
|
||||
await sftp_module.download_file(
|
||||
host_id=1, request=request, remote_path="/tmp/huge.bin",
|
||||
@ -272,7 +323,12 @@ async def test_active_sessions_broadcast_erreicht_alle_beobachter(event_loop=Non
|
||||
import asyncio
|
||||
|
||||
task = asyncio.current_task()
|
||||
active_sessions.register(session_id=4242, task=task)
|
||||
# Bug-Fix (FORTSETZUNG_Teil_C.md Abschnitt 3 Punkt 1): register()
|
||||
# verlangt seit E.4/Teil F user_id als drittes Pflichtargument (echte
|
||||
# Signaturerweiterung der Anwendung, kein Test-Freibleiber -- die
|
||||
# konkrete ID ist fuer diesen Test irrelevant, es geht nur um die
|
||||
# Beobachter-/Broadcast-Mechanik).
|
||||
active_sessions.register(session_id=4242, task=task, user_id=1)
|
||||
try:
|
||||
q1 = active_sessions.add_watcher(4242)
|
||||
q2 = active_sessions.add_watcher(4242)
|
||||
@ -377,7 +433,7 @@ async def test_watch_ssh_session_leitet_frames_weiter_und_raeumt_beobachter_auf(
|
||||
|
||||
monkeypatch.setattr(terminal_ws_module, "write_audit_event", fake_write_audit_event)
|
||||
|
||||
active_sessions.register(session_id=1, task=asyncio.current_task())
|
||||
active_sessions.register(session_id=1, task=asyncio.current_task(), user_id=1)
|
||||
try:
|
||||
class _WsThenDisconnect(_FakeWebSocket):
|
||||
def __init__(self):
|
||||
|
||||
@ -69,6 +69,52 @@ async def _setup_hostgroup_and_host(conn, *, group_name="p9-group", hostname="p9
|
||||
return hg_id, cursor.lastrowid
|
||||
|
||||
|
||||
async def _grant_group_role(conn, *, user_id: int, host_group_id: int, role_names, group_name: str) -> int:
|
||||
"""Teil D Schritt 4/5: Rechte gibt es nur noch ueber Benutzergruppen --
|
||||
ersetzt das fruehere direkte INSERT INTO user_hostgroup_roles bzw. den
|
||||
seit Schritt 5 mit HTTP 410 antwortenden Endpunkt POST
|
||||
/admin/roles/grant. Legt eine neue Gruppe mit user_id als einzigem
|
||||
Mitglied an und vergibt die angegebenen Rollen auf host_group_id.
|
||||
Gibt die neue user_group_id zurueck (fuer eine anschliessende
|
||||
Achse-B-Freigabe, siehe _grant_group_credential)."""
|
||||
cursor = await conn.execute("INSERT INTO user_groups (name) VALUES (?)", (group_name,))
|
||||
group_id = cursor.lastrowid
|
||||
await conn.execute(
|
||||
"INSERT INTO user_group_members (user_group_id, user_id) VALUES (?, ?)", (group_id, user_id)
|
||||
)
|
||||
for role_name in role_names:
|
||||
role_row = await (await conn.execute("SELECT id FROM roles WHERE name = ?", (role_name,))).fetchone()
|
||||
await conn.execute(
|
||||
"INSERT INTO group_hostgroup_roles (user_group_id, host_group_id, role_id) VALUES (?, ?, ?)",
|
||||
(group_id, host_group_id, role_row[0]),
|
||||
)
|
||||
await conn.commit()
|
||||
return group_id
|
||||
|
||||
|
||||
async def _grant_group_credential(conn, *, user_group_id: int, kind: str, credential_id: int) -> None:
|
||||
"""Achse B (Teil D Schritt 3+5): ohne diese Freigabe scheitert seit dem
|
||||
S4-Fix in Schritt 5 jede Nicht-Admin-Zuweisung eines Zugangsdatensatzes
|
||||
an einen Host mit 403, selbst wenn die Achse-A-Rolle
|
||||
(credentials_manage) vorhanden ist -- siehe app/admin/routes.py,
|
||||
assign_rdp_credential_to_host()/map_ssh_key_to_host()."""
|
||||
grant_table = {
|
||||
"ssh_key": "group_ssh_key_grants",
|
||||
"rdp_credential": "group_rdp_credential_grants",
|
||||
"ssh_password_credential": "group_ssh_password_credential_grants",
|
||||
}[kind]
|
||||
col = {
|
||||
"ssh_key": "ssh_key_id",
|
||||
"rdp_credential": "rdp_credential_id",
|
||||
"ssh_password_credential": "ssh_password_credential_id",
|
||||
}[kind]
|
||||
await conn.execute(
|
||||
f"INSERT INTO {grant_table} (user_group_id, {col}) VALUES (?, ?)",
|
||||
(user_group_id, credential_id),
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1) Host-Key ermitteln: kein 500 mehr bei Verbindungsfehlern
|
||||
# ---------------------------------------------------------------------------
|
||||
@ -124,18 +170,14 @@ async def test_discover_host_key_success_after_auth_failure_past_kex(client, mon
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sessions_list_requires_global_admin_not_tenant_admin(client):
|
||||
async def test_sessions_list_requires_admin(client):
|
||||
from app.db import get_db
|
||||
|
||||
conn = get_db()
|
||||
superadmin_id = await _create_user(conn, "sv_super", "Correct-Horse-Battery-Staple-S1", is_admin=True)
|
||||
tenant_admin_id = await _create_user(conn, "sv_tenant", "Correct-Horse-Battery-Staple-S2")
|
||||
await conn.execute(
|
||||
"INSERT INTO tenant_admins (user_id, tenant_id) VALUES (?, 1)", (tenant_admin_id,)
|
||||
)
|
||||
await conn.commit()
|
||||
normal_user_id = await _create_user(conn, "sv_normal", "Correct-Horse-Battery-Staple-S2")
|
||||
|
||||
await _login_full(client, "sv_tenant", "Correct-Horse-Battery-Staple-S2")
|
||||
await _login_full(client, "sv_normal", "Correct-Horse-Battery-Staple-S2")
|
||||
resp = await client.get("/admin/sessions")
|
||||
assert resp.status_code == 403, resp.text
|
||||
|
||||
@ -205,22 +247,29 @@ async def test_credentials_manage_role_grants_non_admin_write_access(client):
|
||||
hg_id, host_id = await _setup_hostgroup_and_host(conn, group_name="cr-group", hostname="cr-host")
|
||||
|
||||
await _login_full(client, "cr_admin", "Correct-Horse-Battery-Staple-C1")
|
||||
resp = await client.post(
|
||||
"/admin/roles/grant",
|
||||
json={"user_id": holder_id, "host_group_id": hg_id, "role_names": ["credentials_manage"]},
|
||||
holder_group_id = await _grant_group_role(
|
||||
conn, user_id=holder_id, host_group_id=hg_id, role_names=["credentials_manage"],
|
||||
group_name="cr-holder-team",
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
# 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},
|
||||
json={"label": "cr-test-cred", "username": "Administrator", "password": "s3hr-geheim!!"},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
credential_id = resp.json()["id"]
|
||||
|
||||
# Teil D Schritt 5 (S4): die Zuweisung an einen Host prueft bei einem
|
||||
# Nicht-Admin zusaetzlich, ob eine seiner Gruppen den Datensatz ueber
|
||||
# Achse B freigegeben bekommen hat -- ohne diese Zeile wuerde der
|
||||
# POST unten mit 403 scheitern, obwohl credentials_manage vorhanden ist.
|
||||
await _grant_group_credential(
|
||||
conn, user_group_id=holder_group_id, kind="rdp_credential", credential_id=credential_id
|
||||
)
|
||||
|
||||
# Rolleninhaber (kein Admin!) darf Zugangsdaten lesen UND zuweisen.
|
||||
client.cookies.clear()
|
||||
await _login_full(client, "cr_holder", "Correct-Horse-Battery-Staple-C2")
|
||||
@ -256,11 +305,10 @@ async def test_credentials_view_role_is_read_only(client):
|
||||
hg_id, host_id = await _setup_hostgroup_and_host(conn, group_name="cv-group", hostname="cv-host")
|
||||
|
||||
await _login_full(client, "cv_admin", "Correct-Horse-Battery-Staple-C4")
|
||||
resp = await client.post(
|
||||
"/admin/roles/grant",
|
||||
json={"user_id": viewer_id, "host_group_id": hg_id, "role_names": ["credentials_view"]},
|
||||
await _grant_group_role(
|
||||
conn, user_id=viewer_id, host_group_id=hg_id, role_names=["credentials_view"],
|
||||
group_name="cv-viewer-team",
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
client.cookies.clear()
|
||||
await _login_full(client, "cv_viewer", "Correct-Horse-Battery-Staple-C5")
|
||||
@ -285,9 +333,9 @@ async def test_catalog_hosts_reports_can_view_credentials_flag(client):
|
||||
hg_id, host_id = await _setup_hostgroup_and_host(conn, group_name="cc-group", hostname="cc-host")
|
||||
|
||||
await _login_full(client, "cc_admin", "Correct-Horse-Battery-Staple-C6")
|
||||
await client.post(
|
||||
"/admin/roles/grant",
|
||||
json={"user_id": user_id, "host_group_id": hg_id, "role_names": ["ssh_connect", "credentials_view"]},
|
||||
await _grant_group_role(
|
||||
conn, user_id=user_id, host_group_id=hg_id, role_names=["ssh_connect", "credentials_view"],
|
||||
group_name="cc-user-team",
|
||||
)
|
||||
|
||||
client.cookies.clear()
|
||||
|
||||
@ -1,3 +1,11 @@
|
||||
"""Teil D.6 Schritt 7: vollstaendig neu geschrieben (Umsetzungsauftrag_
|
||||
Sonnet5.md D.6 Schritt 7 nennt diese Datei ausdruecklich als Beispiel --
|
||||
sie schrieb direkt in user_hostgroup_roles, das app/rbac.py seit Schritt 4
|
||||
nicht mehr liest und das seit Migration 0019 (Schritt 5) nicht mehr unter
|
||||
diesem Namen existiert -- die Tabelle heisst jetzt
|
||||
user_hostgroup_roles_legacy). Rechte werden ausschliesslich noch ueber
|
||||
Benutzergruppen vergeben (group_hostgroup_roles), daher seeden alle Tests
|
||||
hier ueber eine Gruppe."""
|
||||
import pytest
|
||||
import aiosqlite
|
||||
|
||||
@ -21,11 +29,14 @@ async def test_rbac_grants_and_expiry():
|
||||
"INSERT INTO hosts (id, host_group_id, hostname, address, protocol, port, os_type) "
|
||||
"VALUES (1, 1, 'db01', '10.0.0.1', 'ssh', 22, 'linux')"
|
||||
)
|
||||
await conn.execute("INSERT INTO user_groups (id, name) VALUES (1, 'alice-team')")
|
||||
await conn.execute("INSERT INTO user_group_members (user_group_id, user_id) VALUES (1, 1)")
|
||||
await conn.commit()
|
||||
|
||||
assert not await user_has_role(conn, user_id=1, host_group_id=1, role_name="ssh_connect")
|
||||
|
||||
await conn.execute(
|
||||
"INSERT INTO user_hostgroup_roles (user_id, host_group_id, role_id) VALUES (1, 1, 1)"
|
||||
"INSERT INTO group_hostgroup_roles (user_group_id, host_group_id, role_id) VALUES (1, 1, 1)"
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
@ -35,9 +46,54 @@ async def test_rbac_grants_and_expiry():
|
||||
|
||||
# Abgelaufene Freigabe darf nicht mehr gelten.
|
||||
await conn.execute(
|
||||
"UPDATE user_hostgroup_roles SET expires_at = '2000-01-01T00:00:00.000000Z' "
|
||||
"WHERE user_id = 1 AND host_group_id = 1 AND role_id = 1"
|
||||
"UPDATE group_hostgroup_roles SET expires_at = '2000-01-01T00:00:00.000000Z' "
|
||||
"WHERE user_group_id = 1 AND host_group_id = 1 AND role_id = 1"
|
||||
)
|
||||
await conn.commit()
|
||||
assert not await user_has_role(conn, user_id=1, host_group_id=1, role_name="ssh_connect")
|
||||
await conn.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rbac_role_survives_via_second_group_after_first_expires():
|
||||
"""S1 (Umsetzungsauftrag_Sonnet5.md Teil D.2): ein Benutzer kann
|
||||
dieselbe Rolle ueber MEHRERE Gruppen halten -- laeuft die Freigabe
|
||||
einer Gruppe ab, darf das Recht bestehen bleiben, solange eine andere
|
||||
Gruppe es weiterhin gewaehrt (genau das macht GET /admin/roles seit
|
||||
Teil D Schritt 5 ueber die Spalte via_group_name sichtbar)."""
|
||||
conn = await _fresh_db()
|
||||
await conn.execute("INSERT INTO users (id, username, password_hash) VALUES (1, 'bob', 'x')")
|
||||
await conn.execute("INSERT INTO host_groups (id, name) VALUES (1, 'linux-prod')")
|
||||
await conn.execute("INSERT INTO user_groups (id, name) VALUES (1, 'team-a'), (2, 'team-b')")
|
||||
await conn.execute(
|
||||
"INSERT INTO user_group_members (user_group_id, user_id) VALUES (1, 1), (2, 1)"
|
||||
)
|
||||
await conn.execute(
|
||||
"INSERT INTO group_hostgroup_roles (user_group_id, host_group_id, role_id, expires_at) "
|
||||
"VALUES (1, 1, 1, '2000-01-01T00:00:00.000000Z'), (2, 1, 1, NULL)"
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
assert await user_has_role(conn, user_id=1, host_group_id=1, role_name="ssh_connect")
|
||||
await conn.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rbac_role_does_not_leak_across_host_groups():
|
||||
"""Eine Rolle auf Hostgruppe 1 darf keinen Zugriff auf Hostgruppe 2
|
||||
gewaehren."""
|
||||
conn = await _fresh_db()
|
||||
await conn.execute("INSERT INTO users (id, username, password_hash) VALUES (1, 'carol', 'x')")
|
||||
await conn.execute(
|
||||
"INSERT INTO host_groups (id, name) VALUES (1, 'linux-prod'), (2, 'linux-test')"
|
||||
)
|
||||
await conn.execute("INSERT INTO user_groups (id, name) VALUES (1, 'carol-team')")
|
||||
await conn.execute("INSERT INTO user_group_members (user_group_id, user_id) VALUES (1, 1)")
|
||||
await conn.execute(
|
||||
"INSERT INTO group_hostgroup_roles (user_group_id, host_group_id, role_id) VALUES (1, 1, 1)"
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
assert await user_has_role(conn, user_id=1, host_group_id=1, role_name="ssh_connect")
|
||||
assert not await user_has_role(conn, user_id=1, host_group_id=2, role_name="ssh_connect")
|
||||
await conn.close()
|
||||
|
||||
108
tests/test_session_reaper.py
Normal file
108
tests/test_session_reaper.py
Normal file
@ -0,0 +1,108 @@
|
||||
import aiosqlite
|
||||
import pytest
|
||||
|
||||
from app.db import MIGRATIONS_DIR
|
||||
from app.security.audit import verify_chain
|
||||
from app.security.session_reaper import reap_orphaned_sessions
|
||||
|
||||
|
||||
async def _fresh_db() -> aiosqlite.Connection:
|
||||
conn = await aiosqlite.connect(":memory:")
|
||||
for migration_file in sorted(MIGRATIONS_DIR.glob("*.sql")):
|
||||
await conn.executescript(migration_file.read_text(encoding="utf-8"))
|
||||
await conn.execute(
|
||||
"INSERT INTO users (id, username, password_hash) VALUES (1, 'u', 'x')"
|
||||
)
|
||||
await conn.execute(
|
||||
"INSERT INTO host_groups (id, name) VALUES (1, 'hg')"
|
||||
)
|
||||
await conn.execute(
|
||||
"INSERT INTO hosts (id, host_group_id, hostname, address, protocol, port, os_type) "
|
||||
"VALUES (1, 1, 'h1', '10.0.0.1', 'ssh', 22, 'linux')"
|
||||
)
|
||||
await conn.commit()
|
||||
return conn
|
||||
|
||||
|
||||
async def _insert_session(conn, *, ended: bool) -> int:
|
||||
if ended:
|
||||
await conn.execute(
|
||||
"INSERT INTO sessions (user_id, host_id, protocol, client_ip, ended_at, end_reason) "
|
||||
"VALUES (1, 1, 'ssh', '127.0.0.1', strftime('%Y-%m-%dT%H:%M:%fZ','now'), 'logout')"
|
||||
)
|
||||
else:
|
||||
await conn.execute(
|
||||
"INSERT INTO sessions (user_id, host_id, protocol, client_ip) "
|
||||
"VALUES (1, 1, 'ssh', '127.0.0.1')"
|
||||
)
|
||||
await conn.commit()
|
||||
cursor = await conn.execute("SELECT last_insert_rowid()")
|
||||
row = await cursor.fetchone()
|
||||
return row[0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reap_closes_only_open_sessions():
|
||||
"""E7 (Umsetzungsauftrag_Sonnet5.md Teil E.1): beim Start muessen alle
|
||||
Sitzungen mit ended_at IS NULL geschlossen werden (server_restart),
|
||||
bereits beendete Sitzungen bleiben unangetastet."""
|
||||
conn = await _fresh_db()
|
||||
|
||||
already_ended = await _insert_session(conn, ended=True)
|
||||
orphan_1 = await _insert_session(conn, ended=False)
|
||||
orphan_2 = await _insert_session(conn, ended=False)
|
||||
|
||||
count = await reap_orphaned_sessions(conn)
|
||||
assert count == 2
|
||||
|
||||
cursor = await conn.execute(
|
||||
"SELECT id, ended_at, end_reason FROM sessions ORDER BY id"
|
||||
)
|
||||
rows = {row[0]: (row[1], row[2]) async for row in cursor}
|
||||
|
||||
assert rows[already_ended][1] == "logout"
|
||||
for sid in (orphan_1, orphan_2):
|
||||
ended_at, end_reason = rows[sid]
|
||||
assert ended_at is not None
|
||||
assert end_reason == "server_restart"
|
||||
|
||||
await conn.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reap_is_noop_when_nothing_open():
|
||||
conn = await _fresh_db()
|
||||
await _insert_session(conn, ended=True)
|
||||
|
||||
count = await reap_orphaned_sessions(conn)
|
||||
assert count == 0
|
||||
|
||||
await conn.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reap_writes_single_audit_event_and_keeps_chain_intact():
|
||||
conn = await _fresh_db()
|
||||
await _insert_session(conn, ended=False)
|
||||
await _insert_session(conn, ended=False)
|
||||
await _insert_session(conn, ended=False)
|
||||
|
||||
await reap_orphaned_sessions(conn)
|
||||
|
||||
cursor = await conn.execute(
|
||||
"SELECT COUNT(*) FROM audit_log WHERE event_type = 'session_reaper_server_restart'"
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
assert row[0] == 1
|
||||
|
||||
cursor = await conn.execute(
|
||||
"SELECT details_json FROM audit_log WHERE event_type = 'session_reaper_server_restart'"
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
assert '"count": 3' in row[0]
|
||||
|
||||
intact, broken_at = await verify_chain(conn)
|
||||
assert intact is True
|
||||
assert broken_at is None
|
||||
|
||||
await conn.close()
|
||||
108
tests/test_session_recorder.py
Normal file
108
tests/test_session_recorder.py
Normal file
@ -0,0 +1,108 @@
|
||||
"""Tests fuer app/recordings/recorder.py (Umsetzungsauftrag Teil A D2 /
|
||||
Teil E E2): Puffer/Executor-Auslagerung, Hash-Kette, Rotation und
|
||||
Groessenbegrenzung."""
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from app.recordings import recorder as rec_mod
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolated_recordings_dir(tmp_path, monkeypatch):
|
||||
"""settings ist ein Prozess-Singleton (siehe conftest.py) -- fuer
|
||||
Testisolation wird recordings_dir direkt umgebogen statt ueber die
|
||||
Umgebungsvariable (die nach dem ersten Import wirkungslos waere)."""
|
||||
from app.config import settings
|
||||
|
||||
recordings_dir = tmp_path / "recordings"
|
||||
recordings_dir.mkdir(parents=True, exist_ok=True)
|
||||
monkeypatch.setattr(settings, "recordings_dir", recordings_dir)
|
||||
monkeypatch.setattr(rec_mod.SessionRecorder, "FLUSH_INTERVAL_S", 0.02)
|
||||
yield
|
||||
|
||||
|
||||
async def test_record_is_non_blocking_and_flushes_via_background_task():
|
||||
rec = rec_mod.SessionRecorder(session_id=1)
|
||||
for i in range(10):
|
||||
rec.record("output", f"frame-{i}")
|
||||
with open(rec.path, encoding="utf-8") as fh:
|
||||
assert fh.read() == "" # record() darf nicht sofort schreiben
|
||||
|
||||
await asyncio.sleep(0.2)
|
||||
with open(rec.path, encoding="utf-8") as fh:
|
||||
lines = [l for l in fh if l.strip()]
|
||||
assert len(lines) == 10
|
||||
assert rec_mod.verify_recording(rec.path)
|
||||
await rec.aclose()
|
||||
|
||||
|
||||
async def test_aclose_flushes_pending_buffer_even_with_long_interval():
|
||||
rec_mod.SessionRecorder.FLUSH_INTERVAL_S = 100
|
||||
rec = rec_mod.SessionRecorder(session_id=2)
|
||||
for i in range(5):
|
||||
rec.record("input", f"x{i}")
|
||||
await rec.aclose()
|
||||
with open(rec.path, encoding="utf-8") as fh:
|
||||
lines = [l for l in fh if l.strip()]
|
||||
assert len(lines) == 5
|
||||
assert rec_mod.verify_recording(rec.path)
|
||||
|
||||
|
||||
async def test_concurrent_recorders_do_not_interfere():
|
||||
recs = [rec_mod.SessionRecorder(session_id=100 + i) for i in range(5)]
|
||||
for i, r in enumerate(recs):
|
||||
for j in range(20):
|
||||
r.record("output", f"s{i}-{j}")
|
||||
await asyncio.sleep(0.3)
|
||||
for r in recs:
|
||||
with open(r.path, encoding="utf-8") as fh:
|
||||
lines = [l for l in fh if l.strip()]
|
||||
assert len(lines) == 20
|
||||
assert rec_mod.verify_recording(r.path)
|
||||
await r.aclose()
|
||||
|
||||
|
||||
async def test_rotation_splits_into_multiple_valid_parts():
|
||||
from app.config import settings
|
||||
|
||||
settings.recording_max_part_bytes = 400
|
||||
settings.recording_max_total_bytes = 10_000_000
|
||||
rec = rec_mod.SessionRecorder(session_id=3)
|
||||
rec._max_part_bytes = 400
|
||||
rec._max_total_bytes = 10_000_000
|
||||
for i in range(60):
|
||||
rec.record("output", f"payload-{i:04d}-" + ("x" * 20))
|
||||
await rec.aclose()
|
||||
|
||||
parts = list(rec_mod._iter_part_paths(rec.path))
|
||||
assert len(parts) > 1
|
||||
assert rec_mod.verify_recording_set(rec.path)
|
||||
assert rec_mod.count_recording_entries(rec.path) == 60
|
||||
|
||||
for p in parts:
|
||||
with open(p, encoding="utf-8") as fh:
|
||||
first_line = next((l for l in fh if l.strip()), None)
|
||||
assert first_line is not None
|
||||
assert json.loads(first_line)["prev_hash"] == rec_mod.GENESIS_HASH
|
||||
|
||||
|
||||
async def test_total_size_limit_truncates_and_stops_accepting_entries():
|
||||
rec = rec_mod.SessionRecorder(session_id=4)
|
||||
rec._max_part_bytes = 10_000_000
|
||||
rec._max_total_bytes = 500
|
||||
for i in range(50):
|
||||
rec.record("output", f"payload-{i:04d}-" + ("y" * 20))
|
||||
await rec.aclose()
|
||||
|
||||
total_entries = rec_mod.count_recording_entries(rec.path)
|
||||
assert 0 < total_entries < 50
|
||||
assert rec_mod.verify_recording_set(rec.path)
|
||||
|
||||
entries = list(rec_mod.iter_recording_entries(rec.path))
|
||||
assert "truncated" in entries[-1]["data"]
|
||||
|
||||
rec.record("output", "should-be-dropped")
|
||||
await asyncio.sleep(0.1)
|
||||
assert rec_mod.count_recording_entries(rec.path) == total_entries
|
||||
341
tests/test_teil_d_schritt5.py
Normal file
341
tests/test_teil_d_schritt5.py
Normal file
@ -0,0 +1,341 @@
|
||||
"""Tests fuer Teil D.6 Schritt 5 ("Schreibpfade abschalten"):
|
||||
|
||||
1) POST /admin/roles/grant und /revoke antworten mit HTTP 410.
|
||||
2) GET /admin/roles liefert die abgeleitete Effektiv-Rechte-Sicht
|
||||
(via_group_name) statt einer Vergabe-Tabelle.
|
||||
3) Die neuen Achse-B-Endpunkte (/admin/group-credentials/{kind}/grant|
|
||||
revoke, GET .../{kind}) funktionieren fuer alle drei Credential-Arten.
|
||||
4) GET /admin/ssh-password-credentials liefert eine globale Liste.
|
||||
5) S4: ein Nicht-Admin mit credentials_manage darf NUR einen SSH-Key/eine
|
||||
RDP-Zugangsdaten-Ressource an einen Host haengen, wenn seine Gruppe sie
|
||||
ueber Achse B freigegeben hat -- ein fremder, nicht freigegebener
|
||||
Datensatz wird mit 403 abgelehnt. Ein API-Token mit passendem Scope
|
||||
bleibt davon unberuehrt (admin.is_token).
|
||||
6) S10: der Audit-Event bei Gruppenbeitritt/-austritt enthaelt
|
||||
gained_rights/lost_rights.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pyotp
|
||||
import pytest
|
||||
|
||||
|
||||
async def _create_user(conn, username: str, password: str, *, is_admin: bool = False) -> int:
|
||||
from app.security.passwords import hash_password
|
||||
|
||||
cursor = await conn.execute(
|
||||
"INSERT INTO users (username, password_hash, is_admin, must_change_password) "
|
||||
"VALUES (?, ?, ?, 0)",
|
||||
(username, hash_password(password), int(is_admin)),
|
||||
)
|
||||
await conn.commit()
|
||||
return cursor.lastrowid
|
||||
|
||||
|
||||
async def _login_full(client, username: str, password: str) -> str:
|
||||
resp = await client.post("/auth/login", json={"username": username, "password": password})
|
||||
assert resp.status_code == 200, resp.text
|
||||
pending = resp.json()["pending_token"]
|
||||
resp = await client.post("/auth/totp/enroll/start", json={"pending_token": pending})
|
||||
assert resp.status_code == 200, resp.text
|
||||
provisioning_uri = resp.json()["provisioning_uri"]
|
||||
secret = dict(part.split("=") for part in provisioning_uri.split("?", 1)[1].split("&"))["secret"]
|
||||
code = pyotp.TOTP(secret).now()
|
||||
resp = await client.post("/auth/totp/enroll/confirm", json={"pending_token": pending, "code": code})
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.cookies.get("jh_session")
|
||||
|
||||
|
||||
async def _setup_hostgroup_and_host(conn, *, group_name="team-a", hostname="srv-a"):
|
||||
cursor = await conn.execute("INSERT INTO host_groups (name) VALUES (?)", (group_name,))
|
||||
hg_id = cursor.lastrowid
|
||||
cursor = await conn.execute(
|
||||
"INSERT INTO hosts (host_group_id, hostname, address, protocol, port, os_type) "
|
||||
"VALUES (?, ?, '10.0.0.9', 'ssh', 22, 'linux')",
|
||||
(hg_id, hostname),
|
||||
)
|
||||
await conn.commit()
|
||||
return hg_id, cursor.lastrowid
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_direct_role_grant_endpoints_are_gone(client):
|
||||
from app.db import get_db
|
||||
|
||||
conn = get_db()
|
||||
await _create_user(conn, "s5_admin1", "Correct-Horse-Battery-Staple-1", is_admin=True)
|
||||
await _login_full(client, "s5_admin1", "Correct-Horse-Battery-Staple-1")
|
||||
|
||||
resp = await client.post("/admin/roles/grant", json={"foo": "bar"})
|
||||
assert resp.status_code == 410, resp.text
|
||||
|
||||
resp = await client.post("/admin/roles/revoke", json={"foo": "bar"})
|
||||
assert resp.status_code == 410, resp.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_effective_roles_view_shows_via_group(client):
|
||||
from app.db import get_db
|
||||
|
||||
conn = get_db()
|
||||
admin_id = await _create_user(conn, "s5_admin2", "Correct-Horse-Battery-Staple-2", is_admin=True)
|
||||
member_id = await _create_user(conn, "s5_member2", "Correct-Horse-Battery-Staple-3")
|
||||
hg_id, host_id = await _setup_hostgroup_and_host(conn, group_name="s5-hg", hostname="s5-host")
|
||||
|
||||
await _login_full(client, "s5_admin2", "Correct-Horse-Battery-Staple-2")
|
||||
|
||||
resp = await client.post("/admin/user-groups", json={"name": "s5-gruppe", "description": None})
|
||||
assert resp.status_code == 201, resp.text
|
||||
group_id = resp.json()["id"]
|
||||
|
||||
resp = await client.post(f"/admin/user-groups/{group_id}/members", json={"user_id": member_id})
|
||||
assert resp.status_code == 201, resp.text
|
||||
|
||||
resp = await client.post(
|
||||
"/admin/group-roles/grant",
|
||||
json={"user_group_id": group_id, "host_group_id": hg_id, "role_names": ["ssh_connect"]},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
resp = await client.get("/admin/roles")
|
||||
assert resp.status_code == 200, resp.text
|
||||
rows = resp.json()
|
||||
match = [r for r in rows if r["user_id"] == member_id and r["host_group_id"] == hg_id]
|
||||
assert len(match) == 1, rows
|
||||
row = match[0]
|
||||
assert row["role_name"] == "ssh_connect"
|
||||
assert row["via_group_id"] == group_id
|
||||
assert row["via_group_name"] == "s5-gruppe"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_credential_grant_revoke_list_all_kinds(client):
|
||||
from app.db import get_db
|
||||
from app.security.crypto import encrypt_secret
|
||||
|
||||
conn = get_db()
|
||||
await _create_user(conn, "s5_admin3", "Correct-Horse-Battery-Staple-4", is_admin=True)
|
||||
await _login_full(client, "s5_admin3", "Correct-Horse-Battery-Staple-4")
|
||||
|
||||
resp = await client.post("/admin/user-groups", json={"name": "s5-cred-gruppe", "description": None})
|
||||
group_id = resp.json()["id"]
|
||||
|
||||
# SSH-Key anlegen
|
||||
resp = await client.post(
|
||||
"/admin/ssh-keys/generate", json={"key_type": "ed25519"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
key_material = resp.json()
|
||||
resp = await client.post(
|
||||
"/admin/ssh-keys",
|
||||
json={
|
||||
"label": "s5-key", "key_type": "ed25519",
|
||||
"private_key_pem": key_material["private_key_pem"],
|
||||
"public_key": key_material["public_key"],
|
||||
"passphrase": None, "username": "root",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
ssh_key_id = resp.json()["id"]
|
||||
|
||||
# RDP-Zugangsdaten anlegen
|
||||
resp = await client.post(
|
||||
"/admin/rdp-credentials",
|
||||
json={"label": "s5-rdp", "username": "administrator", "domain": None, "password": "Sup3rSecret!"},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
rdp_id = resp.json()["id"]
|
||||
|
||||
# SSH-Passwort-Objekt direkt in der DB anlegen (kein eigener globaler
|
||||
# Create-Endpunkt -- entsteht normalerweise ueber PUT .../ssh-password).
|
||||
encrypted = encrypt_secret(b"hunter2", associated_data=b"ssh_password")
|
||||
cursor = await conn.execute(
|
||||
"INSERT INTO ssh_password_credentials (label, username, password_enc) VALUES (?, ?, ?)",
|
||||
("s5-pw", "root", encrypted),
|
||||
)
|
||||
await conn.commit()
|
||||
pw_id = cursor.lastrowid
|
||||
|
||||
cases = [
|
||||
("ssh_key", ssh_key_id),
|
||||
("rdp_credential", rdp_id),
|
||||
("ssh_password_credential", pw_id),
|
||||
]
|
||||
for kind, credential_id in cases:
|
||||
resp = await client.post(
|
||||
f"/admin/group-credentials/{kind}/grant",
|
||||
json={"user_group_id": group_id, "credential_id": credential_id, "expires_at": None},
|
||||
)
|
||||
assert resp.status_code == 200, (kind, resp.text)
|
||||
|
||||
resp = await client.get(f"/admin/group-credentials/{kind}")
|
||||
assert resp.status_code == 200, (kind, resp.text)
|
||||
rows = resp.json()
|
||||
assert any(
|
||||
r["user_group_id"] == group_id and r["credential_id"] == credential_id for r in rows
|
||||
), (kind, rows)
|
||||
|
||||
resp = await client.post(
|
||||
f"/admin/group-credentials/{kind}/revoke",
|
||||
json={"user_group_id": group_id, "credential_id": credential_id},
|
||||
)
|
||||
assert resp.status_code == 200, (kind, resp.text)
|
||||
|
||||
resp = await client.get(f"/admin/group-credentials/{kind}")
|
||||
rows = resp.json()
|
||||
assert not any(
|
||||
r["user_group_id"] == group_id and r["credential_id"] == credential_id for r in rows
|
||||
), (kind, rows)
|
||||
|
||||
# Unbekannte Credential-Art im Pfad -> 422 (Literal-Validierung)
|
||||
resp = await client.post(
|
||||
f"/admin/group-credentials/telepathy/grant",
|
||||
json={"user_group_id": group_id, "credential_id": ssh_key_id, "expires_at": None},
|
||||
)
|
||||
assert resp.status_code == 422, resp.text
|
||||
|
||||
# Nicht existierender Zugangsdatensatz -> 404
|
||||
resp = await client.post(
|
||||
"/admin/group-credentials/ssh_key/grant",
|
||||
json={"user_group_id": group_id, "credential_id": 999999, "expires_at": None},
|
||||
)
|
||||
assert resp.status_code == 404, resp.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_ssh_password_credentials_global(client):
|
||||
from app.db import get_db
|
||||
from app.security.crypto import encrypt_secret
|
||||
|
||||
conn = get_db()
|
||||
await _create_user(conn, "s5_admin4", "Correct-Horse-Battery-Staple-5", is_admin=True)
|
||||
await _login_full(client, "s5_admin4", "Correct-Horse-Battery-Staple-5")
|
||||
|
||||
hg_id, host_id = await _setup_hostgroup_and_host(conn, group_name="s5-pw-hg", hostname="s5-pw-host")
|
||||
encrypted = encrypt_secret(b"hunter2", associated_data=b"ssh_password")
|
||||
cursor = await conn.execute(
|
||||
"INSERT INTO ssh_password_credentials (label, username, password_enc) VALUES (?, ?, ?)",
|
||||
("s5-pw-list", "root", encrypted),
|
||||
)
|
||||
pw_id = cursor.lastrowid
|
||||
await conn.execute(
|
||||
"INSERT INTO host_ssh_password_credential_map (host_id, ssh_password_credential_id) VALUES (?, ?)",
|
||||
(host_id, pw_id),
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
resp = await client.get("/admin/ssh-password-credentials")
|
||||
assert resp.status_code == 200, resp.text
|
||||
rows = resp.json()
|
||||
match = [r for r in rows if r["id"] == pw_id]
|
||||
assert len(match) == 1, rows
|
||||
assert match[0]["assigned_hosts"] == [{"id": host_id, "hostname": "s5-pw-host"}]
|
||||
assert "password" not in match[0] and "password_enc" not in match[0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_s4_non_admin_needs_group_grant_to_attach_credential(client):
|
||||
from app.db import get_db
|
||||
|
||||
conn = get_db()
|
||||
admin_id = await _create_user(conn, "s5_admin5", "Correct-Horse-Battery-Staple-6", is_admin=True)
|
||||
member_id = await _create_user(conn, "s5_member5", "Correct-Horse-Battery-Staple-7")
|
||||
hg_id, host_id = await _setup_hostgroup_and_host(conn, group_name="s5-s4-hg", hostname="s5-s4-host")
|
||||
|
||||
await _login_full(client, "s5_admin5", "Correct-Horse-Battery-Staple-6")
|
||||
|
||||
resp = await client.post("/admin/user-groups", json={"name": "s5-s4-gruppe", "description": None})
|
||||
group_id = resp.json()["id"]
|
||||
resp = await client.post(f"/admin/user-groups/{group_id}/members", json={"user_id": member_id})
|
||||
assert resp.status_code == 201, resp.text
|
||||
resp = await client.post(
|
||||
"/admin/group-roles/grant",
|
||||
json={
|
||||
"user_group_id": group_id, "host_group_id": hg_id,
|
||||
"role_names": ["ssh_connect", "credentials_manage"],
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
# Zwei SSH-Keys anlegen: einer wird der Gruppe freigegeben, der andere nicht.
|
||||
key_ids = {}
|
||||
for label in ("granted", "not-granted"):
|
||||
resp = await client.post("/admin/ssh-keys/generate", json={"key_type": "ed25519"})
|
||||
km = resp.json()
|
||||
resp = await client.post(
|
||||
"/admin/ssh-keys",
|
||||
json={
|
||||
"label": f"s5-s4-{label}", "key_type": "ed25519",
|
||||
"private_key_pem": km["private_key_pem"], "public_key": km["public_key"],
|
||||
"passphrase": None, "username": "root",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
key_ids[label] = resp.json()["id"]
|
||||
|
||||
resp = await client.post(
|
||||
"/admin/group-credentials/ssh_key/grant",
|
||||
json={"user_group_id": group_id, "credential_id": key_ids["granted"], "expires_at": None},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
client.cookies.clear()
|
||||
await _login_full(client, "s5_member5", "Correct-Horse-Battery-Staple-7")
|
||||
|
||||
# Freigegebener Key: darf angehaengt werden.
|
||||
resp = await client.post(f"/admin/hosts/{host_id}/ssh-keys/{key_ids['granted']}")
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
# Nicht freigegebener Key (existiert im System, aber der Gruppe nicht
|
||||
# freigegeben) -- S4: muss abgelehnt werden.
|
||||
resp = await client.post(f"/admin/hosts/{host_id}/ssh-keys/{key_ids['not-granted']}")
|
||||
assert resp.status_code == 403, resp.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_s10_audit_event_carries_gained_and_lost_rights(client):
|
||||
from app.db import get_db
|
||||
|
||||
conn = get_db()
|
||||
admin_id = await _create_user(conn, "s5_admin6", "Correct-Horse-Battery-Staple-8", is_admin=True)
|
||||
member_id = await _create_user(conn, "s5_member6", "Correct-Horse-Battery-Staple-9")
|
||||
hg_id, host_id = await _setup_hostgroup_and_host(conn, group_name="s5-s10-hg", hostname="s5-s10-host")
|
||||
|
||||
await _login_full(client, "s5_admin6", "Correct-Horse-Battery-Staple-8")
|
||||
|
||||
resp = await client.post("/admin/user-groups", json={"name": "s5-s10-gruppe", "description": None})
|
||||
group_id = resp.json()["id"]
|
||||
resp = await client.post(
|
||||
"/admin/group-roles/grant",
|
||||
json={"user_group_id": group_id, "host_group_id": hg_id, "role_names": ["ssh_connect"]},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
resp = await client.post(f"/admin/user-groups/{group_id}/members", json={"user_id": member_id})
|
||||
assert resp.status_code == 201, resp.text
|
||||
|
||||
row = await (await conn.execute(
|
||||
"SELECT details_json FROM audit_log WHERE event_type = 'user_group_member_added' "
|
||||
"ORDER BY id DESC LIMIT 1"
|
||||
)).fetchone()
|
||||
import json as _json
|
||||
details = _json.loads(row[0])
|
||||
assert "gained_rights" in details, details
|
||||
assert any(
|
||||
r["host_group"] == "s5-s10-hg" and r["role"] == "ssh_connect"
|
||||
for r in details["gained_rights"]["roles"]
|
||||
), details
|
||||
|
||||
resp = await client.delete(f"/admin/user-groups/{group_id}/members/{member_id}")
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
row = await (await conn.execute(
|
||||
"SELECT details_json FROM audit_log WHERE event_type = 'user_group_member_removed' "
|
||||
"ORDER BY id DESC LIMIT 1"
|
||||
)).fetchone()
|
||||
details = _json.loads(row[0])
|
||||
assert "lost_rights" in details, details
|
||||
assert any(
|
||||
r["host_group"] == "s5-s10-hg" and r["role"] == "ssh_connect"
|
||||
for r in details["lost_rights"]["roles"]
|
||||
), details
|
||||
166
tests/test_teil_d_schritt6.py
Normal file
166
tests/test_teil_d_schritt6.py
Normal file
@ -0,0 +1,166 @@
|
||||
"""Tests fuer Teil D.6 Schritt 6 ("Konsolidierung"):
|
||||
|
||||
1) GET /admin/reports/personal-groups gruppiert persoenliche Gruppen nach
|
||||
identischem Rechteprofil -- zwei Gruppen mit identischen Rechten landen
|
||||
im selben Cluster (consolidation_candidate=True), eine Gruppe mit
|
||||
abweichenden Rechten bleibt allein in ihrem Cluster.
|
||||
2) GET /admin/reports/unconfirmed-credential-grants zeigt nur Achse-B-
|
||||
Freigaben mit granted_by IS NULL (Vorbefuellungs-Herkunft) -- eine
|
||||
nachtraeglich manuell bestaetigte Freigabe (granted_by gesetzt)
|
||||
verschwindet aus dem Report.
|
||||
|
||||
Beide Endpunkte sind rein lesend; kein Test fuehrt eine automatische
|
||||
Zusammenlegung oder einen automatischen Entzug durch (den gibt es bewusst
|
||||
nicht, siehe Docstrings in app/admin/routes.py).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pyotp
|
||||
import pytest
|
||||
|
||||
|
||||
async def _create_user(conn, username: str, password: str, *, is_admin: bool = False) -> int:
|
||||
from app.security.passwords import hash_password
|
||||
|
||||
cursor = await conn.execute(
|
||||
"INSERT INTO users (username, password_hash, is_admin, must_change_password) "
|
||||
"VALUES (?, ?, ?, 0)",
|
||||
(username, hash_password(password), int(is_admin)),
|
||||
)
|
||||
await conn.commit()
|
||||
return cursor.lastrowid
|
||||
|
||||
|
||||
async def _login_full(client, username: str, password: str) -> str:
|
||||
resp = await client.post("/auth/login", json={"username": username, "password": password})
|
||||
assert resp.status_code == 200, resp.text
|
||||
pending = resp.json()["pending_token"]
|
||||
resp = await client.post("/auth/totp/enroll/start", json={"pending_token": pending})
|
||||
assert resp.status_code == 200, resp.text
|
||||
provisioning_uri = resp.json()["provisioning_uri"]
|
||||
secret = dict(part.split("=") for part in provisioning_uri.split("?", 1)[1].split("&"))["secret"]
|
||||
code = pyotp.TOTP(secret).now()
|
||||
resp = await client.post("/auth/totp/enroll/confirm", json={"pending_token": pending, "code": code})
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.cookies.get("jh_session")
|
||||
|
||||
|
||||
async def _make_personal_group(conn, *, name: str, user_id: int) -> int:
|
||||
cursor = await conn.execute(
|
||||
"INSERT INTO user_groups (name, description, is_personal) VALUES (?, NULL, 1)",
|
||||
(name,),
|
||||
)
|
||||
group_id = cursor.lastrowid
|
||||
await conn.execute(
|
||||
"INSERT INTO user_group_members (user_group_id, user_id, added_by) VALUES (?, ?, NULL)",
|
||||
(group_id, user_id),
|
||||
)
|
||||
await conn.commit()
|
||||
return group_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_personal_groups_report_clusters_identical_rights(client):
|
||||
from app.db import get_db
|
||||
|
||||
conn = get_db()
|
||||
admin_id = await _create_user(conn, "s6_admin1", "Correct-Horse-Battery-Staple-1", is_admin=True)
|
||||
u1 = await _create_user(conn, "s6_u1", "Correct-Horse-Battery-Staple-2")
|
||||
u2 = await _create_user(conn, "s6_u2", "Correct-Horse-Battery-Staple-3")
|
||||
u3 = await _create_user(conn, "s6_u3", "Correct-Horse-Battery-Staple-4")
|
||||
|
||||
await conn.execute("INSERT INTO host_groups (id, name) VALUES (5001, 's6-hg')")
|
||||
role_row = await (await conn.execute("SELECT id FROM roles WHERE name = 'ssh_connect'")).fetchone()
|
||||
role_id = role_row[0]
|
||||
await conn.commit()
|
||||
|
||||
pg1 = await _make_personal_group(conn, name="s6-pg-1", user_id=u1)
|
||||
pg2 = await _make_personal_group(conn, name="s6-pg-2", user_id=u2)
|
||||
pg3 = await _make_personal_group(conn, name="s6-pg-3", user_id=u3)
|
||||
|
||||
# pg1 und pg2 bekommen identische Rechte (ssh_connect auf derselben
|
||||
# Hostgruppe) -- Konsolidierungs-Kandidat. pg3 bleibt ohne jede Rolle
|
||||
# und muss daher in einem eigenen (ebenfalls "identischen", aber
|
||||
# einelementigen) Cluster landen.
|
||||
for group_id in (pg1, pg2):
|
||||
await conn.execute(
|
||||
"INSERT INTO group_hostgroup_roles (user_group_id, host_group_id, role_id, granted_by) "
|
||||
"VALUES (?, 5001, ?, ?)",
|
||||
(group_id, role_id, admin_id),
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
await _login_full(client, "s6_admin1", "Correct-Horse-Battery-Staple-1")
|
||||
resp = await client.get("/admin/reports/personal-groups")
|
||||
assert resp.status_code == 200, resp.text
|
||||
clusters = resp.json()
|
||||
|
||||
def cluster_containing(group_id):
|
||||
for c in clusters:
|
||||
if any(m["user_group_id"] == group_id for m in c["members"]):
|
||||
return c
|
||||
return None
|
||||
|
||||
c1 = cluster_containing(pg1)
|
||||
c2 = cluster_containing(pg2)
|
||||
c3 = cluster_containing(pg3)
|
||||
assert c1 is c2, "pg1 und pg2 muessen im selben Cluster landen (identisches Rechteprofil)"
|
||||
assert c1["consolidation_candidate"] is True
|
||||
assert c1["group_count"] == 2
|
||||
assert {m["user_group_id"] for m in c1["members"]} == {pg1, pg2}
|
||||
|
||||
assert c3 is not c1
|
||||
assert c3["consolidation_candidate"] is False
|
||||
assert c3["group_count"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unconfirmed_credential_grants_report(client):
|
||||
from app.db import get_db
|
||||
|
||||
conn = get_db()
|
||||
admin_id = await _create_user(conn, "s6_admin2", "Correct-Horse-Battery-Staple-5", is_admin=True)
|
||||
await _login_full(client, "s6_admin2", "Correct-Horse-Battery-Staple-5")
|
||||
|
||||
resp = await client.post("/admin/user-groups", json={"name": "s6-cred-gruppe", "description": None})
|
||||
group_id = resp.json()["id"]
|
||||
|
||||
resp = await client.post("/admin/ssh-keys/generate", json={"key_type": "ed25519"})
|
||||
km = resp.json()
|
||||
resp = await client.post(
|
||||
"/admin/ssh-keys",
|
||||
json={
|
||||
"label": "s6-key", "key_type": "ed25519",
|
||||
"private_key_pem": km["private_key_pem"], "public_key": km["public_key"],
|
||||
"passphrase": None, "username": "root",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
key_id = resp.json()["id"]
|
||||
|
||||
# Simuliert eine Vorbefuellungs-Zeile aus Migration 0018: granted_by IS NULL.
|
||||
await conn.execute(
|
||||
"INSERT INTO group_ssh_key_grants (user_group_id, ssh_key_id, granted_by) VALUES (?, ?, NULL)",
|
||||
(group_id, key_id),
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
resp = await client.get("/admin/reports/unconfirmed-credential-grants")
|
||||
assert resp.status_code == 200, resp.text
|
||||
rows = resp.json()
|
||||
match = [r for r in rows if r["user_group_id"] == group_id and r["credential_id"] == key_id]
|
||||
assert len(match) == 1, rows
|
||||
assert match[0]["kind"] == "ssh_key"
|
||||
|
||||
# Jetzt manuell ueber die API bestaetigen (granted_by wird auf admin.id
|
||||
# gesetzt) -- die Zeile muss aus dem Report verschwinden.
|
||||
resp = await client.post(
|
||||
"/admin/group-credentials/ssh_key/grant",
|
||||
json={"user_group_id": group_id, "credential_id": key_id, "expires_at": None},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
resp = await client.get("/admin/reports/unconfirmed-credential-grants")
|
||||
rows = resp.json()
|
||||
match = [r for r in rows if r["user_group_id"] == group_id and r["credential_id"] == key_id]
|
||||
assert len(match) == 0, rows
|
||||
180
tests/test_teil_f_schritt2.py
Normal file
180
tests/test_teil_f_schritt2.py
Normal file
@ -0,0 +1,180 @@
|
||||
"""Tests fuer Teil F.3.6 (Stufe F2, Umsetzungsauftrag_Sonnet5.md): eigene
|
||||
Sitzungs-API im Katalog-Router.
|
||||
|
||||
1) GET /catalog/sessions liefert AUSSCHLIESSLICH die eigenen Sitzungen des
|
||||
angemeldeten Benutzers -- eine fremde, gleichzeitig laufende Sitzung
|
||||
taucht nicht auf.
|
||||
2) active_only=true (Standard) blendet bereits beendete eigene Sitzungen
|
||||
aus; active_only=false zeigt sie.
|
||||
3) POST /catalog/sessions/{id}/terminate auf eine FREMDE session_id
|
||||
liefert 404 (F.5-Risikotabelle: 'Rechteumgehung ueber die neue
|
||||
Sitzungs-API' -- explizit geforderter Testfall), nicht etwa 403 (das
|
||||
wuerde die Existenz einer fremden Sitzung verraten) und nicht 200.
|
||||
4) POST .../terminate auf die EIGENE, tatsaechlich laufende Sitzung
|
||||
funktioniert: der zugehoerige asyncio.Task wird abgebrochen, die
|
||||
Sitzung landet als beendet in der DB, ein Audit-Ereignis
|
||||
'session_terminated_by_owner' wird geschrieben.
|
||||
5) POST .../terminate auf eine eigene, bereits beendete Sitzung liefert
|
||||
409 (nicht 200 -- kein stiller Erfolg auf einer Sitzung, die gar nicht
|
||||
mehr laeuft).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
|
||||
import pyotp
|
||||
import pytest
|
||||
|
||||
|
||||
async def _create_user(conn, username: str, password: str) -> int:
|
||||
from app.security.passwords import hash_password
|
||||
|
||||
cursor = await conn.execute(
|
||||
"INSERT INTO users (username, password_hash, is_admin, must_change_password) "
|
||||
"VALUES (?, ?, 0, 0)",
|
||||
(username, hash_password(password)),
|
||||
)
|
||||
await conn.commit()
|
||||
return cursor.lastrowid
|
||||
|
||||
|
||||
async def _login_full(client, username: str, password: str) -> None:
|
||||
resp = await client.post("/auth/login", json={"username": username, "password": password})
|
||||
assert resp.status_code == 200, resp.text
|
||||
pending = resp.json()["pending_token"]
|
||||
resp = await client.post("/auth/totp/enroll/start", json={"pending_token": pending})
|
||||
assert resp.status_code == 200, resp.text
|
||||
provisioning_uri = resp.json()["provisioning_uri"]
|
||||
secret = dict(part.split("=") for part in provisioning_uri.split("?", 1)[1].split("&"))["secret"]
|
||||
code = pyotp.TOTP(secret).now()
|
||||
resp = await client.post("/auth/totp/enroll/confirm", json={"pending_token": pending, "code": code})
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
|
||||
async def _make_host(conn, *, hostname: str) -> int:
|
||||
cursor = await conn.execute("INSERT INTO host_groups (name) VALUES (?)", (f"hg-{hostname}",))
|
||||
group_id = cursor.lastrowid
|
||||
cursor = await conn.execute(
|
||||
"INSERT INTO hosts (hostname, address, port, protocol, os_type, host_group_id) "
|
||||
"VALUES (?, '10.0.0.1', 22, 'ssh', 'linux', ?)",
|
||||
(hostname, group_id),
|
||||
)
|
||||
await conn.commit()
|
||||
return cursor.lastrowid
|
||||
|
||||
|
||||
async def _make_session(conn, *, user_id: int, host_id: int, ended: bool) -> int:
|
||||
cursor = await conn.execute(
|
||||
"INSERT INTO sessions (user_id, host_id, protocol, client_ip, ended_at, end_reason) "
|
||||
"VALUES (?, ?, 'ssh', '127.0.0.1', ?, ?)",
|
||||
(user_id, host_id, "2026-01-01T00:00:00.000000Z" if ended else None, "logout" if ended else None),
|
||||
)
|
||||
await conn.commit()
|
||||
return cursor.lastrowid
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_my_sessions_shows_only_own_and_respects_active_only(client):
|
||||
from app.db import get_db
|
||||
|
||||
conn = get_db()
|
||||
u1 = await _create_user(conn, "f2_u1", "Correct-Horse-Battery-Staple-1")
|
||||
u2 = await _create_user(conn, "f2_u2", "Correct-Horse-Battery-Staple-2")
|
||||
host_id = await _make_host(conn, hostname="f2-host-1")
|
||||
|
||||
my_open = await _make_session(conn, user_id=u1, host_id=host_id, ended=False)
|
||||
my_closed = await _make_session(conn, user_id=u1, host_id=host_id, ended=True)
|
||||
other_open = await _make_session(conn, user_id=u2, host_id=host_id, ended=False)
|
||||
|
||||
await _login_full(client, "f2_u1", "Correct-Horse-Battery-Staple-1")
|
||||
|
||||
resp = await client.get("/catalog/sessions")
|
||||
assert resp.status_code == 200, resp.text
|
||||
ids = {row["id"] for row in resp.json()}
|
||||
assert my_open in ids
|
||||
assert my_closed not in ids, "aktive-only (Standard) muss beendete eigene Sitzungen ausblenden"
|
||||
assert other_open not in ids, "fremde Sitzung darf NIE auftauchen"
|
||||
|
||||
resp = await client.get("/catalog/sessions", params={"active_only": "false"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
ids = {row["id"] for row in resp.json()}
|
||||
assert my_open in ids
|
||||
assert my_closed in ids, "active_only=false muss auch beendete eigene Sitzungen zeigen"
|
||||
assert other_open not in ids, "fremde Sitzung darf auch mit active_only=false nie auftauchen"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_terminate_foreign_session_returns_404_not_403(client):
|
||||
from app.db import get_db
|
||||
|
||||
conn = get_db()
|
||||
u1 = await _create_user(conn, "f2_u3", "Correct-Horse-Battery-Staple-3")
|
||||
u2 = await _create_user(conn, "f2_u4", "Correct-Horse-Battery-Staple-4")
|
||||
host_id = await _make_host(conn, hostname="f2-host-2")
|
||||
foreign_session = await _make_session(conn, user_id=u2, host_id=host_id, ended=False)
|
||||
|
||||
await _login_full(client, "f2_u3", "Correct-Horse-Battery-Staple-3")
|
||||
resp = await client.post(f"/catalog/sessions/{foreign_session}/terminate")
|
||||
assert resp.status_code == 404, resp.text
|
||||
|
||||
resp = await client.post("/catalog/sessions/999999/terminate")
|
||||
assert resp.status_code == 404, resp.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_terminate_own_running_session_cancels_task_and_audits(client):
|
||||
from app.db import get_db
|
||||
from app.security import active_sessions
|
||||
from app.security.audit import verify_chain
|
||||
|
||||
conn = get_db()
|
||||
u1 = await _create_user(conn, "f2_u5", "Correct-Horse-Battery-Staple-5")
|
||||
host_id = await _make_host(conn, hostname="f2-host-3")
|
||||
session_id = await _make_session(conn, user_id=u1, host_id=host_id, ended=False)
|
||||
|
||||
async def _fake_long_running():
|
||||
await asyncio.sleep(3600)
|
||||
|
||||
task = asyncio.create_task(_fake_long_running())
|
||||
active_sessions.register(session_id=session_id, task=task, user_id=u1)
|
||||
try:
|
||||
await _login_full(client, "f2_u5", "Correct-Horse-Battery-Staple-5")
|
||||
resp = await client.post(f"/catalog/sessions/{session_id}/terminate")
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
# asyncio.Task.cancel() wirkt asynchron -- auf das tatsaechliche
|
||||
# Ende der Task warten, statt nur einmal nachzugeben (Python 3.10:
|
||||
# kein Task.cancelling(), daher ueber den Ausgang selbst pruefen).
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await asyncio.wait_for(task, timeout=1)
|
||||
assert task.cancelled()
|
||||
|
||||
row = await (await conn.execute(
|
||||
"SELECT event_type, details_json FROM audit_log WHERE user_id = ? ORDER BY id DESC LIMIT 1",
|
||||
(u1,),
|
||||
)).fetchone()
|
||||
assert row is not None
|
||||
assert row[0] == "session_terminated_by_owner"
|
||||
assert str(session_id) in row[1]
|
||||
|
||||
intact, _ = await verify_chain(conn)
|
||||
assert intact
|
||||
finally:
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
active_sessions.unregister(session_id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_terminate_already_ended_own_session_returns_409(client):
|
||||
from app.db import get_db
|
||||
|
||||
conn = get_db()
|
||||
u1 = await _create_user(conn, "f2_u6", "Correct-Horse-Battery-Staple-6")
|
||||
host_id = await _make_host(conn, hostname="f2-host-4")
|
||||
ended_session = await _make_session(conn, user_id=u1, host_id=host_id, ended=True)
|
||||
|
||||
await _login_full(client, "f2_u6", "Correct-Horse-Battery-Staple-6")
|
||||
resp = await client.post(f"/catalog/sessions/{ended_session}/terminate")
|
||||
assert resp.status_code == 409, resp.text
|
||||
76
tests/test_teil_f_schritt3.py
Normal file
76
tests/test_teil_f_schritt3.py
Normal file
@ -0,0 +1,76 @@
|
||||
"""Tests fuer Teil F.3 (Stufe F3, Umsetzungsauftrag_Sonnet5.md): die
|
||||
dauerhafte Arbeitsflaeche /workspace.
|
||||
|
||||
Diese Stufe ist ueberwiegend Frontend (templates/workspace.html,
|
||||
static/js/workspace.js) -- die serverseitige Flaeche ist bewusst klein
|
||||
(rein statisches Markup wie /dashboard, Auth clientseitig ueber
|
||||
GET /auth/me, siehe app/main.py::workspace_page). Die CSP-Konformitaet
|
||||
(kein Inline-style/-script) wird zentral in tests/test_csp_compliance.py
|
||||
mitgeprueft (/workspace wurde dort in die Parametrisierung aufgenommen).
|
||||
Hier: die Route existiert, liefert die erwarteten Bausteine aus, und die
|
||||
Backend-Bausteine, auf denen workspace.js aufbaut (GET /catalog/hosts,
|
||||
GET /catalog/sessions), bleiben nutzbar."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workspace_page_renders_without_login(client):
|
||||
"""Wie /dashboard/-admin: die Seite selbst ist statisches Markup ohne
|
||||
Secrets -- die eigentliche Zugriffskontrolle laeuft clientseitig ueber
|
||||
GET /auth/me (401 -> Redirect zu /) UND serverseitig hart auf jedem
|
||||
/catalog/*-API-Aufruf, den workspace.js danach macht."""
|
||||
resp = await client.get("/workspace")
|
||||
assert resp.status_code == 200, resp.text
|
||||
html = resp.text
|
||||
assert 'id="workspace-tiles"' in html
|
||||
assert 'id="workspace-catalog-view"' in html
|
||||
assert 'id="workspace-sessions-area"' in html
|
||||
assert 'id="workspace-new-btn"' in html
|
||||
assert '/static/js/workspace.js' in html
|
||||
# F1-Bausteine werden mitgeladen (kein zweiter Sitzungs-Code, F.2).
|
||||
assert '/static/js/terminal.js' in html
|
||||
assert '/static/js/rdp.js' in html
|
||||
# F3 baut das Sitzungs-DOM erst zur Laufzeit -- kein #session-container
|
||||
# mit data-host-id wie bei /terminal/{id}, /rdp/{id} (die Seite haette
|
||||
# sonst automatisch eine Sitzung bootstrapped, siehe terminal.js/rdp.js
|
||||
# Bootstrap-Block).
|
||||
assert 'id="session-container"' not in html
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workspace_backend_building_blocks_reachable_after_login(client):
|
||||
"""workspace.js ruft beim Laden GET /catalog/hosts und
|
||||
GET /catalog/sessions auf -- beide muessen fuer einen angemeldeten
|
||||
Benutzer erreichbar sein (volle Auth-Pruefung liegt bereits in
|
||||
test_teil_f_schritt2.py und den bestehenden Katalog-Tests; hier nur der
|
||||
Zusammenhang mit der neuen Seite)."""
|
||||
from app.db import get_db
|
||||
from app.security.passwords import hash_password
|
||||
import pyotp
|
||||
|
||||
conn = get_db()
|
||||
await conn.execute(
|
||||
"INSERT INTO users (username, password_hash, is_admin, must_change_password) "
|
||||
"VALUES ('f3_user', ?, 0, 0)",
|
||||
(hash_password("Correct-Horse-Battery-Staple-F3"),),
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
resp = await client.post("/auth/login", json={"username": "f3_user", "password": "Correct-Horse-Battery-Staple-F3"})
|
||||
pending = resp.json()["pending_token"]
|
||||
resp = await client.post("/auth/totp/enroll/start", json={"pending_token": pending})
|
||||
provisioning_uri = resp.json()["provisioning_uri"]
|
||||
secret = dict(part.split("=") for part in provisioning_uri.split("?", 1)[1].split("&"))["secret"]
|
||||
code = pyotp.TOTP(secret).now()
|
||||
resp = await client.post("/auth/totp/enroll/confirm", json={"pending_token": pending, "code": code})
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
resp = await client.get("/catalog/hosts")
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert isinstance(resp.json(), list)
|
||||
|
||||
resp = await client.get("/catalog/sessions")
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json() == [] # frisch angemeldeter Benutzer hat keine offenen Sitzungen
|
||||
170
tests/test_teil_f_schritt5.py
Normal file
170
tests/test_teil_f_schritt5.py
Normal file
@ -0,0 +1,170 @@
|
||||
"""Tests fuer Teil F.5 (Umsetzungsauftrag_Sonnet5.md): Obergrenzen sichtbar
|
||||
in der Arbeitsflaeche (F.3.8).
|
||||
|
||||
GET /catalog/session-limits liefert nur Zahlen -- die eigentliche
|
||||
Durchsetzung existiert bereits seit Teil E.4 (app/ssh_proxy/terminal_ws.py,
|
||||
app/rdp_proxy/ws_tunnel.py, WS-Code 4429) und wird hier NICHT verdoppelt,
|
||||
sondern ueber dieselbe Registry (app/security/active_sessions.py) und
|
||||
denselben app/config.py-Wert gelesen. Getestet wird deshalb v.a., dass die
|
||||
Zahlen exakt der bereits vorhandenen Durchsetzungslogik entsprechen
|
||||
(insbesondere die Admin-Ausnahme bei der Je-Nutzer-Grenze, NICHT bei der
|
||||
globalen Grenze -- siehe terminal_ws.py Zeile ~120-136) und dass keine
|
||||
Angaben zu FREMDEN Sitzungen durchsickern (F.5-Risikotabelle,
|
||||
Rechteumgehung ueber die Katalog-API, dasselbe Prinzip wie in
|
||||
test_teil_f_schritt2.py)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from app.security import active_sessions
|
||||
|
||||
|
||||
async def _make_user(client, username: str, is_admin: int = 0):
|
||||
from app.db import get_db
|
||||
from app.security.passwords import hash_password
|
||||
import pyotp
|
||||
|
||||
conn = get_db()
|
||||
await conn.execute(
|
||||
"INSERT INTO users (username, password_hash, is_admin, must_change_password) "
|
||||
"VALUES (?, ?, ?, 0)",
|
||||
(username, hash_password("Correct-Horse-Battery-Staple-F5"), is_admin),
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
resp = await client.post("/auth/login", json={"username": username, "password": "Correct-Horse-Battery-Staple-F5"})
|
||||
pending = resp.json()["pending_token"]
|
||||
resp = await client.post("/auth/totp/enroll/start", json={"pending_token": pending})
|
||||
provisioning_uri = resp.json()["provisioning_uri"]
|
||||
secret = dict(part.split("=") for part in provisioning_uri.split("?", 1)[1].split("&"))["secret"]
|
||||
code = pyotp.TOTP(secret).now()
|
||||
resp = await client.post("/auth/totp/enroll/confirm", json={"pending_token": pending, "code": code})
|
||||
assert resp.status_code == 200, resp.text
|
||||
row = await (await conn.execute("SELECT id FROM users WHERE username = ?", (username,))).fetchone()
|
||||
return row[0]
|
||||
|
||||
|
||||
class _FakeTask:
|
||||
"""Ersatz fuer einen echten asyncio.Task in der Registry -- fuer diese
|
||||
Tests wird nie cancel()/await ausgefuehrt, nur die Zaehlung geprueft."""
|
||||
|
||||
def cancel(self):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_limits_reflects_config_when_no_sessions_open(client):
|
||||
"""Frisch angemeldeter Nutzer ohne offene Sitzungen: current_*_count == 0,
|
||||
max_* == die konfigurierten Werte aus app/config.py, keine Grenze
|
||||
erreicht."""
|
||||
from app.config import settings
|
||||
|
||||
await _make_user(client, "f5_user_empty")
|
||||
|
||||
resp = await client.get("/catalog/session-limits")
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["max_per_user"] == settings.max_sessions_per_user
|
||||
assert body["max_global"] == settings.max_sessions_global
|
||||
assert body["current_user_count"] == 0
|
||||
assert body["current_global_count"] == 0
|
||||
assert body["user_limit_applies"] is True
|
||||
assert body["at_user_limit"] is False
|
||||
assert body["at_global_limit"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_limits_counts_only_own_sessions_for_user_count(client):
|
||||
"""current_user_count zaehlt NUR die eigenen Registry-Eintraege --
|
||||
Sitzungen eines anderen Nutzers duerfen den eigenen Zaehler nicht
|
||||
beeinflussen (wohl aber current_global_count, das ist bewusst global)."""
|
||||
user_a_id = await _make_user(client, "f5_user_a")
|
||||
|
||||
other_user_id = 9999 # fremd, muss NICHT in der DB existieren fuer die Registry
|
||||
active_sessions.register(9001, _FakeTask(), other_user_id)
|
||||
try:
|
||||
resp = await client.get("/catalog/session-limits")
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["current_user_count"] == 0 # fremde Sitzung zaehlt NICHT
|
||||
assert body["current_global_count"] == 1 # global schon
|
||||
finally:
|
||||
active_sessions.unregister(9001)
|
||||
|
||||
active_sessions.register(9002, _FakeTask(), user_a_id)
|
||||
try:
|
||||
resp = await client.get("/catalog/session-limits")
|
||||
body = resp.json()
|
||||
assert body["current_user_count"] == 1
|
||||
assert body["current_global_count"] == 1
|
||||
finally:
|
||||
active_sessions.unregister(9002)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_limits_at_user_limit_true_when_reached(client):
|
||||
"""Erreicht ein normaler Nutzer max_sessions_per_user, meldet der
|
||||
Endpunkt at_user_limit=true -- exakt die Schwelle (>=), die auch
|
||||
terminal_ws.py/ws_tunnel.py fuer die Ablehnung verwendet."""
|
||||
from app.config import settings
|
||||
|
||||
user_id = await _make_user(client, "f5_user_at_limit")
|
||||
|
||||
ids = list(range(9100, 9100 + settings.max_sessions_per_user))
|
||||
for sid in ids:
|
||||
active_sessions.register(sid, _FakeTask(), user_id)
|
||||
try:
|
||||
resp = await client.get("/catalog/session-limits")
|
||||
body = resp.json()
|
||||
assert body["current_user_count"] == settings.max_sessions_per_user
|
||||
assert body["at_user_limit"] is True
|
||||
assert body["at_global_limit"] is False # weit unter max_sessions_global
|
||||
finally:
|
||||
for sid in ids:
|
||||
active_sessions.unregister(sid)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_limits_admin_exempt_from_user_limit_not_from_global(client):
|
||||
"""Deckt exakt die Asymmetrie aus terminal_ws.py Zeile ~120-136 ab: Admins
|
||||
sind von der Je-Nutzer-Grenze ausgenommen ('not user.is_admin'-Gate),
|
||||
aber NICHT von der globalen Grenze. Weicht die Anzeige davon ab, wuerde
|
||||
sie einem Admin faelschlich 'gesperrt' oder faelschlich 'frei' zeigen."""
|
||||
from app.config import settings
|
||||
|
||||
admin_id = await _make_user(client, "f5_admin", is_admin=1)
|
||||
|
||||
# Je-Nutzer-Grenze fuer den Admin selbst "erreicht" -- darf ihn laut
|
||||
# Durchsetzungslogik trotzdem nicht ausbremsen.
|
||||
ids = list(range(9200, 9200 + settings.max_sessions_per_user))
|
||||
for sid in ids:
|
||||
active_sessions.register(sid, _FakeTask(), admin_id)
|
||||
try:
|
||||
resp = await client.get("/catalog/session-limits")
|
||||
body = resp.json()
|
||||
assert body["user_limit_applies"] is False
|
||||
assert body["at_user_limit"] is False # trotz current_user_count == max_per_user
|
||||
assert body["current_user_count"] == settings.max_sessions_per_user
|
||||
finally:
|
||||
for sid in ids:
|
||||
active_sessions.unregister(sid)
|
||||
|
||||
# Globale Grenze gilt hingegen auch fuer Admins.
|
||||
global_ids = list(range(9300, 9300 + settings.max_sessions_global))
|
||||
for sid in global_ids:
|
||||
active_sessions.register(sid, _FakeTask(), 424242) # fremder Platzhalter-Nutzer
|
||||
try:
|
||||
resp = await client.get("/catalog/session-limits")
|
||||
body = resp.json()
|
||||
assert body["at_global_limit"] is True
|
||||
finally:
|
||||
for sid in global_ids:
|
||||
active_sessions.unregister(sid)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_limits_requires_login(client):
|
||||
resp = await client.get("/catalog/session-limits")
|
||||
assert resp.status_code == 401
|
||||
Reference in New Issue
Block a user