2157 lines
95 KiB
Python
2157 lines
95 KiB
Python
"""
|
|
Administrative CRUD-API: User, Benutzergruppen, Hostgruppen, Hosts,
|
|
Rollenvergabe (an User UND an Benutzergruppen, Mehrfachauswahl), SSH-Keys,
|
|
RDP-Zugangsdaten, API-Tokens, Audit-Log.
|
|
|
|
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 (/admin/tokens/*)
|
|
laeuft ausschliesslich ueber `require_admin_session` (reine Session-Aktion,
|
|
kein Token-Bypass -- Privilege-Escalation-Schutz).
|
|
|
|
Hinweis (Umsetzungsauftrag_Sonnet5.md Teil C): diese Anwendung war frueher
|
|
mandantenfaehig (Mandanten/`tenant_admins`, `app/tenancy.py::TenantScope`).
|
|
Das wurde vollstaendig zurueckgebaut -- historische `tenant_*`-Audit-Events
|
|
bleiben dabei bewusst im (manipulationssicheren, append-only) Audit-Log
|
|
erhalten, siehe README.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
from pathlib import Path
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
|
|
|
from app.auth.deps import (
|
|
CurrentUser,
|
|
require_admin_or_scope,
|
|
require_admin_scope_or_host_role,
|
|
require_admin_session,
|
|
require_global_admin,
|
|
)
|
|
from app.db import get_db
|
|
from app.models.schemas import (
|
|
ApiTokenCreateRequest,
|
|
GroupCredentialGrantRequest,
|
|
GroupCredentialRevokeRequest,
|
|
GroupMemberRequest,
|
|
GroupRoleGrantRequest,
|
|
GroupRoleRevokeRequest,
|
|
HostCreateRequest,
|
|
HostGroupCreateRequest,
|
|
HostGroupUpdateRequest,
|
|
HostUpdateRequest,
|
|
RdpCredentialCreateRequest,
|
|
RdpCredentialUpdateRequest,
|
|
RoleGrantRequest,
|
|
RoleRevokeRequest,
|
|
SshKeyCreateRequest,
|
|
SshKeyGenerateRequest,
|
|
SshKeyUpdateRequest,
|
|
SshPasswordCredentialsRequest,
|
|
UserCreateRequest,
|
|
UserGroupCreateRequest,
|
|
UserGroupUpdateRequest,
|
|
UserUpdateRequest,
|
|
)
|
|
from app.rbac import CredentialKind, user_can_use_credential
|
|
from app.security import active_sessions
|
|
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 decrypt_secret, encrypt_secret
|
|
from app.security.passwords import hash_password
|
|
from app.recordings.recorder import count_recording_entries, iter_recording_entries, verify_recording_set
|
|
from app.ssh_proxy.proxy import (
|
|
HostKeyDiscoveryError,
|
|
HostNotConfiguredError,
|
|
PrivateKeyUnusableError,
|
|
discover_and_store_host_key,
|
|
generate_key_material,
|
|
import_private_key_material,
|
|
)
|
|
|
|
logger = logging.getLogger("jumphost.admin")
|
|
|
|
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")),
|
|
):
|
|
if payload.is_admin and not admin.is_admin:
|
|
raise HTTPException(status.HTTP_403_FORBIDDEN, "Nur Admins duerfen weitere Admins anlegen")
|
|
|
|
conn = get_db()
|
|
cursor = await conn.execute(
|
|
"SELECT 1 FROM users WHERE username = ? AND deleted_at IS NULL", (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 WHERE deleted_at IS NULL 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
|
|
]
|
|
|
|
|
|
async def _assert_user_in_scope(conn, user_id: int) -> None:
|
|
"""Reine Existenzpruefung (liefert 404 fuer 'gibt es nicht') -- frueher
|
|
zusaetzlich TenantScope-gefiltert, siehe Umsetzungsauftrag_Sonnet5.md
|
|
Teil C.6 Schritt 8."""
|
|
row = await (await conn.execute(
|
|
"SELECT 1 FROM users WHERE id = ? AND deleted_at IS NULL", (user_id,)
|
|
)).fetchone()
|
|
if row is None:
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Nicht gefunden")
|
|
|
|
|
|
@router.put("/users/{user_id}")
|
|
async def update_user(
|
|
user_id: int, payload: UserUpdateRequest, request: Request,
|
|
admin: CurrentUser = Depends(require_admin_or_scope("users", "write")),
|
|
):
|
|
conn = get_db()
|
|
target = await conn.execute(
|
|
"SELECT is_admin FROM users WHERE id = ? AND deleted_at IS NULL", (user_id,)
|
|
)
|
|
row = await target.fetchone()
|
|
if row is None:
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Benutzer nicht gefunden")
|
|
if row[0] and not admin.is_admin:
|
|
raise HTTPException(status.HTTP_403_FORBIDDEN, "Admin-Konten nur durch Admins aenderbar")
|
|
if payload.is_admin is not None and not admin.is_admin:
|
|
raise HTTPException(status.HTTP_403_FORBIDDEN, "Nur Admins duerfen Admin-Rechte vergeben")
|
|
await _assert_user_in_scope(conn, user_id)
|
|
|
|
fields, values = [], []
|
|
if payload.is_admin is not None:
|
|
fields.append("is_admin = ?"); values.append(int(payload.is_admin))
|
|
if payload.is_active is not None:
|
|
fields.append("is_active = ?"); values.append(int(payload.is_active))
|
|
fields.append("session_version = session_version + 1")
|
|
if payload.new_password is not None:
|
|
fields.append("password_hash = ?"); values.append(hash_password(payload.new_password))
|
|
fields.append("must_change_password = 1")
|
|
fields.append("session_version = session_version + 1")
|
|
if not fields:
|
|
return {"status": "ok", "changed": False}
|
|
values.append(user_id)
|
|
await conn.execute(f"UPDATE users SET {', '.join(fields)} WHERE id = ?", values)
|
|
await write_audit_event(
|
|
conn, event_type="user_updated", user_id=admin.id, client_ip=_client_ip(request),
|
|
details={
|
|
"target_user_id": user_id,
|
|
"fields": [k for k, v in payload.model_dump(exclude={"new_password"}).items() if v is not None]
|
|
+ (["new_password"] if payload.new_password is not None else []),
|
|
},
|
|
)
|
|
await conn.commit()
|
|
return {"status": "ok", "changed": True}
|
|
|
|
|
|
@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()
|
|
row = await (await conn.execute(
|
|
"SELECT is_admin FROM users WHERE id = ? AND deleted_at IS NULL", (user_id,)
|
|
)).fetchone()
|
|
if row is None:
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Benutzer nicht gefunden")
|
|
if row[0] and not admin.is_admin:
|
|
raise HTTPException(status.HTTP_403_FORBIDDEN, "Admin-Konten nur durch Admins aenderbar")
|
|
await _assert_user_in_scope(conn, user_id)
|
|
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"}
|
|
|
|
|
|
@router.delete("/users/{user_id}")
|
|
async def delete_user(
|
|
user_id: int, request: Request,
|
|
admin: CurrentUser = Depends(require_admin_or_scope("users", "write")),
|
|
):
|
|
"""Loescht ein Benutzerkonto. Ein Konto MIT Audit-Historie kann nicht per
|
|
SQL-DELETE entfernt werden (audit_log.user_id verweist bewusst OHNE
|
|
ON DELETE CASCADE auf users(id), siehe 0007_crud_extras.sql) -- es wird
|
|
stattdessen deaktiviert und anonymisiert (Benutzername/Passwort/TOTP
|
|
geloescht, deleted_at gesetzt). Nur ein Konto OHNE jede Audit-Historie
|
|
(z.B. versehentlich angelegt und sofort wieder geloescht) wird
|
|
tatsaechlich hart entfernt. Die Response verraet, welcher Fall eintrat."""
|
|
conn = get_db()
|
|
row = await (await conn.execute(
|
|
"SELECT is_admin, username FROM users WHERE id = ? AND deleted_at IS NULL", (user_id,)
|
|
)).fetchone()
|
|
if row is None:
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Benutzer nicht gefunden")
|
|
if row[0] and not admin.is_admin:
|
|
raise HTTPException(status.HTTP_403_FORBIDDEN, "Admin-Konten nur durch Admins loeschbar")
|
|
if user_id == admin.id:
|
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Eigenes Konto kann nicht geloescht werden")
|
|
await _assert_user_in_scope(conn, user_id)
|
|
|
|
# Bug-Fix (FORTSETZUNG_Teil_C.md Abschnitt 3 Punkt 7): audit_log.user_id
|
|
# ist bei "user_updated"/"user_deactivated" und aehnlichen Ereignissen
|
|
# die ID des HANDELNDEN Admins, nicht des betroffenen Zielnutzers (siehe
|
|
# z.B. write_audit_event(..., user_id=admin.id, details={"target_user_id":
|
|
# user_id}) oben in update_user()/deactivate_user()). Eine reine Pruefung
|
|
# auf audit_log.user_id = <Zielnutzer> findet deshalb NIE etwas fuer
|
|
# einen Nutzer, der nur von ANDEREN administriert, aber nie selbst aktiv
|
|
# war (kein eigener Login etc.) -- das Konto wurde dadurch faelschlich
|
|
# hart geloescht statt anonymisiert, obwohl relevante Audit-Historie zu
|
|
# ihm existierte. Zusaetzlich auf das "target_user_id"-Feld in den
|
|
# JSON-details pruefen (SQLite JSON1 json_extract, projektweite
|
|
# Konvention fuer "wer ist betroffen, wenn nicht der Akteur selbst" --
|
|
# siehe u.a. user_added_to_group/removed_from_group, api_token_issued,
|
|
# session_terminated_by_admin). json_extract liefert NULL fuer Zeilen
|
|
# ohne dieses Feld, ein Vergleich mit NULL ist in SQL nie wahr -- kein
|
|
# Sonderfall fuer Ereignisse ohne target_user_id noetig.
|
|
has_audit = await (await conn.execute(
|
|
"SELECT 1 FROM audit_log WHERE user_id = ? "
|
|
"OR json_extract(details_json, '$.target_user_id') = ? LIMIT 1",
|
|
(user_id, user_id),
|
|
)).fetchone()
|
|
hard_deleted = False
|
|
if has_audit is None:
|
|
try:
|
|
await conn.execute("DELETE FROM users WHERE id = ?", (user_id,))
|
|
hard_deleted = True
|
|
except Exception:
|
|
hard_deleted = False
|
|
if not hard_deleted:
|
|
anonymized_username = f"deleted_user_{user_id}"
|
|
await conn.execute(
|
|
"UPDATE users SET username = ?, password_hash = ?, totp_secret_enc = NULL, "
|
|
"totp_enrolled = 0, is_active = 0, session_version = session_version + 1, "
|
|
"deleted_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
|
|
(anonymized_username, hash_password(generate_token()), user_id),
|
|
)
|
|
await write_audit_event(
|
|
conn, event_type="user_deleted", user_id=admin.id, client_ip=_client_ip(request),
|
|
details={"target_user_id": user_id, "hard_deleted": hard_deleted, "was_username": row[1]},
|
|
)
|
|
await conn.commit()
|
|
return {"status": "ok", "hard_deleted": hard_deleted}
|
|
|
|
|
|
# --- 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
|
|
]
|
|
|
|
|
|
async def _assert_user_group_in_scope(conn, group_id: int) -> None:
|
|
"""Reine Existenzpruefung, siehe _assert_user_in_scope."""
|
|
row = await (await conn.execute("SELECT 1 FROM user_groups WHERE id = ?", (group_id,))).fetchone()
|
|
if row is None:
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Gruppe nicht gefunden")
|
|
|
|
|
|
@router.put("/user-groups/{group_id}")
|
|
async def update_user_group(
|
|
group_id: int, payload: UserGroupUpdateRequest, request: Request,
|
|
admin: CurrentUser = Depends(require_admin_or_scope("user_groups", "write")),
|
|
):
|
|
conn = get_db()
|
|
await _assert_user_group_in_scope(conn, group_id)
|
|
fields, values = [], []
|
|
if payload.name is not None:
|
|
fields.append("name = ?"); values.append(payload.name)
|
|
if payload.description is not None:
|
|
fields.append("description = ?"); values.append(payload.description)
|
|
if not fields:
|
|
return {"status": "ok", "changed": False}
|
|
values.append(group_id)
|
|
await conn.execute(f"UPDATE user_groups SET {', '.join(fields)} WHERE id = ?", values)
|
|
await write_audit_event(
|
|
conn, event_type="user_group_updated", user_id=admin.id, client_ip=_client_ip(request),
|
|
details={"id": group_id},
|
|
)
|
|
await conn.commit()
|
|
return {"status": "ok", "changed": True}
|
|
|
|
|
|
@router.delete("/user-groups/{group_id}")
|
|
async def delete_user_group(
|
|
group_id: int, request: Request,
|
|
admin: CurrentUser = Depends(require_admin_or_scope("user_groups", "write")),
|
|
):
|
|
conn = get_db()
|
|
await _assert_user_group_in_scope(conn, group_id)
|
|
await conn.execute("DELETE FROM user_groups WHERE id = ?", (group_id,))
|
|
await write_audit_event(
|
|
conn, event_type="user_group_deleted", user_id=admin.id, client_ip=_client_ip(request),
|
|
details={"id": group_id},
|
|
)
|
|
await conn.commit()
|
|
return {"status": "ok"}
|
|
|
|
|
|
@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()
|
|
await _assert_user_group_in_scope(conn, group_id)
|
|
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()
|
|
await _assert_user_group_in_scope(conn, group_id)
|
|
user_cursor = await conn.execute(
|
|
"SELECT 1 FROM users WHERE id = ? AND deleted_at IS NULL", (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),
|
|
)
|
|
# S10 (Umsetzungsauftrag_Sonnet5.md Teil D.2): das Audit-Event zeigt, was
|
|
# der BEITRITT vermittelt -- die aktuell von dieser Gruppe gewaehrten
|
|
# Rechte (Achse A + B). Bewusst KEIN Diff gegen die vorher schon ueber
|
|
# andere Gruppen gehaltenen Rechte des Users (das waere ein deutlich
|
|
# groesserer Umbau); "gained_rights" beschreibt also, was diese Gruppe
|
|
# gewaehrt, nicht zwingend, was der Benutzer NETTO neu hinzugewinnt, falls
|
|
# er dieselben Rechte schon ueber eine andere Gruppe hatte.
|
|
gained_rights = await _group_effective_rights_summary(conn, group_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,
|
|
"gained_rights": gained_rights,
|
|
},
|
|
)
|
|
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 _assert_user_group_in_scope(conn, group_id)
|
|
# S10: siehe Kommentar in add_group_member() -- symmetrisch fuer den
|
|
# Austritt, VOR dem DELETE ermittelt (macht inhaltlich keinen
|
|
# Unterschied, die Abfrage haengt nur an der Gruppe, nicht an der
|
|
# Mitgliedschaftszeile, aber "was verliert er" liest sich vor der
|
|
# Loeschung klarer als danach).
|
|
lost_rights = await _group_effective_rights_summary(conn, group_id)
|
|
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,
|
|
"lost_rights": lost_rights,
|
|
},
|
|
)
|
|
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 hg.id, hg.name, hg.description FROM host_groups hg ORDER BY hg.id")
|
|
rows = await cursor.fetchall()
|
|
return [{"id": r[0], "name": r[1], "description": r[2]} for r in rows]
|
|
|
|
|
|
async def _assert_host_group_in_scope(conn, host_group_id: int) -> None:
|
|
"""Reine Existenzpruefung, siehe _assert_user_in_scope."""
|
|
row = await (await conn.execute("SELECT 1 FROM host_groups WHERE id = ?", (host_group_id,))).fetchone()
|
|
if row is None:
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Hostgruppe nicht gefunden")
|
|
|
|
|
|
@router.put("/host-groups/{host_group_id}")
|
|
async def update_host_group(
|
|
host_group_id: int, payload: HostGroupUpdateRequest, request: Request,
|
|
admin: CurrentUser = Depends(require_admin_or_scope("host_groups", "write")),
|
|
):
|
|
conn = get_db()
|
|
await _assert_host_group_in_scope(conn, host_group_id)
|
|
fields, values = [], []
|
|
if payload.name is not None:
|
|
fields.append("name = ?"); values.append(payload.name)
|
|
if payload.description is not None:
|
|
fields.append("description = ?"); values.append(payload.description)
|
|
if not fields:
|
|
return {"status": "ok", "changed": False}
|
|
values.append(host_group_id)
|
|
await conn.execute(f"UPDATE host_groups SET {', '.join(fields)} WHERE id = ?", values)
|
|
await write_audit_event(
|
|
conn, event_type="host_group_updated", user_id=admin.id, client_ip=_client_ip(request),
|
|
details={"id": host_group_id},
|
|
)
|
|
await conn.commit()
|
|
return {"status": "ok", "changed": True}
|
|
|
|
|
|
@router.delete("/host-groups/{host_group_id}")
|
|
async def delete_host_group(
|
|
host_group_id: int, request: Request,
|
|
admin: CurrentUser = Depends(require_admin_or_scope("host_groups", "write")),
|
|
):
|
|
conn = get_db()
|
|
await _assert_host_group_in_scope(conn, host_group_id)
|
|
(count,) = await (await conn.execute(
|
|
"SELECT COUNT(*) FROM hosts WHERE host_group_id = ?", (host_group_id,)
|
|
)).fetchone()
|
|
if count:
|
|
raise HTTPException(
|
|
status.HTTP_409_CONFLICT,
|
|
f"Hostgruppe enthaelt noch {count} Host(s) (auch inaktive) -- zuerst entfernen/verschieben",
|
|
)
|
|
await conn.execute("DELETE FROM host_groups WHERE id = ?", (host_group_id,))
|
|
await write_audit_event(
|
|
conn, event_type="host_group_deleted", user_id=admin.id, client_ip=_client_ip(request),
|
|
details={"id": host_group_id},
|
|
)
|
|
await conn.commit()
|
|
return {"status": "ok"}
|
|
|
|
|
|
# --- 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()
|
|
await _assert_host_group_in_scope(conn, payload.host_group_id)
|
|
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, rdp_ignore_cert
|
|
) 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), int(payload.rdp_ignore_cert),
|
|
),
|
|
)
|
|
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()
|
|
where = "WHERE 1=1"
|
|
params: list = []
|
|
if host_group_id is not None:
|
|
where += " AND h.host_group_id = ?"
|
|
params.append(host_group_id)
|
|
cursor = await conn.execute(
|
|
"SELECT h.id, h.hostname, h.address, h.protocol, h.port, h.os_type, h.host_group_id, "
|
|
"h.is_active "
|
|
"FROM hosts h "
|
|
f"{where} ORDER BY h.id",
|
|
params,
|
|
)
|
|
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], "is_active": bool(r[7]),
|
|
}
|
|
for r in rows
|
|
]
|
|
|
|
|
|
async def _assert_host_in_scope(conn, host_id: int) -> None:
|
|
"""Reine Existenzpruefung, siehe _assert_user_in_scope."""
|
|
row = await (await conn.execute("SELECT 1 FROM hosts WHERE id = ?", (host_id,))).fetchone()
|
|
if row is None:
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Host nicht gefunden")
|
|
|
|
|
|
@router.get("/hosts/{host_id}")
|
|
async def get_host_detail(
|
|
host_id: int, admin: CurrentUser = Depends(require_admin_or_scope("hosts", "read"))
|
|
):
|
|
"""Liefert den vollstaendigen, aktuellen Datensatz eines Hosts inkl.
|
|
zugeordneter SSH-Keys und ob RDP-Zugangsdaten hinterlegt sind -- Basis
|
|
fuer die 'Details'-Ansicht der Admin-Oberflaeche (statt sich auf die
|
|
ggf. veraltete Liste zu verlassen)."""
|
|
conn = get_db()
|
|
await _assert_host_in_scope(conn, host_id)
|
|
row = await (await conn.execute(
|
|
"SELECT h.id, h.hostname, h.address, h.protocol, h.port, h.os_type, h.host_group_id, "
|
|
"h.ssh_host_key_fingerprint, h.ssh_username, h.rdp_username, h.rdp_domain, "
|
|
"h.rdp_require_nla, h.clipboard_enabled, h.file_transfer_enabled, h.is_active, "
|
|
"h.rdp_ignore_cert "
|
|
"FROM hosts h "
|
|
"WHERE h.id = ?",
|
|
(host_id,),
|
|
)).fetchone()
|
|
if row is None:
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Host nicht gefunden")
|
|
keys_cursor = await conn.execute(
|
|
"SELECT sk.id, sk.label, sk.username FROM host_ssh_key_map m "
|
|
"JOIN ssh_keys sk ON sk.id = m.ssh_key_id WHERE m.host_id = ?",
|
|
(host_id,),
|
|
)
|
|
ssh_keys = [{"id": k[0], "label": k[1], "username": k[2]} for k in await keys_cursor.fetchall()]
|
|
# Migration 0012: das zugewiesene RDP-Zugangsdaten-Objekt (falls
|
|
# vorhanden) statt eines 1:1-Datensatzes am Host -- siehe
|
|
# host_rdp_credential_map.
|
|
rdp_row = await (await conn.execute(
|
|
"SELECT rc.id, rc.label, rc.username, rc.domain, COALESCE(rc.rotated_at, rc.created_at) "
|
|
"FROM host_rdp_credential_map m JOIN rdp_credentials rc ON rc.id = m.rdp_credential_id "
|
|
"WHERE m.host_id = ?",
|
|
(host_id,),
|
|
)).fetchone()
|
|
ssh_pw_row = await (await conn.execute(
|
|
"SELECT COALESCE(spc.rotated_at, spc.created_at), spc.username "
|
|
"FROM host_ssh_password_credential_map m "
|
|
"JOIN ssh_password_credentials spc ON spc.id = m.ssh_password_credential_id "
|
|
"WHERE m.host_id = ?",
|
|
(host_id,),
|
|
)).fetchone()
|
|
return {
|
|
"id": row[0], "hostname": row[1], "address": row[2], "protocol": row[3], "port": row[4],
|
|
"os_type": row[5], "host_group_id": row[6], "ssh_host_key_fingerprint": row[7],
|
|
"ssh_username": row[8], "rdp_username": row[9], "rdp_domain": row[10],
|
|
"rdp_require_nla": bool(row[11]), "clipboard_enabled": bool(row[12]),
|
|
"file_transfer_enabled": bool(row[13]), "is_active": bool(row[14]),
|
|
"rdp_ignore_cert": bool(row[15]),
|
|
"ssh_keys": ssh_keys,
|
|
"rdp_credentials_set": rdp_row is not None,
|
|
"rdp_credentials_id": rdp_row[0] if rdp_row else None,
|
|
"rdp_credentials_label": rdp_row[1] if rdp_row else None,
|
|
# Benutzername/Domaene gehoeren seit Migration 0010 zu den
|
|
# Zugangsdaten (jetzt: dem zugewiesenen Objekt); nur zur Anzeige.
|
|
"rdp_credentials_username": rdp_row[2] if rdp_row else None,
|
|
"rdp_credentials_domain": rdp_row[3] if rdp_row else None,
|
|
"rdp_credentials_updated_at": rdp_row[4] if rdp_row else None,
|
|
# SSH-Passwort als Alternative zum Schluessel (Migration 0011).
|
|
"ssh_password_credentials_set": ssh_pw_row is not None,
|
|
"ssh_password_credentials_updated_at": ssh_pw_row[0] if ssh_pw_row else None,
|
|
"ssh_password_credentials_username": ssh_pw_row[1] if ssh_pw_row else None,
|
|
}
|
|
|
|
|
|
@router.put("/hosts/{host_id}")
|
|
async def update_host(
|
|
host_id: int, payload: HostUpdateRequest, request: Request,
|
|
admin: CurrentUser = Depends(require_admin_or_scope("hosts", "write")),
|
|
):
|
|
conn = get_db()
|
|
await _assert_host_in_scope(conn, host_id)
|
|
if payload.host_group_id is not None:
|
|
await _assert_host_group_in_scope(conn, payload.host_group_id)
|
|
|
|
field_map = {
|
|
"host_group_id": "host_group_id", "hostname": "hostname", "address": "address",
|
|
"port": "port", "ssh_username": "ssh_username", "rdp_username": "rdp_username",
|
|
"rdp_domain": "rdp_domain",
|
|
}
|
|
bool_field_map = {
|
|
"rdp_require_nla": "rdp_require_nla", "clipboard_enabled": "clipboard_enabled",
|
|
"file_transfer_enabled": "file_transfer_enabled", "is_active": "is_active",
|
|
"rdp_ignore_cert": "rdp_ignore_cert",
|
|
}
|
|
fields, values = [], []
|
|
payload_dict = payload.model_dump(exclude_unset=True)
|
|
for py_field, column in field_map.items():
|
|
if py_field in payload_dict:
|
|
fields.append(f"{column} = ?"); values.append(payload_dict[py_field])
|
|
for py_field, column in bool_field_map.items():
|
|
if py_field in payload_dict:
|
|
fields.append(f"{column} = ?"); values.append(int(payload_dict[py_field]))
|
|
if not fields:
|
|
return {"status": "ok", "changed": False}
|
|
values.append(host_id)
|
|
await conn.execute(f"UPDATE hosts SET {', '.join(fields)} WHERE id = ?", values)
|
|
await write_audit_event(
|
|
conn, event_type="host_updated", user_id=admin.id, client_ip=_client_ip(request),
|
|
details={"id": host_id, "fields": list(payload_dict.keys())},
|
|
)
|
|
await conn.commit()
|
|
return {"status": "ok", "changed": True}
|
|
|
|
|
|
@router.delete("/hosts/{host_id}")
|
|
async def delete_host(
|
|
host_id: int, request: Request, hard: bool = False,
|
|
admin: CurrentUser = Depends(require_admin_or_scope("hosts", "write")),
|
|
):
|
|
"""Standardmaessig ein Soft-Delete (is_active=0, wie schon zuvor von
|
|
load_host()/catalog beruecksichtigt) -- ein Host mit vergangenen
|
|
Sitzungen (sessions.host_id, OHNE ON DELETE CASCADE) kann ohnehin nicht
|
|
hart geloescht werden, ohne die Sitzungs-/Aufzeichnungshistorie zu
|
|
verwaisen. Mit ?hard=true wird ein echtes DELETE versucht (nur sinnvoll
|
|
fuer einen Host ohne jede Sitzungshistorie); schlaegt es fehl, faellt der
|
|
Endpunkt automatisch auf Soft-Delete zurueck."""
|
|
conn = get_db()
|
|
await _assert_host_in_scope(conn, host_id)
|
|
hard_deleted = False
|
|
if hard:
|
|
has_sessions = await (await conn.execute(
|
|
"SELECT 1 FROM sessions WHERE host_id = ? LIMIT 1", (host_id,)
|
|
)).fetchone()
|
|
if has_sessions is None:
|
|
await conn.execute("DELETE FROM host_ssh_key_map WHERE host_id = ?", (host_id,))
|
|
# Migration 0012: rdp_credentials hat seit der Umstellung auf
|
|
# wiederverwendbare Zugangsdaten-Objekte KEINE host_id-Spalte
|
|
# mehr -- nur noch die Zuordnungstabelle referenziert den Host
|
|
# (mit ON DELETE CASCADE, dieser Aufruf ist also strenggenommen
|
|
# redundant, aber explizit wie die anderen Zeilen hier gehalten).
|
|
await conn.execute("DELETE FROM host_rdp_credential_map WHERE host_id = ?", (host_id,))
|
|
# Nebenbefund beim Anpassen dieser Stelle: ssh_password_credentials
|
|
# (Migration 0011) fehlte hier komplett -- ohne ON DELETE CASCADE
|
|
# haette ein harter Loeschversuch mit gesetztem SSH-Passwort bei
|
|
# aktivem foreign_keys=ON (app/db.py) mit einem FK-Fehler gescheitert
|
|
# (dann automatisch auf Soft-Delete zurueckgefallen, siehe unten --
|
|
# also kein sichtbarer 500er, aber ein hartes Loeschen war fuer
|
|
# solche Hosts faktisch nie moeglich). Seit Migration 0016 haengt
|
|
# das Zugangsdaten-Objekt selbst nicht mehr an host_id, sondern an
|
|
# host_ssh_password_credential_map (ON DELETE CASCADE auf
|
|
# host_id) -- das darunterliegende Objekt bleibt technisch
|
|
# bestehen (analog zu rdp_credentials/host_rdp_credential_map,
|
|
# siehe Kommentar direkt darueber), wird hier aber bewusst
|
|
# mitentfernt, da es (noch) keine Mehrfachzuweisung-an-mehrere-
|
|
# Hosts-UI dafuer gibt und ein verwaistes Objekt sonst dauerhaft
|
|
# unerreichbar in der DB liegen bliebe.
|
|
orphan_row = await (await conn.execute(
|
|
"SELECT ssh_password_credential_id FROM host_ssh_password_credential_map "
|
|
"WHERE host_id = ?", (host_id,)
|
|
)).fetchone()
|
|
await conn.execute(
|
|
"DELETE FROM host_ssh_password_credential_map WHERE host_id = ?", (host_id,)
|
|
)
|
|
if orphan_row is not None:
|
|
await conn.execute(
|
|
"DELETE FROM ssh_password_credentials WHERE id = ?", (orphan_row[0],)
|
|
)
|
|
await conn.execute("DELETE FROM hosts WHERE id = ?", (host_id,))
|
|
hard_deleted = True
|
|
if not hard_deleted:
|
|
await conn.execute("UPDATE hosts SET is_active = 0 WHERE id = ?", (host_id,))
|
|
await write_audit_event(
|
|
conn, event_type="host_deleted", user_id=admin.id, client_ip=_client_ip(request),
|
|
details={"id": host_id, "hard_deleted": hard_deleted},
|
|
)
|
|
await conn.commit()
|
|
return {"status": "ok", "hard_deleted": hard_deleted}
|
|
|
|
|
|
@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.
|
|
|
|
Bugfix: discover_and_store_host_key() konnte bisher ein unbehandeltes
|
|
asyncssh/OSError durchreichen -> FastAPI antwortete mit 500 statt einer
|
|
verwertbaren Fehlermeldung (siehe app/ssh_proxy/proxy.py). Jetzt sauber
|
|
auf 502 (Verbindung fehlgeschlagen) bzw. 400 (kein SSH-Host) gemappt.
|
|
|
|
Bugfix 2: das (asyncssh.Error, OSError)-except in proxy.py deckte nicht
|
|
jede Art von Fehlschlag beim Key-Exchange ab (z.B. asyncio.TimeoutError
|
|
vor Python 3.11 -- kein OSError), sodass der 500er trotz obigem Fix
|
|
weiterhin auftrat. proxy.py faengt den externen Aufruf jetzt breiter ab;
|
|
zusaetzlich hier ein Catch-all als zweite Verteidigungslinie, damit ein
|
|
verbleibender unerwarteter Fehler (z.B. in load_host oder beim
|
|
Audit-Log-Schreiben) wenigstens mit vollem Traceback geloggt wird statt
|
|
als nackte 500 ohne jede Spur zu verschwinden -- analog zum
|
|
Exception-Catch-all in terminal_ws.py/ws_tunnel.py (Phase 9)."""
|
|
conn = get_db()
|
|
await _assert_host_in_scope(conn, host_id)
|
|
try:
|
|
fingerprint = await discover_and_store_host_key(conn, host_id, admin_user_id=admin.id)
|
|
except HostNotConfiguredError as exc:
|
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
|
|
except HostKeyDiscoveryError as exc:
|
|
raise HTTPException(
|
|
status.HTTP_502_BAD_GATEWAY,
|
|
f"Host-Key konnte nicht ermittelt werden -- Ziel nicht erreichbar: {exc.reason}",
|
|
) from exc
|
|
except Exception as exc:
|
|
logger.exception("Unerwarteter Fehler bei Host-Key-Ermittlung fuer Host %s", host_id)
|
|
raise HTTPException(
|
|
status.HTTP_502_BAD_GATEWAY,
|
|
f"Host-Key konnte nicht ermittelt werden -- unerwarteter Fehler: "
|
|
f"{type(exc).__name__}: {exc}",
|
|
) from exc
|
|
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.post("/hosts/{host_id}/rdp-credentials/{credential_id}")
|
|
async def assign_rdp_credential_to_host(
|
|
host_id: int, credential_id: int, request: Request,
|
|
admin: CurrentUser = Depends(
|
|
require_admin_scope_or_host_role("rdp_credentials", "write", ("credentials_manage",))
|
|
),
|
|
):
|
|
"""Weist einem Host EIN bereits bestehendes RDP-Zugangsdaten-Objekt zu
|
|
(Migration 0012) -- ersetzt eine zuvor zugewiesene Zuordnung, falls
|
|
vorhanden (INSERT OR REPLACE, host_id ist Primaerschluessel der
|
|
Zuordnungstabelle). Das Anlegen des Zugangsdaten-Objekts selbst passiert
|
|
NICHT mehr hier, sondern ueber POST /admin/rdp-credentials (Reiter
|
|
"Zugangsdaten") -- exakt dieselbe Trennung wie bei SSH-Keys
|
|
(map_ssh_key_to_host)."""
|
|
conn = get_db()
|
|
await _assert_host_in_scope(conn, host_id)
|
|
await _assert_rdp_credential_in_scope(conn, credential_id)
|
|
# Teil D Schritt 5 (S4): Existenzpruefung allein reichte bisher aus, um
|
|
# IRGENDEIN im System vorhandenes RDP-Zugangsdaten-Objekt an einen Host
|
|
# zu haengen -- unabhaengig davon, ob der (nicht-admin) Benutzer ueber
|
|
# Achse B ueberhaupt Zugriff darauf hat. Token-Prinzipale (admin.is_token)
|
|
# bleiben wie Admins von dieser Zusatzpruefung ausgenommen (siehe
|
|
# CurrentUser.is_token in app/auth/deps.py).
|
|
if not admin.is_admin and not admin.is_token:
|
|
if not await user_can_use_credential(
|
|
conn, user_id=admin.id, kind="rdp_credential", credential_id=credential_id
|
|
):
|
|
raise HTTPException(
|
|
status.HTTP_403_FORBIDDEN,
|
|
"Kein Zugriff auf dieses RDP-Zugangsdaten-Objekt (keine Ihrer "
|
|
"Benutzergruppen hat es freigegeben).",
|
|
)
|
|
await conn.execute(
|
|
"INSERT INTO host_rdp_credential_map (host_id, rdp_credential_id) VALUES (?, ?) "
|
|
"ON CONFLICT(host_id) DO UPDATE SET rdp_credential_id = excluded.rdp_credential_id",
|
|
(host_id, credential_id),
|
|
)
|
|
await write_audit_event(
|
|
conn, event_type="rdp_credential_mapped", user_id=admin.id, client_ip=_client_ip(request),
|
|
details={"host_id": host_id, "rdp_credential_id": credential_id},
|
|
)
|
|
await conn.commit()
|
|
return {"status": "ok"}
|
|
|
|
|
|
@router.delete("/hosts/{host_id}/rdp-credentials")
|
|
async def unassign_rdp_credential_from_host(
|
|
host_id: int, request: Request,
|
|
admin: CurrentUser = Depends(
|
|
require_admin_scope_or_host_role("rdp_credentials", "write", ("credentials_manage",))
|
|
),
|
|
):
|
|
"""Entfernt NUR die Zuordnung zu diesem Host -- das Zugangsdaten-Objekt
|
|
selbst bleibt bestehen und kann weiterhin anderen Hosts zugewiesen sein
|
|
bzw. spaeter erneut zugewiesen werden. Loeschen des Objekts selbst:
|
|
DELETE /admin/rdp-credentials/{id}."""
|
|
conn = get_db()
|
|
await _assert_host_in_scope(conn, host_id)
|
|
await conn.execute("DELETE FROM host_rdp_credential_map WHERE host_id = ?", (host_id,))
|
|
await write_audit_event(
|
|
conn, event_type="rdp_credential_unmapped", user_id=admin.id, client_ip=_client_ip(request),
|
|
details={"host_id": host_id},
|
|
)
|
|
await conn.commit()
|
|
return {"status": "ok"}
|
|
|
|
|
|
@router.put("/hosts/{host_id}/ssh-password")
|
|
async def set_ssh_password_credentials(
|
|
host_id: int, payload: SshPasswordCredentialsRequest, request: Request,
|
|
admin: CurrentUser = Depends(
|
|
require_admin_scope_or_host_role("hosts", "write", ("credentials_manage",))
|
|
),
|
|
):
|
|
"""Setzt/aendert das SSH-Passwort fuer einen Host (Migration 0011,
|
|
Alternative zum SSH-Key -- 'Linux kann statt SSH-Key auch Passwort
|
|
haben'). Wird beim Verbindungsaufbau nur beruecksichtigt, solange dem
|
|
Host KEIN SSH-Key zugeordnet ist (siehe
|
|
app/ssh_proxy/proxy.py::connect_to_host)."""
|
|
conn = get_db()
|
|
await _assert_host_in_scope(conn, host_id)
|
|
username = payload.username.strip()
|
|
if not username:
|
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Benutzername darf nicht leer sein")
|
|
encrypted = encrypt_secret(payload.password.encode(), associated_data=b"ssh_password")
|
|
|
|
# Seit Migration 0016 ein eigenstaendiges Objekt mit eigener ID (Vorarbeit
|
|
# fuer Achse B, Teil D.6 Schritt 3) statt eines reinen host_id-1:1-
|
|
# Datensatzes. Diese Endpunkt-Semantik bleibt bewusst unveraendert
|
|
# (weiterhin genau ein SSH-Passwort-Satz pro Host, kein Mehrfachzuweisen
|
|
# an mehrere Hosts wie bei RDP-Zugangsdaten -- das waere ein eigener,
|
|
# spaeterer Schritt): ein erneutes Setzen rotiert das bestehende Objekt
|
|
# in place (gleiche ID bleibt erhalten, `rotated_at` wird gesetzt), statt
|
|
# ein neues anzulegen und das alte verwaist zurueckzulassen.
|
|
existing = await (await conn.execute(
|
|
"SELECT ssh_password_credential_id FROM host_ssh_password_credential_map WHERE host_id = ?",
|
|
(host_id,),
|
|
)).fetchone()
|
|
if existing is not None:
|
|
await conn.execute(
|
|
"UPDATE ssh_password_credentials SET username = ?, password_enc = ?, "
|
|
"rotated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
|
|
(username, encrypted, existing[0]),
|
|
)
|
|
else:
|
|
host_row = await (await conn.execute(
|
|
"SELECT hostname FROM hosts WHERE id = ?", (host_id,)
|
|
)).fetchone()
|
|
label = f"SSH-Passwort: {host_row[0] if host_row else host_id}"
|
|
cursor = await conn.execute(
|
|
"INSERT INTO ssh_password_credentials (label, username, password_enc) VALUES (?, ?, ?)",
|
|
(label, username, encrypted),
|
|
)
|
|
await conn.execute(
|
|
"INSERT INTO host_ssh_password_credential_map (host_id, ssh_password_credential_id) "
|
|
"VALUES (?, ?)",
|
|
(host_id, cursor.lastrowid),
|
|
)
|
|
await write_audit_event(
|
|
conn, event_type="ssh_password_credentials_set", user_id=admin.id, client_ip=_client_ip(request),
|
|
details={"host_id": host_id, "username": username},
|
|
)
|
|
await conn.commit()
|
|
return {"status": "ok"}
|
|
|
|
|
|
@router.delete("/hosts/{host_id}/ssh-password")
|
|
async def delete_ssh_password_credentials(
|
|
host_id: int, request: Request,
|
|
admin: CurrentUser = Depends(
|
|
require_admin_scope_or_host_role("hosts", "write", ("credentials_manage",))
|
|
),
|
|
):
|
|
conn = get_db()
|
|
await _assert_host_in_scope(conn, host_id)
|
|
# Loescht bewusst auch das Zugangsdaten-Objekt selbst (nicht nur die
|
|
# Zuordnung wie bei RDP): solange es keine Mehrfachzuweisung-an-mehrere-
|
|
# Hosts-UI fuer SSH-Passwoerter gibt (siehe set_ssh_password_credentials),
|
|
# kann das Objekt ohnehin nirgends sonst referenziert sein -- ein
|
|
# verwaistes Objekt waere hier nur totes Gewicht, kein wiederverwendbarer
|
|
# Datensatz.
|
|
row = await (await conn.execute(
|
|
"SELECT ssh_password_credential_id FROM host_ssh_password_credential_map WHERE host_id = ?",
|
|
(host_id,),
|
|
)).fetchone()
|
|
if row is not None:
|
|
await conn.execute("DELETE FROM host_ssh_password_credential_map WHERE host_id = ?", (host_id,))
|
|
await conn.execute("DELETE FROM ssh_password_credentials WHERE id = ?", (row[0],))
|
|
await write_audit_event(
|
|
conn, event_type="ssh_password_credentials_deleted", user_id=admin.id, client_ip=_client_ip(request),
|
|
details={"host_id": host_id},
|
|
)
|
|
await conn.commit()
|
|
return {"status": "ok"}
|
|
|
|
|
|
@router.get("/ssh-password-credentials")
|
|
async def list_ssh_password_credentials(
|
|
admin: CurrentUser = Depends(require_admin_or_scope("hosts", "read")),
|
|
):
|
|
"""Teil D Schritt 5: bisher gab es fuer SSH-Passwort-Zugangsdaten -- im
|
|
Unterschied zu SSH-Keys (GET /admin/ssh-keys) und RDP-Zugangsdaten (GET
|
|
/admin/rdp-credentials) -- KEINE globale Liste, obwohl das Objekt schon
|
|
seit Migration 0016 eine eigene stabile ID hat; nur der host-gebundene
|
|
Blick (GET /admin/hosts/{id}/credentials) existierte. Ohne diesen
|
|
Endpunkt haette die neue Achse-B-Freigabe-UI (Zugangsdaten-Freigabe an
|
|
Benutzergruppen) fuer diese Credential-Art keine Datenquelle fuer den
|
|
Auswahl-Dropdown. Struktur bewusst identisch zu list_rdp_credentials()
|
|
-- niemals das Passwort selbst, siehe Konzept 6.4."""
|
|
conn = get_db()
|
|
cursor = await conn.execute(
|
|
"SELECT spc.id, spc.label, spc.username, spc.created_at, spc.rotated_at "
|
|
"FROM ssh_password_credentials spc ORDER BY spc.id"
|
|
)
|
|
rows = await cursor.fetchall()
|
|
hosts_cursor = await conn.execute(
|
|
"SELECT m.ssh_password_credential_id, h.id, h.hostname FROM host_ssh_password_credential_map m "
|
|
"JOIN hosts h ON h.id = m.host_id"
|
|
)
|
|
assigned: dict[int, list[dict]] = {}
|
|
for cred_id, host_id_, hostname in await hosts_cursor.fetchall():
|
|
assigned.setdefault(cred_id, []).append({"id": host_id_, "hostname": hostname})
|
|
return [
|
|
{
|
|
"id": r[0], "label": r[1], "username": r[2],
|
|
"created_at": r[3], "rotated_at": r[4],
|
|
"assigned_hosts": assigned.get(r[0], []),
|
|
}
|
|
for r in rows
|
|
]
|
|
|
|
|
|
@router.get("/hosts/{host_id}/credentials")
|
|
async def get_host_credentials(
|
|
host_id: int,
|
|
admin: CurrentUser = Depends(
|
|
require_admin_scope_or_host_role("hosts", "read", ("credentials_view", "credentials_manage"))
|
|
),
|
|
):
|
|
"""Zugangsdaten-Uebersicht fuer EINEN Host (zugeordnete SSH-Keys, ob ein
|
|
RDP-Passwort gesetzt ist -- niemals der Klartext selbst, siehe Konzept
|
|
6.4). Im Unterschied zu GET /admin/hosts/{id} (voller Datensatz,
|
|
admin-only) ist dieser schlanke Endpunkt bewusst auch fuer Nicht-Admins
|
|
mit Rolle 'credentials_view'/'credentials_manage' erreichbar (RBAC-
|
|
Erweiterung 'Credentials ins RBAC-Modell')."""
|
|
conn = get_db()
|
|
await _assert_host_in_scope(conn, host_id)
|
|
keys_cursor = await conn.execute(
|
|
"SELECT sk.id, sk.label, sk.username FROM host_ssh_key_map m "
|
|
"JOIN ssh_keys sk ON sk.id = m.ssh_key_id WHERE m.host_id = ?",
|
|
(host_id,),
|
|
)
|
|
ssh_keys = [{"id": k[0], "label": k[1], "username": k[2]} for k in await keys_cursor.fetchall()]
|
|
rdp_row = await (await conn.execute(
|
|
"SELECT rc.id, rc.label, rc.username, rc.domain, COALESCE(rc.rotated_at, rc.created_at) "
|
|
"FROM host_rdp_credential_map m JOIN rdp_credentials rc ON rc.id = m.rdp_credential_id "
|
|
"WHERE m.host_id = ?",
|
|
(host_id,),
|
|
)).fetchone()
|
|
ssh_pw_row = await (await conn.execute(
|
|
"SELECT COALESCE(spc.rotated_at, spc.created_at), spc.username "
|
|
"FROM host_ssh_password_credential_map m "
|
|
"JOIN ssh_password_credentials spc ON spc.id = m.ssh_password_credential_id "
|
|
"WHERE m.host_id = ?",
|
|
(host_id,),
|
|
)).fetchone()
|
|
return {
|
|
"host_id": host_id, "ssh_keys": ssh_keys,
|
|
"rdp_credentials_set": rdp_row is not None,
|
|
"rdp_credentials_id": rdp_row[0] if rdp_row else None,
|
|
"rdp_credentials_label": rdp_row[1] if rdp_row else None,
|
|
"rdp_credentials_username": rdp_row[2] if rdp_row else None,
|
|
"rdp_credentials_domain": rdp_row[3] if rdp_row else None,
|
|
"rdp_credentials_updated_at": rdp_row[4] if rdp_row else None,
|
|
"ssh_password_credentials_set": ssh_pw_row is not None,
|
|
"ssh_password_credentials_updated_at": ssh_pw_row[0] if ssh_pw_row else None,
|
|
"ssh_password_credentials_username": ssh_pw_row[1] if ssh_pw_row else None,
|
|
}
|
|
|
|
|
|
# --- RDP-Zugangsdatenverwaltung (Migration 0012) ------------------------------
|
|
#
|
|
# Eigenstaendige, wiederverwendbare Objekte -- strukturell und in der
|
|
# Endpunktaufteilung bewusst identisch zur SSH-Keyverwaltung weiter unten
|
|
# (create/list/update/delete + Zuordnung/Entfernung am Host), damit sich
|
|
# beide Zugangsdaten-Arten im Reiter "Zugangsdaten" gleich bedienen.
|
|
|
|
@router.post("/rdp-credentials", status_code=status.HTTP_201_CREATED)
|
|
async def create_rdp_credential(
|
|
payload: RdpCredentialCreateRequest, request: Request,
|
|
admin: CurrentUser = Depends(require_admin_or_scope("rdp_credentials", "write")),
|
|
):
|
|
conn = get_db()
|
|
encrypted = encrypt_secret(payload.password.encode(), associated_data=b"rdp_password")
|
|
domain = (payload.domain or "").strip() or None
|
|
cursor = await conn.execute(
|
|
"INSERT INTO rdp_credentials (label, username, domain, password_enc) VALUES (?, ?, ?, ?)",
|
|
(payload.label, payload.username.strip(), domain, encrypted),
|
|
)
|
|
new_id = cursor.lastrowid
|
|
await write_audit_event(
|
|
conn, event_type="rdp_credential_created", user_id=admin.id, client_ip=_client_ip(request),
|
|
details={"id": new_id, "label": payload.label, "username": payload.username, "domain": domain},
|
|
)
|
|
await conn.commit()
|
|
return {"id": new_id, "label": payload.label}
|
|
|
|
|
|
@router.get("/rdp-credentials")
|
|
async def list_rdp_credentials(admin: CurrentUser = Depends(require_admin_or_scope("rdp_credentials", "read"))):
|
|
"""Alle RDP-Zugangsdaten-Objekte fuer den 'Zugangsdaten'-Tab, inklusive
|
|
der Hosts, denen das jeweilige Objekt aktuell zugewiesen ist -- ein
|
|
Objekt kann mehreren Hosts zugewiesen sein (siehe Migration 0012)."""
|
|
conn = get_db()
|
|
cursor = await conn.execute(
|
|
"SELECT rc.id, rc.label, rc.username, rc.domain, rc.created_at, rc.rotated_at "
|
|
"FROM rdp_credentials rc ORDER BY rc.id"
|
|
)
|
|
rows = await cursor.fetchall()
|
|
hosts_cursor = await conn.execute(
|
|
"SELECT m.rdp_credential_id, h.id, h.hostname FROM host_rdp_credential_map m "
|
|
"JOIN hosts h ON h.id = m.host_id"
|
|
)
|
|
assigned: dict[int, list[dict]] = {}
|
|
for cred_id, host_id, hostname in await hosts_cursor.fetchall():
|
|
assigned.setdefault(cred_id, []).append({"id": host_id, "hostname": hostname})
|
|
return [
|
|
{
|
|
"id": r[0], "label": r[1], "username": r[2], "domain": r[3],
|
|
"created_at": r[4], "rotated_at": r[5],
|
|
"assigned_hosts": assigned.get(r[0], []),
|
|
}
|
|
for r in rows
|
|
]
|
|
|
|
|
|
async def _assert_rdp_credential_in_scope(conn, credential_id: int) -> None:
|
|
"""Reine Existenzpruefung, siehe _assert_user_in_scope."""
|
|
row = await (
|
|
await conn.execute("SELECT 1 FROM rdp_credentials WHERE id = ?", (credential_id,))
|
|
).fetchone()
|
|
if row is None:
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "RDP-Zugangsdaten nicht gefunden")
|
|
|
|
|
|
@router.put("/rdp-credentials/{credential_id}")
|
|
async def update_rdp_credential(
|
|
credential_id: int, payload: RdpCredentialUpdateRequest, request: Request,
|
|
admin: CurrentUser = Depends(require_admin_or_scope("rdp_credentials", "write")),
|
|
):
|
|
conn = get_db()
|
|
await _assert_rdp_credential_in_scope(conn, credential_id)
|
|
|
|
fields, values = [], []
|
|
if payload.label is not None:
|
|
fields.append("label = ?"); values.append(payload.label)
|
|
if payload.username is not None:
|
|
fields.append("username = ?"); values.append(payload.username.strip())
|
|
if "domain" in payload.model_fields_set:
|
|
fields.append("domain = ?"); values.append((payload.domain or "").strip() or None)
|
|
rotating = payload.password is not None
|
|
if rotating:
|
|
fields += ["password_enc = ?", "rotated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')"]
|
|
values.append(encrypt_secret(payload.password.encode(), associated_data=b"rdp_password"))
|
|
if not fields:
|
|
return {"status": "ok", "changed": False}
|
|
values.append(credential_id)
|
|
await conn.execute(f"UPDATE rdp_credentials SET {', '.join(fields)} WHERE id = ?", values)
|
|
await write_audit_event(
|
|
conn, event_type="rdp_credential_updated", user_id=admin.id, client_ip=_client_ip(request),
|
|
details={
|
|
"id": credential_id, "rotated": rotating,
|
|
"label_changed": payload.label is not None,
|
|
"username_changed": payload.username is not None,
|
|
"domain_changed": "domain" in payload.model_fields_set,
|
|
},
|
|
)
|
|
await conn.commit()
|
|
return {"status": "ok", "changed": True}
|
|
|
|
|
|
@router.delete("/rdp-credentials/{credential_id}")
|
|
async def delete_rdp_credential(
|
|
credential_id: int, request: Request,
|
|
admin: CurrentUser = Depends(require_admin_or_scope("rdp_credentials", "write")),
|
|
):
|
|
"""host_rdp_credential_map verweist bewusst OHNE ON DELETE auf
|
|
rdp_credentials(id) -- Zuordnungen werden hier explizit mit entfernt
|
|
(samt Vermerk, welche Hosts betroffen waren), analog delete_ssh_key."""
|
|
conn = get_db()
|
|
await _assert_rdp_credential_in_scope(conn, credential_id)
|
|
affected = await (await conn.execute(
|
|
"SELECT host_id FROM host_rdp_credential_map WHERE rdp_credential_id = ?", (credential_id,)
|
|
)).fetchall()
|
|
await conn.execute("DELETE FROM host_rdp_credential_map WHERE rdp_credential_id = ?", (credential_id,))
|
|
await conn.execute("DELETE FROM rdp_credentials WHERE id = ?", (credential_id,))
|
|
await write_audit_event(
|
|
conn, event_type="rdp_credential_deleted", user_id=admin.id, client_ip=_client_ip(request),
|
|
details={"id": credential_id, "unmapped_host_ids": [r[0] for r in affected]},
|
|
)
|
|
await conn.commit()
|
|
return {"status": "ok", "unmapped_host_ids": [r[0] for r in affected]}
|
|
|
|
|
|
# --- Rollenvergabe (an einzelne User) -----------------------------------------
|
|
|
|
async def _role_id(conn, role_name: str) -> int:
|
|
row = await (await conn.execute("SELECT id FROM roles WHERE name = ?", (role_name,))).fetchone()
|
|
if row is None:
|
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Unbekannte Rolle")
|
|
return row[0]
|
|
|
|
|
|
@router.post("/roles/grant")
|
|
async def grant_role(
|
|
admin: CurrentUser = Depends(require_admin_or_scope("roles", "write")),
|
|
):
|
|
"""Teil D Schritt 5 (Umsetzungsauftrag_Sonnet5.md D.5): Direktvergabe
|
|
entfaellt ersatzlos -- Rechte werden ausschliesslich noch ueber
|
|
Benutzergruppen vergeben (POST /admin/group-roles/grant). Bewusst HTTP
|
|
410 statt 404, damit bestehende Token-Integrationen einen sprechenden
|
|
Fehler statt eines mehrdeutigen "nicht gefunden" bekommen. Die
|
|
Auth-Dependency bleibt aktiv (kein anonymer Zugriff auf diesen
|
|
Endpunktnamen), der Request-Body wird bewusst NICHT mehr als
|
|
RoleGrantRequest geparst -- ein alter Client soll den 410 sehen, keinen
|
|
422 wegen eines inzwischen irrelevanten Validierungsdetails."""
|
|
raise HTTPException(
|
|
status.HTTP_410_GONE,
|
|
"Direktvergabe von Rollen an einzelne Benutzer ist entfallen (Teil D). "
|
|
"Rechte werden ausschliesslich ueber Benutzergruppen vergeben, siehe "
|
|
"POST /admin/group-roles/grant.",
|
|
)
|
|
|
|
|
|
@router.post("/roles/revoke")
|
|
async def revoke_role(
|
|
admin: CurrentUser = Depends(require_admin_or_scope("roles", "write")),
|
|
):
|
|
"""Siehe grant_role() -- derselbe Rueckbau, spiegelbildlich fuer den
|
|
Entzug."""
|
|
raise HTTPException(
|
|
status.HTTP_410_GONE,
|
|
"Direktvergabe von Rollen an einzelne Benutzer ist entfallen (Teil D). "
|
|
"Rechte werden ausschliesslich ueber Benutzergruppen vergeben, siehe "
|
|
"POST /admin/group-roles/revoke.",
|
|
)
|
|
|
|
|
|
@router.get("/roles")
|
|
async def list_role_grants(admin: CurrentUser = Depends(require_admin_or_scope("roles", "read"))):
|
|
"""Teil D Schritt 5 (S1, S9): KEINE Vergabe-Tabelle mehr -- eine
|
|
ABGELEITETE Sicht auf die effektiven Rechte je (aktivem) Benutzer,
|
|
berechnet aus group_hostgroup_roles + user_group_members. Nicht
|
|
editierbar (kein Revoke-Button im UI mehr dafuer vorgesehen): Vergabe
|
|
und Entzug laufen ausschliesslich noch ueber /admin/group-roles/*, auf
|
|
Gruppenebene. Eine Zeile pro (Benutzer, Hostgruppe, Rolle, Gruppe-die-
|
|
sie-vermittelt) -- ist ein Benutzer ueber mehrere Gruppen fuer dieselbe
|
|
Rolle/Hostgruppe berechtigt, erscheinen entsprechend mehrere Zeilen mit
|
|
unterschiedlichem `via_group_name`; das macht S1 sichtbar (vorher war
|
|
nirgends erkennbar, WARUM ein Entzug wirkungslos blieb, weil eine
|
|
zweite Gruppe dasselbe Recht weiterhin lieferte). `deleted_at IS NULL`
|
|
filtert Karteileichen aus (S9) -- ein soft-geloeschter Benutzer taucht
|
|
hier nicht mehr auf, obwohl seine Mitgliedschafts-/Vergabezeilen
|
|
technisch noch existieren koennen."""
|
|
conn = get_db()
|
|
cursor = await conn.execute(
|
|
"""
|
|
SELECT u.id, u.username, hg.id, hg.name, r.name, ug.id, ug.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 user_group_members ugm ON ugm.user_group_id = ug.id
|
|
JOIN users u ON u.id = ugm.user_id AND u.deleted_at IS NULL
|
|
JOIN host_groups hg ON hg.id = ghr.host_group_id
|
|
JOIN roles r ON r.id = ghr.role_id
|
|
WHERE (ghr.expires_at IS NULL OR ghr.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
|
ORDER BY u.username, hg.name, r.name, ug.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], "via_group_id": r[5], "via_group_name": r[6],
|
|
"granted_by": r[7], "granted_at": r[8], "expires_at": r[9],
|
|
}
|
|
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()
|
|
await _assert_host_group_in_scope(conn, payload.host_group_id)
|
|
await _assert_user_group_in_scope(conn, payload.user_group_id)
|
|
|
|
granted = []
|
|
for role_name in payload.role_names:
|
|
role_id = await _role_id(conn, role_name)
|
|
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_id, admin.id, payload.expires_at),
|
|
)
|
|
granted.append(role_name)
|
|
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,
|
|
"roles": granted, "expires_at": payload.expires_at,
|
|
},
|
|
)
|
|
await conn.commit()
|
|
return {"status": "ok", "roles_granted": granted}
|
|
|
|
|
|
@router.post("/group-roles/revoke")
|
|
async def revoke_group_role(
|
|
payload: GroupRoleRevokeRequest, request: Request,
|
|
admin: CurrentUser = Depends(require_admin_or_scope("roles", "write")),
|
|
):
|
|
"""S12 (Umsetzungsauftrag_Sonnet5.md Teil D.2): siehe revoke_role -- auch
|
|
hier fehlte die Existenzpruefung der Benutzergruppe, die grant_group_role
|
|
bereits hat."""
|
|
conn = get_db()
|
|
await _assert_host_group_in_scope(conn, payload.host_group_id)
|
|
await _assert_user_group_in_scope(conn, payload.user_group_id)
|
|
role_id = await _role_id(conn, payload.role_name)
|
|
|
|
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_id),
|
|
)
|
|
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
|
|
]
|
|
|
|
|
|
@router.get("/roles/names")
|
|
async def list_role_names(admin: CurrentUser = Depends(require_admin_or_scope("roles", "read"))):
|
|
conn = get_db()
|
|
cursor = await conn.execute("SELECT name FROM roles ORDER BY id")
|
|
return [r[0] for r in await cursor.fetchall()]
|
|
|
|
|
|
# --- Zugangsdaten-Freigabe an Benutzergruppen (Achse B) -----------------------
|
|
# Teil D Schritt 5 (Umsetzungsauftrag_Sonnet5.md D.4/D.5): Freigabe, Entzug
|
|
# und Lesen der drei group_*_grants-Tabellen (Migration 0018) je
|
|
# Benutzergruppe -- Gegenstueck zu /admin/group-roles/* (Achse A), nur fuer
|
|
# "WOMIT darf sich die Gruppe anmelden" statt "WORAUF darf sie zugreifen".
|
|
# Ein einziges Endpunkt-Trio bedient alle drei Credential-Arten ueber den
|
|
# {kind}-Pfadparameter statt drei fast identischer Kopien -- dieselbe
|
|
# Ueberlegung wie bei app.rbac._GRANT_TABLE/_MAP_TABLE (siehe dort).
|
|
|
|
_CREDENTIAL_KIND_TABLES: dict[CredentialKind, tuple[str, str, str]] = {
|
|
# kind -> (Freigabetabelle, ID-Spalte, Tabelle fuer Existenzpruefung)
|
|
"ssh_key": ("group_ssh_key_grants", "ssh_key_id", "ssh_keys"),
|
|
"rdp_credential": ("group_rdp_credential_grants", "rdp_credential_id", "rdp_credentials"),
|
|
"ssh_password_credential": (
|
|
"group_ssh_password_credential_grants", "ssh_password_credential_id", "ssh_password_credentials",
|
|
),
|
|
}
|
|
|
|
|
|
async def _assert_credential_exists(conn, kind: CredentialKind, credential_id: int) -> None:
|
|
"""Reine Existenzpruefung des Zugangsdaten-OBJEKTS selbst (nicht zu
|
|
verwechseln mit der Achse-B-Freigabepruefung user_can_use_credential() in
|
|
app/rbac.py) -- hier geht es nur darum, dass credential_id ueberhaupt auf
|
|
etwas Vorhandenes zeigt, bevor eine Gruppe dafuer freigeschaltet wird."""
|
|
_, _, table = _CREDENTIAL_KIND_TABLES[kind]
|
|
row = await (await conn.execute(f"SELECT 1 FROM {table} WHERE id = ?", (credential_id,))).fetchone()
|
|
if row is None:
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Zugangsdatensatz nicht gefunden")
|
|
|
|
|
|
async def _group_effective_rights_summary(conn, group_id: int) -> dict:
|
|
"""Fuer S10: fasst zusammen, welche Rechte eine Mitgliedschaft in dieser
|
|
Gruppe JETZT vermittelt -- Achse A (Rollen je Hostgruppe, nur nicht
|
|
abgelaufene Eintraege) und Achse B (Anzahl freigegebener Zugangsdaten je
|
|
Art). Wird sowohl bei add_group_member() (gained_rights) als auch bei
|
|
remove_group_member() (lost_rights) verwendet -- dieselbe Abfrage, da das
|
|
Ergebnis nur von der Gruppe abhaengt, nicht vom Zeitpunkt der
|
|
Mitgliedschaftsaenderung."""
|
|
roles_cursor = await conn.execute(
|
|
"""
|
|
SELECT hg.name, r.name FROM group_hostgroup_roles ghr
|
|
JOIN host_groups hg ON hg.id = ghr.host_group_id
|
|
JOIN roles r ON r.id = ghr.role_id
|
|
WHERE ghr.user_group_id = ?
|
|
AND (ghr.expires_at IS NULL OR ghr.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
|
ORDER BY hg.name, r.name
|
|
""",
|
|
(group_id,),
|
|
)
|
|
roles = [{"host_group": r[0], "role": r[1]} for r in await roles_cursor.fetchall()]
|
|
|
|
credential_counts: dict[str, int] = {}
|
|
for kind, (table, _col, _existence_table) in _CREDENTIAL_KIND_TABLES.items():
|
|
count_cursor = await conn.execute(
|
|
f"SELECT COUNT(*) FROM {table} WHERE user_group_id = ? "
|
|
"AND (expires_at IS NULL OR expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now'))",
|
|
(group_id,),
|
|
)
|
|
credential_counts[kind] = (await count_cursor.fetchone())[0]
|
|
|
|
return {"roles": roles, "credential_counts": credential_counts}
|
|
|
|
|
|
@router.post("/group-credentials/{kind}/grant")
|
|
async def grant_group_credential(
|
|
kind: CredentialKind, payload: GroupCredentialGrantRequest, request: Request,
|
|
admin: CurrentUser = Depends(require_admin_or_scope("roles", "write")),
|
|
):
|
|
conn = get_db()
|
|
await _assert_user_group_in_scope(conn, payload.user_group_id)
|
|
await _assert_credential_exists(conn, kind, payload.credential_id)
|
|
grant_table, col, _ = _CREDENTIAL_KIND_TABLES[kind]
|
|
await conn.execute(
|
|
f"INSERT OR REPLACE INTO {grant_table} (user_group_id, {col}, granted_by, expires_at) "
|
|
"VALUES (?, ?, ?, ?)",
|
|
(payload.user_group_id, payload.credential_id, admin.id, payload.expires_at),
|
|
)
|
|
await write_audit_event(
|
|
conn, event_type="group_credential_granted", user_id=admin.id, client_ip=_client_ip(request),
|
|
details={
|
|
"kind": kind, "user_group_id": payload.user_group_id,
|
|
"credential_id": payload.credential_id, "expires_at": payload.expires_at,
|
|
},
|
|
)
|
|
await conn.commit()
|
|
return {"status": "ok"}
|
|
|
|
|
|
@router.post("/group-credentials/{kind}/revoke")
|
|
async def revoke_group_credential(
|
|
kind: CredentialKind, payload: GroupCredentialRevokeRequest, request: Request,
|
|
admin: CurrentUser = Depends(require_admin_or_scope("roles", "write")),
|
|
):
|
|
conn = get_db()
|
|
await _assert_user_group_in_scope(conn, payload.user_group_id)
|
|
grant_table, col, _ = _CREDENTIAL_KIND_TABLES[kind]
|
|
await conn.execute(
|
|
f"DELETE FROM {grant_table} WHERE user_group_id = ? AND {col} = ?",
|
|
(payload.user_group_id, payload.credential_id),
|
|
)
|
|
await write_audit_event(
|
|
conn, event_type="group_credential_revoked", user_id=admin.id, client_ip=_client_ip(request),
|
|
details={"kind": kind, "user_group_id": payload.user_group_id, "credential_id": payload.credential_id},
|
|
)
|
|
await conn.commit()
|
|
return {"status": "ok"}
|
|
|
|
|
|
@router.get("/group-credentials/{kind}")
|
|
async def list_group_credential_grants(
|
|
kind: CredentialKind, admin: CurrentUser = Depends(require_admin_or_scope("roles", "read")),
|
|
):
|
|
conn = get_db()
|
|
grant_table, col, _ = _CREDENTIAL_KIND_TABLES[kind]
|
|
cursor = await conn.execute(
|
|
f"""
|
|
SELECT ug.id, ug.name, g.{col}, g.granted_by, g.granted_at, g.expires_at
|
|
FROM {grant_table} g
|
|
JOIN user_groups ug ON ug.id = g.user_group_id
|
|
ORDER BY ug.name, g.{col}
|
|
"""
|
|
)
|
|
rows = await cursor.fetchall()
|
|
return [
|
|
{
|
|
"user_group_id": r[0], "user_group_name": r[1], "credential_id": r[2],
|
|
"granted_by": r[3], "granted_at": r[4], "expires_at": r[5],
|
|
}
|
|
for r in rows
|
|
]
|
|
|
|
|
|
# --- Konsolidierungs-Reports (Teil D.6 Schritt 6) ------------------------------
|
|
# Umsetzungsauftrag_Sonnet5.md D.4 Punkt 6 / D.6 Schritt 6: "Report ueber
|
|
# persoenliche Gruppen, Zusammenlegung zu Teams, restriktives Aufraeumen der
|
|
# Achse-B-Vorbefuellung". Beide Endpunkte hier sind bewusst REIN LESEND --
|
|
# weder die Zusammenlegung persoenlicher Gruppen zu Teams noch das
|
|
# restriktive Entziehen von Achse-B-Vorbefuellung wird automatisiert:
|
|
# fuer beides fehlen dem System echte Nutzungsdaten (wer benutzt welches
|
|
# Credential/welche Rolle tatsaechlich), ein automatischer Eingriff waere
|
|
# blindes Raten mit echtem Rechteverlust-Risiko. Die D.7-Risikotabelle
|
|
# sieht genau deshalb ausdruecklich vor, das als Aufraeum-Backlog zu
|
|
# DOKUMENTIEREN (siehe FORTSETZUNG_Teil_D.md) statt es hier automatisch
|
|
# durchzufuehren -- diese beiden Reports sind das Werkzeug fuer die
|
|
# bewusste, manuelle Admin-Entscheidung.
|
|
|
|
async def _personal_group_rights_fingerprint(conn, group_id: int) -> str:
|
|
"""Kanonischer String aus allen Rechten einer Gruppe (Achse A + B),
|
|
sortiert und zusammengesetzt -- zwei Gruppen mit identischem
|
|
Fingerprint haben exakt dasselbe Rechteprofil (nicht nur dieselbe
|
|
ANZAHL an Rechten)."""
|
|
roles_cursor = await conn.execute(
|
|
"SELECT host_group_id, role_id, expires_at FROM group_hostgroup_roles "
|
|
"WHERE user_group_id = ? ORDER BY host_group_id, role_id, expires_at",
|
|
(group_id,),
|
|
)
|
|
roles_part = "|".join(f"A:{r[0]}:{r[1]}:{r[2] or ''}" for r in await roles_cursor.fetchall())
|
|
|
|
cred_parts = []
|
|
for kind, (table, col, _existence_table) in _CREDENTIAL_KIND_TABLES.items():
|
|
cred_cursor = await conn.execute(
|
|
f"SELECT {col}, expires_at FROM {table} WHERE user_group_id = ? ORDER BY {col}, expires_at",
|
|
(group_id,),
|
|
)
|
|
cred_parts.append(f"{kind}:" + ",".join(f"{r[0]}:{r[1] or ''}" for r in await cred_cursor.fetchall()))
|
|
|
|
return roles_part + "||" + "||".join(cred_parts)
|
|
|
|
|
|
@router.get("/reports/personal-groups")
|
|
async def report_personal_groups(admin: CurrentUser = Depends(require_admin_or_scope("roles", "read"))):
|
|
"""Report 1: gruppiert alle persoenlichen Gruppen (is_personal=1, aus
|
|
Migration 0017) nach identischem Rechteprofil -- ein Cluster mit mehr
|
|
als einer Gruppe ist ein Konsolidierungs-Kandidat: dieselben Rechte
|
|
liessen sich als EINE echte Team-Gruppe abbilden statt als mehrere
|
|
Einzelgruppen. Fuehrt selbst KEINE Zusammenlegung durch (das aendert
|
|
Mitgliedschaften und Vergaben und ist eine bewusste Admin-
|
|
Entscheidung -- ueber die bestehenden Gruppen-/Rollen-Endpunkte)."""
|
|
conn = get_db()
|
|
groups_cursor = await conn.execute(
|
|
"SELECT id, name FROM user_groups WHERE is_personal = 1 ORDER BY id"
|
|
)
|
|
groups = await groups_cursor.fetchall()
|
|
|
|
clusters: dict[str, list[dict]] = {}
|
|
for group_id, group_name in groups:
|
|
member_cursor = await conn.execute(
|
|
"SELECT u.id, u.username FROM user_group_members ugm "
|
|
"JOIN users u ON u.id = ugm.user_id WHERE ugm.user_group_id = ?",
|
|
(group_id,),
|
|
)
|
|
member = await member_cursor.fetchone()
|
|
fingerprint = await _personal_group_rights_fingerprint(conn, group_id)
|
|
clusters.setdefault(fingerprint, []).append({
|
|
"user_group_id": group_id,
|
|
"user_group_name": group_name,
|
|
"member_user_id": member[0] if member else None,
|
|
"member_username": member[1] if member else None,
|
|
})
|
|
|
|
return [
|
|
{
|
|
"group_count": len(members),
|
|
"consolidation_candidate": len(members) > 1,
|
|
"members": members,
|
|
}
|
|
for members in sorted(clusters.values(), key=lambda ms: (-len(ms), ms[0]["user_group_name"]))
|
|
]
|
|
|
|
|
|
@router.get("/reports/unconfirmed-credential-grants")
|
|
async def report_unconfirmed_credential_grants(
|
|
admin: CurrentUser = Depends(require_admin_or_scope("roles", "read")),
|
|
):
|
|
"""Report 2 (D.7 "Rechteausweitung durch die Achse-B-Vorbefuellung"):
|
|
listet alle Achse-B-Freigaben, die aus der rechteneutralen Vorbefuellung
|
|
in Migration 0018 stammen und seither von KEINEM Admin ueber POST
|
|
/admin/group-credentials/{kind}/grant bestaetigt wurden -- erkennbar an
|
|
granted_by IS NULL (die Vorbefuellung setzt bewusst NULL, ein manueller
|
|
Grant immer admin.id, siehe grant_group_credential()). Das ist die
|
|
konkrete, sichere erste Stufe des "restriktiven Aufraeumens": OHNE echte
|
|
Nutzungsdaten waere ein automatisches Entziehen dieser Zeilen blindes
|
|
Raten mit Rechteverlust-Risiko -- deshalb bewusst nur Report, kein
|
|
Auto-Entzug. Ein Admin prueft Zeile fuer Zeile und entzieht bei Bedarf
|
|
ueber POST /admin/group-credentials/{kind}/revoke."""
|
|
conn = get_db()
|
|
rows_out: list[dict] = []
|
|
for kind, (table, col, _existence_table) in _CREDENTIAL_KIND_TABLES.items():
|
|
cursor = await conn.execute(
|
|
f"""
|
|
SELECT ug.id, ug.name, g.{col}, g.granted_at, g.expires_at
|
|
FROM {table} g JOIN user_groups ug ON ug.id = g.user_group_id
|
|
WHERE g.granted_by IS NULL
|
|
ORDER BY ug.name, g.{col}
|
|
"""
|
|
)
|
|
for r in await cursor.fetchall():
|
|
rows_out.append({
|
|
"kind": kind, "user_group_id": r[0], "user_group_name": r[1],
|
|
"credential_id": r[2], "granted_at": r[3], "expires_at": r[4],
|
|
})
|
|
return rows_out
|
|
|
|
|
|
# --- SSH-Keyverwaltung ---------------------------------------------------------
|
|
|
|
def _validate_private_key(pem: str, passphrase: str | None) -> None:
|
|
"""Prueft, ob asyncssh das Material mit der angegebenen Passphrase laden
|
|
kann, und uebersetzt einen Fehlschlag in HTTP 400 mit Klartextmeldung.
|
|
|
|
Der importierte Schluessel wird bewusst NICHT zurueckgegeben und nicht
|
|
weiterverwendet -- er dient nur der Pruefung und wird sofort wieder
|
|
verworfen (Konzept 6.4: Schluesselmaterial so kurz wie moeglich im
|
|
Prozessspeicher)."""
|
|
try:
|
|
import_private_key_material(pem, passphrase)
|
|
except PrivateKeyUnusableError as exc:
|
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
|
|
|
|
|
|
@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()
|
|
# Schluessel SOFORT gegen asyncssh pruefen (inkl. Passphrase), statt einen
|
|
# unbrauchbaren Schluessel entgegenzunehmen und den Fehler erst beim
|
|
# ersten Verbindungsversuch eines Benutzers auftauchen zu lassen -- genau
|
|
# so blieb ein passphrasegeschuetzter Schluessel bisher unbemerkt, bis
|
|
# die Sitzung im Betrieb abbrach.
|
|
_validate_private_key(payload.private_key_pem, payload.passphrase)
|
|
encrypted = encrypt_secret(payload.private_key_pem.encode(), associated_data=b"ssh_private_key")
|
|
passphrase_enc = (
|
|
encrypt_secret(payload.passphrase.encode(), associated_data=b"ssh_key_passphrase")
|
|
if payload.passphrase
|
|
else None
|
|
)
|
|
cursor = await conn.execute(
|
|
"INSERT INTO ssh_keys (label, private_key_enc, public_key, key_type, "
|
|
"passphrase_enc, username) VALUES (?, ?, ?, ?, ?, ?)",
|
|
(payload.label, encrypted, payload.public_key, payload.key_type,
|
|
passphrase_enc, (payload.username or "").strip() or None),
|
|
)
|
|
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,
|
|
"has_passphrase": bool(payload.passphrase),
|
|
# Der Anmeldename ist kein Geheimnis und im Audit-Log ausdruecklich
|
|
# erwuenscht: er beantwortet "wer hat sich womit angemeldet".
|
|
"username": (payload.username or "").strip() or None,
|
|
},
|
|
)
|
|
await conn.commit()
|
|
return {"id": new_id, "label": payload.label}
|
|
|
|
|
|
@router.post("/ssh-keys/generate")
|
|
async def generate_ssh_key(
|
|
payload: SshKeyGenerateRequest,
|
|
admin: CurrentUser = Depends(require_admin_or_scope("ssh_keys", "write")),
|
|
):
|
|
"""Erzeugt ein neues Schluesselpaar serverseitig ('Schluessel automatisch
|
|
generieren'-Button) und gibt es EINMALIG zurueck -- es wird hier nichts
|
|
gespeichert, das passiert erst ueber den regulaeren POST /ssh-keys, wenn
|
|
der Admin das befuellte Formular tatsaechlich absendet (siehe
|
|
app/ssh_proxy/proxy.py::generate_key_material)."""
|
|
try:
|
|
private_key_pem, public_key = generate_key_material(payload.key_type)
|
|
except ValueError as exc:
|
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
|
|
return {"key_type": payload.key_type, "private_key_pem": private_key_pem, "public_key": public_key}
|
|
|
|
|
|
@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 sk.id, sk.label, sk.key_type, sk.created_at, sk.rotated_at, "
|
|
"sk.expires_at, (sk.passphrase_enc IS NOT NULL), sk.username "
|
|
"FROM ssh_keys sk ORDER BY sk.id"
|
|
)
|
|
rows = await cursor.fetchall()
|
|
return [
|
|
{
|
|
"id": r[0], "label": r[1], "key_type": r[2],
|
|
"created_at": r[3], "rotated_at": r[4], "expires_at": r[5],
|
|
# Nur die Tatsache, NIE die Passphrase selbst -- kein Endpunkt
|
|
# dieser Anwendung gibt jemals Klartext-Geheimnisse zurueck.
|
|
"has_passphrase": bool(r[6]),
|
|
# Anmeldename des Zielsystems (Migration 0010). Kein Geheimnis --
|
|
# er wird angezeigt, damit erkennbar ist, welcher Zugang das ist.
|
|
"username": r[7],
|
|
}
|
|
for r in rows
|
|
]
|
|
|
|
|
|
async def _assert_ssh_key_in_scope(conn, key_id: int) -> None:
|
|
"""Reine Existenzpruefung, siehe _assert_user_in_scope."""
|
|
row = await (await conn.execute("SELECT 1 FROM ssh_keys WHERE id = ?", (key_id,))).fetchone()
|
|
if row is None:
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "SSH-Key nicht gefunden")
|
|
|
|
|
|
@router.put("/ssh-keys/{key_id}")
|
|
async def update_ssh_key(
|
|
key_id: int, payload: SshKeyUpdateRequest, request: Request,
|
|
admin: CurrentUser = Depends(require_admin_or_scope("ssh_keys", "write")),
|
|
):
|
|
conn = get_db()
|
|
await _assert_ssh_key_in_scope(conn, key_id)
|
|
|
|
rotating = payload.private_key_pem is not None or payload.public_key is not None or payload.key_type is not None
|
|
if rotating and not (payload.private_key_pem and payload.public_key and payload.key_type):
|
|
raise HTTPException(
|
|
status.HTTP_400_BAD_REQUEST,
|
|
"Fuer eine Rotation muessen private_key_pem, public_key UND key_type gemeinsam angegeben werden",
|
|
)
|
|
|
|
# "passphrase" wird nur angefasst, wenn das Feld im Request tatsaechlich
|
|
# vorkommt -- ein Update von z.B. nur dem Label darf eine hinterlegte
|
|
# Passphrase nicht stillschweigend loeschen. Explizit uebergebenes null
|
|
# oder "" entfernt sie dagegen bewusst.
|
|
passphrase_given = "passphrase" in payload.model_fields_set
|
|
passphrase = payload.passphrase or None
|
|
|
|
if rotating:
|
|
# Neues Material immer sofort pruefen (inkl. der -- ggf. neuen --
|
|
# Passphrase), damit ein unbrauchbarer Schluessel gar nicht erst in
|
|
# die Datenbank kommt.
|
|
_validate_private_key(payload.private_key_pem, passphrase)
|
|
elif passphrase_given:
|
|
# Nur die Passphrase wird nachgetragen/geaendert: gegen das BEREITS
|
|
# gespeicherte Schluesselmaterial pruefen. Das ist der Weg, einen
|
|
# bereits hinterlegten, passphrasegeschuetzten Schluessel wieder
|
|
# benutzbar zu machen, ohne ihn neu hochzuladen.
|
|
row = await (
|
|
await conn.execute("SELECT private_key_enc FROM ssh_keys WHERE id = ?", (key_id,))
|
|
).fetchone()
|
|
if row is None:
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "SSH-Key nicht gefunden")
|
|
stored_pem = decrypt_secret(row[0], associated_data=b"ssh_private_key")
|
|
try:
|
|
_validate_private_key(stored_pem.decode(), passphrase)
|
|
finally:
|
|
del stored_pem
|
|
|
|
fields, values = [], []
|
|
if payload.label is not None:
|
|
fields.append("label = ?"); values.append(payload.label)
|
|
if "username" in payload.model_fields_set:
|
|
# Gleiche Semantik wie bei passphrase: Weglassen = unveraendert,
|
|
# explizites null/"" = entfernen.
|
|
fields.append("username = ?"); values.append((payload.username or "").strip() or None)
|
|
if rotating:
|
|
encrypted = encrypt_secret(payload.private_key_pem.encode(), associated_data=b"ssh_private_key")
|
|
fields += ["private_key_enc = ?", "public_key = ?", "key_type = ?",
|
|
"rotated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')"]
|
|
values += [encrypted, payload.public_key, payload.key_type]
|
|
if rotating or passphrase_given:
|
|
# Bei einer Rotation wird die Passphrase in JEDEM Fall neu gesetzt --
|
|
# eine zum alten Schluessel gehoerende Passphrase darf nicht am neuen
|
|
# Schluessel haengenbleiben.
|
|
fields.append("passphrase_enc = ?")
|
|
values.append(
|
|
encrypt_secret(passphrase.encode(), associated_data=b"ssh_key_passphrase")
|
|
if passphrase
|
|
else None
|
|
)
|
|
if not fields:
|
|
return {"status": "ok", "changed": False}
|
|
values.append(key_id)
|
|
await conn.execute(f"UPDATE ssh_keys SET {', '.join(fields)} WHERE id = ?", values)
|
|
await write_audit_event(
|
|
conn, event_type="ssh_key_updated", user_id=admin.id, client_ip=_client_ip(request),
|
|
details={
|
|
"id": key_id, "rotated": rotating,
|
|
"passphrase_changed": bool(rotating or passphrase_given),
|
|
"has_passphrase": bool(passphrase),
|
|
"username_changed": "username" in payload.model_fields_set,
|
|
},
|
|
)
|
|
await conn.commit()
|
|
return {"status": "ok", "changed": True}
|
|
|
|
|
|
@router.delete("/ssh-keys/{key_id}")
|
|
async def delete_ssh_key(
|
|
key_id: int, request: Request,
|
|
admin: CurrentUser = Depends(require_admin_or_scope("ssh_keys", "write")),
|
|
):
|
|
"""host_ssh_key_map verweist bewusst OHNE ON DELETE auf ssh_keys(id) --
|
|
Zuordnungen werden hier explizit mit entfernt (samt Vermerk, welche Hosts
|
|
betroffen waren) statt den Key unloeschbar zu machen."""
|
|
conn = get_db()
|
|
await _assert_ssh_key_in_scope(conn, key_id)
|
|
affected = await (await conn.execute(
|
|
"SELECT host_id FROM host_ssh_key_map WHERE ssh_key_id = ?", (key_id,)
|
|
)).fetchall()
|
|
await conn.execute("DELETE FROM host_ssh_key_map WHERE ssh_key_id = ?", (key_id,))
|
|
await conn.execute("DELETE FROM ssh_keys WHERE id = ?", (key_id,))
|
|
await write_audit_event(
|
|
conn, event_type="ssh_key_deleted", user_id=admin.id, client_ip=_client_ip(request),
|
|
details={"id": key_id, "unmapped_host_ids": [r[0] for r in affected]},
|
|
)
|
|
await conn.commit()
|
|
return {"status": "ok", "unmapped_host_ids": [r[0] for r in affected]}
|
|
|
|
|
|
@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_scope_or_host_role("ssh_keys", "write", ("credentials_manage",))
|
|
),
|
|
):
|
|
conn = get_db()
|
|
await _assert_host_in_scope(conn, host_id)
|
|
await _assert_ssh_key_in_scope(conn, key_id)
|
|
# Teil D Schritt 5 (S4): siehe Kommentar in assign_rdp_credential_to_host().
|
|
if not admin.is_admin and not admin.is_token:
|
|
if not await user_can_use_credential(
|
|
conn, user_id=admin.id, kind="ssh_key", credential_id=key_id
|
|
):
|
|
raise HTTPException(
|
|
status.HTTP_403_FORBIDDEN,
|
|
"Kein Zugriff auf diesen SSH-Schluessel (keine Ihrer Benutzergruppen "
|
|
"hat ihn freigegeben).",
|
|
)
|
|
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"}
|
|
|
|
|
|
@router.delete("/hosts/{host_id}/ssh-keys/{key_id}")
|
|
async def unmap_ssh_key_from_host(
|
|
host_id: int, key_id: int, request: Request,
|
|
admin: CurrentUser = Depends(
|
|
require_admin_scope_or_host_role("ssh_keys", "write", ("credentials_manage",))
|
|
),
|
|
):
|
|
conn = get_db()
|
|
await _assert_host_in_scope(conn, host_id)
|
|
await conn.execute(
|
|
"DELETE FROM host_ssh_key_map WHERE host_id = ? AND ssh_key_id = ?", (host_id, key_id)
|
|
)
|
|
await write_audit_event(
|
|
conn, event_type="ssh_key_unmapped", 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_admin_session (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_admin_session)):
|
|
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_admin_session)
|
|
):
|
|
try:
|
|
scopes = validate_scopes(payload.scopes)
|
|
except ValueError as exc:
|
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
|
|
|
|
conn = get_db()
|
|
await _assert_user_in_scope(conn, payload.user_id)
|
|
user_cursor = await conn.execute(
|
|
"SELECT 1 FROM users WHERE id = ? AND deleted_at IS NULL", (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_admin_session)):
|
|
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_admin_session)
|
|
):
|
|
conn = get_db()
|
|
row = await (await conn.execute("SELECT 1 FROM api_tokens WHERE id = ?", (token_id,))).fetchone()
|
|
if row is None:
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Token nicht gefunden")
|
|
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 GESAMTE 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}
|
|
|
|
|
|
# --- Sessionview (nur Admin) ---------------------------------------------
|
|
#
|
|
# Bewusst require_global_admin (Session, kein Token), da Sitzungsdaten
|
|
# (Client-IP, genutzter Host) hochsensibel sind. 'Beenden' nutzt
|
|
# app/security/active_sessions.py (siehe dort fuer die Prozess-lokale
|
|
# Registry und die genaue Beenden-Semantik per asyncio.Task.cancel()).
|
|
|
|
@router.get("/sessions")
|
|
async def list_sessions(
|
|
active_only: bool = False, limit: int = 200, offset: int = 0,
|
|
admin: CurrentUser = Depends(require_global_admin),
|
|
):
|
|
limit = max(1, min(limit, 1000))
|
|
conn = get_db()
|
|
where = "WHERE s.ended_at IS NULL" if active_only else ""
|
|
cursor = await conn.execute(
|
|
f"""
|
|
SELECT s.id, s.user_id, u.username, s.host_id, h.hostname, hg.name,
|
|
s.protocol, s.started_at, s.ended_at, s.client_ip, s.end_reason,
|
|
s.recording_path
|
|
FROM sessions s
|
|
JOIN users u ON u.id = s.user_id
|
|
JOIN hosts h ON h.id = s.host_id
|
|
JOIN host_groups hg ON hg.id = h.host_group_id
|
|
{where}
|
|
ORDER BY s.started_at DESC LIMIT ? OFFSET ?
|
|
""",
|
|
(limit, offset),
|
|
)
|
|
rows = await cursor.fetchall()
|
|
active_ids = active_sessions.all_ids()
|
|
result = []
|
|
for r in rows:
|
|
recording_path = r[11]
|
|
is_active = r[8] is None
|
|
killable = r[0] in active_ids
|
|
result.append({
|
|
"id": r[0], "user_id": r[1], "username": r[2], "host_id": r[3],
|
|
"hostname": r[4], "host_group_name": r[5], "protocol": r[6],
|
|
"started_at": r[7], "ended_at": r[8], "client_ip": r[9], "end_reason": r[10],
|
|
"is_active": is_active,
|
|
"killable": killable,
|
|
"has_recording": bool(recording_path) and Path(recording_path).exists(),
|
|
# Live-Mitschau (GET /ws/sessions/{id}/watch): wie 'killable' nur
|
|
# moeglich, wenn die Sitzung auf DIESEM Worker-Prozess laeuft --
|
|
# und bisher nur fuer SSH umgesetzt (RDP haette dafuer eine eigene
|
|
# Multiplexing-Loesung fuer den guacd-Binaer-Tunnel noetig).
|
|
"watchable": is_active and killable and r[6] == "ssh",
|
|
})
|
|
return result
|
|
|
|
|
|
@router.post("/sessions/{session_id}/terminate")
|
|
async def terminate_session(
|
|
session_id: int, request: Request, admin: CurrentUser = Depends(require_global_admin),
|
|
):
|
|
conn = get_db()
|
|
row = await (await conn.execute(
|
|
"SELECT ended_at, user_id, host_id FROM sessions WHERE id = ?", (session_id,)
|
|
)).fetchone()
|
|
if row is None:
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Sitzung nicht gefunden")
|
|
if row[0] is not None:
|
|
raise HTTPException(status.HTTP_409_CONFLICT, "Sitzung ist bereits beendet")
|
|
entry = active_sessions.get(session_id)
|
|
if entry is None:
|
|
raise HTTPException(
|
|
status.HTTP_409_CONFLICT,
|
|
"Sitzung laeuft nicht auf diesem Server-Prozess (evtl. anderer Worker) "
|
|
"und kann von hier aus nicht beendet werden",
|
|
)
|
|
entry.task.cancel()
|
|
await write_audit_event(
|
|
conn, event_type="session_terminated_by_admin", user_id=admin.id, client_ip=_client_ip(request),
|
|
details={"session_id": session_id, "target_user_id": row[1], "host_id": row[2]},
|
|
)
|
|
await conn.commit()
|
|
return {"status": "ok"}
|
|
|
|
|
|
@router.get("/sessions/{session_id}/recording")
|
|
async def get_session_recording(session_id: int, admin: CurrentUser = Depends(require_global_admin)):
|
|
"""Prueft und liefert die Hash-verkettete Sitzungsaufzeichnung (siehe
|
|
app/recordings/recorder.py) -- bewusst nur Metadaten + Integritaetsstatus,
|
|
kein vollstaendiger Tastatur-/Bildschirm-Dump in der Response (Konzept
|
|
6.5: Aufzeichnungen sind hochsensibel)."""
|
|
conn = get_db()
|
|
row = await (await conn.execute(
|
|
"SELECT recording_path FROM sessions WHERE id = ?", (session_id,)
|
|
)).fetchone()
|
|
if row is None or not row[0]:
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Keine Aufzeichnung fuer diese Sitzung vorhanden")
|
|
path = Path(row[0])
|
|
if not path.exists():
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Aufzeichnungsdatei nicht (mehr) vorhanden")
|
|
try:
|
|
# verify_recording_set/count_recording_entries beruecksichtigen auch
|
|
# rotierte Teildateien (session_<id>.jsonl, .jsonl.2, ... -- siehe
|
|
# app/recordings/recorder.py, Umsetzungsauftrag Teil A D2/Teil E E2).
|
|
verified = verify_recording_set(path)
|
|
entry_count = count_recording_entries(path)
|
|
except Exception:
|
|
logger.exception("Aufzeichnung %s konnte nicht gelesen/verifiziert werden", session_id)
|
|
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Aufzeichnung konnte nicht gelesen werden")
|
|
return {"session_id": session_id, "verified": verified, "entry_count": entry_count}
|
|
|
|
|
|
@router.get("/sessions/{session_id}/recording/entries")
|
|
async def get_session_recording_entries(
|
|
session_id: int, request: Request, admin: CurrentUser = Depends(require_global_admin)
|
|
):
|
|
"""Liefert den VOLLEN Inhalt einer Sitzungsaufzeichnung fuer die
|
|
Wiedergabe im Adminbereich (SSH: Terminal-Replay ueber xterm.js; RDP:
|
|
grafische Wiedergabe ueber Guacamole.SessionRecording -- siehe
|
|
static/js/admin.js). Im Unterschied zu GET .../recording (nur Metadaten
|
|
+ Integritaetsstatus) verlaesst hier der tatsaechliche Sitzungsinhalt
|
|
(Tastatureingaben bzw. Bildschirminhalt) den Server -- deshalb bewusst
|
|
NUR require_global_admin (echter Super-Admin, ausschliesslich per
|
|
Session, wie die uebrige Sessionview) UND ein eigener, prominenter
|
|
Audit-Log-Eintrag bei jedem Aufruf (Konzept 6.5: Aufzeichnungen sind
|
|
hochsensibel -- dieselbe bewusste 'wird jede Nutzung vermerkt'-Haltung
|
|
wie bei 'Host-Key ermitteln')."""
|
|
conn = get_db()
|
|
row = await (await conn.execute(
|
|
"SELECT recording_path, protocol FROM sessions WHERE id = ?", (session_id,)
|
|
)).fetchone()
|
|
if row is None or not row[0]:
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Keine Aufzeichnung fuer diese Sitzung vorhanden")
|
|
path = Path(row[0])
|
|
if not path.exists():
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Aufzeichnungsdatei nicht (mehr) vorhanden")
|
|
try:
|
|
verified = verify_recording_set(path)
|
|
except Exception:
|
|
logger.exception("Aufzeichnung %s konnte nicht gelesen/verifiziert werden", session_id)
|
|
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Aufzeichnung konnte nicht gelesen werden")
|
|
|
|
entries = []
|
|
try:
|
|
for entry in iter_recording_entries(path):
|
|
entries.append({"t": entry["t"], "dir": entry["dir"], "data": entry["data"]})
|
|
except (json.JSONDecodeError, KeyError) as exc:
|
|
logger.exception("Aufzeichnung %s konnte nicht geparst werden", session_id)
|
|
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Aufzeichnung konnte nicht gelesen werden") from exc
|
|
|
|
await write_audit_event(
|
|
conn, event_type="session_recording_viewed", user_id=admin.id, client_ip=_client_ip(request),
|
|
details={"session_id": session_id, "protocol": row[1], "entry_count": len(entries)},
|
|
)
|
|
await conn.commit()
|
|
return {"session_id": session_id, "protocol": row[1], "verified": verified, "entries": entries}
|
|
|
|
|
|
# Hinweis: Das Live-'Verbindungslog' (WS /ws/logs) liegt bewusst NICHT unter
|
|
# diesem /admin-Router, sondern als eigener Top-Level-Router in
|
|
# app/admin/log_ws.py -- siehe dort fuer den Grund (nginx-Reverse-Proxy-
|
|
# Location-Matching auf /ws/).
|