umbau 1.0
This commit is contained in:
@ -1,65 +1,44 @@
|
||||
"""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)."""
|
||||
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
|
||||
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()
|
||||
if user.is_admin:
|
||||
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]
|
||||
for h in hosts:
|
||||
h["can_connect"] = True
|
||||
h["can_file_transfer"] = True
|
||||
h["can_view_credentials"] = True
|
||||
return hosts
|
||||
|
||||
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
|
||||
AND (
|
||||
EXISTS (
|
||||
SELECT 1 FROM user_hostgroup_roles uhr
|
||||
JOIN roles r ON r.id = uhr.role_id
|
||||
WHERE uhr.user_id = ? AND uhr.host_group_id = h.host_group_id
|
||||
AND r.name IN ('ssh_connect', 'rdp_connect')
|
||||
AND (uhr.expires_at IS NULL OR uhr.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM group_hostgroup_roles ghr
|
||||
JOIN roles r ON r.id = ghr.role_id
|
||||
JOIN user_group_members ugm ON ugm.user_group_id = ghr.user_group_id
|
||||
WHERE ugm.user_id = ? AND ghr.host_group_id = h.host_group_id
|
||||
AND r.name IN ('ssh_connect', 'rdp_connect')
|
||||
AND (ghr.expires_at IS NULL OR ghr.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
)
|
||||
)
|
||||
ORDER BY g.name, h.hostname
|
||||
""",
|
||||
(user.id, user.id),
|
||||
"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(
|
||||
@ -67,42 +46,145 @@ async def my_hosts(user: CurrentUser = Depends(get_current_user)):
|
||||
"clipboard_enabled", "file_transfer_enabled"), r
|
||||
)) for r in rows]
|
||||
|
||||
ft_cursor = await conn.execute(
|
||||
"""
|
||||
SELECT uhr.host_group_id FROM user_hostgroup_roles uhr
|
||||
JOIN roles r ON r.id = uhr.role_id
|
||||
WHERE uhr.user_id = ? AND r.name = 'file_transfer'
|
||||
AND (uhr.expires_at IS NULL OR uhr.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
UNION
|
||||
SELECT ghr.host_group_id FROM group_hostgroup_roles ghr
|
||||
JOIN roles r ON r.id = ghr.role_id
|
||||
JOIN user_group_members ugm ON ugm.user_group_id = ghr.user_group_id
|
||||
WHERE ugm.user_id = ? AND r.name = 'file_transfer'
|
||||
AND (ghr.expires_at IS NULL OR ghr.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
""",
|
||||
(user.id, user.id),
|
||||
)
|
||||
ft_groups = {row[0] for row in await ft_cursor.fetchall()}
|
||||
if user.is_admin:
|
||||
for h in hosts:
|
||||
h["can_connect"] = True
|
||||
h["can_file_transfer"] = True
|
||||
h["can_view_credentials"] = True
|
||||
return hosts
|
||||
|
||||
cred_cursor = await conn.execute(
|
||||
"""
|
||||
SELECT uhr.host_group_id FROM user_hostgroup_roles uhr
|
||||
JOIN roles r ON r.id = uhr.role_id
|
||||
WHERE uhr.user_id = ? AND r.name IN ('credentials_view', 'credentials_manage')
|
||||
AND (uhr.expires_at IS NULL OR uhr.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
UNION
|
||||
SELECT ghr.host_group_id FROM group_hostgroup_roles ghr
|
||||
JOIN roles r ON r.id = ghr.role_id
|
||||
JOIN user_group_members ugm ON ugm.user_group_id = ghr.user_group_id
|
||||
WHERE ugm.user_id = ? AND r.name IN ('credentials_view', 'credentials_manage')
|
||||
AND (ghr.expires_at IS NULL OR ghr.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
""",
|
||||
(user.id, user.id),
|
||||
# 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")
|
||||
)
|
||||
cred_groups = {row[0] for row in await cred_cursor.fetchall()}
|
||||
|
||||
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
|
||||
return hosts
|
||||
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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user