""" Administrative CRUD-API: Mandanten, User, Benutzergruppen, Hostgruppen, Hosts, Rollenvergabe (an User UND an Benutzergruppen, Mehrfachauswahl), SSH-Keys, RDP-Zugangsdaten, API-Tokens, Audit-Log. Zustandsaendernde/-lesende Endpunkte sind entweder auf eine eingeloggte Admin-Session (Super- ODER Mandanten-Admin) ODER ein API-Token mit passendem Scope beschraenkt (`require_admin_or_scope`, siehe app/auth/deps.py) und schreiben einen Audit-Log-Eintrag (Konzept 4.7). Zusaetzlich zur Scope-Pruefung wird bei JEDEM mandantengebundenen Datensatz (Hostgruppen, Hosts, Benutzergruppen, SSH-Keys, Tokens, Benutzer, Rollenvergaben, Audit-Log) per `TenantScope` (app/tenancy.py) geprueft/gefiltert, ob der Principal ueberhaupt in diesem Mandanten agieren darf -- ein Mandanten-Admin bekommt fuer alles ausserhalb seines/seiner Mandanten ein 404 (bewusst kein 403, siehe app/tenancy.py). Tenant-CRUD und Mandanten-Admin-Ernennung selbst sind ausschliesslich Super-Admin-Aktionen (`require_global_admin`). Die Token-Verwaltung (/admin/tokens/*) laeuft ausschliesslich ueber `require_admin_session` (reine Session-Aktion, kein Token-Bypass -- Privilege-Escalation-Schutz). """ from __future__ import annotations import json import logging from pathlib import Path from fastapi import APIRouter, Depends, HTTPException, Request, status from app.auth.deps import ( CurrentUser, effective_tenant_ids, require_admin_or_scope, require_admin_scope_or_host_role, require_admin_session, require_global_admin, ) from app.db import get_db from app.models.schemas import ( ApiTokenCreateRequest, GroupMemberRequest, GroupRoleGrantRequest, GroupRoleRevokeRequest, HostCreateRequest, HostGroupCreateRequest, HostGroupUpdateRequest, HostUpdateRequest, RdpCredentialCreateRequest, RdpCredentialUpdateRequest, RoleGrantRequest, RoleRevokeRequest, SshKeyCreateRequest, SshKeyGenerateRequest, SshKeyUpdateRequest, SshPasswordCredentialsRequest, TenantAdminAssignRequest, TenantCreateRequest, TenantUpdateRequest, UserCreateRequest, UserGroupCreateRequest, UserGroupUpdateRequest, UserUpdateRequest, ) from app.security import active_sessions from app.security.api_tokens import ( VALID_SCOPES, generate_token, hash_token, token_prefix_for_display, validate_scopes, ) from app.security.audit import verify_chain, write_audit_event from app.security.crypto import decrypt_secret, encrypt_secret from app.security.passwords import hash_password from app.recordings.recorder import verify_recording from app.ssh_proxy.proxy import ( HostKeyDiscoveryError, HostNotConfiguredError, PrivateKeyUnusableError, discover_and_store_host_key, generate_key_material, import_private_key_material, ) 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"]) def _client_ip(request: Request) -> str: return request.client.host if request.client else "unknown" def _scope(admin: CurrentUser) -> TenantScope: return TenantScope(effective_tenant_ids(admin)) async def _tenant_exists(conn, tenant_id: int) -> bool: row = await (await conn.execute("SELECT 1 FROM tenants WHERE id = ?", (tenant_id,))).fetchone() return row is not None async def _resolve_write_tenant(conn, scope: TenantScope, requested_tenant_id: int | None) -> int: """Ermittelt den Mandanten fuer eine NEU anzulegende Ressource: Super-Admin muss requested_tenant_id angeben; ein Mandanten-Admin mit genau einem Mandanten bekommt ihn automatisch erzwungen (Client-Angaben werden dabei ignoriert); hat er mehrere, muss requested_tenant_id einer davon sein. Prueft in jedem Fall, dass der Mandant tatsaechlich existiert (sonst 404 statt eines rohen FK-Fehlers).""" if scope.all_tenants: if requested_tenant_id is None: raise HTTPException(status.HTTP_400_BAD_REQUEST, "tenant_id ist erforderlich") if not await _tenant_exists(conn, requested_tenant_id): raise HTTPException(status.HTTP_404_NOT_FOUND, "Mandant nicht gefunden") return requested_tenant_id single = scope.single_tenant_id() if single is not None: return single if requested_tenant_id is None or requested_tenant_id not in scope.tenant_ids: raise HTTPException(status.HTTP_400_BAD_REQUEST, "Ungueltiger oder fehlender Mandant") return requested_tenant_id # --- Mandanten (Super-Admin only) --------------------------------------------- @router.post("/tenants", status_code=status.HTTP_201_CREATED) async def create_tenant( payload: TenantCreateRequest, request: Request, admin: CurrentUser = Depends(require_global_admin) ): conn = get_db() cursor = await conn.execute("SELECT 1 FROM tenants WHERE name = ?", (payload.name,)) if await cursor.fetchone() is not None: raise HTTPException(status.HTTP_409_CONFLICT, "Mandantenname existiert bereits") cursor = await conn.execute( "INSERT INTO tenants (name, description) VALUES (?, ?)", (payload.name, payload.description) ) new_id = cursor.lastrowid await write_audit_event( conn, event_type="tenant_created", user_id=admin.id, client_ip=_client_ip(request), details={"id": new_id, "name": payload.name}, ) await conn.commit() return {"id": new_id, "name": payload.name} @router.get("/tenants") async def list_tenants(admin: CurrentUser = Depends(require_global_admin)): conn = get_db() cursor = await conn.execute( "SELECT t.id, t.name, t.description, t.is_active, t.created_at, " "(SELECT COUNT(*) FROM host_groups WHERE tenant_id = t.id), " "(SELECT COUNT(*) FROM user_groups WHERE tenant_id = t.id) " "FROM tenants t ORDER BY t.name" ) rows = await cursor.fetchall() return [ { "id": r[0], "name": r[1], "description": r[2], "is_active": bool(r[3]), "created_at": r[4], "host_group_count": r[5], "user_group_count": r[6], } for r in rows ] @router.put("/tenants/{tenant_id}") async def update_tenant( tenant_id: int, payload: TenantUpdateRequest, request: Request, admin: CurrentUser = Depends(require_global_admin), ): conn = get_db() fields, values = [], [] if payload.name is not None: fields.append("name = ?"); values.append(payload.name) if payload.description is not None: fields.append("description = ?"); values.append(payload.description) if payload.is_active is not None: fields.append("is_active = ?"); values.append(int(payload.is_active)) if not fields: return {"status": "ok", "changed": False} values.append(tenant_id) await conn.execute(f"UPDATE tenants SET {', '.join(fields)} WHERE id = ?", values) await write_audit_event( conn, event_type="tenant_updated", user_id=admin.id, client_ip=_client_ip(request), details={"tenant_id": tenant_id, "fields": list(payload.model_dump(exclude_none=True))}, ) await conn.commit() return {"status": "ok", "changed": True} @router.delete("/tenants/{tenant_id}") async def delete_tenant(tenant_id: int, request: Request, admin: CurrentUser = Depends(require_global_admin)): conn = get_db() for table in ("host_groups", "user_groups", "ssh_keys", "api_tokens"): cursor = await conn.execute(f"SELECT COUNT(*) FROM {table} WHERE tenant_id = ?", (tenant_id,)) (count,) = await cursor.fetchone() if count: raise HTTPException( status.HTTP_409_CONFLICT, f"Mandant enthaelt noch Ressourcen in '{table}' ({count}) -- zuerst entfernen/verschieben", ) cursor = await conn.execute("DELETE FROM tenants WHERE id = ?", (tenant_id,)) if cursor.rowcount == 0: raise HTTPException(status.HTTP_404_NOT_FOUND, "Mandant nicht gefunden") await write_audit_event( conn, event_type="tenant_deleted", user_id=admin.id, client_ip=_client_ip(request), details={"tenant_id": tenant_id}, ) await conn.commit() return {"status": "ok"} @router.get("/tenants/{tenant_id}/admins") async def list_tenant_admins(tenant_id: int, admin: CurrentUser = Depends(require_global_admin)): conn = get_db() cursor = await conn.execute( "SELECT u.id, u.username, ta.granted_at FROM tenant_admins ta " "JOIN users u ON u.id = ta.user_id WHERE ta.tenant_id = ? ORDER BY u.username", (tenant_id,), ) rows = await cursor.fetchall() return [{"user_id": r[0], "username": r[1], "granted_at": r[2]} for r in rows] @router.post("/tenants/{tenant_id}/admins", status_code=status.HTTP_201_CREATED) async def add_tenant_admin( tenant_id: int, payload: TenantAdminAssignRequest, request: Request, admin: CurrentUser = Depends(require_global_admin), ): conn = get_db() user_cursor = await conn.execute( "SELECT 1 FROM users WHERE id = ? AND deleted_at IS NULL", (payload.user_id,) ) if await user_cursor.fetchone() is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Benutzer nicht gefunden") await conn.execute( "INSERT OR IGNORE INTO tenant_admins (user_id, tenant_id, granted_by) VALUES (?, ?, ?)", (payload.user_id, tenant_id, admin.id), ) await write_audit_event( conn, event_type="tenant_admin_granted", user_id=admin.id, client_ip=_client_ip(request), details={"tenant_id": tenant_id, "target_user_id": payload.user_id}, ) await conn.commit() return {"status": "ok"} @router.delete("/tenants/{tenant_id}/admins/{user_id}") async def remove_tenant_admin( tenant_id: int, user_id: int, request: Request, admin: CurrentUser = Depends(require_global_admin) ): conn = get_db() await conn.execute( "DELETE FROM tenant_admins WHERE tenant_id = ? AND user_id = ?", (tenant_id, user_id) ) await write_audit_event( conn, event_type="tenant_admin_revoked", user_id=admin.id, client_ip=_client_ip(request), details={"tenant_id": tenant_id, "target_user_id": user_id}, ) await conn.commit() return {"status": "ok"} # --- Users ----------------------------------------------------------------- @router.post("/users", status_code=status.HTTP_201_CREATED) async def create_user( payload: UserCreateRequest, request: Request, admin: CurrentUser = Depends(require_admin_or_scope("users", "write")), ): if payload.is_admin and not admin.is_admin: raise HTTPException(status.HTTP_403_FORBIDDEN, "Nur Super-Admins duerfen weitere Super-Admins anlegen") scope = _scope(admin) home_tenant_id = payload.home_tenant_id if not scope.all_tenants: single = scope.single_tenant_id() home_tenant_id = single if single is not None else home_tenant_id if home_tenant_id is not None: scope.check(home_tenant_id) conn = get_db() cursor = await conn.execute( "SELECT 1 FROM users WHERE username = ? AND deleted_at IS NULL", (payload.username,) ) if await cursor.fetchone() is not None: raise HTTPException(status.HTTP_409_CONFLICT, "Benutzername existiert bereits") pw_hash = hash_password(payload.initial_password) cursor = await conn.execute( "INSERT INTO users (username, password_hash, is_admin, must_change_password, home_tenant_id) " "VALUES (?, ?, ?, 1, ?)", (payload.username, pw_hash, int(payload.is_admin), home_tenant_id), ) new_id = cursor.lastrowid await write_audit_event( conn, event_type="user_created", user_id=admin.id, client_ip=_client_ip(request), details={"new_user_id": new_id, "username": payload.username, "is_admin": payload.is_admin}, ) await conn.commit() return {"id": new_id, "username": payload.username} @router.get("/users") async def list_users(admin: CurrentUser = Depends(require_admin_or_scope("users", "read"))): conn = get_db() scope = _scope(admin) if scope.all_tenants: cursor = await conn.execute( "SELECT id, username, is_admin, is_active, totp_enrolled, created_at, home_tenant_id " "FROM users WHERE deleted_at IS NULL ORDER BY id" ) rows = await cursor.fetchall() else: visible: set[int] = set() for tid in scope.tenant_ids: visible |= await tenant_user_ids(conn, tid) if not visible: return [] placeholders = ",".join("?" for _ in visible) cursor = await conn.execute( f"SELECT id, username, is_admin, is_active, totp_enrolled, created_at, home_tenant_id " f"FROM users WHERE deleted_at IS NULL AND id IN ({placeholders}) ORDER BY id", tuple(visible), ) rows = await cursor.fetchall() return [ { "id": r[0], "username": r[1], "is_admin": bool(r[2]), "is_active": bool(r[3]), "totp_enrolled": bool(r[4]), "created_at": r[5], "home_tenant_id": r[6], } for r in rows ] async def _assert_user_in_scope(conn, scope: TenantScope, user_id: int) -> None: if scope.all_tenants: return visible: set[int] = set() for tid in scope.tenant_ids: visible |= await tenant_user_ids(conn, tid) if user_id not in visible: raise HTTPException(status.HTTP_404_NOT_FOUND, "Nicht gefunden") @router.put("/users/{user_id}") async def update_user( user_id: int, payload: UserUpdateRequest, request: Request, admin: CurrentUser = Depends(require_admin_or_scope("users", "write")), ): conn = get_db() scope = _scope(admin) target = await conn.execute( "SELECT is_admin FROM users WHERE id = ? AND deleted_at IS NULL", (user_id,) ) row = await target.fetchone() if row is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Benutzer nicht gefunden") if row[0] and not admin.is_admin: raise HTTPException(status.HTTP_403_FORBIDDEN, "Super-Admin-Konten nur durch Super-Admins aenderbar") if payload.is_admin is not None and not admin.is_admin: raise HTTPException(status.HTTP_403_FORBIDDEN, "Nur Super-Admins duerfen Super-Admin-Rechte vergeben") await _assert_user_in_scope(conn, scope, user_id) fields, values = [], [] if payload.is_admin is not None: fields.append("is_admin = ?"); values.append(int(payload.is_admin)) if payload.is_active is not None: fields.append("is_active = ?"); values.append(int(payload.is_active)) fields.append("session_version = session_version + 1") if payload.new_password is not None: fields.append("password_hash = ?"); values.append(hash_password(payload.new_password)) fields.append("must_change_password = 1") fields.append("session_version = session_version + 1") if not fields: return {"status": "ok", "changed": False} values.append(user_id) await conn.execute(f"UPDATE users SET {', '.join(fields)} WHERE id = ?", values) await write_audit_event( conn, event_type="user_updated", user_id=admin.id, client_ip=_client_ip(request), details={ "target_user_id": user_id, "fields": [k for k, v in payload.model_dump(exclude={"new_password"}).items() if v is not None] + (["new_password"] if payload.new_password is not None else []), }, ) await conn.commit() return {"status": "ok", "changed": True} @router.post("/users/{user_id}/deactivate") async def deactivate_user( user_id: int, request: Request, admin: CurrentUser = Depends(require_admin_or_scope("users", "write")), ): conn = get_db() scope = _scope(admin) row = await (await conn.execute( "SELECT is_admin FROM users WHERE id = ? AND deleted_at IS NULL", (user_id,) )).fetchone() if row is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Benutzer nicht gefunden") if row[0] and not admin.is_admin: raise HTTPException(status.HTTP_403_FORBIDDEN, "Super-Admin-Konten nur durch Super-Admins aenderbar") await _assert_user_in_scope(conn, scope, user_id) await conn.execute( "UPDATE users SET is_active = 0, session_version = session_version + 1 WHERE id = ?", (user_id,), ) await write_audit_event( conn, event_type="user_deactivated", user_id=admin.id, client_ip=_client_ip(request), details={"target_user_id": user_id}, ) await conn.commit() return {"status": "ok"} @router.delete("/users/{user_id}") async def delete_user( user_id: int, request: Request, admin: CurrentUser = Depends(require_admin_or_scope("users", "write")), ): """Loescht ein Benutzerkonto. Ein Konto MIT Audit-Historie kann nicht per SQL-DELETE entfernt werden (audit_log.user_id verweist bewusst OHNE ON DELETE CASCADE auf users(id), siehe 0007_crud_extras.sql) -- es wird stattdessen deaktiviert und anonymisiert (Benutzername/Passwort/TOTP geloescht, deleted_at gesetzt). Nur ein Konto OHNE jede Audit-Historie (z.B. versehentlich angelegt und sofort wieder geloescht) wird tatsaechlich hart entfernt. Die Response verraet, welcher Fall eintrat.""" conn = get_db() scope = _scope(admin) row = await (await conn.execute( "SELECT is_admin, username FROM users WHERE id = ? AND deleted_at IS NULL", (user_id,) )).fetchone() if row is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Benutzer nicht gefunden") if row[0] and not admin.is_admin: raise HTTPException(status.HTTP_403_FORBIDDEN, "Super-Admin-Konten nur durch Super-Admins loeschbar") if user_id == admin.id: raise HTTPException(status.HTTP_400_BAD_REQUEST, "Eigenes Konto kann nicht geloescht werden") await _assert_user_in_scope(conn, scope, user_id) has_audit = await (await conn.execute( "SELECT 1 FROM audit_log WHERE user_id = ? LIMIT 1", (user_id,) )).fetchone() hard_deleted = False if has_audit is None: try: await conn.execute("DELETE FROM users WHERE id = ?", (user_id,)) hard_deleted = True except Exception: hard_deleted = False if not hard_deleted: anonymized_username = f"deleted_user_{user_id}" await conn.execute( "UPDATE users SET username = ?, password_hash = ?, totp_secret_enc = NULL, " "totp_enrolled = 0, is_active = 0, session_version = session_version + 1, " "deleted_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?", (anonymized_username, hash_password(generate_token()), user_id), ) await write_audit_event( conn, event_type="user_deleted", user_id=admin.id, client_ip=_client_ip(request), details={"target_user_id": user_id, "hard_deleted": hard_deleted, "was_username": row[1]}, ) await conn.commit() return {"status": "ok", "hard_deleted": hard_deleted} # --- Benutzergruppen (Teams) ------------------------------------------------- # # "Verbindungen mit einer Gruppe teilen" = einer Benutzergruppe ueber # /admin/group-roles/grant eine Rolle auf einer Hostgruppe geben -- jedes # aktuelle und zukuenftige Mitglied erbt diese Rolle vollstaendig (siehe # app/rbac.py: user_has_role() vereinigt direkte und Gruppen-Grants). @router.post("/user-groups", status_code=status.HTTP_201_CREATED) async def create_user_group( payload: UserGroupCreateRequest, request: Request, admin: CurrentUser = Depends(require_admin_or_scope("user_groups", "write")), ): conn = get_db() scope = _scope(admin) tenant_id = await _resolve_write_tenant(conn, scope, payload.tenant_id) cursor = await conn.execute("SELECT 1 FROM user_groups WHERE name = ?", (payload.name,)) if await cursor.fetchone() is not None: raise HTTPException(status.HTTP_409_CONFLICT, "Gruppenname existiert bereits") cursor = await conn.execute( "INSERT INTO user_groups (name, description, tenant_id) VALUES (?, ?, ?)", (payload.name, payload.description, tenant_id), ) new_id = cursor.lastrowid await write_audit_event( conn, event_type="user_group_created", user_id=admin.id, client_ip=_client_ip(request), details={"id": new_id, "name": payload.name, "tenant_id": tenant_id}, ) await conn.commit() return {"id": new_id, "name": payload.name} @router.get("/user-groups") async def list_user_groups(admin: CurrentUser = Depends(require_admin_or_scope("user_groups", "read"))): conn = get_db() scope = _scope(admin) tenant_filter, params = scope.sql_filter("ug.tenant_id") cursor = await conn.execute( "SELECT ug.id, ug.name, ug.description, ug.created_at, COUNT(ugm.user_id), ug.tenant_id, t.name " "FROM user_groups ug LEFT JOIN user_group_members ugm ON ugm.user_group_id = ug.id " "JOIN tenants t ON t.id = ug.tenant_id " f"WHERE 1=1{tenant_filter} GROUP BY ug.id ORDER BY ug.name", params, ) rows = await cursor.fetchall() return [ { "id": r[0], "name": r[1], "description": r[2], "created_at": r[3], "member_count": r[4], "tenant_id": r[5], "tenant_name": r[6], } for r in rows ] async def _assert_user_group_in_scope(conn, scope: TenantScope, group_id: int) -> int: row = await (await conn.execute("SELECT tenant_id FROM user_groups WHERE id = ?", (group_id,))).fetchone() if row is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Gruppe nicht gefunden") scope.check(row[0]) return row[0] @router.put("/user-groups/{group_id}") async def update_user_group( group_id: int, payload: UserGroupUpdateRequest, request: Request, admin: CurrentUser = Depends(require_admin_or_scope("user_groups", "write")), ): conn = get_db() await _assert_user_group_in_scope(conn, _scope(admin), group_id) fields, values = [], [] if payload.name is not None: fields.append("name = ?"); values.append(payload.name) if payload.description is not None: fields.append("description = ?"); values.append(payload.description) if not fields: return {"status": "ok", "changed": False} values.append(group_id) await conn.execute(f"UPDATE user_groups SET {', '.join(fields)} WHERE id = ?", values) await write_audit_event( conn, event_type="user_group_updated", user_id=admin.id, client_ip=_client_ip(request), details={"id": group_id}, ) await conn.commit() return {"status": "ok", "changed": True} @router.delete("/user-groups/{group_id}") async def delete_user_group( group_id: int, request: Request, admin: CurrentUser = Depends(require_admin_or_scope("user_groups", "write")), ): conn = get_db() await _assert_user_group_in_scope(conn, _scope(admin), group_id) await conn.execute("DELETE FROM user_groups WHERE id = ?", (group_id,)) await write_audit_event( conn, event_type="user_group_deleted", user_id=admin.id, client_ip=_client_ip(request), details={"id": group_id}, ) await conn.commit() return {"status": "ok"} @router.get("/user-groups/{group_id}/members") async def list_group_members( group_id: int, admin: CurrentUser = Depends(require_admin_or_scope("user_groups", "read")) ): conn = get_db() await _assert_user_group_in_scope(conn, _scope(admin), group_id) cursor = await conn.execute( "SELECT u.id, u.username, ugm.added_at FROM user_group_members ugm " "JOIN users u ON u.id = ugm.user_id WHERE ugm.user_group_id = ? ORDER BY u.username", (group_id,), ) rows = await cursor.fetchall() return [{"user_id": r[0], "username": r[1], "added_at": r[2]} for r in rows] @router.post("/user-groups/{group_id}/members", status_code=status.HTTP_201_CREATED) async def add_group_member( group_id: int, payload: GroupMemberRequest, request: Request, admin: CurrentUser = Depends(require_admin_or_scope("user_groups", "write")), ): conn = get_db() await _assert_user_group_in_scope(conn, _scope(admin), group_id) user_cursor = await conn.execute( "SELECT 1 FROM users WHERE id = ? AND deleted_at IS NULL", (payload.user_id,) ) if await user_cursor.fetchone() is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Benutzer nicht gefunden") await conn.execute( "INSERT OR IGNORE INTO user_group_members (user_group_id, user_id, added_by) VALUES (?, ?, ?)", (group_id, payload.user_id, admin.id), ) await write_audit_event( conn, event_type="user_group_member_added", user_id=admin.id, client_ip=_client_ip(request), details={"user_group_id": group_id, "target_user_id": payload.user_id}, ) await conn.commit() return {"status": "ok"} @router.delete("/user-groups/{group_id}/members/{user_id}") async def remove_group_member( group_id: int, user_id: int, request: Request, admin: CurrentUser = Depends(require_admin_or_scope("user_groups", "write")), ): conn = get_db() await _assert_user_group_in_scope(conn, _scope(admin), group_id) await conn.execute( "DELETE FROM user_group_members WHERE user_group_id = ? AND user_id = ?", (group_id, user_id), ) await write_audit_event( conn, event_type="user_group_member_removed", user_id=admin.id, client_ip=_client_ip(request), details={"user_group_id": group_id, "target_user_id": user_id}, ) await conn.commit() return {"status": "ok"} # --- Hostgruppen ------------------------------------------------------------- @router.post("/host-groups", status_code=status.HTTP_201_CREATED) async def create_host_group( payload: HostGroupCreateRequest, request: Request, admin: CurrentUser = Depends(require_admin_or_scope("host_groups", "write")), ): conn = get_db() scope = _scope(admin) tenant_id = await _resolve_write_tenant(conn, scope, payload.tenant_id) cursor = await conn.execute( "INSERT INTO host_groups (name, description, tenant_id) VALUES (?, ?, ?)", (payload.name, payload.description, tenant_id), ) new_id = cursor.lastrowid await write_audit_event( conn, event_type="host_group_created", user_id=admin.id, client_ip=_client_ip(request), details={"id": new_id, "name": payload.name, "tenant_id": tenant_id}, ) await conn.commit() return {"id": new_id, "name": payload.name} @router.get("/host-groups") async def list_host_groups(admin: CurrentUser = Depends(require_admin_or_scope("host_groups", "read"))): conn = get_db() scope = _scope(admin) tenant_filter, params = scope.sql_filter("hg.tenant_id") cursor = await conn.execute( "SELECT hg.id, hg.name, hg.description, hg.tenant_id, t.name FROM host_groups hg " f"JOIN tenants t ON t.id = hg.tenant_id WHERE 1=1{tenant_filter} ORDER BY hg.id", params, ) rows = await cursor.fetchall() return [ {"id": r[0], "name": r[1], "description": r[2], "tenant_id": r[3], "tenant_name": r[4]} for r in rows ] async def _assert_host_group_in_scope(conn, scope: TenantScope, host_group_id: int) -> int: tenant_id = await resolve_host_group_tenant(conn, host_group_id) if tenant_id is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Hostgruppe nicht gefunden") scope.check(tenant_id) return tenant_id @router.put("/host-groups/{host_group_id}") async def update_host_group( host_group_id: int, payload: HostGroupUpdateRequest, request: Request, admin: CurrentUser = Depends(require_admin_or_scope("host_groups", "write")), ): conn = get_db() await _assert_host_group_in_scope(conn, _scope(admin), host_group_id) fields, values = [], [] if payload.name is not None: fields.append("name = ?"); values.append(payload.name) if payload.description is not None: fields.append("description = ?"); values.append(payload.description) if not fields: return {"status": "ok", "changed": False} values.append(host_group_id) await conn.execute(f"UPDATE host_groups SET {', '.join(fields)} WHERE id = ?", values) await write_audit_event( conn, event_type="host_group_updated", user_id=admin.id, client_ip=_client_ip(request), details={"id": host_group_id}, ) await conn.commit() return {"status": "ok", "changed": True} @router.delete("/host-groups/{host_group_id}") async def delete_host_group( host_group_id: int, request: Request, admin: CurrentUser = Depends(require_admin_or_scope("host_groups", "write")), ): conn = get_db() await _assert_host_group_in_scope(conn, _scope(admin), host_group_id) (count,) = await (await conn.execute( "SELECT COUNT(*) FROM hosts WHERE host_group_id = ?", (host_group_id,) )).fetchone() if count: raise HTTPException( status.HTTP_409_CONFLICT, f"Hostgruppe enthaelt noch {count} Host(s) (auch inaktive) -- zuerst entfernen/verschieben", ) await conn.execute("DELETE FROM host_groups WHERE id = ?", (host_group_id,)) await write_audit_event( conn, event_type="host_group_deleted", user_id=admin.id, client_ip=_client_ip(request), details={"id": host_group_id}, ) await conn.commit() return {"status": "ok"} # --- Hosts ------------------------------------------------------------------- @router.post("/hosts", status_code=status.HTTP_201_CREATED) async def create_host( payload: HostCreateRequest, request: Request, admin: CurrentUser = Depends(require_admin_or_scope("hosts", "write")), ): conn = get_db() await _assert_host_group_in_scope(conn, _scope(admin), payload.host_group_id) cursor = await conn.execute( """ INSERT INTO hosts ( host_group_id, hostname, address, protocol, port, os_type, ssh_host_key_fingerprint, ssh_username, rdp_username, rdp_domain, rdp_require_nla, clipboard_enabled, file_transfer_enabled, rdp_ignore_cert ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( payload.host_group_id, payload.hostname, payload.address, payload.protocol, payload.port, payload.os_type, payload.ssh_host_key_fingerprint, payload.ssh_username, payload.rdp_username, payload.rdp_domain, int(payload.rdp_require_nla), int(payload.clipboard_enabled), int(payload.file_transfer_enabled), int(payload.rdp_ignore_cert), ), ) new_id = cursor.lastrowid await write_audit_event( conn, event_type="host_created", user_id=admin.id, client_ip=_client_ip(request), details={"id": new_id, "hostname": payload.hostname, "protocol": payload.protocol}, ) await conn.commit() return {"id": new_id} @router.get("/hosts") async def list_hosts( host_group_id: int | None = None, admin: CurrentUser = Depends(require_admin_or_scope("hosts", "read")), ): conn = get_db() scope = _scope(admin) tenant_filter, tenant_params = scope.sql_filter("hg.tenant_id") where = "WHERE 1=1" + tenant_filter params = list(tenant_params) if host_group_id is not None: where += " AND h.host_group_id = ?" params.append(host_group_id) cursor = await conn.execute( "SELECT h.id, h.hostname, h.address, h.protocol, h.port, h.os_type, h.host_group_id, " "h.is_active, hg.tenant_id, t.name " "FROM hosts h JOIN host_groups hg ON hg.id = h.host_group_id JOIN tenants t ON t.id = hg.tenant_id " f"{where} ORDER BY h.id", params, ) rows = await cursor.fetchall() return [ { "id": r[0], "hostname": r[1], "address": r[2], "protocol": r[3], "port": r[4], "os_type": r[5], "host_group_id": r[6], "is_active": bool(r[7]), "tenant_id": r[8], "tenant_name": r[9], } for r in rows ] async def _assert_host_in_scope(conn, scope: TenantScope, host_id: int) -> None: tenant_id = await resolve_host_tenant(conn, host_id) if tenant_id is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Host nicht gefunden") scope.check(tenant_id) @router.get("/hosts/{host_id}") async def get_host_detail( host_id: int, admin: CurrentUser = Depends(require_admin_or_scope("hosts", "read")) ): """Liefert den vollstaendigen, aktuellen Datensatz eines Hosts inkl. zugeordneter SSH-Keys und ob RDP-Zugangsdaten hinterlegt sind -- Basis fuer die 'Details'-Ansicht der Admin-Oberflaeche (statt sich auf die ggf. veraltete Liste zu verlassen).""" conn = get_db() await _assert_host_in_scope(conn, _scope(admin), host_id) row = await (await conn.execute( "SELECT h.id, h.hostname, h.address, h.protocol, h.port, h.os_type, h.host_group_id, " "h.ssh_host_key_fingerprint, h.ssh_username, h.rdp_username, h.rdp_domain, " "h.rdp_require_nla, h.clipboard_enabled, h.file_transfer_enabled, h.is_active, " "hg.tenant_id, t.name, h.rdp_ignore_cert " "FROM hosts h JOIN host_groups hg ON hg.id = h.host_group_id JOIN tenants t ON t.id = hg.tenant_id " "WHERE h.id = ?", (host_id,), )).fetchone() if row is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Host nicht gefunden") keys_cursor = await conn.execute( "SELECT sk.id, sk.label, sk.username 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], "username": k[2]} for k in await keys_cursor.fetchall()] # Migration 0012: das zugewiesene RDP-Zugangsdaten-Objekt (falls # vorhanden) statt eines 1:1-Datensatzes am Host -- siehe # host_rdp_credential_map. rdp_row = await (await conn.execute( "SELECT rc.id, rc.label, rc.username, rc.domain, COALESCE(rc.rotated_at, rc.created_at) " "FROM host_rdp_credential_map m JOIN rdp_credentials rc ON rc.id = m.rdp_credential_id " "WHERE m.host_id = ?", (host_id,), )).fetchone() ssh_pw_row = await (await conn.execute( "SELECT updated_at, username FROM ssh_password_credentials WHERE host_id = ?", (host_id,) )).fetchone() return { "id": row[0], "hostname": row[1], "address": row[2], "protocol": row[3], "port": row[4], "os_type": row[5], "host_group_id": row[6], "ssh_host_key_fingerprint": row[7], "ssh_username": row[8], "rdp_username": row[9], "rdp_domain": row[10], "rdp_require_nla": bool(row[11]), "clipboard_enabled": bool(row[12]), "file_transfer_enabled": bool(row[13]), "is_active": bool(row[14]), "tenant_id": row[15], "tenant_name": row[16], "rdp_ignore_cert": bool(row[17]), "ssh_keys": ssh_keys, "rdp_credentials_set": rdp_row is not None, "rdp_credentials_id": rdp_row[0] if rdp_row else None, "rdp_credentials_label": rdp_row[1] if rdp_row else None, # Benutzername/Domaene gehoeren seit Migration 0010 zu den # Zugangsdaten (jetzt: dem zugewiesenen Objekt); nur zur Anzeige. "rdp_credentials_username": rdp_row[2] if rdp_row else None, "rdp_credentials_domain": rdp_row[3] if rdp_row else None, "rdp_credentials_updated_at": rdp_row[4] if rdp_row else None, # SSH-Passwort als Alternative zum Schluessel (Migration 0011). "ssh_password_credentials_set": ssh_pw_row is not None, "ssh_password_credentials_updated_at": ssh_pw_row[0] if ssh_pw_row else None, "ssh_password_credentials_username": ssh_pw_row[1] if ssh_pw_row else None, } @router.put("/hosts/{host_id}") async def update_host( host_id: int, payload: HostUpdateRequest, request: Request, admin: CurrentUser = Depends(require_admin_or_scope("hosts", "write")), ): conn = get_db() scope = _scope(admin) await _assert_host_in_scope(conn, scope, host_id) if payload.host_group_id is not None: await _assert_host_group_in_scope(conn, scope, payload.host_group_id) field_map = { "host_group_id": "host_group_id", "hostname": "hostname", "address": "address", "port": "port", "ssh_username": "ssh_username", "rdp_username": "rdp_username", "rdp_domain": "rdp_domain", } bool_field_map = { "rdp_require_nla": "rdp_require_nla", "clipboard_enabled": "clipboard_enabled", "file_transfer_enabled": "file_transfer_enabled", "is_active": "is_active", "rdp_ignore_cert": "rdp_ignore_cert", } fields, values = [], [] payload_dict = payload.model_dump(exclude_unset=True) for py_field, column in field_map.items(): if py_field in payload_dict: fields.append(f"{column} = ?"); values.append(payload_dict[py_field]) for py_field, column in bool_field_map.items(): if py_field in payload_dict: fields.append(f"{column} = ?"); values.append(int(payload_dict[py_field])) if not fields: return {"status": "ok", "changed": False} values.append(host_id) await conn.execute(f"UPDATE hosts SET {', '.join(fields)} WHERE id = ?", values) await write_audit_event( conn, event_type="host_updated", user_id=admin.id, client_ip=_client_ip(request), details={"id": host_id, "fields": list(payload_dict.keys())}, ) await conn.commit() return {"status": "ok", "changed": True} @router.delete("/hosts/{host_id}") async def delete_host( host_id: int, request: Request, hard: bool = False, admin: CurrentUser = Depends(require_admin_or_scope("hosts", "write")), ): """Standardmaessig ein Soft-Delete (is_active=0, wie schon zuvor von load_host()/catalog beruecksichtigt) -- ein Host mit vergangenen Sitzungen (sessions.host_id, OHNE ON DELETE CASCADE) kann ohnehin nicht hart geloescht werden, ohne die Sitzungs-/Aufzeichnungshistorie zu verwaisen. Mit ?hard=true wird ein echtes DELETE versucht (nur sinnvoll fuer einen Host ohne jede Sitzungshistorie); schlaegt es fehl, faellt der Endpunkt automatisch auf Soft-Delete zurueck.""" conn = get_db() await _assert_host_in_scope(conn, _scope(admin), host_id) hard_deleted = False if hard: has_sessions = await (await conn.execute( "SELECT 1 FROM sessions WHERE host_id = ? LIMIT 1", (host_id,) )).fetchone() if has_sessions is None: await conn.execute("DELETE FROM host_ssh_key_map WHERE host_id = ?", (host_id,)) # Migration 0012: rdp_credentials hat seit der Umstellung auf # wiederverwendbare Zugangsdaten-Objekte KEINE host_id-Spalte # mehr -- nur noch die Zuordnungstabelle referenziert den Host # (mit ON DELETE CASCADE, dieser Aufruf ist also strenggenommen # redundant, aber explizit wie die anderen Zeilen hier gehalten). await conn.execute("DELETE FROM host_rdp_credential_map WHERE host_id = ?", (host_id,)) # Nebenbefund beim Anpassen dieser Stelle: ssh_password_credentials # (Migration 0011) fehlte hier komplett -- ohne ON DELETE CASCADE # haette ein harter Loeschversuch mit gesetztem SSH-Passwort bei # aktivem foreign_keys=ON (app/db.py) mit einem FK-Fehler gescheitert # (dann automatisch auf Soft-Delete zurueckgefallen, siehe unten -- # also kein sichtbarer 500er, aber ein hartes Loeschen war fuer # solche Hosts faktisch nie moeglich). await conn.execute("DELETE FROM ssh_password_credentials WHERE host_id = ?", (host_id,)) await conn.execute("DELETE FROM hosts WHERE id = ?", (host_id,)) hard_deleted = True if not hard_deleted: await conn.execute("UPDATE hosts SET is_active = 0 WHERE id = ?", (host_id,)) await write_audit_event( conn, event_type="host_deleted", user_id=admin.id, client_ip=_client_ip(request), details={"id": host_id, "hard_deleted": hard_deleted}, ) await conn.commit() return {"status": "ok", "hard_deleted": hard_deleted} @router.post("/hosts/{host_id}/discover-host-key") async def discover_host_key( host_id: int, request: Request, admin: CurrentUser = Depends(require_admin_or_scope("hosts", "write")), ): """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. 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. Bugfix 2: das (asyncssh.Error, OSError)-except in proxy.py deckte nicht jede Art von Fehlschlag beim Key-Exchange ab (z.B. asyncio.TimeoutError vor Python 3.11 -- kein OSError), sodass der 500er trotz obigem Fix weiterhin auftrat. proxy.py faengt den externen Aufruf jetzt breiter ab; zusaetzlich hier ein Catch-all als zweite Verteidigungslinie, damit ein verbleibender unerwarteter Fehler (z.B. in load_host oder beim Audit-Log-Schreiben) wenigstens mit vollem Traceback geloggt wird statt als nackte 500 ohne jede Spur zu verschwinden -- analog zum Exception-Catch-all in terminal_ws.py/ws_tunnel.py (Phase 9).""" conn = get_db() await _assert_host_in_scope(conn, _scope(admin), host_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 except Exception as exc: logger.exception("Unerwarteter Fehler bei Host-Key-Ermittlung fuer Host %s", host_id) raise HTTPException( status.HTTP_502_BAD_GATEWAY, f"Host-Key konnte nicht ermittelt werden -- unerwarteter Fehler: " f"{type(exc).__name__}: {exc}", ) 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}, ) await conn.commit() return {"host_id": host_id, "fingerprint": fingerprint} @router.post("/hosts/{host_id}/rdp-credentials/{credential_id}") async def assign_rdp_credential_to_host( host_id: int, credential_id: int, request: Request, admin: CurrentUser = Depends( require_admin_scope_or_host_role("rdp_credentials", "write", ("credentials_manage",)) ), ): """Weist einem Host EIN bereits bestehendes RDP-Zugangsdaten-Objekt zu (Migration 0012) -- ersetzt eine zuvor zugewiesene Zuordnung, falls vorhanden (INSERT OR REPLACE, host_id ist Primaerschluessel der Zuordnungstabelle). Das Anlegen des Zugangsdaten-Objekts selbst passiert NICHT mehr hier, sondern ueber POST /admin/rdp-credentials (Reiter "Zugangsdaten") -- exakt dieselbe Trennung wie bei SSH-Keys (map_ssh_key_to_host).""" conn = get_db() if admin.is_any_admin: scope = _scope(admin) await _assert_host_in_scope(conn, scope, host_id) await _assert_rdp_credential_in_scope(conn, scope, credential_id) else: cred_row = await ( await conn.execute("SELECT 1 FROM rdp_credentials WHERE id = ?", (credential_id,)) ).fetchone() if cred_row is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "RDP-Zugangsdaten nicht gefunden") await conn.execute( "INSERT INTO host_rdp_credential_map (host_id, rdp_credential_id) VALUES (?, ?) " "ON CONFLICT(host_id) DO UPDATE SET rdp_credential_id = excluded.rdp_credential_id", (host_id, credential_id), ) await write_audit_event( conn, event_type="rdp_credential_mapped", user_id=admin.id, client_ip=_client_ip(request), details={"host_id": host_id, "rdp_credential_id": credential_id}, ) await conn.commit() return {"status": "ok"} @router.delete("/hosts/{host_id}/rdp-credentials") async def unassign_rdp_credential_from_host( host_id: int, request: Request, admin: CurrentUser = Depends( require_admin_scope_or_host_role("rdp_credentials", "write", ("credentials_manage",)) ), ): """Entfernt NUR die Zuordnung zu diesem Host -- das Zugangsdaten-Objekt selbst bleibt bestehen und kann weiterhin anderen Hosts zugewiesen sein bzw. spaeter erneut zugewiesen werden. Loeschen des Objekts selbst: DELETE /admin/rdp-credentials/{id}.""" conn = get_db() if admin.is_any_admin: await _assert_host_in_scope(conn, _scope(admin), host_id) await conn.execute("DELETE FROM host_rdp_credential_map WHERE host_id = ?", (host_id,)) await write_audit_event( conn, event_type="rdp_credential_unmapped", user_id=admin.id, client_ip=_client_ip(request), details={"host_id": host_id}, ) await conn.commit() return {"status": "ok"} @router.put("/hosts/{host_id}/ssh-password") async def set_ssh_password_credentials( host_id: int, payload: SshPasswordCredentialsRequest, request: Request, admin: CurrentUser = Depends( require_admin_scope_or_host_role("hosts", "write", ("credentials_manage",)) ), ): """Setzt/aendert das SSH-Passwort fuer einen Host (Migration 0011, Alternative zum SSH-Key -- 'Linux kann statt SSH-Key auch Passwort haben'). Wird beim Verbindungsaufbau nur beruecksichtigt, solange dem Host KEIN SSH-Key zugeordnet ist (siehe app/ssh_proxy/proxy.py::connect_to_host).""" conn = get_db() if admin.is_any_admin: await _assert_host_in_scope(conn, _scope(admin), host_id) username = payload.username.strip() if not username: raise HTTPException(status.HTTP_400_BAD_REQUEST, "Benutzername darf nicht leer sein") encrypted = encrypt_secret(payload.password.encode(), associated_data=b"ssh_password") await conn.execute( "INSERT INTO ssh_password_credentials (host_id, username, password_enc, updated_at) " "VALUES (?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ','now')) " "ON CONFLICT(host_id) DO UPDATE SET username = excluded.username, " "password_enc = excluded.password_enc, updated_at = excluded.updated_at", (host_id, username, encrypted), ) await write_audit_event( conn, event_type="ssh_password_credentials_set", user_id=admin.id, client_ip=_client_ip(request), details={"host_id": host_id, "username": username}, ) await conn.commit() return {"status": "ok"} @router.delete("/hosts/{host_id}/ssh-password") async def delete_ssh_password_credentials( host_id: int, request: Request, admin: CurrentUser = Depends( require_admin_scope_or_host_role("hosts", "write", ("credentials_manage",)) ), ): conn = get_db() if admin.is_any_admin: await _assert_host_in_scope(conn, _scope(admin), host_id) await conn.execute("DELETE FROM ssh_password_credentials WHERE host_id = ?", (host_id,)) await write_audit_event( conn, event_type="ssh_password_credentials_deleted", user_id=admin.id, client_ip=_client_ip(request), details={"host_id": host_id}, ) await conn.commit() 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, sk.username 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], "username": k[2]} for k in await keys_cursor.fetchall()] rdp_row = await (await conn.execute( "SELECT rc.id, rc.label, rc.username, rc.domain, COALESCE(rc.rotated_at, rc.created_at) " "FROM host_rdp_credential_map m JOIN rdp_credentials rc ON rc.id = m.rdp_credential_id " "WHERE m.host_id = ?", (host_id,), )).fetchone() ssh_pw_row = await (await conn.execute( "SELECT updated_at, username FROM ssh_password_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_id": rdp_row[0] if rdp_row else None, "rdp_credentials_label": rdp_row[1] if rdp_row else None, "rdp_credentials_username": rdp_row[2] if rdp_row else None, "rdp_credentials_domain": rdp_row[3] if rdp_row else None, "rdp_credentials_updated_at": rdp_row[4] if rdp_row else None, "ssh_password_credentials_set": ssh_pw_row is not None, "ssh_password_credentials_updated_at": ssh_pw_row[0] if ssh_pw_row else None, "ssh_password_credentials_username": ssh_pw_row[1] if ssh_pw_row else None, } # --- RDP-Zugangsdatenverwaltung (Migration 0012) ------------------------------ # # Eigenstaendige, wiederverwendbare Objekte -- strukturell und in der # Endpunktaufteilung bewusst identisch zur SSH-Keyverwaltung weiter unten # (create/list/update/delete + Zuordnung/Entfernung am Host), damit sich # beide Zugangsdaten-Arten im Reiter "Zugangsdaten" gleich bedienen. @router.post("/rdp-credentials", status_code=status.HTTP_201_CREATED) async def create_rdp_credential( payload: RdpCredentialCreateRequest, request: Request, admin: CurrentUser = Depends(require_admin_or_scope("rdp_credentials", "write")), ): conn = get_db() scope = _scope(admin) tenant_id = await _resolve_write_tenant(conn, scope, payload.tenant_id) encrypted = encrypt_secret(payload.password.encode(), associated_data=b"rdp_password") domain = (payload.domain or "").strip() or None cursor = await conn.execute( "INSERT INTO rdp_credentials (label, username, domain, password_enc, tenant_id) " "VALUES (?, ?, ?, ?, ?)", (payload.label, payload.username.strip(), domain, encrypted, tenant_id), ) new_id = cursor.lastrowid await write_audit_event( conn, event_type="rdp_credential_created", user_id=admin.id, client_ip=_client_ip(request), details={"id": new_id, "label": payload.label, "username": payload.username, "domain": domain}, ) await conn.commit() return {"id": new_id, "label": payload.label} @router.get("/rdp-credentials") async def list_rdp_credentials(admin: CurrentUser = Depends(require_admin_or_scope("rdp_credentials", "read"))): """Alle RDP-Zugangsdaten-Objekte fuer den 'Zugangsdaten'-Tab, inklusive der Hosts, denen das jeweilige Objekt aktuell zugewiesen ist -- ein Objekt kann mehreren Hosts zugewiesen sein (siehe Migration 0012).""" conn = get_db() scope = _scope(admin) tenant_filter, params = scope.sql_filter("rc.tenant_id") cursor = await conn.execute( "SELECT rc.id, rc.label, rc.username, rc.domain, rc.tenant_id, t.name, " "rc.created_at, rc.rotated_at " "FROM rdp_credentials rc JOIN tenants t ON t.id = rc.tenant_id " f"WHERE 1=1{tenant_filter} ORDER BY rc.id", params, ) rows = await cursor.fetchall() hosts_cursor = await conn.execute( "SELECT m.rdp_credential_id, h.id, h.hostname FROM host_rdp_credential_map m " "JOIN hosts h ON h.id = m.host_id" ) assigned: dict[int, list[dict]] = {} for cred_id, host_id, hostname in await hosts_cursor.fetchall(): assigned.setdefault(cred_id, []).append({"id": host_id, "hostname": hostname}) return [ { "id": r[0], "label": r[1], "username": r[2], "domain": r[3], "tenant_id": r[4], "tenant_name": r[5], "created_at": r[6], "rotated_at": r[7], "assigned_hosts": assigned.get(r[0], []), } for r in rows ] async def _assert_rdp_credential_in_scope(conn, scope: TenantScope, credential_id: int) -> None: row = await ( await conn.execute("SELECT tenant_id FROM rdp_credentials WHERE id = ?", (credential_id,)) ).fetchone() if row is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "RDP-Zugangsdaten nicht gefunden") scope.check(row[0]) @router.put("/rdp-credentials/{credential_id}") async def update_rdp_credential( credential_id: int, payload: RdpCredentialUpdateRequest, request: Request, admin: CurrentUser = Depends(require_admin_or_scope("rdp_credentials", "write")), ): conn = get_db() await _assert_rdp_credential_in_scope(conn, _scope(admin), credential_id) fields, values = [], [] if payload.label is not None: fields.append("label = ?"); values.append(payload.label) if payload.username is not None: fields.append("username = ?"); values.append(payload.username.strip()) if "domain" in payload.model_fields_set: fields.append("domain = ?"); values.append((payload.domain or "").strip() or None) rotating = payload.password is not None if rotating: fields += ["password_enc = ?", "rotated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')"] values.append(encrypt_secret(payload.password.encode(), associated_data=b"rdp_password")) if not fields: return {"status": "ok", "changed": False} values.append(credential_id) await conn.execute(f"UPDATE rdp_credentials SET {', '.join(fields)} WHERE id = ?", values) await write_audit_event( conn, event_type="rdp_credential_updated", user_id=admin.id, client_ip=_client_ip(request), details={ "id": credential_id, "rotated": rotating, "label_changed": payload.label is not None, "username_changed": payload.username is not None, "domain_changed": "domain" in payload.model_fields_set, }, ) await conn.commit() return {"status": "ok", "changed": True} @router.delete("/rdp-credentials/{credential_id}") async def delete_rdp_credential( credential_id: int, request: Request, admin: CurrentUser = Depends(require_admin_or_scope("rdp_credentials", "write")), ): """host_rdp_credential_map verweist bewusst OHNE ON DELETE auf rdp_credentials(id) -- Zuordnungen werden hier explizit mit entfernt (samt Vermerk, welche Hosts betroffen waren), analog delete_ssh_key.""" conn = get_db() await _assert_rdp_credential_in_scope(conn, _scope(admin), credential_id) affected = await (await conn.execute( "SELECT host_id FROM host_rdp_credential_map WHERE rdp_credential_id = ?", (credential_id,) )).fetchall() await conn.execute("DELETE FROM host_rdp_credential_map WHERE rdp_credential_id = ?", (credential_id,)) await conn.execute("DELETE FROM rdp_credentials WHERE id = ?", (credential_id,)) await write_audit_event( conn, event_type="rdp_credential_deleted", user_id=admin.id, client_ip=_client_ip(request), details={"id": credential_id, "unmapped_host_ids": [r[0] for r in affected]}, ) await conn.commit() return {"status": "ok", "unmapped_host_ids": [r[0] for r in affected]} # --- Rollenvergabe (an einzelne User) ----------------------------------------- async def _role_id(conn, role_name: str) -> int: row = await (await conn.execute("SELECT id FROM roles WHERE name = ?", (role_name,))).fetchone() if row is None: raise HTTPException(status.HTTP_400_BAD_REQUEST, "Unbekannte Rolle") return row[0] @router.post("/roles/grant") async def grant_role( payload: RoleGrantRequest, request: Request, admin: CurrentUser = Depends(require_admin_or_scope("roles", "write")), ): conn = get_db() scope = _scope(admin) await _assert_host_group_in_scope(conn, scope, payload.host_group_id) await _assert_user_in_scope(conn, scope, payload.user_id) granted = [] for role_name in payload.role_names: role_id = await _role_id(conn, role_name) await conn.execute( "INSERT OR REPLACE INTO user_hostgroup_roles " "(user_id, host_group_id, role_id, granted_by, expires_at) VALUES (?, ?, ?, ?, ?)", (payload.user_id, payload.host_group_id, role_id, admin.id, payload.expires_at), ) granted.append(role_name) await write_audit_event( conn, event_type="role_granted", user_id=admin.id, client_ip=_client_ip(request), details={ "target_user_id": payload.user_id, "host_group_id": payload.host_group_id, "roles": granted, "expires_at": payload.expires_at, }, ) await conn.commit() return {"status": "ok", "roles_granted": granted} @router.post("/roles/revoke") async def revoke_role( payload: RoleRevokeRequest, request: Request, admin: CurrentUser = Depends(require_admin_or_scope("roles", "write")), ): conn = get_db() scope = _scope(admin) await _assert_host_group_in_scope(conn, scope, payload.host_group_id) role_id = await _role_id(conn, payload.role_name) await conn.execute( "DELETE FROM user_hostgroup_roles WHERE user_id = ? AND host_group_id = ? AND role_id = ?", (payload.user_id, payload.host_group_id, role_id), ) await write_audit_event( conn, event_type="role_revoked", user_id=admin.id, client_ip=_client_ip(request), details={ "target_user_id": payload.user_id, "host_group_id": payload.host_group_id, "role": payload.role_name, }, ) await conn.commit() return {"status": "ok"} @router.get("/roles") async def list_role_grants(admin: CurrentUser = Depends(require_admin_or_scope("roles", "read"))): conn = get_db() scope = _scope(admin) tenant_filter, params = scope.sql_filter("hg.tenant_id") cursor = await conn.execute( f""" SELECT u.id, u.username, hg.id, hg.name, r.name, uhr.granted_by, uhr.granted_at, uhr.expires_at FROM user_hostgroup_roles uhr JOIN users u ON u.id = uhr.user_id JOIN host_groups hg ON hg.id = uhr.host_group_id JOIN roles r ON r.id = uhr.role_id WHERE 1=1{tenant_filter} ORDER BY u.username, hg.name, r.name """, params, ) rows = await cursor.fetchall() return [ { "user_id": r[0], "username": r[1], "host_group_id": r[2], "host_group_name": r[3], "role_name": r[4], "granted_by": r[5], "granted_at": r[6], "expires_at": r[7], } for r in rows ] # --- Rollenvergabe (an Benutzergruppen) --------------------------------------- @router.post("/group-roles/grant") async def grant_group_role( payload: GroupRoleGrantRequest, request: Request, admin: CurrentUser = Depends(require_admin_or_scope("roles", "write")), ): conn = get_db() scope = _scope(admin) await _assert_host_group_in_scope(conn, scope, payload.host_group_id) await _assert_user_group_in_scope(conn, scope, payload.user_group_id) granted = [] for role_name in payload.role_names: role_id = await _role_id(conn, role_name) await conn.execute( "INSERT OR REPLACE INTO group_hostgroup_roles " "(user_group_id, host_group_id, role_id, granted_by, expires_at) VALUES (?, ?, ?, ?, ?)", (payload.user_group_id, payload.host_group_id, role_id, admin.id, payload.expires_at), ) granted.append(role_name) await write_audit_event( conn, event_type="group_role_granted", user_id=admin.id, client_ip=_client_ip(request), details={ "user_group_id": payload.user_group_id, "host_group_id": payload.host_group_id, "roles": granted, "expires_at": payload.expires_at, }, ) await conn.commit() return {"status": "ok", "roles_granted": granted} @router.post("/group-roles/revoke") async def revoke_group_role( payload: GroupRoleRevokeRequest, request: Request, admin: CurrentUser = Depends(require_admin_or_scope("roles", "write")), ): conn = get_db() scope = _scope(admin) await _assert_host_group_in_scope(conn, scope, payload.host_group_id) role_id = await _role_id(conn, payload.role_name) await conn.execute( "DELETE FROM group_hostgroup_roles WHERE user_group_id = ? AND host_group_id = ? AND role_id = ?", (payload.user_group_id, payload.host_group_id, role_id), ) await write_audit_event( conn, event_type="group_role_revoked", user_id=admin.id, client_ip=_client_ip(request), details={ "user_group_id": payload.user_group_id, "host_group_id": payload.host_group_id, "role": payload.role_name, }, ) await conn.commit() return {"status": "ok"} @router.get("/group-roles") async def list_group_role_grants(admin: CurrentUser = Depends(require_admin_or_scope("roles", "read"))): conn = get_db() scope = _scope(admin) tenant_filter, params = scope.sql_filter("hg.tenant_id") cursor = await conn.execute( f""" SELECT ug.id, ug.name, hg.id, hg.name, r.name, ghr.granted_by, ghr.granted_at, ghr.expires_at FROM group_hostgroup_roles ghr JOIN user_groups ug ON ug.id = ghr.user_group_id JOIN host_groups hg ON hg.id = ghr.host_group_id JOIN roles r ON r.id = ghr.role_id WHERE 1=1{tenant_filter} ORDER BY ug.name, hg.name, r.name """, params, ) rows = await cursor.fetchall() return [ { "user_group_id": r[0], "user_group_name": r[1], "host_group_id": r[2], "host_group_name": r[3], "role_name": r[4], "granted_by": r[5], "granted_at": r[6], "expires_at": r[7], } for r in rows ] @router.get("/roles/names") async def list_role_names(admin: CurrentUser = Depends(require_admin_or_scope("roles", "read"))): conn = get_db() cursor = await conn.execute("SELECT name FROM roles ORDER BY id") return [r[0] for r in await cursor.fetchall()] # --- SSH-Keyverwaltung --------------------------------------------------------- def _validate_private_key(pem: str, passphrase: str | None) -> None: """Prueft, ob asyncssh das Material mit der angegebenen Passphrase laden kann, und uebersetzt einen Fehlschlag in HTTP 400 mit Klartextmeldung. Der importierte Schluessel wird bewusst NICHT zurueckgegeben und nicht weiterverwendet -- er dient nur der Pruefung und wird sofort wieder verworfen (Konzept 6.4: Schluesselmaterial so kurz wie moeglich im Prozessspeicher).""" try: import_private_key_material(pem, passphrase) except PrivateKeyUnusableError as exc: raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc @router.post("/ssh-keys", status_code=status.HTTP_201_CREATED) async def create_ssh_key( payload: SshKeyCreateRequest, request: Request, admin: CurrentUser = Depends(require_admin_or_scope("ssh_keys", "write")), ): """Nimmt einen privaten Schluessel entgegen, verschluesselt ihn sofort mit dem KEK (AES-256-GCM) und haelt den Klartext nur fuer die Dauer dieses Requests im Prozessspeicher (siehe Konzept 6.4: Key verlaesst den Server nie).""" conn = get_db() scope = _scope(admin) tenant_id = await _resolve_write_tenant(conn, scope, payload.tenant_id) # Schluessel SOFORT gegen asyncssh pruefen (inkl. Passphrase), statt einen # unbrauchbaren Schluessel entgegenzunehmen und den Fehler erst beim # ersten Verbindungsversuch eines Benutzers auftauchen zu lassen -- genau # so blieb ein passphrasegeschuetzter Schluessel bisher unbemerkt, bis # die Sitzung im Betrieb abbrach. _validate_private_key(payload.private_key_pem, payload.passphrase) encrypted = encrypt_secret(payload.private_key_pem.encode(), associated_data=b"ssh_private_key") passphrase_enc = ( encrypt_secret(payload.passphrase.encode(), associated_data=b"ssh_key_passphrase") if payload.passphrase else None ) cursor = await conn.execute( "INSERT INTO ssh_keys (label, private_key_enc, public_key, key_type, " "tenant_id, passphrase_enc, username) VALUES (?, ?, ?, ?, ?, ?, ?)", (payload.label, encrypted, payload.public_key, payload.key_type, tenant_id, passphrase_enc, (payload.username or "").strip() or None), ) new_id = cursor.lastrowid await write_audit_event( conn, event_type="ssh_key_created", user_id=admin.id, client_ip=_client_ip(request), details={ "id": new_id, "label": payload.label, "key_type": payload.key_type, "has_passphrase": bool(payload.passphrase), # Der Anmeldename ist kein Geheimnis und im Audit-Log ausdruecklich # erwuenscht: er beantwortet "wer hat sich womit angemeldet". "username": (payload.username or "").strip() or None, }, ) await conn.commit() return {"id": new_id, "label": payload.label} @router.post("/ssh-keys/generate") async def generate_ssh_key( payload: SshKeyGenerateRequest, admin: CurrentUser = Depends(require_admin_or_scope("ssh_keys", "write")), ): """Erzeugt ein neues Schluesselpaar serverseitig ('Schluessel automatisch generieren'-Button) und gibt es EINMALIG zurueck -- es wird hier nichts gespeichert, das passiert erst ueber den regulaeren POST /ssh-keys, wenn der Admin das befuellte Formular tatsaechlich absendet (siehe app/ssh_proxy/proxy.py::generate_key_material).""" try: private_key_pem, public_key = generate_key_material(payload.key_type) except ValueError as exc: raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc return {"key_type": payload.key_type, "private_key_pem": private_key_pem, "public_key": public_key} @router.get("/ssh-keys") async def list_ssh_keys(admin: CurrentUser = Depends(require_admin_or_scope("ssh_keys", "read"))): conn = get_db() scope = _scope(admin) tenant_filter, params = scope.sql_filter("sk.tenant_id") cursor = await conn.execute( "SELECT sk.id, sk.label, sk.key_type, sk.created_at, sk.rotated_at, " "sk.expires_at, sk.tenant_id, t.name, (sk.passphrase_enc IS NOT NULL), sk.username " "FROM ssh_keys sk JOIN tenants t ON t.id = sk.tenant_id " f"WHERE 1=1{tenant_filter} ORDER BY sk.id", params, ) rows = await cursor.fetchall() return [ { "id": r[0], "label": r[1], "key_type": r[2], "created_at": r[3], "rotated_at": r[4], "expires_at": r[5], "tenant_id": r[6], "tenant_name": r[7], # Nur die Tatsache, NIE die Passphrase selbst -- kein Endpunkt # dieser Anwendung gibt jemals Klartext-Geheimnisse zurueck. "has_passphrase": bool(r[8]), # Anmeldename des Zielsystems (Migration 0010). Kein Geheimnis -- # er wird angezeigt, damit erkennbar ist, welcher Zugang das ist. "username": r[9], } for r in rows ] async def _assert_ssh_key_in_scope(conn, scope: TenantScope, key_id: int) -> None: row = await (await conn.execute("SELECT tenant_id FROM ssh_keys WHERE id = ?", (key_id,))).fetchone() if row is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "SSH-Key nicht gefunden") scope.check(row[0]) @router.put("/ssh-keys/{key_id}") async def update_ssh_key( key_id: int, payload: SshKeyUpdateRequest, request: Request, admin: CurrentUser = Depends(require_admin_or_scope("ssh_keys", "write")), ): conn = get_db() await _assert_ssh_key_in_scope(conn, _scope(admin), key_id) rotating = payload.private_key_pem is not None or payload.public_key is not None or payload.key_type is not None if rotating and not (payload.private_key_pem and payload.public_key and payload.key_type): raise HTTPException( status.HTTP_400_BAD_REQUEST, "Fuer eine Rotation muessen private_key_pem, public_key UND key_type gemeinsam angegeben werden", ) # "passphrase" wird nur angefasst, wenn das Feld im Request tatsaechlich # vorkommt -- ein Update von z.B. nur dem Label darf eine hinterlegte # Passphrase nicht stillschweigend loeschen. Explizit uebergebenes null # oder "" entfernt sie dagegen bewusst. passphrase_given = "passphrase" in payload.model_fields_set passphrase = payload.passphrase or None if rotating: # Neues Material immer sofort pruefen (inkl. der -- ggf. neuen -- # Passphrase), damit ein unbrauchbarer Schluessel gar nicht erst in # die Datenbank kommt. _validate_private_key(payload.private_key_pem, passphrase) elif passphrase_given: # Nur die Passphrase wird nachgetragen/geaendert: gegen das BEREITS # gespeicherte Schluesselmaterial pruefen. Das ist der Weg, einen # bereits hinterlegten, passphrasegeschuetzten Schluessel wieder # benutzbar zu machen, ohne ihn neu hochzuladen. row = await ( await conn.execute("SELECT private_key_enc FROM ssh_keys WHERE id = ?", (key_id,)) ).fetchone() if row is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "SSH-Key nicht gefunden") stored_pem = decrypt_secret(row[0], associated_data=b"ssh_private_key") try: _validate_private_key(stored_pem.decode(), passphrase) finally: del stored_pem fields, values = [], [] if payload.label is not None: fields.append("label = ?"); values.append(payload.label) if "username" in payload.model_fields_set: # Gleiche Semantik wie bei passphrase: Weglassen = unveraendert, # explizites null/"" = entfernen. fields.append("username = ?"); values.append((payload.username or "").strip() or None) if rotating: encrypted = encrypt_secret(payload.private_key_pem.encode(), associated_data=b"ssh_private_key") fields += ["private_key_enc = ?", "public_key = ?", "key_type = ?", "rotated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')"] values += [encrypted, payload.public_key, payload.key_type] if rotating or passphrase_given: # Bei einer Rotation wird die Passphrase in JEDEM Fall neu gesetzt -- # eine zum alten Schluessel gehoerende Passphrase darf nicht am neuen # Schluessel haengenbleiben. fields.append("passphrase_enc = ?") values.append( encrypt_secret(passphrase.encode(), associated_data=b"ssh_key_passphrase") if passphrase else None ) if not fields: return {"status": "ok", "changed": False} values.append(key_id) await conn.execute(f"UPDATE ssh_keys SET {', '.join(fields)} WHERE id = ?", values) await write_audit_event( conn, event_type="ssh_key_updated", user_id=admin.id, client_ip=_client_ip(request), details={ "id": key_id, "rotated": rotating, "passphrase_changed": bool(rotating or passphrase_given), "has_passphrase": bool(passphrase), "username_changed": "username" in payload.model_fields_set, }, ) await conn.commit() return {"status": "ok", "changed": True} @router.delete("/ssh-keys/{key_id}") async def delete_ssh_key( key_id: int, request: Request, admin: CurrentUser = Depends(require_admin_or_scope("ssh_keys", "write")), ): """host_ssh_key_map verweist bewusst OHNE ON DELETE auf ssh_keys(id) -- Zuordnungen werden hier explizit mit entfernt (samt Vermerk, welche Hosts betroffen waren) statt den Key unloeschbar zu machen.""" conn = get_db() await _assert_ssh_key_in_scope(conn, _scope(admin), key_id) affected = await (await conn.execute( "SELECT host_id FROM host_ssh_key_map WHERE ssh_key_id = ?", (key_id,) )).fetchall() await conn.execute("DELETE FROM host_ssh_key_map WHERE ssh_key_id = ?", (key_id,)) await conn.execute("DELETE FROM ssh_keys WHERE id = ?", (key_id,)) await write_audit_event( conn, event_type="ssh_key_deleted", user_id=admin.id, client_ip=_client_ip(request), details={"id": key_id, "unmapped_host_ids": [r[0] for r in affected]}, ) await conn.commit() return {"status": "ok", "unmapped_host_ids": [r[0] for r in affected]} @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_scope_or_host_role("ssh_keys", "write", ("credentials_manage",)) ), ): conn = get_db() 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), ) await write_audit_event( conn, event_type="ssh_key_mapped", user_id=admin.id, client_ip=_client_ip(request), details={"host_id": host_id, "ssh_key_id": key_id}, ) await conn.commit() return {"status": "ok"} @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_scope_or_host_role("ssh_keys", "write", ("credentials_manage",)) ), ): conn = get_db() 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) ) await write_audit_event( conn, event_type="ssh_key_unmapped", user_id=admin.id, client_ip=_client_ip(request), details={"host_id": host_id, "ssh_key_id": key_id}, ) await conn.commit() return {"status": "ok"} # --- API-Tokens ------------------------------------------------------------ # # Bewusst ausschliesslich ueber require_admin_session (Session, Super- ODER # Mandanten-Admin), NIE ueber require_admin_or_scope -- ein geleaktes Token # darf sich damit nicht selbst weitere/staerkere Tokens ausstellen # (Privilege-Escalation-Schutz). Ein Mandanten-Admin sieht/verwaltet nur # Tokens seines/seiner Mandanten (tenant_id, siehe Migration 0006) und kann # Tokens nur fuer Benutzer seines Mandanten ausstellen. @router.get("/scopes") async def list_valid_scopes(admin: CurrentUser = Depends(require_admin_session)): return {"scopes": sorted(VALID_SCOPES)} @router.post("/tokens", status_code=status.HTTP_201_CREATED) async def create_api_token( payload: ApiTokenCreateRequest, request: Request, admin: CurrentUser = Depends(require_admin_session) ): try: scopes = validate_scopes(payload.scopes) except ValueError as exc: raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc conn = get_db() scope = _scope(admin) tenant_id = await _resolve_write_tenant(conn, scope, payload.tenant_id) await _assert_user_in_scope(conn, scope, payload.user_id) user_cursor = await conn.execute( "SELECT 1 FROM users WHERE id = ? AND deleted_at IS NULL", (payload.user_id,) ) if await user_cursor.fetchone() is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Benutzer nicht gefunden") token = generate_token() cursor = await conn.execute( "INSERT INTO api_tokens (user_id, label, token_hash, token_prefix, scopes_json, " "created_by, expires_at, tenant_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", ( payload.user_id, payload.label, hash_token(token), token_prefix_for_display(token), json.dumps(scopes), admin.id, payload.expires_at, tenant_id, ), ) new_id = cursor.lastrowid await write_audit_event( conn, event_type="api_token_created", user_id=admin.id, client_ip=_client_ip(request), details={ "token_id": new_id, "target_user_id": payload.user_id, "label": payload.label, "scopes": scopes, "expires_at": payload.expires_at, "tenant_id": tenant_id, }, ) await conn.commit() # Klartext-Token wird NUR in dieser einen Response zurueckgegeben -- danach # ist nur noch der Hash in der DB, das Token ist nicht mehr rekonstruierbar. return {"id": new_id, "token": token, "prefix": token_prefix_for_display(token), "scopes": scopes} @router.get("/tokens") async def list_api_tokens(admin: CurrentUser = Depends(require_admin_session)): conn = get_db() scope = _scope(admin) tenant_filter, params = scope.sql_filter("t.tenant_id") cursor = await conn.execute( "SELECT t.id, t.user_id, u.username, t.label, t.token_prefix, t.scopes_json, " "t.created_at, t.expires_at, t.last_used_at, t.revoked_at, t.tenant_id, tn.name " "FROM api_tokens t JOIN users u ON u.id = t.user_id JOIN tenants tn ON tn.id = t.tenant_id " f"WHERE 1=1{tenant_filter} ORDER BY t.id DESC", params, ) rows = await cursor.fetchall() return [ { "id": r[0], "user_id": r[1], "username": r[2], "label": r[3], "prefix": r[4], "scopes": json.loads(r[5]), "created_at": r[6], "expires_at": r[7], "last_used_at": r[8], "revoked_at": r[9], "tenant_id": r[10], "tenant_name": r[11], } for r in rows ] @router.post("/tokens/{token_id}/revoke") async def revoke_api_token( token_id: int, request: Request, admin: CurrentUser = Depends(require_admin_session) ): conn = get_db() row = await (await conn.execute("SELECT tenant_id FROM api_tokens WHERE id = ?", (token_id,))).fetchone() if row is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Token nicht gefunden") _scope(admin).check(row[0]) await conn.execute( "UPDATE api_tokens SET revoked_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') " "WHERE id = ? AND revoked_at IS NULL", (token_id,), ) await write_audit_event( conn, event_type="api_token_revoked", user_id=admin.id, client_ip=_client_ip(request), details={"token_id": token_id}, ) await conn.commit() return {"status": "ok"} # --- Audit-Log ----------------------------------------------------------------- @router.get("/audit-log") async def get_audit_log( limit: int = 100, offset: int = 0, admin: CurrentUser = Depends(require_admin_or_scope("audit", "read")), ): limit = max(1, min(limit, 1000)) conn = get_db() scope = _scope(admin) if scope.all_tenants: cursor = await conn.execute( "SELECT id, ts, user_id, client_ip, event_type, details_json FROM audit_log " "ORDER BY id DESC LIMIT ? OFFSET ?", (limit, offset), ) rows = await cursor.fetchall() else: visible: set[int] = set() for tid in scope.tenant_ids: visible |= await tenant_user_ids(conn, tid) if not visible: return [] placeholders = ",".join("?" for _ in visible) cursor = await conn.execute( f"SELECT id, ts, user_id, client_ip, event_type, details_json FROM audit_log " f"WHERE user_id IN ({placeholders}) ORDER BY id DESC LIMIT ? OFFSET ?", (*visible, limit, offset), ) rows = await cursor.fetchall() return [ {"id": r[0], "ts": r[1], "user_id": r[2], "client_ip": r[3], "event_type": r[4], "details": r[5]} for r in rows ] @router.get("/audit-log/verify") async def verify_audit_log(admin: CurrentUser = Depends(require_admin_or_scope("audit", "read"))): """Prueft die GESAMTE Hash-Chain auf Manipulationsfreiheit (Konzept 6.1/4.7) -- bewusst fuer jeden Admin (auch Mandanten-Admins) verfuegbar, da nur intakt/kaputt zurueckgegeben wird, keine mandantenfremden Inhalte.""" 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} @router.get("/sessions/{session_id}/recording/entries") async def get_session_recording_entries( session_id: int, request: Request, admin: CurrentUser = Depends(require_global_admin) ): """Liefert den VOLLEN Inhalt einer Sitzungsaufzeichnung fuer die Wiedergabe im Adminbereich (SSH: Terminal-Replay ueber xterm.js; RDP: grafische Wiedergabe ueber Guacamole.SessionRecording -- siehe static/js/admin.js). Im Unterschied zu GET .../recording (nur Metadaten + Integritaetsstatus) verlaesst hier der tatsaechliche Sitzungsinhalt (Tastatureingaben bzw. Bildschirminhalt) den Server -- deshalb bewusst NUR require_global_admin (echter Super-Admin, ausschliesslich per Session, wie die uebrige Sessionview) UND ein eigener, prominenter Audit-Log-Eintrag bei jedem Aufruf (Konzept 6.5: Aufzeichnungen sind hochsensibel -- dieselbe bewusste 'wird jede Nutzung vermerkt'-Haltung wie bei 'Host-Key ermitteln').""" conn = get_db() row = await (await conn.execute( "SELECT recording_path, protocol 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") entries = [] try: with open(path, encoding="utf-8") as fh: for line in fh: line = line.strip() if not line: continue parsed = json.loads(line) entry = parsed["entry"] entries.append({"t": entry["t"], "dir": entry["dir"], "data": entry["data"]}) except (json.JSONDecodeError, KeyError) as exc: logger.exception("Aufzeichnung %s konnte nicht geparst werden", session_id) raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Aufzeichnung konnte nicht gelesen werden") from exc await write_audit_event( conn, event_type="session_recording_viewed", user_id=admin.id, client_ip=_client_ip(request), details={"session_id": session_id, "protocol": row[1], "entry_count": len(entries)}, ) await conn.commit() return {"session_id": session_id, "protocol": row[1], "verified": verified, "entries": entries} # Hinweis: Das Live-'Verbindungslog' (WS /ws/logs) liegt bewusst NICHT unter # diesem /admin-Router, sondern als eigener Top-Level-Router in # app/admin/log_ws.py -- siehe dort fuer den Grund (nginx-Reverse-Proxy- # Location-Matching auf /ws/).