more admin stuff

This commit is contained in:
2026-08-20 14:49:19 +02:00
parent 91758a2701
commit 0e67092ba0
14 changed files with 3030 additions and 247 deletions

154
README.md
View File

@ -16,7 +16,7 @@ app/ Backend (FastAPI, Python 3.11+)
ssh_proxy/ SSH-Terminal-WebSocket + SFTP-Filetransfer ssh_proxy/ SSH-Terminal-WebSocket + SFTP-Filetransfer
rdp_proxy/ Guacamole-Protokoll-Tunnel zu guacd rdp_proxy/ Guacamole-Protokoll-Tunnel zu guacd
recordings/ Hash-verkettete Session-Aufzeichnung recordings/ Hash-verkettete Session-Aufzeichnung
db/ SQLite-Migrationen (0001-0005, laufen automatisch beim Start) db/ SQLite-Migrationen (0001-0007, laufen automatisch beim Start)
static/, templates/ Frontend (Vanilla JS, xterm.js, guacamole-common-js, Admin-Oberflaeche, API-Doku) static/, templates/ Frontend (Vanilla JS, xterm.js, guacamole-common-js, Admin-Oberflaeche, API-Doku)
scripts/ Betriebs-/Hilfsskripte (Admin anlegen, Assets bauen) scripts/ Betriebs-/Hilfsskripte (Admin anlegen, Assets bauen)
ansible/ Deployment (Rollen, systemd-Unit-Templates) ansible/ Deployment (Rollen, systemd-Unit-Templates)
@ -48,10 +48,10 @@ verwalten; ein manueller Umweg ueber die rohe JSON-API ist dafuer nicht mehr
noetig. Fuer RDP muss zusaetzlich ein laufender `guacd` erreichbar sein (siehe noetig. Fuer RDP muss zusaetzlich ein laufender `guacd` erreichbar sein (siehe
`JUMPHOST_GUACD_HOST`/`_PORT`). `JUMPHOST_GUACD_HOST`/`_PORT`).
Die SQLite-Migrationen 0004 (`user_groups`) und 0005 (`api_tokens`) werden Die SQLite-Migrationen 0004 (`user_groups`), 0005 (`api_tokens`), 0006
beim naechsten Start automatisch angewendet (`app/db.py`, `_apply_migrations`) (`tenants`) und 0007 (`crud_extras`) werden beim naechsten Start automatisch
— kein manueller Migrationsschritt noetig, auch nicht bei einem bestehenden angewendet (`app/db.py`, `_apply_migrations`) — kein manueller
Datenbestand. Migrationsschritt noetig, auch nicht bei einem bestehenden Datenbestand.
## Admin-Oberflaeche, Gruppen-RBAC, API-Tokens und API-Dokumentation ## Admin-Oberflaeche, Gruppen-RBAC, API-Tokens und API-Dokumentation
@ -105,13 +105,94 @@ rendert Endpunkte gruppiert nach Tag, ausschliesslich per
`document.createElement`/`textContent` (kein `innerHTML`, keine Inline- `document.createElement`/`textContent` (kein `innerHTML`, keine Inline-
Styles/-Scripts, siehe `tests/test_csp_compliance.py`). Styles/-Scripts, siehe `tests/test_csp_compliance.py`).
## Phase 8: Mandantenfaehigkeit, CRUD-Vervollstaendigung, Dateitransfer, Mehrfachrollen
Diese Session hat die Admin-Oberflaeche um sieben zusammenhaengende
Erweiterungen ergaenzt:
**1) Mandantenfaehigkeit ("volle Isolation + Mandanten-Admins", Migration
`0006_tenants.sql`)**: jede Hostgruppe, Benutzergruppe, jeder SSH-Key und
jedes API-Token gehoert zu genau einem Mandanten (`tenants`-Tabelle,
Standard-Mandant `id=1` "Standard" fuer bestehende Daten). Es gibt zwei
Admin-Stufen: **Super-Admin** (`users.is_admin=1`, unveraendert wie zuvor)
sieht/verwaltet **alle** Mandanten weiterhin vollstaendig — keine Regression
gegenueber Phase 7. **Mandanten-Admin** (neue `tenant_admins`-Zuordnung,
ein User kann Admin mehrerer Mandanten sein) sieht/verwaltet **ausschliesslich**
die Ressourcen seines/seiner Mandanten; jeder Zugriffsversuch auf eine
fremde Mandanten-ID liefert bewusst **404** (nicht 403, siehe
`app/tenancy.py`, `TenantScope`) — ein Mandanten-Admin soll aus der
Fehlerantwort nicht einmal ableiten koennen, dass eine ID ausserhalb seines
Mandanten ueberhaupt existiert. Tenant-CRUD und die Ernennung von
Mandanten-Admins sind ausschliesslich Super-Admin-Aktionen (neuer Tab
"Mandanten" in der Admin-Oberflaeche, fuer Mandanten-Admins nicht sichtbar).
Ein API-Token ist immer an **genau einen** Mandanten gebunden
(`api_tokens.tenant_id`) und kann diesen nie verlassen.
**2) CRUD-Vervollstaendigung**: jede Ressource, die die Admin-Oberflaeche
anlegen kann, ist jetzt auch bearbeitbar und loeschbar — Benutzer (inkl.
Passwort setzen, deaktivieren, reaktivieren), Benutzergruppen, Hostgruppen,
Hosts, SSH-Keys (inkl. Rotation). Zwei bewusste Sicherheits-/Integritaets-
Entscheidungen dabei: ein Benutzerkonto mit vorhandener Audit-Historie wird
**nicht** hart geloescht (die Hash-Chain verweist absichtlich ohne `ON DELETE
CASCADE` auf `users.id`, siehe Migration `0007_crud_extras.sql`), sondern
deaktiviert und anonymisiert (`deleted_user_<id>`, Passwort/TOTP geloescht);
nur ein Konto ganz ohne Audit-Spuren wird tatsaechlich entfernt — die
Response (`hard_deleted: true/false`) zeigt an, welcher Fall eintrat. Ein
Host wird per Default **soft-deleted** (`is_active=0`, wie schon zuvor vom
Katalog beruecksichtigt); `DELETE /admin/hosts/{id}?hard=true` versucht
zusaetzlich ein echtes Entfernen, faellt aber automatisch auf Soft-Delete
zurueck, falls der Host bereits Sitzungshistorie hat.
**3) Login-Verlauf**: neuer Tab "Login-Verlauf" filtert clientseitig aus dem
bestehenden Audit-Log-Feed (`GET /admin/audit-log`) gezielt Login-/Logout-/
Fehlversuch-Ereignisse heraus — kein neuer Backend-Endpunkt noetig, da die
Rohdaten bereits vorhanden waren, nur bisher nicht dediziert sichtbar.
**4) "Details"-Fix fuer Hosts**: der defekte "Details"-Button in der Hosts-
Tabelle wurde durch einen neuen Endpunkt `GET /admin/hosts/{id}` (liefert den
vollstaendigen, aktuellen Datensatz inkl. zugeordneter SSH-Keys und ob
RDP-Zugangsdaten hinterlegt sind) sowie eine neu geschriebene, robuste
`showHostDetail()`-Funktion in `admin.js` ersetzt, die bei jedem Aufruf
frisch nachlaedt statt sich auf ggf. veraltete Listendaten zu verlassen, und
Fehler inline anzeigt statt sie zu verschlucken. Da diese Sandbox die App
nicht tatsaechlich im Browser ausfuehren kann, war die urspruengliche
Ursache nicht direkt reproduzierbar — bitte nach dem Update pruefen, ob der
Button jetzt zuverlaessig funktioniert, oder bei einem verbleibenden Fehler
die genaue Browser-Konsolenmeldung mitteilen.
**5) Eigener "Zugangsdaten"-Tab**: buendelt SSH-Keys UND RDP/Windows-
Passwoerter (bisher Teil des Hosts-Formulars) an einer Stelle, inkl. Uebersicht,
welche RDP-Hosts bereits ein Passwort hinterlegt haben (`GET
/admin/rdp-credentials`).
**6) Dateitransfer-Fenster (SSH-Terminal)**: der bisherige, `prompt()`-
basierte Einzel-Upload-Knopf in der Terminal-Sitzung (`templates/terminal.html`,
`static/js/terminal.js`) wurde durch ein eigenstaendiges Dateitransfer-Panel
ersetzt, das **beide Richtungen** abdeckt — Upload (wie zuvor, jetzt mit
eigenem Formular statt Browser-`prompt()`) und **Download** (neu in der UI;
der Backend-Endpunkt `GET /ssh/{host_id}/files/download` existierte bereits
und wird per `fetch()` + Blob + synthetischem `<a download>`-Link
angesteuert, damit Fehler inline im Panel erscheinen statt die Seite zu
verlassen). Das Panel fuehrt zusaetzlich ein kurzes Transfer-Log der
laufenden Sitzung. Gilt bewusst nur fuer SSH (RDP hat in dieser
Implementierung keinen eigenen Dateitransfer-Endpunkt).
**7) Mehrfachauswahl bei Rollenvergabe**: `POST /admin/roles/grant` und
`POST /admin/group-roles/grant` akzeptieren jetzt `role_names` (Liste, 1-6
Rollen) statt einer einzelnen `role_name` — ein Benutzer bzw. eine
Benutzergruppe kann damit in einem Schritt mehrere Rollen auf derselben
Hostgruppe erhalten (Checkbox-Raster statt Dropdown in der Oberflaeche).
Das Entziehen bleibt bewusst pro Zeile/Rolle (`role_name`, Einzahl) — das
entspricht dem bestehenden "Entziehen"-Knopf pro Tabellenzeile und braucht
keine Mehrfachauswahl.
## Tests ## Tests
```bash ```bash
pytest -q pytest -q
``` ```
46 Tests (vorher 27) decken ab: Argon2id/TOTP-Grundfunktionen, Audit-Hash-Chain 61 Tests (vorher 46) decken ab: Argon2id/TOTP-Grundfunktionen, Audit-Hash-Chain
(inkl. Manipulationserkennung und Trigger-Durchsetzung), RBAC-Logik inkl. (inkl. Manipulationserkennung und Trigger-Durchsetzung), RBAC-Logik inkl.
Ablaufdaten, den vollstaendigen Login-Flow (Passwort -> TOTP-Enrollment -> Ablaufdaten, den vollstaendigen Login-Flow (Passwort -> TOTP-Enrollment ->
Session-Cookie -> geschuetzte Endpunkte) gegen die echte FastAPI-App, 17 Session-Cookie -> geschuetzte Endpunkte) gegen die echte FastAPI-App, 17
@ -125,19 +206,42 @@ Admin-only-Durchsetzung fuer alle neuen Endpunkte, Token-Scope-Durchsetzung
Scope), dass ein Token niemals andere Tokens verwalten kann (Rechte- Scope), dass ein Token niemals andere Tokens verwalten kann (Rechte-
Eskalationsschutz), sowie Admin-Gating von `/docs` und `/openapi.json`. Eskalationsschutz), sowie Admin-Gating von `/docs` und `/openapi.json`.
Details, Vorgehen und Ergebnisse der urspruenglichen 27 Tests: siehe Details, Vorgehen und Ergebnisse der urspruenglichen 27 Tests: siehe
`Pentest_Report.md`. `Pentest_Report.md`. 15 weitere neue Tests zu Phase 8
(`tests/test_tenants.py`) decken zusaetzlich ab: Tenant-CRUD (Super-Admin
only, Mandanten-Admin explizit ausgeschlossen), volle Mandanten-Isolation
(Hostgruppen/Hosts/Benutzer/SSH-Keys/Audit-Log — fremde Mandanten-IDs
liefern 404), Mehrfachauswahl bei Einzel- UND Gruppen-Rollenvergabe,
Update/Deactivate/Delete fuer Benutzer (inkl. Anonymisieren-statt-Hart-
Loeschen bei vorhandener Audit-Historie vs. echtem Hard-Delete ohne),
Update/Delete fuer Hostgruppen (blockiert solange Hosts enthalten sind),
Hosts (Soft- vs. Hard-Delete), SSH-Keys (inkl. Rotation) und Benutzergruppen,
sowie den neuen `GET /admin/hosts/{id}`-Detailendpunkt inkl. SSH-Key-
Zuordnungen und RDP-Zugangsdaten-Status.
> **Hinweis:** Die 19 in dieser Session neu hinzugekommenen Tests (5 CSP + > **Hinweis:** Alle 34 in dieser und der vorherigen Session neu
> 14 Admin/Gruppen/Token) konnten in der verwendeten Cloud-Sandbox nicht > hinzugekommenen Tests (5 CSP + 14 Admin/Gruppen/Token + 15 Mandanten/CRUD)
> mit `pytest -q` ausgefuehrt werden, da diese Sandbox keinen Netzwerkzugriff > konnten in der verwendeten Cloud-Sandbox nicht mit `pytest -q` ausgefuehrt
> auf PyPI hat und `fastapi`/`aiosqlite` dort nicht vorinstalliert sind. > werden, da diese Sandbox keinen Netzwerkzugriff auf PyPI hat und
> Stattdessen wurden alle neuen/gaenderten SQL-Queries gegen eine echte > `fastapi`/`aiosqlite`/`argon2`/`asyncssh` dort nicht vorinstalliert sind.
> `sqlite3`-Instanz mit allen 5 Migrationen (inkl. der beiden neuen) manuell > Fuer Phase 8 wurde stattdessen ein tieferes Verifikationsverfahren
> durchgespielt, `app/security/api_tokens.py` und die neuen Pydantic-Schemas > angewendet als in der vorherigen Session: minimale Stub-Module fuer die
> direkt importiert und mit echten Assertions verifiziert, und alle > vier fehlenden Pakete (`fastapi`s `APIRouter`-Dekoratoren als No-Ops, ein
> geaenderten Python-/JS-Dateien mit `py_compile`/`node --check` auf > synchrones `sqlite3`-basiertes Shim mit aiosqlite-kompatiblem
> Syntaxfehler geprueft. Bitte `pytest -q` lokal ausfuehren und Ergebnis > `async`-Interface) erlauben es, die **echten** Endpunkt-Funktionen aus
> melden. > `app/admin/routes.py` direkt (ohne HTTP-Layer) gegen eine echte
> In-Memory-`sqlite3`-Datenbank mit allen 7 Migrationen auszufuehren — damit
> wurden ueber 20 Integrations-Assertions (Mandanten-Isolation,
> Rollen-Mehrfachvergabe, Delete-Semantik) tatsaechlich lauffaehig
> verifiziert, nicht nur simulierte SQL-Queries. Der neue `tests/test_tenants.py`
> selbst nutzt weiterhin `httpx.AsyncClient` gegen die echte ASGI-App (wie
> alle anderen Testdateien) und wurde daher zeilenweise gegen die tatsaechliche
> Endpunkt-Implementierung gegengeprueft (Pfade, Payload-Felder, Statuscodes),
> aber nicht selbst mit `pytest` ausgefuehrt. Alle geaenderten Python-/
> JS-Dateien wurden mit `py_compile`/`node --check` auf Syntaxfehler geprueft,
> und jeder `document.getElementById`-Aufruf in `admin.js`/`terminal.js` wurde
> automatisiert gegen die tatsaechlichen HTML-IDs in `admin.html`/`terminal.html`
> abgeglichen (0 Abweichungen). Bitte `pytest -q` lokal ausfuehren und
> Ergebnis melden.
Manuell zusaetzlich verifiziert (siehe Entwicklungs-Log dieser Session): Manuell zusaetzlich verifiziert (siehe Entwicklungs-Log dieser Session):
Server-Start, Static-/Template-Auslieferung, Security-Header, vollstaendiger Server-Start, Static-/Template-Auslieferung, Security-Header, vollstaendiger
@ -241,7 +345,15 @@ ist die massgebliche, aktuelle Fassung dieser Liste. Kurzfassung:
gibt noch keine erzwungene maximale Laufzeit, keine automatische gibt noch keine erzwungene maximale Laufzeit, keine automatische
Benachrichtigung vor Ablauf und keine "Token zuletzt benutzt vor X Tagen Benachrichtigung vor Ablauf und keine "Token zuletzt benutzt vor X Tagen
-> automatisch deaktivieren"-Routine. -> automatisch deaktivieren"-Routine.
11. Ein erneuter `bandit`/`pip-audit`-Lauf ueber die in dieser Session neu 11. Ein erneuter `bandit`/`pip-audit`-Lauf ueber die in dieser und der
hinzugekommenen Dateien (Admin-Oberflaeche, Gruppen-RBAC, API-Tokens) Phase-8-Session neu hinzugekommenen Dateien (Admin-Oberflaeche,
steht noch aus (siehe oben). Gruppen-RBAC, API-Tokens, Mandantenfaehigkeit, Dateitransfer-Panel)
steht noch aus (keine neuen Fremdabhaengigkeiten hinzugekommen, siehe
oben).
12. **Phase 8 / "Details"-Fix**: der Root-Cause des urspruenglich gemeldeten
defekten "Details"-Buttons konnte in dieser Sandbox nicht reproduziert
werden (die App laesst sich hier nicht im Browser ausfuehren) — der neue
`GET /admin/hosts/{id}`-Endpunkt plus robustere `showHostDetail()`-Logik
sollten das Problem loesen, sollten aber nach dem Deployment einmal
manuell im Browser bestaetigt werden.
</content> </content>

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
from dataclasses import dataclass from dataclasses import dataclass, field
from datetime import datetime, timezone from datetime import datetime, timezone
from fastapi import Cookie, Depends, Header, HTTPException, Request, Response, WebSocket, status from fastapi import Cookie, Depends, Header, HTTPException, Request, Response, WebSocket, status
@ -22,7 +22,25 @@ from app.security.sessions import (
class CurrentUser: class CurrentUser:
id: int id: int
username: str username: str
is_admin: bool is_admin: bool # Super-Admin (siehe Migration 0006, Mandantenfaehigkeit):
# sieht/verwaltet ALLE Mandanten. Ein Token ist NIE is_admin=True, auch
# wenn der zugehoerige User selbst Super-Admin ist (siehe unten).
tenant_admin_ids: frozenset[int] = field(default_factory=frozenset)
# Nur bei Token-Auth gesetzt: das Token ist zusaetzlich auf GENAU diesen
# einen Mandanten eingeschraenkt (siehe app/db/migrations/0006_tenants.sql:
# api_tokens.tenant_id ist NOT NULL -- jedes Token gehoert einem Mandanten).
token_tenant_id: int | None = None
@property
def is_any_admin(self) -> bool:
"""Super-Admin ODER Mandanten-Admin (mind. eines Mandanten)."""
return self.is_admin or bool(self.tenant_admin_ids)
async def _load_tenant_admin_ids(conn, user_id: int) -> frozenset[int]:
cursor = await conn.execute("SELECT tenant_id FROM tenant_admins WHERE user_id = ?", (user_id,))
rows = await cursor.fetchall()
return frozenset(r[0] for r in rows)
async def get_current_user( async def get_current_user(
@ -39,7 +57,8 @@ async def get_current_user(
conn = get_db() conn = get_db()
cursor = await conn.execute( cursor = await conn.execute(
"SELECT id, username, is_admin, is_active, session_version FROM users WHERE id = ?", "SELECT id, username, is_admin, is_active, session_version FROM users "
"WHERE id = ? AND deleted_at IS NULL",
(payload.user_id,), (payload.user_id,),
) )
row = await cursor.fetchone() row = await cursor.fetchone()
@ -59,7 +78,8 @@ async def get_current_user(
path="/", path="/",
) )
return CurrentUser(id=row[0], username=row[1], is_admin=bool(row[2])) tenant_admin_ids = await _load_tenant_admin_ids(conn, row[0])
return CurrentUser(id=row[0], username=row[1], is_admin=bool(row[2]), tenant_admin_ids=tenant_admin_ids)
async def get_current_user_ws(websocket: WebSocket) -> CurrentUser | None: async def get_current_user_ws(websocket: WebSocket) -> CurrentUser | None:
@ -75,7 +95,8 @@ async def get_current_user_ws(websocket: WebSocket) -> CurrentUser | None:
conn = get_db() conn = get_db()
cursor = await conn.execute( cursor = await conn.execute(
"SELECT id, username, is_admin, is_active, session_version FROM users WHERE id = ?", "SELECT id, username, is_admin, is_active, session_version FROM users "
"WHERE id = ? AND deleted_at IS NULL",
(payload.user_id,), (payload.user_id,),
) )
row = await cursor.fetchone() row = await cursor.fetchone()
@ -85,7 +106,18 @@ async def get_current_user_ws(websocket: WebSocket) -> CurrentUser | None:
async def require_global_admin(user: CurrentUser = Depends(get_current_user)) -> CurrentUser: async def require_global_admin(user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
"""NUR Super-Admin (Session, kein Token). Fuer die maechtigsten Aktionen:
Mandanten anlegen/loeschen, Mandanten-Admins ernennen, API-Dokumentation."""
if not user.is_admin: if not user.is_admin:
raise HTTPException(status.HTTP_403_FORBIDDEN, "Super-Admin-Rechte erforderlich")
return user
async def require_admin_session(user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
"""Super-Admin ODER Mandanten-Admin, aber ausschliesslich per Session
(NIE per Token) -- fuer Aktionen, die bewusst keinen Token-Bypass erlauben
sollen (z.B. Token-Verwaltung selbst, Mandanten-Admin-Zuweisung lesen)."""
if not user.is_any_admin:
raise HTTPException(status.HTTP_403_FORBIDDEN, "Admin-Rechte erforderlich") raise HTTPException(status.HTTP_403_FORBIDDEN, "Admin-Rechte erforderlich")
return user return user
@ -131,10 +163,12 @@ def require_host_role(role_name: str):
# User, Rollen, ...), NICHT fuer SSH/RDP/SFTP-Sitzungen selbst -- die bleiben # User, Rollen, ...), NICHT fuer SSH/RDP/SFTP-Sitzungen selbst -- die bleiben
# ausschliesslich an eine eingeloggte, TOTP-verifizierte Browser-Session # ausschliesslich an eine eingeloggte, TOTP-verifizierte Browser-Session
# gebunden. Ein Token ist nie maechtiger als sein explizit vergebener Scope, # gebunden. Ein Token ist nie maechtiger als sein explizit vergebener Scope,
# auch wenn der zugehoerige User Admin ist (kein automatisches is_admin). # auch wenn der zugehoerige User Admin ist (kein automatisches is_admin), UND
# Die Tokenverwaltung selbst (/admin/tokens/*) laeuft ausschliesslich ueber # ist seit Migration 0006 zusaetzlich auf GENAU EINEN Mandanten eingeschraenkt
# require_global_admin (Session), niemals ueber ein Token -- ein geleaktes # (token_tenant_id). Die Tokenverwaltung selbst (/admin/tokens/*) laeuft
# Token darf sich damit nicht selbst weitere/staerkere Tokens ausstellen. # ausschliesslich ueber require_admin_session (Session, Super- ODER
# Mandanten-Admin), niemals ueber ein Token -- ein geleaktes Token darf sich
# damit nicht selbst weitere/staerkere Tokens ausstellen.
async def _validate_api_token(token: str, *, resource: str, action: str) -> CurrentUser | None: async def _validate_api_token(token: str, *, resource: str, action: str) -> CurrentUser | None:
@ -143,15 +177,16 @@ async def _validate_api_token(token: str, *, resource: str, action: str) -> Curr
conn = get_db() conn = get_db()
token_hash = hash_token(token) token_hash = hash_token(token)
cursor = await conn.execute( cursor = await conn.execute(
"SELECT at.id, at.user_id, at.scopes_json, at.expires_at, at.revoked_at, " "SELECT at.id, at.user_id, at.scopes_json, at.expires_at, at.revoked_at, at.tenant_id, "
"u.username, u.is_active " "u.username, u.is_active "
"FROM api_tokens at JOIN users u ON u.id = at.user_id WHERE at.token_hash = ?", "FROM api_tokens at JOIN users u ON u.id = at.user_id "
"WHERE at.token_hash = ? AND u.deleted_at IS NULL",
(token_hash,), (token_hash,),
) )
row = await cursor.fetchone() row = await cursor.fetchone()
if row is None: if row is None:
return None return None
token_id, user_id, scopes_json, expires_at, revoked_at, username, is_active = row token_id, user_id, scopes_json, expires_at, revoked_at, tenant_id, username, is_active = row
if revoked_at is not None or not is_active: if revoked_at is not None or not is_active:
return None return None
now_str = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ") now_str = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
@ -166,15 +201,17 @@ async def _validate_api_token(token: str, *, resource: str, action: str) -> Curr
(token_id,), (token_id,),
) )
await conn.commit() await conn.commit()
return CurrentUser(id=user_id, username=username, is_admin=False) return CurrentUser(id=user_id, username=username, is_admin=False, token_tenant_id=tenant_id)
def require_admin_or_scope(resource: str, action: str): def require_admin_or_scope(resource: str, action: str):
"""Dependency-Factory fuer die Admin-API: erlaubt Zugriff entweder ueber """Dependency-Factory fuer die Admin-API: erlaubt Zugriff entweder ueber
eine eingeloggte Admin-Session (Cookie, wie bisher) ODER ueber ein eine eingeloggte Admin-Session (Super- ODER Mandanten-Admin, Cookie) ODER
API-Token mit passendem Scope (Header 'Authorization: Bearer <token>'). ueber ein API-Token mit passendem Scope (Header 'Authorization: Bearer
'write' schliesst 'read' auf derselben Ressource ein (siehe <token>'). 'write' schliesst 'read' auf derselben Ressource ein (siehe
token_has_scope).""" token_has_scope). Mandanten-Scoping selbst erfolgt NICHT hier, sondern im
jeweiligen Endpunkt ueber app/tenancy.py::TenantScope, basierend auf
effective_tenant_ids() des zurueckgegebenen CurrentUser."""
async def _dep( async def _dep(
response: Response, response: Response,
@ -192,8 +229,25 @@ def require_admin_or_scope(resource: str, action: str):
return principal return principal
user = await get_current_user(response, jh_session) user = await get_current_user(response, jh_session)
if not user.is_admin: if not user.is_any_admin:
raise HTTPException(status.HTTP_403_FORBIDDEN, "Admin-Rechte erforderlich") raise HTTPException(status.HTTP_403_FORBIDDEN, "Admin-Rechte erforderlich")
return user return user
return _dep return _dep
def effective_tenant_ids(admin: CurrentUser) -> set[int] | None:
"""None bedeutet 'alle Mandanten' (Super-Admin-Session, nicht ueber ein
mandantengebundenes Token authentifiziert). Sonst ein Set mit genau den
Mandanten-IDs, auf die der Principal beschraenkt ist:
- Token-Auth: immer genau {token_tenant_id} (Tokens sind nie
Mandanten-UEBERGREIFEND einsetzbar, siehe Migration 0006).
- Mandanten-Admin-Session: die Menge ihrer tenant_admin_ids.
- Ein regulaerer (Nicht-Admin-)User wuerde require_admin_or_scope /
require_admin_session ohnehin nie passieren -- kommt hier praktisch
nicht an."""
if admin.token_tenant_id is not None:
return {admin.token_tenant_id}
if admin.is_admin:
return None
return set(admin.tenant_admin_ids)

View File

@ -327,4 +327,19 @@ async def change_password(
@router.get("/me") @router.get("/me")
async def me(user: CurrentUser = Depends(get_current_user)): async def me(user: CurrentUser = Depends(get_current_user)):
return {"id": user.id, "username": user.username, "is_admin": user.is_admin} tenant_admin_of = []
if user.tenant_admin_ids:
conn = get_db()
placeholders = ",".join("?" for _ in user.tenant_admin_ids)
cursor = await conn.execute(
f"SELECT id, name FROM tenants WHERE id IN ({placeholders}) ORDER BY name",
tuple(user.tenant_admin_ids),
)
tenant_admin_of = [{"id": r[0], "name": r[1]} for r in await cursor.fetchall()]
return {
"id": user.id,
"username": user.username,
"is_admin": user.is_admin,
"is_any_admin": user.is_any_admin,
"tenant_admin_of": tenant_admin_of,
}

View File

@ -0,0 +1,65 @@
-- Mandantenfaehigkeit (volle Isolation, siehe Konzept-Erweiterung dieser
-- Session): jede Hostgruppe, Benutzergruppe, jeder SSH-Key und jedes
-- API-Token gehoert genau einem Mandanten. Ein Benutzer kann Mandanten-Admin
-- fuer einen oder mehrere Mandanten sein (tenant_admins) -- er sieht/
-- verwaltet dann AUSSCHLIESSLICH Ressourcen seines/seiner Mandanten
-- (Durchsetzung zentral in app/tenancy.py + app/auth/deps.py, angewendet in
-- jedem Endpunkt in app/admin/routes.py). Das bestehende users.is_admin
-- bleibt unveraendert bestehen und wird zum "Super-Admin": sieht/verwaltet
-- weiterhin ALLE Mandanten uebergreifend (volle Abwaertskompatibilitaet zu
-- allen bisherigen Admin-Workflows dieser App).
--
-- Rueckwirkende Kompatibilitaet: ein Standard-Mandant (id=1) wird automatisch
-- angelegt und alle VOR dieser Migration bestehenden Hostgruppen/
-- Benutzergruppen/SSH-Keys/API-Tokens werden ihm zugeordnet -- fuer
-- Super-Admins aendert sich dadurch am sichtbaren Verhalten nichts.
CREATE TABLE IF NOT EXISTS tenants (
id INTEGER PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
description TEXT,
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
INSERT OR IGNORE INTO tenants (id, name, description) VALUES
(1, 'Standard', 'Automatisch angelegter Standard-Mandant (Daten von vor der Einfuehrung der Mandantenfaehigkeit).');
-- Mandanten-Admin-Zuweisung ist bewusst eine eigene Tabelle (statt eines
-- einzelnen users.tenant_id) -- ein User kann Mandanten-Admin fuer MEHRERE
-- Mandanten sein (z.B. externer Dienstleister fuer mehrere Kunden), ohne
-- gleich globaler Super-Admin zu sein. Nur ein Super-Admin darf diese
-- Zuweisung vornehmen (require_global_admin, siehe admin/routes.py).
CREATE TABLE IF NOT EXISTS tenant_admins (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
tenant_id INTEGER NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
granted_by INTEGER REFERENCES users(id),
granted_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
PRIMARY KEY (user_id, tenant_id)
);
CREATE INDEX IF NOT EXISTS idx_tenant_admins_user ON tenant_admins(user_id);
ALTER TABLE host_groups ADD COLUMN tenant_id INTEGER NOT NULL DEFAULT 1 REFERENCES tenants(id);
ALTER TABLE user_groups ADD COLUMN tenant_id INTEGER NOT NULL DEFAULT 1 REFERENCES tenants(id);
ALTER TABLE ssh_keys ADD COLUMN tenant_id INTEGER NOT NULL DEFAULT 1 REFERENCES tenants(id);
-- api_tokens: bewusst NICHT nullable -- jedes Token (auch von einem
-- Super-Admin erzeugte) ist genau einem Mandanten zugeordnet. Ein Token ist
-- (wie schon vor dieser Migration) nie is_admin=True; ohne verpflichtenden
-- Mandantenbezug waere unklar, welche Mandanten-Ressourcen es sehen darf --
-- lieber explizit pro Mandant ein Token ausstellen als eine mehrdeutige
-- "gilt ueberall"-Sonderregel einzufuehren (Prinzip: fail closed).
ALTER TABLE api_tokens ADD COLUMN tenant_id INTEGER NOT NULL DEFAULT 1 REFERENCES tenants(id);
-- users.home_tenant_id: rein informativ/UX -- der Mandant, "fuer" den ein
-- Benutzer angelegt wurde (automatisch gesetzt, wenn ein Mandanten-Admin den
-- User anlegt), damit ein frisch angelegter User sofort in der Mandanten-
-- Admin-Sicht auftaucht, auch bevor er einer Gruppe zugeordnet oder ihm eine
-- Rolle gewaehrt wurde. Erzwingt KEINE Zugriffsbeschraenkung selbst -- die
-- eigentliche Sichtbarkeit/Isolation ergibt sich weiterhin aus Rollen-
-- Vergabe auf Hostgruppen eines Mandanten (siehe app/tenancy.py).
ALTER TABLE users ADD COLUMN home_tenant_id INTEGER REFERENCES tenants(id);
CREATE INDEX IF NOT EXISTS idx_host_groups_tenant ON host_groups(tenant_id);
CREATE INDEX IF NOT EXISTS idx_user_groups_tenant ON user_groups(tenant_id);
CREATE INDEX IF NOT EXISTS idx_ssh_keys_tenant ON ssh_keys(tenant_id);
CREATE INDEX IF NOT EXISTS idx_api_tokens_tenant ON api_tokens(tenant_id);
CREATE INDEX IF NOT EXISTS idx_users_home_tenant ON users(home_tenant_id);

View File

@ -0,0 +1,14 @@
-- Ergaenzung fuer vollstaendige Loesch-/Editierbarkeit (siehe app/admin/routes.py):
--
-- users.deleted_at: ein Benutzerkonto kann nicht immer per hartem SQL-DELETE
-- entfernt werden, weil audit_log.user_id (bewusst, siehe 0001_initial.sql)
-- OHNE ON DELETE CASCADE auf users(id) verweist -- das manipulationssichere
-- Audit-Log darf durch das Loeschen eines Kontos nicht nachtraeglich
-- veraendert oder seiner Zuordnung beraubt werden (jede Aenderung an
-- audit_log-Zeilen wuerde die Hash-Chain brechen bzw. ist durch DB-Trigger
-- ohnehin hart verboten). Ein Konto mit vorhandener Audit-Historie (praktisch
-- jedes jemals eingeloggte Konto) wird deshalb beim "Loeschen" stattdessen
-- deaktiviert, anonymisiert (Benutzername/Passwort/TOTP-Secret geloescht)
-- und hier markiert; nur ein Konto OHNE jede Audit-Historie wird tatsaechlich
-- per SQL-DELETE entfernt. Siehe delete_user() in app/admin/routes.py.
ALTER TABLE users ADD COLUMN deleted_at TEXT;

View File

@ -15,6 +15,11 @@ from pydantic import BaseModel, Field, field_validator
USERNAME_RE = re.compile(r"^[a-zA-Z0-9._-]{3,64}$") USERNAME_RE = re.compile(r"^[a-zA-Z0-9._-]{3,64}$")
HOSTNAME_LABEL_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$") HOSTNAME_LABEL_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$")
ROLE_NAME = Literal[
"ssh_connect", "rdp_connect", "file_transfer", "clipboard",
"session_recording_view", "admin_hostgroup",
]
class LoginRequest(BaseModel): class LoginRequest(BaseModel):
username: str = Field(min_length=3, max_length=64) username: str = Field(min_length=3, max_length=64)
@ -46,6 +51,11 @@ class UserCreateRequest(BaseModel):
username: str = Field(min_length=3, max_length=64) username: str = Field(min_length=3, max_length=64)
initial_password: str = Field(min_length=12, max_length=256) initial_password: str = Field(min_length=12, max_length=256)
is_admin: bool = False is_admin: bool = False
# Nur relevant, wenn der anlegende Principal Mandanten-Admin ist: wird
# serverseitig ohnehin auf den/die erlaubten Mandanten erzwungen (siehe
# create_user() in admin/routes.py) -- ein Mandanten-Admin kann hierueber
# KEINEN User einem fremden Mandanten zuordnen. Fuer Super-Admins optional.
home_tenant_id: int | None = None
@field_validator("username") @field_validator("username")
@classmethod @classmethod
@ -55,9 +65,38 @@ class UserCreateRequest(BaseModel):
return v return v
class UserUpdateRequest(BaseModel):
is_admin: bool | None = None
is_active: bool | None = None
new_password: str | None = Field(default=None, min_length=12, max_length=256)
class TenantCreateRequest(BaseModel):
name: str = Field(min_length=1, max_length=128)
description: str | None = Field(default=None, max_length=1024)
class TenantUpdateRequest(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=128)
description: str | None = Field(default=None, max_length=1024)
is_active: bool | None = None
class TenantAdminAssignRequest(BaseModel):
user_id: int
class HostGroupCreateRequest(BaseModel): class HostGroupCreateRequest(BaseModel):
name: str = Field(min_length=1, max_length=128) name: str = Field(min_length=1, max_length=128)
description: str | None = Field(default=None, max_length=1024) description: str | None = Field(default=None, max_length=1024)
# Nur fuer Super-Admins relevant (Mandanten-Admins werden serverseitig
# ohnehin auf ihren eigenen Mandanten festgelegt, siehe admin/routes.py).
tenant_id: int | None = None
class HostGroupUpdateRequest(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=128)
description: str | None = Field(default=None, max_length=1024)
class HostCreateRequest(BaseModel): class HostCreateRequest(BaseModel):
@ -83,19 +122,49 @@ class HostCreateRequest(BaseModel):
return v return v
class HostUpdateRequest(BaseModel):
host_group_id: int | None = None
hostname: str | None = Field(default=None, min_length=1, max_length=128)
address: str | None = Field(default=None, min_length=1, max_length=255)
port: int | None = Field(default=None, gt=0, le=65535)
ssh_username: str | None = None
rdp_username: str | None = None
rdp_domain: str | None = None
rdp_require_nla: bool | None = None
clipboard_enabled: bool | None = None
file_transfer_enabled: bool | None = None
is_active: bool | None = None
@field_validator("hostname")
@classmethod
def check_hostname(cls, v: str | None) -> str | None:
if v is not None and not HOSTNAME_LABEL_RE.match(v):
raise ValueError("Ungueltiger Hostname")
return v
class RoleGrantRequest(BaseModel): class RoleGrantRequest(BaseModel):
user_id: int user_id: int
host_group_id: int host_group_id: int
role_name: Literal[ role_names: list[ROLE_NAME] = Field(min_length=1, max_length=6)
"ssh_connect", "rdp_connect", "file_transfer", "clipboard",
"session_recording_view", "admin_hostgroup",
]
expires_at: str | None = None expires_at: str | None = None
class RoleRevokeRequest(BaseModel):
user_id: int
host_group_id: int
role_name: ROLE_NAME
class UserGroupCreateRequest(BaseModel): class UserGroupCreateRequest(BaseModel):
name: str = Field(min_length=1, max_length=128) name: str = Field(min_length=1, max_length=128)
description: str | None = Field(default=None, max_length=1024) description: str | None = Field(default=None, max_length=1024)
tenant_id: int | None = None
class UserGroupUpdateRequest(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=128)
description: str | None = Field(default=None, max_length=1024)
class GroupMemberRequest(BaseModel): class GroupMemberRequest(BaseModel):
@ -105,18 +174,24 @@ class GroupMemberRequest(BaseModel):
class GroupRoleGrantRequest(BaseModel): class GroupRoleGrantRequest(BaseModel):
user_group_id: int user_group_id: int
host_group_id: int host_group_id: int
role_name: Literal[ role_names: list[ROLE_NAME] = Field(min_length=1, max_length=6)
"ssh_connect", "rdp_connect", "file_transfer", "clipboard",
"session_recording_view", "admin_hostgroup",
]
expires_at: str | None = None expires_at: str | None = None
class GroupRoleRevokeRequest(BaseModel):
user_group_id: int
host_group_id: int
role_name: ROLE_NAME
class ApiTokenCreateRequest(BaseModel): class ApiTokenCreateRequest(BaseModel):
label: str = Field(min_length=1, max_length=128) label: str = Field(min_length=1, max_length=128)
user_id: int user_id: int
scopes: list[str] = Field(min_length=1, max_length=32) scopes: list[str] = Field(min_length=1, max_length=32)
expires_at: str | None = None expires_at: str | None = None
# Nur fuer Super-Admins relevant -- ein Mandanten-Admin wird serverseitig
# ohnehin auf seinen eigenen Mandanten festgelegt.
tenant_id: int | None = None
class SshKeyCreateRequest(BaseModel): class SshKeyCreateRequest(BaseModel):
@ -125,6 +200,18 @@ class SshKeyCreateRequest(BaseModel):
private_key_pem: str = Field(min_length=1, max_length=32_768) private_key_pem: str = Field(min_length=1, max_length=32_768)
public_key: str = Field(min_length=1, max_length=8192) public_key: str = Field(min_length=1, max_length=8192)
key_type: Literal["ed25519", "rsa-3072", "rsa-4096", "ca-cert"] key_type: Literal["ed25519", "rsa-3072", "rsa-4096", "ca-cert"]
tenant_id: int | None = None
class SshKeyUpdateRequest(BaseModel):
label: str | None = Field(default=None, min_length=1, max_length=128)
owner_user_id: int | None = None
# Wenn gesetzt, wird der Schluessel rotiert (neues Schluesselmaterial,
# rotated_at wird aktualisiert). Alle drei Felder muessen dann zusammen
# angegeben werden (siehe rotate_ssh_key() in admin/routes.py).
private_key_pem: str | None = Field(default=None, min_length=1, max_length=32_768)
public_key: str | None = Field(default=None, min_length=1, max_length=8192)
key_type: Literal["ed25519", "rsa-3072", "rsa-4096", "ca-cert"] | None = None
class ConnectRequest(BaseModel): class ConnectRequest(BaseModel):

97
app/tenancy.py Normal file
View File

@ -0,0 +1,97 @@
"""
Mandanten-Scoping: zentrale Stelle, die die Mandanten-Sichtbarkeit eines
Principals (Super-Admin / Mandanten-Admin-Session / Mandanten-gebundenes
API-Token, siehe app/auth/deps.py) in SQL-WHERE-Fragmente und
Zugriffspruefungen uebersetzt. JEDER /admin-Endpunkt, der mandantengebundene
Ressourcen (Hostgruppen, Hosts, Benutzergruppen, SSH-Keys, API-Tokens,
Rollenvergaben, Audit-Log) liest oder schreibt, muss ueber TenantScope gehen
-- damit die Isolation an genau einer Stelle durchgesetzt wird statt in jedem
Endpunkt einzeln neu erfunden zu werden.
Sicherheitsprinzip: eine Zugriffsverletzung ueber eine ID aus einem fremden
Mandanten liefert 404 (nicht 403) -- ein Mandanten-Admin soll aus der
Fehlerantwort nicht einmal ableiten koennen, DASS eine ID in einem anderen
Mandanten existiert.
"""
from __future__ import annotations
import aiosqlite
from fastapi import HTTPException, status
class TenantScope:
def __init__(self, tenant_ids: set[int] | None):
# tenant_ids is None -> Super-Admin, keine Einschraenkung.
# tenant_ids ist Set -> nur diese Mandanten sichtbar/aenderbar
# (leeres Set == gar kein Mandant -> nichts sichtbar).
self.all_tenants = tenant_ids is None
self.tenant_ids: set[int] = set(tenant_ids) if tenant_ids else set()
def sql_filter(self, column: str) -> tuple[str, tuple]:
"""SQL-Fragment (mit fuehrendem ' AND ...') + Parameter-Tuple, das an
eine bestehende WHERE-Klausel angehaengt werden kann."""
if self.all_tenants:
return "", ()
if not self.tenant_ids:
return " AND 0", ()
placeholders = ",".join("?" for _ in self.tenant_ids)
return f" AND {column} IN ({placeholders})", tuple(self.tenant_ids)
def check(self, tenant_id: int | None) -> None:
"""Wirft 404, wenn tenant_id ausserhalb des erlaubten Bereichs liegt."""
if self.all_tenants:
return
if tenant_id is None or tenant_id not in self.tenant_ids:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Nicht gefunden")
def single_tenant_id(self) -> int | None:
"""Fuer Endpunkte, die zwingend GENAU einen Mandanten brauchen (z.B.
beim Anlegen einer neuen Hostgruppe). None bei Super-Admin (muss dann
explizit im Request mitgegeben werden), sonst der einzige erlaubte
Mandant -- oder ein 400/409, falls der Aufrufer mehrere hat (in dieser
Version hat ein Mandanten-Admin praktisch immer genau einen Mandanten,
die Datenstruktur erlaubt aber theoretisch mehrere)."""
if self.all_tenants or len(self.tenant_ids) != 1:
return None
return next(iter(self.tenant_ids))
async def resolve_host_group_tenant(conn: aiosqlite.Connection, host_group_id: int) -> int | None:
cursor = await conn.execute("SELECT tenant_id FROM host_groups WHERE id = ?", (host_group_id,))
row = await cursor.fetchone()
return row[0] if row else None
async def resolve_host_tenant(conn: aiosqlite.Connection, host_id: int) -> int | None:
cursor = await conn.execute(
"SELECT hg.tenant_id FROM hosts h JOIN host_groups hg ON hg.id = h.host_group_id WHERE h.id = ?",
(host_id,),
)
row = await cursor.fetchone()
return row[0] if row else None
async def tenant_user_ids(conn: aiosqlite.Connection, tenant_id: int) -> set[int]:
"""Alle Benutzer, die zu diesem Mandanten gehoeren: Mandanten-Admins,
Benutzer mit home_tenant_id = tenant_id, Mitglieder einer Benutzergruppe
dieses Mandanten, sowie Benutzer mit einer direkten Rollenvergabe auf
einer Hostgruppe dieses Mandanten. Wird sowohl fuer die Benutzer-Liste
einer Mandanten-Admin-Sicht als auch fuer die Audit-Log-Filterung genutzt."""
cursor = await conn.execute(
"""
SELECT user_id FROM tenant_admins WHERE tenant_id = ?
UNION
SELECT id FROM users WHERE home_tenant_id = ?
UNION
SELECT ugm.user_id FROM user_group_members ugm
JOIN user_groups ug ON ug.id = ugm.user_group_id
WHERE ug.tenant_id = ?
UNION
SELECT uhr.user_id FROM user_hostgroup_roles uhr
JOIN host_groups hg ON hg.id = uhr.host_group_id
WHERE hg.tenant_id = ?
""",
(tenant_id, tenant_id, tenant_id, tenant_id),
)
rows = await cursor.fetchall()
return {r[0] for r in rows}

View File

@ -192,3 +192,30 @@ pre.json-view {
.session-toolbar .status { font-size: 0.8rem; color: var(--muted); } .session-toolbar .status { font-size: 0.8rem; color: var(--muted); }
#terminal, #rdp-display { flex: 1; background: black; } #terminal, #rdp-display { flex: 1; background: black; }
#rdp-display canvas { display: block; margin: 0 auto; } #rdp-display canvas { display: block; margin: 0 auto; }
/* --- Dateitransfer-Panel (Terminal-Sitzung) ---------------------------- */
.ft-overlay {
position: fixed; inset: 0; background: rgba(0, 0, 0, 0.55); z-index: 50;
display: flex; justify-content: flex-end;
}
.ft-overlay.hidden { display: none !important; }
.ft-panel {
width: 420px; max-width: 100%; height: 100%; background: var(--panel);
border-left: 1px solid var(--border); padding: 1.25rem; overflow-y: auto;
}
.ft-panel h2 {
font-size: 1rem; margin: 0 0 1rem; display: flex; align-items: center; justify-content: space-between;
}
.ft-panel h3 {
font-size: 0.8rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em;
margin: 1.5rem 0 0.5rem;
}
.ft-btn-row { display: flex; align-items: center; gap: 0.6rem; margin-top: 0.75rem; }
.ft-btn-row .hint { margin: 0; }
.ft-list { font-size: 0.8rem; }
.ft-list-row {
display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0; border-bottom: 1px solid var(--border);
}
.ft-list-row .name { flex: 1; word-break: break-all; }
.ft-list-row .size { color: var(--muted); flex-shrink: 0; white-space: nowrap; }

View File

@ -5,8 +5,15 @@
"ssh_connect", "rdp_connect", "file_transfer", "clipboard", "ssh_connect", "rdp_connect", "file_transfer", "clipboard",
"session_recording_view", "admin_hostgroup", "session_recording_view", "admin_hostgroup",
]; ];
const AUTH_EVENT_TYPES = new Set([
"login_password_ok", "login_failed", "login_success", "login_totp_failed",
"login_recovery_code_used", "totp_enroll_started", "totp_enroll_confirmed",
"totp_enroll_failed", "logout", "logout_everywhere", "password_changed",
]);
const FAILURE_EVENT_TYPES = new Set(["login_failed", "login_totp_failed", "totp_enroll_failed"]);
const bannerBox = document.getElementById("banner-box"); const bannerBox = document.getElementById("banner-box");
let meInfo = { is_admin: false, tenant_admin_of: [] };
function showBanner(message, type) { function showBanner(message, type) {
const div = document.createElement("div"); const div = document.createElement("div");
@ -80,6 +87,24 @@
}); });
} }
function roleCheckboxGrid(container) {
container.replaceChildren(
...ROLE_NAMES.map((name) => {
const id = `${container.id}-${name}`;
const row = el("div", { className: "checkbox-row" });
const checkbox = el("input", { type: "checkbox", id, value: name });
const label = el("label", { for: id, textContent: name });
row.appendChild(checkbox);
row.appendChild(label);
return row;
})
);
}
function checkedValues(container) {
return Array.from(container.querySelectorAll("input:checked")).map((c) => c.value);
}
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
// Tabs // Tabs
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
@ -88,8 +113,11 @@
users: loadUsersTab, users: loadUsersTab,
groups: loadGroupsTab, groups: loadGroupsTab,
hosts: loadHostsTab, hosts: loadHostsTab,
credentials: loadCredentialsTab,
roles: loadRolesTab, roles: loadRolesTab,
tokens: loadTokensTab, tokens: loadTokensTab,
tenants: loadTenantsTab,
authlog: loadAuthLogTab,
audit: loadAuditTab, audit: loadAuditTab,
}; };
const loadedTabs = new Set(); const loadedTabs = new Set();
@ -108,23 +136,57 @@
} }
}); });
// ---------------------------------------------------------------------
// Mandanten-Auswahlfelder (fuer Super-Admins Dropdown aller Mandanten,
// fuer Mandanten-Admins nur die eigenen -- kein Server-Request noetig,
// steht bereits in meInfo.tenant_admin_of).
// ---------------------------------------------------------------------
let cachedTenants = [];
async function populateTenantSelect(select) {
if (meInfo.is_admin) {
if (cachedTenants.length === 0) {
const result = await getJson("/admin/tenants");
cachedTenants = result;
}
select.replaceChildren(...optionsFromList(cachedTenants, "id", (t) => `${t.name} (#${t.id})`));
} else {
select.replaceChildren(
...optionsFromList(meInfo.tenant_admin_of, "id", (t) => `${t.name} (#${t.id})`)
);
}
}
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
// Benutzer // Benutzer
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
async function loadUsersTab() { async function loadUsersTab() {
document.getElementById("uc-is-admin-row").classList.toggle("hidden", !meInfo.is_admin);
await refreshUsers(); await refreshUsers();
} }
let cachedUsers = []; let cachedUsers = [];
let editingUserId = null;
function tenantName(tenantId) {
if (tenantId === null || tenantId === undefined) return "-";
const t = cachedTenants.find((x) => x.id === tenantId);
return t ? t.name : `#${tenantId}`;
}
async function refreshUsers() { async function refreshUsers() {
if (meInfo.is_admin && cachedTenants.length === 0) {
cachedTenants = await getJson("/admin/tenants");
}
cachedUsers = await getJson("/admin/users"); cachedUsers = await getJson("/admin/users");
const tbody = document.querySelector("#users-table tbody"); const tbody = document.querySelector("#users-table tbody");
fillTable( fillTable(
tbody, tbody,
cachedUsers.map((u) => { cachedUsers.map((u) => {
const actions = el("td", {}); const actions = el("td", {});
actions.appendChild(actionButton("Bearbeiten", "btn-secondary", () => showUserEdit(u)));
if (u.is_active) { if (u.is_active) {
actions.appendChild( actions.appendChild(
actionButton("Deaktivieren", "btn-danger", async () => { actionButton("Deaktivieren", "btn-danger", async () => {
@ -134,12 +196,25 @@
}) })
); );
} }
actions.appendChild(
actionButton("Loeschen", "btn-danger", async () => {
const result = await apiFetch(`/admin/users/${u.id}`, { method: "DELETE" });
showBanner(
result.hard_deleted
? `Benutzer '${u.username}' vollstaendig geloescht.`
: `Benutzer '${u.username}' hat Audit-Historie und wurde deaktiviert/anonymisiert.`,
"ok"
);
await refreshUsers();
})
);
return el("tr", {}, [ return el("tr", {}, [
el("td", { textContent: String(u.id) }), el("td", { textContent: String(u.id) }),
el("td", { textContent: u.username }), el("td", { textContent: u.username }),
el("td", { textContent: u.is_admin ? "ja" : "nein" }), el("td", { textContent: u.is_admin ? "ja" : "nein" }),
el("td", { textContent: u.is_active ? "ja" : "nein" }), el("td", { textContent: u.is_active ? "ja" : "nein" }),
el("td", { textContent: u.totp_enrolled ? "ja" : "nein" }), el("td", { textContent: u.totp_enrolled ? "ja" : "nein" }),
el("td", { textContent: tenantName(u.home_tenant_id) }),
el("td", { textContent: u.created_at }), el("td", { textContent: u.created_at }),
actions, actions,
]); ]);
@ -147,12 +222,37 @@
); );
} }
function showUserEdit(u) {
editingUserId = u.id;
document.getElementById("user-edit-panel").classList.remove("hidden");
document.getElementById("user-edit-name").textContent = `${u.username} (#${u.id})`;
document.getElementById("ue-is-admin-row").classList.toggle("hidden", !meInfo.is_admin);
document.getElementById("ue-is-admin").checked = u.is_admin;
document.getElementById("ue-is-active").checked = u.is_active;
document.getElementById("ue-new-password").value = "";
}
document.getElementById("user-edit-form").addEventListener("submit", async (ev) => {
ev.preventDefault();
try {
const payload = { is_active: document.getElementById("ue-is-active").checked };
if (meInfo.is_admin) payload.is_admin = document.getElementById("ue-is-admin").checked;
const pw = document.getElementById("ue-new-password").value;
if (pw) payload.new_password = pw;
await sendJson(`/admin/users/${editingUserId}`, "PUT", payload);
showBanner("Benutzer aktualisiert.", "ok");
await refreshUsers();
} catch (err) {
showBanner(err.message, "error");
}
});
document.getElementById("user-create-form").addEventListener("submit", async (ev) => { document.getElementById("user-create-form").addEventListener("submit", async (ev) => {
ev.preventDefault(); ev.preventDefault();
try { try {
const username = document.getElementById("uc-username").value.trim(); const username = document.getElementById("uc-username").value.trim();
const password = document.getElementById("uc-password").value; const password = document.getElementById("uc-password").value;
const isAdmin = document.getElementById("uc-is-admin").checked; const isAdmin = meInfo.is_admin && document.getElementById("uc-is-admin").checked;
await sendJson("/admin/users", "POST", { await sendJson("/admin/users", "POST", {
username, initial_password: password, is_admin: isAdmin, username, initial_password: password, is_admin: isAdmin,
}); });
@ -171,8 +271,11 @@
let cachedGroups = []; let cachedGroups = [];
let activeGroupId = null; let activeGroupId = null;
let editingGroupId = null;
async function loadGroupsTab() { async function loadGroupsTab() {
document.getElementById("gc-tenant-box").classList.toggle("hidden", !meInfo.is_admin && meInfo.tenant_admin_of.length <= 1);
await populateTenantSelect(document.getElementById("gc-tenant"));
await refreshGroups(); await refreshGroups();
} }
@ -186,15 +289,44 @@
el("td", { textContent: String(g.id) }), el("td", { textContent: String(g.id) }),
el("td", { textContent: g.name }), el("td", { textContent: g.name }),
el("td", { textContent: g.description || "" }), el("td", { textContent: g.description || "" }),
el("td", { textContent: g.tenant_name }),
el("td", { textContent: String(g.member_count) }), el("td", { textContent: String(g.member_count) }),
el("td", {}, [ el("td", {}, [
actionButton("Mitglieder", "btn-secondary", () => showGroupMembers(g)), actionButton("Mitglieder", "btn-secondary", () => showGroupMembers(g)),
actionButton("Bearbeiten", "btn-secondary", () => showGroupEdit(g)),
actionButton("Loeschen", "btn-danger", async () => {
await apiFetch(`/admin/user-groups/${g.id}`, { method: "DELETE" });
showBanner(`Gruppe '${g.name}' geloescht.`, "ok");
await refreshGroups();
}),
]), ]),
]) ])
) )
); );
} }
function showGroupEdit(g) {
editingGroupId = g.id;
document.getElementById("group-edit-panel").classList.remove("hidden");
document.getElementById("group-edit-name").textContent = g.name;
document.getElementById("ge-name").value = g.name;
document.getElementById("ge-description").value = g.description || "";
}
document.getElementById("group-edit-form").addEventListener("submit", async (ev) => {
ev.preventDefault();
try {
await sendJson(`/admin/user-groups/${editingGroupId}`, "PUT", {
name: document.getElementById("ge-name").value.trim(),
description: document.getElementById("ge-description").value.trim() || null,
});
showBanner("Gruppe aktualisiert.", "ok");
await refreshGroups();
} catch (err) {
showBanner(err.message, "error");
}
});
async function showGroupMembers(group) { async function showGroupMembers(group) {
activeGroupId = group.id; activeGroupId = group.id;
document.getElementById("group-members-panel").classList.remove("hidden"); document.getElementById("group-members-panel").classList.remove("hidden");
@ -232,7 +364,9 @@
try { try {
const name = document.getElementById("gc-name").value.trim(); const name = document.getElementById("gc-name").value.trim();
const description = document.getElementById("gc-description").value.trim() || null; const description = document.getElementById("gc-description").value.trim() || null;
await sendJson("/admin/user-groups", "POST", { name, description }); const tenantSelect = document.getElementById("gc-tenant");
const tenant_id = tenantSelect.value ? Number(tenantSelect.value) : null;
await sendJson("/admin/user-groups", "POST", { name, description, tenant_id });
showBanner(`Benutzergruppe '${name}' angelegt.`, "ok"); showBanner(`Benutzergruppe '${name}' angelegt.`, "ok");
ev.target.reset(); ev.target.reset();
await refreshGroups(); await refreshGroups();
@ -263,9 +397,12 @@
let cachedHosts = []; let cachedHosts = [];
let cachedSshKeys = []; let cachedSshKeys = [];
let activeHostId = null; let activeHostId = null;
let editingHostGroupId = null;
async function loadHostsTab() { async function loadHostsTab() {
await Promise.all([refreshHostGroups(), refreshSshKeys()]); document.getElementById("hgc-tenant-box").classList.toggle("hidden", !meInfo.is_admin && meInfo.tenant_admin_of.length <= 1);
await populateTenantSelect(document.getElementById("hgc-tenant"));
await Promise.all([refreshHostGroups(), refreshSshKeysCache()]);
await refreshHosts(); await refreshHosts();
} }
@ -279,6 +416,15 @@
el("td", { textContent: String(g.id) }), el("td", { textContent: String(g.id) }),
el("td", { textContent: g.name }), el("td", { textContent: g.name }),
el("td", { textContent: g.description || "" }), el("td", { textContent: g.description || "" }),
el("td", { textContent: g.tenant_name }),
el("td", {}, [
actionButton("Bearbeiten", "btn-secondary", () => showHostGroupEdit(g)),
actionButton("Loeschen", "btn-danger", async () => {
await apiFetch(`/admin/host-groups/${g.id}`, { method: "DELETE" });
showBanner(`Hostgruppe '${g.name}' geloescht.`, "ok");
await refreshHostGroups();
}),
]),
]) ])
) )
); );
@ -286,6 +432,28 @@
hcSelect.replaceChildren(...optionsFromList(cachedHostGroups, "id", (g) => `${g.name} (#${g.id})`)); hcSelect.replaceChildren(...optionsFromList(cachedHostGroups, "id", (g) => `${g.name} (#${g.id})`));
} }
function showHostGroupEdit(g) {
editingHostGroupId = g.id;
document.getElementById("hostgroup-edit-panel").classList.remove("hidden");
document.getElementById("hostgroup-edit-name").textContent = g.name;
document.getElementById("hge-name").value = g.name;
document.getElementById("hge-description").value = g.description || "";
}
document.getElementById("hostgroup-edit-form").addEventListener("submit", async (ev) => {
ev.preventDefault();
try {
await sendJson(`/admin/host-groups/${editingHostGroupId}`, "PUT", {
name: document.getElementById("hge-name").value.trim(),
description: document.getElementById("hge-description").value.trim() || null,
});
showBanner("Hostgruppe aktualisiert.", "ok");
await refreshHostGroups();
} catch (err) {
showBanner(err.message, "error");
}
});
async function refreshHosts() { async function refreshHosts() {
cachedHosts = await getJson("/admin/hosts"); cachedHosts = await getJson("/admin/hosts");
const groupName = (id) => (cachedHostGroups.find((g) => g.id === id) || {}).name || `#${id}`; const groupName = (id) => (cachedHostGroups.find((g) => g.id === id) || {}).name || `#${id}`;
@ -300,40 +468,124 @@
el("td", { textContent: h.protocol }), el("td", { textContent: h.protocol }),
el("td", { textContent: h.os_type }), el("td", { textContent: h.os_type }),
el("td", { textContent: groupName(h.host_group_id) }), el("td", { textContent: groupName(h.host_group_id) }),
el("td", {}, [actionButton("Details", "btn-secondary", () => showHostDetail(h))]), el("td", { textContent: h.tenant_name }),
el("td", {}, [el("span", { className: `badge ${h.is_active ? "ok" : "danger"}`, textContent: h.is_active ? "aktiv" : "inaktiv" })]),
el("td", {}, [
actionButton("Details", "btn-secondary", () => showHostDetail(h.id)),
actionButton(h.is_active ? "Loeschen" : "Reaktivieren", h.is_active ? "btn-danger" : "btn-secondary", async () => {
if (h.is_active) {
await apiFetch(`/admin/hosts/${h.id}`, { method: "DELETE" });
showBanner(`Host '${h.hostname}' deaktiviert/geloescht.`, "ok");
} else {
await sendJson(`/admin/hosts/${h.id}`, "PUT", { is_active: true });
showBanner(`Host '${h.hostname}' reaktiviert.`, "ok");
}
await refreshHosts();
if (activeHostId === h.id) document.getElementById("host-detail-panel").classList.add("hidden");
}),
]),
]) ])
) )
); );
} }
async function refreshSshKeys() { async function refreshSshKeysCache() {
cachedSshKeys = await getJson("/admin/ssh-keys"); cachedSshKeys = await getJson("/admin/ssh-keys");
const tbody = document.querySelector("#ssh-keys-table tbody");
fillTable(
tbody,
cachedSshKeys.map((k) =>
el("tr", {}, [
el("td", { textContent: String(k.id) }),
el("td", { textContent: k.label }),
el("td", { textContent: k.key_type }),
el("td", { textContent: k.owner_user_id === null ? "-" : String(k.owner_user_id) }),
el("td", { textContent: k.created_at }),
])
)
);
} }
function showHostDetail(host) { function setDetailBanner(message, type) {
activeHostId = host.id; const box = document.getElementById("host-detail-banner");
document.getElementById("host-detail-panel").classList.remove("hidden"); if (!message) {
document.getElementById("host-detail-name").textContent = `${host.hostname} (#${host.id})`; box.replaceChildren();
document.getElementById("host-detail-ssh").classList.toggle("hidden", host.protocol !== "ssh"); return;
document.getElementById("host-detail-rdp").classList.toggle("hidden", host.protocol !== "rdp"); }
document.getElementById("host-key-result").classList.add("hidden"); box.replaceChildren(el("div", { className: `banner ${type === "error" ? "error" : "ok"}`, textContent: message }));
const keySelect = document.getElementById("hkm-key-select");
keySelect.replaceChildren(...optionsFromList(cachedSshKeys, "id", (k) => `${k.label} (#${k.id})`));
} }
async function showHostDetail(hostId) {
setDetailBanner("", "ok");
try {
const host = await getJson(`/admin/hosts/${hostId}`);
activeHostId = host.id;
document.getElementById("host-detail-panel").classList.remove("hidden");
document.getElementById("host-detail-name").textContent = `${host.hostname} (#${host.id}, ${host.tenant_name})`;
document.getElementById("he-hostname").value = host.hostname;
document.getElementById("he-address").value = host.address;
document.getElementById("he-port").value = String(host.port);
document.getElementById("he-ssh-username").value = host.ssh_username || "";
document.getElementById("he-rdp-username").value = host.rdp_username || "";
document.getElementById("he-rdp-domain").value = host.rdp_domain || "";
document.getElementById("he-clipboard").checked = host.clipboard_enabled;
document.getElementById("he-filetransfer").checked = host.file_transfer_enabled;
document.getElementById("he-nla").checked = host.rdp_require_nla;
document.getElementById("he-active").checked = host.is_active;
const isRdp = host.protocol === "rdp";
document.getElementById("he-ssh-username-box").classList.toggle("hidden", isRdp);
document.getElementById("he-rdp-username-box").classList.toggle("hidden", !isRdp);
document.getElementById("he-rdp-domain-box").classList.toggle("hidden", !isRdp);
document.getElementById("he-nla-box").classList.toggle("hidden", !isRdp);
document.getElementById("host-detail-ssh").classList.toggle("hidden", isRdp);
document.getElementById("host-detail-rdp").classList.toggle("hidden", !isRdp);
document.getElementById("host-key-result").classList.add("hidden");
if (host.ssh_host_key_fingerprint) {
const box = document.getElementById("host-key-result");
box.textContent = `Aktueller Fingerprint: ${host.ssh_host_key_fingerprint}`;
box.classList.remove("hidden");
}
const keysTbody = document.querySelector("#host-ssh-keys-table tbody");
fillTable(
keysTbody,
host.ssh_keys.map((k) =>
el("tr", {}, [
el("td", { textContent: String(k.id) }),
el("td", { textContent: k.label }),
el("td", {}, [
actionButton("Entfernen", "btn-danger", async () => {
await apiFetch(`/admin/hosts/${host.id}/ssh-keys/${k.id}`, { method: "DELETE" });
await showHostDetail(host.id);
}),
]),
])
)
);
const keySelect = document.getElementById("hkm-key-select");
keySelect.replaceChildren(...optionsFromList(cachedSshKeys, "id", (k) => `${k.label} (#${k.id})`));
document.getElementById("rdp-cred-status").textContent = host.rdp_credentials_set
? `RDP-Passwort ist gesetzt (zuletzt aktualisiert: ${host.rdp_credentials_updated_at}).`
: "Noch kein RDP-Passwort gesetzt.";
} catch (err) {
setDetailBanner(err.message, "error");
}
}
document.getElementById("host-edit-form").addEventListener("submit", async (ev) => {
ev.preventDefault();
try {
await sendJson(`/admin/hosts/${activeHostId}`, "PUT", {
hostname: document.getElementById("he-hostname").value.trim(),
address: document.getElementById("he-address").value.trim(),
port: Number(document.getElementById("he-port").value),
ssh_username: document.getElementById("he-ssh-username").value.trim() || null,
rdp_username: document.getElementById("he-rdp-username").value.trim() || null,
rdp_domain: document.getElementById("he-rdp-domain").value.trim() || null,
clipboard_enabled: document.getElementById("he-clipboard").checked,
file_transfer_enabled: document.getElementById("he-filetransfer").checked,
rdp_require_nla: document.getElementById("he-nla").checked,
is_active: document.getElementById("he-active").checked,
});
showBanner("Host aktualisiert.", "ok");
await refreshHosts();
await showHostDetail(activeHostId);
} catch (err) {
setDetailBanner(err.message, "error");
}
});
document.getElementById("hc-protocol").addEventListener("change", (ev) => { document.getElementById("hc-protocol").addEventListener("change", (ev) => {
const isRdp = ev.target.value === "rdp"; const isRdp = ev.target.value === "rdp";
document.getElementById("hc-ssh-username-box").classList.toggle("hidden", isRdp); document.getElementById("hc-ssh-username-box").classList.toggle("hidden", isRdp);
@ -349,7 +601,9 @@
try { try {
const name = document.getElementById("hgc-name").value.trim(); const name = document.getElementById("hgc-name").value.trim();
const description = document.getElementById("hgc-description").value.trim() || null; const description = document.getElementById("hgc-description").value.trim() || null;
await sendJson("/admin/host-groups", "POST", { name, description }); const tenantSelect = document.getElementById("hgc-tenant");
const tenant_id = tenantSelect.value ? Number(tenantSelect.value) : null;
await sendJson("/admin/host-groups", "POST", { name, description, tenant_id });
showBanner(`Hostgruppe '${name}' angelegt.`, "ok"); showBanner(`Hostgruppe '${name}' angelegt.`, "ok");
ev.target.reset(); ev.target.reset();
await refreshHostGroups(); await refreshHostGroups();
@ -396,7 +650,7 @@
box.classList.remove("hidden"); box.classList.remove("hidden");
showBanner("Host-Key ermittelt und gespeichert.", "ok"); showBanner("Host-Key ermittelt und gespeichert.", "ok");
} catch (err) { } catch (err) {
showBanner(err.message, "error"); setDetailBanner(err.message, "error");
} }
}); });
@ -406,8 +660,9 @@
const keyId = document.getElementById("hkm-key-select").value; const keyId = document.getElementById("hkm-key-select").value;
await sendJson(`/admin/hosts/${activeHostId}/ssh-keys/${keyId}`, "POST", {}); await sendJson(`/admin/hosts/${activeHostId}/ssh-keys/${keyId}`, "POST", {});
showBanner("SSH-Key zugeordnet.", "ok"); showBanner("SSH-Key zugeordnet.", "ok");
await showHostDetail(activeHostId);
} catch (err) { } catch (err) {
showBanner(err.message, "error"); setDetailBanner(err.message, "error");
} }
}); });
@ -418,6 +673,89 @@
await sendJson(`/admin/hosts/${activeHostId}/rdp-credentials`, "PUT", { password }); await sendJson(`/admin/hosts/${activeHostId}/rdp-credentials`, "PUT", { password });
showBanner("RDP-Zugangsdaten gespeichert.", "ok"); showBanner("RDP-Zugangsdaten gespeichert.", "ok");
ev.target.reset(); ev.target.reset();
await showHostDetail(activeHostId);
} catch (err) {
setDetailBanner(err.message, "error");
}
});
// ---------------------------------------------------------------------
// Zugangsdaten (SSH-Keys + RDP-Passwoerter)
// ---------------------------------------------------------------------
let editingSshKeyId = null;
async function loadCredentialsTab() {
document.getElementById("skc-tenant-box").classList.toggle("hidden", !meInfo.is_admin && meInfo.tenant_admin_of.length <= 1);
await populateTenantSelect(document.getElementById("skc-tenant"));
await Promise.all([refreshSshKeys(), refreshRdpCredentials()]);
}
async function refreshSshKeys() {
cachedSshKeys = await getJson("/admin/ssh-keys");
const tbody = document.querySelector("#ssh-keys-table tbody");
fillTable(
tbody,
cachedSshKeys.map((k) =>
el("tr", {}, [
el("td", { textContent: String(k.id) }),
el("td", { textContent: k.label }),
el("td", { textContent: k.key_type }),
el("td", { textContent: k.owner_user_id === null ? "-" : String(k.owner_user_id) }),
el("td", { textContent: k.tenant_name }),
el("td", { textContent: k.created_at }),
el("td", { textContent: k.rotated_at || "-" }),
el("td", {}, [
actionButton("Bearbeiten", "btn-secondary", () => showSshKeyEdit(k)),
actionButton("Loeschen", "btn-danger", async () => {
const result = await apiFetch(`/admin/ssh-keys/${k.id}`, { method: "DELETE" });
const suffix = result.unmapped_host_ids.length
? ` (Zuordnung zu Host(s) ${result.unmapped_host_ids.join(", ")} entfernt)`
: "";
showBanner(`SSH-Key '${k.label}' geloescht.${suffix}`, "ok");
await refreshSshKeys();
}),
]),
])
)
);
}
function showSshKeyEdit(k) {
editingSshKeyId = k.id;
document.getElementById("ssh-key-edit-panel").classList.remove("hidden");
document.getElementById("ssh-key-edit-name").textContent = `${k.label} (#${k.id})`;
document.getElementById("ske-label").value = k.label;
document.getElementById("ske-owner").value = k.owner_user_id === null ? "" : String(k.owner_user_id);
document.getElementById("skr-type").value = k.key_type;
}
document.getElementById("ssh-key-edit-form").addEventListener("submit", async (ev) => {
ev.preventDefault();
try {
const ownerRaw = document.getElementById("ske-owner").value.trim();
await sendJson(`/admin/ssh-keys/${editingSshKeyId}`, "PUT", {
label: document.getElementById("ske-label").value.trim() || null,
owner_user_id: ownerRaw ? Number(ownerRaw) : null,
});
showBanner("SSH-Key aktualisiert.", "ok");
await refreshSshKeys();
} catch (err) {
showBanner(err.message, "error");
}
});
document.getElementById("ssh-key-rotate-form").addEventListener("submit", async (ev) => {
ev.preventDefault();
try {
await sendJson(`/admin/ssh-keys/${editingSshKeyId}`, "PUT", {
key_type: document.getElementById("skr-type").value,
private_key_pem: document.getElementById("skr-private").value,
public_key: document.getElementById("skr-public").value,
});
showBanner("SSH-Key rotiert.", "ok");
ev.target.reset();
await refreshSshKeys();
} catch (err) { } catch (err) {
showBanner(err.message, "error"); showBanner(err.message, "error");
} }
@ -427,42 +765,61 @@
ev.preventDefault(); ev.preventDefault();
try { try {
const ownerRaw = document.getElementById("skc-owner").value.trim(); const ownerRaw = document.getElementById("skc-owner").value.trim();
const tenantSelect = document.getElementById("skc-tenant");
const payload = { const payload = {
label: document.getElementById("skc-label").value.trim(), label: document.getElementById("skc-label").value.trim(),
owner_user_id: ownerRaw ? Number(ownerRaw) : null, owner_user_id: ownerRaw ? Number(ownerRaw) : null,
key_type: document.getElementById("skc-type").value, key_type: document.getElementById("skc-type").value,
private_key_pem: document.getElementById("skc-private").value, private_key_pem: document.getElementById("skc-private").value,
public_key: document.getElementById("skc-public").value, public_key: document.getElementById("skc-public").value,
tenant_id: tenantSelect.value ? Number(tenantSelect.value) : null,
}; };
await sendJson("/admin/ssh-keys", "POST", payload); await sendJson("/admin/ssh-keys", "POST", payload);
showBanner(`SSH-Key '${payload.label}' angelegt.`, "ok"); showBanner(`SSH-Key '${payload.label}' angelegt.`, "ok");
ev.target.reset(); ev.target.reset();
await refreshSshKeys(); await refreshSshKeys();
if (activeHostId !== null) {
const keySelect = document.getElementById("hkm-key-select");
keySelect.replaceChildren(...optionsFromList(cachedSshKeys, "id", (k) => `${k.label} (#${k.id})`));
}
} catch (err) { } catch (err) {
showBanner(err.message, "error"); showBanner(err.message, "error");
} }
}); });
async function refreshRdpCredentials() {
const rows = await getJson("/admin/rdp-credentials");
const tbody = document.querySelector("#rdp-creds-table tbody");
fillTable(
tbody,
rows.map((r) =>
el("tr", {}, [
el("td", { textContent: r.hostname }),
el("td", { textContent: r.address }),
el("td", { textContent: r.host_group_name }),
el("td", {}, [el("span", { className: `badge ${r.credentials_set ? "ok" : ""}`, textContent: r.credentials_set ? "gesetzt" : "nicht gesetzt" })]),
el("td", { textContent: r.updated_at || "-" }),
el("td", {}, [
actionButton("Zum Host", "btn-secondary", async () => {
document.querySelector('.tab-btn[data-tab="hosts"]').click();
await showHostDetail(r.host_id);
}),
...(r.credentials_set
? [actionButton("Entfernen", "btn-danger", async () => {
await apiFetch(`/admin/hosts/${r.host_id}/rdp-credentials`, { method: "DELETE" });
showBanner("RDP-Passwort entfernt.", "ok");
await refreshRdpCredentials();
})]
: []),
]),
])
)
);
}
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
// Rollen // Rollen
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
function roleOptions() {
return ROLE_NAMES.map((name) => {
const opt = document.createElement("option");
opt.value = name;
opt.textContent = name;
return opt;
});
}
async function loadRolesTab() { async function loadRolesTab() {
document.getElementById("rg-role").replaceChildren(...roleOptions()); roleCheckboxGrid(document.getElementById("rg-role-grid"));
document.getElementById("grg-role").replaceChildren(...roleOptions()); roleCheckboxGrid(document.getElementById("grg-role-grid"));
await refreshSharedSelects(); await refreshSharedSelects();
await Promise.all([refreshRoleGrants(), refreshGroupRoleGrants()]); await Promise.all([refreshRoleGrants(), refreshGroupRoleGrants()]);
} }
@ -518,13 +875,16 @@
document.getElementById("role-grant-form").addEventListener("submit", async (ev) => { document.getElementById("role-grant-form").addEventListener("submit", async (ev) => {
ev.preventDefault(); ev.preventDefault();
try { try {
const role_names = checkedValues(document.getElementById("rg-role-grid"));
if (role_names.length === 0) throw new Error("Mindestens eine Rolle auswaehlen.");
await sendJson("/admin/roles/grant", "POST", { await sendJson("/admin/roles/grant", "POST", {
user_id: Number(document.getElementById("rg-user").value), user_id: Number(document.getElementById("rg-user").value),
host_group_id: Number(document.getElementById("rg-hostgroup").value), host_group_id: Number(document.getElementById("rg-hostgroup").value),
role_name: document.getElementById("rg-role").value, role_names,
expires_at: document.getElementById("rg-expires").value.trim() || null, expires_at: document.getElementById("rg-expires").value.trim() || null,
}); });
showBanner("Rolle vergeben.", "ok"); showBanner(`Rolle(n) vergeben: ${role_names.join(", ")}.`, "ok");
document.querySelectorAll("#rg-role-grid input:checked").forEach((c) => { c.checked = false; });
await refreshRoleGrants(); await refreshRoleGrants();
} catch (err) { } catch (err) {
showBanner(err.message, "error"); showBanner(err.message, "error");
@ -534,13 +894,16 @@
document.getElementById("group-role-grant-form").addEventListener("submit", async (ev) => { document.getElementById("group-role-grant-form").addEventListener("submit", async (ev) => {
ev.preventDefault(); ev.preventDefault();
try { try {
const role_names = checkedValues(document.getElementById("grg-role-grid"));
if (role_names.length === 0) throw new Error("Mindestens eine Rolle auswaehlen.");
await sendJson("/admin/group-roles/grant", "POST", { await sendJson("/admin/group-roles/grant", "POST", {
user_group_id: Number(document.getElementById("grg-group").value), user_group_id: Number(document.getElementById("grg-group").value),
host_group_id: Number(document.getElementById("grg-hostgroup").value), host_group_id: Number(document.getElementById("grg-hostgroup").value),
role_name: document.getElementById("grg-role").value, role_names,
expires_at: document.getElementById("grg-expires").value.trim() || null, expires_at: document.getElementById("grg-expires").value.trim() || null,
}); });
showBanner("Rolle an Gruppe vergeben.", "ok"); showBanner(`Rolle(n) an Gruppe vergeben: ${role_names.join(", ")}.`, "ok");
document.querySelectorAll("#grg-role-grid input:checked").forEach((c) => { c.checked = false; });
await refreshGroupRoleGrants(); await refreshGroupRoleGrants();
} catch (err) { } catch (err) {
showBanner(err.message, "error"); showBanner(err.message, "error");
@ -570,6 +933,8 @@
}) })
); );
} }
document.getElementById("tc-tenant-box").classList.toggle("hidden", !meInfo.is_admin && meInfo.tenant_admin_of.length <= 1);
await populateTenantSelect(document.getElementById("tc-tenant"));
await refreshSharedSelects(); await refreshSharedSelects();
await refreshTokens(); await refreshTokens();
} }
@ -599,6 +964,7 @@
el("td", { textContent: t.label }), el("td", { textContent: t.label }),
el("td", { textContent: t.username }), el("td", { textContent: t.username }),
el("td", { textContent: t.prefix }), el("td", { textContent: t.prefix }),
el("td", { textContent: t.tenant_name }),
scopesCell, scopesCell,
el("td", { textContent: t.last_used_at || "nie" }), el("td", { textContent: t.last_used_at || "nie" }),
el("td", { textContent: status }), el("td", { textContent: status }),
@ -615,11 +981,13 @@
if (scopes.length === 0) { if (scopes.length === 0) {
throw new Error("Mindestens einen Scope auswaehlen."); throw new Error("Mindestens einen Scope auswaehlen.");
} }
const tenantSelect = document.getElementById("tc-tenant");
const payload = { const payload = {
label: document.getElementById("tc-label").value.trim(), label: document.getElementById("tc-label").value.trim(),
user_id: Number(document.getElementById("tc-user").value), user_id: Number(document.getElementById("tc-user").value),
scopes, scopes,
expires_at: document.getElementById("tc-expires").value.trim() || null, expires_at: document.getElementById("tc-expires").value.trim() || null,
tenant_id: tenantSelect.value ? Number(tenantSelect.value) : null,
}; };
const result = await sendJson("/admin/tokens", "POST", payload); const result = await sendJson("/admin/tokens", "POST", payload);
const box = document.getElementById("token-reveal-box"); const box = document.getElementById("token-reveal-box");
@ -634,6 +1002,156 @@
} }
}); });
// ---------------------------------------------------------------------
// Mandanten (nur Super-Admin -- Tab-Button ist fuer alle anderen hidden)
// ---------------------------------------------------------------------
let editingTenantId = null;
let activeTenantAdminsId = null;
async function loadTenantsTab() {
await refreshTenants();
}
async function refreshTenants() {
cachedTenants = await getJson("/admin/tenants");
const tbody = document.querySelector("#tenants-table tbody");
fillTable(
tbody,
cachedTenants.map((t) =>
el("tr", {}, [
el("td", { textContent: String(t.id) }),
el("td", { textContent: t.name }),
el("td", { textContent: t.description || "" }),
el("td", { textContent: t.is_active ? "ja" : "nein" }),
el("td", { textContent: String(t.host_group_count) }),
el("td", { textContent: String(t.user_group_count) }),
el("td", {}, [
actionButton("Admins", "btn-secondary", () => showTenantAdmins(t)),
actionButton("Bearbeiten", "btn-secondary", () => showTenantEdit(t)),
actionButton("Loeschen", "btn-danger", async () => {
await apiFetch(`/admin/tenants/${t.id}`, { method: "DELETE" });
showBanner(`Mandant '${t.name}' geloescht.`, "ok");
await refreshTenants();
}),
]),
])
)
);
}
function showTenantEdit(t) {
editingTenantId = t.id;
document.getElementById("tenant-edit-panel").classList.remove("hidden");
document.getElementById("tenant-edit-name").textContent = t.name;
document.getElementById("tne-name").value = t.name;
document.getElementById("tne-description").value = t.description || "";
document.getElementById("tne-active").checked = t.is_active;
}
document.getElementById("tenant-edit-form").addEventListener("submit", async (ev) => {
ev.preventDefault();
try {
await sendJson(`/admin/tenants/${editingTenantId}`, "PUT", {
name: document.getElementById("tne-name").value.trim(),
description: document.getElementById("tne-description").value.trim() || null,
is_active: document.getElementById("tne-active").checked,
});
showBanner("Mandant aktualisiert.", "ok");
await refreshTenants();
} catch (err) {
showBanner(err.message, "error");
}
});
async function showTenantAdmins(t) {
activeTenantAdminsId = t.id;
document.getElementById("tenant-admins-panel").classList.remove("hidden");
document.getElementById("tenant-admins-name").textContent = t.name;
await refreshTenantAdmins();
const select = document.getElementById("ta-user-select");
if (cachedUsers.length === 0) await refreshUsers();
select.replaceChildren(...optionsFromList(cachedUsers, "id", (u) => `${u.username} (#${u.id})`));
}
async function refreshTenantAdmins() {
const admins = await getJson(`/admin/tenants/${activeTenantAdminsId}/admins`);
const tbody = document.querySelector("#tenant-admins-table tbody");
fillTable(
tbody,
admins.map((a) =>
el("tr", {}, [
el("td", { textContent: String(a.user_id) }),
el("td", { textContent: a.username }),
el("td", { textContent: a.granted_at }),
el("td", {}, [
actionButton("Entfernen", "btn-danger", async () => {
await apiFetch(`/admin/tenants/${activeTenantAdminsId}/admins/${a.user_id}`, { method: "DELETE" });
await refreshTenantAdmins();
}),
]),
])
)
);
}
document.getElementById("tenant-create-form").addEventListener("submit", async (ev) => {
ev.preventDefault();
try {
const name = document.getElementById("tnc-name").value.trim();
const description = document.getElementById("tnc-description").value.trim() || null;
await sendJson("/admin/tenants", "POST", { name, description });
showBanner(`Mandant '${name}' angelegt.`, "ok");
ev.target.reset();
await refreshTenants();
} catch (err) {
showBanner(err.message, "error");
}
});
document.getElementById("tenant-admin-add-form").addEventListener("submit", async (ev) => {
ev.preventDefault();
try {
const userId = Number(document.getElementById("ta-user-select").value);
await sendJson(`/admin/tenants/${activeTenantAdminsId}/admins`, "POST", { user_id: userId });
showBanner("Mandanten-Admin hinzugefuegt.", "ok");
await refreshTenantAdmins();
} catch (err) {
showBanner(err.message, "error");
}
});
// ---------------------------------------------------------------------
// Login-Verlauf (Auth-Log) -- clientseitig aus dem Audit-Log gefiltert
// ---------------------------------------------------------------------
async function loadAuthLogTab() {
await refreshAuthLog();
}
async function refreshAuthLog() {
const entries = await getJson("/admin/audit-log?limit=500");
const authEntries = entries.filter((e) => AUTH_EVENT_TYPES.has(e.event_type));
const tbody = document.querySelector("#authlog-table tbody");
fillTable(
tbody,
authEntries.map((e) =>
el("tr", {}, [
el("td", { textContent: e.ts }),
el("td", { textContent: e.user_id === null ? "-" : String(e.user_id) }),
el("td", { textContent: e.client_ip || "-" }),
el("td", { textContent: e.event_type }),
el("td", {}, [
el("span", {
className: `badge ${FAILURE_EVENT_TYPES.has(e.event_type) ? "danger" : "ok"}`,
textContent: FAILURE_EVENT_TYPES.has(e.event_type) ? "fehlgeschlagen" : "erfolgreich",
}),
]),
])
)
);
}
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
// Audit-Log // Audit-Log
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
@ -710,11 +1228,16 @@
async function main() { async function main() {
const me = await getJson("/auth/me"); const me = await getJson("/auth/me");
if (!me.is_admin) { if (!me.is_any_admin) {
window.location.href = "/dashboard"; window.location.href = "/dashboard";
return; return;
} }
document.getElementById("whoami").textContent = `${me.username} (Admin)`; meInfo = me;
const roleLabel = me.is_admin
? "Super-Admin"
: `Mandanten-Admin: ${me.tenant_admin_of.map((t) => t.name).join(", ")}`;
document.getElementById("whoami").textContent = `${me.username} (${roleLabel})`;
document.getElementById("tenants-tab-btn").classList.toggle("hidden", !me.is_admin);
loadedTabs.add("users"); loadedTabs.add("users");
await loadUsersTab(); await loadUsersTab();
} }

