Files
ssh-jumphost/app/catalog/routes.py
2026-09-02 20:30:44 +02:00

191 lines
8.8 KiB
Python

"""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 = <eigene angemeldete> 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,
}