more admin stuff 2

This commit is contained in:
2026-08-20 17:10:19 +02:00
parent e8216b14e9
commit cd5957cbd2
19 changed files with 1120 additions and 78 deletions

View File

@ -21,13 +21,17 @@ Privilege-Escalation-Schutz).
from __future__ import annotations
import json
import logging
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect, status
from app.auth.deps import (
CurrentUser,
effective_tenant_ids,
get_current_user_ws,
require_admin_or_scope,
require_admin_scope_or_host_role,
require_admin_session,
require_global_admin,
)
@ -54,6 +58,7 @@ from app.models.schemas import (
UserGroupUpdateRequest,
UserUpdateRequest,
)
from app.security import active_sessions, log_stream
from app.security.api_tokens import (
VALID_SCOPES,
generate_token,
@ -64,9 +69,12 @@ from app.security.api_tokens import (
from app.security.audit import verify_chain, write_audit_event
from app.security.crypto import encrypt_secret
from app.security.passwords import hash_password
from app.ssh_proxy.proxy import discover_and_store_host_key
from app.recordings.recorder import verify_recording
from app.ssh_proxy.proxy import HostKeyDiscoveryError, HostNotConfiguredError, discover_and_store_host_key
from app.tenancy import TenantScope, resolve_host_group_tenant, resolve_host_tenant, tenant_user_ids
logger = logging.getLogger("jumphost.admin")
router = APIRouter(prefix="/admin", tags=["admin"])
@ -900,10 +908,23 @@ async def discover_host_key(
"""ACHTUNG: Verbindet einmalig OHNE Host-Key-Pruefung, um den Fingerprint zu
erfassen (bewusste Trust-Entscheidung, siehe Konzept 4.2). Danach gilt fuer
alle regulaeren Verbindungen wieder striktes Pinning. Jeder Aufruf wird
prominent im Audit-Log vermerkt."""
prominent im Audit-Log vermerkt.
Bugfix: discover_and_store_host_key() konnte bisher ein unbehandeltes
asyncssh/OSError durchreichen -> FastAPI antwortete mit 500 statt einer
verwertbaren Fehlermeldung (siehe app/ssh_proxy/proxy.py). Jetzt sauber
auf 502 (Verbindung fehlgeschlagen) bzw. 400 (kein SSH-Host) gemappt."""
conn = get_db()
await _assert_host_in_scope(conn, _scope(admin), host_id)
fingerprint = await discover_and_store_host_key(conn, host_id, admin_user_id=admin.id)
try:
fingerprint = await discover_and_store_host_key(conn, host_id, admin_user_id=admin.id)
except HostNotConfiguredError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
except HostKeyDiscoveryError as exc:
raise HTTPException(
status.HTTP_502_BAD_GATEWAY,
f"Host-Key konnte nicht ermittelt werden -- Ziel nicht erreichbar: {exc.reason}",
) from exc
await write_audit_event(
conn, event_type="host_key_discovered_trust_decision", user_id=admin.id,
client_ip=_client_ip(request), details={"host_id": host_id, "fingerprint": fingerprint},
@ -915,12 +936,18 @@ async def discover_host_key(
@router.put("/hosts/{host_id}/rdp-credentials")
async def set_rdp_credentials(
host_id: int, payload: RdpCredentialsRequest, request: Request,
admin: CurrentUser = Depends(require_admin_or_scope("hosts", "write")),
admin: CurrentUser = Depends(
require_admin_scope_or_host_role("hosts", "write", ("credentials_manage",))
),
):
"""Speichert/rotiert das RDP-Passwort fuer einen Host, verschluesselt mit
dem KEK (eigener AAD-Kontext, siehe app/security/crypto.py)."""
dem KEK (eigener AAD-Kontext, siehe app/security/crypto.py). Zugriff:
Admin/Mandanten-Admin/Token ODER ein Nicht-Admin mit Rolle
'credentials_manage' auf der Hostgruppe dieses Hosts (siehe RBAC-
Erweiterung 'Credentials ins RBAC-Modell')."""
conn = get_db()
await _assert_host_in_scope(conn, _scope(admin), host_id)
if admin.is_any_admin:
await _assert_host_in_scope(conn, _scope(admin), host_id)
encrypted = encrypt_secret(payload.password.encode(), associated_data=b"rdp_password")
await conn.execute(
"INSERT INTO rdp_credentials (host_id, password_enc, updated_at) "
@ -940,10 +967,13 @@ async def set_rdp_credentials(
@router.delete("/hosts/{host_id}/rdp-credentials")
async def delete_rdp_credentials(
host_id: int, request: Request,
admin: CurrentUser = Depends(require_admin_or_scope("hosts", "write")),
admin: CurrentUser = Depends(
require_admin_scope_or_host_role("hosts", "write", ("credentials_manage",))
),
):
conn = get_db()
await _assert_host_in_scope(conn, _scope(admin), host_id)
if admin.is_any_admin:
await _assert_host_in_scope(conn, _scope(admin), host_id)
await conn.execute("DELETE FROM rdp_credentials WHERE host_id = ?", (host_id,))
await write_audit_event(
conn, event_type="rdp_credentials_deleted", user_id=admin.id, client_ip=_client_ip(request),
@ -953,6 +983,42 @@ async def delete_rdp_credentials(
return {"status": "ok"}
@router.get("/hosts/{host_id}/credentials")
async def get_host_credentials(
host_id: int,
admin: CurrentUser = Depends(
require_admin_scope_or_host_role("hosts", "read", ("credentials_view", "credentials_manage"))
),
):
"""Zugangsdaten-Uebersicht fuer EINEN Host (zugeordnete SSH-Keys, ob ein
RDP-Passwort gesetzt ist -- niemals der Klartext selbst, siehe Konzept
6.4). Im Unterschied zu GET /admin/hosts/{id} (voller Datensatz,
admin-only) ist dieser schlanke Endpunkt bewusst auch fuer Nicht-Admins
mit Rolle 'credentials_view'/'credentials_manage' erreichbar (RBAC-
Erweiterung 'Credentials ins RBAC-Modell')."""
conn = get_db()
if admin.is_any_admin:
await _assert_host_in_scope(conn, _scope(admin), host_id)
else:
host_row = await (await conn.execute("SELECT 1 FROM hosts WHERE id = ?", (host_id,))).fetchone()
if host_row is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Host nicht gefunden")
keys_cursor = await conn.execute(
"SELECT sk.id, sk.label FROM host_ssh_key_map m JOIN ssh_keys sk ON sk.id = m.ssh_key_id "
"WHERE m.host_id = ?",
(host_id,),
)
ssh_keys = [{"id": k[0], "label": k[1]} for k in await keys_cursor.fetchall()]
rdp_row = await (await conn.execute(
"SELECT updated_at FROM rdp_credentials WHERE host_id = ?", (host_id,)
)).fetchone()
return {
"host_id": host_id, "ssh_keys": ssh_keys,
"rdp_credentials_set": rdp_row is not None,
"rdp_credentials_updated_at": rdp_row[0] if rdp_row else None,
}
@router.get("/rdp-credentials")
async def list_rdp_credentials(admin: CurrentUser = Depends(require_admin_or_scope("hosts", "read"))):
"""Uebersicht aller RDP/Windows-Hosts fuer den 'Zugangsdaten'-Tab: welche
@ -1281,12 +1347,19 @@ async def delete_ssh_key(
@router.post("/hosts/{host_id}/ssh-keys/{key_id}")
async def map_ssh_key_to_host(
host_id: int, key_id: int, request: Request,
admin: CurrentUser = Depends(require_admin_or_scope("ssh_keys", "write")),
admin: CurrentUser = Depends(
require_admin_scope_or_host_role("ssh_keys", "write", ("credentials_manage",))
),
):
conn = get_db()
scope = _scope(admin)
await _assert_host_in_scope(conn, scope, host_id)
await _assert_ssh_key_in_scope(conn, scope, key_id)
if admin.is_any_admin:
scope = _scope(admin)
await _assert_host_in_scope(conn, scope, host_id)
await _assert_ssh_key_in_scope(conn, scope, key_id)
else:
key_row = await (await conn.execute("SELECT 1 FROM ssh_keys WHERE id = ?", (key_id,))).fetchone()
if key_row is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "SSH-Key nicht gefunden")
await conn.execute(
"INSERT OR IGNORE INTO host_ssh_key_map (host_id, ssh_key_id) VALUES (?, ?)",
(host_id, key_id),
@ -1302,11 +1375,13 @@ async def map_ssh_key_to_host(
@router.delete("/hosts/{host_id}/ssh-keys/{key_id}")
async def unmap_ssh_key_from_host(
host_id: int, key_id: int, request: Request,
admin: CurrentUser = Depends(require_admin_or_scope("ssh_keys", "write")),
admin: CurrentUser = Depends(
require_admin_scope_or_host_role("ssh_keys", "write", ("credentials_manage",))
),
):
conn = get_db()
scope = _scope(admin)
await _assert_host_in_scope(conn, scope, host_id)
if admin.is_any_admin:
await _assert_host_in_scope(conn, _scope(admin), host_id)
await conn.execute(
"DELETE FROM host_ssh_key_map WHERE host_id = ? AND ssh_key_id = ?", (host_id, key_id)
)
@ -1463,3 +1538,130 @@ async def verify_audit_log(admin: CurrentUser = Depends(require_admin_or_scope("
conn = get_db()
intact, broken_at = await verify_chain(conn)
return {"intact": intact, "first_broken_id": broken_at}
# --- Sessionview (nur Super-Admin) --------------------------------------------
#
# Zeigt ALLE Sitzungen ueber alle Mandanten hinweg -- bewusst require_global_
# admin (kein Mandanten-Admin, kein Token), da Sitzungsdaten (Client-IP,
# genutzter Host) ueber Mandantengrenzen hinweg sichtbar waeren. 'Beenden'
# nutzt app/security/active_sessions.py (siehe dort fuer die Prozess-lokale
# Registry und die genaue Beenden-Semantik per asyncio.Task.cancel()).
@router.get("/sessions")
async def list_sessions(
active_only: bool = False, limit: int = 200, offset: int = 0,
admin: CurrentUser = Depends(require_global_admin),
):
limit = max(1, min(limit, 1000))
conn = get_db()
where = "WHERE s.ended_at IS NULL" if active_only else ""
cursor = await conn.execute(
f"""
SELECT s.id, s.user_id, u.username, s.host_id, h.hostname, hg.name,
s.protocol, s.started_at, s.ended_at, s.client_ip, s.end_reason,
s.recording_path
FROM sessions s
JOIN users u ON u.id = s.user_id
JOIN hosts h ON h.id = s.host_id
JOIN host_groups hg ON hg.id = h.host_group_id
{where}
ORDER BY s.started_at DESC LIMIT ? OFFSET ?
""",
(limit, offset),
)
rows = await cursor.fetchall()
active_ids = active_sessions.all_ids()
result = []
for r in rows:
recording_path = r[11]
result.append({
"id": r[0], "user_id": r[1], "username": r[2], "host_id": r[3],
"hostname": r[4], "host_group_name": r[5], "protocol": r[6],
"started_at": r[7], "ended_at": r[8], "client_ip": r[9], "end_reason": r[10],
"is_active": r[8] is None,
"killable": r[0] in active_ids,
"has_recording": bool(recording_path) and Path(recording_path).exists(),
})
return result
@router.post("/sessions/{session_id}/terminate")
async def terminate_session(
session_id: int, request: Request, admin: CurrentUser = Depends(require_global_admin),
):
conn = get_db()
row = await (await conn.execute(
"SELECT ended_at, user_id, host_id FROM sessions WHERE id = ?", (session_id,)
)).fetchone()
if row is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Sitzung nicht gefunden")
if row[0] is not None:
raise HTTPException(status.HTTP_409_CONFLICT, "Sitzung ist bereits beendet")
entry = active_sessions.get(session_id)
if entry is None:
raise HTTPException(
status.HTTP_409_CONFLICT,
"Sitzung laeuft nicht auf diesem Server-Prozess (evtl. anderer Worker) "
"und kann von hier aus nicht beendet werden",
)
entry.task.cancel()
await write_audit_event(
conn, event_type="session_terminated_by_admin", user_id=admin.id, client_ip=_client_ip(request),
details={"session_id": session_id, "target_user_id": row[1], "host_id": row[2]},
)
await conn.commit()
return {"status": "ok"}
@router.get("/sessions/{session_id}/recording")
async def get_session_recording(session_id: int, admin: CurrentUser = Depends(require_global_admin)):
"""Prueft und liefert die Hash-verkettete Sitzungsaufzeichnung (siehe
app/recordings/recorder.py) -- bewusst nur Metadaten + Integritaetsstatus,
kein vollstaendiger Tastatur-/Bildschirm-Dump in der Response (Konzept
6.5: Aufzeichnungen sind hochsensibel)."""
conn = get_db()
row = await (await conn.execute(
"SELECT recording_path FROM sessions WHERE id = ?", (session_id,)
)).fetchone()
if row is None or not row[0]:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Keine Aufzeichnung fuer diese Sitzung vorhanden")
path = Path(row[0])
if not path.exists():
raise HTTPException(status.HTTP_404_NOT_FOUND, "Aufzeichnungsdatei nicht (mehr) vorhanden")
try:
verified = verify_recording(path)
except Exception:
logger.exception("Aufzeichnung %s konnte nicht gelesen/verifiziert werden", session_id)
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Aufzeichnung konnte nicht gelesen werden")
with open(path, encoding="utf-8") as fh:
entry_count = sum(1 for line in fh if line.strip())
return {"session_id": session_id, "verified": verified, "entry_count": entry_count}
# --- Verbindungslog (nur Super-Admin, Live-Tail) ------------------------------
#
# Reiner Live-Tail der Anwendungslogs dieses Server-Prozesses (siehe
# app/security/log_stream.py) -- kein durchsuchbares Archiv, keine
# Persistenz. Nur Super-Admin (require_global_admin-Semantik), da die Logs
# ueber alle Mandanten hinweg technische Details preisgeben koennen.
@router.websocket("/ws/logs")
async def stream_logs(websocket: WebSocket):
user = await get_current_user_ws(websocket)
if user is None or not user.is_admin:
await websocket.close(code=4403)
return
await websocket.accept()
queue = log_stream.subscribe()
try:
for line in log_stream.recent_lines():
await websocket.send_json({"type": "line", "line": line})
while True:
line = await queue.get()
await websocket.send_json({"type": "line", "line": line})
except WebSocketDisconnect:
pass
finally:
log_stream.unsubscribe(queue)