Files
ssh-jumphost/app/admin/routes.py
2026-08-20 10:43:19 +02:00

680 lines
26 KiB
Python

"""
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}