more admin stuff 2
This commit is contained in:
@ -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)
|
||||
|
||||
Reference in New Issue
Block a user