""" Administrative CRUD-API: User, Benutzergruppen, Hostgruppen, Hosts, Rollenvergabe (an User UND an Benutzergruppen), SSH-Keys, API-Tokens. Zustandsaendernde/-lesende Endpunkte sind entweder auf eine eingeloggte Admin-Session 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). Die Token-Verwaltung selbst (/admin/tokens/*) laeuft bewusst ausschliesslich ueber `require_global_admin` (reine Session-Aktion, kein Token-Bypass -- Privilege-Escalation-Schutz). 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 import json from fastapi import APIRouter, Depends, HTTPException, Request, status from app.auth.deps import CurrentUser, require_admin_or_scope, require_global_admin from app.db import get_db from app.models.schemas import ( ApiTokenCreateRequest, GroupMemberRequest, GroupRoleGrantRequest, HostCreateRequest, HostGroupCreateRequest, RdpCredentialsRequest, RoleGrantRequest, SshKeyCreateRequest, UserCreateRequest, UserGroupCreateRequest, ) 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 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_admin_or_scope("users", "write")), ): 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_admin_or_scope("users", "read"))): 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_admin_or_scope("users", "write")), ): 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"} # --- 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() 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) VALUES (?, ?)", (payload.name, payload.description), ) 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}, ) 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() cursor = await conn.execute( "SELECT ug.id, ug.name, ug.description, ug.created_at, COUNT(ugm.user_id) " "FROM user_groups ug LEFT JOIN user_group_members ugm ON ugm.user_group_id = ug.id " "GROUP BY ug.id ORDER BY ug.name" ) rows = await cursor.fetchall() return [ {"id": r[0], "name": r[1], "description": r[2], "created_at": r[3], "member_count": r[4]} for r in rows ] @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() 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() group_cursor = await conn.execute("SELECT 1 FROM user_groups WHERE id = ?", (group_id,)) if await group_cursor.fetchone() is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Gruppe nicht gefunden") user_cursor = await conn.execute("SELECT 1 FROM users WHERE id = ?", (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 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() 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_admin_or_scope("host_groups", "read"))): 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_admin_or_scope("hosts", "write")), ): 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_admin_or_scope("hosts", "read")), ): 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_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.""" 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_admin_or_scope("hosts", "write")), ): """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 (an einzelne User) ----------------------------------------- @router.post("/roles/grant") async def grant_role( payload: RoleGrantRequest, request: Request, admin: CurrentUser = Depends(require_admin_or_scope("roles", "write")), ): 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_admin_or_scope("roles", "write")), ): 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"} @router.get("/roles") async def list_role_grants(admin: CurrentUser = Depends(require_admin_or_scope("roles", "read"))): conn = get_db() cursor = await conn.execute( """ 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 ORDER BY u.username, hg.name, r.name """ ) 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() 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 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_row[0], admin.id, payload.expires_at), ) 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, "role": payload.role_name, "expires_at": payload.expires_at, }, ) await conn.commit() return {"status": "ok"} @router.post("/group-roles/revoke") async def revoke_group_role( payload: GroupRoleGrantRequest, request: Request, admin: CurrentUser = Depends(require_admin_or_scope("roles", "write")), ): 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 group_hostgroup_roles WHERE user_group_id = ? AND host_group_id = ? AND role_id = ?", (payload.user_group_id, payload.host_group_id, role_row[0]), ) 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() cursor = await conn.execute( """ 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 ORDER BY ug.name, hg.name, r.name """ ) 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 ] # --- 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_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() 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.get("/ssh-keys") async def list_ssh_keys(admin: CurrentUser = Depends(require_admin_or_scope("ssh_keys", "read"))): conn = get_db() cursor = await conn.execute( "SELECT id, label, owner_user_id, key_type, created_at, rotated_at, expires_at " "FROM ssh_keys ORDER BY id" ) rows = await cursor.fetchall() return [ { "id": r[0], "label": r[1], "owner_user_id": r[2], "key_type": r[3], "created_at": r[4], "rotated_at": r[5], "expires_at": r[6], } for r in rows ] @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")), ): 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"} # --- API-Tokens ------------------------------------------------------------ # # Bewusst ausschliesslich ueber require_global_admin (Session), NIE ueber # require_admin_or_scope -- ein geleaktes Token darf sich damit nicht selbst # weitere/staerkere Tokens ausstellen (Privilege-Escalation-Schutz). @router.get("/scopes") async def list_valid_scopes(admin: CurrentUser = Depends(require_global_admin)): 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_global_admin) ): try: scopes = validate_scopes(payload.scopes) except ValueError as exc: raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc conn = get_db() user_cursor = await conn.execute("SELECT 1 FROM users WHERE id = ?", (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) VALUES (?, ?, ?, ?, ?, ?, ?)", ( payload.user_id, payload.label, hash_token(token), token_prefix_for_display(token), json.dumps(scopes), admin.id, payload.expires_at, ), ) 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, }, ) 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_global_admin)): conn = get_db() 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 " "FROM api_tokens t JOIN users u ON u.id = t.user_id ORDER BY t.id DESC" ) 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], } for r in rows ] @router.post("/tokens/{token_id}/revoke") async def revoke_api_token( token_id: int, request: Request, admin: CurrentUser = Depends(require_global_admin) ): conn = get_db() 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() 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_admin_or_scope("audit", "read"))): """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}