View File

@ -5,6 +5,12 @@
* Copy & Paste: xterm.js liefert dies bei SSH bereits nativ ueber die * Copy & Paste: xterm.js liefert dies bei SSH bereits nativ ueber die
* System-Zwischenablage (Markieren-zum-Kopieren / Strg+Umschalt+V) -- keine * System-Zwischenablage (Markieren-zum-Kopieren / Strg+Umschalt+V) -- keine
* serverseitige Sonderbehandlung noetig, im Gegensatz zu RDP (siehe rdp.js). * serverseitige Sonderbehandlung noetig, im Gegensatz zu RDP (siehe rdp.js).
*
* Dateitransfer: eigenes Panel (statt frueherem prompt()-basiertem Upload)
* mit Formularen fuer Upload UND Download in beide Richtungen. Der Download
* laeuft ueber fetch() + Blob + synthetischen <a download>-Link, damit
* Fehler inline im Panel angezeigt werden koennen statt die Seite zu
* verlassen (siehe GET /ssh/{host_id}/files/download).
*/ */
(() => { (() => {
"use strict"; "use strict";
@ -73,17 +79,100 @@
}); });
document.addEventListener("fullscreenchange", sendResize); document.addEventListener("fullscreenchange", sendResize);
const fileInput = document.getElementById("file-input"); // --- Dateitransfer-Panel ------------------------------------------------
document.getElementById("upload-btn").addEventListener("click", () => fileInput.click());
fileInput.addEventListener("change", async () => { const ftOverlay = document.getElementById("ft-overlay");
const file = fileInput.files[0]; const ftStatus = document.getElementById("ft-status");
if (!file) return; const ftList = document.getElementById("ft-list");
const remotePath = prompt("Zielpfad auf dem Server:", `/tmp/${file.name}`); const transfers = [];
if (!remotePath) return;
function openFt() {
ftOverlay.classList.remove("hidden");
ftStatus.textContent = "";
}
function closeFt() {
ftOverlay.classList.add("hidden");
}
document.getElementById("filetransfer-btn").addEventListener("click", openFt);
document.getElementById("ft-close-btn").addEventListener("click", closeFt);
function formatSize(bytes) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function recordTransfer(direction, filename, size, ok, message) {
transfers.unshift({ direction, filename, size, ok, message });
if (transfers.length > 20) transfers.length = 20;
renderTransfers();
}
function renderTransfers() {
ftList.innerHTML = "";
if (transfers.length === 0) {
const empty = document.createElement("div");
empty.className = "hint";
empty.textContent = "Noch keine Transfers in dieser Sitzung.";
ftList.appendChild(empty);
return;
}
for (const t of transfers) {
const row = document.createElement("div");
row.className = "ft-list-row";
const dir = document.createElement("span");
dir.className = "badge " + (t.ok ? "ok" : "danger");
dir.textContent = t.direction === "upload" ? "↑ Upload" : "↓ Download";
row.appendChild(dir);
const name = document.createElement("span");
name.className = "name";
name.textContent = t.ok ? t.filename : `${t.filename} -- ${t.message}`;
row.appendChild(name);
if (t.ok) {
const size = document.createElement("span");
size.className = "size";
size.textContent = formatSize(t.size);
row.appendChild(size);
}
ftList.appendChild(row);
}
}
renderTransfers();
// Upload
const uploadRemotePathInput = document.getElementById("ft-upload-remote-path");
const uploadFileInput = document.getElementById("ft-upload-file-input");
const uploadFilenameLabel = document.getElementById("ft-upload-filename");
document.getElementById("ft-upload-pick-btn").addEventListener("click", () => uploadFileInput.click());
uploadFileInput.addEventListener("change", () => {
const file = uploadFileInput.files[0];
uploadFilenameLabel.textContent = file ? file.name : "Keine Datei ausgewaehlt";
if (file && !uploadRemotePathInput.value) {
uploadRemotePathInput.value = `/tmp/${file.name}`;
}
});
document.getElementById("ft-upload-submit-btn").addEventListener("click", async () => {
const file = uploadFileInput.files[0];
const remotePath = uploadRemotePathInput.value.trim();
ftStatus.textContent = "";
if (!file) {
ftStatus.textContent = "Bitte zuerst eine Datei auswaehlen.";
return;
}
if (!remotePath) {
ftStatus.textContent = "Bitte einen Zielpfad angeben.";
return;
}
const formData = new FormData(); const formData = new FormData();
formData.append("file", file); formData.append("file", file);
statusEl.textContent = `Lade ${file.name} hoch ...`; ftStatus.textContent = `Lade ${file.name} hoch ...`;
try { try {
const res = await fetch( const res = await fetch(
`/ssh/${hostId}/files/upload?remote_path=${encodeURIComponent(remotePath)}`, `/ssh/${hostId}/files/upload?remote_path=${encodeURIComponent(remotePath)}`,
@ -91,10 +180,58 @@
); );
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data.detail || "Upload fehlgeschlagen"); if (!res.ok) throw new Error(data.detail || "Upload fehlgeschlagen");
statusEl.textContent = `Upload ok (AV: ${data.av_scan_result})`; ftStatus.textContent = `Upload abgeschlossen (AV: ${data.av_scan_result})`;
recordTransfer("upload", file.name, data.size, true, "");
uploadFileInput.value = "";
uploadFilenameLabel.textContent = "Keine Datei ausgewaehlt";
uploadRemotePathInput.value = "";
} catch (err) { } catch (err) {
statusEl.textContent = "Upload-Fehler: " + err.message; ftStatus.textContent = "Upload-Fehler: " + err.message;
recordTransfer("upload", file.name, 0, false, err.message);
}
});
// Download
const downloadRemotePathInput = document.getElementById("ft-download-remote-path");
document.getElementById("ft-download-submit-btn").addEventListener("click", async () => {
const remotePath = downloadRemotePathInput.value.trim();
ftStatus.textContent = "";
if (!remotePath) {
ftStatus.textContent = "Bitte einen Pfad angeben.";
return;
}
const filename = remotePath.split("/").filter(Boolean).pop() || "download";
ftStatus.textContent = `Lade ${filename} herunter ...`;
try {
const res = await fetch(
`/ssh/${hostId}/files/download?remote_path=${encodeURIComponent(remotePath)}`,
{ method: "GET", credentials: "same-origin" }
);
if (!res.ok) {
let message = "Download fehlgeschlagen";
try {
const data = await res.json();
message = data.detail || message;
} catch (_) { /* Antwort war kein JSON */ }
throw new Error(message);
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
ftStatus.textContent = `Download abgeschlossen: ${filename}`;
recordTransfer("download", filename, blob.size, true, "");
downloadRemotePathInput.value = "";
} catch (err) {
ftStatus.textContent = "Download-Fehler: " + err.message;
recordTransfer("download", filename, 0, false, err.message);
} }
fileInput.value = "";
}); });
})(); })();

