""" Administrative CRUD-API: User, Hostgruppen, Hosts, Rollenvergabe, SSH-Keys. Alle zustandsaendernden Endpunkte sind auf globale Admins beschraenkt (`require_global_admin`) und schreiben einen Audit-Log-Eintrag (Konzept 4.7). Eine feingranulare, auf `admin_hostgroup` beschraenkte Admin-Rolle ist im Datenmodell vorbereitet, wird hier aus Uebersichtlichkeitsgruenden aber nicht vollstaendig verdrahtet -- siehe TODO-Markierungen fuer den naechsten Ausbauschritt. """ from __future__ import annotations from fastapi import APIRouter, Depends, HTTPException, Request, status from app.auth.deps import CurrentUser, require_global_admin from app.db import get_db from app.models.schemas import ( HostCreateRequest, HostGroupCreateRequest, RdpCredentialsRequest, RoleGrantRequest, SshKeyCreateRequest, UserCreateRequest, ) 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 router = APIRouter(prefix="/admin", tags=["admin"]) def _client_ip(request: Request) -> str: return request.client.host if request.client else "unknown" # --- Users ----------------------------------------------------------------- @router.post("/users", status_code=status.HTTP_201_CREATED) async def create_user( payload: UserCreateRequest, request: Request, admin: CurrentUser = Depends(require_global_admin) ): conn = get_db() cursor = await conn.execute("SELECT 1 FROM users WHERE username = ?", (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) " "VALUES (?, ?, ?, 1)", (payload.username, pw_hash, int(payload.is_admin)), ) 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_global_admin)): conn = get_db() cursor = await conn.execute( "SELECT id, username, is_admin, is_active, totp_enrolled, created_at FROM users ORDER BY id" ) 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], } for r in rows ] @router.post("/users/{user_id}/deactivate") async def deactivate_user( user_id: int, request: Request, admin: CurrentUser = Depends(require_global_admin) ): conn = get_db() 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"} # --- Hostgruppen ------------------------------------------------------------- @router.post("/host-groups", status_code=status.HTTP_201_CREATED) async def create_host_group( payload: HostGroupCreateRequest, request: Request, admin: CurrentUser = Depends(require_global_admin) ): conn = get_db() cursor = await conn.execute( "INSERT INTO host_groups (name, description) VALUES (?, ?)", (payload.name, payload.description), ) 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}, ) await conn.commit() return {"id": new_id, "name": payload.name} @router.get("/host-groups") async def list_host_groups(admin: CurrentUser = Depends(require_global_admin)): conn = get_db() cursor = await conn.execute("SELECT id, name, description FROM host_groups ORDER BY id") rows = await cursor.fetchall() return [{"id": r[0], "name": r[1], "description": r[2]} for r in rows] # --- Hosts ------------------------------------------------------------------- @router.post("/hosts", status_code=status.HTTP_201_CREATED) async def create_host( payload: HostCreateRequest, request: Request, admin: CurrentUser = Depends(require_global_admin) ): conn = get_db() 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 ) 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), ), ) 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_global_admin)): conn = get_db() if host_group_id is not None: cursor = await conn.execute( "SELECT id, hostname, address, protocol, port, os_type, host_group_id " "FROM hosts WHERE host_group_id = ? ORDER BY id", (host_group_id,), ) else: cursor = await conn.execute( "SELECT id, hostname, address, protocol, port, os_type, host_group_id FROM hosts ORDER BY id" ) 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], } for r in rows ] @router.post("/hosts/{host_id}/discover-host-key") async def discover_host_key( host_id: int, request: Request, admin: CurrentUser = Depends(require_global_admin) ): """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.""" conn = get_db() fingerprint = await discover_and_store_host_key(conn, host_id, admin_user_id=admin.id) 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.put("/hosts/{host_id}/rdp-credentials") async def set_rdp_credentials( host_id: int, payload: RdpCredentialsRequest, request: Request, admin: CurrentUser = Depends(require_global_admin), ): """Speichert/rotiert das RDP-Passwort fuer einen Host, verschluesselt mit dem KEK (eigener AAD-Kontext, siehe app/security/crypto.py).""" conn = get_db() encrypted = encrypt_secret(payload.password.encode(), associated_data=b"rdp_password") await conn.execute( "INSERT INTO rdp_credentials (host_id, password_enc, updated_at) " "VALUES (?, ?, strftime('%Y-%m-%dT%H:%M:%fZ','now')) " "ON CONFLICT(host_id) DO UPDATE SET password_enc = excluded.password_enc, " "updated_at = excluded.updated_at", (host_id, encrypted), ) await write_audit_event( conn, event_type="rdp_credentials_set", user_id=admin.id, client_ip=_client_ip(request), details={"host_id": host_id}, ) await conn.commit() return {"status": "ok"} # --- Rollenvergabe ------------------------------------------------------------- @router.post("/roles/grant") async def grant_role( payload: RoleGrantRequest, request: Request, admin: CurrentUser = Depends(require_global_admin) ): conn = get_db() role_cursor = await conn.execute("SELECT id FROM roles WHERE name = ?", (payload.role_name,)) role_row = await role_cursor.fetchone() if role_row is None: raise HTTPException(status.HTTP_400_BAD_REQUEST, "Unbekannte Rolle") 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_row[0], admin.id, payload.expires_at), ) 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, "role": payload.role_name, "expires_at": payload.expires_at, }, ) await conn.commit() return {"status": "ok"} @router.post("/roles/revoke") async def revoke_role( payload: RoleGrantRequest, request: Request, admin: CurrentUser = Depends(require_global_admin) ): conn = get_db() role_cursor = await conn.execute("SELECT id FROM roles WHERE name = ?", (payload.role_name,)) role_row = await role_cursor.fetchone() if role_row is None: raise HTTPException(status.HTTP_400_BAD_REQUEST, "Unbekannte Rolle") 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_row[0]), ) 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"} # --- SSH-Keyverwaltung --------------------------------------------------------- @router.post("/ssh-keys", status_code=status.HTTP_201_CREATED) async def create_ssh_key( payload: SshKeyCreateRequest, request: Request, admin: CurrentUser = Depends(require_global_admin) ): """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() encrypted = encrypt_secret(payload.private_key_pem.encode(), associated_data=b"ssh_private_key") cursor = await conn.execute( "INSERT INTO ssh_keys (label, owner_user_id, private_key_enc, public_key, key_type) " "VALUES (?, ?, ?, ?, ?)", (payload.label, payload.owner_user_id, encrypted, payload.public_key, payload.key_type), ) 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}, ) await conn.commit() return {"id": new_id, "label": payload.label} @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_global_admin) ): conn = get_db() 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"} # --- Audit-Log ----------------------------------------------------------------- @router.get("/audit-log") async def get_audit_log( limit: int = 100, offset: int = 0, admin: CurrentUser = Depends(require_global_admin) ): limit = max(1, min(limit, 1000)) conn = get_db() 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() 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_global_admin)): """Prueft die Hash-Chain auf Manipulationsfreiheit (Konzept 6.1/4.7).""" conn = get_db() intact, broken_at = await verify_chain(conn) return {"intact": intact, "first_broken_id": broken_at}