"""Sicht fuer normale Nutzer: nur die Hosts/Aktionen, fuer die RBAC eine Rolle in der jeweiligen Hostgruppe vergeben hat -- entweder direkt (Konzept 4.6) oder ueber eine Benutzergruppe, in der der User Mitglied ist (volle Rollen-Vererbung, siehe app/rbac.py). S13 (Umsetzungsauftrag_Sonnet5.md Teil D.2): app/rbac.py ist die einzige Wahrheitsquelle fuer die Vereinigung aus Direkt- und Gruppenvergabe. Dieses Modul baut sie nicht mehr selbst per UNION/EXISTS nach, sondern ruft user_host_group_ids_with_any_role() auf und filtert/verknuepft in Python. F2 (Umsetzungsauftrag_Sonnet5.md Teil F.3.6): eigene Sitzungs-API. Bewusst HIER und NICHT im Admin-Router -- GET /admin/sessions (app/admin/routes.py) zeigt fremde Sitzungen, Benutzernamen und Aufzeichnungspfade und darf dafuer nicht geoeffnet werden. Jede Abfrage/Aktion hier ist zwingend auf user_id = beschraenkt.""" from __future__ import annotations from fastapi import APIRouter, Depends, HTTPException, Request, status from app.auth.deps import CurrentUser, get_current_user from app.config import settings from app.db import get_db from app.rbac import user_host_group_ids_with_any_role from app.security import active_sessions from app.security.audit import write_audit_event router = APIRouter(prefix="/catalog", tags=["catalog"]) def _client_ip(request: Request) -> str: return request.client.host if request.client else "unknown" @router.get("/hosts") async def my_hosts(user: CurrentUser = Depends(get_current_user)): conn = get_db() cursor = await conn.execute( "SELECT h.id, h.hostname, h.address, h.protocol, h.os_type, h.host_group_id, " "g.name, h.clipboard_enabled, h.file_transfer_enabled " "FROM hosts h JOIN host_groups g ON g.id = h.host_group_id " "WHERE h.is_active = 1 ORDER BY g.name, h.hostname" ) rows = await cursor.fetchall() hosts = [dict(zip( ("id", "hostname", "address", "protocol", "os_type", "host_group_id", "host_group_name", "clipboard_enabled", "file_transfer_enabled"), r )) for r in rows] if user.is_admin: for h in hosts: h["can_connect"] = True h["can_file_transfer"] = True h["can_view_credentials"] = True return hosts # Protokoll-Abgleich (S6, Umsetzungsauftrag_Sonnet5.md D.1/D.2): ssh_connect # zaehlt nur fuer SSH-Hosts, rdp_connect nur fuer RDP-Hosts -- getrennte # Gruppenmengen je Rolle, kein pauschales "irgendeine der beiden Rollen". ssh_groups = await user_host_group_ids_with_any_role(conn, user_id=user.id, role_names=("ssh_connect",)) rdp_groups = await user_host_group_ids_with_any_role(conn, user_id=user.id, role_names=("rdp_connect",)) ft_groups = await user_host_group_ids_with_any_role(conn, user_id=user.id, role_names=("file_transfer",)) cred_groups = await user_host_group_ids_with_any_role( conn, user_id=user.id, role_names=("credentials_view", "credentials_manage") ) visible = [] for h in hosts: can_connect = ( (h["protocol"] == "ssh" and h["host_group_id"] in ssh_groups) or (h["protocol"] == "rdp" and h["host_group_id"] in rdp_groups) ) if not can_connect: continue h["can_connect"] = True h["can_file_transfer"] = h["host_group_id"] in ft_groups and bool(h["file_transfer_enabled"]) h["can_view_credentials"] = h["host_group_id"] in cred_groups visible.append(h) return visible # --- Eigene Sitzungen (F2, Umsetzungsauftrag Teil F.3.6) -------------------- # # Grundlage fuer die kuenftige Arbeitsflaeche /workspace (Stufe F3): eine # Seitenleiste braucht eine Liste der eigenen offenen Sitzungen, um Kacheln # zu zeichnen, und einen Weg, eine davon (z.B. ueber den Schliessen-Knopf # einer Kachel) zu beenden. Referenzmuster: app/admin/routes.py:: # list_sessions/terminate_session -- hier aber zwingend auf den eigenen # Benutzer beschraenkt und ohne die dort zusaetzlich gelieferten sensiblen # Felder (Client-IP, Aufzeichnungspfad, fremde Benutzernamen). @router.get("/sessions") async def my_sessions(active_only: bool = True, user: CurrentUser = Depends(get_current_user)): """Eigene Sitzungen (Standard: nur die noch offenen). Nutzt den zusammengesetzten Index idx_sessions_user_ended (Migration 0020) -- deckt per Index-Praefix-Regel sowohl diese Abfrage (user_id, ended_at) als auch active_only=false (user_id allein) ab.""" conn = get_db() where = "WHERE s.user_id = ? AND s.ended_at IS NULL" if active_only else "WHERE s.user_id = ?" cursor = await conn.execute( f""" SELECT s.id, s.host_id, h.hostname, hg.name, s.protocol, s.started_at, s.ended_at, s.end_reason FROM sessions s 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 """, (user.id,), ) rows = await cursor.fetchall() active_ids = active_sessions.all_ids() return [ { "id": r[0], "host_id": r[1], "hostname": r[2], "host_group_name": r[3], "protocol": r[4], "started_at": r[5], "ended_at": r[6], "end_reason": r[7], "is_active": r[6] is None, # Wie bei der Admin-Sessionview: nur auf DIESEM Worker-Prozess # beendbar (app/security/active_sessions.py, Moduldoc). "killable": r[0] in active_ids, } for r in rows ] @router.post("/sessions/{session_id}/terminate") async def terminate_my_session(session_id: int, request: Request, user: CurrentUser = Depends(get_current_user)): """Beendet eine EIGENE laufende Sitzung. Zwingend auf user_id = user.id beschraenkt -- sowohl beim DB-Zeilen- als auch beim Registry-Zugriff (F.5-Risikotabelle: 'Rechteumgehung ueber die neue Sitzungs-API' / 'Wiederverwendung des Admin-Endpunkts'). Eine fremde oder nicht existierende session_id liefert in BEIDEN Faellen 404 -- die Existenz einer fremden Sitzung wird nicht ueber einen abweichenden Statuscode (z.B. 403 nur bei existierender, aber fremder ID) verraten.""" 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 or row[1] != user.id: 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 or entry.user_id != user.id: raise HTTPException( status.HTTP_409_CONFLICT, "Sitzung laeuft nicht (mehr) auf diesem Server-Prozess und kann von hier aus nicht beendet werden", ) entry.task.cancel() await write_audit_event( conn, event_type="session_terminated_by_owner", user_id=user.id, client_ip=_client_ip(request), details={"session_id": session_id, "host_id": row[2]}, ) await conn.commit() return {"status": "ok"} # --- Sitzungs-Obergrenzen (F5, Umsetzungsauftrag Teil F.3.8) ---------------- # # Die Durchsetzung selbst existiert bereits seit Teil E.4 und bleibt dort # die alleinige, verbindliche Kontrolle (app/ssh_proxy/terminal_ws.py, # app/rdp_proxy/ws_tunnel.py -- vor jedem Ressourcenverbrauch, WS-Code 4429 # bei Ueberschreitung). Dieser Endpunkt liefert nur die Zahlen dazu, damit # die Arbeitsflaeche (static/js/workspace.js) den Zustand VORHER anzeigen # und "+ Neue Sitzung" deaktivieren kann, statt den Nutzer erst beim # WS-Verbindungsversuch abzuweisen (F.3.8 verlangt sichtbare Obergrenzen, # nicht nur eine funktionierende Durchsetzung -- die gab es bereits). # # Absichtlich nur aggregierte Zahlen, keine Details zu FREMDEN Sitzungen # (kein user_id/hostname anderer Nutzer) -- sonst waere dies ein Fall fuer # den Admin-Router (siehe Moduldoc oben). # # Admins sind wie bei der Durchsetzung selbst von der Je-Nutzer-Grenze # ausgenommen, nicht aber von der globalen Grenze -- exakt dieselbe Logik # wie in terminal_ws.py/ws_tunnel.py. Das MUSS synchron bleiben: sonst zeigt # die Oberflaeche "frei", obwohl der Server ablehnen wuerde, oder umgekehrt. @router.get("/session-limits") async def my_session_limits(user: CurrentUser = Depends(get_current_user)): user_count = active_sessions.count_for_user(user.id) global_count = active_sessions.count_total() user_limit_applies = not user.is_admin return { "max_per_user": settings.max_sessions_per_user, "current_user_count": user_count, "user_limit_applies": user_limit_applies, "at_user_limit": user_limit_applies and user_count >= settings.max_sessions_per_user, "max_global": settings.max_sessions_global, "current_global_count": global_count, "at_global_limit": global_count >= settings.max_sessions_global, }