191 lines
8.8 KiB
Python
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,
|
|
}
|