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)
|
||||
|
||||
@ -204,6 +204,43 @@ async def _validate_api_token(token: str, *, resource: str, action: str) -> Curr
|
||||
return CurrentUser(id=user_id, username=username, is_admin=False, token_tenant_id=tenant_id)
|
||||
|
||||
|
||||
def require_admin_scope_or_host_role(resource: str, action: str, role_names: tuple[str, ...]):
|
||||
"""Wie require_admin_or_scope, erlaubt zusaetzlich einen regulaeren
|
||||
(Nicht-Admin-)User per Session, wenn er fuer den per Pfadparameter
|
||||
'host_id' angegebenen Host mindestens eine der uebergebenen Hostgruppen-
|
||||
Rollen besitzt (siehe RBAC-Erweiterung 'Credentials ins RBAC-Modell':
|
||||
Rollen 'credentials_view'/'credentials_manage', app/models/schemas.py).
|
||||
Token-Auth bleibt unveraendert ausschliesslich ueber den Admin-Scope
|
||||
moeglich -- kein Token traegt je eine Hostgruppen-Rolle."""
|
||||
|
||||
async def _dep(
|
||||
host_id: int,
|
||||
response: Response,
|
||||
jh_session: str | None = Cookie(default=None, alias=SESSION_COOKIE_NAME),
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> CurrentUser:
|
||||
if authorization and authorization.startswith("Bearer "):
|
||||
token = authorization.removeprefix("Bearer ").strip()
|
||||
principal = await _validate_api_token(token, resource=resource, action=action)
|
||||
if principal is None:
|
||||
raise HTTPException(
|
||||
status.HTTP_401_UNAUTHORIZED,
|
||||
"Ungueltiges, abgelaufenes oder nicht ausreichend berechtigtes Token",
|
||||
)
|
||||
return principal
|
||||
|
||||
user = await get_current_user(response, jh_session)
|
||||
if user.is_any_admin:
|
||||
return user
|
||||
conn = get_db()
|
||||
for role_name in role_names:
|
||||
if await user_has_role_for_host(conn, user_id=user.id, host_id=host_id, role_name=role_name):
|
||||
return user
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, "Keine Berechtigung fuer diese Zugangsdaten")
|
||||
|
||||
return _dep
|
||||
|
||||
|
||||
def require_admin_or_scope(resource: str, action: str):
|
||||
"""Dependency-Factory fuer die Admin-API: erlaubt Zugriff entweder ueber
|
||||
eine eingeloggte Admin-Session (Super- ODER Mandanten-Admin, Cookie) ODER
|
||||
|
||||
@ -30,6 +30,7 @@ async def my_hosts(user: CurrentUser = Depends(get_current_user)):
|
||||
for h in hosts:
|
||||
h["can_connect"] = True
|
||||
h["can_file_transfer"] = True
|
||||
h["can_view_credentials"] = True
|
||||
return hosts
|
||||
|
||||
cursor = await conn.execute(
|
||||
@ -82,7 +83,26 @@ async def my_hosts(user: CurrentUser = Depends(get_current_user)):
|
||||
(user.id, user.id),
|
||||
)
|
||||
ft_groups = {row[0] for row in await ft_cursor.fetchall()}
|
||||
|
||||
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),
|
||||
)
|
||||
cred_groups = {row[0] for row in await cred_cursor.fetchall()}
|
||||
|
||||
for h in hosts:
|
||||
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
|
||||
|
||||
19
app/db/migrations/0008_credentials_roles.sql
Normal file
19
app/db/migrations/0008_credentials_roles.sql
Normal file
@ -0,0 +1,19 @@
|
||||
-- RBAC-Erweiterung "Credentials ins RBAC-Modell aufnehmen": zwei neue Rollen,
|
||||
-- die -- wie ssh_connect/rdp_connect/file_transfer/clipboard -- pro
|
||||
-- Hostgruppe an einzelne User oder Benutzergruppen vergeben werden koennen
|
||||
-- (user_hostgroup_roles / group_hostgroup_roles, unveraendert). Damit koennen
|
||||
-- auch Nicht-Admins gezielt Zugriff auf Zugangsdaten (SSH-Keys, RDP-Passwoerter)
|
||||
-- der Hosts "ihrer" Hostgruppe bekommen, ohne globaler oder Mandanten-Admin
|
||||
-- sein zu muessen. Durchsetzung: app/auth/deps.py::require_admin_scope_or_host_role,
|
||||
-- verwendet in app/admin/routes.py fuer die RDP-Credential- und SSH-Key-
|
||||
-- Zuordnungs-Endpunkte sowie den neuen GET /admin/hosts/{id}/credentials.
|
||||
--
|
||||
-- credentials_view: darf sehen, OB/wann ein RDP-Passwort gesetzt ist und
|
||||
-- welche SSH-Keys einem Host zugeordnet sind (NIE den
|
||||
-- Klartext selbst -- der wird ueber KEINEN Endpunkt
|
||||
-- jemals im Klartext zurueckgegeben, siehe Konzept 6.4).
|
||||
-- credentials_manage: wie credentials_view, zusaetzlich RDP-Passwort setzen/
|
||||
-- loeschen und SSH-Keys einem Host zuordnen/entfernen.
|
||||
INSERT OR IGNORE INTO roles (id, name) VALUES
|
||||
(7, 'credentials_view'),
|
||||
(8, 'credentials_manage');
|
||||
@ -19,6 +19,7 @@ from app.auth.routes import router as auth_router
|
||||
from app.catalog.routes import router as catalog_router
|
||||
from app.db import close_db, init_db
|
||||
from app.rdp_proxy.ws_tunnel import router as rdp_ws_router
|
||||
from app.security import log_stream
|
||||
from app.ssh_proxy.sftp import router as sftp_router
|
||||
from app.ssh_proxy.terminal_ws import router as ssh_ws_router
|
||||
|
||||
@ -27,6 +28,10 @@ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Ring-Buffer-Handler fuers Live-'Verbindungslog' (Admin-only, siehe
|
||||
# app/security/log_stream.py) -- MUSS vor init_db() installiert werden,
|
||||
# damit auch fruehe Startmeldungen im Puffer landen.
|
||||
log_stream.install()
|
||||
await init_db()
|
||||
yield
|
||||
await close_db()
|
||||
|
||||
@ -18,6 +18,13 @@ HOSTNAME_LABEL_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$")
|
||||
ROLE_NAME = Literal[
|
||||
"ssh_connect", "rdp_connect", "file_transfer", "clipboard",
|
||||
"session_recording_view", "admin_hostgroup",
|
||||
# Hinzugefuegt fuer RBAC-Erweiterung 'Credentials ins RBAC-Modell' (Migration
|
||||
# 0008_credentials_roles.sql): erlaubt Nicht-Admins mit dieser Rolle auf
|
||||
# einer Hostgruppe gezielten Zugriff auf Zugangsdaten (SSH-Keys, RDP-
|
||||
# Passwoerter) der Hosts dieser Gruppe, siehe app/auth/deps.py::
|
||||
# require_admin_scope_or_host_role und admin/routes.py (rdp-credentials,
|
||||
# ssh-keys-Zuordnung, GET /admin/hosts/{id}/credentials).
|
||||
"credentials_view", "credentials_manage",
|
||||
]
|
||||
|
||||
|
||||
@ -146,7 +153,7 @@ class HostUpdateRequest(BaseModel):
|
||||
class RoleGrantRequest(BaseModel):
|
||||
user_id: int
|
||||
host_group_id: int
|
||||
role_names: list[ROLE_NAME] = Field(min_length=1, max_length=6)
|
||||
role_names: list[ROLE_NAME] = Field(min_length=1, max_length=8)
|
||||
expires_at: str | None = None
|
||||
|
||||
|
||||
@ -174,7 +181,7 @@ class GroupMemberRequest(BaseModel):
|
||||
class GroupRoleGrantRequest(BaseModel):
|
||||
user_group_id: int
|
||||
host_group_id: int
|
||||
role_names: list[ROLE_NAME] = Field(min_length=1, max_length=6)
|
||||
role_names: list[ROLE_NAME] = Field(min_length=1, max_length=8)
|
||||
expires_at: str | None = None
|
||||
|
||||
|
||||
|
||||
@ -19,6 +19,7 @@ from app.config import settings
|
||||
from app.db import get_db
|
||||
from app.rbac import user_has_role_for_host
|
||||
from app.recordings.recorder import SessionRecorder
|
||||
from app.security import active_sessions
|
||||
from app.security.audit import write_audit_event
|
||||
from app.security.crypto import decrypt_secret
|
||||
from app.rdp_proxy.guacd_client import (
|
||||
@ -115,6 +116,11 @@ async def rdp_tunnel(
|
||||
details={"host_id": host_id, "hostname": host["hostname"], "session_id": session_id},
|
||||
)
|
||||
await conn.commit()
|
||||
logger.debug(
|
||||
"RDP-Sitzung %s gestartet: user=%s host=%s (%s:%s) client_ip=%s",
|
||||
session_id, user.username, host["hostname"], host["address"], host["port"], client_ip,
|
||||
)
|
||||
active_sessions.register(session_id, asyncio.current_task())
|
||||
|
||||
end_reason = "logout"
|
||||
tunnel = None
|
||||
@ -125,6 +131,7 @@ async def rdp_tunnel(
|
||||
guacd_host=settings.guacd_host, guacd_port=settings.guacd_port,
|
||||
protocol="rdp", params=params, screen_width=width, screen_height=height, dpi=dpi,
|
||||
)
|
||||
logger.debug("RDP-Sitzung %s: guacd-Tunnel zu %s aufgebaut", session_id, host["hostname"])
|
||||
clipboard_enabled = bool(host.get("clipboard_enabled", True))
|
||||
tasks = [
|
||||
asyncio.create_task(_guacd_to_ws(tunnel, websocket, recorder)),
|
||||
@ -144,13 +151,20 @@ async def rdp_tunnel(
|
||||
except (GuacamoleProtocolError, ConnectionError, OSError) as exc:
|
||||
logger.warning("RDP-Sessionfehler (session_id=%s): %s", session_id, exc)
|
||||
end_reason = "error"
|
||||
except asyncio.CancelledError:
|
||||
# Zwangs-Beendigung durch einen Superadmin ueber die Sessionview
|
||||
# (POST /admin/sessions/{id}/terminate, siehe app/security/active_sessions.py).
|
||||
end_reason = "terminated_by_admin"
|
||||
raise
|
||||
finally:
|
||||
active_sessions.unregister(session_id)
|
||||
del password # Klartext-Passwort so schnell wie moeglich freigeben
|
||||
for task in tasks:
|
||||
task.cancel()
|
||||
if tunnel:
|
||||
await tunnel.close()
|
||||
recorder.close()
|
||||
logger.debug("RDP-Sitzung %s beendet: reason=%s", session_id, end_reason)
|
||||
await conn.execute(
|
||||
"UPDATE sessions SET ended_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), end_reason = ? "
|
||||
"WHERE id = ?",
|
||||
|
||||
46
app/security/active_sessions.py
Normal file
46
app/security/active_sessions.py
Normal file
@ -0,0 +1,46 @@
|
||||
"""
|
||||
In-Memory-Registry der laufenden SSH/RDP-Sitzungen dieses Server-Prozesses.
|
||||
|
||||
Wird von app/ssh_proxy/terminal_ws.py und app/rdp_proxy/ws_tunnel.py beim
|
||||
Sitzungsstart befuellt (Referenz auf den eigenen asyncio.Task) und beim
|
||||
Sitzungsende wieder entfernt. Basis fuer die Superadmin-'Sessionview'
|
||||
(app/admin/routes.py: GET /admin/sessions, POST /admin/sessions/{id}/terminate)
|
||||
-- 'Beenden' funktioniert per asyncio.Task.cancel(), was den regulaeren
|
||||
finally-Cleanup-Pfad der jeweiligen WS-Route ausloest (DB-Update, Audit-
|
||||
Log-Eintrag, WebSocket schliessen), genau wie bei einem normalen Logout.
|
||||
|
||||
Bewusst rein prozesslokal (kein Redis/DB-Backing): bei einem einzelnen
|
||||
uvicorn-Worker (Standard-Deployment dieses Projekts, siehe ansible/) sieht
|
||||
und beendet jeder Superadmin-Request alle laufenden Sitzungen. Bei mehreren
|
||||
Worker-Prozessen sind nur die Sitzungen DIESES Workers 'killable' -- die
|
||||
Sessionview zeigt das ueber das 'killable'-Feld pro Sitzung an, statt einen
|
||||
Beenden-Versuch fehlschlagen zu lassen."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class ActiveSession:
|
||||
session_id: int
|
||||
task: asyncio.Task
|
||||
|
||||
|
||||
_active: dict[int, ActiveSession] = {}
|
||||
|
||||
|
||||
def register(session_id: int, task: asyncio.Task) -> None:
|
||||
_active[session_id] = ActiveSession(session_id=session_id, task=task)
|
||||
|
||||
|
||||
def unregister(session_id: int) -> None:
|
||||
_active.pop(session_id, None)
|
||||
|
||||
|
||||
def get(session_id: int) -> ActiveSession | None:
|
||||
return _active.get(session_id)
|
||||
|
||||
|
||||
def all_ids() -> set[int]:
|
||||
return set(_active.keys())
|
||||
71
app/security/log_stream.py
Normal file
71
app/security/log_stream.py
Normal file
@ -0,0 +1,71 @@
|
||||
"""
|
||||
Ring-Buffer + Pub/Sub fuer das Live-'Verbindungslog' im Admin-Bereich.
|
||||
|
||||
Haengt sich als zusaetzlicher logging.Handler an den Root-Logger, haelt die
|
||||
letzten LOG-Zeilen im Prozessspeicher (fuer den initialen Replay beim
|
||||
Oeffnen des Tabs) und verteilt jede neue Zeile an alle aktuell verbundenen
|
||||
WebSocket-Abonnenten (siehe app/admin/routes.py, WS /admin/ws/logs).
|
||||
|
||||
Bewusst KEINE Persistenz in der DB -- das ist ein reiner Live-Tail (wie
|
||||
'journalctl -f'), kein durchsuchbares Archiv. Rein prozesslokal: bei mehreren
|
||||
Worker-Prozessen sieht ein Abonnent nur die Logs des Worker-Prozesses, der
|
||||
seine WebSocket-Verbindung bedient.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections import deque
|
||||
|
||||
MAX_BUFFER_LINES = 1000
|
||||
_FORMATTER = logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s")
|
||||
|
||||
_buffer: deque[str] = deque(maxlen=MAX_BUFFER_LINES)
|
||||
_subscribers: set[asyncio.Queue[str]] = set()
|
||||
_installed = False
|
||||
|
||||
|
||||
class _BroadcastHandler(logging.Handler):
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
try:
|
||||
line = _FORMATTER.format(record)
|
||||
except Exception:
|
||||
return
|
||||
_buffer.append(line)
|
||||
for queue in list(_subscribers):
|
||||
try:
|
||||
queue.put_nowait(line)
|
||||
except asyncio.QueueFull:
|
||||
# Langsamer/verschwundener Abonnent -- lieber eine Zeile
|
||||
# verlieren als das Event-Loop-Logging blockieren.
|
||||
pass
|
||||
|
||||
|
||||
def install() -> None:
|
||||
"""Idempotent -- an app.main::lifespan gebunden. Haengt den Handler an
|
||||
den Root-Logger (sieht alles, was propagiert) und hebt den Level des
|
||||
eigenen App-Namespace 'jumphost' auf DEBUG an, damit die Verbindungs-
|
||||
Debug-Meldungen (SSH/RDP-Handshake etc.) ueberhaupt erzeugt werden, ohne
|
||||
Drittanbieter-Logger (uvicorn, asyncssh) unnoetig auf DEBUG zu stellen."""
|
||||
global _installed
|
||||
if _installed:
|
||||
return
|
||||
handler = _BroadcastHandler()
|
||||
handler.setLevel(logging.DEBUG)
|
||||
logging.getLogger().addHandler(handler)
|
||||
logging.getLogger("jumphost").setLevel(logging.DEBUG)
|
||||
_installed = True
|
||||
|
||||
|
||||
def subscribe() -> asyncio.Queue[str]:
|
||||
queue: asyncio.Queue[str] = asyncio.Queue(maxsize=500)
|
||||
_subscribers.add(queue)
|
||||
return queue
|
||||
|
||||
|
||||
def unsubscribe(queue: asyncio.Queue[str]) -> None:
|
||||
_subscribers.discard(queue)
|
||||
|
||||
|
||||
def recent_lines() -> list[str]:
|
||||
return list(_buffer)
|
||||
@ -30,6 +30,20 @@ class HostKeyMismatchError(Exception):
|
||||
super().__init__(f"Host-Key-Mismatch: erwartet={expected!r} beobachtet={observed!r}")
|
||||
|
||||
|
||||
class HostKeyDiscoveryError(Exception):
|
||||
"""Wird geworfen, wenn beim Host-Key-Discovery-Versuch (siehe
|
||||
discover_and_store_host_key) ueberhaupt KEIN Fingerprint beobachtet
|
||||
werden konnte (TCP/DNS/Timeout-Fehler VOR dem SSH-Key-Exchange) -- im
|
||||
Unterschied zu einem erwarteten Auth-Fehler NACH dem KEX (siehe dort).
|
||||
Der admin-only Endpunkt (admin/routes.py) faengt dies ab und liefert
|
||||
eine saubere 502 statt eines unbehandelten 500."""
|
||||
|
||||
def __init__(self, host_id: int, reason: str) -> None:
|
||||
self.host_id = host_id
|
||||
self.reason = reason
|
||||
super().__init__(f"Host-Key-Ermittlung fuer Host {host_id} fehlgeschlagen: {reason}")
|
||||
|
||||
|
||||
class _PinnedHostKeyClient(asyncssh.SSHClient):
|
||||
"""Erzwingt Strict Host Key Checking gegen einen fest hinterlegten
|
||||
SHA-256-Fingerprint. Kein automatisches Trust-on-First-Use (TOFU)."""
|
||||
@ -114,14 +128,45 @@ async def discover_and_store_host_key(
|
||||
"""Verbindet EINMALIG ohne Pinning, um den Host-Key-Fingerprint zu erfassen
|
||||
und in der DB zu hinterlegen. Nur ueber einen dedizierten, admin-only
|
||||
Endpunkt aufrufbar -- jeder Aufruf ist eine bewusste Vertrauensentscheidung
|
||||
und wird im Audit-Log als solche vermerkt (siehe admin/routes.py)."""
|
||||
und wird im Audit-Log als solche vermerkt (siehe admin/routes.py).
|
||||
|
||||
Bugfix (Fehler 500 bei 'Host-Key ermitteln'): asyncssh.connect() fuehrt
|
||||
nach dem Key-Exchange (bei dem validate_host_public_key() den Fingerprint
|
||||
bereits erfasst) automatisch die Authentifizierung durch -- ohne
|
||||
hinterlegten Client-Key/Passwort schlaegt die IMMER fehl (asyncssh.Error,
|
||||
z.B. PermissionDenied), obwohl der Fingerprint laengst bekannt ist. Diese
|
||||
fuer die reine Discovery irrelevante Auth-Fehlermeldung wurde bisher NICHT
|
||||
abgefangen und riss als unbehandelte Exception bis zu FastAPI durch ->
|
||||
500 Internal Server Error bei praktisch jedem Aufruf. Fix: Verbindungs-
|
||||
fehler abfangen; wenn dabei bereits ein Fingerprint beobachtet wurde, gilt
|
||||
die Discovery als erfolgreich. Nur wenn wirklich KEIN Fingerprint erfasst
|
||||
wurde (Host nicht erreichbar, DNS-Fehler, Timeout -- also ein Fehler VOR
|
||||
dem Key-Exchange), ist es ein echter Fehler (HostKeyDiscoveryError)."""
|
||||
host = await load_host(conn, host_id)
|
||||
if host["protocol"] != "ssh":
|
||||
raise HostNotConfiguredError("Host-Key-Ermittlung ist nur fuer SSH-Ziele moeglich")
|
||||
|
||||
client = _PinnedHostKeyClient(None, discovery_mode=True)
|
||||
connection = await asyncssh.connect(
|
||||
host["address"], port=host["port"], username=host["ssh_username"],
|
||||
known_hosts=None, client_factory=lambda: client, connect_timeout=10,
|
||||
)
|
||||
connection.close()
|
||||
connection = None
|
||||
try:
|
||||
connection = await asyncssh.connect(
|
||||
host["address"], port=host["port"], username=host["ssh_username"],
|
||||
known_hosts=None, client_factory=lambda: client, connect_timeout=10,
|
||||
)
|
||||
except (asyncssh.Error, OSError) as exc:
|
||||
if client.observed_fingerprint is None:
|
||||
logger.warning("Host-Key-Ermittlung fuer Host %s fehlgeschlagen: %s", host_id, exc)
|
||||
raise HostKeyDiscoveryError(host_id, str(exc)) from exc
|
||||
# Fingerprint wurde bereits waehrend des Key-Exchange erfasst -- der
|
||||
# anschliessende Auth-Fehler ist fuer die Discovery unschaedlich.
|
||||
logger.info(
|
||||
"Host-Key fuer Host %s erfasst (Auth-Phase erwartungsgemaess fehlgeschlagen: %s)",
|
||||
host_id, exc,
|
||||
)
|
||||
finally:
|
||||
if connection is not None:
|
||||
connection.close()
|
||||
|
||||
fingerprint = client.observed_fingerprint
|
||||
await conn.execute(
|
||||
"UPDATE hosts SET ssh_host_key_fingerprint = ? WHERE id = ?", (fingerprint, host_id)
|
||||
|
||||
@ -21,6 +21,7 @@ from app.auth.deps import get_current_user_ws
|
||||
from app.db import get_db
|
||||
from app.rbac import user_has_role_for_host
|
||||
from app.recordings.recorder import SessionRecorder
|
||||
from app.security import active_sessions
|
||||
from app.security.audit import write_audit_event
|
||||
from app.ssh_proxy.proxy import HostNotConfiguredError, connect_to_host, load_host
|
||||
|
||||
@ -83,6 +84,11 @@ async def ssh_terminal(websocket: WebSocket, host_id: int):
|
||||
details={"host_id": host_id, "hostname": host["hostname"], "session_id": session_id},
|
||||
)
|
||||
await conn.commit()
|
||||
logger.debug(
|
||||
"SSH-Sitzung %s gestartet: user=%s host=%s (%s:%s) client_ip=%s",
|
||||
session_id, user.username, host["hostname"], host["address"], host["port"], client_ip,
|
||||
)
|
||||
active_sessions.register(session_id, asyncio.current_task())
|
||||
|
||||
end_reason = "logout"
|
||||
ssh_conn = None
|
||||
@ -90,6 +96,7 @@ async def ssh_terminal(websocket: WebSocket, host_id: int):
|
||||
pump_task = None
|
||||
try:
|
||||
ssh_conn = await connect_to_host(conn, host_id)
|
||||
logger.debug("SSH-Sitzung %s: Verbindung zu %s hergestellt", session_id, host["hostname"])
|
||||
process = await ssh_conn.create_process(term_type="xterm-256color")
|
||||
pump_task = asyncio.create_task(_pump_ssh_to_ws(process, websocket, recorder))
|
||||
|
||||
@ -120,7 +127,13 @@ async def ssh_terminal(websocket: WebSocket, host_id: int):
|
||||
logger.debug("Fehlermeldung konnte nicht mehr an Client gesendet werden", exc_info=True)
|
||||
except HostNotConfiguredError:
|
||||
end_reason = "error"
|
||||
except asyncio.CancelledError:
|
||||
# Zwangs-Beendigung durch einen Superadmin ueber die Sessionview
|
||||
# (POST /admin/sessions/{id}/terminate, siehe app/security/active_sessions.py).
|
||||
end_reason = "terminated_by_admin"
|
||||
raise
|
||||
finally:
|
||||
active_sessions.unregister(session_id)
|
||||
if pump_task:
|
||||
pump_task.cancel()
|
||||
if process:
|
||||
@ -128,6 +141,7 @@ async def ssh_terminal(websocket: WebSocket, host_id: int):
|
||||
if ssh_conn:
|
||||
ssh_conn.close()
|
||||
recorder.close()
|
||||
logger.debug("SSH-Sitzung %s beendet: reason=%s", session_id, end_reason)
|
||||
await conn.execute(
|
||||
"UPDATE sessions SET ended_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), end_reason = ? "
|
||||
"WHERE id = ?",
|
||||
|
||||
Reference in New Issue
Block a user