View File

@ -24,8 +24,11 @@
<button type="button" class="tab-btn active" data-tab="users">Benutzer</button> <button type="button" class="tab-btn active" data-tab="users">Benutzer</button>
<button type="button" class="tab-btn" data-tab="groups">Benutzergruppen</button> <button type="button" class="tab-btn" data-tab="groups">Benutzergruppen</button>
<button type="button" class="tab-btn" data-tab="hosts">Hosts &amp; Verbindungen</button> <button type="button" class="tab-btn" data-tab="hosts">Hosts &amp; Verbindungen</button>
<button type="button" class="tab-btn" data-tab="credentials">Zugangsdaten</button>
<button type="button" class="tab-btn" data-tab="roles">Rollen</button> <button type="button" class="tab-btn" data-tab="roles">Rollen</button>
<button type="button" class="tab-btn" data-tab="tokens">API-Tokens</button> <button type="button" class="tab-btn" data-tab="tokens">API-Tokens</button>
<button type="button" class="tab-btn hidden" data-tab="tenants" id="tenants-tab-btn">Mandanten</button>
<button type="button" class="tab-btn" data-tab="authlog">Login-Verlauf</button>
<button type="button" class="tab-btn" data-tab="audit">Audit-Log</button> <button type="button" class="tab-btn" data-tab="audit">Audit-Log</button>
</div> </div>
@ -42,9 +45,9 @@
<label for="uc-password">Initiales Passwort (min. 12 Zeichen)</label> <label for="uc-password">Initiales Passwort (min. 12 Zeichen)</label>
<input type="text" id="uc-password" required minlength="12" maxlength="256"> <input type="text" id="uc-password" required minlength="12" maxlength="256">
</div> </div>
<div class="checkbox-row"> <div class="checkbox-row" id="uc-is-admin-row">
<input type="checkbox" id="uc-is-admin"> <input type="checkbox" id="uc-is-admin">
<label for="uc-is-admin">Globaler Admin</label> <label for="uc-is-admin">Globaler Super-Admin</label>
</div> </div>
<button type="submit">Anlegen</button> <button type="submit">Anlegen</button>
</form> </form>
@ -57,13 +60,32 @@
<thead> <thead>
<tr> <tr>
<th>ID</th><th>Benutzername</th><th>Admin</th><th>Aktiv</th> <th>ID</th><th>Benutzername</th><th>Admin</th><th>Aktiv</th>
<th>TOTP</th><th>Erstellt</th><th></th> <th>TOTP</th><th>Mandant</th><th>Erstellt</th><th></th>
</tr> </tr>
</thead> </thead>
<tbody></tbody> <tbody></tbody>
</table> </table>
</div> </div>
</div> </div>
<div class="panel hidden" id="user-edit-panel">
<h2>Benutzer bearbeiten: <span id="user-edit-name"></span></h2>
<form id="user-edit-form" class="form-grid">
<div class="checkbox-row" id="ue-is-admin-row">
<input type="checkbox" id="ue-is-admin">
<label for="ue-is-admin">Globaler Super-Admin</label>
</div>
<div class="checkbox-row">
<input type="checkbox" id="ue-is-active">
<label for="ue-is-active">Aktiv</label>
</div>
<div>
<label for="ue-new-password">Neues Passwort setzen (optional, min. 12 Zeichen)</label>
<input type="text" id="ue-new-password" minlength="12" maxlength="256">
</div>
<button type="submit">Speichern</button>
</form>
</div>
</div> </div>
<!-- ================= Benutzergruppen ================= --> <!-- ================= Benutzergruppen ================= -->
@ -79,6 +101,10 @@
<label for="gc-description">Beschreibung (optional)</label> <label for="gc-description">Beschreibung (optional)</label>
<input type="text" id="gc-description" maxlength="1024"> <input type="text" id="gc-description" maxlength="1024">
</div> </div>
<div id="gc-tenant-box">
<label for="gc-tenant">Mandant</label>
<select id="gc-tenant" required></select>
</div>
<button type="submit">Anlegen</button> <button type="submit">Anlegen</button>
</form> </form>
</div> </div>
@ -88,12 +114,27 @@
<p class="hint">Rollen fuer eine Gruppe (= Verbindungen mit der ganzen Gruppe teilen) werden im Tab "Rollen" vergeben.</p> <p class="hint">Rollen fuer eine Gruppe (= Verbindungen mit der ganzen Gruppe teilen) werden im Tab "Rollen" vergeben.</p>
<div class="table-wrap"> <div class="table-wrap">
<table class="data-table" id="groups-table"> <table class="data-table" id="groups-table">
<thead><tr><th>ID</th><th>Name</th><th>Beschreibung</th><th>Mitglieder</th><th></th></tr></thead> <thead><tr><th>ID</th><th>Name</th><th>Beschreibung</th><th>Mandant</th><th>Mitglieder</th><th></th></tr></thead>
<tbody></tbody> <tbody></tbody>
</table> </table>
</div> </div>
</div> </div>
<div class="panel hidden" id="group-edit-panel">
<h2>Gruppe bearbeiten: <span id="group-edit-name"></span></h2>
<form id="group-edit-form" class="form-grid">
<div>
<label for="ge-name">Name</label>
<input type="text" id="ge-name" required maxlength="128">
</div>
<div>
<label for="ge-description">Beschreibung</label>
<input type="text" id="ge-description" maxlength="1024">
</div>
<button type="submit">Speichern</button>
</form>
</div>
<div class="panel hidden" id="group-members-panel"> <div class="panel hidden" id="group-members-panel">
<h2>Mitglieder von <span id="group-members-name"></span></h2> <h2>Mitglieder von <span id="group-members-name"></span></h2>
<div class="table-wrap"> <div class="table-wrap">
@ -126,17 +167,36 @@
<label for="hgc-description">Beschreibung (optional)</label> <label for="hgc-description">Beschreibung (optional)</label>
<input type="text" id="hgc-description" maxlength="1024"> <input type="text" id="hgc-description" maxlength="1024">
</div> </div>
<div id="hgc-tenant-box">
<label for="hgc-tenant">Mandant</label>
<select id="hgc-tenant" required></select>
</div>
<button type="submit">Anlegen</button> <button type="submit">Anlegen</button>
</form> </form>
<h3>Hostgruppen</h3> <h3>Hostgruppen</h3>
<div class="table-wrap"> <div class="table-wrap">
<table class="data-table" id="hostgroups-table"> <table class="data-table" id="hostgroups-table">
<thead><tr><th>ID</th><th>Name</th><th>Beschreibung</th></tr></thead> <thead><tr><th>ID</th><th>Name</th><th>Beschreibung</th><th>Mandant</th><th></th></tr></thead>
<tbody></tbody> <tbody></tbody>
</table> </table>
</div> </div>
</div> </div>
<div class="panel hidden" id="hostgroup-edit-panel">
<h2>Hostgruppe bearbeiten: <span id="hostgroup-edit-name"></span></h2>
<form id="hostgroup-edit-form" class="form-grid">
<div>
<label for="hge-name">Name</label>
<input type="text" id="hge-name" required maxlength="128">
</div>
<div>
<label for="hge-description">Beschreibung</label>
<input type="text" id="hge-description" maxlength="1024">
</div>
<button type="submit">Speichern</button>
</form>
</div>
<div class="panel"> <div class="panel">
<h2>Neue Verbindung (Host) anlegen</h2> <h2>Neue Verbindung (Host) anlegen</h2>
<form id="host-create-form" class="form-grid"> <form id="host-create-form" class="form-grid">
@ -194,7 +254,7 @@
<div class="table-wrap"> <div class="table-wrap">
<table class="data-table" id="hosts-table"> <table class="data-table" id="hosts-table">
<thead> <thead>
<tr><th>ID</th><th>Hostname</th><th>Adresse</th><th>Protokoll</th><th>OS</th><th>Hostgruppe</th><th></th></tr> <tr><th>ID</th><th>Hostname</th><th>Adresse</th><th>Protokoll</th><th>OS</th><th>Hostgruppe</th><th>Mandant</th><th>Status</th><th></th></tr>
</thead> </thead>
<tbody></tbody> <tbody></tbody>
</table> </table>
@ -203,33 +263,78 @@
<div class="panel hidden" id="host-detail-panel"> <div class="panel hidden" id="host-detail-panel">
<h2>Verbindung: <span id="host-detail-name"></span></h2> <h2>Verbindung: <span id="host-detail-name"></span></h2>
<div id="host-detail-banner"></div>
<h3>Felder bearbeiten</h3>
<form id="host-edit-form" class="form-grid">
<div>
<label for="he-hostname">Hostname (Label)</label>
<input type="text" id="he-hostname" required maxlength="128" pattern="[a-zA-Z0-9][a-zA-Z0-9._-]*">
</div>
<div>
<label for="he-address">Adresse (IP/DNS)</label>
<input type="text" id="he-address" required maxlength="255">
</div>
<div>
<label for="he-port">Port</label>
<input type="text" id="he-port" inputmode="numeric" required maxlength="5">
</div>
<div id="he-ssh-username-box">
<label for="he-ssh-username">SSH-Benutzername</label>
<input type="text" id="he-ssh-username" maxlength="128">
</div>
<div id="he-rdp-username-box" class="hidden">
<label for="he-rdp-username">RDP-Benutzername</label>
<input type="text" id="he-rdp-username" maxlength="128">
</div>
<div id="he-rdp-domain-box" class="hidden">
<label for="he-rdp-domain">RDP-Domaene</label>
<input type="text" id="he-rdp-domain" maxlength="128">
</div>
<div class="checkbox-row"><input type="checkbox" id="he-clipboard"><label for="he-clipboard">Zwischenablage erlaubt</label></div>
<div class="checkbox-row"><input type="checkbox" id="he-filetransfer"><label for="he-filetransfer">Dateitransfer erlaubt</label></div>
<div class="checkbox-row hidden" id="he-nla-box"><input type="checkbox" id="he-nla"><label for="he-nla">RDP: NLA erforderlich</label></div>
<div class="checkbox-row"><input type="checkbox" id="he-active"><label for="he-active">Aktiv (verbindbar)</label></div>
<button type="submit">Speichern</button>
</form>
<div id="host-detail-ssh"> <div id="host-detail-ssh">
<h3>SSH-Host-Key</h3> <h3>SSH-Host-Key</h3>
<p class="hint">Ermittelt den Host-Key EINMALIG ohne Pruefung (bewusste Trust-Entscheidung) und speichert den Fingerprint fuer kuenftiges Pinning. Wird prominent im Audit-Log vermerkt.</p> <p class="hint">Ermittelt den Host-Key EINMALIG ohne Pruefung (bewusste Trust-Entscheidung) und speichert den Fingerprint fuer kuenftiges Pinning. Wird prominent im Audit-Log vermerkt.</p>
<button type="button" class="btn-secondary" id="host-discover-key-btn">Host-Key ermitteln</button> <button type="button" class="btn-secondary" id="host-discover-key-btn">Host-Key ermitteln</button>
<div id="host-key-result" class="reveal-box hidden"></div> <div id="host-key-result" class="reveal-box hidden"></div>
<h3>SSH-Key zuordnen</h3> <h3>Zugeordnete SSH-Keys</h3>
<div class="table-wrap">
<table class="data-table" id="host-ssh-keys-table">
<thead><tr><th>ID</th><th>Label</th><th></th></tr></thead>
<tbody></tbody>
</table>
</div>
<form id="host-key-map-form" class="form-grid"> <form id="host-key-map-form" class="form-grid">
<div> <div>
<label for="hkm-key-select">SSH-Key</label> <label for="hkm-key-select">SSH-Key zuordnen</label>
<select id="hkm-key-select" required></select> <select id="hkm-key-select" required></select>
</div> </div>
<button type="submit">Zuordnen</button> <button type="submit">Zuordnen</button>
</form> </form>
</div> </div>
<div id="host-detail-rdp" class="hidden"> <div id="host-detail-rdp" class="hidden">
<h3>RDP-Zugangsdaten setzen</h3> <h3>RDP-Zugangsdaten</h3>
<p class="hint" id="rdp-cred-status"></p>
<form id="host-rdp-cred-form" class="form-grid"> <form id="host-rdp-cred-form" class="form-grid">
<div class="full-width"> <div class="full-width">
<label for="hrc-password">RDP-Passwort</label> <label for="hrc-password">RDP-Passwort setzen/aendern</label>
<input type="text" id="hrc-password" required maxlength="512"> <input type="text" id="hrc-password" required maxlength="512">
</div> </div>
<button type="submit">Speichern</button> <button type="submit">Speichern</button>
</form> </form>
</div> </div>
</div> </div>
</div>
<!-- ================= Zugangsdaten ================= -->
<div class="tab-panel hidden" id="tab-credentials">
<div class="panel"> <div class="panel">
<h2>Neuen SSH-Key anlegen</h2> <h2>Neuen SSH-Key anlegen</h2>
<form id="ssh-key-create-form" class="form-grid"> <form id="ssh-key-create-form" class="form-grid">
@ -250,6 +355,10 @@
<option value="ca-cert">ca-cert</option> <option value="ca-cert">ca-cert</option>
</select> </select>
</div> </div>
<div id="skc-tenant-box">
<label for="skc-tenant">Mandant</label>
<select id="skc-tenant" required></select>
</div>
<div class="full-width"> <div class="full-width">
<label for="skc-private">Privater Schluessel (PEM)</label> <label for="skc-private">Privater Schluessel (PEM)</label>
<textarea id="skc-private" required></textarea> <textarea id="skc-private" required></textarea>
@ -263,7 +372,55 @@
<h3>Vorhandene SSH-Keys</h3> <h3>Vorhandene SSH-Keys</h3>
<div class="table-wrap"> <div class="table-wrap">
<table class="data-table" id="ssh-keys-table"> <table class="data-table" id="ssh-keys-table">
<thead><tr><th>ID</th><th>Label</th><th>Typ</th><th>Besitzer</th><th>Erstellt</th></tr></thead> <thead><tr><th>ID</th><th>Label</th><th>Typ</th><th>Besitzer</th><th>Mandant</th><th>Erstellt</th><th>Rotiert</th><th></th></tr></thead>
<tbody></tbody>
</table>
</div>
</div>
<div class="panel hidden" id="ssh-key-edit-panel">
<h2>SSH-Key bearbeiten: <span id="ssh-key-edit-name"></span></h2>
<form id="ssh-key-edit-form" class="form-grid">
<div>
<label for="ske-label">Label</label>
<input type="text" id="ske-label" maxlength="128">
</div>
<div>
<label for="ske-owner">Besitzer (User-ID, leer = keiner)</label>
<input type="text" id="ske-owner" inputmode="numeric" maxlength="10">
</div>
<button type="submit">Speichern (nur Label/Besitzer)</button>
</form>
<h3>Schluessel rotieren</h3>
<p class="hint">Ersetzt das Schluesselmaterial vollstaendig (neuer privater + oeffentlicher Schluessel). Bereits zugeordnete Hosts nutzen danach automatisch den neuen Schluessel.</p>
<form id="ssh-key-rotate-form" class="form-grid">
<div>
<label for="skr-type">Key-Typ</label>
<select id="skr-type">
<option value="ed25519">ed25519</option>
<option value="rsa-3072">rsa-3072</option>
<option value="rsa-4096">rsa-4096</option>
<option value="ca-cert">ca-cert</option>
</select>
</div>
<div class="full-width">
<label for="skr-private">Neuer privater Schluessel (PEM)</label>
<textarea id="skr-private" required></textarea>
</div>
<div class="full-width">
<label for="skr-public">Neuer oeffentlicher Schluessel</label>
<textarea id="skr-public" required></textarea>
</div>
<button type="submit" class="btn-secondary">Rotieren</button>
</form>
</div>
<div class="panel">
<h2>RDP/Windows-Passwoerter</h2>
<p class="hint">Uebersicht aller RDP-Hosts. Passwoerter werden AES-256-GCM-verschluesselt gespeichert und sind nach dem Setzen nicht mehr im Klartext abrufbar.</p>
<div class="table-wrap">
<table class="data-table" id="rdp-creds-table">
<thead><tr><th>Host</th><th>Adresse</th><th>Hostgruppe</th><th>Status</th><th>Zuletzt gesetzt</th><th></th></tr></thead>
<tbody></tbody> <tbody></tbody>
</table> </table>
</div> </div>
@ -273,7 +430,8 @@
<!-- ================= Rollen ================= --> <!-- ================= Rollen ================= -->
<div class="tab-panel hidden" id="tab-roles"> <div class="tab-panel hidden" id="tab-roles">
<div class="panel"> <div class="panel">
<h2>Rolle an Benutzer vergeben</h2> <h2>Rolle(n) an Benutzer vergeben</h2>
<p class="hint">Mehrfachauswahl moeglich -- alle ausgewaehlten Rollen werden in einem Schritt vergeben.</p>
<form id="role-grant-form" class="form-grid"> <form id="role-grant-form" class="form-grid">
<div> <div>
<label for="rg-user">Benutzer</label> <label for="rg-user">Benutzer</label>
@ -283,9 +441,9 @@
<label for="rg-hostgroup">Hostgruppe</label> <label for="rg-hostgroup">Hostgruppe</label>
<select id="rg-hostgroup" required></select> <select id="rg-hostgroup" required></select>
</div> </div>
<div> <div class="full-width">
<label for="rg-role">Rolle</label> <label>Rollen</label>
<select id="rg-role"></select> <div class="scope-grid" id="rg-role-grid"></div>
</div> </div>
<div> <div>
<label for="rg-expires">Ablaufdatum (optional, ISO)</label> <label for="rg-expires">Ablaufdatum (optional, ISO)</label>
@ -302,8 +460,8 @@
</div> </div>
<div class="panel"> <div class="panel">
<h2>Rolle an Benutzergruppe vergeben (Verbindung mit Gruppe teilen)</h2> <h2>Rolle(n) an Benutzergruppe vergeben (Verbindung mit Gruppe teilen)</h2>
<p class="hint">Volle Rollen-Vererbung: jedes aktuelle und zukuenftige Mitglied der Gruppe erhaelt diese Rolle automatisch.</p> <p class="hint">Volle Rollen-Vererbung: jedes aktuelle und zukuenftige Mitglied der Gruppe erhaelt diese Rollen automatisch. Mehrfachauswahl moeglich.</p>
<form id="group-role-grant-form" class="form-grid"> <form id="group-role-grant-form" class="form-grid">
<div> <div>
<label for="grg-group">Benutzergruppe</label> <label for="grg-group">Benutzergruppe</label>
@ -313,9 +471,9 @@
<label for="grg-hostgroup">Hostgruppe</label> <label for="grg-hostgroup">Hostgruppe</label>
<select id="grg-hostgroup" required></select> <select id="grg-hostgroup" required></select>
</div> </div>
<div> <div class="full-width">
<label for="grg-role">Rolle</label> <label>Rollen</label>
<select id="grg-role"></select> <div class="scope-grid" id="grg-role-grid"></div>
</div> </div>
<div> <div>
<label for="grg-expires">Ablaufdatum (optional, ISO)</label> <label for="grg-expires">Ablaufdatum (optional, ISO)</label>
@ -336,7 +494,7 @@
<div class="tab-panel hidden" id="tab-tokens"> <div class="tab-panel hidden" id="tab-tokens">
<div class="panel"> <div class="panel">
<h2>Neues API-Token erstellen</h2> <h2>Neues API-Token erstellen</h2>
<p class="hint">Tokens wirken NUR auf die Verwaltungs-API (Benutzer/Hosts/Rollen/...), nie auf SSH-/RDP-Sitzungen selbst. "rw" schliesst "ro" auf derselben Ressource automatisch mit ein.</p> <p class="hint">Tokens wirken NUR auf die Verwaltungs-API (Benutzer/Hosts/Rollen/...), nie auf SSH-/RDP-Sitzungen selbst. "rw" schliesst "ro" auf derselben Ressource automatisch mit ein. Jedes Token gehoert genau einem Mandanten.</p>
<form id="token-create-form" class="form-grid"> <form id="token-create-form" class="form-grid">
<div> <div>
<label for="tc-label">Bezeichnung</label> <label for="tc-label">Bezeichnung</label>
@ -346,6 +504,10 @@
<label for="tc-user">Fuer Benutzer</label> <label for="tc-user">Fuer Benutzer</label>
<select id="tc-user" required></select> <select id="tc-user" required></select>
</div> </div>
<div id="tc-tenant-box">
<label for="tc-tenant">Mandant</label>
<select id="tc-tenant" required></select>
</div>
<div> <div>
<label for="tc-expires">Ablaufdatum (optional, ISO)</label> <label for="tc-expires">Ablaufdatum (optional, ISO)</label>
<input type="text" id="tc-expires" placeholder="2026-12-31T00:00:00.000Z" maxlength="40"> <input type="text" id="tc-expires" placeholder="2026-12-31T00:00:00.000Z" maxlength="40">
@ -364,7 +526,7 @@
<div class="table-wrap"> <div class="table-wrap">
<table class="data-table" id="tokens-table"> <table class="data-table" id="tokens-table">
<thead> <thead>
<tr><th>ID</th><th>Bezeichnung</th><th>Benutzer</th><th>Praefix</th><th>Scopes</th><th>Zuletzt genutzt</th><th>Status</th><th></th></tr> <tr><th>ID</th><th>Bezeichnung</th><th>Benutzer</th><th>Praefix</th><th>Mandant</th><th>Scopes</th><th>Zuletzt genutzt</th><th>Status</th><th></th></tr>
</thead> </thead>
<tbody></tbody> <tbody></tbody>
</table> </table>
@ -372,6 +534,79 @@
</div> </div>
</div> </div>
<!-- ================= Mandanten (nur Super-Admin) ================= -->
<div class="tab-panel hidden" id="tab-tenants">
<div class="panel">
<h2>Neuen Mandanten anlegen</h2>
<form id="tenant-create-form" class="form-grid">
<div>
<label for="tnc-name">Name</label>
<input type="text" id="tnc-name" required maxlength="128">
</div>
<div>
<label for="tnc-description">Beschreibung (optional)</label>
<input type="text" id="tnc-description" maxlength="1024">
</div>
<button type="submit">Anlegen</button>
</form>
<h3>Mandanten</h3>
<div class="table-wrap">
<table class="data-table" id="tenants-table">
<thead><tr><th>ID</th><th>Name</th><th>Beschreibung</th><th>Aktiv</th><th>Hostgruppen</th><th>Benutzergruppen</th><th></th></tr></thead>
<tbody></tbody>
</table>
</div>
</div>
<div class="panel hidden" id="tenant-edit-panel">
<h2>Mandant bearbeiten: <span id="tenant-edit-name"></span></h2>
<form id="tenant-edit-form" class="form-grid">
<div>
<label for="tne-name">Name</label>
<input type="text" id="tne-name" required maxlength="128">
</div>
<div>
<label for="tne-description">Beschreibung</label>
<input type="text" id="tne-description" maxlength="1024">
</div>
<div class="checkbox-row"><input type="checkbox" id="tne-active"><label for="tne-active">Aktiv</label></div>
<button type="submit">Speichern</button>
</form>
</div>
<div class="panel hidden" id="tenant-admins-panel">
<h2>Mandanten-Admins von <span id="tenant-admins-name"></span></h2>
<div class="table-wrap">
<table class="data-table" id="tenant-admins-table">
<thead><tr><th>User-ID</th><th>Benutzername</th><th>Seit</th><th></th></tr></thead>
<tbody></tbody>
</table>
</div>
<h3>Mandanten-Admin hinzufuegen</h3>
<form id="tenant-admin-add-form" class="form-grid">
<div>
<label for="ta-user-select">Benutzer</label>
<select id="ta-user-select" required></select>
</div>
<button type="submit">Hinzufuegen</button>
</form>
</div>
</div>
<!-- ================= Login-Verlauf ================= -->
<div class="tab-panel hidden" id="tab-authlog">
<div class="panel">
<h2>Login-Verlauf</h2>
<p class="hint">Authentifizierungs-Ereignisse (Login/Logout/TOTP/Passwortwechsel), gefiltert aus dem Audit-Log dieses/dieser Mandanten.</p>
<div class="table-wrap">
<table class="data-table" id="authlog-table">
<thead><tr><th>Zeit</th><th>Benutzer</th><th>IP</th><th>Ereignis</th><th>Status</th></tr></thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
<!-- ================= Audit-Log ================= --> <!-- ================= Audit-Log ================= -->
<div class="tab-panel hidden" id="tab-audit"> <div class="tab-panel hidden" id="tab-audit">
<div class="panel"> <div class="panel">

