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
rdp_proxy/ Guacamole-Protokoll-Tunnel zu guacd
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)
scripts/ Betriebs-/Hilfsskripte (Admin anlegen, Assets bauen)
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
`JUMPHOST_GUACD_HOST`/`_PORT`).
Die SQLite-Migrationen 0004 (`user_groups`) und 0005 (`api_tokens`) werden
beim naechsten Start automatisch angewendet (`app/db.py`, `_apply_migrations`)
— kein manueller Migrationsschritt noetig, auch nicht bei einem bestehenden
Datenbestand.
Die SQLite-Migrationen 0004 (`user_groups`), 0005 (`api_tokens`), 0006
(`tenants`) und 0007 (`crud_extras`) werden beim naechsten Start automatisch
angewendet (`app/db.py`, `_apply_migrations`) — kein manueller
Migrationsschritt noetig, auch nicht bei einem bestehenden Datenbestand.
## 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-
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
```bash
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.
Ablaufdaten, den vollstaendigen Login-Flow (Passwort -> TOTP-Enrollment ->
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-
Eskalationsschutz), sowie Admin-Gating von `/docs` und `/openapi.json`.
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 +
> 14 Admin/Gruppen/Token) konnten in der verwendeten Cloud-Sandbox nicht
> mit `pytest -q` ausgefuehrt werden, da diese Sandbox keinen Netzwerkzugriff
> auf PyPI hat und `fastapi`/`aiosqlite` dort nicht vorinstalliert sind.
> Stattdessen wurden alle neuen/gaenderten SQL-Queries gegen eine echte
> `sqlite3`-Instanz mit allen 5 Migrationen (inkl. der beiden neuen) manuell
> durchgespielt, `app/security/api_tokens.py` und die neuen Pydantic-Schemas
> direkt importiert und mit echten Assertions verifiziert, und alle
> geaenderten Python-/JS-Dateien mit `py_compile`/`node --check` auf
> Syntaxfehler geprueft. Bitte `pytest -q` lokal ausfuehren und Ergebnis
> melden.
> **Hinweis:** Alle 34 in dieser und der vorherigen Session neu
> hinzugekommenen Tests (5 CSP + 14 Admin/Gruppen/Token + 15 Mandanten/CRUD)
> konnten in der verwendeten Cloud-Sandbox nicht mit `pytest -q` ausgefuehrt
> werden, da diese Sandbox keinen Netzwerkzugriff auf PyPI hat und
> `fastapi`/`aiosqlite`/`argon2`/`asyncssh` dort nicht vorinstalliert sind.
> Fuer Phase 8 wurde stattdessen ein tieferes Verifikationsverfahren
> angewendet als in der vorherigen Session: minimale Stub-Module fuer die
> vier fehlenden Pakete (`fastapi`s `APIRouter`-Dekoratoren als No-Ops, ein
> synchrones `sqlite3`-basiertes Shim mit aiosqlite-kompatiblem
> `async`-Interface) erlauben es, die **echten** Endpunkt-Funktionen aus
> `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):
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
Benachrichtigung vor Ablauf und keine "Token zuletzt benutzt vor X Tagen
-> automatisch deaktivieren"-Routine.
11. Ein erneuter `bandit`/`pip-audit`-Lauf ueber die in dieser Session neu
hinzugekommenen Dateien (Admin-Oberflaeche, Gruppen-RBAC, API-Tokens)
steht noch aus (siehe oben).
11. Ein erneuter `bandit`/`pip-audit`-Lauf ueber die in dieser und der
Phase-8-Session neu hinzugekommenen Dateien (Admin-Oberflaeche,
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>

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,7 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from dataclasses import dataclass, field
from datetime import datetime, timezone
from fastapi import Cookie, Depends, Header, HTTPException, Request, Response, WebSocket, status
@ -22,7 +22,25 @@ from app.security.sessions import (
class CurrentUser:
id: int
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(
@ -39,7 +57,8 @@ async def get_current_user(
conn = get_db()
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,),
)
row = await cursor.fetchone()
@ -59,7 +78,8 @@ async def get_current_user(
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:
@ -75,7 +95,8 @@ async def get_current_user_ws(websocket: WebSocket) -> CurrentUser | None:
conn = get_db()
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,),
)
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:
"""NUR Super-Admin (Session, kein Token). Fuer die maechtigsten Aktionen:
Mandanten anlegen/loeschen, Mandanten-Admins ernennen, API-Dokumentation."""
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")
return user
@ -131,10 +163,12 @@ def require_host_role(role_name: str):
# User, Rollen, ...), NICHT fuer SSH/RDP/SFTP-Sitzungen selbst -- die bleiben
# ausschliesslich an eine eingeloggte, TOTP-verifizierte Browser-Session
# gebunden. Ein Token ist nie maechtiger als sein explizit vergebener Scope,
# auch wenn der zugehoerige User Admin ist (kein automatisches is_admin).
# Die Tokenverwaltung selbst (/admin/tokens/*) laeuft ausschliesslich ueber
# require_global_admin (Session), niemals ueber ein Token -- ein geleaktes
# Token darf sich damit nicht selbst weitere/staerkere Tokens ausstellen.
# auch wenn der zugehoerige User Admin ist (kein automatisches is_admin), UND
# ist seit Migration 0006 zusaetzlich auf GENAU EINEN Mandanten eingeschraenkt
# (token_tenant_id). Die Tokenverwaltung selbst (/admin/tokens/*) laeuft
# 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:
@ -143,15 +177,16 @@ async def _validate_api_token(token: str, *, resource: str, action: str) -> Curr
conn = get_db()
token_hash = hash_token(token)
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 "
"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,),
)
row = await cursor.fetchone()
if row is 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:
return None
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,),
)
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):
"""Dependency-Factory fuer die Admin-API: erlaubt Zugriff entweder ueber
eine eingeloggte Admin-Session (Cookie, wie bisher) ODER ueber ein
API-Token mit passendem Scope (Header 'Authorization: Bearer <token>').
'write' schliesst 'read' auf derselben Ressource ein (siehe
token_has_scope)."""
eine eingeloggte Admin-Session (Super- ODER Mandanten-Admin, Cookie) ODER
ueber ein API-Token mit passendem Scope (Header 'Authorization: Bearer
<token>'). 'write' schliesst 'read' auf derselben Ressource ein (siehe
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(
response: Response,
@ -192,8 +229,25 @@ def require_admin_or_scope(resource: str, action: str):
return principal
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")
return user
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")
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}$")
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):
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)
initial_password: str = Field(min_length=12, max_length=256)
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")
@classmethod
@ -55,9 +65,38 @@ class UserCreateRequest(BaseModel):
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):
name: str = Field(min_length=1, max_length=128)
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):
@ -83,19 +122,49 @@ class HostCreateRequest(BaseModel):
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):
user_id: int
host_group_id: int
role_name: Literal[
"ssh_connect", "rdp_connect", "file_transfer", "clipboard",
"session_recording_view", "admin_hostgroup",
]
role_names: list[ROLE_NAME] = Field(min_length=1, max_length=6)
expires_at: str | None = None
class RoleRevokeRequest(BaseModel):
user_id: int
host_group_id: int
role_name: ROLE_NAME
class UserGroupCreateRequest(BaseModel):
name: str = Field(min_length=1, max_length=128)
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):
@ -105,18 +174,24 @@ class GroupMemberRequest(BaseModel):
class GroupRoleGrantRequest(BaseModel):
user_group_id: int
host_group_id: int
role_name: Literal[
"ssh_connect", "rdp_connect", "file_transfer", "clipboard",
"session_recording_view", "admin_hostgroup",
]
role_names: list[ROLE_NAME] = Field(min_length=1, max_length=6)
expires_at: str | None = None
class GroupRoleRevokeRequest(BaseModel):
user_group_id: int
host_group_id: int
role_name: ROLE_NAME
class ApiTokenCreateRequest(BaseModel):
label: str = Field(min_length=1, max_length=128)
user_id: int
scopes: list[str] = Field(min_length=1, max_length=32)
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):
@ -125,6 +200,18 @@ class SshKeyCreateRequest(BaseModel):
private_key_pem: str = Field(min_length=1, max_length=32_768)
public_key: str = Field(min_length=1, max_length=8192)
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):

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); }
#terminal, #rdp-display { flex: 1; background: black; }
#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",
"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");
let meInfo = { is_admin: false, tenant_admin_of: [] };
function showBanner(message, type) {
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
// ---------------------------------------------------------------------
@ -88,8 +113,11 @@
users: loadUsersTab,
groups: loadGroupsTab,
hosts: loadHostsTab,
credentials: loadCredentialsTab,
roles: loadRolesTab,
tokens: loadTokensTab,
tenants: loadTenantsTab,
authlog: loadAuthLogTab,
audit: loadAuditTab,
};
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
// ---------------------------------------------------------------------
async function loadUsersTab() {
document.getElementById("uc-is-admin-row").classList.toggle("hidden", !meInfo.is_admin);
await refreshUsers();
}
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() {
if (meInfo.is_admin && cachedTenants.length === 0) {
cachedTenants = await getJson("/admin/tenants");
}
cachedUsers = await getJson("/admin/users");
const tbody = document.querySelector("#users-table tbody");
fillTable(
tbody,
cachedUsers.map((u) => {
const actions = el("td", {});
actions.appendChild(actionButton("Bearbeiten", "btn-secondary", () => showUserEdit(u)));
if (u.is_active) {
actions.appendChild(
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", {}, [
el("td", { textContent: String(u.id) }),
el("td", { textContent: u.username }),
el("td", { textContent: u.is_admin ? "ja" : "nein" }),
el("td", { textContent: u.is_active ? "ja" : "nein" }),
el("td", { textContent: u.totp_enrolled ? "ja" : "nein" }),
el("td", { textContent: tenantName(u.home_tenant_id) }),
el("td", { textContent: u.created_at }),
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) => {
ev.preventDefault();
try {
const username = document.getElementById("uc-username").value.trim();
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", {
username, initial_password: password, is_admin: isAdmin,
});
@ -171,8 +271,11 @@
let cachedGroups = [];
let activeGroupId = null;
let editingGroupId = null;
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();
}
@ -186,15 +289,44 @@
el("td", { textContent: String(g.id) }),
el("td", { textContent: g.name }),
el("td", { textContent: g.description || "" }),
el("td", { textContent: g.tenant_name }),
el("td", { textContent: String(g.member_count) }),
el("td", {}, [
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) {
activeGroupId = group.id;
document.getElementById("group-members-panel").classList.remove("hidden");
@ -232,7 +364,9 @@
try {
const name = document.getElementById("gc-name").value.trim();
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");
ev.target.reset();
await refreshGroups();
@ -263,9 +397,12 @@
let cachedHosts = [];
let cachedSshKeys = [];
let activeHostId = null;
let editingHostGroupId = null;
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();
}
@ -279,6 +416,15 @@
el("td", { textContent: String(g.id) }),
el("td", { textContent: g.name }),
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})`));
}
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() {
cachedHosts = await getJson("/admin/hosts");
const groupName = (id) => (cachedHostGroups.find((g) => g.id === id) || {}).name || `#${id}`;
@ -300,40 +468,124 @@
el("td", { textContent: h.protocol }),
el("td", { textContent: h.os_type }),
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");
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) {
activeHostId = host.id;
document.getElementById("host-detail-panel").classList.remove("hidden");
document.getElementById("host-detail-name").textContent = `${host.hostname} (#${host.id})`;
document.getElementById("host-detail-ssh").classList.toggle("hidden", host.protocol !== "ssh");
document.getElementById("host-detail-rdp").classList.toggle("hidden", host.protocol !== "rdp");
document.getElementById("host-key-result").classList.add("hidden");
const keySelect = document.getElementById("hkm-key-select");
keySelect.replaceChildren(...optionsFromList(cachedSshKeys, "id", (k) => `${k.label} (#${k.id})`));
function setDetailBanner(message, type) {
const box = document.getElementById("host-detail-banner");
if (!message) {
box.replaceChildren();
return;
}
box.replaceChildren(el("div", { className: `banner ${type === "error" ? "error" : "ok"}`, textContent: message }));
}
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) => {
const isRdp = ev.target.value === "rdp";
document.getElementById("hc-ssh-username-box").classList.toggle("hidden", isRdp);
@ -349,7 +601,9 @@
try {
const name = document.getElementById("hgc-name").value.trim();
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");
ev.target.reset();
await refreshHostGroups();
@ -396,7 +650,7 @@
box.classList.remove("hidden");
showBanner("Host-Key ermittelt und gespeichert.", "ok");
} catch (err) {
showBanner(err.message, "error");
setDetailBanner(err.message, "error");
}
});
@ -406,8 +660,9 @@
const keyId = document.getElementById("hkm-key-select").value;
await sendJson(`/admin/hosts/${activeHostId}/ssh-keys/${keyId}`, "POST", {});
showBanner("SSH-Key zugeordnet.", "ok");
await showHostDetail(activeHostId);
} catch (err) {
showBanner(err.message, "error");
setDetailBanner(err.message, "error");
}
});
@ -418,6 +673,89 @@
await sendJson(`/admin/hosts/${activeHostId}/rdp-credentials`, "PUT", { password });
showBanner("RDP-Zugangsdaten gespeichert.", "ok");
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) {
showBanner(err.message, "error");
}
@ -427,42 +765,61 @@
ev.preventDefault();
try {
const ownerRaw = document.getElementById("skc-owner").value.trim();
const tenantSelect = document.getElementById("skc-tenant");
const payload = {
label: document.getElementById("skc-label").value.trim(),
owner_user_id: ownerRaw ? Number(ownerRaw) : null,
key_type: document.getElementById("skc-type").value,
private_key_pem: document.getElementById("skc-private").value,
public_key: document.getElementById("skc-public").value,
tenant_id: tenantSelect.value ? Number(tenantSelect.value) : null,
};
await sendJson("/admin/ssh-keys", "POST", payload);
showBanner(`SSH-Key '${payload.label}' angelegt.`, "ok");
ev.target.reset();
await refreshSshKeys();
if (activeHostId !== null) {
const keySelect = document.getElementById("hkm-key-select");
keySelect.replaceChildren(...optionsFromList(cachedSshKeys, "id", (k) => `${k.label} (#${k.id})`));
}
} catch (err) {
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
// ---------------------------------------------------------------------
function roleOptions() {
return ROLE_NAMES.map((name) => {
const opt = document.createElement("option");
opt.value = name;
opt.textContent = name;
return opt;
});
}
async function loadRolesTab() {
document.getElementById("rg-role").replaceChildren(...roleOptions());
document.getElementById("grg-role").replaceChildren(...roleOptions());
roleCheckboxGrid(document.getElementById("rg-role-grid"));
roleCheckboxGrid(document.getElementById("grg-role-grid"));
await refreshSharedSelects();
await Promise.all([refreshRoleGrants(), refreshGroupRoleGrants()]);
}
@ -518,13 +875,16 @@
document.getElementById("role-grant-form").addEventListener("submit", async (ev) => {
ev.preventDefault();
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", {
user_id: Number(document.getElementById("rg-user").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,
});
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();
} catch (err) {
showBanner(err.message, "error");
@ -534,13 +894,16 @@
document.getElementById("group-role-grant-form").addEventListener("submit", async (ev) => {
ev.preventDefault();
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", {
user_group_id: Number(document.getElementById("grg-group").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,
});
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();
} catch (err) {
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 refreshTokens();
}
@ -599,6 +964,7 @@
el("td", { textContent: t.label }),
el("td", { textContent: t.username }),
el("td", { textContent: t.prefix }),
el("td", { textContent: t.tenant_name }),
scopesCell,
el("td", { textContent: t.last_used_at || "nie" }),
el("td", { textContent: status }),
@ -615,11 +981,13 @@
if (scopes.length === 0) {
throw new Error("Mindestens einen Scope auswaehlen.");
}
const tenantSelect = document.getElementById("tc-tenant");
const payload = {
label: document.getElementById("tc-label").value.trim(),
user_id: Number(document.getElementById("tc-user").value),
scopes,
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 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
// ---------------------------------------------------------------------
@ -710,11 +1228,16 @@
async function main() {
const me = await getJson("/auth/me");
if (!me.is_admin) {
if (!me.is_any_admin) {
window.location.href = "/dashboard";
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");
await loadUsersTab();
}

View File

@ -5,6 +5,12 @@
* Copy & Paste: xterm.js liefert dies bei SSH bereits nativ ueber die
* System-Zwischenablage (Markieren-zum-Kopieren / Strg+Umschalt+V) -- keine
* 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";
@ -73,17 +79,100 @@
});
document.addEventListener("fullscreenchange", sendResize);
const fileInput = document.getElementById("file-input");
document.getElementById("upload-btn").addEventListener("click", () => fileInput.click());
fileInput.addEventListener("change", async () => {
const file = fileInput.files[0];
if (!file) return;
const remotePath = prompt("Zielpfad auf dem Server:", `/tmp/${file.name}`);
if (!remotePath) return;
// --- Dateitransfer-Panel ------------------------------------------------
const ftOverlay = document.getElementById("ft-overlay");
const ftStatus = document.getElementById("ft-status");
const ftList = document.getElementById("ft-list");
const transfers = [];
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();
formData.append("file", file);
statusEl.textContent = `Lade ${file.name} hoch ...`;
ftStatus.textContent = `Lade ${file.name} hoch ...`;
try {
const res = await fetch(
`/ssh/${hostId}/files/upload?remote_path=${encodeURIComponent(remotePath)}`,
@ -91,10 +180,58 @@
);
const data = await res.json();
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) {
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" 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="credentials">Zugangsdaten</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 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>
</div>
@ -42,9 +45,9 @@
<label for="uc-password">Initiales Passwort (min. 12 Zeichen)</label>
<input type="text" id="uc-password" required minlength="12" maxlength="256">
</div>
<div class="checkbox-row">
<div class="checkbox-row" id="uc-is-admin-row">
<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>
<button type="submit">Anlegen</button>
</form>
@ -57,13 +60,32 @@
<thead>
<tr>
<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>
</thead>
<tbody></tbody>
</table>
</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>
<!-- ================= Benutzergruppen ================= -->
@ -79,6 +101,10 @@
<label for="gc-description">Beschreibung (optional)</label>
<input type="text" id="gc-description" maxlength="1024">
</div>
<div id="gc-tenant-box">
<label for="gc-tenant">Mandant</label>
<select id="gc-tenant" required></select>
</div>
<button type="submit">Anlegen</button>
</form>
</div>
@ -88,12 +114,27 @@
<p class="hint">Rollen fuer eine Gruppe (= Verbindungen mit der ganzen Gruppe teilen) werden im Tab "Rollen" vergeben.</p>
<div class="table-wrap">
<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>
</table>
</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">
<h2>Mitglieder von <span id="group-members-name"></span></h2>
<div class="table-wrap">
@ -126,17 +167,36 @@
<label for="hgc-description">Beschreibung (optional)</label>
<input type="text" id="hgc-description" maxlength="1024">
</div>
<div id="hgc-tenant-box">
<label for="hgc-tenant">Mandant</label>
<select id="hgc-tenant" required></select>
</div>
<button type="submit">Anlegen</button>
</form>
<h3>Hostgruppen</h3>
<div class="table-wrap">
<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>
</table>
</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">
<h2>Neue Verbindung (Host) anlegen</h2>
<form id="host-create-form" class="form-grid">
@ -194,7 +254,7 @@
<div class="table-wrap">
<table class="data-table" id="hosts-table">
<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>
<tbody></tbody>
</table>
@ -203,33 +263,78 @@
<div class="panel hidden" id="host-detail-panel">
<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">
<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>
<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>
<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">
<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>
</div>
<button type="submit">Zuordnen</button>
</form>
</div>
<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">
<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">
</div>
<button type="submit">Speichern</button>
</form>
</div>
</div>
</div>
<!-- ================= Zugangsdaten ================= -->
<div class="tab-panel hidden" id="tab-credentials">
<div class="panel">
<h2>Neuen SSH-Key anlegen</h2>
<form id="ssh-key-create-form" class="form-grid">
@ -250,6 +355,10 @@
<option value="ca-cert">ca-cert</option>
</select>
</div>
<div id="skc-tenant-box">
<label for="skc-tenant">Mandant</label>
<select id="skc-tenant" required></select>
</div>
<div class="full-width">
<label for="skc-private">Privater Schluessel (PEM)</label>
<textarea id="skc-private" required></textarea>
@ -263,7 +372,55 @@
<h3>Vorhandene SSH-Keys</h3>
<div class="table-wrap">
<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>
</table>
</div>
@ -273,7 +430,8 @@
<!-- ================= Rollen ================= -->
<div class="tab-panel hidden" id="tab-roles">
<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">
<div>
<label for="rg-user">Benutzer</label>
@ -283,9 +441,9 @@
<label for="rg-hostgroup">Hostgruppe</label>
<select id="rg-hostgroup" required></select>
</div>
<div>
<label for="rg-role">Rolle</label>
<select id="rg-role"></select>
<div class="full-width">
<label>Rollen</label>
<div class="scope-grid" id="rg-role-grid"></div>
</div>
<div>
<label for="rg-expires">Ablaufdatum (optional, ISO)</label>
@ -302,8 +460,8 @@
</div>
<div class="panel">
<h2>Rolle 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>
<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 Rollen automatisch. Mehrfachauswahl moeglich.</p>
<form id="group-role-grant-form" class="form-grid">
<div>
<label for="grg-group">Benutzergruppe</label>
@ -313,9 +471,9 @@
<label for="grg-hostgroup">Hostgruppe</label>
<select id="grg-hostgroup" required></select>
</div>
<div>
<label for="grg-role">Rolle</label>
<select id="grg-role"></select>
<div class="full-width">
<label>Rollen</label>
<div class="scope-grid" id="grg-role-grid"></div>
</div>
<div>
<label for="grg-expires">Ablaufdatum (optional, ISO)</label>
@ -336,7 +494,7 @@
<div class="tab-panel hidden" id="tab-tokens">
<div class="panel">
<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">
<div>
<label for="tc-label">Bezeichnung</label>
@ -346,6 +504,10 @@
<label for="tc-user">Fuer Benutzer</label>
<select id="tc-user" required></select>
</div>
<div id="tc-tenant-box">
<label for="tc-tenant">Mandant</label>
<select id="tc-tenant" required></select>
</div>
<div>
<label for="tc-expires">Ablaufdatum (optional, ISO)</label>
<input type="text" id="tc-expires" placeholder="2026-12-31T00:00:00.000Z" maxlength="40">
@ -364,7 +526,7 @@
<div class="table-wrap">
<table class="data-table" id="tokens-table">
<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>
<tbody></tbody>
</table>
@ -372,6 +534,79 @@
</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 ================= -->
<div class="tab-panel hidden" id="tab-audit">
<div class="panel">

View File

@ -11,14 +11,41 @@
<div class="session-shell" id="session-shell" data-host-id="{{ host_id }}">
<div class="session-toolbar">
<button id="fullscreen-btn">Vollbild</button>
<button id="upload-btn">Datei hochladen</button>
<input type="file" id="file-input" class="hidden">
<button id="filetransfer-btn">Dateitransfer</button>
<span class="spacer"></span>
<span class="status" id="status">Verbinde ...</span>
</div>
<div id="terminal"></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-addon-fit.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