second commit
This commit is contained in:
340
app/admin/routes.py
Normal file
340
app/admin/routes.py
Normal 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}
|
||||
Reference in New Issue
Block a user