View File

@ -11,14 +11,41 @@
<div class="session-shell" id="session-shell" data-host-id="{{ host_id }}"> <div class="session-shell" id="session-shell" data-host-id="{{ host_id }}">
<div class="session-toolbar"> <div class="session-toolbar">
<button id="fullscreen-btn">Vollbild</button> <button id="fullscreen-btn">Vollbild</button>
<button id="upload-btn">Datei hochladen</button> <button id="filetransfer-btn">Dateitransfer</button>
<input type="file" id="file-input" class="hidden">
<span class="spacer"></span> <span class="spacer"></span>
<span class="status" id="status">Verbinde ...</span> <span class="status" id="status">Verbinde ...</span>
</div> </div>
<div id="terminal"></div> <div id="terminal"></div>
</div> </div>
<div class="ft-overlay hidden" id="ft-overlay">
<div class="ft-panel">
<h2>Dateitransfer
<button type="button" class="btn-secondary btn-small" id="ft-close-btn">Schliessen</button>
</h2>
<h3>Hochladen</h3>
<label for="ft-upload-remote-path">Zielpfad auf dem Server</label>
<input type="text" id="ft-upload-remote-path" placeholder="/tmp/datei.txt">
<input type="file" id="ft-upload-file-input" class="hidden">
<div class="ft-btn-row">
<button type="button" class="btn-secondary btn-small" id="ft-upload-pick-btn">Datei auswaehlen</button>
<span class="hint" id="ft-upload-filename">Keine Datei ausgewaehlt</span>
</div>
<button type="button" class="btn-small" id="ft-upload-submit-btn">Hochladen</button>
<h3>Herunterladen</h3>
<label for="ft-download-remote-path">Pfad auf dem Server</label>
<input type="text" id="ft-download-remote-path" placeholder="/etc/hosts">
<button type="button" class="btn-small" id="ft-download-submit-btn">Herunterladen</button>
<div class="error" id="ft-status"></div>
<h3>Letzte Transfers</h3>
<div class="ft-list" id="ft-list"></div>
</div>
</div>
<script src="/static/js/vendor/xterm.js"></script> <script src="/static/js/vendor/xterm.js"></script>
<script src="/static/js/vendor/xterm-addon-fit.js"></script> <script src="/static/js/vendor/xterm-addon-fit.js"></script>
<script src="/static/js/terminal.js"></script> <script src="/static/js/terminal.js"></script>

