second commit

This commit is contained in:
2026-08-19 22:33:19 +02:00
parent 411812e954
commit 199f306993
107 changed files with 5984 additions and 0 deletions

0
app/admin/__init__.py Normal file
View File

340
app/admin/routes.py Normal file
View File

@ -0,0 +1,340 @@
"""
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}