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