more admin stuff
This commit is contained in:
154
README.md
154
README.md
@ -16,7 +16,7 @@ app/ Backend (FastAPI, Python 3.11+)
|
|||||||
ssh_proxy/ SSH-Terminal-WebSocket + SFTP-Filetransfer
|
ssh_proxy/ SSH-Terminal-WebSocket + SFTP-Filetransfer
|
||||||
rdp_proxy/ Guacamole-Protokoll-Tunnel zu guacd
|
rdp_proxy/ Guacamole-Protokoll-Tunnel zu guacd
|
||||||
recordings/ Hash-verkettete Session-Aufzeichnung
|
recordings/ Hash-verkettete Session-Aufzeichnung
|
||||||
db/ SQLite-Migrationen (0001-0005, laufen automatisch beim Start)
|
db/ SQLite-Migrationen (0001-0007, laufen automatisch beim Start)
|
||||||
static/, templates/ Frontend (Vanilla JS, xterm.js, guacamole-common-js, Admin-Oberflaeche, API-Doku)
|
static/, templates/ Frontend (Vanilla JS, xterm.js, guacamole-common-js, Admin-Oberflaeche, API-Doku)
|
||||||
scripts/ Betriebs-/Hilfsskripte (Admin anlegen, Assets bauen)
|
scripts/ Betriebs-/Hilfsskripte (Admin anlegen, Assets bauen)
|
||||||
ansible/ Deployment (Rollen, systemd-Unit-Templates)
|
ansible/ Deployment (Rollen, systemd-Unit-Templates)
|
||||||
@ -48,10 +48,10 @@ verwalten; ein manueller Umweg ueber die rohe JSON-API ist dafuer nicht mehr
|
|||||||
noetig. Fuer RDP muss zusaetzlich ein laufender `guacd` erreichbar sein (siehe
|
noetig. Fuer RDP muss zusaetzlich ein laufender `guacd` erreichbar sein (siehe
|
||||||
`JUMPHOST_GUACD_HOST`/`_PORT`).
|
`JUMPHOST_GUACD_HOST`/`_PORT`).
|
||||||
|
|
||||||
Die SQLite-Migrationen 0004 (`user_groups`) und 0005 (`api_tokens`) werden
|
Die SQLite-Migrationen 0004 (`user_groups`), 0005 (`api_tokens`), 0006
|
||||||
beim naechsten Start automatisch angewendet (`app/db.py`, `_apply_migrations`)
|
(`tenants`) und 0007 (`crud_extras`) werden beim naechsten Start automatisch
|
||||||
— kein manueller Migrationsschritt noetig, auch nicht bei einem bestehenden
|
angewendet (`app/db.py`, `_apply_migrations`) — kein manueller
|
||||||
Datenbestand.
|
Migrationsschritt noetig, auch nicht bei einem bestehenden Datenbestand.
|
||||||
|
|
||||||
## Admin-Oberflaeche, Gruppen-RBAC, API-Tokens und API-Dokumentation
|
## Admin-Oberflaeche, Gruppen-RBAC, API-Tokens und API-Dokumentation
|
||||||
|
|
||||||
@ -105,13 +105,94 @@ rendert Endpunkte gruppiert nach Tag, ausschliesslich per
|
|||||||
`document.createElement`/`textContent` (kein `innerHTML`, keine Inline-
|
`document.createElement`/`textContent` (kein `innerHTML`, keine Inline-
|
||||||
Styles/-Scripts, siehe `tests/test_csp_compliance.py`).
|
Styles/-Scripts, siehe `tests/test_csp_compliance.py`).
|
||||||
|
|
||||||
|
## Phase 8: Mandantenfaehigkeit, CRUD-Vervollstaendigung, Dateitransfer, Mehrfachrollen
|
||||||
|
|
||||||
|
Diese Session hat die Admin-Oberflaeche um sieben zusammenhaengende
|
||||||
|
Erweiterungen ergaenzt:
|
||||||
|
|
||||||
|
**1) Mandantenfaehigkeit ("volle Isolation + Mandanten-Admins", Migration
|
||||||
|
`0006_tenants.sql`)**: jede Hostgruppe, Benutzergruppe, jeder SSH-Key und
|
||||||
|
jedes API-Token gehoert zu genau einem Mandanten (`tenants`-Tabelle,
|
||||||
|
Standard-Mandant `id=1` "Standard" fuer bestehende Daten). Es gibt zwei
|
||||||
|
Admin-Stufen: **Super-Admin** (`users.is_admin=1`, unveraendert wie zuvor)
|
||||||
|
sieht/verwaltet **alle** Mandanten weiterhin vollstaendig — keine Regression
|
||||||
|
gegenueber Phase 7. **Mandanten-Admin** (neue `tenant_admins`-Zuordnung,
|
||||||
|
ein User kann Admin mehrerer Mandanten sein) sieht/verwaltet **ausschliesslich**
|
||||||
|
die Ressourcen seines/seiner Mandanten; jeder Zugriffsversuch auf eine
|
||||||
|
fremde Mandanten-ID liefert bewusst **404** (nicht 403, siehe
|
||||||
|
`app/tenancy.py`, `TenantScope`) — ein Mandanten-Admin soll aus der
|
||||||
|
Fehlerantwort nicht einmal ableiten koennen, dass eine ID ausserhalb seines
|
||||||
|
Mandanten ueberhaupt existiert. Tenant-CRUD und die Ernennung von
|
||||||
|
Mandanten-Admins sind ausschliesslich Super-Admin-Aktionen (neuer Tab
|
||||||
|
"Mandanten" in der Admin-Oberflaeche, fuer Mandanten-Admins nicht sichtbar).
|
||||||
|
Ein API-Token ist immer an **genau einen** Mandanten gebunden
|
||||||
|
(`api_tokens.tenant_id`) und kann diesen nie verlassen.
|
||||||
|
|
||||||
|
**2) CRUD-Vervollstaendigung**: jede Ressource, die die Admin-Oberflaeche
|
||||||
|
anlegen kann, ist jetzt auch bearbeitbar und loeschbar — Benutzer (inkl.
|
||||||
|
Passwort setzen, deaktivieren, reaktivieren), Benutzergruppen, Hostgruppen,
|
||||||
|
Hosts, SSH-Keys (inkl. Rotation). Zwei bewusste Sicherheits-/Integritaets-
|
||||||
|
Entscheidungen dabei: ein Benutzerkonto mit vorhandener Audit-Historie wird
|
||||||
|
**nicht** hart geloescht (die Hash-Chain verweist absichtlich ohne `ON DELETE
|
||||||
|
CASCADE` auf `users.id`, siehe Migration `0007_crud_extras.sql`), sondern
|
||||||
|
deaktiviert und anonymisiert (`deleted_user_<id>`, Passwort/TOTP geloescht);
|
||||||
|
nur ein Konto ganz ohne Audit-Spuren wird tatsaechlich entfernt — die
|
||||||
|
Response (`hard_deleted: true/false`) zeigt an, welcher Fall eintrat. Ein
|
||||||
|
Host wird per Default **soft-deleted** (`is_active=0`, wie schon zuvor vom
|
||||||
|
Katalog beruecksichtigt); `DELETE /admin/hosts/{id}?hard=true` versucht
|
||||||
|
zusaetzlich ein echtes Entfernen, faellt aber automatisch auf Soft-Delete
|
||||||
|
zurueck, falls der Host bereits Sitzungshistorie hat.
|
||||||
|
|
||||||
|
**3) Login-Verlauf**: neuer Tab "Login-Verlauf" filtert clientseitig aus dem
|
||||||
|
bestehenden Audit-Log-Feed (`GET /admin/audit-log`) gezielt Login-/Logout-/
|
||||||
|
Fehlversuch-Ereignisse heraus — kein neuer Backend-Endpunkt noetig, da die
|
||||||
|
Rohdaten bereits vorhanden waren, nur bisher nicht dediziert sichtbar.
|
||||||
|
|
||||||
|
**4) "Details"-Fix fuer Hosts**: der defekte "Details"-Button in der Hosts-
|
||||||
|
Tabelle wurde durch einen neuen Endpunkt `GET /admin/hosts/{id}` (liefert den
|
||||||
|
vollstaendigen, aktuellen Datensatz inkl. zugeordneter SSH-Keys und ob
|
||||||
|
RDP-Zugangsdaten hinterlegt sind) sowie eine neu geschriebene, robuste
|
||||||
|
`showHostDetail()`-Funktion in `admin.js` ersetzt, die bei jedem Aufruf
|
||||||
|
frisch nachlaedt statt sich auf ggf. veraltete Listendaten zu verlassen, und
|
||||||
|
Fehler inline anzeigt statt sie zu verschlucken. Da diese Sandbox die App
|
||||||
|
nicht tatsaechlich im Browser ausfuehren kann, war die urspruengliche
|
||||||
|
Ursache nicht direkt reproduzierbar — bitte nach dem Update pruefen, ob der
|
||||||
|
Button jetzt zuverlaessig funktioniert, oder bei einem verbleibenden Fehler
|
||||||
|
die genaue Browser-Konsolenmeldung mitteilen.
|
||||||
|
|
||||||
|
**5) Eigener "Zugangsdaten"-Tab**: buendelt SSH-Keys UND RDP/Windows-
|
||||||
|
Passwoerter (bisher Teil des Hosts-Formulars) an einer Stelle, inkl. Uebersicht,
|
||||||
|
welche RDP-Hosts bereits ein Passwort hinterlegt haben (`GET
|
||||||
|
/admin/rdp-credentials`).
|
||||||
|
|
||||||
|
**6) Dateitransfer-Fenster (SSH-Terminal)**: der bisherige, `prompt()`-
|
||||||
|
basierte Einzel-Upload-Knopf in der Terminal-Sitzung (`templates/terminal.html`,
|
||||||
|
`static/js/terminal.js`) wurde durch ein eigenstaendiges Dateitransfer-Panel
|
||||||
|
ersetzt, das **beide Richtungen** abdeckt — Upload (wie zuvor, jetzt mit
|
||||||
|
eigenem Formular statt Browser-`prompt()`) und **Download** (neu in der UI;
|
||||||
|
der Backend-Endpunkt `GET /ssh/{host_id}/files/download` existierte bereits
|
||||||
|
und wird per `fetch()` + Blob + synthetischem `<a download>`-Link
|
||||||
|
angesteuert, damit Fehler inline im Panel erscheinen statt die Seite zu
|
||||||
|
verlassen). Das Panel fuehrt zusaetzlich ein kurzes Transfer-Log der
|
||||||
|
laufenden Sitzung. Gilt bewusst nur fuer SSH (RDP hat in dieser
|
||||||
|
Implementierung keinen eigenen Dateitransfer-Endpunkt).
|
||||||
|
|
||||||
|
**7) Mehrfachauswahl bei Rollenvergabe**: `POST /admin/roles/grant` und
|
||||||
|
`POST /admin/group-roles/grant` akzeptieren jetzt `role_names` (Liste, 1-6
|
||||||
|
Rollen) statt einer einzelnen `role_name` — ein Benutzer bzw. eine
|
||||||
|
Benutzergruppe kann damit in einem Schritt mehrere Rollen auf derselben
|
||||||
|
Hostgruppe erhalten (Checkbox-Raster statt Dropdown in der Oberflaeche).
|
||||||
|
Das Entziehen bleibt bewusst pro Zeile/Rolle (`role_name`, Einzahl) — das
|
||||||
|
entspricht dem bestehenden "Entziehen"-Knopf pro Tabellenzeile und braucht
|
||||||
|
keine Mehrfachauswahl.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pytest -q
|
pytest -q
|
||||||
```
|
```
|
||||||
|
|
||||||
46 Tests (vorher 27) decken ab: Argon2id/TOTP-Grundfunktionen, Audit-Hash-Chain
|
61 Tests (vorher 46) decken ab: Argon2id/TOTP-Grundfunktionen, Audit-Hash-Chain
|
||||||
(inkl. Manipulationserkennung und Trigger-Durchsetzung), RBAC-Logik inkl.
|
(inkl. Manipulationserkennung und Trigger-Durchsetzung), RBAC-Logik inkl.
|
||||||
Ablaufdaten, den vollstaendigen Login-Flow (Passwort -> TOTP-Enrollment ->
|
Ablaufdaten, den vollstaendigen Login-Flow (Passwort -> TOTP-Enrollment ->
|
||||||
Session-Cookie -> geschuetzte Endpunkte) gegen die echte FastAPI-App, 17
|
Session-Cookie -> geschuetzte Endpunkte) gegen die echte FastAPI-App, 17
|
||||||
@ -125,19 +206,42 @@ Admin-only-Durchsetzung fuer alle neuen Endpunkte, Token-Scope-Durchsetzung
|
|||||||
Scope), dass ein Token niemals andere Tokens verwalten kann (Rechte-
|
Scope), dass ein Token niemals andere Tokens verwalten kann (Rechte-
|
||||||
Eskalationsschutz), sowie Admin-Gating von `/docs` und `/openapi.json`.
|
Eskalationsschutz), sowie Admin-Gating von `/docs` und `/openapi.json`.
|
||||||
Details, Vorgehen und Ergebnisse der urspruenglichen 27 Tests: siehe
|
Details, Vorgehen und Ergebnisse der urspruenglichen 27 Tests: siehe
|
||||||
`Pentest_Report.md`.
|
`Pentest_Report.md`. 15 weitere neue Tests zu Phase 8
|
||||||
|
(`tests/test_tenants.py`) decken zusaetzlich ab: Tenant-CRUD (Super-Admin
|
||||||
|
only, Mandanten-Admin explizit ausgeschlossen), volle Mandanten-Isolation
|
||||||
|
(Hostgruppen/Hosts/Benutzer/SSH-Keys/Audit-Log — fremde Mandanten-IDs
|
||||||
|
liefern 404), Mehrfachauswahl bei Einzel- UND Gruppen-Rollenvergabe,
|
||||||
|
Update/Deactivate/Delete fuer Benutzer (inkl. Anonymisieren-statt-Hart-
|
||||||
|
Loeschen bei vorhandener Audit-Historie vs. echtem Hard-Delete ohne),
|
||||||
|
Update/Delete fuer Hostgruppen (blockiert solange Hosts enthalten sind),
|
||||||
|
Hosts (Soft- vs. Hard-Delete), SSH-Keys (inkl. Rotation) und Benutzergruppen,
|
||||||
|
sowie den neuen `GET /admin/hosts/{id}`-Detailendpunkt inkl. SSH-Key-
|
||||||
|
Zuordnungen und RDP-Zugangsdaten-Status.
|
||||||
|
|
||||||
> **Hinweis:** Die 19 in dieser Session neu hinzugekommenen Tests (5 CSP +
|
> **Hinweis:** Alle 34 in dieser und der vorherigen Session neu
|
||||||
> 14 Admin/Gruppen/Token) konnten in der verwendeten Cloud-Sandbox nicht
|
> hinzugekommenen Tests (5 CSP + 14 Admin/Gruppen/Token + 15 Mandanten/CRUD)
|
||||||
> mit `pytest -q` ausgefuehrt werden, da diese Sandbox keinen Netzwerkzugriff
|
> konnten in der verwendeten Cloud-Sandbox nicht mit `pytest -q` ausgefuehrt
|
||||||
> auf PyPI hat und `fastapi`/`aiosqlite` dort nicht vorinstalliert sind.
|
> werden, da diese Sandbox keinen Netzwerkzugriff auf PyPI hat und
|
||||||
> Stattdessen wurden alle neuen/gaenderten SQL-Queries gegen eine echte
|
> `fastapi`/`aiosqlite`/`argon2`/`asyncssh` dort nicht vorinstalliert sind.
|
||||||
> `sqlite3`-Instanz mit allen 5 Migrationen (inkl. der beiden neuen) manuell
|
> Fuer Phase 8 wurde stattdessen ein tieferes Verifikationsverfahren
|
||||||
> durchgespielt, `app/security/api_tokens.py` und die neuen Pydantic-Schemas
|
> angewendet als in der vorherigen Session: minimale Stub-Module fuer die
|
||||||
> direkt importiert und mit echten Assertions verifiziert, und alle
|
> vier fehlenden Pakete (`fastapi`s `APIRouter`-Dekoratoren als No-Ops, ein
|
||||||
> geaenderten Python-/JS-Dateien mit `py_compile`/`node --check` auf
|
> synchrones `sqlite3`-basiertes Shim mit aiosqlite-kompatiblem
|
||||||
> Syntaxfehler geprueft. Bitte `pytest -q` lokal ausfuehren und Ergebnis
|
> `async`-Interface) erlauben es, die **echten** Endpunkt-Funktionen aus
|
||||||
> melden.
|
> `app/admin/routes.py` direkt (ohne HTTP-Layer) gegen eine echte
|
||||||
|
> In-Memory-`sqlite3`-Datenbank mit allen 7 Migrationen auszufuehren — damit
|
||||||
|
> wurden ueber 20 Integrations-Assertions (Mandanten-Isolation,
|
||||||
|
> Rollen-Mehrfachvergabe, Delete-Semantik) tatsaechlich lauffaehig
|
||||||
|
> verifiziert, nicht nur simulierte SQL-Queries. Der neue `tests/test_tenants.py`
|
||||||
|
> selbst nutzt weiterhin `httpx.AsyncClient` gegen die echte ASGI-App (wie
|
||||||
|
> alle anderen Testdateien) und wurde daher zeilenweise gegen die tatsaechliche
|
||||||
|
> Endpunkt-Implementierung gegengeprueft (Pfade, Payload-Felder, Statuscodes),
|
||||||
|
> aber nicht selbst mit `pytest` ausgefuehrt. Alle geaenderten Python-/
|
||||||
|
> JS-Dateien wurden mit `py_compile`/`node --check` auf Syntaxfehler geprueft,
|
||||||
|
> und jeder `document.getElementById`-Aufruf in `admin.js`/`terminal.js` wurde
|
||||||
|
> automatisiert gegen die tatsaechlichen HTML-IDs in `admin.html`/`terminal.html`
|
||||||
|
> abgeglichen (0 Abweichungen). Bitte `pytest -q` lokal ausfuehren und
|
||||||
|
> Ergebnis melden.
|
||||||
|
|
||||||
Manuell zusaetzlich verifiziert (siehe Entwicklungs-Log dieser Session):
|
Manuell zusaetzlich verifiziert (siehe Entwicklungs-Log dieser Session):
|
||||||
Server-Start, Static-/Template-Auslieferung, Security-Header, vollstaendiger
|
Server-Start, Static-/Template-Auslieferung, Security-Header, vollstaendiger
|
||||||
@ -241,7 +345,15 @@ ist die massgebliche, aktuelle Fassung dieser Liste. Kurzfassung:
|
|||||||
gibt noch keine erzwungene maximale Laufzeit, keine automatische
|
gibt noch keine erzwungene maximale Laufzeit, keine automatische
|
||||||
Benachrichtigung vor Ablauf und keine "Token zuletzt benutzt vor X Tagen
|
Benachrichtigung vor Ablauf und keine "Token zuletzt benutzt vor X Tagen
|
||||||
-> automatisch deaktivieren"-Routine.
|
-> automatisch deaktivieren"-Routine.
|
||||||
11. Ein erneuter `bandit`/`pip-audit`-Lauf ueber die in dieser Session neu
|
11. Ein erneuter `bandit`/`pip-audit`-Lauf ueber die in dieser und der
|
||||||
hinzugekommenen Dateien (Admin-Oberflaeche, Gruppen-RBAC, API-Tokens)
|
Phase-8-Session neu hinzugekommenen Dateien (Admin-Oberflaeche,
|
||||||
steht noch aus (siehe oben).
|
Gruppen-RBAC, API-Tokens, Mandantenfaehigkeit, Dateitransfer-Panel)
|
||||||
|
steht noch aus (keine neuen Fremdabhaengigkeiten hinzugekommen, siehe
|
||||||
|
oben).
|
||||||
|
12. **Phase 8 / "Details"-Fix**: der Root-Cause des urspruenglich gemeldeten
|
||||||
|
defekten "Details"-Buttons konnte in dieser Sandbox nicht reproduziert
|
||||||
|
werden (die App laesst sich hier nicht im Browser ausfuehren) — der neue
|
||||||
|
`GET /admin/hosts/{id}`-Endpunkt plus robustere `showHostDetail()`-Logik
|
||||||
|
sollten das Problem loesen, sollten aber nach dem Deployment einmal
|
||||||
|
manuell im Browser bestaetigt werden.
|
||||||
</content>
|
</content>
|
||||||
|
|||||||
1012
app/admin/routes.py
1012
app/admin/routes.py
File diff suppressed because it is too large
Load Diff
@ -2,7 +2,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from fastapi import Cookie, Depends, Header, HTTPException, Request, Response, WebSocket, status
|
from fastapi import Cookie, Depends, Header, HTTPException, Request, Response, WebSocket, status
|
||||||
@ -22,7 +22,25 @@ from app.security.sessions import (
|
|||||||
class CurrentUser:
|
class CurrentUser:
|
||||||
id: int
|
id: int
|
||||||
username: str
|
username: str
|
||||||
is_admin: bool
|
is_admin: bool # Super-Admin (siehe Migration 0006, Mandantenfaehigkeit):
|
||||||
|
# sieht/verwaltet ALLE Mandanten. Ein Token ist NIE is_admin=True, auch
|
||||||
|
# wenn der zugehoerige User selbst Super-Admin ist (siehe unten).
|
||||||
|
tenant_admin_ids: frozenset[int] = field(default_factory=frozenset)
|
||||||
|
# Nur bei Token-Auth gesetzt: das Token ist zusaetzlich auf GENAU diesen
|
||||||
|
# einen Mandanten eingeschraenkt (siehe app/db/migrations/0006_tenants.sql:
|
||||||
|
# api_tokens.tenant_id ist NOT NULL -- jedes Token gehoert einem Mandanten).
|
||||||
|
token_tenant_id: int | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_any_admin(self) -> bool:
|
||||||
|
"""Super-Admin ODER Mandanten-Admin (mind. eines Mandanten)."""
|
||||||
|
return self.is_admin or bool(self.tenant_admin_ids)
|
||||||
|
|
||||||
|
|
||||||
|
async def _load_tenant_admin_ids(conn, user_id: int) -> frozenset[int]:
|
||||||
|
cursor = await conn.execute("SELECT tenant_id FROM tenant_admins WHERE user_id = ?", (user_id,))
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
return frozenset(r[0] for r in rows)
|
||||||
|
|
||||||
|
|
||||||
async def get_current_user(
|
async def get_current_user(
|
||||||
@ -39,7 +57,8 @@ async def get_current_user(
|
|||||||
|
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
cursor = await conn.execute(
|
cursor = await conn.execute(
|
||||||
"SELECT id, username, is_admin, is_active, session_version FROM users WHERE id = ?",
|
"SELECT id, username, is_admin, is_active, session_version FROM users "
|
||||||
|
"WHERE id = ? AND deleted_at IS NULL",
|
||||||
(payload.user_id,),
|
(payload.user_id,),
|
||||||
)
|
)
|
||||||
row = await cursor.fetchone()
|
row = await cursor.fetchone()
|
||||||
@ -59,7 +78,8 @@ async def get_current_user(
|
|||||||
path="/",
|
path="/",
|
||||||
)
|
)
|
||||||
|
|
||||||
return CurrentUser(id=row[0], username=row[1], is_admin=bool(row[2]))
|
tenant_admin_ids = await _load_tenant_admin_ids(conn, row[0])
|
||||||
|
return CurrentUser(id=row[0], username=row[1], is_admin=bool(row[2]), tenant_admin_ids=tenant_admin_ids)
|
||||||
|
|
||||||
|
|
||||||
async def get_current_user_ws(websocket: WebSocket) -> CurrentUser | None:
|
async def get_current_user_ws(websocket: WebSocket) -> CurrentUser | None:
|
||||||
@ -75,7 +95,8 @@ async def get_current_user_ws(websocket: WebSocket) -> CurrentUser | None:
|
|||||||
|
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
cursor = await conn.execute(
|
cursor = await conn.execute(
|
||||||
"SELECT id, username, is_admin, is_active, session_version FROM users WHERE id = ?",
|
"SELECT id, username, is_admin, is_active, session_version FROM users "
|
||||||
|
"WHERE id = ? AND deleted_at IS NULL",
|
||||||
(payload.user_id,),
|
(payload.user_id,),
|
||||||
)
|
)
|
||||||
row = await cursor.fetchone()
|
row = await cursor.fetchone()
|
||||||
@ -85,7 +106,18 @@ async def get_current_user_ws(websocket: WebSocket) -> CurrentUser | None:
|
|||||||
|
|
||||||
|
|
||||||
async def require_global_admin(user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
|
async def require_global_admin(user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
|
||||||
|
"""NUR Super-Admin (Session, kein Token). Fuer die maechtigsten Aktionen:
|
||||||
|
Mandanten anlegen/loeschen, Mandanten-Admins ernennen, API-Dokumentation."""
|
||||||
if not user.is_admin:
|
if not user.is_admin:
|
||||||
|
raise HTTPException(status.HTTP_403_FORBIDDEN, "Super-Admin-Rechte erforderlich")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def require_admin_session(user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
|
||||||
|
"""Super-Admin ODER Mandanten-Admin, aber ausschliesslich per Session
|
||||||
|
(NIE per Token) -- fuer Aktionen, die bewusst keinen Token-Bypass erlauben
|
||||||
|
sollen (z.B. Token-Verwaltung selbst, Mandanten-Admin-Zuweisung lesen)."""
|
||||||
|
if not user.is_any_admin:
|
||||||
raise HTTPException(status.HTTP_403_FORBIDDEN, "Admin-Rechte erforderlich")
|
raise HTTPException(status.HTTP_403_FORBIDDEN, "Admin-Rechte erforderlich")
|
||||||
return user
|
return user
|
||||||
|
|
||||||
@ -131,10 +163,12 @@ def require_host_role(role_name: str):
|
|||||||
# User, Rollen, ...), NICHT fuer SSH/RDP/SFTP-Sitzungen selbst -- die bleiben
|
# User, Rollen, ...), NICHT fuer SSH/RDP/SFTP-Sitzungen selbst -- die bleiben
|
||||||
# ausschliesslich an eine eingeloggte, TOTP-verifizierte Browser-Session
|
# ausschliesslich an eine eingeloggte, TOTP-verifizierte Browser-Session
|
||||||
# gebunden. Ein Token ist nie maechtiger als sein explizit vergebener Scope,
|
# gebunden. Ein Token ist nie maechtiger als sein explizit vergebener Scope,
|
||||||
# auch wenn der zugehoerige User Admin ist (kein automatisches is_admin).
|
# auch wenn der zugehoerige User Admin ist (kein automatisches is_admin), UND
|
||||||
# Die Tokenverwaltung selbst (/admin/tokens/*) laeuft ausschliesslich ueber
|
# ist seit Migration 0006 zusaetzlich auf GENAU EINEN Mandanten eingeschraenkt
|
||||||
# require_global_admin (Session), niemals ueber ein Token -- ein geleaktes
|
# (token_tenant_id). Die Tokenverwaltung selbst (/admin/tokens/*) laeuft
|
||||||
# Token darf sich damit nicht selbst weitere/staerkere Tokens ausstellen.
|
# ausschliesslich ueber require_admin_session (Session, Super- ODER
|
||||||
|
# Mandanten-Admin), niemals ueber ein Token -- ein geleaktes Token darf sich
|
||||||
|
# damit nicht selbst weitere/staerkere Tokens ausstellen.
|
||||||
|
|
||||||
|
|
||||||
async def _validate_api_token(token: str, *, resource: str, action: str) -> CurrentUser | None:
|
async def _validate_api_token(token: str, *, resource: str, action: str) -> CurrentUser | None:
|
||||||
@ -143,15 +177,16 @@ async def _validate_api_token(token: str, *, resource: str, action: str) -> Curr
|
|||||||
conn = get_db()
|
conn = get_db()
|
||||||
token_hash = hash_token(token)
|
token_hash = hash_token(token)
|
||||||
cursor = await conn.execute(
|
cursor = await conn.execute(
|
||||||
"SELECT at.id, at.user_id, at.scopes_json, at.expires_at, at.revoked_at, "
|
"SELECT at.id, at.user_id, at.scopes_json, at.expires_at, at.revoked_at, at.tenant_id, "
|
||||||
"u.username, u.is_active "
|
"u.username, u.is_active "
|
||||||
"FROM api_tokens at JOIN users u ON u.id = at.user_id WHERE at.token_hash = ?",
|
"FROM api_tokens at JOIN users u ON u.id = at.user_id "
|
||||||
|
"WHERE at.token_hash = ? AND u.deleted_at IS NULL",
|
||||||
(token_hash,),
|
(token_hash,),
|
||||||
)
|
)
|
||||||
row = await cursor.fetchone()
|
row = await cursor.fetchone()
|
||||||
if row is None:
|
if row is None:
|
||||||
return None
|
return None
|
||||||
token_id, user_id, scopes_json, expires_at, revoked_at, username, is_active = row
|
token_id, user_id, scopes_json, expires_at, revoked_at, tenant_id, username, is_active = row
|
||||||
if revoked_at is not None or not is_active:
|
if revoked_at is not None or not is_active:
|
||||||
return None
|
return None
|
||||||
now_str = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
|
now_str = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
|
||||||
@ -166,15 +201,17 @@ async def _validate_api_token(token: str, *, resource: str, action: str) -> Curr
|
|||||||
(token_id,),
|
(token_id,),
|
||||||
)
|
)
|
||||||
await conn.commit()
|
await conn.commit()
|
||||||
return CurrentUser(id=user_id, username=username, is_admin=False)
|
return CurrentUser(id=user_id, username=username, is_admin=False, token_tenant_id=tenant_id)
|
||||||
|
|
||||||
|
|
||||||
def require_admin_or_scope(resource: str, action: str):
|
def require_admin_or_scope(resource: str, action: str):
|
||||||
"""Dependency-Factory fuer die Admin-API: erlaubt Zugriff entweder ueber
|
"""Dependency-Factory fuer die Admin-API: erlaubt Zugriff entweder ueber
|
||||||
eine eingeloggte Admin-Session (Cookie, wie bisher) ODER ueber ein
|
eine eingeloggte Admin-Session (Super- ODER Mandanten-Admin, Cookie) ODER
|
||||||
API-Token mit passendem Scope (Header 'Authorization: Bearer <token>').
|
ueber ein API-Token mit passendem Scope (Header 'Authorization: Bearer
|
||||||
'write' schliesst 'read' auf derselben Ressource ein (siehe
|
<token>'). 'write' schliesst 'read' auf derselben Ressource ein (siehe
|
||||||
token_has_scope)."""
|
token_has_scope). Mandanten-Scoping selbst erfolgt NICHT hier, sondern im
|
||||||
|
jeweiligen Endpunkt ueber app/tenancy.py::TenantScope, basierend auf
|
||||||
|
effective_tenant_ids() des zurueckgegebenen CurrentUser."""
|
||||||
|
|
||||||
async def _dep(
|
async def _dep(
|
||||||
response: Response,
|
response: Response,
|
||||||
@ -192,8 +229,25 @@ def require_admin_or_scope(resource: str, action: str):
|
|||||||
return principal
|
return principal
|
||||||
|
|
||||||
user = await get_current_user(response, jh_session)
|
user = await get_current_user(response, jh_session)
|
||||||
if not user.is_admin:
|
if not user.is_any_admin:
|
||||||
raise HTTPException(status.HTTP_403_FORBIDDEN, "Admin-Rechte erforderlich")
|
raise HTTPException(status.HTTP_403_FORBIDDEN, "Admin-Rechte erforderlich")
|
||||||
return user
|
return user
|
||||||
|
|
||||||
return _dep
|
return _dep
|
||||||
|
|
||||||
|
|
||||||
|
def effective_tenant_ids(admin: CurrentUser) -> set[int] | None:
|
||||||
|
"""None bedeutet 'alle Mandanten' (Super-Admin-Session, nicht ueber ein
|
||||||
|
mandantengebundenes Token authentifiziert). Sonst ein Set mit genau den
|
||||||
|
Mandanten-IDs, auf die der Principal beschraenkt ist:
|
||||||
|
- Token-Auth: immer genau {token_tenant_id} (Tokens sind nie
|
||||||
|
Mandanten-UEBERGREIFEND einsetzbar, siehe Migration 0006).
|
||||||
|
- Mandanten-Admin-Session: die Menge ihrer tenant_admin_ids.
|
||||||
|
- Ein regulaerer (Nicht-Admin-)User wuerde require_admin_or_scope /
|
||||||
|
require_admin_session ohnehin nie passieren -- kommt hier praktisch
|
||||||
|
nicht an."""
|
||||||
|
if admin.token_tenant_id is not None:
|
||||||
|
return {admin.token_tenant_id}
|
||||||
|
if admin.is_admin:
|
||||||
|
return None
|
||||||
|
return set(admin.tenant_admin_ids)
|
||||||
|
|||||||
@ -327,4 +327,19 @@ async def change_password(
|
|||||||
|
|
||||||
@router.get("/me")
|
@router.get("/me")
|
||||||
async def me(user: CurrentUser = Depends(get_current_user)):
|
async def me(user: CurrentUser = Depends(get_current_user)):
|
||||||
return {"id": user.id, "username": user.username, "is_admin": user.is_admin}
|
tenant_admin_of = []
|
||||||
|
if user.tenant_admin_ids:
|
||||||
|
conn = get_db()
|
||||||
|
placeholders = ",".join("?" for _ in user.tenant_admin_ids)
|
||||||
|
cursor = await conn.execute(
|
||||||
|
f"SELECT id, name FROM tenants WHERE id IN ({placeholders}) ORDER BY name",
|
||||||
|
tuple(user.tenant_admin_ids),
|
||||||
|
)
|
||||||
|
tenant_admin_of = [{"id": r[0], "name": r[1]} for r in await cursor.fetchall()]
|
||||||
|
return {
|
||||||
|
"id": user.id,
|
||||||
|
"username": user.username,
|
||||||
|
"is_admin": user.is_admin,
|
||||||
|
"is_any_admin": user.is_any_admin,
|
||||||
|
"tenant_admin_of": tenant_admin_of,
|
||||||
|
}
|
||||||
|
|||||||
65
app/db/migrations/0006_tenants.sql
Normal file
65
app/db/migrations/0006_tenants.sql
Normal 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);
|
||||||
14
app/db/migrations/0007_crud_extras.sql
Normal file
14
app/db/migrations/0007_crud_extras.sql
Normal 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;
|
||||||
@ -15,6 +15,11 @@ from pydantic import BaseModel, Field, field_validator
|
|||||||
USERNAME_RE = re.compile(r"^[a-zA-Z0-9._-]{3,64}$")
|
USERNAME_RE = re.compile(r"^[a-zA-Z0-9._-]{3,64}$")
|
||||||
HOSTNAME_LABEL_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$")
|
HOSTNAME_LABEL_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$")
|
||||||
|
|
||||||
|
ROLE_NAME = Literal[
|
||||||
|
"ssh_connect", "rdp_connect", "file_transfer", "clipboard",
|
||||||
|
"session_recording_view", "admin_hostgroup",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class LoginRequest(BaseModel):
|
class LoginRequest(BaseModel):
|
||||||
username: str = Field(min_length=3, max_length=64)
|
username: str = Field(min_length=3, max_length=64)
|
||||||
@ -46,6 +51,11 @@ class UserCreateRequest(BaseModel):
|
|||||||
username: str = Field(min_length=3, max_length=64)
|
username: str = Field(min_length=3, max_length=64)
|
||||||
initial_password: str = Field(min_length=12, max_length=256)
|
initial_password: str = Field(min_length=12, max_length=256)
|
||||||
is_admin: bool = False
|
is_admin: bool = False
|
||||||
|
# Nur relevant, wenn der anlegende Principal Mandanten-Admin ist: wird
|
||||||
|
# serverseitig ohnehin auf den/die erlaubten Mandanten erzwungen (siehe
|
||||||
|
# create_user() in admin/routes.py) -- ein Mandanten-Admin kann hierueber
|
||||||
|
# KEINEN User einem fremden Mandanten zuordnen. Fuer Super-Admins optional.
|
||||||
|
home_tenant_id: int | None = None
|
||||||
|
|
||||||
@field_validator("username")
|
@field_validator("username")
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -55,9 +65,38 @@ class UserCreateRequest(BaseModel):
|
|||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class UserUpdateRequest(BaseModel):
|
||||||
|
is_admin: bool | None = None
|
||||||
|
is_active: bool | None = None
|
||||||
|
new_password: str | None = Field(default=None, min_length=12, max_length=256)
|
||||||
|
|
||||||
|
|
||||||
|
class TenantCreateRequest(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=128)
|
||||||
|
description: str | None = Field(default=None, max_length=1024)
|
||||||
|
|
||||||
|
|
||||||
|
class TenantUpdateRequest(BaseModel):
|
||||||
|
name: str | None = Field(default=None, min_length=1, max_length=128)
|
||||||
|
description: str | None = Field(default=None, max_length=1024)
|
||||||
|
is_active: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TenantAdminAssignRequest(BaseModel):
|
||||||
|
user_id: int
|
||||||
|
|
||||||
|
|
||||||
class HostGroupCreateRequest(BaseModel):
|
class HostGroupCreateRequest(BaseModel):
|
||||||
name: str = Field(min_length=1, max_length=128)
|
name: str = Field(min_length=1, max_length=128)
|
||||||
description: str | None = Field(default=None, max_length=1024)
|
description: str | None = Field(default=None, max_length=1024)
|
||||||
|
# Nur fuer Super-Admins relevant (Mandanten-Admins werden serverseitig
|
||||||
|
# ohnehin auf ihren eigenen Mandanten festgelegt, siehe admin/routes.py).
|
||||||
|
tenant_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class HostGroupUpdateRequest(BaseModel):
|
||||||
|
name: str | None = Field(default=None, min_length=1, max_length=128)
|
||||||
|
description: str | None = Field(default=None, max_length=1024)
|
||||||
|
|
||||||
|
|
||||||
class HostCreateRequest(BaseModel):
|
class HostCreateRequest(BaseModel):
|
||||||
@ -83,19 +122,49 @@ class HostCreateRequest(BaseModel):
|
|||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class HostUpdateRequest(BaseModel):
|
||||||
|
host_group_id: int | None = None
|
||||||
|
hostname: str | None = Field(default=None, min_length=1, max_length=128)
|
||||||
|
address: str | None = Field(default=None, min_length=1, max_length=255)
|
||||||
|
port: int | None = Field(default=None, gt=0, le=65535)
|
||||||
|
ssh_username: str | None = None
|
||||||
|
rdp_username: str | None = None
|
||||||
|
rdp_domain: str | None = None
|
||||||
|
rdp_require_nla: bool | None = None
|
||||||
|
clipboard_enabled: bool | None = None
|
||||||
|
file_transfer_enabled: bool | None = None
|
||||||
|
is_active: bool | None = None
|
||||||
|
|
||||||
|
@field_validator("hostname")
|
||||||
|
@classmethod
|
||||||
|
def check_hostname(cls, v: str | None) -> str | None:
|
||||||
|
if v is not None and not HOSTNAME_LABEL_RE.match(v):
|
||||||
|
raise ValueError("Ungueltiger Hostname")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
class RoleGrantRequest(BaseModel):
|
class RoleGrantRequest(BaseModel):
|
||||||
user_id: int
|
user_id: int
|
||||||
host_group_id: int
|
host_group_id: int
|
||||||
role_name: Literal[
|
role_names: list[ROLE_NAME] = Field(min_length=1, max_length=6)
|
||||||
"ssh_connect", "rdp_connect", "file_transfer", "clipboard",
|
|
||||||
"session_recording_view", "admin_hostgroup",
|
|
||||||
]
|
|
||||||
expires_at: str | None = None
|
expires_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RoleRevokeRequest(BaseModel):
|
||||||
|
user_id: int
|
||||||
|
host_group_id: int
|
||||||
|
role_name: ROLE_NAME
|
||||||
|
|
||||||
|
|
||||||
class UserGroupCreateRequest(BaseModel):
|
class UserGroupCreateRequest(BaseModel):
|
||||||
name: str = Field(min_length=1, max_length=128)
|
name: str = Field(min_length=1, max_length=128)
|
||||||
description: str | None = Field(default=None, max_length=1024)
|
description: str | None = Field(default=None, max_length=1024)
|
||||||
|
tenant_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class UserGroupUpdateRequest(BaseModel):
|
||||||
|
name: str | None = Field(default=None, min_length=1, max_length=128)
|
||||||
|
description: str | None = Field(default=None, max_length=1024)
|
||||||
|
|
||||||
|
|
||||||
class GroupMemberRequest(BaseModel):
|
class GroupMemberRequest(BaseModel):
|
||||||
@ -105,18 +174,24 @@ class GroupMemberRequest(BaseModel):
|
|||||||
class GroupRoleGrantRequest(BaseModel):
|
class GroupRoleGrantRequest(BaseModel):
|
||||||
user_group_id: int
|
user_group_id: int
|
||||||
host_group_id: int
|
host_group_id: int
|
||||||
role_name: Literal[
|
role_names: list[ROLE_NAME] = Field(min_length=1, max_length=6)
|
||||||
"ssh_connect", "rdp_connect", "file_transfer", "clipboard",
|
|
||||||
"session_recording_view", "admin_hostgroup",
|
|
||||||
]
|
|
||||||
expires_at: str | None = None
|
expires_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class GroupRoleRevokeRequest(BaseModel):
|
||||||
|
user_group_id: int
|
||||||
|
host_group_id: int
|
||||||
|
role_name: ROLE_NAME
|
||||||
|
|
||||||
|
|
||||||
class ApiTokenCreateRequest(BaseModel):
|
class ApiTokenCreateRequest(BaseModel):
|
||||||
label: str = Field(min_length=1, max_length=128)
|
label: str = Field(min_length=1, max_length=128)
|
||||||
user_id: int
|
user_id: int
|
||||||
scopes: list[str] = Field(min_length=1, max_length=32)
|
scopes: list[str] = Field(min_length=1, max_length=32)
|
||||||
expires_at: str | None = None
|
expires_at: str | None = None
|
||||||
|
# Nur fuer Super-Admins relevant -- ein Mandanten-Admin wird serverseitig
|
||||||
|
# ohnehin auf seinen eigenen Mandanten festgelegt.
|
||||||
|
tenant_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class SshKeyCreateRequest(BaseModel):
|
class SshKeyCreateRequest(BaseModel):
|
||||||
@ -125,6 +200,18 @@ class SshKeyCreateRequest(BaseModel):
|
|||||||
private_key_pem: str = Field(min_length=1, max_length=32_768)
|
private_key_pem: str = Field(min_length=1, max_length=32_768)
|
||||||
public_key: str = Field(min_length=1, max_length=8192)
|
public_key: str = Field(min_length=1, max_length=8192)
|
||||||
key_type: Literal["ed25519", "rsa-3072", "rsa-4096", "ca-cert"]
|
key_type: Literal["ed25519", "rsa-3072", "rsa-4096", "ca-cert"]
|
||||||
|
tenant_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SshKeyUpdateRequest(BaseModel):
|
||||||
|
label: str | None = Field(default=None, min_length=1, max_length=128)
|
||||||
|
owner_user_id: int | None = None
|
||||||
|
# Wenn gesetzt, wird der Schluessel rotiert (neues Schluesselmaterial,
|
||||||
|
# rotated_at wird aktualisiert). Alle drei Felder muessen dann zusammen
|
||||||
|
# angegeben werden (siehe rotate_ssh_key() in admin/routes.py).
|
||||||
|
private_key_pem: str | None = Field(default=None, min_length=1, max_length=32_768)
|
||||||
|
public_key: str | None = Field(default=None, min_length=1, max_length=8192)
|
||||||
|
key_type: Literal["ed25519", "rsa-3072", "rsa-4096", "ca-cert"] | None = None
|
||||||
|
|
||||||
|
|
||||||
class ConnectRequest(BaseModel):
|
class ConnectRequest(BaseModel):
|
||||||
|
|||||||
97
app/tenancy.py
Normal file
97
app/tenancy.py
Normal 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}
|
||||||
@ -192,3 +192,30 @@ pre.json-view {
|
|||||||
.session-toolbar .status { font-size: 0.8rem; color: var(--muted); }
|
.session-toolbar .status { font-size: 0.8rem; color: var(--muted); }
|
||||||
#terminal, #rdp-display { flex: 1; background: black; }
|
#terminal, #rdp-display { flex: 1; background: black; }
|
||||||
#rdp-display canvas { display: block; margin: 0 auto; }
|
#rdp-display canvas { display: block; margin: 0 auto; }
|
||||||
|
|
||||||
|
/* --- Dateitransfer-Panel (Terminal-Sitzung) ---------------------------- */
|
||||||
|
|
||||||
|
.ft-overlay {
|
||||||
|
position: fixed; inset: 0; background: rgba(0, 0, 0, 0.55); z-index: 50;
|
||||||
|
display: flex; justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.ft-overlay.hidden { display: none !important; }
|
||||||
|
.ft-panel {
|
||||||
|
width: 420px; max-width: 100%; height: 100%; background: var(--panel);
|
||||||
|
border-left: 1px solid var(--border); padding: 1.25rem; overflow-y: auto;
|
||||||
|
}
|
||||||
|
.ft-panel h2 {
|
||||||
|
font-size: 1rem; margin: 0 0 1rem; display: flex; align-items: center; justify-content: space-between;
|
||||||
|
}
|
||||||
|
.ft-panel h3 {
|
||||||
|
font-size: 0.8rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em;
|
||||||
|
margin: 1.5rem 0 0.5rem;
|
||||||
|
}
|
||||||
|
.ft-btn-row { display: flex; align-items: center; gap: 0.6rem; margin-top: 0.75rem; }
|
||||||
|
.ft-btn-row .hint { margin: 0; }
|
||||||
|
.ft-list { font-size: 0.8rem; }
|
||||||
|
.ft-list-row {
|
||||||
|
display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0; border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.ft-list-row .name { flex: 1; word-break: break-all; }
|
||||||
|
.ft-list-row .size { color: var(--muted); flex-shrink: 0; white-space: nowrap; }
|
||||||
|
|||||||
@ -5,8 +5,15 @@
|
|||||||
"ssh_connect", "rdp_connect", "file_transfer", "clipboard",
|
"ssh_connect", "rdp_connect", "file_transfer", "clipboard",
|
||||||
"session_recording_view", "admin_hostgroup",
|
"session_recording_view", "admin_hostgroup",
|
||||||
];
|
];
|
||||||
|
const AUTH_EVENT_TYPES = new Set([
|
||||||
|
"login_password_ok", "login_failed", "login_success", "login_totp_failed",
|
||||||
|
"login_recovery_code_used", "totp_enroll_started", "totp_enroll_confirmed",
|
||||||
|
"totp_enroll_failed", "logout", "logout_everywhere", "password_changed",
|
||||||
|
]);
|
||||||
|
const FAILURE_EVENT_TYPES = new Set(["login_failed", "login_totp_failed", "totp_enroll_failed"]);
|
||||||
|
|
||||||
const bannerBox = document.getElementById("banner-box");
|
const bannerBox = document.getElementById("banner-box");
|
||||||
|
let meInfo = { is_admin: false, tenant_admin_of: [] };
|
||||||
|
|
||||||
function showBanner(message, type) {
|
function showBanner(message, type) {
|
||||||
const div = document.createElement("div");
|
const div = document.createElement("div");
|
||||||
@ -80,6 +87,24 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function roleCheckboxGrid(container) {
|
||||||
|
container.replaceChildren(
|
||||||
|
...ROLE_NAMES.map((name) => {
|
||||||
|
const id = `${container.id}-${name}`;
|
||||||
|
const row = el("div", { className: "checkbox-row" });
|
||||||
|
const checkbox = el("input", { type: "checkbox", id, value: name });
|
||||||
|
const label = el("label", { for: id, textContent: name });
|
||||||
|
row.appendChild(checkbox);
|
||||||
|
row.appendChild(label);
|
||||||
|
return row;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkedValues(container) {
|
||||||
|
return Array.from(container.querySelectorAll("input:checked")).map((c) => c.value);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
// Tabs
|
// Tabs
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
@ -88,8 +113,11 @@
|
|||||||
users: loadUsersTab,
|
users: loadUsersTab,
|
||||||
groups: loadGroupsTab,
|
groups: loadGroupsTab,
|
||||||
hosts: loadHostsTab,
|
hosts: loadHostsTab,
|
||||||
|
credentials: loadCredentialsTab,
|
||||||
roles: loadRolesTab,
|
roles: loadRolesTab,
|
||||||
tokens: loadTokensTab,
|
tokens: loadTokensTab,
|
||||||
|
tenants: loadTenantsTab,
|
||||||
|
authlog: loadAuthLogTab,
|
||||||
audit: loadAuditTab,
|
audit: loadAuditTab,
|
||||||
};
|
};
|
||||||
const loadedTabs = new Set();
|
const loadedTabs = new Set();
|
||||||
@ -108,23 +136,57 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Mandanten-Auswahlfelder (fuer Super-Admins Dropdown aller Mandanten,
|
||||||
|
// fuer Mandanten-Admins nur die eigenen -- kein Server-Request noetig,
|
||||||
|
// steht bereits in meInfo.tenant_admin_of).
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
let cachedTenants = [];
|
||||||
|
|
||||||
|
async function populateTenantSelect(select) {
|
||||||
|
if (meInfo.is_admin) {
|
||||||
|
if (cachedTenants.length === 0) {
|
||||||
|
const result = await getJson("/admin/tenants");
|
||||||
|
cachedTenants = result;
|
||||||
|
}
|
||||||
|
select.replaceChildren(...optionsFromList(cachedTenants, "id", (t) => `${t.name} (#${t.id})`));
|
||||||
|
} else {
|
||||||
|
select.replaceChildren(
|
||||||
|
...optionsFromList(meInfo.tenant_admin_of, "id", (t) => `${t.name} (#${t.id})`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
// Benutzer
|
// Benutzer
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
async function loadUsersTab() {
|
async function loadUsersTab() {
|
||||||
|
document.getElementById("uc-is-admin-row").classList.toggle("hidden", !meInfo.is_admin);
|
||||||
await refreshUsers();
|
await refreshUsers();
|
||||||
}
|
}
|
||||||
|
|
||||||
let cachedUsers = [];
|
let cachedUsers = [];
|
||||||
|
let editingUserId = null;
|
||||||
|
|
||||||
|
function tenantName(tenantId) {
|
||||||
|
if (tenantId === null || tenantId === undefined) return "-";
|
||||||
|
const t = cachedTenants.find((x) => x.id === tenantId);
|
||||||
|
return t ? t.name : `#${tenantId}`;
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshUsers() {
|
async function refreshUsers() {
|
||||||
|
if (meInfo.is_admin && cachedTenants.length === 0) {
|
||||||
|
cachedTenants = await getJson("/admin/tenants");
|
||||||
|
}
|
||||||
cachedUsers = await getJson("/admin/users");
|
cachedUsers = await getJson("/admin/users");
|
||||||
const tbody = document.querySelector("#users-table tbody");
|
const tbody = document.querySelector("#users-table tbody");
|
||||||
fillTable(
|
fillTable(
|
||||||
tbody,
|
tbody,
|
||||||
cachedUsers.map((u) => {
|
cachedUsers.map((u) => {
|
||||||
const actions = el("td", {});
|
const actions = el("td", {});
|
||||||
|
actions.appendChild(actionButton("Bearbeiten", "btn-secondary", () => showUserEdit(u)));
|
||||||
if (u.is_active) {
|
if (u.is_active) {
|
||||||
actions.appendChild(
|
actions.appendChild(
|
||||||
actionButton("Deaktivieren", "btn-danger", async () => {
|
actionButton("Deaktivieren", "btn-danger", async () => {
|
||||||
@ -134,12 +196,25 @@
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
actions.appendChild(
|
||||||
|
actionButton("Loeschen", "btn-danger", async () => {
|
||||||
|
const result = await apiFetch(`/admin/users/${u.id}`, { method: "DELETE" });
|
||||||
|
showBanner(
|
||||||
|
result.hard_deleted
|
||||||
|
? `Benutzer '${u.username}' vollstaendig geloescht.`
|
||||||
|
: `Benutzer '${u.username}' hat Audit-Historie und wurde deaktiviert/anonymisiert.`,
|
||||||
|
"ok"
|
||||||
|
);
|
||||||
|
await refreshUsers();
|
||||||
|
})
|
||||||
|
);
|
||||||
return el("tr", {}, [
|
return el("tr", {}, [
|
||||||
el("td", { textContent: String(u.id) }),
|
el("td", { textContent: String(u.id) }),
|
||||||
el("td", { textContent: u.username }),
|
el("td", { textContent: u.username }),
|
||||||
el("td", { textContent: u.is_admin ? "ja" : "nein" }),
|
el("td", { textContent: u.is_admin ? "ja" : "nein" }),
|
||||||
el("td", { textContent: u.is_active ? "ja" : "nein" }),
|
el("td", { textContent: u.is_active ? "ja" : "nein" }),
|
||||||
el("td", { textContent: u.totp_enrolled ? "ja" : "nein" }),
|
el("td", { textContent: u.totp_enrolled ? "ja" : "nein" }),
|
||||||
|
el("td", { textContent: tenantName(u.home_tenant_id) }),
|
||||||
el("td", { textContent: u.created_at }),
|
el("td", { textContent: u.created_at }),
|
||||||
actions,
|
actions,
|
||||||
]);
|
]);
|
||||||
@ -147,12 +222,37 @@
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showUserEdit(u) {
|
||||||
|
editingUserId = u.id;
|
||||||
|
document.getElementById("user-edit-panel").classList.remove("hidden");
|
||||||
|
document.getElementById("user-edit-name").textContent = `${u.username} (#${u.id})`;
|
||||||
|
document.getElementById("ue-is-admin-row").classList.toggle("hidden", !meInfo.is_admin);
|
||||||
|
document.getElementById("ue-is-admin").checked = u.is_admin;
|
||||||
|
document.getElementById("ue-is-active").checked = u.is_active;
|
||||||
|
document.getElementById("ue-new-password").value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("user-edit-form").addEventListener("submit", async (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
try {
|
||||||
|
const payload = { is_active: document.getElementById("ue-is-active").checked };
|
||||||
|
if (meInfo.is_admin) payload.is_admin = document.getElementById("ue-is-admin").checked;
|
||||||
|
const pw = document.getElementById("ue-new-password").value;
|
||||||
|
if (pw) payload.new_password = pw;
|
||||||
|
await sendJson(`/admin/users/${editingUserId}`, "PUT", payload);
|
||||||
|
showBanner("Benutzer aktualisiert.", "ok");
|
||||||
|
await refreshUsers();
|
||||||
|
} catch (err) {
|
||||||
|
showBanner(err.message, "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById("user-create-form").addEventListener("submit", async (ev) => {
|
document.getElementById("user-create-form").addEventListener("submit", async (ev) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
try {
|
try {
|
||||||
const username = document.getElementById("uc-username").value.trim();
|
const username = document.getElementById("uc-username").value.trim();
|
||||||
const password = document.getElementById("uc-password").value;
|
const password = document.getElementById("uc-password").value;
|
||||||
const isAdmin = document.getElementById("uc-is-admin").checked;
|
const isAdmin = meInfo.is_admin && document.getElementById("uc-is-admin").checked;
|
||||||
await sendJson("/admin/users", "POST", {
|
await sendJson("/admin/users", "POST", {
|
||||||
username, initial_password: password, is_admin: isAdmin,
|
username, initial_password: password, is_admin: isAdmin,
|
||||||
});
|
});
|
||||||
@ -171,8 +271,11 @@
|
|||||||
|
|
||||||
let cachedGroups = [];
|
let cachedGroups = [];
|
||||||
let activeGroupId = null;
|
let activeGroupId = null;
|
||||||
|
let editingGroupId = null;
|
||||||
|
|
||||||
async function loadGroupsTab() {
|
async function loadGroupsTab() {
|
||||||
|
document.getElementById("gc-tenant-box").classList.toggle("hidden", !meInfo.is_admin && meInfo.tenant_admin_of.length <= 1);
|
||||||
|
await populateTenantSelect(document.getElementById("gc-tenant"));
|
||||||
await refreshGroups();
|
await refreshGroups();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -186,15 +289,44 @@
|
|||||||
el("td", { textContent: String(g.id) }),
|
el("td", { textContent: String(g.id) }),
|
||||||
el("td", { textContent: g.name }),
|
el("td", { textContent: g.name }),
|
||||||
el("td", { textContent: g.description || "" }),
|
el("td", { textContent: g.description || "" }),
|
||||||
|
el("td", { textContent: g.tenant_name }),
|
||||||
el("td", { textContent: String(g.member_count) }),
|
el("td", { textContent: String(g.member_count) }),
|
||||||
el("td", {}, [
|
el("td", {}, [
|
||||||
actionButton("Mitglieder", "btn-secondary", () => showGroupMembers(g)),
|
actionButton("Mitglieder", "btn-secondary", () => showGroupMembers(g)),
|
||||||
|
actionButton("Bearbeiten", "btn-secondary", () => showGroupEdit(g)),
|
||||||
|
actionButton("Loeschen", "btn-danger", async () => {
|
||||||
|
await apiFetch(`/admin/user-groups/${g.id}`, { method: "DELETE" });
|
||||||
|
showBanner(`Gruppe '${g.name}' geloescht.`, "ok");
|
||||||
|
await refreshGroups();
|
||||||
|
}),
|
||||||
]),
|
]),
|
||||||
])
|
])
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showGroupEdit(g) {
|
||||||
|
editingGroupId = g.id;
|
||||||
|
document.getElementById("group-edit-panel").classList.remove("hidden");
|
||||||
|
document.getElementById("group-edit-name").textContent = g.name;
|
||||||
|
document.getElementById("ge-name").value = g.name;
|
||||||
|
document.getElementById("ge-description").value = g.description || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("group-edit-form").addEventListener("submit", async (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
try {
|
||||||
|
await sendJson(`/admin/user-groups/${editingGroupId}`, "PUT", {
|
||||||
|
name: document.getElementById("ge-name").value.trim(),
|
||||||
|
description: document.getElementById("ge-description").value.trim() || null,
|
||||||
|
});
|
||||||
|
showBanner("Gruppe aktualisiert.", "ok");
|
||||||
|
await refreshGroups();
|
||||||
|
} catch (err) {
|
||||||
|
showBanner(err.message, "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
async function showGroupMembers(group) {
|
async function showGroupMembers(group) {
|
||||||
activeGroupId = group.id;
|
activeGroupId = group.id;
|
||||||
document.getElementById("group-members-panel").classList.remove("hidden");
|
document.getElementById("group-members-panel").classList.remove("hidden");
|
||||||
@ -232,7 +364,9 @@
|
|||||||
try {
|
try {
|
||||||
const name = document.getElementById("gc-name").value.trim();
|
const name = document.getElementById("gc-name").value.trim();
|
||||||
const description = document.getElementById("gc-description").value.trim() || null;
|
const description = document.getElementById("gc-description").value.trim() || null;
|
||||||
await sendJson("/admin/user-groups", "POST", { name, description });
|
const tenantSelect = document.getElementById("gc-tenant");
|
||||||
|
const tenant_id = tenantSelect.value ? Number(tenantSelect.value) : null;
|
||||||
|
await sendJson("/admin/user-groups", "POST", { name, description, tenant_id });
|
||||||
showBanner(`Benutzergruppe '${name}' angelegt.`, "ok");
|
showBanner(`Benutzergruppe '${name}' angelegt.`, "ok");
|
||||||
ev.target.reset();
|
ev.target.reset();
|
||||||
await refreshGroups();
|
await refreshGroups();
|
||||||
@ -263,9 +397,12 @@
|
|||||||
let cachedHosts = [];
|
let cachedHosts = [];
|
||||||
let cachedSshKeys = [];
|
let cachedSshKeys = [];
|
||||||
let activeHostId = null;
|
let activeHostId = null;
|
||||||
|
let editingHostGroupId = null;
|
||||||
|
|
||||||
async function loadHostsTab() {
|
async function loadHostsTab() {
|
||||||
await Promise.all([refreshHostGroups(), refreshSshKeys()]);
|
document.getElementById("hgc-tenant-box").classList.toggle("hidden", !meInfo.is_admin && meInfo.tenant_admin_of.length <= 1);
|
||||||
|
await populateTenantSelect(document.getElementById("hgc-tenant"));
|
||||||
|
await Promise.all([refreshHostGroups(), refreshSshKeysCache()]);
|
||||||
await refreshHosts();
|
await refreshHosts();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -279,6 +416,15 @@
|
|||||||
el("td", { textContent: String(g.id) }),
|
el("td", { textContent: String(g.id) }),
|
||||||
el("td", { textContent: g.name }),
|
el("td", { textContent: g.name }),
|
||||||
el("td", { textContent: g.description || "" }),
|
el("td", { textContent: g.description || "" }),
|
||||||
|
el("td", { textContent: g.tenant_name }),
|
||||||
|
el("td", {}, [
|
||||||
|
actionButton("Bearbeiten", "btn-secondary", () => showHostGroupEdit(g)),
|
||||||
|
actionButton("Loeschen", "btn-danger", async () => {
|
||||||
|
await apiFetch(`/admin/host-groups/${g.id}`, { method: "DELETE" });
|
||||||
|
showBanner(`Hostgruppe '${g.name}' geloescht.`, "ok");
|
||||||
|
await refreshHostGroups();
|
||||||
|
}),
|
||||||
|
]),
|
||||||
])
|
])
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@ -286,6 +432,28 @@
|
|||||||
hcSelect.replaceChildren(...optionsFromList(cachedHostGroups, "id", (g) => `${g.name} (#${g.id})`));
|
hcSelect.replaceChildren(...optionsFromList(cachedHostGroups, "id", (g) => `${g.name} (#${g.id})`));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showHostGroupEdit(g) {
|
||||||
|
editingHostGroupId = g.id;
|
||||||
|
document.getElementById("hostgroup-edit-panel").classList.remove("hidden");
|
||||||
|
document.getElementById("hostgroup-edit-name").textContent = g.name;
|
||||||
|
document.getElementById("hge-name").value = g.name;
|
||||||
|
document.getElementById("hge-description").value = g.description || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("hostgroup-edit-form").addEventListener("submit", async (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
try {
|
||||||
|
await sendJson(`/admin/host-groups/${editingHostGroupId}`, "PUT", {
|
||||||
|
name: document.getElementById("hge-name").value.trim(),
|
||||||
|
description: document.getElementById("hge-description").value.trim() || null,
|
||||||
|
});
|
||||||
|
showBanner("Hostgruppe aktualisiert.", "ok");
|
||||||
|
await refreshHostGroups();
|
||||||
|
} catch (err) {
|
||||||
|
showBanner(err.message, "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
async function refreshHosts() {
|
async function refreshHosts() {
|
||||||
cachedHosts = await getJson("/admin/hosts");
|
cachedHosts = await getJson("/admin/hosts");
|
||||||
const groupName = (id) => (cachedHostGroups.find((g) => g.id === id) || {}).name || `#${id}`;
|
const groupName = (id) => (cachedHostGroups.find((g) => g.id === id) || {}).name || `#${id}`;
|
||||||
@ -300,40 +468,124 @@
|
|||||||
el("td", { textContent: h.protocol }),
|
el("td", { textContent: h.protocol }),
|
||||||
el("td", { textContent: h.os_type }),
|
el("td", { textContent: h.os_type }),
|
||||||
el("td", { textContent: groupName(h.host_group_id) }),
|
el("td", { textContent: groupName(h.host_group_id) }),
|
||||||
el("td", {}, [actionButton("Details", "btn-secondary", () => showHostDetail(h))]),
|
el("td", { textContent: h.tenant_name }),
|
||||||
|
el("td", {}, [el("span", { className: `badge ${h.is_active ? "ok" : "danger"}`, textContent: h.is_active ? "aktiv" : "inaktiv" })]),
|
||||||
|
el("td", {}, [
|
||||||
|
actionButton("Details", "btn-secondary", () => showHostDetail(h.id)),
|
||||||
|
actionButton(h.is_active ? "Loeschen" : "Reaktivieren", h.is_active ? "btn-danger" : "btn-secondary", async () => {
|
||||||
|
if (h.is_active) {
|
||||||
|
await apiFetch(`/admin/hosts/${h.id}`, { method: "DELETE" });
|
||||||
|
showBanner(`Host '${h.hostname}' deaktiviert/geloescht.`, "ok");
|
||||||
|
} else {
|
||||||
|
await sendJson(`/admin/hosts/${h.id}`, "PUT", { is_active: true });
|
||||||
|
showBanner(`Host '${h.hostname}' reaktiviert.`, "ok");
|
||||||
|
}
|
||||||
|
await refreshHosts();
|
||||||
|
if (activeHostId === h.id) document.getElementById("host-detail-panel").classList.add("hidden");
|
||||||
|
}),
|
||||||
|
]),
|
||||||
])
|
])
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshSshKeys() {
|
async function refreshSshKeysCache() {
|
||||||
cachedSshKeys = await getJson("/admin/ssh-keys");
|
cachedSshKeys = await getJson("/admin/ssh-keys");
|
||||||
const tbody = document.querySelector("#ssh-keys-table tbody");
|
|
||||||
fillTable(
|
|
||||||
tbody,
|
|
||||||
cachedSshKeys.map((k) =>
|
|
||||||
el("tr", {}, [
|
|
||||||
el("td", { textContent: String(k.id) }),
|
|
||||||
el("td", { textContent: k.label }),
|
|
||||||
el("td", { textContent: k.key_type }),
|
|
||||||
el("td", { textContent: k.owner_user_id === null ? "-" : String(k.owner_user_id) }),
|
|
||||||
el("td", { textContent: k.created_at }),
|
|
||||||
])
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function showHostDetail(host) {
|
function setDetailBanner(message, type) {
|
||||||
activeHostId = host.id;
|
const box = document.getElementById("host-detail-banner");
|
||||||
document.getElementById("host-detail-panel").classList.remove("hidden");
|
if (!message) {
|
||||||
document.getElementById("host-detail-name").textContent = `${host.hostname} (#${host.id})`;
|
box.replaceChildren();
|
||||||
document.getElementById("host-detail-ssh").classList.toggle("hidden", host.protocol !== "ssh");
|
return;
|
||||||
document.getElementById("host-detail-rdp").classList.toggle("hidden", host.protocol !== "rdp");
|
}
|
||||||
document.getElementById("host-key-result").classList.add("hidden");
|
box.replaceChildren(el("div", { className: `banner ${type === "error" ? "error" : "ok"}`, textContent: message }));
|
||||||
const keySelect = document.getElementById("hkm-key-select");
|
|
||||||
keySelect.replaceChildren(...optionsFromList(cachedSshKeys, "id", (k) => `${k.label} (#${k.id})`));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function showHostDetail(hostId) {
|
||||||
|
setDetailBanner("", "ok");
|
||||||
|
try {
|
||||||
|
const host = await getJson(`/admin/hosts/${hostId}`);
|
||||||
|
activeHostId = host.id;
|
||||||
|
document.getElementById("host-detail-panel").classList.remove("hidden");
|
||||||
|
document.getElementById("host-detail-name").textContent = `${host.hostname} (#${host.id}, ${host.tenant_name})`;
|
||||||
|
|
||||||
|
document.getElementById("he-hostname").value = host.hostname;
|
||||||
|
document.getElementById("he-address").value = host.address;
|
||||||
|
document.getElementById("he-port").value = String(host.port);
|
||||||
|
document.getElementById("he-ssh-username").value = host.ssh_username || "";
|
||||||
|
document.getElementById("he-rdp-username").value = host.rdp_username || "";
|
||||||
|
document.getElementById("he-rdp-domain").value = host.rdp_domain || "";
|
||||||
|
document.getElementById("he-clipboard").checked = host.clipboard_enabled;
|
||||||
|
document.getElementById("he-filetransfer").checked = host.file_transfer_enabled;
|
||||||
|
document.getElementById("he-nla").checked = host.rdp_require_nla;
|
||||||
|
document.getElementById("he-active").checked = host.is_active;
|
||||||
|
|
||||||
|
const isRdp = host.protocol === "rdp";
|
||||||
|
document.getElementById("he-ssh-username-box").classList.toggle("hidden", isRdp);
|
||||||
|
document.getElementById("he-rdp-username-box").classList.toggle("hidden", !isRdp);
|
||||||
|
document.getElementById("he-rdp-domain-box").classList.toggle("hidden", !isRdp);
|
||||||
|
document.getElementById("he-nla-box").classList.toggle("hidden", !isRdp);
|
||||||
|
document.getElementById("host-detail-ssh").classList.toggle("hidden", isRdp);
|
||||||
|
document.getElementById("host-detail-rdp").classList.toggle("hidden", !isRdp);
|
||||||
|
|
||||||
|
document.getElementById("host-key-result").classList.add("hidden");
|
||||||
|
if (host.ssh_host_key_fingerprint) {
|
||||||
|
const box = document.getElementById("host-key-result");
|
||||||
|
box.textContent = `Aktueller Fingerprint: ${host.ssh_host_key_fingerprint}`;
|
||||||
|
box.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
const keysTbody = document.querySelector("#host-ssh-keys-table tbody");
|
||||||
|
fillTable(
|
||||||
|
keysTbody,
|
||||||
|
host.ssh_keys.map((k) =>
|
||||||
|
el("tr", {}, [
|
||||||
|
el("td", { textContent: String(k.id) }),
|
||||||
|
el("td", { textContent: k.label }),
|
||||||
|
el("td", {}, [
|
||||||
|
actionButton("Entfernen", "btn-danger", async () => {
|
||||||
|
await apiFetch(`/admin/hosts/${host.id}/ssh-keys/${k.id}`, { method: "DELETE" });
|
||||||
|
await showHostDetail(host.id);
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const keySelect = document.getElementById("hkm-key-select");
|
||||||
|
keySelect.replaceChildren(...optionsFromList(cachedSshKeys, "id", (k) => `${k.label} (#${k.id})`));
|
||||||
|
|
||||||
|
document.getElementById("rdp-cred-status").textContent = host.rdp_credentials_set
|
||||||
|
? `RDP-Passwort ist gesetzt (zuletzt aktualisiert: ${host.rdp_credentials_updated_at}).`
|
||||||
|
: "Noch kein RDP-Passwort gesetzt.";
|
||||||
|
} catch (err) {
|
||||||
|
setDetailBanner(err.message, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("host-edit-form").addEventListener("submit", async (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
try {
|
||||||
|
await sendJson(`/admin/hosts/${activeHostId}`, "PUT", {
|
||||||
|
hostname: document.getElementById("he-hostname").value.trim(),
|
||||||
|
address: document.getElementById("he-address").value.trim(),
|
||||||
|
port: Number(document.getElementById("he-port").value),
|
||||||
|
ssh_username: document.getElementById("he-ssh-username").value.trim() || null,
|
||||||
|
rdp_username: document.getElementById("he-rdp-username").value.trim() || null,
|
||||||
|
rdp_domain: document.getElementById("he-rdp-domain").value.trim() || null,
|
||||||
|
clipboard_enabled: document.getElementById("he-clipboard").checked,
|
||||||
|
file_transfer_enabled: document.getElementById("he-filetransfer").checked,
|
||||||
|
rdp_require_nla: document.getElementById("he-nla").checked,
|
||||||
|
is_active: document.getElementById("he-active").checked,
|
||||||
|
});
|
||||||
|
showBanner("Host aktualisiert.", "ok");
|
||||||
|
await refreshHosts();
|
||||||
|
await showHostDetail(activeHostId);
|
||||||
|
} catch (err) {
|
||||||
|
setDetailBanner(err.message, "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById("hc-protocol").addEventListener("change", (ev) => {
|
document.getElementById("hc-protocol").addEventListener("change", (ev) => {
|
||||||
const isRdp = ev.target.value === "rdp";
|
const isRdp = ev.target.value === "rdp";
|
||||||
document.getElementById("hc-ssh-username-box").classList.toggle("hidden", isRdp);
|
document.getElementById("hc-ssh-username-box").classList.toggle("hidden", isRdp);
|
||||||
@ -349,7 +601,9 @@
|
|||||||
try {
|
try {
|
||||||
const name = document.getElementById("hgc-name").value.trim();
|
const name = document.getElementById("hgc-name").value.trim();
|
||||||
const description = document.getElementById("hgc-description").value.trim() || null;
|
const description = document.getElementById("hgc-description").value.trim() || null;
|
||||||
await sendJson("/admin/host-groups", "POST", { name, description });
|
const tenantSelect = document.getElementById("hgc-tenant");
|
||||||
|
const tenant_id = tenantSelect.value ? Number(tenantSelect.value) : null;
|
||||||
|
await sendJson("/admin/host-groups", "POST", { name, description, tenant_id });
|
||||||
showBanner(`Hostgruppe '${name}' angelegt.`, "ok");
|
showBanner(`Hostgruppe '${name}' angelegt.`, "ok");
|
||||||
ev.target.reset();
|
ev.target.reset();
|
||||||
await refreshHostGroups();
|
await refreshHostGroups();
|
||||||
@ -396,7 +650,7 @@
|
|||||||
box.classList.remove("hidden");
|
box.classList.remove("hidden");
|
||||||
showBanner("Host-Key ermittelt und gespeichert.", "ok");
|
showBanner("Host-Key ermittelt und gespeichert.", "ok");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showBanner(err.message, "error");
|
setDetailBanner(err.message, "error");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -406,8 +660,9 @@
|
|||||||
const keyId = document.getElementById("hkm-key-select").value;
|
const keyId = document.getElementById("hkm-key-select").value;
|
||||||
await sendJson(`/admin/hosts/${activeHostId}/ssh-keys/${keyId}`, "POST", {});
|
await sendJson(`/admin/hosts/${activeHostId}/ssh-keys/${keyId}`, "POST", {});
|
||||||
showBanner("SSH-Key zugeordnet.", "ok");
|
showBanner("SSH-Key zugeordnet.", "ok");
|
||||||
|
await showHostDetail(activeHostId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showBanner(err.message, "error");
|
setDetailBanner(err.message, "error");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -418,6 +673,89 @@
|
|||||||
await sendJson(`/admin/hosts/${activeHostId}/rdp-credentials`, "PUT", { password });
|
await sendJson(`/admin/hosts/${activeHostId}/rdp-credentials`, "PUT", { password });
|
||||||
showBanner("RDP-Zugangsdaten gespeichert.", "ok");
|
showBanner("RDP-Zugangsdaten gespeichert.", "ok");
|
||||||
ev.target.reset();
|
ev.target.reset();
|
||||||
|
await showHostDetail(activeHostId);
|
||||||
|
} catch (err) {
|
||||||
|
setDetailBanner(err.message, "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Zugangsdaten (SSH-Keys + RDP-Passwoerter)
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
let editingSshKeyId = null;
|
||||||
|
|
||||||
|
async function loadCredentialsTab() {
|
||||||
|
document.getElementById("skc-tenant-box").classList.toggle("hidden", !meInfo.is_admin && meInfo.tenant_admin_of.length <= 1);
|
||||||
|
await populateTenantSelect(document.getElementById("skc-tenant"));
|
||||||
|
await Promise.all([refreshSshKeys(), refreshRdpCredentials()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshSshKeys() {
|
||||||
|
cachedSshKeys = await getJson("/admin/ssh-keys");
|
||||||
|
const tbody = document.querySelector("#ssh-keys-table tbody");
|
||||||
|
fillTable(
|
||||||
|
tbody,
|
||||||
|
cachedSshKeys.map((k) =>
|
||||||
|
el("tr", {}, [
|
||||||
|
el("td", { textContent: String(k.id) }),
|
||||||
|
el("td", { textContent: k.label }),
|
||||||
|
el("td", { textContent: k.key_type }),
|
||||||
|
el("td", { textContent: k.owner_user_id === null ? "-" : String(k.owner_user_id) }),
|
||||||
|
el("td", { textContent: k.tenant_name }),
|
||||||
|
el("td", { textContent: k.created_at }),
|
||||||
|
el("td", { textContent: k.rotated_at || "-" }),
|
||||||
|
el("td", {}, [
|
||||||
|
actionButton("Bearbeiten", "btn-secondary", () => showSshKeyEdit(k)),
|
||||||
|
actionButton("Loeschen", "btn-danger", async () => {
|
||||||
|
const result = await apiFetch(`/admin/ssh-keys/${k.id}`, { method: "DELETE" });
|
||||||
|
const suffix = result.unmapped_host_ids.length
|
||||||
|
? ` (Zuordnung zu Host(s) ${result.unmapped_host_ids.join(", ")} entfernt)`
|
||||||
|
: "";
|
||||||
|
showBanner(`SSH-Key '${k.label}' geloescht.${suffix}`, "ok");
|
||||||
|
await refreshSshKeys();
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSshKeyEdit(k) {
|
||||||
|
editingSshKeyId = k.id;
|
||||||
|
document.getElementById("ssh-key-edit-panel").classList.remove("hidden");
|
||||||
|
document.getElementById("ssh-key-edit-name").textContent = `${k.label} (#${k.id})`;
|
||||||
|
document.getElementById("ske-label").value = k.label;
|
||||||
|
document.getElementById("ske-owner").value = k.owner_user_id === null ? "" : String(k.owner_user_id);
|
||||||
|
document.getElementById("skr-type").value = k.key_type;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("ssh-key-edit-form").addEventListener("submit", async (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
try {
|
||||||
|
const ownerRaw = document.getElementById("ske-owner").value.trim();
|
||||||
|
await sendJson(`/admin/ssh-keys/${editingSshKeyId}`, "PUT", {
|
||||||
|
label: document.getElementById("ske-label").value.trim() || null,
|
||||||
|
owner_user_id: ownerRaw ? Number(ownerRaw) : null,
|
||||||
|
});
|
||||||
|
showBanner("SSH-Key aktualisiert.", "ok");
|
||||||
|
await refreshSshKeys();
|
||||||
|
} catch (err) {
|
||||||
|
showBanner(err.message, "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("ssh-key-rotate-form").addEventListener("submit", async (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
try {
|
||||||
|
await sendJson(`/admin/ssh-keys/${editingSshKeyId}`, "PUT", {
|
||||||
|
key_type: document.getElementById("skr-type").value,
|
||||||
|
private_key_pem: document.getElementById("skr-private").value,
|
||||||
|
public_key: document.getElementById("skr-public").value,
|
||||||
|
});
|
||||||
|
showBanner("SSH-Key rotiert.", "ok");
|
||||||
|
ev.target.reset();
|
||||||
|
await refreshSshKeys();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showBanner(err.message, "error");
|
showBanner(err.message, "error");
|
||||||
}
|
}
|
||||||
@ -427,42 +765,61 @@
|
|||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
try {
|
try {
|
||||||
const ownerRaw = document.getElementById("skc-owner").value.trim();
|
const ownerRaw = document.getElementById("skc-owner").value.trim();
|
||||||
|
const tenantSelect = document.getElementById("skc-tenant");
|
||||||
const payload = {
|
const payload = {
|
||||||
label: document.getElementById("skc-label").value.trim(),
|
label: document.getElementById("skc-label").value.trim(),
|
||||||
owner_user_id: ownerRaw ? Number(ownerRaw) : null,
|
owner_user_id: ownerRaw ? Number(ownerRaw) : null,
|
||||||
key_type: document.getElementById("skc-type").value,
|
key_type: document.getElementById("skc-type").value,
|
||||||
private_key_pem: document.getElementById("skc-private").value,
|
private_key_pem: document.getElementById("skc-private").value,
|
||||||
public_key: document.getElementById("skc-public").value,
|
public_key: document.getElementById("skc-public").value,
|
||||||
|
tenant_id: tenantSelect.value ? Number(tenantSelect.value) : null,
|
||||||
};
|
};
|
||||||
await sendJson("/admin/ssh-keys", "POST", payload);
|
await sendJson("/admin/ssh-keys", "POST", payload);
|
||||||
showBanner(`SSH-Key '${payload.label}' angelegt.`, "ok");
|
showBanner(`SSH-Key '${payload.label}' angelegt.`, "ok");
|
||||||
ev.target.reset();
|
ev.target.reset();
|
||||||
await refreshSshKeys();
|
await refreshSshKeys();
|
||||||
if (activeHostId !== null) {
|
|
||||||
const keySelect = document.getElementById("hkm-key-select");
|
|
||||||
keySelect.replaceChildren(...optionsFromList(cachedSshKeys, "id", (k) => `${k.label} (#${k.id})`));
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showBanner(err.message, "error");
|
showBanner(err.message, "error");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function refreshRdpCredentials() {
|
||||||
|
const rows = await getJson("/admin/rdp-credentials");
|
||||||
|
const tbody = document.querySelector("#rdp-creds-table tbody");
|
||||||
|
fillTable(
|
||||||
|
tbody,
|
||||||
|
rows.map((r) =>
|
||||||
|
el("tr", {}, [
|
||||||
|
el("td", { textContent: r.hostname }),
|
||||||
|
el("td", { textContent: r.address }),
|
||||||
|
el("td", { textContent: r.host_group_name }),
|
||||||
|
el("td", {}, [el("span", { className: `badge ${r.credentials_set ? "ok" : ""}`, textContent: r.credentials_set ? "gesetzt" : "nicht gesetzt" })]),
|
||||||
|
el("td", { textContent: r.updated_at || "-" }),
|
||||||
|
el("td", {}, [
|
||||||
|
actionButton("Zum Host", "btn-secondary", async () => {
|
||||||
|
document.querySelector('.tab-btn[data-tab="hosts"]').click();
|
||||||
|
await showHostDetail(r.host_id);
|
||||||
|
}),
|
||||||
|
...(r.credentials_set
|
||||||
|
? [actionButton("Entfernen", "btn-danger", async () => {
|
||||||
|
await apiFetch(`/admin/hosts/${r.host_id}/rdp-credentials`, { method: "DELETE" });
|
||||||
|
showBanner("RDP-Passwort entfernt.", "ok");
|
||||||
|
await refreshRdpCredentials();
|
||||||
|
})]
|
||||||
|
: []),
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
// Rollen
|
// Rollen
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
function roleOptions() {
|
|
||||||
return ROLE_NAMES.map((name) => {
|
|
||||||
const opt = document.createElement("option");
|
|
||||||
opt.value = name;
|
|
||||||
opt.textContent = name;
|
|
||||||
return opt;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadRolesTab() {
|
async function loadRolesTab() {
|
||||||
document.getElementById("rg-role").replaceChildren(...roleOptions());
|
roleCheckboxGrid(document.getElementById("rg-role-grid"));
|
||||||
document.getElementById("grg-role").replaceChildren(...roleOptions());
|
roleCheckboxGrid(document.getElementById("grg-role-grid"));
|
||||||
await refreshSharedSelects();
|
await refreshSharedSelects();
|
||||||
await Promise.all([refreshRoleGrants(), refreshGroupRoleGrants()]);
|
await Promise.all([refreshRoleGrants(), refreshGroupRoleGrants()]);
|
||||||
}
|
}
|
||||||
@ -518,13 +875,16 @@
|
|||||||
document.getElementById("role-grant-form").addEventListener("submit", async (ev) => {
|
document.getElementById("role-grant-form").addEventListener("submit", async (ev) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
try {
|
try {
|
||||||
|
const role_names = checkedValues(document.getElementById("rg-role-grid"));
|
||||||
|
if (role_names.length === 0) throw new Error("Mindestens eine Rolle auswaehlen.");
|
||||||
await sendJson("/admin/roles/grant", "POST", {
|
await sendJson("/admin/roles/grant", "POST", {
|
||||||
user_id: Number(document.getElementById("rg-user").value),
|
user_id: Number(document.getElementById("rg-user").value),
|
||||||
host_group_id: Number(document.getElementById("rg-hostgroup").value),
|
host_group_id: Number(document.getElementById("rg-hostgroup").value),
|
||||||
role_name: document.getElementById("rg-role").value,
|
role_names,
|
||||||
expires_at: document.getElementById("rg-expires").value.trim() || null,
|
expires_at: document.getElementById("rg-expires").value.trim() || null,
|
||||||
});
|
});
|
||||||
showBanner("Rolle vergeben.", "ok");
|
showBanner(`Rolle(n) vergeben: ${role_names.join(", ")}.`, "ok");
|
||||||
|
document.querySelectorAll("#rg-role-grid input:checked").forEach((c) => { c.checked = false; });
|
||||||
await refreshRoleGrants();
|
await refreshRoleGrants();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showBanner(err.message, "error");
|
showBanner(err.message, "error");
|
||||||
@ -534,13 +894,16 @@
|
|||||||
document.getElementById("group-role-grant-form").addEventListener("submit", async (ev) => {
|
document.getElementById("group-role-grant-form").addEventListener("submit", async (ev) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
try {
|
try {
|
||||||
|
const role_names = checkedValues(document.getElementById("grg-role-grid"));
|
||||||
|
if (role_names.length === 0) throw new Error("Mindestens eine Rolle auswaehlen.");
|
||||||
await sendJson("/admin/group-roles/grant", "POST", {
|
await sendJson("/admin/group-roles/grant", "POST", {
|
||||||
user_group_id: Number(document.getElementById("grg-group").value),
|
user_group_id: Number(document.getElementById("grg-group").value),
|
||||||
host_group_id: Number(document.getElementById("grg-hostgroup").value),
|
host_group_id: Number(document.getElementById("grg-hostgroup").value),
|
||||||
role_name: document.getElementById("grg-role").value,
|
role_names,
|
||||||
expires_at: document.getElementById("grg-expires").value.trim() || null,
|
expires_at: document.getElementById("grg-expires").value.trim() || null,
|
||||||
});
|
});
|
||||||
showBanner("Rolle an Gruppe vergeben.", "ok");
|
showBanner(`Rolle(n) an Gruppe vergeben: ${role_names.join(", ")}.`, "ok");
|
||||||
|
document.querySelectorAll("#grg-role-grid input:checked").forEach((c) => { c.checked = false; });
|
||||||
await refreshGroupRoleGrants();
|
await refreshGroupRoleGrants();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showBanner(err.message, "error");
|
showBanner(err.message, "error");
|
||||||
@ -570,6 +933,8 @@
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
document.getElementById("tc-tenant-box").classList.toggle("hidden", !meInfo.is_admin && meInfo.tenant_admin_of.length <= 1);
|
||||||
|
await populateTenantSelect(document.getElementById("tc-tenant"));
|
||||||
await refreshSharedSelects();
|
await refreshSharedSelects();
|
||||||
await refreshTokens();
|
await refreshTokens();
|
||||||
}
|
}
|
||||||
@ -599,6 +964,7 @@
|
|||||||
el("td", { textContent: t.label }),
|
el("td", { textContent: t.label }),
|
||||||
el("td", { textContent: t.username }),
|
el("td", { textContent: t.username }),
|
||||||
el("td", { textContent: t.prefix }),
|
el("td", { textContent: t.prefix }),
|
||||||
|
el("td", { textContent: t.tenant_name }),
|
||||||
scopesCell,
|
scopesCell,
|
||||||
el("td", { textContent: t.last_used_at || "nie" }),
|
el("td", { textContent: t.last_used_at || "nie" }),
|
||||||
el("td", { textContent: status }),
|
el("td", { textContent: status }),
|
||||||
@ -615,11 +981,13 @@
|
|||||||
if (scopes.length === 0) {
|
if (scopes.length === 0) {
|
||||||
throw new Error("Mindestens einen Scope auswaehlen.");
|
throw new Error("Mindestens einen Scope auswaehlen.");
|
||||||
}
|
}
|
||||||
|
const tenantSelect = document.getElementById("tc-tenant");
|
||||||
const payload = {
|
const payload = {
|
||||||
label: document.getElementById("tc-label").value.trim(),
|
label: document.getElementById("tc-label").value.trim(),
|
||||||
user_id: Number(document.getElementById("tc-user").value),
|
user_id: Number(document.getElementById("tc-user").value),
|
||||||
scopes,
|
scopes,
|
||||||
expires_at: document.getElementById("tc-expires").value.trim() || null,
|
expires_at: document.getElementById("tc-expires").value.trim() || null,
|
||||||
|
tenant_id: tenantSelect.value ? Number(tenantSelect.value) : null,
|
||||||
};
|
};
|
||||||
const result = await sendJson("/admin/tokens", "POST", payload);
|
const result = await sendJson("/admin/tokens", "POST", payload);
|
||||||
const box = document.getElementById("token-reveal-box");
|
const box = document.getElementById("token-reveal-box");
|
||||||
@ -634,6 +1002,156 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Mandanten (nur Super-Admin -- Tab-Button ist fuer alle anderen hidden)
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
let editingTenantId = null;
|
||||||
|
let activeTenantAdminsId = null;
|
||||||
|
|
||||||
|
async function loadTenantsTab() {
|
||||||
|
await refreshTenants();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshTenants() {
|
||||||
|
cachedTenants = await getJson("/admin/tenants");
|
||||||
|
const tbody = document.querySelector("#tenants-table tbody");
|
||||||
|
fillTable(
|
||||||
|
tbody,
|
||||||
|
cachedTenants.map((t) =>
|
||||||
|
el("tr", {}, [
|
||||||
|
el("td", { textContent: String(t.id) }),
|
||||||
|
el("td", { textContent: t.name }),
|
||||||
|
el("td", { textContent: t.description || "" }),
|
||||||
|
el("td", { textContent: t.is_active ? "ja" : "nein" }),
|
||||||
|
el("td", { textContent: String(t.host_group_count) }),
|
||||||
|
el("td", { textContent: String(t.user_group_count) }),
|
||||||
|
el("td", {}, [
|
||||||
|
actionButton("Admins", "btn-secondary", () => showTenantAdmins(t)),
|
||||||
|
actionButton("Bearbeiten", "btn-secondary", () => showTenantEdit(t)),
|
||||||
|
actionButton("Loeschen", "btn-danger", async () => {
|
||||||
|
await apiFetch(`/admin/tenants/${t.id}`, { method: "DELETE" });
|
||||||
|
showBanner(`Mandant '${t.name}' geloescht.`, "ok");
|
||||||
|
await refreshTenants();
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showTenantEdit(t) {
|
||||||
|
editingTenantId = t.id;
|
||||||
|
document.getElementById("tenant-edit-panel").classList.remove("hidden");
|
||||||
|
document.getElementById("tenant-edit-name").textContent = t.name;
|
||||||
|
document.getElementById("tne-name").value = t.name;
|
||||||
|
document.getElementById("tne-description").value = t.description || "";
|
||||||
|
document.getElementById("tne-active").checked = t.is_active;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("tenant-edit-form").addEventListener("submit", async (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
try {
|
||||||
|
await sendJson(`/admin/tenants/${editingTenantId}`, "PUT", {
|
||||||
|
name: document.getElementById("tne-name").value.trim(),
|
||||||
|
description: document.getElementById("tne-description").value.trim() || null,
|
||||||
|
is_active: document.getElementById("tne-active").checked,
|
||||||
|
});
|
||||||
|
showBanner("Mandant aktualisiert.", "ok");
|
||||||
|
await refreshTenants();
|
||||||
|
} catch (err) {
|
||||||
|
showBanner(err.message, "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function showTenantAdmins(t) {
|
||||||
|
activeTenantAdminsId = t.id;
|
||||||
|
document.getElementById("tenant-admins-panel").classList.remove("hidden");
|
||||||
|
document.getElementById("tenant-admins-name").textContent = t.name;
|
||||||
|
await refreshTenantAdmins();
|
||||||
|
const select = document.getElementById("ta-user-select");
|
||||||
|
if (cachedUsers.length === 0) await refreshUsers();
|
||||||
|
select.replaceChildren(...optionsFromList(cachedUsers, "id", (u) => `${u.username} (#${u.id})`));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshTenantAdmins() {
|
||||||
|
const admins = await getJson(`/admin/tenants/${activeTenantAdminsId}/admins`);
|
||||||
|
const tbody = document.querySelector("#tenant-admins-table tbody");
|
||||||
|
fillTable(
|
||||||
|
tbody,
|
||||||
|
admins.map((a) =>
|
||||||
|
el("tr", {}, [
|
||||||
|
el("td", { textContent: String(a.user_id) }),
|
||||||
|
el("td", { textContent: a.username }),
|
||||||
|
el("td", { textContent: a.granted_at }),
|
||||||
|
el("td", {}, [
|
||||||
|
actionButton("Entfernen", "btn-danger", async () => {
|
||||||
|
await apiFetch(`/admin/tenants/${activeTenantAdminsId}/admins/${a.user_id}`, { method: "DELETE" });
|
||||||
|
await refreshTenantAdmins();
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("tenant-create-form").addEventListener("submit", async (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
try {
|
||||||
|
const name = document.getElementById("tnc-name").value.trim();
|
||||||
|
const description = document.getElementById("tnc-description").value.trim() || null;
|
||||||
|
await sendJson("/admin/tenants", "POST", { name, description });
|
||||||
|
showBanner(`Mandant '${name}' angelegt.`, "ok");
|
||||||
|
ev.target.reset();
|
||||||
|
await refreshTenants();
|
||||||
|
} catch (err) {
|
||||||
|
showBanner(err.message, "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("tenant-admin-add-form").addEventListener("submit", async (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
try {
|
||||||
|
const userId = Number(document.getElementById("ta-user-select").value);
|
||||||
|
await sendJson(`/admin/tenants/${activeTenantAdminsId}/admins`, "POST", { user_id: userId });
|
||||||
|
showBanner("Mandanten-Admin hinzugefuegt.", "ok");
|
||||||
|
await refreshTenantAdmins();
|
||||||
|
} catch (err) {
|
||||||
|
showBanner(err.message, "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Login-Verlauf (Auth-Log) -- clientseitig aus dem Audit-Log gefiltert
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function loadAuthLogTab() {
|
||||||
|
await refreshAuthLog();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAuthLog() {
|
||||||
|
const entries = await getJson("/admin/audit-log?limit=500");
|
||||||
|
const authEntries = entries.filter((e) => AUTH_EVENT_TYPES.has(e.event_type));
|
||||||
|
const tbody = document.querySelector("#authlog-table tbody");
|
||||||
|
fillTable(
|
||||||
|
tbody,
|
||||||
|
authEntries.map((e) =>
|
||||||
|
el("tr", {}, [
|
||||||
|
el("td", { textContent: e.ts }),
|
||||||
|
el("td", { textContent: e.user_id === null ? "-" : String(e.user_id) }),
|
||||||
|
el("td", { textContent: e.client_ip || "-" }),
|
||||||
|
el("td", { textContent: e.event_type }),
|
||||||
|
el("td", {}, [
|
||||||
|
el("span", {
|
||||||
|
className: `badge ${FAILURE_EVENT_TYPES.has(e.event_type) ? "danger" : "ok"}`,
|
||||||
|
textContent: FAILURE_EVENT_TYPES.has(e.event_type) ? "fehlgeschlagen" : "erfolgreich",
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
// Audit-Log
|
// Audit-Log
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
@ -710,11 +1228,16 @@
|
|||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const me = await getJson("/auth/me");
|
const me = await getJson("/auth/me");
|
||||||
if (!me.is_admin) {
|
if (!me.is_any_admin) {
|
||||||
window.location.href = "/dashboard";
|
window.location.href = "/dashboard";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
document.getElementById("whoami").textContent = `${me.username} (Admin)`;
|
meInfo = me;
|
||||||
|
const roleLabel = me.is_admin
|
||||||
|
? "Super-Admin"
|
||||||
|
: `Mandanten-Admin: ${me.tenant_admin_of.map((t) => t.name).join(", ")}`;
|
||||||
|
document.getElementById("whoami").textContent = `${me.username} (${roleLabel})`;
|
||||||
|
document.getElementById("tenants-tab-btn").classList.toggle("hidden", !me.is_admin);
|
||||||
loadedTabs.add("users");
|
loadedTabs.add("users");
|
||||||
await loadUsersTab();
|
await loadUsersTab();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,12 @@
|
|||||||
* Copy & Paste: xterm.js liefert dies bei SSH bereits nativ ueber die
|
* Copy & Paste: xterm.js liefert dies bei SSH bereits nativ ueber die
|
||||||
* System-Zwischenablage (Markieren-zum-Kopieren / Strg+Umschalt+V) -- keine
|
* System-Zwischenablage (Markieren-zum-Kopieren / Strg+Umschalt+V) -- keine
|
||||||
* serverseitige Sonderbehandlung noetig, im Gegensatz zu RDP (siehe rdp.js).
|
* serverseitige Sonderbehandlung noetig, im Gegensatz zu RDP (siehe rdp.js).
|
||||||
|
*
|
||||||
|
* Dateitransfer: eigenes Panel (statt frueherem prompt()-basiertem Upload)
|
||||||
|
* mit Formularen fuer Upload UND Download in beide Richtungen. Der Download
|
||||||
|
* laeuft ueber fetch() + Blob + synthetischen <a download>-Link, damit
|
||||||
|
* Fehler inline im Panel angezeigt werden koennen statt die Seite zu
|
||||||
|
* verlassen (siehe GET /ssh/{host_id}/files/download).
|
||||||
*/
|
*/
|
||||||
(() => {
|
(() => {
|
||||||
"use strict";
|
"use strict";
|
||||||
@ -73,17 +79,100 @@
|
|||||||
});
|
});
|
||||||
document.addEventListener("fullscreenchange", sendResize);
|
document.addEventListener("fullscreenchange", sendResize);
|
||||||
|
|
||||||
const fileInput = document.getElementById("file-input");
|
// --- Dateitransfer-Panel ------------------------------------------------
|
||||||
document.getElementById("upload-btn").addEventListener("click", () => fileInput.click());
|
|
||||||
fileInput.addEventListener("change", async () => {
|
const ftOverlay = document.getElementById("ft-overlay");
|
||||||
const file = fileInput.files[0];
|
const ftStatus = document.getElementById("ft-status");
|
||||||
if (!file) return;
|
const ftList = document.getElementById("ft-list");
|
||||||
const remotePath = prompt("Zielpfad auf dem Server:", `/tmp/${file.name}`);
|
const transfers = [];
|
||||||
if (!remotePath) return;
|
|
||||||
|
function openFt() {
|
||||||
|
ftOverlay.classList.remove("hidden");
|
||||||
|
ftStatus.textContent = "";
|
||||||
|
}
|
||||||
|
function closeFt() {
|
||||||
|
ftOverlay.classList.add("hidden");
|
||||||
|
}
|
||||||
|
document.getElementById("filetransfer-btn").addEventListener("click", openFt);
|
||||||
|
document.getElementById("ft-close-btn").addEventListener("click", closeFt);
|
||||||
|
|
||||||
|
function formatSize(bytes) {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordTransfer(direction, filename, size, ok, message) {
|
||||||
|
transfers.unshift({ direction, filename, size, ok, message });
|
||||||
|
if (transfers.length > 20) transfers.length = 20;
|
||||||
|
renderTransfers();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTransfers() {
|
||||||
|
ftList.innerHTML = "";
|
||||||
|
if (transfers.length === 0) {
|
||||||
|
const empty = document.createElement("div");
|
||||||
|
empty.className = "hint";
|
||||||
|
empty.textContent = "Noch keine Transfers in dieser Sitzung.";
|
||||||
|
ftList.appendChild(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const t of transfers) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "ft-list-row";
|
||||||
|
|
||||||
|
const dir = document.createElement("span");
|
||||||
|
dir.className = "badge " + (t.ok ? "ok" : "danger");
|
||||||
|
dir.textContent = t.direction === "upload" ? "↑ Upload" : "↓ Download";
|
||||||
|
row.appendChild(dir);
|
||||||
|
|
||||||
|
const name = document.createElement("span");
|
||||||
|
name.className = "name";
|
||||||
|
name.textContent = t.ok ? t.filename : `${t.filename} -- ${t.message}`;
|
||||||
|
row.appendChild(name);
|
||||||
|
|
||||||
|
if (t.ok) {
|
||||||
|
const size = document.createElement("span");
|
||||||
|
size.className = "size";
|
||||||
|
size.textContent = formatSize(t.size);
|
||||||
|
row.appendChild(size);
|
||||||
|
}
|
||||||
|
|
||||||
|
ftList.appendChild(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
renderTransfers();
|
||||||
|
|
||||||
|
// Upload
|
||||||
|
const uploadRemotePathInput = document.getElementById("ft-upload-remote-path");
|
||||||
|
const uploadFileInput = document.getElementById("ft-upload-file-input");
|
||||||
|
const uploadFilenameLabel = document.getElementById("ft-upload-filename");
|
||||||
|
|
||||||
|
document.getElementById("ft-upload-pick-btn").addEventListener("click", () => uploadFileInput.click());
|
||||||
|
uploadFileInput.addEventListener("change", () => {
|
||||||
|
const file = uploadFileInput.files[0];
|
||||||
|
uploadFilenameLabel.textContent = file ? file.name : "Keine Datei ausgewaehlt";
|
||||||
|
if (file && !uploadRemotePathInput.value) {
|
||||||
|
uploadRemotePathInput.value = `/tmp/${file.name}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("ft-upload-submit-btn").addEventListener("click", async () => {
|
||||||
|
const file = uploadFileInput.files[0];
|
||||||
|
const remotePath = uploadRemotePathInput.value.trim();
|
||||||
|
ftStatus.textContent = "";
|
||||||
|
if (!file) {
|
||||||
|
ftStatus.textContent = "Bitte zuerst eine Datei auswaehlen.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!remotePath) {
|
||||||
|
ftStatus.textContent = "Bitte einen Zielpfad angeben.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", file);
|
formData.append("file", file);
|
||||||
statusEl.textContent = `Lade ${file.name} hoch ...`;
|
ftStatus.textContent = `Lade ${file.name} hoch ...`;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`/ssh/${hostId}/files/upload?remote_path=${encodeURIComponent(remotePath)}`,
|
`/ssh/${hostId}/files/upload?remote_path=${encodeURIComponent(remotePath)}`,
|
||||||
@ -91,10 +180,58 @@
|
|||||||
);
|
);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) throw new Error(data.detail || "Upload fehlgeschlagen");
|
if (!res.ok) throw new Error(data.detail || "Upload fehlgeschlagen");
|
||||||
statusEl.textContent = `Upload ok (AV: ${data.av_scan_result})`;
|
ftStatus.textContent = `Upload abgeschlossen (AV: ${data.av_scan_result})`;
|
||||||
|
recordTransfer("upload", file.name, data.size, true, "");
|
||||||
|
uploadFileInput.value = "";
|
||||||
|
uploadFilenameLabel.textContent = "Keine Datei ausgewaehlt";
|
||||||
|
uploadRemotePathInput.value = "";
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
statusEl.textContent = "Upload-Fehler: " + err.message;
|
ftStatus.textContent = "Upload-Fehler: " + err.message;
|
||||||
|
recordTransfer("upload", file.name, 0, false, err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Download
|
||||||
|
const downloadRemotePathInput = document.getElementById("ft-download-remote-path");
|
||||||
|
|
||||||
|
document.getElementById("ft-download-submit-btn").addEventListener("click", async () => {
|
||||||
|
const remotePath = downloadRemotePathInput.value.trim();
|
||||||
|
ftStatus.textContent = "";
|
||||||
|
if (!remotePath) {
|
||||||
|
ftStatus.textContent = "Bitte einen Pfad angeben.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const filename = remotePath.split("/").filter(Boolean).pop() || "download";
|
||||||
|
ftStatus.textContent = `Lade ${filename} herunter ...`;
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/ssh/${hostId}/files/download?remote_path=${encodeURIComponent(remotePath)}`,
|
||||||
|
{ method: "GET", credentials: "same-origin" }
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
let message = "Download fehlgeschlagen";
|
||||||
|
try {
|
||||||
|
const data = await res.json();
|
||||||
|
message = data.detail || message;
|
||||||
|
} catch (_) { /* Antwort war kein JSON */ }
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
const blob = await res.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
ftStatus.textContent = `Download abgeschlossen: ${filename}`;
|
||||||
|
recordTransfer("download", filename, blob.size, true, "");
|
||||||
|
downloadRemotePathInput.value = "";
|
||||||
|
} catch (err) {
|
||||||
|
ftStatus.textContent = "Download-Fehler: " + err.message;
|
||||||
|
recordTransfer("download", filename, 0, false, err.message);
|
||||||
}
|
}
|
||||||
fileInput.value = "";
|
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@ -24,8 +24,11 @@
|
|||||||
<button type="button" class="tab-btn active" data-tab="users">Benutzer</button>
|
<button type="button" class="tab-btn active" data-tab="users">Benutzer</button>
|
||||||
<button type="button" class="tab-btn" data-tab="groups">Benutzergruppen</button>
|
<button type="button" class="tab-btn" data-tab="groups">Benutzergruppen</button>
|
||||||
<button type="button" class="tab-btn" data-tab="hosts">Hosts & Verbindungen</button>
|
<button type="button" class="tab-btn" data-tab="hosts">Hosts & Verbindungen</button>
|
||||||
|
<button type="button" class="tab-btn" data-tab="credentials">Zugangsdaten</button>
|
||||||
<button type="button" class="tab-btn" data-tab="roles">Rollen</button>
|
<button type="button" class="tab-btn" data-tab="roles">Rollen</button>
|
||||||
<button type="button" class="tab-btn" data-tab="tokens">API-Tokens</button>
|
<button type="button" class="tab-btn" data-tab="tokens">API-Tokens</button>
|
||||||
|
<button type="button" class="tab-btn hidden" data-tab="tenants" id="tenants-tab-btn">Mandanten</button>
|
||||||
|
<button type="button" class="tab-btn" data-tab="authlog">Login-Verlauf</button>
|
||||||
<button type="button" class="tab-btn" data-tab="audit">Audit-Log</button>
|
<button type="button" class="tab-btn" data-tab="audit">Audit-Log</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -42,9 +45,9 @@
|
|||||||
<label for="uc-password">Initiales Passwort (min. 12 Zeichen)</label>
|
<label for="uc-password">Initiales Passwort (min. 12 Zeichen)</label>
|
||||||
<input type="text" id="uc-password" required minlength="12" maxlength="256">
|
<input type="text" id="uc-password" required minlength="12" maxlength="256">
|
||||||
</div>
|
</div>
|
||||||
<div class="checkbox-row">
|
<div class="checkbox-row" id="uc-is-admin-row">
|
||||||
<input type="checkbox" id="uc-is-admin">
|
<input type="checkbox" id="uc-is-admin">
|
||||||
<label for="uc-is-admin">Globaler Admin</label>
|
<label for="uc-is-admin">Globaler Super-Admin</label>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit">Anlegen</button>
|
<button type="submit">Anlegen</button>
|
||||||
</form>
|
</form>
|
||||||
@ -57,13 +60,32 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>ID</th><th>Benutzername</th><th>Admin</th><th>Aktiv</th>
|
<th>ID</th><th>Benutzername</th><th>Admin</th><th>Aktiv</th>
|
||||||
<th>TOTP</th><th>Erstellt</th><th></th>
|
<th>TOTP</th><th>Mandant</th><th>Erstellt</th><th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody></tbody>
|
<tbody></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="panel hidden" id="user-edit-panel">
|
||||||
|
<h2>Benutzer bearbeiten: <span id="user-edit-name"></span></h2>
|
||||||
|
<form id="user-edit-form" class="form-grid">
|
||||||
|
<div class="checkbox-row" id="ue-is-admin-row">
|
||||||
|
<input type="checkbox" id="ue-is-admin">
|
||||||
|
<label for="ue-is-admin">Globaler Super-Admin</label>
|
||||||
|
</div>
|
||||||
|
<div class="checkbox-row">
|
||||||
|
<input type="checkbox" id="ue-is-active">
|
||||||
|
<label for="ue-is-active">Aktiv</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="ue-new-password">Neues Passwort setzen (optional, min. 12 Zeichen)</label>
|
||||||
|
<input type="text" id="ue-new-password" minlength="12" maxlength="256">
|
||||||
|
</div>
|
||||||
|
<button type="submit">Speichern</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ================= Benutzergruppen ================= -->
|
<!-- ================= Benutzergruppen ================= -->
|
||||||
@ -79,6 +101,10 @@
|
|||||||
<label for="gc-description">Beschreibung (optional)</label>
|
<label for="gc-description">Beschreibung (optional)</label>
|
||||||
<input type="text" id="gc-description" maxlength="1024">
|
<input type="text" id="gc-description" maxlength="1024">
|
||||||
</div>
|
</div>
|
||||||
|
<div id="gc-tenant-box">
|
||||||
|
<label for="gc-tenant">Mandant</label>
|
||||||
|
<select id="gc-tenant" required></select>
|
||||||
|
</div>
|
||||||
<button type="submit">Anlegen</button>
|
<button type="submit">Anlegen</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@ -88,12 +114,27 @@
|
|||||||
<p class="hint">Rollen fuer eine Gruppe (= Verbindungen mit der ganzen Gruppe teilen) werden im Tab "Rollen" vergeben.</p>
|
<p class="hint">Rollen fuer eine Gruppe (= Verbindungen mit der ganzen Gruppe teilen) werden im Tab "Rollen" vergeben.</p>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table class="data-table" id="groups-table">
|
<table class="data-table" id="groups-table">
|
||||||
<thead><tr><th>ID</th><th>Name</th><th>Beschreibung</th><th>Mitglieder</th><th></th></tr></thead>
|
<thead><tr><th>ID</th><th>Name</th><th>Beschreibung</th><th>Mandant</th><th>Mitglieder</th><th></th></tr></thead>
|
||||||
<tbody></tbody>
|
<tbody></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="panel hidden" id="group-edit-panel">
|
||||||
|
<h2>Gruppe bearbeiten: <span id="group-edit-name"></span></h2>
|
||||||
|
<form id="group-edit-form" class="form-grid">
|
||||||
|
<div>
|
||||||
|
<label for="ge-name">Name</label>
|
||||||
|
<input type="text" id="ge-name" required maxlength="128">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="ge-description">Beschreibung</label>
|
||||||
|
<input type="text" id="ge-description" maxlength="1024">
|
||||||
|
</div>
|
||||||
|
<button type="submit">Speichern</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="panel hidden" id="group-members-panel">
|
<div class="panel hidden" id="group-members-panel">
|
||||||
<h2>Mitglieder von <span id="group-members-name"></span></h2>
|
<h2>Mitglieder von <span id="group-members-name"></span></h2>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
@ -126,17 +167,36 @@
|
|||||||
<label for="hgc-description">Beschreibung (optional)</label>
|
<label for="hgc-description">Beschreibung (optional)</label>
|
||||||
<input type="text" id="hgc-description" maxlength="1024">
|
<input type="text" id="hgc-description" maxlength="1024">
|
||||||
</div>
|
</div>
|
||||||
|
<div id="hgc-tenant-box">
|
||||||
|
<label for="hgc-tenant">Mandant</label>
|
||||||
|
<select id="hgc-tenant" required></select>
|
||||||
|
</div>
|
||||||
<button type="submit">Anlegen</button>
|
<button type="submit">Anlegen</button>
|
||||||
</form>
|
</form>
|
||||||
<h3>Hostgruppen</h3>
|
<h3>Hostgruppen</h3>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table class="data-table" id="hostgroups-table">
|
<table class="data-table" id="hostgroups-table">
|
||||||
<thead><tr><th>ID</th><th>Name</th><th>Beschreibung</th></tr></thead>
|
<thead><tr><th>ID</th><th>Name</th><th>Beschreibung</th><th>Mandant</th><th></th></tr></thead>
|
||||||
<tbody></tbody>
|
<tbody></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="panel hidden" id="hostgroup-edit-panel">
|
||||||
|
<h2>Hostgruppe bearbeiten: <span id="hostgroup-edit-name"></span></h2>
|
||||||
|
<form id="hostgroup-edit-form" class="form-grid">
|
||||||
|
<div>
|
||||||
|
<label for="hge-name">Name</label>
|
||||||
|
<input type="text" id="hge-name" required maxlength="128">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="hge-description">Beschreibung</label>
|
||||||
|
<input type="text" id="hge-description" maxlength="1024">
|
||||||
|
</div>
|
||||||
|
<button type="submit">Speichern</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<h2>Neue Verbindung (Host) anlegen</h2>
|
<h2>Neue Verbindung (Host) anlegen</h2>
|
||||||
<form id="host-create-form" class="form-grid">
|
<form id="host-create-form" class="form-grid">
|
||||||
@ -194,7 +254,7 @@
|
|||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table class="data-table" id="hosts-table">
|
<table class="data-table" id="hosts-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>ID</th><th>Hostname</th><th>Adresse</th><th>Protokoll</th><th>OS</th><th>Hostgruppe</th><th></th></tr>
|
<tr><th>ID</th><th>Hostname</th><th>Adresse</th><th>Protokoll</th><th>OS</th><th>Hostgruppe</th><th>Mandant</th><th>Status</th><th></th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody></tbody>
|
<tbody></tbody>
|
||||||
</table>
|
</table>
|
||||||
@ -203,33 +263,78 @@
|
|||||||
|
|
||||||
<div class="panel hidden" id="host-detail-panel">
|
<div class="panel hidden" id="host-detail-panel">
|
||||||
<h2>Verbindung: <span id="host-detail-name"></span></h2>
|
<h2>Verbindung: <span id="host-detail-name"></span></h2>
|
||||||
|
<div id="host-detail-banner"></div>
|
||||||
|
|
||||||
|
<h3>Felder bearbeiten</h3>
|
||||||
|
<form id="host-edit-form" class="form-grid">
|
||||||
|
<div>
|
||||||
|
<label for="he-hostname">Hostname (Label)</label>
|
||||||
|
<input type="text" id="he-hostname" required maxlength="128" pattern="[a-zA-Z0-9][a-zA-Z0-9._-]*">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="he-address">Adresse (IP/DNS)</label>
|
||||||
|
<input type="text" id="he-address" required maxlength="255">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="he-port">Port</label>
|
||||||
|
<input type="text" id="he-port" inputmode="numeric" required maxlength="5">
|
||||||
|
</div>
|
||||||
|
<div id="he-ssh-username-box">
|
||||||
|
<label for="he-ssh-username">SSH-Benutzername</label>
|
||||||
|
<input type="text" id="he-ssh-username" maxlength="128">
|
||||||
|
</div>
|
||||||
|
<div id="he-rdp-username-box" class="hidden">
|
||||||
|
<label for="he-rdp-username">RDP-Benutzername</label>
|
||||||
|
<input type="text" id="he-rdp-username" maxlength="128">
|
||||||
|
</div>
|
||||||
|
<div id="he-rdp-domain-box" class="hidden">
|
||||||
|
<label for="he-rdp-domain">RDP-Domaene</label>
|
||||||
|
<input type="text" id="he-rdp-domain" maxlength="128">
|
||||||
|
</div>
|
||||||
|
<div class="checkbox-row"><input type="checkbox" id="he-clipboard"><label for="he-clipboard">Zwischenablage erlaubt</label></div>
|
||||||
|
<div class="checkbox-row"><input type="checkbox" id="he-filetransfer"><label for="he-filetransfer">Dateitransfer erlaubt</label></div>
|
||||||
|
<div class="checkbox-row hidden" id="he-nla-box"><input type="checkbox" id="he-nla"><label for="he-nla">RDP: NLA erforderlich</label></div>
|
||||||
|
<div class="checkbox-row"><input type="checkbox" id="he-active"><label for="he-active">Aktiv (verbindbar)</label></div>
|
||||||
|
<button type="submit">Speichern</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
<div id="host-detail-ssh">
|
<div id="host-detail-ssh">
|
||||||
<h3>SSH-Host-Key</h3>
|
<h3>SSH-Host-Key</h3>
|
||||||
<p class="hint">Ermittelt den Host-Key EINMALIG ohne Pruefung (bewusste Trust-Entscheidung) und speichert den Fingerprint fuer kuenftiges Pinning. Wird prominent im Audit-Log vermerkt.</p>
|
<p class="hint">Ermittelt den Host-Key EINMALIG ohne Pruefung (bewusste Trust-Entscheidung) und speichert den Fingerprint fuer kuenftiges Pinning. Wird prominent im Audit-Log vermerkt.</p>
|
||||||
<button type="button" class="btn-secondary" id="host-discover-key-btn">Host-Key ermitteln</button>
|
<button type="button" class="btn-secondary" id="host-discover-key-btn">Host-Key ermitteln</button>
|
||||||
<div id="host-key-result" class="reveal-box hidden"></div>
|
<div id="host-key-result" class="reveal-box hidden"></div>
|
||||||
|
|
||||||
<h3>SSH-Key zuordnen</h3>
|
<h3>Zugeordnete SSH-Keys</h3>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="data-table" id="host-ssh-keys-table">
|
||||||
|
<thead><tr><th>ID</th><th>Label</th><th></th></tr></thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
<form id="host-key-map-form" class="form-grid">
|
<form id="host-key-map-form" class="form-grid">
|
||||||
<div>
|
<div>
|
||||||
<label for="hkm-key-select">SSH-Key</label>
|
<label for="hkm-key-select">SSH-Key zuordnen</label>
|
||||||
<select id="hkm-key-select" required></select>
|
<select id="hkm-key-select" required></select>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit">Zuordnen</button>
|
<button type="submit">Zuordnen</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div id="host-detail-rdp" class="hidden">
|
<div id="host-detail-rdp" class="hidden">
|
||||||
<h3>RDP-Zugangsdaten setzen</h3>
|
<h3>RDP-Zugangsdaten</h3>
|
||||||
|
<p class="hint" id="rdp-cred-status"></p>
|
||||||
<form id="host-rdp-cred-form" class="form-grid">
|
<form id="host-rdp-cred-form" class="form-grid">
|
||||||
<div class="full-width">
|
<div class="full-width">
|
||||||
<label for="hrc-password">RDP-Passwort</label>
|
<label for="hrc-password">RDP-Passwort setzen/aendern</label>
|
||||||
<input type="text" id="hrc-password" required maxlength="512">
|
<input type="text" id="hrc-password" required maxlength="512">
|
||||||
</div>
|
</div>
|
||||||
<button type="submit">Speichern</button>
|
<button type="submit">Speichern</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ================= Zugangsdaten ================= -->
|
||||||
|
<div class="tab-panel hidden" id="tab-credentials">
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<h2>Neuen SSH-Key anlegen</h2>
|
<h2>Neuen SSH-Key anlegen</h2>
|
||||||
<form id="ssh-key-create-form" class="form-grid">
|
<form id="ssh-key-create-form" class="form-grid">
|
||||||
@ -250,6 +355,10 @@
|
|||||||
<option value="ca-cert">ca-cert</option>
|
<option value="ca-cert">ca-cert</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="skc-tenant-box">
|
||||||
|
<label for="skc-tenant">Mandant</label>
|
||||||
|
<select id="skc-tenant" required></select>
|
||||||
|
</div>
|
||||||
<div class="full-width">
|
<div class="full-width">
|
||||||
<label for="skc-private">Privater Schluessel (PEM)</label>
|
<label for="skc-private">Privater Schluessel (PEM)</label>
|
||||||
<textarea id="skc-private" required></textarea>
|
<textarea id="skc-private" required></textarea>
|
||||||
@ -263,7 +372,55 @@
|
|||||||
<h3>Vorhandene SSH-Keys</h3>
|
<h3>Vorhandene SSH-Keys</h3>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table class="data-table" id="ssh-keys-table">
|
<table class="data-table" id="ssh-keys-table">
|
||||||
<thead><tr><th>ID</th><th>Label</th><th>Typ</th><th>Besitzer</th><th>Erstellt</th></tr></thead>
|
<thead><tr><th>ID</th><th>Label</th><th>Typ</th><th>Besitzer</th><th>Mandant</th><th>Erstellt</th><th>Rotiert</th><th></th></tr></thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel hidden" id="ssh-key-edit-panel">
|
||||||
|
<h2>SSH-Key bearbeiten: <span id="ssh-key-edit-name"></span></h2>
|
||||||
|
<form id="ssh-key-edit-form" class="form-grid">
|
||||||
|
<div>
|
||||||
|
<label for="ske-label">Label</label>
|
||||||
|
<input type="text" id="ske-label" maxlength="128">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="ske-owner">Besitzer (User-ID, leer = keiner)</label>
|
||||||
|
<input type="text" id="ske-owner" inputmode="numeric" maxlength="10">
|
||||||
|
</div>
|
||||||
|
<button type="submit">Speichern (nur Label/Besitzer)</button>
|
||||||
|
</form>
|
||||||
|
<h3>Schluessel rotieren</h3>
|
||||||
|
<p class="hint">Ersetzt das Schluesselmaterial vollstaendig (neuer privater + oeffentlicher Schluessel). Bereits zugeordnete Hosts nutzen danach automatisch den neuen Schluessel.</p>
|
||||||
|
<form id="ssh-key-rotate-form" class="form-grid">
|
||||||
|
<div>
|
||||||
|
<label for="skr-type">Key-Typ</label>
|
||||||
|
<select id="skr-type">
|
||||||
|
<option value="ed25519">ed25519</option>
|
||||||
|
<option value="rsa-3072">rsa-3072</option>
|
||||||
|
<option value="rsa-4096">rsa-4096</option>
|
||||||
|
<option value="ca-cert">ca-cert</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="full-width">
|
||||||
|
<label for="skr-private">Neuer privater Schluessel (PEM)</label>
|
||||||
|
<textarea id="skr-private" required></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="full-width">
|
||||||
|
<label for="skr-public">Neuer oeffentlicher Schluessel</label>
|
||||||
|
<textarea id="skr-public" required></textarea>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-secondary">Rotieren</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<h2>RDP/Windows-Passwoerter</h2>
|
||||||
|
<p class="hint">Uebersicht aller RDP-Hosts. Passwoerter werden AES-256-GCM-verschluesselt gespeichert und sind nach dem Setzen nicht mehr im Klartext abrufbar.</p>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="data-table" id="rdp-creds-table">
|
||||||
|
<thead><tr><th>Host</th><th>Adresse</th><th>Hostgruppe</th><th>Status</th><th>Zuletzt gesetzt</th><th></th></tr></thead>
|
||||||
<tbody></tbody>
|
<tbody></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@ -273,7 +430,8 @@
|
|||||||
<!-- ================= Rollen ================= -->
|
<!-- ================= Rollen ================= -->
|
||||||
<div class="tab-panel hidden" id="tab-roles">
|
<div class="tab-panel hidden" id="tab-roles">
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<h2>Rolle an Benutzer vergeben</h2>
|
<h2>Rolle(n) an Benutzer vergeben</h2>
|
||||||
|
<p class="hint">Mehrfachauswahl moeglich -- alle ausgewaehlten Rollen werden in einem Schritt vergeben.</p>
|
||||||
<form id="role-grant-form" class="form-grid">
|
<form id="role-grant-form" class="form-grid">
|
||||||
<div>
|
<div>
|
||||||
<label for="rg-user">Benutzer</label>
|
<label for="rg-user">Benutzer</label>
|
||||||
@ -283,9 +441,9 @@
|
|||||||
<label for="rg-hostgroup">Hostgruppe</label>
|
<label for="rg-hostgroup">Hostgruppe</label>
|
||||||
<select id="rg-hostgroup" required></select>
|
<select id="rg-hostgroup" required></select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="full-width">
|
||||||
<label for="rg-role">Rolle</label>
|
<label>Rollen</label>
|
||||||
<select id="rg-role"></select>
|
<div class="scope-grid" id="rg-role-grid"></div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="rg-expires">Ablaufdatum (optional, ISO)</label>
|
<label for="rg-expires">Ablaufdatum (optional, ISO)</label>
|
||||||
@ -302,8 +460,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<h2>Rolle an Benutzergruppe vergeben (Verbindung mit Gruppe teilen)</h2>
|
<h2>Rolle(n) an Benutzergruppe vergeben (Verbindung mit Gruppe teilen)</h2>
|
||||||
<p class="hint">Volle Rollen-Vererbung: jedes aktuelle und zukuenftige Mitglied der Gruppe erhaelt diese Rolle automatisch.</p>
|
<p class="hint">Volle Rollen-Vererbung: jedes aktuelle und zukuenftige Mitglied der Gruppe erhaelt diese Rollen automatisch. Mehrfachauswahl moeglich.</p>
|
||||||
<form id="group-role-grant-form" class="form-grid">
|
<form id="group-role-grant-form" class="form-grid">
|
||||||
<div>
|
<div>
|
||||||
<label for="grg-group">Benutzergruppe</label>
|
<label for="grg-group">Benutzergruppe</label>
|
||||||
@ -313,9 +471,9 @@
|
|||||||
<label for="grg-hostgroup">Hostgruppe</label>
|
<label for="grg-hostgroup">Hostgruppe</label>
|
||||||
<select id="grg-hostgroup" required></select>
|
<select id="grg-hostgroup" required></select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="full-width">
|
||||||
<label for="grg-role">Rolle</label>
|
<label>Rollen</label>
|
||||||
<select id="grg-role"></select>
|
<div class="scope-grid" id="grg-role-grid"></div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="grg-expires">Ablaufdatum (optional, ISO)</label>
|
<label for="grg-expires">Ablaufdatum (optional, ISO)</label>
|
||||||
@ -336,7 +494,7 @@
|
|||||||
<div class="tab-panel hidden" id="tab-tokens">
|
<div class="tab-panel hidden" id="tab-tokens">
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<h2>Neues API-Token erstellen</h2>
|
<h2>Neues API-Token erstellen</h2>
|
||||||
<p class="hint">Tokens wirken NUR auf die Verwaltungs-API (Benutzer/Hosts/Rollen/...), nie auf SSH-/RDP-Sitzungen selbst. "rw" schliesst "ro" auf derselben Ressource automatisch mit ein.</p>
|
<p class="hint">Tokens wirken NUR auf die Verwaltungs-API (Benutzer/Hosts/Rollen/...), nie auf SSH-/RDP-Sitzungen selbst. "rw" schliesst "ro" auf derselben Ressource automatisch mit ein. Jedes Token gehoert genau einem Mandanten.</p>
|
||||||
<form id="token-create-form" class="form-grid">
|
<form id="token-create-form" class="form-grid">
|
||||||
<div>
|
<div>
|
||||||
<label for="tc-label">Bezeichnung</label>
|
<label for="tc-label">Bezeichnung</label>
|
||||||
@ -346,6 +504,10 @@
|
|||||||
<label for="tc-user">Fuer Benutzer</label>
|
<label for="tc-user">Fuer Benutzer</label>
|
||||||
<select id="tc-user" required></select>
|
<select id="tc-user" required></select>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="tc-tenant-box">
|
||||||
|
<label for="tc-tenant">Mandant</label>
|
||||||
|
<select id="tc-tenant" required></select>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="tc-expires">Ablaufdatum (optional, ISO)</label>
|
<label for="tc-expires">Ablaufdatum (optional, ISO)</label>
|
||||||
<input type="text" id="tc-expires" placeholder="2026-12-31T00:00:00.000Z" maxlength="40">
|
<input type="text" id="tc-expires" placeholder="2026-12-31T00:00:00.000Z" maxlength="40">
|
||||||
@ -364,7 +526,7 @@
|
|||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table class="data-table" id="tokens-table">
|
<table class="data-table" id="tokens-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>ID</th><th>Bezeichnung</th><th>Benutzer</th><th>Praefix</th><th>Scopes</th><th>Zuletzt genutzt</th><th>Status</th><th></th></tr>
|
<tr><th>ID</th><th>Bezeichnung</th><th>Benutzer</th><th>Praefix</th><th>Mandant</th><th>Scopes</th><th>Zuletzt genutzt</th><th>Status</th><th></th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody></tbody>
|
<tbody></tbody>
|
||||||
</table>
|
</table>
|
||||||
@ -372,6 +534,79 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ================= Mandanten (nur Super-Admin) ================= -->
|
||||||
|
<div class="tab-panel hidden" id="tab-tenants">
|
||||||
|
<div class="panel">
|
||||||
|
<h2>Neuen Mandanten anlegen</h2>
|
||||||
|
<form id="tenant-create-form" class="form-grid">
|
||||||
|
<div>
|
||||||
|
<label for="tnc-name">Name</label>
|
||||||
|
<input type="text" id="tnc-name" required maxlength="128">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="tnc-description">Beschreibung (optional)</label>
|
||||||
|
<input type="text" id="tnc-description" maxlength="1024">
|
||||||
|
</div>
|
||||||
|
<button type="submit">Anlegen</button>
|
||||||
|
</form>
|
||||||
|
<h3>Mandanten</h3>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="data-table" id="tenants-table">
|
||||||
|
<thead><tr><th>ID</th><th>Name</th><th>Beschreibung</th><th>Aktiv</th><th>Hostgruppen</th><th>Benutzergruppen</th><th></th></tr></thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel hidden" id="tenant-edit-panel">
|
||||||
|
<h2>Mandant bearbeiten: <span id="tenant-edit-name"></span></h2>
|
||||||
|
<form id="tenant-edit-form" class="form-grid">
|
||||||
|
<div>
|
||||||
|
<label for="tne-name">Name</label>
|
||||||
|
<input type="text" id="tne-name" required maxlength="128">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="tne-description">Beschreibung</label>
|
||||||
|
<input type="text" id="tne-description" maxlength="1024">
|
||||||
|
</div>
|
||||||
|
<div class="checkbox-row"><input type="checkbox" id="tne-active"><label for="tne-active">Aktiv</label></div>
|
||||||
|
<button type="submit">Speichern</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel hidden" id="tenant-admins-panel">
|
||||||
|
<h2>Mandanten-Admins von <span id="tenant-admins-name"></span></h2>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="data-table" id="tenant-admins-table">
|
||||||
|
<thead><tr><th>User-ID</th><th>Benutzername</th><th>Seit</th><th></th></tr></thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<h3>Mandanten-Admin hinzufuegen</h3>
|
||||||
|
<form id="tenant-admin-add-form" class="form-grid">
|
||||||
|
<div>
|
||||||
|
<label for="ta-user-select">Benutzer</label>
|
||||||
|
<select id="ta-user-select" required></select>
|
||||||
|
</div>
|
||||||
|
<button type="submit">Hinzufuegen</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ================= Login-Verlauf ================= -->
|
||||||
|
<div class="tab-panel hidden" id="tab-authlog">
|
||||||
|
<div class="panel">
|
||||||
|
<h2>Login-Verlauf</h2>
|
||||||
|
<p class="hint">Authentifizierungs-Ereignisse (Login/Logout/TOTP/Passwortwechsel), gefiltert aus dem Audit-Log dieses/dieser Mandanten.</p>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="data-table" id="authlog-table">
|
||||||
|
<thead><tr><th>Zeit</th><th>Benutzer</th><th>IP</th><th>Ereignis</th><th>Status</th></tr></thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ================= Audit-Log ================= -->
|
<!-- ================= Audit-Log ================= -->
|
||||||
<div class="tab-panel hidden" id="tab-audit">
|
<div class="tab-panel hidden" id="tab-audit">
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
|
|||||||
@ -11,14 +11,41 @@
|
|||||||
<div class="session-shell" id="session-shell" data-host-id="{{ host_id }}">
|
<div class="session-shell" id="session-shell" data-host-id="{{ host_id }}">
|
||||||
<div class="session-toolbar">
|
<div class="session-toolbar">
|
||||||
<button id="fullscreen-btn">Vollbild</button>
|
<button id="fullscreen-btn">Vollbild</button>
|
||||||
<button id="upload-btn">Datei hochladen</button>
|
<button id="filetransfer-btn">Dateitransfer</button>
|
||||||
<input type="file" id="file-input" class="hidden">
|
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
<span class="status" id="status">Verbinde ...</span>
|
<span class="status" id="status">Verbinde ...</span>
|
||||||
</div>
|
</div>
|
||||||
<div id="terminal"></div>
|
<div id="terminal"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="ft-overlay hidden" id="ft-overlay">
|
||||||
|
<div class="ft-panel">
|
||||||
|
<h2>Dateitransfer
|
||||||
|
<button type="button" class="btn-secondary btn-small" id="ft-close-btn">Schliessen</button>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<h3>Hochladen</h3>
|
||||||
|
<label for="ft-upload-remote-path">Zielpfad auf dem Server</label>
|
||||||
|
<input type="text" id="ft-upload-remote-path" placeholder="/tmp/datei.txt">
|
||||||
|
<input type="file" id="ft-upload-file-input" class="hidden">
|
||||||
|
<div class="ft-btn-row">
|
||||||
|
<button type="button" class="btn-secondary btn-small" id="ft-upload-pick-btn">Datei auswaehlen</button>
|
||||||
|
<span class="hint" id="ft-upload-filename">Keine Datei ausgewaehlt</span>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn-small" id="ft-upload-submit-btn">Hochladen</button>
|
||||||
|
|
||||||
|
<h3>Herunterladen</h3>
|
||||||
|
<label for="ft-download-remote-path">Pfad auf dem Server</label>
|
||||||
|
<input type="text" id="ft-download-remote-path" placeholder="/etc/hosts">
|
||||||
|
<button type="button" class="btn-small" id="ft-download-submit-btn">Herunterladen</button>
|
||||||
|
|
||||||
|
<div class="error" id="ft-status"></div>
|
||||||
|
|
||||||
|
<h3>Letzte Transfers</h3>
|
||||||
|
<div class="ft-list" id="ft-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="/static/js/vendor/xterm.js"></script>
|
<script src="/static/js/vendor/xterm.js"></script>
|
||||||
<script src="/static/js/vendor/xterm-addon-fit.js"></script>
|
<script src="/static/js/vendor/xterm-addon-fit.js"></script>
|
||||||
<script src="/static/js/terminal.js"></script>
|
<script src="/static/js/terminal.js"></script>
|
||||||
|
|||||||
604
tests/test_tenants.py
Normal file
604
tests/test_tenants.py
Normal 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
|
||||||
Reference in New Issue
Block a user