604
tests/test_tenants.py Normal file
View File

@ -0,0 +1,604 @@
"""
Tests fuer Mandantenfaehigkeit (volle Isolation + Mandanten-Admins, siehe
app/tenancy.py, app/db/migrations/0006_tenants.sql) sowie fuer die
CRUD-Vervollstaendigung (Edit/Delete fuer alles, was die Admin-API anlegt,
siehe 0007_crud_extras.sql) und die Mehrfachauswahl bei Rollenvergaben
(role_names: list statt role_name: str).
Deckt ab:
1) Tenant-CRUD ist ausschliesslich Super-Admin-Aktion.
2) Ein Mandanten-Admin sieht/aendert NUR Ressourcen seines eigenen
Mandanten -- Zugriff auf eine fremde Mandanten-ID liefert 404 (nicht 403,
bewusst um Existenz nicht zu verraten, siehe app/tenancy.py).
3) Der Super-Admin (Alt-Verhalten, Mandanten-uebergreifend) bleibt
vollstaendig funktionsfaehig -- keine Regression durch Mandantenfaehigkeit.
4) Mehrfachauswahl bei Rollenvergabe (role_names) funktioniert fuer
Einzel-User UND Benutzergruppen.
5) Update/Delete-Endpunkte fuer User, Hostgruppen, Hosts, SSH-Keys,
Benutzergruppen funktionieren wie vorgesehen (inkl. Anonymisieren statt
Hard-Delete bei vorhandener Audit-Historie, Soft-Delete bei Hosts).
6) GET /admin/hosts/{id} (Grundlage fuer den "Details"-Fix) liefert den
vollstaendigen aktuellen Datensatz inkl. SSH-Key-Zuordnungen.
"""
from __future__ import annotations
import pyotp
import pytest
async def _create_user(conn, username: str, password: str, *, is_admin: bool = False, home_tenant_id=None) -> int:
from app.security.passwords import hash_password
cursor = await conn.execute(
"INSERT INTO users (username, password_hash, is_admin, must_change_password, home_tenant_id) "
"VALUES (?, ?, ?, 0, ?)",
(username, hash_password(password), int(is_admin), home_tenant_id),
)
await conn.commit()
return cursor.lastrowid
async def _login_full(client, username: str, password: str) -> str:
resp = await client.post("/auth/login", json={"username": username, "password": password})
assert resp.status_code == 200, resp.text
pending = resp.json()["pending_token"]
resp = await client.post("/auth/totp/enroll/start", json={"pending_token": pending})
assert resp.status_code == 200, resp.text
provisioning_uri = resp.json()["provisioning_uri"]
secret = dict(part.split("=") for part in provisioning_uri.split("?", 1)[1].split("&"))["secret"]
code = pyotp.TOTP(secret).now()
resp = await client.post("/auth/totp/enroll/confirm", json={"pending_token": pending, "code": code})
assert resp.status_code == 200, resp.text
return resp.cookies.get("jh_session")
async def _setup_hostgroup_and_host(conn, *, tenant_id=1, group_name="team-a", hostname="srv-a"):
cursor = await conn.execute(
"INSERT INTO host_groups (name, tenant_id) VALUES (?, ?)", (group_name, tenant_id)
)
hg_id = cursor.lastrowid
cursor = await conn.execute(
"INSERT INTO hosts (host_group_id, hostname, address, protocol, port, os_type) "
"VALUES (?, ?, '10.0.0.9', 'ssh', 22, 'linux')",
(hg_id, hostname),
)
await conn.commit()
return hg_id, cursor.lastrowid
# ---------------------------------------------------------------------------
# 1) Tenant-CRUD: Super-Admin only
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_tenant_crud_requires_super_admin(client):
from app.db import get_db
conn = get_db()
await _create_user(conn, "sa1", "Correct-Horse-Battery-Staple-A1", is_admin=True)
await _login_full(client, "sa1", "Correct-Horse-Battery-Staple-A1")
resp = await client.post("/admin/tenants", json={"name": "Kunde A", "description": "Erster MSP-Kunde"})
assert resp.status_code == 201, resp.text
tenant_id = resp.json()["id"]
resp = await client.get("/admin/tenants")
assert resp.status_code == 200, resp.text
assert any(t["id"] == tenant_id for t in resp.json())
resp = await client.put(f"/admin/tenants/{tenant_id}", json={"description": "Aktualisiert"})
assert resp.status_code == 200, resp.text
assert resp.json()["changed"] is True
# Nicht-Admin darf nicht mal lesen.
client.cookies.clear()
await _create_user(conn, "plain_t", "Correct-Horse-Battery-Staple-A2")
await _login_full(client, "plain_t", "Correct-Horse-Battery-Staple-A2")
resp = await client.get("/admin/tenants")
assert resp.status_code == 403
resp = await client.post("/admin/tenants", json={"name": "sollte nicht klappen"})
assert resp.status_code == 403
@pytest.mark.asyncio
async def test_tenant_admin_cannot_perform_tenant_crud(client):
"""Ein Mandanten-Admin ist is_any_admin=True, aber NICHT is_admin -- Tenant-
CRUD haengt an require_global_admin (is_admin), nicht require_admin_session."""
from app.db import get_db
conn = get_db()
sa_id = await _create_user(conn, "sa2", "Correct-Horse-Battery-Staple-B1", is_admin=True)
await _login_full(client, "sa2", "Correct-Horse-Battery-Staple-B1")
resp = await client.post("/admin/tenants", json={"name": "Kunde B"})
tenant_id = resp.json()["id"]
ta_id = await _create_user(conn, "ta_user", "Correct-Horse-Battery-Staple-B2")
resp = await client.post(f"/admin/tenants/{tenant_id}/admins", json={"user_id": ta_id})
assert resp.status_code == 201, resp.text
client.cookies.clear()
await _login_full(client, "ta_user", "Correct-Horse-Battery-Staple-B2")
resp = await client.get("/admin/tenants")
assert resp.status_code == 403
resp = await client.post("/admin/tenants", json={"name": "sollte nicht klappen"})
assert resp.status_code == 403
resp = await client.post(f"/admin/tenants/{tenant_id}/admins", json={"user_id": sa_id})
assert resp.status_code == 403
@pytest.mark.asyncio
async def test_tenant_delete_blocked_while_resources_exist(client):
from app.db import get_db
conn = get_db()
await _create_user(conn, "sa3", "Correct-Horse-Battery-Staple-C1", is_admin=True)
await _login_full(client, "sa3", "Correct-Horse-Battery-Staple-C1")
resp = await client.post("/admin/tenants", json={"name": "Kunde C"})
tenant_id = resp.json()["id"]
resp = await client.post(
"/admin/host-groups", json={"name": "c-team", "tenant_id": tenant_id}
)
assert resp.status_code == 201, resp.text
hg_id = resp.json()["id"]
resp = await client.delete(f"/admin/tenants/{tenant_id}")
assert resp.status_code == 409, resp.text
resp = await client.delete(f"/admin/host-groups/{hg_id}")
assert resp.status_code == 200, resp.text
resp = await client.delete(f"/admin/tenants/{tenant_id}")
assert resp.status_code == 200, resp.text
# ---------------------------------------------------------------------------
# 2) Volle Isolation: Mandanten-Admin sieht/aendert nur den eigenen Mandanten
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_tenant_admin_isolated_from_other_tenants_hostgroups_and_hosts(client):
from app.db import get_db
conn = get_db()
await _create_user(conn, "sa4", "Correct-Horse-Battery-Staple-D1", is_admin=True)
await _login_full(client, "sa4", "Correct-Horse-Battery-Staple-D1")
resp = await client.post("/admin/tenants", json={"name": "Kunde D"})
tenant_d = resp.json()["id"]
# Ressourcen im Standard-Mandanten (id=1) UND im neuen Mandanten D anlegen.
resp = await client.post("/admin/host-groups", json={"name": "std-team", "tenant_id": 1})
hg_std = resp.json()["id"]
resp = await client.post("/admin/host-groups", json={"name": "d-team", "tenant_id": tenant_d})
hg_d = resp.json()["id"]
resp = await client.post(
"/admin/hosts",
json={
"host_group_id": hg_d, "hostname": "d-srv1", "address": "10.1.0.1",
"protocol": "ssh", "port": 22, "os_type": "linux",
},
)
assert resp.status_code == 201, resp.text
host_d = resp.json()["id"]
ta_id = await _create_user(conn, "ta_d", "Correct-Horse-Battery-Staple-D2")
resp = await client.post(f"/admin/tenants/{tenant_d}/admins", json={"user_id": ta_id})
assert resp.status_code == 201, resp.text
client.cookies.clear()
await _login_full(client, "ta_d", "Correct-Horse-Battery-Staple-D2")
# Host-Gruppen-Liste: nur der eigene Mandant ist sichtbar.
resp = await client.get("/admin/host-groups")
assert resp.status_code == 200, resp.text
ids = {hg["id"] for hg in resp.json()}
assert hg_d in ids
assert hg_std not in ids
# Direkter Zugriff auf eine fremde Hostgruppen-ID -> 404 (nicht 403).
resp = await client.put(f"/admin/host-groups/{hg_std}", json={"name": "hack"})
assert resp.status_code == 404, resp.text
resp = await client.delete(f"/admin/host-groups/{hg_std}")
assert resp.status_code == 404, resp.text
# Eigener Host ist sichtbar und aenderbar.
resp = await client.get(f"/admin/hosts/{host_d}")
assert resp.status_code == 200, resp.text
assert resp.json()["hostname"] == "d-srv1"
# Ein Host im Standard-Mandanten (existiert nicht -- wird nicht angelegt,
# aber ID 999999 simuliert "fremde/unbekannte ID" -> ebenfalls 404).
resp = await client.get("/admin/hosts/999999")
assert resp.status_code == 404
# Anlegen einer Hostgruppe OHNE tenant_id wird automatisch auf den
# eigenen (einzigen) Mandanten erzwungen, Client-Angaben eines fremden
# Mandanten werden ignoriert/abgelehnt.
resp = await client.post("/admin/host-groups", json={"name": "d-team-2"})
assert resp.status_code == 201, resp.text
resp = await client.get("/admin/host-groups")
new_ids = {hg["id"]: hg["tenant_id"] for hg in resp.json()}
assert new_ids[resp.json()[-1]["id"]] == tenant_d
@pytest.mark.asyncio
async def test_tenant_admin_isolated_from_other_tenants_users_and_ssh_keys(client):
from app.db import get_db
conn = get_db()
await _create_user(conn, "sa5", "Correct-Horse-Battery-Staple-E1", is_admin=True)
await _login_full(client, "sa5", "Correct-Horse-Battery-Staple-E1")
resp = await client.post("/admin/tenants", json={"name": "Kunde E"})
tenant_e = resp.json()["id"]
ta_id = await _create_user(conn, "ta_e", "Correct-Horse-Battery-Staple-E2")
resp = await client.post(f"/admin/tenants/{tenant_e}/admins", json={"user_id": ta_id})
assert resp.status_code == 201, resp.text
# Ein User im Standard-Mandanten (home_tenant_id=1), unsichtbar fuer ta_e.
std_user_id = await _create_user(conn, "std_user", "Correct-Horse-Battery-Staple-E3", home_tenant_id=1)
resp = await client.post(
"/admin/ssh-keys",
json={
"label": "std-key", "private_key_pem": "PEM", "public_key": "PUB",
"key_type": "ed25519", "tenant_id": 1,
},
)
assert resp.status_code == 201, resp.text
std_key_id = resp.json()["id"]
client.cookies.clear()
await _login_full(client, "ta_e", "Correct-Horse-Battery-Staple-E2")
resp = await client.get("/admin/users")
assert resp.status_code == 200, resp.text
user_ids = {u["id"] for u in resp.json()}
assert ta_id in user_ids
assert std_user_id not in user_ids
resp = await client.put(f"/admin/users/{std_user_id}", json={"is_active": False})
assert resp.status_code == 404, resp.text
resp = await client.get("/admin/ssh-keys")
key_ids = {k["id"] for k in resp.json()}
assert std_key_id not in key_ids
resp = await client.put(f"/admin/ssh-keys/{std_key_id}", json={"label": "hack"})
assert resp.status_code == 404, resp.text
# Eigenen SSH-Key anlegen -- ohne tenant_id automatisch auf E erzwungen.
resp = await client.post(
"/admin/ssh-keys",
json={"label": "e-key", "private_key_pem": "PEM2", "public_key": "PUB2", "key_type": "ed25519"},
)
assert resp.status_code == 201, resp.text
e_key_id = resp.json()["id"]
resp = await client.get("/admin/ssh-keys")
assert e_key_id in {k["id"] for k in resp.json()}
@pytest.mark.asyncio
async def test_tenant_admin_isolated_audit_log(client):
from app.db import get_db
conn = get_db()
await _create_user(conn, "sa6", "Correct-Horse-Battery-Staple-F1", is_admin=True)
await _login_full(client, "sa6", "Correct-Horse-Battery-Staple-F1")
resp = await client.post("/admin/tenants", json={"name": "Kunde F"})
tenant_f = resp.json()["id"]
ta_id = await _create_user(conn, "ta_f", "Correct-Horse-Battery-Staple-F2")
await client.post(f"/admin/tenants/{tenant_f}/admins", json={"user_id": ta_id})
client.cookies.clear()
await _login_full(client, "ta_f", "Correct-Horse-Battery-Staple-F2")
# Eigene Aktion erzeugt einen Audit-Eintrag mit user_id=ta_id.
resp = await client.post("/admin/host-groups", json={"name": "f-team"})
assert resp.status_code == 201, resp.text
resp = await client.get("/admin/audit-log")
assert resp.status_code == 200, resp.text
entries = resp.json()
assert len(entries) >= 1
assert all(e["user_id"] == ta_id for e in entries), "Mandanten-Admin darf nur eigene Aktionen sehen"
# ---------------------------------------------------------------------------
# 3) Mehrfachauswahl bei Rollenvergabe (role_names)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_multi_role_grant_for_individual_user(client):
from app.db import get_db
conn = get_db()
await _create_user(conn, "mr_admin", "Correct-Horse-Battery-Staple-G1", is_admin=True)
member_id = await _create_user(conn, "mr_member", "Correct-Horse-Battery-Staple-G2")
hg_id, host_id = await _setup_hostgroup_and_host(conn, group_name="mr-team", hostname="mr-srv")
await _login_full(client, "mr_admin", "Correct-Horse-Battery-Staple-G1")
resp = await client.post(
"/admin/roles/grant",
json={
"user_id": member_id, "host_group_id": hg_id,
"role_names": ["ssh_connect", "file_transfer"],
},
)
assert resp.status_code == 200, resp.text
assert set(resp.json()["roles_granted"]) == {"ssh_connect", "file_transfer"}
resp = await client.get("/admin/roles")
assert resp.status_code == 200, resp.text
granted_roles = {r["role_name"] for r in resp.json() if r["user_id"] == member_id}
assert {"ssh_connect", "file_transfer"} <= granted_roles
# Einzelne Rolle wieder entziehen -- die andere bleibt bestehen.
resp = await client.post(
"/admin/roles/revoke",
json={"user_id": member_id, "host_group_id": hg_id, "role_name": "file_transfer"},
)
assert resp.status_code == 200, resp.text
resp = await client.get("/admin/roles")
remaining = {r["role_name"] for r in resp.json() if r["user_id"] == member_id}
assert remaining == {"ssh_connect"}
@pytest.mark.asyncio
async def test_multi_role_grant_for_user_group(client):
from app.db import get_db
conn = get_db()
await _create_user(conn, "mrg_admin", "Correct-Horse-Battery-Staple-H1", is_admin=True)
hg_id, host_id = await _setup_hostgroup_and_host(conn, group_name="mrg-team", hostname="mrg-srv")
await _login_full(client, "mrg_admin", "Correct-Horse-Battery-Staple-H1")
resp = await client.post("/admin/user-groups", json={"name": "mrg-group"})
group_id = resp.json()["id"]
resp = await client.post(
"/admin/group-roles/grant",
json={
"user_group_id": group_id, "host_group_id": hg_id,
"role_names": ["ssh_connect", "file_transfer"],
},
)
assert resp.status_code == 200, resp.text
assert set(resp.json()["roles_granted"]) == {"ssh_connect", "file_transfer"}
resp = await client.get("/admin/group-roles")
roles = {r["role_name"] for r in resp.json() if r["user_group_id"] == group_id}
assert {"ssh_connect", "file_transfer"} <= roles
# ---------------------------------------------------------------------------
# 4) CRUD-Vervollstaendigung: Edit/Delete ueberall
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_user_update_deactivate_and_delete_semantics(client):
from app.db import get_db
conn = get_db()
await _create_user(conn, "crud_admin", "Correct-Horse-Battery-Staple-I1", is_admin=True)
await _login_full(client, "crud_admin", "Correct-Horse-Battery-Staple-I1")
resp = await client.post(
"/admin/users",
json={"username": "crud_target", "initial_password": "Correct-Horse-Battery-Staple-I2"},
)
assert resp.status_code == 201, resp.text
target_id = resp.json()["id"]
# Update: Passwort setzen -> must_change_password/session_version bumped.
resp = await client.put(f"/admin/users/{target_id}", json={"new_password": "Correct-Horse-Battery-Staple-I3"})
assert resp.status_code == 200, resp.text
assert resp.json()["changed"] is True
# Deactivate.
resp = await client.post(f"/admin/users/{target_id}/deactivate")
assert resp.status_code == 200, resp.text
resp = await client.get("/admin/users")
row = next(u for u in resp.json() if u["id"] == target_id)
assert row["is_active"] is False
# Reaktivieren ueber update.
resp = await client.put(f"/admin/users/{target_id}", json={"is_active": True})
assert resp.status_code == 200, resp.text
# Dieses Konto hat inzwischen Audit-Historie (user_updated/deactivated) ->
# Loeschen anonymisiert statt hart zu entfernen.
resp = await client.delete(f"/admin/users/{target_id}")
assert resp.status_code == 200, resp.text
assert resp.json()["hard_deleted"] is False
resp = await client.get("/admin/users")
assert all(u["id"] != target_id for u in resp.json()), "Anonymisierter User darf nicht mehr gelistet werden"
@pytest.mark.asyncio
async def test_user_without_audit_history_is_hard_deleted(client):
from app.db import get_db
conn = get_db()
await _create_user(conn, "crud_admin2", "Correct-Horse-Battery-Staple-J1", is_admin=True)
await _login_full(client, "crud_admin2", "Correct-Horse-Battery-Staple-J1")
resp = await client.post(
"/admin/users",
json={"username": "throwaway", "initial_password": "Correct-Horse-Battery-Staple-J2"},
)
target_id = resp.json()["id"]
# Das einzige Audit-Ereignis, das diesen User referenziert, waere sein
# eigenes Login -- er hat sich nie eingeloggt, also existiert keine
# audit_log-Zeile mit user_id=target_id -> Hard-Delete moeglich.
resp = await client.delete(f"/admin/users/{target_id}")
assert resp.status_code == 200, resp.text
assert resp.json()["hard_deleted"] is True
@pytest.mark.asyncio
async def test_user_cannot_delete_own_account(client):
from app.db import get_db
conn = get_db()
await _create_user(conn, "self_admin", "Correct-Horse-Battery-Staple-K1", is_admin=True)
await _login_full(client, "self_admin", "Correct-Horse-Battery-Staple-K1")
resp = await client.get("/auth/me")
my_id = resp.json()["id"]
resp = await client.delete(f"/admin/users/{my_id}")
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_host_group_and_host_update_delete(client):
from app.db import get_db
conn = get_db()
await _create_user(conn, "hcrud_admin", "Correct-Horse-Battery-Staple-L1", is_admin=True)
await _login_full(client, "hcrud_admin", "Correct-Horse-Battery-Staple-L1")
resp = await client.post("/admin/host-groups", json={"name": "hcrud-team"})
hg_id = resp.json()["id"]
resp = await client.put(f"/admin/host-groups/{hg_id}", json={"description": "Aktualisiert"})
assert resp.status_code == 200, resp.text
resp = await client.post(
"/admin/hosts",
json={
"host_group_id": hg_id, "hostname": "hcrud-srv", "address": "10.2.0.1",
"protocol": "ssh", "port": 22, "os_type": "linux",
},
)
host_id = resp.json()["id"]
resp = await client.put(f"/admin/hosts/{host_id}", json={"hostname": "hcrud-srv-renamed"})
assert resp.status_code == 200, resp.text
resp = await client.get(f"/admin/hosts/{host_id}")
assert resp.json()["hostname"] == "hcrud-srv-renamed"
# Hostgruppe kann nicht geloescht werden, solange sie noch (auch inaktive) Hosts enthaelt.
resp = await client.delete(f"/admin/host-groups/{hg_id}")
assert resp.status_code == 409, resp.text
# Host-Delete ist standardmaessig Soft-Delete.
resp = await client.delete(f"/admin/hosts/{host_id}")
assert resp.status_code == 200, resp.text
assert resp.json()["hard_deleted"] is False
resp = await client.get(f"/admin/hosts/{host_id}")
assert resp.json()["is_active"] is False
# ?hard=true entfernt ihn tatsaechlich (keine Sitzungshistorie vorhanden).
resp = await client.delete(f"/admin/hosts/{host_id}?hard=true")
assert resp.status_code == 200, resp.text
assert resp.json()["hard_deleted"] is True
resp = await client.get(f"/admin/hosts/{host_id}")
assert resp.status_code == 404
resp = await client.delete(f"/admin/host-groups/{hg_id}")
assert resp.status_code == 200, resp.text
@pytest.mark.asyncio
async def test_ssh_key_update_rotate_and_delete(client):
from app.db import get_db
conn = get_db()
await _create_user(conn, "kcrud_admin", "Correct-Horse-Battery-Staple-M1", is_admin=True)
await _login_full(client, "kcrud_admin", "Correct-Horse-Battery-Staple-M1")
resp = await client.post(
"/admin/ssh-keys",
json={"label": "orig-key", "private_key_pem": "PEM", "public_key": "PUB", "key_type": "ed25519"},
)
key_id = resp.json()["id"]
resp = await client.put(f"/admin/ssh-keys/{key_id}", json={"label": "renamed-key"})
assert resp.status_code == 200, resp.text
# Rotation erfordert alle drei Felder gemeinsam.
resp = await client.put(f"/admin/ssh-keys/{key_id}", json={"private_key_pem": "NEWPEM"})
assert resp.status_code == 400, resp.text
resp = await client.put(
f"/admin/ssh-keys/{key_id}",
json={"private_key_pem": "NEWPEM", "public_key": "NEWPUB", "key_type": "rsa-4096"},
)
assert resp.status_code == 200, resp.text
resp = await client.delete(f"/admin/ssh-keys/{key_id}")
assert resp.status_code == 200, resp.text
resp = await client.get("/admin/ssh-keys")
assert all(k["id"] != key_id for k in resp.json())
@pytest.mark.asyncio
async def test_user_group_update_and_delete(client):
from app.db import get_db
conn = get_db()
await _create_user(conn, "gcrud_admin", "Correct-Horse-Battery-Staple-N1", is_admin=True)
await _login_full(client, "gcrud_admin", "Correct-Horse-Battery-Staple-N1")
resp = await client.post("/admin/user-groups", json={"name": "gcrud-team"})
group_id = resp.json()["id"]
resp = await client.put(f"/admin/user-groups/{group_id}", json={"description": "Aktualisiert"})
assert resp.status_code == 200, resp.text
resp = await client.delete(f"/admin/user-groups/{group_id}")
assert resp.status_code == 200, resp.text
resp = await client.get("/admin/user-groups")
assert all(g["id"] != group_id for g in resp.json())
# ---------------------------------------------------------------------------
# 5) GET /admin/hosts/{id} -- Grundlage fuer den "Details"-Fix
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_host_detail_endpoint_includes_ssh_keys_and_rdp_flag(client):
from app.db import get_db
conn = get_db()
await _create_user(conn, "detail_admin", "Correct-Horse-Battery-Staple-O1", is_admin=True)
await _login_full(client, "detail_admin", "Correct-Horse-Battery-Staple-O1")
resp = await client.post("/admin/host-groups", json={"name": "detail-team"})
hg_id = resp.json()["id"]
resp = await client.post(
"/admin/hosts",
json={
"host_group_id": hg_id, "hostname": "win-srv", "address": "10.3.0.1",
"protocol": "rdp", "port": 3389, "os_type": "windows",
},
)
host_id = resp.json()["id"]
resp = await client.post(
"/admin/ssh-keys",
json={"label": "detail-key", "private_key_pem": "PEM", "public_key": "PUB", "key_type": "ed25519"},
)
key_id = resp.json()["id"]
resp = await client.post(f"/admin/hosts/{host_id}/ssh-keys/{key_id}")
assert resp.status_code == 200, resp.text
resp = await client.put(f"/admin/hosts/{host_id}/rdp-credentials", json={"password": "Correct-Horse-Battery-Staple-O2"})
assert resp.status_code == 200, resp.text
resp = await client.get(f"/admin/hosts/{host_id}")
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["hostname"] == "win-srv"
assert any(k["id"] == key_id for k in data["ssh_keys"])
assert data["rdp_credentials_set"] is True
resp = await client.get("/admin/rdp-credentials")
assert resp.status_code == 200, resp.text
row = next(r for r in resp.json() if r["host_id"] == host_id)
assert row["credentials_set"] is True
resp = await client.get("/admin/hosts/424242")
assert resp.status_code == 404