umbau 1.0

This commit is contained in:
2026-09-02 20:30:44 +02:00
parent afe6719f51
commit 5c95b21be7
77 changed files with 10733 additions and 1914 deletions

169
FORTSETZUNG_Aufgabe11.md Normal file
View File

@ -0,0 +1,169 @@
# Fortsetzungsdokument: Aufgabe #11 (SSH Copy-Paste + Terminal-Farben)
Stand: 2026-09-02, Code-Stand abgeschlossen und regressionsfrei verifiziert;
Live-Browser-Test weiterhin ausstehend (siehe Abschnitt 3 + Nachtrag in
Abschnitt 4 -- dieselbe Einschraenkung wie bei Teil F).
Repo: `ssh_jumphost`, erreichbar ueber die `device_bash`-Bridge unter
`$HOME/mnt/ssh_jumphost`.
**Einordnung**: Aufgabe #11 ist vom Nutzer nachtraeglich hinzugefuegt worden,
nicht Teil von `Umsetzungsauftrag_Sonnet5.md` und hatte bis zu dieser Session
keine Detail-Spec -- nur das Stichwort "SSH Copy-Paste + Terminal-Farben"
(siehe `FORTSETZUNG_Teil_C.md`/`_D.md`/`_F.md`). Das Scope wurde zu Beginn
dieser Session per Rueckfrage geklaert (zwei AskUserQuestion-Antworten):
1. **Copy-Paste**: "kopieren mit markieren des textes und einfuegen mit
rechtsklick, wenn es umsetzbar ist sonst mit Rechtsklickkontextmenu."
2. **Terminal-Farben**: "Vom Benutzer waehlbar."
Beides betrifft ausschliesslich SSH (`static/js/terminal.js`,
`SshTerminalSession`) -- RDP hat ueber Guacamole bereits eine eigene,
funktionierende Zwischenablage-Anbindung (`onclipboard` in `rdp.js`) und war
nie Teil der Aufgabe (Titel: "**SSH** Copy-Paste").
---
## 1) SSH Copy-Paste
* **Kopieren**: `copyOnSelect: true` bei der `Terminal`-Konstruktion
(`_setupTerminal()`). xterm.js kopiert eine Markierung damit sofort in die
System-Zwischenablage -- kein Strg+C/Cmd+C mehr noetig. Strg+C OHNE
Markierung bleibt unveraendert SIGINT (xterm.js' Standardverhalten, von
`copyOnSelect` nicht beruehrt -- kein neues Verhalten, keine Ueberraschung
fuer bestehende Nutzung).
* **Einfuegen per Rechtsklick**, zweistufig, genau wie gewuenscht:
1. `contextmenu`-Listener auf `this.terminalContainer`
(`_wireClipboard()`) ruft `event.preventDefault()` auf (unterdrueckt
das native Browser-Kontextmenue) und versucht direkt
`navigator.clipboard.readText()` (`_attemptDirectPaste()`). In den
meisten Chromium-Browsern reicht ein Rechtsklick als "transiente
Aktivierung" fuer diese Berechtigung -- Einfuegen passiert dann sofort,
ohne sichtbares Menue.
2. Schlaegt das fehl (kein `navigator.clipboard`, Berechtigung verweigert
-- typischerweise Firefox/Safari ausserhalb einer frischen
Klick-Geste, oder unsicherer Kontext), erscheint ersatzweise ein
winziges Kontextmenue mit einem "Einfuegen"-Eintrag (`_showPasteMenu()`).
Dessen Klick ist selbst wieder eine frische Benutzergeste und hat
dadurch nochmal bessere Chancen auf die Berechtigung. Schlaegt auch das
fehl, zeigt der Status-Bereich der Sitzung einen erklaerenden Hinweis
("... bitte Strg+Umschalt+V verwenden") statt eines stillen No-Ops.
3. Das eigentliche Einfuegen laeuft ueber `this.term.paste(text)` (xterm.js
v5 API, bestaetigt durch `.paste=`-Vorkommen in `vendor/xterm.js`) --
das ist KEIN separater Codepfad zum normalen Tippen: `paste()` loest
intern denselben `onData`-Callback aus wie eine Tastatureingabe, geht
also automatisch durch die bestehende WS-"input"-Sendefunktion.
* **CSP-Falle vermieden**: der erste Entwurf positionierte das
Ruecksicherungs-Kontextmenue ueber `menu.style.left/top` an der
Klickposition -- das haette der Server-CSP (`style-src 'self'`, kein
`unsafe-inline`) im echten Browser stillschweigend blockiert (dieselbe
Regel, die JS-gesetzte `element.style`-Aenderungen genauso trifft wie
`style="..."`-Attribute, siehe app.css-Kopfkommentar zu `.hidden`).
Ersetzt durch vier feste CSS-Klassen (`.term-paste-menu-{top,bottom}` x
`{left,right}`, app.css), die je nach Klick-Viertel von `.session-shell`
gesetzt werden -- keine Pixelgenauigkeit, aber vollstaendig CSP-konform
und konsistent mit der einzigen im ganzen Projekt verwendeten
Sichtbarkeits-/Positionierungstechnik (Klassen, nie `.style`).
* Aufraeumen: `_closePasteMenu()` entfernt das Menue plus seine
Dokument-Listener (Klick-daneben/Escape/Scroll) -- aufgerufen aus
`dispose()` UND aus `setActive(false)` (ein Umschalten auf eine andere
Sitzung soll kein Geister-Kontextmenue im Hintergrund hinterlassen).
## 2) Terminal-Farben (vom Benutzer waehlbar)
* Vier feste Farbschemata (`TERMINAL_THEMES`, Modul-Konstante):
Standard (dunkel, bisheriges Verhalten unveraendert als Vorgabe),
Dunkel/kontrastreich, Solarized Dark, Solarized Light. Bewusst eine kleine
feste Auswahl statt eines vollen Theme-Editors -- deckt "vom Benutzer
waehlbar" ab, ohne eine neue Einstellungsseite einzufuehren.
* `<select class="theme-select">` in der Sitzungs-Toolbar (zwischen
"Dateitransfer" und dem Status-Text). Aenderung setzt
`this.term.options.theme = ...` (xterm.js v5: `options` ist ein
Getter/Setter-Objekt, bestaetigt durch `OptionsService`/`onOptionChange`
in `vendor/xterm.js` -- kein `setOption()` mehr, das gab es nur bis v4).
* **Persistenz**: `localStorage` (Schluessel `jumphost_terminal_theme"`),
rein clientseitig, kein Server-Roundtrip -- konsistent mit der ueblichen
Behandlung reiner Darstellungspraeferenzen. Bewusst KEIN neues DB-Feld/
keine neue Migration: die Wahl ist eine Eigenschaft des Browsers/Nutzers
vor Ort, keine sicherheits- oder audit-relevante Einstellung.
Try/catch um jeden Zugriff (privater Modus/blockierter Speicher faellt
dann einfach auf das Standardschema zurueck, kein Fehler).
* **Scope-Grenze, bewusst so belassen**: die Wahl gilt fuer NEUE
Sitzungen ab dem Zeitpunkt der Aenderung (liest `localStorage` beim
Sitzungsaufbau in `_setupTerminal()`) -- sie synchronisiert sich NICHT
live in andere, gerade schon offene Sitzungen (F4: mehrere gleichzeitige
Instanzen moeglich). Dieselbe Klasse von Eventual-Consistency wie an
mehreren anderen Stellen im Projekt bereits akzeptiert (z.B. keine
Live-Aktualisierung der Sitzungsliste zwischen Tabs, `FORTSETZUNG_Teil_F.md`).
Ein Wechsel-Knopf in jeder einzelnen Toolbar ist bewusst so belassen statt
einer neuen globalen Einstellungsseite -- kein Mehrwert gegenueber "einmal
in einer beliebigen Sitzung waehlen, gilt danach ueberall".
## 3) Verifikation
* `node --check static/js/terminal.js` -- fehlerfrei.
* CSS-Klammerbalance (`{`/`}`) in `app.css` geprueft -- ausgeglichen.
* Voller Regressionslauf ueber alle 23 Testdateien (unveraendert gegenueber
Teil-F-Stufe F5 -- diese Aufgabe aendert ausschliesslich
`static/js/terminal.js` und `static/css/app.css`, keine Python-Datei,
keine Migration, kein Template):
| Gruppe | Ergebnis |
|---|---|
| csp_compliance/teil_f_schritt2/teil_f_schritt3/teil_f_schritt5 | 22 passed, 0 failed |
| admin_crud/admin_groups_tokens/audit_chain/auth_flow/phase9 | 36 passed, 1 failed |
| passwords_totp/pentest_security/phase10/phase11 | 37 passed, 2 failed |
| phase12/phase13 | 22 passed, 6 failed |
| phase14/phase15/rbac/teil_d_schritt5/teil_d_schritt6/session_recorder/session_reaper | 29 passed, 9 failed |
| **Summe** | **146 passed, 18 failed, 164 total** |
Identisch zu Teil F Stufe F5 -- dieselben 18 laengst bekannten,
dokumentierten Fehlschlaege (`FORTSETZUNG_Teil_C.md`), **null neue
Regressionen**.
* **Kein Live-Browser-Test moeglich** (dieselbe Sandbox-Einschraenkung wie
bei Teil F, siehe `FORTSETZUNG_Teil_F.md` Abschnitt 6): weder das direkte
Rechtsklick-Einfuegen (haengt vom tatsaechlichen Berechtigungsverhalten
des jeweiligen Browsers ab, nicht vollstaendig durch Code-Lesen
verifizierbar) noch das Ruecksicherungs-Kontextmenue noch die visuelle
Wirkung der vier Farbschemata wurden tatsaechlich im Browser
durchgeklickt. **Empfehlung**: bei Gelegenheit einmal in einem echten
Chromium- UND einem echten Firefox-Fenster gegenstesten (Chromium deckt
den Direktpfad ab, Firefox eher den Menue-Fallback) -- am besten zusammen
mit dem noch ausstehenden F.6-Live-Test aus Teil F, da beide dieselbe
echte SSH-Zielumgebung brauchen.
## 4) Nachtrag: Rueckfrage zum urspruenglichen Symptom
Bei der Klaerung des Scopes (Abschnitt oben) kam in einer spaeteren
Rueckfrage eine praezisere Beschreibung des urspruenglichen Problems: "Ich
konnte in der SSH-Sitzung nichts mit der linken Maustaste markieren" -- und
zwar an einem GANZ NORMALEN Shell-Prompt, nicht in einem Vollbild-Programm
mit eigener Maussteuerung (tmux/vim/htop/...). Das schliesst die
naheliegendste Erklaerung ("Mausklicks gehen an ein Programm wie tmux, nicht
an die lokale Markierung -- kein Bug, Standardverhalten jedes Terminals")
fuer DIESEN konkreten Fall aus.
**Wahrscheinlichste Erklaerung** (kein Live-Test moeglich, daher nicht
absicherbar): xterm.js unterstuetzt Markieren-per-Ziehen schon immer nativ,
unabhaengig von `copyOnSelect` -- das war also vermutlich nie kaputt. Ohne
`copyOnSelect` (Zustand VOR dieser Session) landet eine Markierung aber
NICHT automatisch in der Zwischenablage; der naheliegende Reflex Strg+C
loest bei xterm.js ohne Markierung SIGINT aus (unveraendertes
Standardverhalten) -- aus Nutzersicht sieht das exakt so aus wie "Markieren
geht nicht", obwohl technisch nur der Kopiervorgang danach fehlte. Genau das
behebt `copyOnSelect: true` (Abschnitt 1 oben): Ziehen MARKIERT UND kopiert
in einem Schritt, ganz ohne Strg+C.
**Zusaetzlich ergaenzt, unabhaengig vom obigen Verdacht**: ein
`title`-Tooltip auf `.session-terminal` erklaert das (bereits vorher
bestehende, unveraenderte) Shift+Ziehen-Verhalten fuer den Fall, dass doch
einmal ein Vollbild-Programm mit eigener Maussteuerung im Spiel ist -- rein
zusaetzliche Dokumentation direkt in der Oberflaeche, kein neuer Codepfad,
kein Risiko fuer bestehendes Verhalten.
**Weiterhin nicht abschliessend geklaert ohne Live-Test**: ob
`copyOnSelect: true` das urspruenglich beschriebene Symptom tatsaechlich
vollstaendig behebt, kann nur der Nutzer selbst durch Ausprobieren
bestaetigen (Text im Terminal markieren, dann an anderer Stelle einfuegen --
sollte jetzt ohne Strg+C funktionieren). Sollte es weiterhin nicht gehen,
ist das ein Hinweis auf eine andere, noch nicht gefundene Ursache und
braucht eine zweite Runde.

312
FORTSETZUNG_Teil_C.md Normal file
View File

@ -0,0 +1,312 @@
# Fortsetzungsdokument: Umsetzungsauftrag_Sonnet5.md — Teil C (Mandanten-Rueckbau)
Stand: 2026-09-02. Teil C ist **vollstaendig abgeschlossen** (inkl. echtem
`pytest`-Lauf, siehe Abschnitt 3). **Update 2026-09-02:** alle 7 in
Abschnitt 3 dokumentierten vorbestehenden Bugs sind jetzt behoben, voller
Regressionslauf 164/164 gruen -- der zuvor zurueckgestellte Livetest kann
jetzt als echter Abnahmetest stattfinden (siehe Abschnitt 5).
Repo: `ssh_jumphost` (FastAPI/SQLite SSH/RDP-Jumphost), erreichbar ueber die
device_bash-Bridge unter `$HOME/mnt/ssh_jumphost`.
**Reihenfolge des Gesamtauftrags** (verbindlich, siehe
`Umsetzungsauftrag_Sonnet5.md`): Teil A/B -> Teil E -> Teil C (abgeschlossen,
dieses Dokument) -> **Teil D (naechster Schritt)** -> Teil F, plus eine vom
Nutzer nachtraeglich hinzugefuegte Aufgabe #11 (SSH Copy-Paste +
Terminal-Farben).
**Erledigt: Teil A, Teil B, Teil E, Teil C** -- Teil A/B/E vollstaendig
abgeschlossen in frueheren Sessions, hier nicht mehr relevant. Teil C siehe
unten.
---
## 0) Wichtige Umgebungs-Erkenntnis fuer die naechste Session
Alle Sessions vor dieser hier haben ausschliesslich in einer Cloud-Sandbox
ohne Netzwerk/pip gearbeitet und konnten deshalb **nie** echten `pytest`
laufen lassen (nur `py_compile` + selbstgebaute Undefined-Name-/Tag-Balance-
Checker). Diese Session laeuft stattdessen ueber die `device_bash`-Bridge
**direkt auf dem Nutzer-Rechner** -- dort **funktioniert Netzwerk/pip**.
Das aendert die Verifikationsmoeglichkeiten grundlegend:
- Eine funktionierende venv liegt bereits unter `.venv/` im Projektordner
(Python 3.10, pip 22.0.2). Alle `requirements.txt`+`requirements-dev.txt`-
Pakete sind installiert und mit `.venv/bin/python3 -m pytest` lauffaehig.
**`.venv/bin/pip` laesst sich NICHT upgraden/loeschen** (device_bash darf
nicht loeschen, siehe Abschnitt 4 unten) -- das ist unschaedlich, die
vorhandene pip-Version reicht zum Installieren.
- Vor jedem `pytest`-Lauf muessen diese Env-Vars gesetzt sein (sonst schlaegt
JEDER Test mit `PermissionError: /var/lib/jumphost` fehl, weil
`app/config.py::Settings.data_dir` beim ERSTEN Modul-Import aus der Umgebung
gelesen wird und ohne `JUMPHOST_DATA_DIR` auf den Produktionspfad
`/var/lib/jumphost` faellt):
```bash
export JUMPHOST_DATA_DIR=/tmp/jumphost-test
export JUMPHOST_ENV=development
export JUMPHOST_DEV_KEK=$(python3 -c "import secrets;print(secrets.token_hex(32))")
export JUMPHOST_DEV_SESSION_SECRET=$(python3 -c "import secrets;print(secrets.token_hex(32))")
.venv/bin/python3 -m pytest -q
```
- `device_bash`-Aufrufe laufen **jeweils in einer frischen, isolierten
Sandbox** (bwrap, eigener Network-Namespace) -- Hintergrundprozesse per
`nohup ... & disown` ueberleben das Ende des Aufrufs NICHT (anders als ein
normaler Shell-Hintergrundprozess auf einer echten Maschine). Lang laufende
Kommandos (z.B. `pip install`) muessen daher als EIN Aufruf mit `timeout`
unterhalb des 180s-Caps laufen; bei Abbruch einfach denselben Befehl erneut
ausfuehren (pip ist idempotent, laedt bereits installierte Pakete nicht neu
herunter).
- `rm -rf` auf Dateien, die device_bash selbst angelegt hat (z.B. eine kaputte
`.venv`), schlaegt mit `Operation not permitted` fehl (siehe Abschnitt 4) --
nicht wundern, das ist die bekannte Loesch-Restriktion, keine defekte venv.
---
## 1) Betreiberentscheidungen (Phase 0 von Teil C) -- weiterhin verbindlich
Diese drei Entscheidungen wurden vom Nutzer per Rueckfrage getroffen und
**duerfen von keiner Folge-Session stillschweigend geaendert werden**:
1. **`tenant_admins`-Behandlung** -> *"Zu is_admin=1 befoerdern"* (jeder
bestehende `tenant_admins`-Eintrag wird zu vollem `is_admin=1` befoerdert,
NICHT demotet). Umgesetzt durch `scripts/promote_tenant_admins.py`
(Phase 1, muss vor erstem Start mit neuem Code auf der echten
Produktions-DB laufen, siehe Abschnitt 4 im alten Dokumentstand /
weiterhin offen).
2. **`users.home_tenant_id`** -> *"Vollstaendiger Rebuild (Recommended)"* (die
Spalte wurde beim Tabellen-Rebuild vollstaendig entfernt). Umgesetzt in
`0014_drop_tenants.sql`.
3. **Bestehende API-Tokens** -> *"Bestehende Tokens weiterlaufen lassen"* --
**weicht bewusst von der Empfehlung des Umsetzungsauftrags selbst ab**.
Es fand KEIN Massenwiderruf statt. **Diese Abweichung ist Absicht und darf
nicht "korrigiert" werden.**
---
## 2) Teil C: vollstaendiger Abschluss-Stand
### Phase 1 (Operator-Skript) -- weiterhin offen fuer den Betreiber
`scripts/promote_tenant_admins.py` ist gebaut und verifiziert (`py_compile`),
aber **noch nie gegen eine echte Produktions-DB gelaufen** -- es gibt in
keiner bisherigen Session-Umgebung eine solche DB. **Muss vom Betreiber vor
dem ersten Produktions-Deployment dieses Codestands einmalig ausgefuehrt
werden, idealerweise zuerst mit `--dry-run`.**
### Phase 2 (Code-Aenderungen) -- vollstaendig, in frueherer Session erledigt
Siehe Git-Historie / vorherige Version dieses Dokuments: `app/tenancy.py`
entfernt (nach `_to_delete/` verschoben), `app/auth/deps.py`,
`app/auth/routes.py`, `app/models/schemas.py`, `app/admin/routes.py`,
`app/admin/log_ws.py`, `templates/admin.html`, `static/js/admin.js`,
`app/db/migrations/0014_drop_tenants.sql`, `app/db.py::_apply_migrations()`
(FK-Check nach jeder Migration) -- alles bereits fertiggestellt und jetzt
zusaetzlich durch den echten `pytest`-Lauf bestaetigt (siehe Abschnitt 3).
### Phase 3 (Tests + README) -- in DIESER Session abgeschlossen
- `tests/test_phase9.py`: `test_sessions_list_requires_global_admin_not_tenant_admin`
zu `test_sessions_list_requires_admin` vereinfacht (normaler User statt
`tenant_admins`-Konstrukt), `"tenant_id": 1` aus dem rdp-credentials-Payload
entfernt.
- `tests/test_phase12.py`: alle 7 `tenant_id`-Spalten/-Werte aus den
`host_groups`/`ssh_keys`-INSERTs entfernt (auch in den `_pre_0010_db`-
Migrationstests -- die Spalte ist dort nullable, das Entfernen aendert
nichts am getesteten Verhalten von Migration 0010).
- `tests/test_phase13.py`: `"tenant_id": 1` aus rdp-credentials-Payload
entfernt, `tenant_id`/`tenant_name` aus der erwarteten SSH-Key-Spaltenliste
entfernt, `test_session_recording_entries_requires_global_admin` von einem
Mandanten-Admin-Aufbau (`POST /admin/tenants` -- existiert nicht mehr) auf
einen normalen Nicht-Admin-User umgebaut.
- `README.md`: alle im vorherigen Dokumentstand gelisteten Fundstellen
bereinigt -- Phase-8-Abschnitt zu einer kurzen historischen Rueckschau
umformuliert (inkl. Neu-Nummerierung der verbleibenden 6 Unterpunkte),
Migrations-Cross-Referenzen von `0006_tenants.sql` auf
`0014_drop_tenants.sql` umgestellt, Testzahlen/-beschreibungen an
`tests/test_admin_crud.py` (Nachfolger von `tests/test_tenants.py`)
angepasst, Hinweis auf dauerhaft erhaltene `tenant_*`-Audit-Log-Eintraege
ergaenzt, "Mandantenfaehigkeit" aus der offenen-Punkte-Aufzaehlung
gestrichen.
- `pytest.ini`: `norecursedirs = _to_delete .venv .git` ergaenzt (sonst
sammelt pytest die nach `_to_delete/` verschobene `test_tenants.py` weiter
ein und importiert das ebenfalls verschobene `app/tenancy.py` -> Import-
Error bei jedem Lauf).
Alle Aenderungen mit `py_compile` verifiziert; die Test-Datei-Aenderungen
zusaetzlich durch den echten Suite-Lauf (Abschnitt 3).
---
## 3) Echter `pytest`-Lauf: Ergebnis und Einordnung (AKTUALISIERT -- alle 7 Bugs behoben)
**Update (Folgesession, auf ausdruecklichen Nutzerwunsch "livetest spaeter
machen zuerst den rest machen das das dann wirkliche abnahmetests sind"):**
Alle 7 nachfolgend dokumentierten Root-Cause-Bugs hinter den urspruenglich
18 Fehlschlaegen sind jetzt behoben und einzeln per echtem `pytest`
verifiziert. Der abschliessende volle Regressionslauf
(`.venv/bin/python3 -m pytest -q`, frisches `JUMPHOST_DATA_DIR` +
Dev-Secrets wie in Abschnitt 0) liefert: **164 passed, 0 failed** (6
harmlose, vorbestehende `DeprecationWarning`s in `app/admin/routes.py:562`
-- ausserhalb des Scopes dieser 7 Bugs, siehe Fussnote unten). Der urspruengliche
Lauf hatte 132 Tests gezaehlt (124 passed/18 failed); die Gesamtzahl ist auf
164 gestiegen, weil zwischenzeitlich in Teil F/Aufgabe #11 neue Testdateien
hinzugekommen sind (`test_teil_f_schritt5.py` u.a.), die alle bereits gruen
waren.
Damit ist die Grundlage fuer eine **echte Abnahmetest-Session** (Live-Browser/
SSH/RDP-Test) gelegt: der zuvor bekannte Bug-Rucksack existiert nicht mehr,
der Live-Test in einer kuenftigen Session prueft ein tatsaechlich
vollstaendig korrigiertes System und nicht eines mit bekannten offenen
Defekten.
Die folgenden 7 Eintraege sind nur noch als **Nachweis-Dokumentation**
(Root-Cause + Fix) erhalten, nicht mehr als offene Punkte:
1. **`tests/test_phase15.py`** (7 Tests, urspruenglich) -- Signatur-Drift
zwischen Testcode und Implementierung: `Request()` ohne `scope`,
`active_sessions.register()` ohne `user_id`, `fake_connect_to_host()`
ohne `user_id`-Keyword, `UploadFile("name", bytes)` mit veralteter
Zwei-Positional-Argumente-Signatur (echte Starlette-Signatur inzwischen
`UploadFile(file, *, filename=...)`), `start_sftp_client()`-Fakes ohne
passenden `async`/`await`-Vertrag und fehlendem `exit()`, sowie
`asyncssh.Error(reason=...)` ohne das inzwischen pflicht-positionale
`code`-Argument. **Fix:** ausschliesslich in `tests/test_phase15.py` --
alle Fake-Objekte/Aufrufe an die echten, aktuellen Signaturen angepasst
(kein Produktivcode veraendert, reiner Test-Drift). Verifiziert: 11/11
gruen.
2. **`tests/test_phase12.py`** (4 Tests) + **`tests/test_phase10.py`**
(1 Test) -- `build_rdp_params()` (`app/rdp_proxy/guacd_client.py:413`)
verlangt inzwischen `session_id` als Pflicht-Keyword-Argument, die Tests
riefen ohne dieses Argument auf. **Fix:** in beiden Testdateien an allen
betroffenen Aufrufstellen `session_id=1` (Testwert, fachlich irrelevant
fuer die geprueften Assertions) ergaenzt. Verifiziert: 26/26 gruen.
3. **`tests/test_session_reaper.py`** (2 Tests) -- echter, reproduzierbarer
Bug: `app/security/session_reaper.py::reap_orphaned_sessions()` fuehrte
ein `UPDATE sessions ...` aus (oeffnet bei Test-Verbindungen ohne
`isolation_level=None` implizit eine Transaktion), rief danach
`write_audit_event()` auf, das seinerseits `BEGIN IMMEDIATE` versucht --
doppeltes BEGIN, `sqlite3.OperationalError`. **Fix:** in
`app/security/session_reaper.py` ein explizites `await conn.commit()`
direkt nach dem `UPDATE` ergaenzt (schliesst die implizite Transaktion,
bevor `write_audit_event()` seine eigene oeffnet -- funktioniert sowohl
mit Produktions-Autocommit-Verbindungen als auch mit dem
Test-`:memory:`-Verbindungsstil). Verifiziert: 3/3 gruen.
4. **`tests/test_phase13.py::test_session_recording_entries_ssh_playback_and_audit`**
-- `SessionRecorder` (`app/recordings/recorder.py`) hat nur noch
`async def aclose()`, der Test rief synchron `.close()` (existiert
nicht). **Fix:** Testaufruf auf `await recorder.aclose()` umgestellt
(reiner Test-Drift, kein Produktivcode veraendert). Verifiziert: gruen.
5. **`tests/test_phase13.py::test_ssh_password_credentials_set_and_delete`**
-- Test-Bug, kein echtes Leck: `assert "password" not in <json>` schlug
fehl, weil bereits die Response-FELDNAMEN
(`ssh_password_credentials_set`/`_username`) die Zeichenkette "password"
enthalten -- Substring-Treffer auf den Feldnamen, nicht auf einen
Passwort-Klartextwert. **Fix:** Assertion auf den tatsaechlich
sicherheitsrelevanten Fall praezisiert: geprueft wird jetzt, dass der
konkrete im Test gesetzte Passwort-WERT
(`"sehr-geheimes-passwort"`) nicht im JSON auftaucht. Verifiziert:
11/11 gruen (ganze Datei).
6. **`tests/test_pentest_security.py::test_password_change_invalidates_old_session_everywhere`**
-- **sicherheitsrelevant, per empirischer Debug-Sitzung (temporaeres,
danach geloeschtes Debug-Testskript) untersucht.** Ergebnis: die
Sicherheitslogik selbst (`session_version`-Vergleich in
`app/auth/deps.py::get_current_user`) war die ganze Zeit KORREKT -- der
alte Cookie wurde bei isolierter Pruefung zuverlaessig mit 401
abgelehnt. Der Fehlschlag war ein reines Test-Harness-Artefakt: die
`get_current_user()`-Dependency schreibt bei jeder Anfrage bereits ein
eigenes Refresh-Set-Cookie (gleitender Idle-Timeout), und
`change_password()`/`logout()`/`logout_everywhere()` haengten danach
noch ein ZWEITES, eigenes Set-Cookie an dieselbe Antwort -- zwei
`Set-Cookie: jh_session=...`-Header in einer Antwort. Der
*geteilte* httpx-Test-Client verwechselte beim naeherungsweisen
Ueberschreiben seines Cookie-Jars (`client.cookies.set(...)` auf einem
Jar, das durch die doppelten Set-Cookie-Header bereits einen
widerspruechlichen Zustand hatte) den alten mit dem neuen Cookie-Wert --
ein von httpx selbst dokumentiertes Ambiguitaetsproblem beim
Ueberschreiben/per-Request-Setzen von Cookies in einem bereits
befuellten Jar. **Fix (zwei Teile):** (a) Anwendungsseitig in
`app/auth/routes.py` -- in `change_password()`, `logout()` und
`logout_everywhere()` wird das von der Dependency bereits gesetzte
Set-Cookie explizit entfernt (`del response.headers["set-cookie"]`),
bevor die jeweilige Routenfunktion ihr eigenes, autoritatives
Set-Cookie schreibt -- Verteidigung in der Tiefe, sauberere
HTTP-Antworten unabhaengig vom Test. (b) Testseitig in
`tests/test_pentest_security.py` -- der alte Cookie wird jetzt gegen
einen komplett FRISCHEN `httpx.AsyncClient` (leeres Cookie-Jar) geprueft
statt gegen den geteilten, bereits "verschmutzten" Test-Client.
Verifiziert: 17/17 gruen, keine httpx-Warnungen mehr.
7. **`tests/test_admin_crud.py::test_user_update_deactivate_and_delete_semantics`**
-- echter Bug: `app/admin/routes.py::delete_user()` pruefte
`SELECT 1 FROM audit_log WHERE user_id = ? LIMIT 1` mit der ID des
ZIEL-Nutzers, aber `user_updated`/`user_deactivated`-Events werden mit
`user_id=<handelnder Admin>` geschrieben -- der betroffene Nutzer steht
stattdessen unter `details_json.target_user_id` (dieselbe Konvention wie
bei `user_added_to_group`/`removed_from_group`, `api_token_issued`,
`session_terminated_by_admin`, per Grep bestaetigt). Der
Audit-Historie-Check fand deshalb nie etwas fuer den Zielnutzer und
loeschte hart, obwohl ein anonymisierendes Soft-Delete erwartet war --
**eine echte, sicherheitsrelevante Datenverlust-Regression** (Accounts
mit Aenderungshistorie waeren faelschlich hart geloescht statt
anonymisiert worden). **Fix:** die `SELECT`-Bedingung in
`app/admin/routes.py::delete_user()` um
`OR json_extract(details_json, '$.target_user_id') = ?` erweitert
(SQLite-JSON1-Verfuegbarkeit vorab eigenstaendig verifiziert). Verifiziert:
9/9 gruen (ganze Datei).
**Fussnote (nicht Teil der 7 Bugs, nicht behoben):** der volle
Regressionslauf zeigt 6 `DeprecationWarning`s in `app/admin/routes.py:562`
(`payload.ssh_username, payload.rdp_username, payload.rdp_domain` --
vermutlich ein veraltetes Pydantic-Zugriffsmuster). Harmlos (keine
Testfehlschlaege), ausserhalb des vom Nutzer beauftragten Scopes dieser
Session, aber als Hinweis fuer eine kuenftige Aufraeum-Session vermerkt.
## 4) Wichtiger Hinweis: zwei Dateien liegen weiterhin in `_to_delete/`
Unveraendert gegenueber dem vorherigen Dokumentstand: `device_bash` darf
standardmaessig keine Dateien loeschen. In `_to_delete/` liegen
`tenancy.py` (ehemals `app/tenancy.py`) und `test_tenants.py` (ehemals
`tests/test_tenants.py`), technisch noch vorhanden, aber aus dem aktiven
Code-/Test-Pfad entfernt (pytest sammelt den Ordner seit dem
`pytest.ini`-Update in dieser Session auch nicht mehr ein). Dazu aeltere,
nicht zusammenhaengende Reste (`appsrc.tgz`, `jh.tgz`,
`tmp_stage_1788191215`). **Der Nutzer kann diesen Ordner jederzeit selbst
loeschen**, oder eine kuenftige Session kann `device_request_delete_permission`
versuchen.
---
## 5) Empfohlene naechste Schritte
1. **Livetest/Abnahmetest** (Browser/SSH/RDP, echtes Deployment) fuer
Teil F.6 und Aufgabe #11 -- war vom Nutzer ausdruecklich zurueckgestellt
("livetest spaeter machen zuerst den rest machen das das dann wirkliche
abnahmetests sind"), bis die in Abschnitt 3 dokumentierten 7 Bugs
behoben sind. **Das ist jetzt der Fall** (alle 7 behoben, voller
Regressionslauf 164/164 gruen) -- der Livetest kann in der naechsten
Session als echter, aussagekraeftiger Abnahmetest durchgefuehrt werden.
2. **Teil D -- Berechtigungen ausschliesslich ueber Benutzergruppen**
(naechster Abschnitt in `Umsetzungsauftrag_Sonnet5.md`, direkt nach
Teil C) -- noch nicht gelesen/begonnen.
3. Weiterhin offen, unabhaengig von der Code-Reihenfolge: Phase-1-Skript
(`scripts/promote_tenant_admins.py`) auf der echten Produktions-DB
ausfuehren, sobald ein Deployment-Fenster dafuer ansteht.
4. Kleinere, nicht dringende Aufraeumarbeit (siehe Fussnote Abschnitt 3):
die 6 `DeprecationWarning`s in `app/admin/routes.py:562` bei
Gelegenheit beheben.
---
## 6) Technische Hinweise fuer die naechste Session
- Repo ueber `mcp__remote-devices__device_bash` erreichbar, gemountet unter
`$HOME/mnt/ssh_jumphost`. Diese Bridge laeuft direkt auf dem Nutzer-Rechner
(nicht die evtl. veraltete Cloud-Container-Kopie unter
`/mnt/user-data/uploads/ssh_jumphost/`, die nur zum schnellen Lesen taugt).
- Siehe Abschnitt 0 fuer venv/Env-Var-Setup und die Eigenheiten der
device_bash-Sandbox (frische Namespaces pro Aufruf, 180s-Cap,
Loesch-Restriktion).
- Alle Datei-Edits weiterhin ueber `device_bash`-Python-Skripte mit
`content.count(old) == 1`-Assert-dann-Replace-Muster durchfuehren (nie
blindes `sed`), danach IMMER per `grep` gegenpruefen, dass alles
persistiert wurde.
- Fuer SQL-Migrationsaenderungen weiterhin einen unabhaengigen
`sqlite3`-Test-Harness bauen (wie in einer frueheren Session unter
`/tmp/migtest/run_test.py` im Cloud-Container) -- jetzt aber alternativ
auch direkt mit echtem `pytest` gegen die volle FastAPI-App verifizierbar,
siehe Abschnitt 0/3.

702
FORTSETZUNG_Teil_D.md Normal file
View File

@ -0,0 +1,702 @@
# Fortsetzungsdokument: Umsetzungsauftrag_Sonnet5.md — Teil D (Berechtigungen ausschliesslich ueber Benutzergruppen)
Stand: 2026-09-01, 05:20 UTC. **Teil D (D.6 Schritte 1-7) ist
vollstaendig abgeschlossen.** Achse B ist scharf geschaltet (SSH/RDP-
Verbindungen loesen Zugangsdaten benutzer-/gruppenabhaengig auf), die
Direktvergabe-Schreibpfade sind abgeschaltet (HTTP 410), zwei
Konsolidierungs-Reports stehen bereit (Schritt 6, bewusst ohne
automatische Zusammenlegung/Entzug, siehe Abschnitt 2), und alle 14 durch
Schritt 4/5 verursachten Testfehlschlaege sind behoben (Schritt 7, siehe
Abschnitt 1d). Es verbleiben nur die urspruenglichen, von Teil D
unabhaengigen 18 Bugs aus Teil C (siehe `FORTSETZUNG_Teil_C.md` Abschnitt
0) sowie ein paar bewusst zurueckgestellte Backlog-Punkte (Abschnitt 2,
Schritt 6). Repo: `ssh_jumphost`, erreichbar ueber die device_bash-Bridge
unter `$HOME/mnt/ssh_jumphost`.
Teil C ist vollstaendig abgeschlossen, siehe `FORTSETZUNG_Teil_C.md`.
**Reihenfolge des Gesamtauftrags:** Teil A/B -> Teil E -> Teil C (fertig) ->
**Teil D (hier, in Arbeit)** -> Teil F -> Aufgabe #11.
Die vollstaendige fachliche Spezifikation steht in
`Umsetzungsauftrag_Sonnet5.md`, Abschnitt "TEIL D" (Zeilen 449-653 zum
Zeitpunkt dieses Dokuments -- IMMER per `grep -n "^# TEIL"` neu verorten,
da sich Zeilennummern durch andere Aenderungen verschieben). Dieses
Fortsetzungsdokument dupliziert die Spezifikation NICHT, sondern haelt nur
Umsetzungsstand, Entscheidungen und naechste Schritte fest.
---
## 0) Betreiberentscheidung: die drei toten Rollen (D.3)
Per Rueckfrage am 2026-08-31 entschieden -- **alle drei entfernen, keine
ausimplementieren**:
1. **`clipboard`** -> entfernen. Clipboard bleibt ausschliesslich ueber
`hosts.clipboard_enabled` (host-global) steuerbar, keine Pro-Nutzer-
Ebene. `Jumphost_Konzept.md` 4.6 wurde entsprechend korrigiert (warb
vorher faelschlich mit rollenbasierter Steuerung).
2. **`session_recording_view`** -> entfernen. Sitzungs-Playback bleibt
dauerhaft ausschliesslich `require_global_admin` vorbehalten.
3. **`admin_hostgroup`** -> entfernen. Keine delegierte Hostgruppen-Admin-
Ebene; es bleibt bei genau zwei Stufen (globaler Admin vs. granulare
Rollen ueber Gruppen).
**Diese Entscheidung darf von keiner Folge-Session stillschweigend
geaendert werden.**
---
## 1) Was in dieser Session bereits fertiggestellt wurde (D.6 Schritt 1: Vorarbeit, rechteneutral)
- **Tote Rollen entfernt:** neue Migration
`app/db/migrations/0015_remove_dead_roles.sql` (loescht zuerst alle
Vergaben in `user_hostgroup_roles`/`group_hostgroup_roles` fuer die drei
Rollen, dann die `roles`-Zeilen selbst). Unabhaengig gegen einen
synthetischen Voll-Migrationslauf (0001-0015) getestet: keine FK-
Verletzungen, `roles` enthaelt danach nur noch `ssh_connect`,
`rdp_connect`, `file_transfer`, `credentials_view`, `credentials_manage`.
`app/models/schemas.py::ROLE_NAME` (Pydantic-Whitelist) entsprechend
gekuerzt und kommentiert.
- **`static/js/admin.js`:** `ROLE_NAMES`-Konstante war hartkodiert (Doppelung
zur DB) -- jetzt `let ROLE_NAMES = []`, wird beim ersten Aufruf von
`loadRolesTab()` per `GET /admin/roles/names` gefuellt (dieser Endpunkt
existierte bereits, war nur ungenutzt). Erfuellt nebenbei einen Punkt aus
D.5 ("ROLE_NAMES von der Hartkodierung auf GET /admin/roles/names
umstellen").
- **S6 (Katalog-Protokoll-Filter) gegengeprueft: war bereits behoben.**
`app/catalog/routes.py` filterte schon vor dieser Session korrekt nach
`h.protocol` (vermutlich in einer frueheren Phase-9-Session erledigt, der
Umsetzungsauftrag war nur nicht nachgezogen). Trotzdem im Zuge von S13
(siehe unten) umgebaut -- die Protokoll-Filterung ist jetzt in Python
nach der zentralen `rbac.py`-Abfrage statt in eingebettetem SQL.
- **S12 geschlossen:** `revoke_role` (`app/admin/routes.py`) prueft jetzt
zusaetzlich `_assert_user_in_scope(conn, payload.user_id)` (vorher nur
Hostgruppe), `revoke_group_role` zusaetzlich
`_assert_user_group_in_scope(conn, payload.user_group_id)` -- analog zu
den bereits vorhandenen Pruefungen in `grant_role`/`grant_group_role`.
Hinweis: die urspruengliche Schwere dieses Punkts war an Mandanten-Scoping
gebunden (ein Tenant-Admin haette sonst hostgruppen-fremde User treffen
koennen) -- seit dem Teil-C-Rueckbau ist das nur noch eine
Existenz-/Konsistenzpruefung (404 statt stillem No-Op-DELETE), kein
eigentliches Sicherheitsloch mehr.
- **S13 (RBAC-Logik dreifach implementiert) geschlossen fuer die
verbleibenden zwei Implementierungen** (die dritte, `app/tenancy.py`, war
bereits in Teil C nach `_to_delete/` verschoben worden): `app/rbac.py`
bekam eine neue Funktion `user_host_group_ids_with_any_role(conn, *,
user_id, role_names)` -- loest die Vereinigung aus Direkt- und
Gruppenvergabe fuer eine oder mehrere Rollen auf, ueber ALLE Hostgruppen
hinweg (Mengenrueckgabe statt Einzel-Bool). `app/catalog/routes.py` baut
die UNION/EXISTS-Logik nicht mehr selbst nach, sondern ruft diese
Funktion viermal auf (ssh_connect, rdp_connect, file_transfer,
credentials_view+manage) und verknuepft/filtert in Python
(Protokoll-Abgleich inklusive). `app/rbac.py` ist damit die einzige
verbleibende Wahrheitsquelle fuer RBAC-Auflösung.
- **Echter `pytest`-Lauf nach allen obigen Aenderungen: unveraendert 124
passed, 18 failed** -- exakt dieselben 18 vorbestehenden, von Teil D
unabhaengigen Fehlschlaege wie am Ende von Teil C (siehe
`FORTSETZUNG_Teil_C.md` Abschnitt 3). **Keine Regression.**
`tests/test_rbac.py` und die catalog-bezogenen Tests in
`tests/test_phase9.py` laufen weiterhin gruen.
### Bewusst NICHT in Schritt 1 erledigt: S4
D.6 listet S4 ("Nicht-Admin mit `credentials_manage` kann fremde
Zugangsdaten anziehen") unter Schritt 1 ("rechteneutral"), D.5 beschreibt
die eigentliche Lösung aber als Teil der Achse-B-Umstellung ("Prüfung im
Nicht-Admin-Zweig wird von 'existiert' auf 'ist einer meiner Gruppen
freigegeben' umgestellt"). Ein Blick auf den Code zeigt, warum eine
Zwischenloesung in Schritt 1 riskanter waere als sie nuetzt: `CurrentUser`
unterscheidet aktuell NICHT zwischen "Nicht-Admin-Session-User ueber
Hostgruppen-Rolle durchgelassen" und "API-Token mit passendem Scope
durchgelassen" (beide kommen mit `is_admin=False` aus
`require_admin_scope_or_host_role` zurueck, siehe `app/auth/deps.py:190-224`).
Tokens sind aber laut Docstring dort explizit als admin-aequivalent fuer
ihren Scope gedacht ("kein Token traegt je eine Hostgruppen-Rolle") -- eine
pauschale Verschaerfung in `assign_rdp_credential_to_host`/
`map_ssh_key_to_host` wuerde also entweder Tokens unbeabsichtigt
einschraenken oder muesste `CurrentUser` um ein Unterscheidungsmerkmal
erweitern, was kein rein "rechteneutraler" Schritt mehr waere. **Entscheidung
dieser Session: S4 wird korrekt erst mit Achse B in Schritt 5 geloest** (dort
ist die Unterscheidung ohnehin noetig, weil Achse B die Freigabe explizit
prueft). Bis dahin bleibt die Luecke offen -- **das ist ein bekanntes Risiko,
kein vergessener Punkt.**
---
## 1a) Was in dieser Session zusaetzlich fertiggestellt wurde (D.6 Schritt 3, alle 6 Punkte: Migration)
Drei neue, sequenziell aufeinander aufbauende Migrationen, jede einzeln per
synthetischem Voll-Migrationslauf (`sqlite3.executescript` ueber die
gesamte Kette + `PRAGMA foreign_key_check` + Stichproben-Scenario) UND
anschliessend per echtem `pytest`-Lauf gegenverifiziert:
1. **`app/db/migrations/0016_ssh_password_credential_objects.sql`**
(D.4 Punkt 4, Vorbedingung fuer Achse B): baut `ssh_password_credentials`
von einer reinen `host_id`-Zuordnungstabelle zu einem eigenstaendigen
Objekt mit eigener ID um, analog zu Migration 0012
(`rdp_credentials`/`host_rdp_credential_map`). Die alte Tabelle wird zu
`ssh_password_credentials_legacy` umbenannt und dauerhaft aufbewahrt
(Projektkonvention, nicht droppen), die neue `ssh_password_credentials`
(id, label, username, password_enc, created_at, rotated_at) plus
`host_ssh_password_credential_map` (host_id PK, ssh_password_credential_id)
werden befuellt, Korrelation Alt->Neu ueber `password_enc` als natuerlichen
Unique-Schluessel (jede Verschluesselung nutzt einen frischen Nonce).
Begleitende Code-Anpassungen: `app/admin/routes.py`
(`set_ssh_password_credentials`/`delete_ssh_password_credentials` sowie
die beiden Lesepfade im Host-Detail-Endpunkt und die Hard-Delete-Kaskade
pruefen/schreiben jetzt ueber die Map-Tabelle), `app/ssh_proxy/proxy.py`
(Credential-Lookup ebenso ueber die Map-Tabelle).
2. **`app/db/migrations/0017_personal_groups.sql`** (D.4 Punkte 1-2):
fuegt `user_groups.is_personal` (INTEGER NOT NULL DEFAULT 0) hinzu, dazu
einen `BEFORE INSERT ON user_group_members`-Trigger
(`enforce_personal_group_single_member`), der fuer persoenliche Gruppen
ab dem zweiten Mitglied mit `RAISE(ABORT, ...)` hart abbricht (D.7:
"Rechteausweitung ueber persoenliche Gruppen" -- serverseitig erzwungen,
nicht nur Konvention). Anschliessend legt die Migration in einem
`BEGIN IMMEDIATE ... COMMIT`-Block fuer jeden aktiven User mit
mindestens einer `user_hostgroup_roles`-Zeile eine persoenliche Gruppe
an (`'Persoenlich: ' || username || ' (#' || id || ')'`), macht ihn zum
einzigen Mitglied und spiegelt alle seine Direktvergaben 1:1 nach
`group_hostgroup_roles` (inkl. `granted_by`/`granted_at`/`expires_at`
verbatim, keine Neuvergabe). Bewusst OHNE
`PRAGMA foreign_keys = OFF/ON` (anders als Migration 0014) -- reine
INSERTs koennen hier keine FK-Verletzung erzeugen, ein Abschalten der
Pruefung waere eine unbegruendete Abweichung und wurde deshalb aus einem
ersten Entwurf wieder entfernt. Mit einem synthetischen 5-User-Szenario
verifiziert (admin, zwei User mit Direktvergaben, ein User ohne
Vergabe, ein soft-geloeschter User mit Vergabe) -- korrekte Anlage,
korrekter Ausschluss der letzten beiden, exakte Feld-Uebernahme, und der
Trigger blockiert nachweislich ein zweites `INSERT INTO
user_group_members` fuer dieselbe persoenliche Gruppe.
3. **`app/db/migrations/0018_credential_group_grants.sql`** (D.4 Punkte 3
und 5): legt die drei Achse-B-Freigabetabellen an --
`group_ssh_key_grants`, `group_rdp_credential_grants`,
`group_ssh_password_credential_grants` (je zusammengesetzter PK aus
`user_group_id` + Credential-ID, `granted_by`/`granted_at`/`expires_at`,
Indizes auf beiden FK-Spalten, `ON DELETE CASCADE` auf `user_group_id`).
Befuellt rechteneutral per `INSERT OR IGNORE ... SELECT DISTINCT`: jede
Gruppe, die auf der Hostgruppe eines Hosts mit zugeordnetem Credential
die passende Connect-Rolle (`ssh_connect` bzw. `rdp_connect`) in
`group_hostgroup_roles` haelt, wird fuer dessen Credential(s) freigegeben
-- reproduziert exakt das heutige Verhalten. Muss zwingend NACH Migration
0017 laufen, sonst wuerden User mit ausschliesslich Direktvergaben (noch
nicht gespiegelt) faelschlich uebergangen. **Wichtig: diese Migration
aendert noch KEIN Laufzeitverhalten** -- `app/rbac.py` und die Proxies
lesen diese Tabellen noch nicht, das ist Schritt 4. Mit einem
synthetischen Mehrgruppen-Szenario verifiziert (Team-Gruppe "devs" +
`ssh_connect` auf einer Hostgruppe, dazu eine bereits gespiegelte
persoenliche Gruppe von "alice" mit derselben Rolle, drei Hosts
darunter einer mit SSH-Key, einer mit SSH-Passwort-Credential, einer
mit RDP-Credential auf einer Hostgruppe OHNE `rdp_connect`-Vergabe) --
Ergebnis: keine FK-Verletzungen, SSH-Key- und SSH-Passwort-Freigaben
korrekt fuer beide Gruppen erzeugt, `group_rdp_credential_grants`
korrekt leer geblieben.
4. **Echter `pytest`-Lauf gegen die volle Kette 0001-0018** (aufgeteilt in
vier serielle Gruppen ueber getrennte `device_bash`-Aufrufe mit je
eigenem `JUMPHOST_DATA_DIR`, wegen der ~120s-Aufrufgrenze; ein einzelner
`pytest -n 4`-Parallellauf wurde verworfen, da er durch
Worker-Interferenz 8 zusaetzliche, nicht reproduzierbare Errors erzeugte
-- kein echter Befund, siehe unten): **exakt 124 passed, 18 failed**,
Fehlschlaege 1:1 identisch mit den in `FORTSETZUNG_Teil_C.md` Abschnitt 3
dokumentierten 18 vorbestehenden Bugs (Signatur-Drift RDP/Session-
Broadcast, Session-Reaper-Doppel-BEGIN, Recorder-`.close()`, Test-
Substring-Fehlalarm, Session-Invalidierung nach Passwortwechsel,
`hard_deleted`-Logik). **Keine Regression durch Migrationen 0016-0018.**
**Schritt 3 ist damit vollstaendig abgeschlossen.** Empfehlung fuer die
naechste Session: vor dem eigentlichen Produktions-Deployment dieser
Migrationskette zusaetzlich `scripts/diff_effective_rights.py --before
<Kopie-der-Produktions-DB-vor-0016> --after <DB-nach-0018>` gegen die
ECHTEN Produktionsdaten laufen lassen (bisher nur synthetisch verifiziert)
-- siehe Docstring des Skripts.
---
## 1b) Testverifikation nach Schritt 4 -- 12 ERWARTETE neue Fehlschlaege, keine Regression
Echter `pytest`-Lauf (dieselbe Vier-Gruppen-Aufteilung wie zuvor, wegen der
~120s-Aufrufgrenze) gegen den vollstaendigen Codestand nach Schritt 4:
**30 failed, 112 passed** (vorher, am Ende von Schritt 3: 18 failed, 124
passed -- macht +12 neue Fehlschlaege). Alle 12 wurden EINZELN nachverfolgt
(nie pauschal angenommen) und sind auf genau zwei erwartete, mit dem Umbau
selbst zusammenhaengende Ursachen zurueckgefuehrt -- **keine davon ist eine
unerklaerte Regression:**
**Ursache A -- Test seedet Rechte direkt in `user_hostgroup_roles`, das
`app/rbac.py` seit Schritt 4 nicht mehr liest** (6 Tests):
`tests/test_pentest_security.py::test_user_cannot_access_host_outside_granted_hostgroup`,
`tests/test_phase9.py::test_credentials_manage_role_grants_non_admin_write_access`,
`tests/test_phase9.py::test_credentials_view_role_is_read_only`,
`tests/test_phase9.py::test_catalog_hosts_reports_can_view_credentials_flag`,
`tests/test_phase13.py::test_ssh_password_credentials_via_credentials_manage_role`,
`tests/test_rbac.py::test_rbac_grants_and_expiry`. Jeweils per Einzellauf
gegengeprueft: die Assertion schlaegt fehl, weil die zuvor per
`INSERT INTO user_hostgroup_roles ...` vergebene Rolle jetzt ignoriert wird
-- exakt das erwartete Verhalten, siehe Modul-Docstring von `app/rbac.py`.
Fix gehoert zu Schritt 7 (Tests auf Gruppen-basiertes Seeding umstellen).
**Ursache B -- Test ruft `connect_to_host()`/`load_private_key_for_host()`
ohne das neue Pflicht-Keyword `user_id` auf, UND/ODER die Test-DB seedet
keine Achse-B-Freigabe fuer den verwendeten Nutzer** (6 Tests):
`tests/test_phase10.py::test_load_private_key_for_host_uses_stored_passphrase`,
`tests/test_phase12.py::test_verbindung_ohne_hinterlegten_hostkey_wird_abgelehnt`,
`tests/test_phase12.py::test_abweichender_hostkey_bricht_vor_der_anmeldung_ab`,
`tests/test_phase12.py::test_verbindung_nutzt_benutzernamen_der_zugangsdaten`,
`tests/test_phase12.py::test_hostkey_wechsel_nach_der_pruefung_beendet_die_sitzung`,
`tests/test_phase12.py::test_altbestand_ohne_gespeicherten_hostkey_wird_nachgetragen`.
**Bewusst NICHT** mit einem schnellen `user_id=1` "repariert": diese Tests'
`_make_db()`-Hilfsfunktion seedet ueberhaupt keine `users`/`user_groups`/
`group_ssh_key_grants`-Zeilen -- ein einfaches Nachreichen des Arguments
wuerde `resolve_credential_for_user_on_host()` fuer jeden beliebigen
`user_id` leer zurueckliefern lassen und den Test von einem
`TypeError`-Fehlschlag in einen `HostNotConfiguredError`-Fehlschlag
verwandeln, ohne die eigentliche Testabsicht (Host-Key-Pinning, NICHT
Credential-Autorisierung) wiederherzustellen. Ein sauberer Fix gehoert
deshalb ebenfalls zu Schritt 7 -- `_make_db()` braucht dafuer echtes
Achse-B-Seeding (Benutzer, Gruppe, Mitgliedschaft, `group_ssh_key_grants`-
Zeile), keine Ein-Zeilen-Notloesung.
**Fuer Schritt 7 (Abschnitt 2 unten) folgt daraus eine Ergaenzung:** die
dort bereits gelistete Datei-Liste (`test_rbac.py`, `test_phase9.py`,
`test_phase13.py`, `test_admin_groups_tokens.py`) ist um
**`test_pentest_security.py`, `test_phase10.py` und `test_phase12.py`**
zu erweitern -- diese drei waren im urspruenglichen Umsetzungsauftrag NICHT
als betroffen gelistet, sind es aber nachweislich seit Schritt 4.
`tests/test_admin_groups_tokens.py` (im urspruenglichen Auftrag als
betroffen genannt) lief in dieser Session bereits **vollstaendig gruen**
weiter -- vermutlich, weil es (wie schon in Teil C festgestellt) bereits
gruppen-basiert seedet.
---
## 1c) Testverifikation nach Schritt 5 -- 2 ERWARTETE neue Fehlschlaege, keine Regression, plus 6 neue gruene Tests
Echter `pytest`-Lauf (dieselbe Vier-Gruppen-Aufteilung, plus ein separater
Lauf nur der neuen Datei) gegen den vollstaendigen Codestand nach Schritt 5:
**116 passed, 32 failed** (148 Tests insgesamt; vorher, am Ende von
Schritt 4: 112 passed, 30 failed, 142 Tests). Rechnet sich vollstaendig
auf: 142 alte Tests + 6 neue (`tests/test_teil_d_schritt5.py`, alle gruen)
= 148; von den 142 alten wurden GENAU 2 durch Schritt 5 neu zum
Fehlschlag, macht 112 - 2 + 6 = 116 passed und 30 + 2 = 32 failed. Beide
neuen Fehlschlaege wurden einzeln (nie pauschal) auf Schritt 5 selbst
zurueckgefuehrt -- **keine unerklaerte Regression:**
- **`tests/test_admin_crud.py::test_multi_role_grant_for_individual_user`**
-- ruft `POST /admin/roles/grant` auf und erwartet 200; bekommt jetzt
410. Erwartet: dieser Test pruefte exakt den jetzt retirierten
Direktvergabe-Pfad. Fix gehoert zu Schritt 7 (auf `/admin/group-roles/*`
umstellen, wie im Umsetzungsauftrag fuer diese Datei ohnehin vorgesehen).
- **`tests/test_pentest_security.py::test_role_on_one_hostgroup_does_not_grant_different_permission_type`**
-- seedet per rohem SQL direkt in `user_hostgroup_roles`; diese Tabelle
heisst seit Migration 0019 `user_hostgroup_roles_legacy`, das `INSERT`
scheitert daher jetzt mit `sqlite3.OperationalError: no such table:
user_hostgroup_roles` statt (wie unter Schritt 4) still zu laufen. War
bei den 12 Ursache-A-Fehlschlaegen aus Abschnitt 1b NICHT gelistet, weil
die Testaussage (403 bei fehlendem `file_transfer`) zufaellig unabhaengig
davon war, ob die separat geseedete `ssh_connect`-Rolle tatsaechlich
griff -- der Test blieb unter Schritt 4 also aus falschem Grund gruen.
Gehoert in dieselbe Kategorie wie die 6 Ursache-A-Tests aus 1b und damit
zu deren Schritt-7-Fix.
**Zur Vollstaendigkeit gegengeprueft, dass sich auch die 12
Ursache-A/B-Fehlschlaege aus Schritt 4 sauber wiederfinden:** vier davon
(`test_credentials_manage_role_grants_non_admin_write_access`,
`test_credentials_view_role_is_read_only`,
`test_ssh_password_credentials_via_credentials_manage_role`,
`test_catalog_hosts_reports_can_view_credentials_flag`) seeden ueber den
API-Aufruf `POST /admin/roles/grant` statt per rohem SQL -- ihre
Fehlermeldung hat sich dadurch ebenfalls verschoben (Assertion auf
Status 410 bzw. nachgelagerte Assertion, statt der urspruenglichen
Rollenpruefungs-Assertion aus Schritt 4), bleibt aber inhaltlich dieselbe
Ursache-A-Familie. `test_user_cannot_access_host_outside_granted_hostgroup`
und `test_rbac.py::test_rbac_grants_and_expiry` seeden wie
`test_role_on_one_hostgroup_...` oben per rohem SQL und zeigen daher
ebenfalls `no such table: user_hostgroup_roles` statt der Schritt-4-
Assertion. Keine dieser Verschiebungen aendert etwas an der Schritt-7-
Aufgabe (Tests auf Gruppen-Seeding umstellen) -- nur die Fehlermeldung, an
der man das erkennt, hat sich geaendert.
Die 6 neuen Tests in `tests/test_teil_d_schritt5.py` decken ab: 410 fuer
`/admin/roles/grant|revoke`; `GET /admin/roles` zeigt `via_group_name`;
Freigeben/Lesen/Entziehen fuer alle drei Achse-B-Arten inkl. 422 bei
unbekannter Art und 404 bei nicht existierendem Zugangsdatensatz; `GET
/admin/ssh-password-credentials` (globale Liste, keine Passwort-Leckage);
S4 (freigegebener Key darf angehaengt werden, nicht freigegebener wird mit
403 abgelehnt); S10 (`gained_rights`/`lost_rights` im Audit-Event). Dazu
ein isolierter `GET /admin`-Smoke-Test: die Seite rendert, enthaelt das
neue Panel und nicht mehr den alten Text "Rolle(n) an Benutzer vergeben"
-- sowie `tests/test_csp_compliance.py`, das ohnehin bei jedem Lauf
mitlief und weiterhin gruen ist.
---
## 1d) Testverifikation nach Schritt 7 -- alle 14 bekannten Fehlschlaege behoben, nur noch die urspruenglichen 18 (Teil C) offen
Echter `pytest`-Lauf (dieselbe Vier-Gruppen-Aufteilung) gegen den
vollstaendigen Codestand nach Schritt 7: **134 passed, 18 failed** (152
Tests insgesamt; vorher, am Ende von Schritt 6: 118 passed, 32 failed, 150
Tests -- die 2 zusaetzlichen Tests kommen aus `tests/test_rbac.py`s
Neuschreibung, siehe unten). Rechnet sich vollstaendig auf: 118 + 2 neue
`test_rbac.py`-Tests = 120; von den bisher 32 fehlschlagenden Tests wurden
GENAU 14 durch das Umstellen auf Gruppen-basiertes Seeding repariert, macht
120 + 14 = 134 passed und 32 - 14 = 18 failed. **Die verbleibenden 18
Fehlschlaege sind exakt (namentlich geprueft, keine zufaellige
Uebereinstimmung der Anzahl) die urspruenglichen, in `FORTSETZUNG_Teil_C.md`
Abschnitt 0 dokumentierten Bugs aus Teil C** -- ausserhalb des Schritt-7-
Auftrags (der deckte namentlich nur die 14 aus den Abschnitten 1b/1c ab)
und explizit NICHT ohne neue Anweisung zu beheben (Betreiberentscheidung
aus einer fruegeren Session, siehe `FORTSETZUNG_Teil_C.md`).
Damit ist Teil D (Umsetzungsauftrag_Sonnet5.md, D.6 Schritte 1-7)
**vollstaendig abgeschlossen.** Die einzigen offenen Punkte sind bewusst
zurueckgestellte Backlog-Punkte (siehe Abschnitt 2, Schritt 6: manuelle
Konsolidierung persoenlicher Gruppen; manuelles Bestaetigen/Entziehen der
Achse-B-Vorbefuellung; siehe auch die "Fuer eine kuenftige Session"-Hinweise
in den Abschnitten 1/1a/1b) sowie der unabhaengige 18-Bugs-Rucksack aus
Teil C.
---
## 2) Was noch zu tun ist (D.6 Schritte 2-7)
**Schritt 2 ist bereits erfuellt** (Teil C ist abgeschlossen, Voraussetzung
fuer Schritt 3). **Schritt 3 UND Schritt 4 sind ebenfalls vollstaendig
abgeschlossen**, siehe Abschnitt 1a bzw. 1b -- naechster offener Schritt
ist Schritt 5.
### Schritt 3: Migration (D.4) -- VOLLSTAENDIG FERTIG (alle 6 Punkte)
1. ~~Neue Spalte/Kennzeichen "ist persoenliche Gruppe" auf `user_groups`~~
**FERTIG** -- `app/db/migrations/0017_personal_groups.sql`
(`user_groups.is_personal`), inkl. serverseitigem Trigger gegen ein
zweites Mitglied. Siehe Abschnitt 1a.
2. ~~Fuer jeden User mit >=1 Zeile in `user_hostgroup_roles`: persoenliche
Gruppe anlegen ...~~ **FERTIG** -- Teil derselben Migration 0017. Siehe
Abschnitt 1a.
3. ~~Drei neue Achse-B-Tabellen~~ **FERTIG** --
`app/db/migrations/0018_credential_group_grants.sql`
(`group_ssh_key_grants`, `group_rdp_credential_grants`,
`group_ssh_password_credential_grants`). Siehe Abschnitt 1a.
4. ~~Vorher noetig: `ssh_password_credentials` ... umbauen~~ **FERTIG** --
`app/db/migrations/0016_ssh_password_credential_objects.sql`
(`ssh_password_credentials_legacy` + neues eigenstaendiges Objekt +
`host_ssh_password_credential_map`), inkl. Anpassung der lesenden/
schreibenden Codepfade in `app/admin/routes.py` und
`app/ssh_proxy/proxy.py`. Siehe Abschnitt 1a.
5. ~~Achse B rechteneutral vorbefuellen~~ **FERTIG** -- Teil von Migration
0018 (`INSERT OR IGNORE ... SELECT DISTINCT`, gegen ein
Mehrgruppen-Szenario verifiziert). Siehe Abschnitt 1a.
6. ~~Verpflichtend (D.7): Vorher/Nachher-Diff der effektiven Rechte je
Benutzer~~ **FERTIG** -- neues, dauerhaftes Werkzeug
`scripts/diff_effective_rights.py` (nicht nur ein Einmal-Skript: nimmt
zwei SQLite-Dateipfade `--before`/`--after`, oeffnet beide read-only und
vergleicht fuer jeden aktiven User und jede Rolle aus
`app.models.schemas.ROLE_NAME` die Menge der Hostgruppen-IDs ueber
`app.rbac.user_host_group_ids_with_any_role` -- dieselbe Funktion, die
die Anwendung selbst zur Laufzeit nutzt, keine Nachbau-Logik. Exit-Code
1 bei jeder Abweichung, 0 bei Uebereinstimmung; alternativ `--dump
<db> --out <json>` fuer einen reinen Snapshot-Export vor einem
Wartungsfenster. Verifiziert an einem synthetischen 5-User-Szenario
(Direktvergabe, Gruppenvergabe, Ueberschneidung von Direkt- und
Gruppenvergabe auf derselben Hostgruppe/Rolle als Dedup-Test, sowie eine
abgelaufene Vergabe als Ausschluss-Test): Diff gegen die Kette
0001-0015 vs. 0001-0018 mit identischem Seed liefert **keine
Abweichung** (Exit 0). Zusaetzlich mit einer Negativprobe abgesichert
(eine `group_hostgroup_roles`-Zeile in der Nachher-DB absichtlich
geloescht) -- das Werkzeug erkennt den Rechteverlust korrekt (Exit 1,
`user_id=3 ('bob'), Rolle='rdp_connect': VERLOREN auf Hostgruppen [2]`),
das Werkzeug ist also kein Blindgaenger, der immer "keine Abweichung"
meldet.
### Schritt 4: Lesepfade umstellen -- FERTIG
- ~~`app/rbac.py::user_has_role()` auf einen Zweig kuerzen~~ **FERTIG.**
`app/rbac.py` komplett neu geschrieben: `user_has_role()`,
`user_has_role_for_host()` und `user_host_group_ids_with_any_role()`
lesen nur noch `group_hostgroup_roles` -- der `user_hostgroup_roles`-Teil
des fruehreren `UNION` ist vollstaendig entfallen. Sicher, weil Migration
0017 (Schritt 3) jede vormals direkte Vergabe bereits 1:1 in eine
persoenliche Gruppe gespiegelt hat.
- ~~Zwei neue Funktionen in `app/rbac.py`~~ **FERTIG:**
`user_can_use_credential(conn, *, user_id, kind, credential_id)` ("darf
Benutzer X Credential Y nutzen") und
`resolve_credential_for_user_on_host(conn, *, user_id, host_id, kind)`
("welches Credential gilt fuer Benutzer X auf Host Z"), beide generisch
ueber `kind: Literal["ssh_key", "rdp_credential",
"ssh_password_credential"]` fuer alle drei Achse-B-Tabellen aus Migration
0018. Deterministische Auswahlregel wie im Auftrag empfohlen: bei mehr
als einem Treffer wirft `resolve_credential_for_user_on_host()` eine neue
`AmbiguousCredentialError` (mit `host_id`, `kind`, `credential_ids`) statt
still auszuwaehlen. Kann strukturell nur bei `kind="ssh_key"` auftreten
(n:m-Zuordnung ueber `host_ssh_key_map`) -- RDP- und SSH-Passwort-Map
haben laut Schema PK auf `host_id`, also hoechstens einen Treffer.
- ~~`app/ssh_proxy/proxy.py` ... auf die neue Auflösung umstellen~~
**FERTIG.** `load_ssh_key_credential_for_host()` und
`load_ssh_password_credential_for_host()` (sowie die davon abgeleitete
`load_private_key_for_host()`) nehmen jetzt ein PFLICHT-Keyword-Argument
`user_id` und loesen ueber `resolve_credential_for_user_on_host()` auf,
statt blind per Host-ID zu selektieren. `connect_to_host()` nimmt
ebenfalls `user_id` entgegen und reicht es durch; die drei Aufrufer
(`app/ssh_proxy/terminal_ws.py`, `app/ssh_proxy/sftp.py` x2) uebergeben
`user_id=user.id`. `AmbiguousCredentialError` ist in `SSH_SETUP_ERRORS`
aufgenommen (von `terminal_ws.py`/`sftp.py` bereits gemeinsam behandelt)
und hat eine eigene Klartextmeldung in `describe_connection_error()`.
- ~~`app/rdp_proxy/ws_tunnel.py` ... auf die neue Auflösung umstellen~~
**FERTIG.** Die vormals direkte
`SELECT ... FROM host_rdp_credential_map JOIN rdp_credentials ... WHERE
host_id = ?`-Abfrage ist durch `resolve_credential_for_user_on_host(...,
kind="rdp_credential")` plus eine anschliessende ID-Abfrage ersetzt.
`AmbiguousCredentialError` wird defensiv abgefangen (strukturell nicht
erreichbar, siehe oben) und wie der "kein Credential"-Fall ueber
`_reject()` sauber beantwortet, statt unbehandelt durchzuschlagen.
- `app/catalog/routes.py` **brauchte keine weitere Aenderung** -- war seit
Schritt 1 (S13) bereits vollstaendig auf
`user_host_group_ids_with_any_role()` umgestellt und profitiert von der
Zweig-Kuerzung automatisch. Die D.5-Empfehlung, `can_connect` zusaetzlich
abzubilden, ob ueberhaupt ein fuer den Benutzer nutzbares Credential
existiert, ist NICHT umgesetzt (kein Pflichtpunkt von D.6 Schritt 4) --
offener Backlog-Punkt fuer eine spaetere Session.
- ~~**Vorab-Report (D.7, Pflicht vor diesem Schritt):** "Hosts mit
Zugangsdaten ohne Gruppenfreigabe"~~ **FERTIG.** Neues Werkzeug
`scripts/pre_schritt4_checks.py` (zwei Reports in einem Skript, `--db
<pfad>`, Exit-Code 1 bei Funden). Mit einem synthetischen 3-Host-
Szenario verifiziert (Host mit einem abgedeckten Schluessel -> still;
Host mit zwei Schluesseln, beide dergleichen Gruppe freigegeben -> Report
2 schlaegt an; Host mit einem Zugangsdatensatz auf einer Hostgruppe ohne
jede `ssh_connect`-Vergabe -> Report 1 schlaegt an) -- beide Reports
feuern exakt an den engineeren Problemfaellen und bleiben beim sauberen
Host still.
- ~~**Vorab-Pruefung (D.7):** welche Hosts haben mehr als einen SSH-Key~~
**FERTIG** -- Report 2 desselben Skripts, siehe oben.
**WICHTIG fuer die naechste Session:** Diese beiden Reports wurden nur
gegen ein synthetisches Testszenario gefahren, NICHT gegen die echten
Produktionsdaten (dafuer gibt es in dieser Umgebung keine Kopie). Vor dem
tatsaechlichen Produktions-Deployment dieses Codestands MUSS
`scripts/pre_schritt4_checks.py --db <Kopie-der-Produktions-DB>` einmal
real laufen und sauber (Exit 0) sein -- siehe auch die analoge Empfehlung
fuer `scripts/diff_effective_rights.py` in Abschnitt 1a.
### Schritt 5: Schreibpfade abschalten -- FERTIG
- ~~`POST /admin/roles/grant` + `/revoke` auf HTTP 410~~ **FERTIG.** Beide
Endpunkte nehmen keinen Body mehr entgegen (bewusst -- ein alter Client
soll den 410 sehen, keinen 422 wegen eines irrelevanten
Validierungsdetails) und antworten mit HTTP 410 plus Verweis auf
`/admin/group-roles/grant|revoke`. Die Auth-Dependency bleibt aktiv
(kein anonymer Zugriff auf den Endpunktnamen).
- ~~`GET /admin/roles` zur abgeleiteten Sicht umbauen~~ **FERTIG.** Liest
jetzt `group_hostgroup_roles JOIN user_groups JOIN user_group_members
JOIN users (deleted_at IS NULL) JOIN host_groups JOIN roles`, gefiltert
auf nicht abgelaufene `expires_at`. Neue Felder `via_group_id`/
`via_group_name` (S1: macht sichtbar, WELCHE Gruppe ein Recht vermittelt
-- steht ein Benutzer ueber zwei Gruppen fuer dieselbe Rolle/Hostgruppe
berechtigt, erscheinen zwei Zeilen). `deleted_at IS NULL` filtert
Karteileichen aus (S9).
- ~~S4 loesen~~ **FERTIG, aber NUR fuer `map_ssh_key_to_host` und
`assign_rdp_credential_to_host`** -- beide pruefen jetzt zusaetzlich zur
Existenz (`_assert_ssh_key_in_scope`/`_assert_rdp_credential_in_scope`)
`user_can_use_credential(conn, user_id=admin.id, kind=..., credential_id=...)`,
wenn `not admin.is_admin and not admin.is_token` (neues Feld
`CurrentUser.is_token`, gesetzt in `app/auth/deps.py::_validate_api_token`
-- unterscheidet einen echten Nicht-Admin-Session-User von einem
API-Token mit passendem Scope, das wie ein Admin behandelt bleibt).
Verletzung -> HTTP 403. **Bewusste Abweichung vom oben unter Schritt 4
notierten Plan:** `set_ssh_password_credentials`/
`delete_ssh_password_credentials` bekommen die Pruefung NICHT -- anders
als bei SSH-Keys/RDP-Zugangsdaten legt dieser Endpunkt IMMER ein neues
Credential-Objekt frisch an bzw. rotiert das bestehende in place; es gibt
keinen `credential_id`-Parameter, ueber den ein fremdes, bereits
existierendes Objekt angehaengt werden koennte -- die S4-Angriffsflaeche
("beliebiges vorhandenes Credential an eigenen Host haengen") existiert
dort strukturell nicht. Mit einem echten End-to-End-Test verifiziert
(`test_s4_non_admin_needs_group_grant_to_attach_credential` in
`tests/test_teil_d_schritt5.py`): Mitglied mit `credentials_manage`
haengt einen freigegebenen Key erfolgreich an, ein zweiter, existierender
aber nicht freigegebener Key wird mit 403 abgelehnt.
- ~~Neue Achse-B-Endpunkte~~ **FERTIG.** Ein Endpunkt-Trio bedient alle
drei Credential-Arten ueber einen `{kind}`-Pfadparameter (FastAPI-Literal-
Validierung, unbekannte Art -> 422) statt drei Kopien: `POST
/admin/group-credentials/{kind}/grant`, `POST
/admin/group-credentials/{kind}/revoke`, `GET
/admin/group-credentials/{kind}`. Neue Schemas
`GroupCredentialGrantRequest`/`GroupCredentialRevokeRequest`
(`app/models/schemas.py`) -- Struktur bewusst analog zu
`GroupRoleGrantRequest`. Dazu **neu** `GET /admin/ssh-password-credentials`
(globale Liste, analog zu `list_rdp_credentials()`) -- fehlte bisher
komplett, obwohl das Objekt seit Migration 0016 eine eigene ID hat; ohne
diesen Endpunkt haette die neue Achse-B-UI fuer diese Credential-Art
keine Datenquelle fuer den Auswahl-Dropdown gehabt.
- ~~`POST /admin/user-groups/{id}/members` S10~~ **FERTIG.**
`add_group_member()`/`remove_group_member()` ermitteln vor der
Mutation ueber die neue Hilfsfunktion `_group_effective_rights_summary()`
die aktuell von der Gruppe gewaehrten Rechte (Achse A: Liste
`{host_group, role}`; Achse B: Anzahl freigegebener Zugangsdaten je Art)
und schreiben sie als `gained_rights`/`lost_rights` in die
`details_json`-Spalte des Audit-Events. **Bewusst KEIN Diff** gegen
zuvor schon ueber ANDERE Gruppen gehaltene Rechte des Users (deutlich
groesserer Umbau) -- beschreibt, was DIESE Gruppe gewaehrt, nicht
zwingend das Netto-Delta. Mit echtem Test verifiziert
(`test_s10_audit_event_carries_gained_and_lost_rights`).
- ~~UI: Direktvergabe-Formular entfernen~~ **FERTIG.** Panel "Rolle(n) an
Benutzer vergeben" (`templates/admin.html`, vormals ~Zeilen 498-529)
vollstaendig entfernt; `#role-grants-table` ist jetzt eine
nicht-editierbare Tabelle (Spalten Benutzer/Hostgruppe/Rolle/Gruppe/
Ablauf, keine Aktionen-Spalte mehr). Neues Panel "Zugangsdaten fuer
Gruppe freigeben" (Gruppe-/Art-/Zugangsdatensatz-Auswahl, Ablaufdatum,
Tabelle mit Entziehen-Button). `static/js/admin.js`: `#role-grant-form`-
Handler entfernt, `refreshRoleGrants()` liest die neue Antwortform
(`via_group_name`), neue Funktionen `populateCredentialSelect()`
(befuellt den Zugangsdatensatz-Dropdown je gewaehlter Art aus den
bereits vorhandenen Caches `cachedSshKeys`/`cachedRdpCredentials` plus
neu `cachedSshPasswordCredentials`) und `refreshGroupCredentialGrants()`
(holt alle drei Arten parallel, mergt fuer eine gemeinsame Tabelle).
`ROLE_NAMES` kam schon seit Schritt 1 aus `GET /admin/roles/names` --
hier keine Aenderung noetig. Verifiziert: `node --check` auf beiden
JS-Dateien, `tests/test_csp_compliance.py` gruen (CSP-Konformitaet der
neuen Inline-freien Struktur), `GET /admin` liefert das neue Panel und
NICHT mehr den alten Text "Rolle(n) an Benutzer vergeben".
- ~~`user_hostgroup_roles` leeren/umbenennen~~ **FERTIG** -- Migration
0019 (`ALTER TABLE user_hostgroup_roles RENAME TO
user_hostgroup_roles_legacy`), NICHT gedroppt (Projektkonvention siehe
`0010:9-14`, `0012:12-17`). Synthetisch gegen die volle Kette 0001-0019
verifiziert: keine FK-Verletzungen, alter Tabellenname verschwunden,
`user_hostgroup_roles_legacy` vorhanden und weiterhin abfragbar, Index
automatisch mitgewandert.
### Schritt 6: Konsolidierung -- FERTIG (als Report-Werkzeug, bewusst OHNE automatische Zusammenlegung/Entzug)
Zwei neue, rein lesende Endpunkte in `app/admin/routes.py`, beide hinter
`require_admin_or_scope("roles", "read")`:
- ~~Admin-Report "persoenliche Gruppen mit identischem Rechteprofil"~~
**FERTIG.** `GET /admin/reports/personal-groups`. Fuer jede Gruppe mit
`is_personal=1` (Migration 0017) berechnet
`_personal_group_rights_fingerprint()` einen kanonischen String aus ALLEN
Rechten (Achse A: `group_hostgroup_roles`, sortiert nach
Hostgruppe/Rolle/Ablauf; Achse B: alle drei Freigabetabellen, sortiert
nach Credential-ID/Ablauf) -- Gruppen mit identischem Fingerprint landen
im selben Cluster. Antwort: Liste von Clustern mit `group_count`,
`consolidation_candidate` (`group_count > 1`) und den Mitgliedsgruppen
(inkl. des jeweils einzigen Mitglieds-Users, siehe Migration-0017-
Trigger). Mit echtem Test verifiziert (zwei Gruppen mit identischer
`ssh_connect`-Rolle landen im selben Cluster, eine dritte ohne jede Rolle
bleibt allein).
- ~~restriktives Aufraeumen der Achse-B-Vorbefuellung~~ **BEWUSST NICHT als
automatischer Entzug umgesetzt -- als Report-Werkzeug FERTIG, siehe
Begruendung unten.** `GET /admin/reports/unconfirmed-credential-grants`
listet alle Achse-B-Freigaben mit `granted_by IS NULL` -- exakt die
Kennzeichnung, die Migration 0018 fuer ihre rechteneutrale Vorbefuellung
verwendet (ein manueller Grant ueber `POST
/admin/group-credentials/{kind}/grant` setzt immer `admin.id`). Sobald
ein Admin eine Zeile ueber diesen Endpunkt bestaetigt, verschwindet sie
aus dem Report (mit echtem Test verifiziert: Vorbefuellungs-Zeile
erscheint, nach manueller Bestaetigung nicht mehr).
**Warum kein automatischer Entzug:** die D.7-Zeile "Rechteausweitung durch
die Achse-B-Vorbefuellung" sieht ausdruecklich vor, das als Aufraeum-
Backlog zu DOKUMENTIEREN, "wenn diese Session/ein Folgeauftrag es nicht
mehr vollstaendig umsetzt" -- das System hat keine echten Nutzungsdaten
(wer benutzt welches Credential tatsaechlich), ein automatisches Entziehen
ungenutzt erscheinender Vorbefuellungs-Zeilen waere daher blindes Raten mit
echtem Rechteverlust-Risiko (genau das Risiko, das die erste D.7-Zeile
"Rechteverlust beim Abschalten der Direktvergabe" fuer den gesamten
Umbau vermeiden wollte). Der Report macht die Vorbefuellung sichtbar und
auditierbar; das tatsaechliche Entziehen bleibt eine bewusste,
fallweise Admin-Entscheidung ueber den bereits vorhandenen
`POST /admin/group-credentials/{kind}/revoke`-Endpunkt (Schritt 5).
Ebenso fuehrt `GET /admin/reports/personal-groups` KEINE automatische
Zusammenlegung durch -- das Verschmelzen zweier Gruppen aendert
Mitgliedschaften und wuerde bei falscher Annahme (zwei Gruppen mit *heute*
identischem Profil, die bewusst getrennt bleiben sollen, z.B. fuer
zukuenftige Differenzierung) unnoetig Struktur zerstoeren, die sich nicht
ohne Weiteres wiederherstellen laesst.
**Fuer eine kuenftige Session:** `GET /admin/reports/unconfirmed-credential-grants`
gegen die echten Produktionsdaten laufen lassen (analog zur Empfehlung fuer
`scripts/pre_schritt4_checks.py`/`scripts/diff_effective_rights.py` in den
Abschnitten 1/1a) und Zeile fuer Zeile entscheiden: freigeben (bestaetigen)
oder entziehen. `GET /admin/reports/personal-groups` als Grundlage fuer
eine manuelle Entscheidung nutzen, welche Cluster tatsaechlich zu echten
Team-Gruppen zusammengelegt werden sollen (z.B. per `PUT
/admin/user-groups/{id}` umbenennen und `is_personal` -- dafuer existiert
aktuell noch KEIN Endpunkt, der das Flag aendert; das waere ein
naheliegender, aber bisher nicht spezifizierter Folgepunkt).
### Schritt 7: Tests -- FERTIG (alle 14 bekannten Fehlschlaege behoben, siehe Abschnitt 1d)
Alle 14 in den Abschnitten 1b/1c namentlich bekannten Fehlschlaege sind
einzeln (nie pauschal) auf Gruppen-basiertes Seeding umgestellt und
verifiziert. Details siehe Abschnitt 1d. Kurzfassung je betroffener Datei:
- ~~`tests/test_rbac.py` ... vollstaendig neu geschrieben~~ **FERTIG.**
Alle drei Tests seeden jetzt ueber `user_groups`/`user_group_members`/
`group_hostgroup_roles` statt `user_hostgroup_roles`. Zwei zusaetzliche
Tests ergaenzt (Rolle bleibt bei Ablauf EINER von zwei Gruppen bestehen;
Rolle leckt nicht zwischen Hostgruppen) -- nicht im urspruenglichen
Umsetzungsauftrag gefordert, aber naheliegende Luecken angesichts des
Neuschreibens.
- ~~`tests/test_phase9.py`, `tests/test_phase13.py`,
`tests/test_admin_groups_tokens.py`~~ **`test_admin_groups_tokens.py`
war bereits gruen (siehe Abschnitt 1b), keine Aenderung noetig.
`test_phase9.py`/`test_phase13.py`: FERTIG**, neue Hilfsfunktionen
`_grant_group_role()` (beide Dateien) und `_grant_group_credential()`
(nur `test_phase9.py`, fuer den S4-Fall) ersetzen `POST
/admin/roles/grant`.
- ~~`tests/test_pentest_security.py` (2 Tests), `tests/test_phase10.py`
(1 Test), `tests/test_phase12.py` (5 Tests)~~ **FERTIG.** Bei
`test_phase10.py`/`test_phase12.py` zusaetzlich zur Gruppen-Freigabe das
neue Pflicht-Keyword `user_id` an allen `connect_to_host()`/
`load_private_key_for_host()`-Aufrufen ergaenzt; `test_phase12.py`s
`_make_db()`-Hilfsfunktion seedet jetzt immer einen Benutzer samt Gruppe
und (bei `with_key=True`) eine `group_ssh_key_grants`-Freigabe --
genau das zuvor als "kein Notloesungs-`user_id=1`" zurueckgestellte
echte Achse-B-Seeding aus Abschnitt 1b.
- ~~`tests/test_admin_crud.py` (1 Test)~~ **FERTIG, aber NICHT durch einen
Ersatztest fuer den Einzel-User-Pfad** -- `test_multi_role_grant_for_individual_user`
wurde zu `test_direct_multi_role_grant_for_individual_user_is_retired`:
bestaetigt, dass `POST /admin/roles/grant` mit `role_names` jetzt 410
liefert UND folgenlos bleibt (`GET /admin/roles` zeigt keine Rolle). Kein
Ersatz fuer die Mehrfachauswahl-Faehigkeit selbst noetig -- die deckt
`test_multi_role_grant_for_user_group` (Gruppen-Pfad) bereits ab, diese
Faehigkeit existierte fuer einzelne Benutzer ohnehin nur als jetzt
entfallener Spezialfall derselben Logik.
- **`tests/test_tenants.py`** (vom Umsetzungsauftrag noch referenziert)
existiert weiterhin nicht als aktive Testdatei -- liegt seit Teil C in
`_to_delete/`. Keine Aktion noetig.
- Jede Aenderung einzeln gegen einen echten `pytest`-Lauf verifiziert
(siehe `FORTSETZUNG_Teil_C.md` Abschnitt 0 fuer venv-/Env-Var-Setup).
---
## 3) Risiken, die beim Weitermachen besonders im Blick bleiben muessen (D.7)
Die vollstaendige Tabelle steht im Umsetzungsauftrag; am wichtigsten fuer
die Reihenfolge:
- **Reihenfolge Schritt 3 vor Schritt 5 ist zwingend** -- sonst
Rechteverlust beim Abschalten der Direktvergabe.
- **Reihenfolge "Vorab-Report" vor Schritt 4** ist zwingend -- sonst
Zugriffsausfall durch Achse B fuer jeden Host ohne Gruppenfreigabe.
- Mehrdeutigkeit bei der Credential-Auswahl (mehrere Keys an einem Host)
muss VOR der Umstellung auf die neue Auflösung geprueft werden, nicht
erst danach durch Nutzerbeschwerden auffallen.
---
## 4) Technische Hinweise (siehe auch `FORTSETZUNG_Teil_C.md` Abschnitt 0)
- venv liegt unter `.venv/` im Projektordner, funktioniert. Vor jedem
`pytest`-Lauf `JUMPHOST_DATA_DIR`/`JUMPHOST_ENV`/`JUMPHOST_DEV_KEK`/
`JUMPHOST_DEV_SESSION_SECRET` exportieren (siehe dortiger Codeblock).
- Migrations-Dateinamen sind alphabetisch sortiert entscheidend
(`app/db.py::_apply_migrations`, `sorted(MIGRATIONS_DIR.glob("*.sql"))`)
-- naechste freie Nummer nach `0018_credential_group_grants.sql` ist
`0019`.
- Migrationen schreiben laut Projektkonvention KEINE eigenen
`audit_log`-Eintraege (Hash-Chain wird ausschliesslich aus Python-Code
gepflegt, `write_audit_event()`) -- bei Bedarf einen Audit-Eintrag aus
einer begleitenden Codeaenderung schreiben, nicht aus dem SQL-Skript
selbst.
- Alle Datei-Edits weiterhin ueber `device_bash`-Python-Skripte mit
`content.count(old) == 1`-Assert-dann-Replace-Muster, danach
`py_compile`/`node --check` UND einen echten `pytest`-Lauf zur
Verifikation (jetzt moeglich, siehe Teil-C-Dokument Abschnitt 0) --
keine Aenderung mehr nur "statisch" verifizieren, wenn ein echter Test-
Lauf verfuegbar ist.

742
FORTSETZUNG_Teil_F.md Normal file
View File

@ -0,0 +1,742 @@
# Fortsetzungsdokument: Umsetzungsauftrag_Sonnet5.md — Teil F (Mehrere Sitzungen je Benutzer mit Seitenleiste)
Stand: 2026-09-01, Stufen F1+F2+F3+F4+F5 abgeschlossen und verifiziert (F.6-Abnahme
noch ausstehend, siehe Abschnitt 6). Repo: `ssh_jumphost`
(FastAPI/SQLite SSH/RDP-Jumphost), erreichbar ueber die device_bash-Bridge
unter `$HOME/mnt/ssh_jumphost`.
**Reihenfolge des Gesamtauftrags** (verbindlich, siehe
`Umsetzungsauftrag_Sonnet5.md`): Teil A/B -> Teil E -> Teil C (fertig) ->
Teil D (fertig, siehe `FORTSETZUNG_Teil_D.md`) -> **Teil F (dieses Dokument,
laufend)** -> Aufgabe #11 (SSH Copy-Paste + Terminal-Farben, vom Nutzer
nachtraeglich hinzugefuegt).
**Erledigt: Teil A, Teil B, Teil E, Teil C, Teil D** -- alle vollstaendig
abgeschlossen in frueheren Sessions, hier nicht mehr relevant. Teil F siehe
unten.
---
## 0) Betreiberentscheidungen fuer Teil F (Nutzerantworten dieser Session)
Der Umsetzungsauftrag verlangt vor Beginn von Teil F Antworten auf drei der
acht offenen Betreiberentscheidungen (Abschnitt "Offene Entscheidungen fuer
den Betreiber", Punkte 6-8):
1. **#6 Obergrenze gleichzeitiger Sitzungen (Teil E.4)** -- bereits durch
eine fruehere Session beantwortet und implementiert: `app/config.py`
`max_sessions_per_user=6`, `max_sessions_global=100` (Env-Overrides
`JUMPHOST_MAX_SESSIONS_PER_USER`/`_GLOBAL`), durchgesetzt in
`app/ssh_proxy/terminal_ws.py` und `app/rdp_proxy/ws_tunnel.py` VOR jedem
Ressourcenverbrauch. Keine neue Entscheidung noetig.
2. **#7 Verhalten von Hintergrundsitzungen (F.3.9)** -- Nutzerentscheidung
dieser Session: **voll verbunden bleiben** (Spec-Empfehlung fuer Stufe 1).
Kein Halte-/Pausenzustand, keine neue Zustandsmaschine. Die bereits
vorhandene niedrige Obergrenze (Punkt 1) begrenzt den Ressourcenverbrauch.
3. **#8 Wiederanschluss nach Seiten-Reload (F.3.7)** -- Nutzerentscheidung
dieser Session: **kein Wiederanschluss, klar beschriftet**
(Spec-Empfehlung fuer Stufe 1). Ein Reload beendet die Sitzung wie heute;
die Oberflaeche muss das ehrlich kommunizieren ("Sitzung wurde beendet,
erneut verbinden"), statt es zu verschweigen. Serverseitiges Halten von
SSH-Verbindung + Ausgabepuffer ueber eine Trennung hinweg ist explizit
NICHT Teil dieser Umsetzung (F.3.7, Umsetzungsauftrag: "kein
Nebenbei-Feature").
## 0a) Ist-Zustand-Bestandsaufnahme (vor F1)
Bereits vorhanden und fuer Teil F wiederverwendbar (nichts davon musste neu
gebaut werden):
* **E7 Session-Reaper** (`app/security/session_reaper.py`, in
`app/main.py::lifespan` vor dem ersten Request aufgerufen) -- keine
Geistersitzungen nach Neustart.
* **E9 Keepalive** (`static/js/session-keepalive.js`, in terminal.html/
rdp.html eingebunden) -- haelt das Anmelde-Cookie waehrend einer offenen
Sitzung per periodischem `GET /auth/me` am Leben.
* **E10 Aktivitaets-basierter Idle-Timeout** (`app/ssh_proxy/terminal_ws.py`
`IDLE_TIMEOUT_SECONDS`, `activity["t"]` wird sowohl bei Ein- als auch bei
Ausgabe aktualisiert) -- ein `tail -f` im Hintergrund haelt die Sitzung am
Leben.
* **E.4 Obergrenzen + Registry** (`app/security/active_sessions.py`:
`count_for_user()`, `count_total()`, prozesslokale In-Memory-Registry mit
`task`/`user_id`/`watchers`) -- Grundlage fuer F.3.6 (eigene
Sitzungs-API, Stufe F2).
* **D4 RDP dynamische Aufloesung** (`resize-method: display-update` in
`app/rdp_proxy/guacd_client.py::build_rdp_params()`, `client.sendSize()`
bereits in `rdp.js` aufgerufen mit Debounce) -- F.3.3 kann direkt darauf
aufbauen, keine Server-Aenderung noetig.
* **GET /admin/sessions + Beenden-Logik** (`app/admin/routes.py`) als
Referenzmuster fuer die in F2 zu bauende, auf `user_id=eigene`
beschraenkte Variante im Katalog-Router.
## 1) Stufe F1 -- FERTIG (terminal.js/rdp.js zu instanzierbaren Bausteinen)
**Ziel (F.3.1):** aus zwei Dokument-Singletons (ein Skript pro Seite, feste
Element-IDs) werden zwei Klassen mit genau einer Instanz pro Sitzung:
`connect()`, `dispose()`, `setActive(bool)`, ein Statusrueckruf fuer eine
kuenftige Seitenleiste. Verhalten danach unveraendert (reiner Umbau).
### Umgesetzt
* **`static/js/terminal.js`**: `class SshTerminalSession` -- baut ihr
komplettes DOM (Toolbar, Terminal-Container, Dateitransfer-Panel) selbst
per `innerHTML`-Template und `document.createElement`, referenziert intern
ausschliesslich ueber Klassen (`.session-shell`, `.status`, `.ft-overlay`
usw. -- keine IDs mehr fuer die interne Verdrahtung, mit Ausnahme von zwei
dynamisch generierten, instanzeindeutigen IDs fuer die
`<label for>`-Kopplung im Dateitransfer-Panel). Konstruktor baut nur auf;
`connect()` oeffnet den WebSocket getrennt davon (damit eine kuenftige
Seitenleiste eine Kachel erst anzeigen und danach verbinden kann).
`dispose()` ist idempotent: schliesst WebSocket + xterm.js-Instanz,
entfernt dokumentweite Resize-/Fullscreen-Listener und das eigene
DOM-Element. `setActive(bool)` macht bei genau einer Instanz (heutiger
Stand) nur ein Refit+Fokus -- die eigentliche Mehrfach-Instanz-Wirkung
kommt erst in Stufe F4.
* Tastatur-Sicherheit (F.3.2) ist fuer SSH bereits strukturell gegeben:
xterm.js haengt seine Tastatur-Erfassung an sein EIGENES
Containerelement, nicht an `document` -- das in F.3.2 beschriebene
Risiko ("Tastendruck landet auf drei Servern") betrifft nur RDP.
* **`static/js/rdp.js`**: `class RdpSession`, analog aufgebaut (Toolbar,
Anzeigeflaeche, Fehler-Overlay als eigenes DOM-Fragment). Da
guacamole-common-js' `Guacamole.Keyboard` nur an `document` gebunden
werden kann (keine saubere Abmeldung/Umschaltung in der Bibliothek),
filtert jede Instanz Tastatur-/Paste-Weiterleitung ueber `this._active`
(gesetzt durch `setActive()`) -- das ist die Grundlage fuer F.3.2, aber
noch NICHT die vollstaendige Loesung (fehlende Modifiertasten-Ruecksetzung
beim Umschalten folgt in Stufe F4, wenn es ueberhaupt zwei gleichzeitige
Instanzen geben kann). Bei genau einer Instanz (heutiger Stand:
Stufe F1/F2/F3) ist das Verhalten exakt wie vorher. `dispose()` hat eine
dokumentierte Einschraenkung: der zugrundeliegende `document`-Listener von
`Guacamole.Keyboard` bleibt technisch bestehen (keine Bibliotheks-API zum
Abmelden), sendet aber nach `dispose()` nichts mehr, da `_active=false`
bleibt -- folgenlos bei einer Instanz, muss bei Stufe F4
(Kachel-Schliessen mit mehreren offenen Sitzungen) erneut betrachtet
werden.
* **`templates/terminal.html`, `templates/rdp.html`**: auf duennen Rahmen
reduziert -- nur noch `<div id="session-container" data-host-id="{{ host_id }}">`
plus die Script-Includes. Die Seite baut kein Sitzungs-DOM mehr selbst;
`terminal.js`/`rdp.js` erkennen `#session-container` beim Laden (Bootstrap-
Block am Dateiende), erzeugen GENAU EINE Instanz, haengen deren
`.element` ein und rufen `connect()` + `setActive(true)` auf. Eine
kuenftige Arbeitsflaeche `/workspace` (Stufe F3) hat kein Element mit
dieser ID -- der Bootstrap-Block bleibt dort wirkungslos und baut ihre
Instanzen stattdessen selbst; **keine zweite Implementierung** (F.2,
letzter Absatz, ausdruecklich eingehalten).
* **`static/css/app.css`**: `#terminal, #rdp-display` (dokumentweit
eindeutige IDs, passten zum alten Ein-Instanz-Modell) zu
`.session-terminal, .session-rdp-display` (Klassen, da das DOM jetzt pro
Instanz dynamisch gebaut wird und mit mehreren gleichzeitigen Instanzen
in Stufe F4 IDs nicht mehr eindeutig waeren). Alle anderen betroffenen
Selektoren (`.session-shell`, `.session-toolbar`, `.ft-overlay`,
`.rdp-error-overlay` usw.) waren bereits klassenbasiert, keine Aenderung
noetig.
* **`tests/test_csp_compliance.py`**: Docstring-Korrektur bei
`test_session_pages_expose_host_id_via_data_attribute` (data-host-id sitzt
jetzt auf `#session-container`, nicht mehr auf `#session-shell` -- die
eigentliche Assertion pruefte nur den rohen `data-host-id="..."`-String
und war davon nicht betroffen, blieb aber inhaltlich unpraezise).
### Bewusst NICHT in Stufe F1 (folgt in spaeteren Stufen)
* F.3.2 (vollstaendige Fokus-/Tastaturtrennung bei RDP inkl.
Modifiertasten-Reset) -- Grundlage gelegt (`_active`-Gate), vollstaendig
erst relevant/loesbar mit echten Mehrfach-Instanzen in F4.
* F.3.3 (Refit erst beim Sichtbarwerden einer ausgeblendeten Kachel) --
betrifft nur die kuenftige Seitenleiste, in F1 gibt es noch keine
ausgeblendeten Instanzen.
* F.3.6 (eigene Sitzungs-API im Katalog-Router) -- Stufe F2.
* `/workspace` selbst -- Stufe F3.
### Verifikation
* `node --check static/js/terminal.js` und `static/js/rdp.js` -- beide
syntaktisch fehlerfrei.
* Vollstaendiger Grep nach allen vormaligen IDs (`session-shell`,
`fullscreen-btn`, `exit-btn`, `filetransfer-btn`, `ctrlaltdel-btn`,
`rdp-error*`, `ft-overlay`, `ft-status`, `ft-list`) VOR der Aenderung
ergab: nur `terminal.js`/`rdp.js`/`terminal.html`/`rdp.html`/`app.css`
referenzieren sie direkt; `templates/admin.html` nutzt dieselbe
`.ft-overlay`-CSS-Klasse fuer zwei eigene, unabhaengige Overlays
(`#rec-overlay`, `#watch-overlay` -- Sitzungswiedergabe/Live-Mitschau im
Adminbereich) mit eigenen IDs, keine Kopplung an terminal.js/rdp.js-Intern
-- keine weiteren Anpassungen noetig.
* Echter `pytest`-Lauf (4-Gruppen-Split wie in Teil C/D etabliert, ueber die
device_bash-Bridge mit funktionierendem venv/pip):
`test_csp_compliance.py` einzeln: **10/10 bestanden**. Voller
Regressionslauf ueber alle 19 Testdateien:
| Gruppe | Dateien | Ergebnis |
|---|---|---|
| 1 | admin_crud, admin_groups_tokens, audit_chain, auth_flow, csp_compliance | 39 passed, 1 failed |
| 2 | passwords_totp, pentest_security, phase9, phase10, phase11 | 44 passed, 2 failed |
| 3 | phase12, phase13, phase14, phase15 | 34 passed, 13 failed |
| 4a | rbac, teil_d_schritt5, teil_d_schritt6 | 11 passed, 0 failed |
| 4b | session_reaper | 1 passed, 2 failed |
| 4c | session_recorder | 5 passed, 0 failed |
| **Summe** | | **134 passed, 18 failed, 152 total** |
**Exakt dieselben Zahlen wie am Ende von Teil D** (siehe
`FORTSETZUNG_Teil_D.md` Abschnitt 1d) -- die 18 Fehlschlaege sind
einzeln stichprobenartig gegengeprueft (u.a.
`test_user_update_deactivate_and_delete_semantics` [Bug #7],
`test_password_change_invalidates_old_session_everywhere` [Bug #6,
sicherheitsrelevant, bewusst unangetastet], `test_build_rdp_params_...`
[Bug #2, `session_id`-Keyword], `test_reap_closes_only_open_sessions`
[„cannot start a transaction within a transaction"]) und stammen
unveraendert aus dem bereits dokumentierten 18-Bug-Backlog aus Teil C.
**Stufe F1 hat null neue Testfehlschlaege verursacht** -- reiner,
verhaltensneutraler Umbau bestaetigt.
* Kein manueller Browser-Test in dieser Session moeglich (keine laufende
Instanz mit echtem SSH-/RDP-Ziel in der Sandbox) -- die Aequivalenz
Alt-/Neu-Verhalten stuetzt sich auf: (a) den unveraenderten Testlauf oben
(die Test-Suite hat keine dedizierten JS-Tests fuer
terminal.js/rdp.js -- diese Luecke bestand bereits vorher), (b) die
Codegegenueberstellung Zeile fuer Zeile (jede Funktion des alten IIFE hat
eine 1:1-Entsprechung als Methode in der neuen Klasse, keine Logik
geaendert, nur Referenzierung von globalen IDs auf `this.xxx`
umgestellt). Empfehlung fuer eine kuenftige Session mit Zugriff auf einen
echten SSH-/RDP-Testhost: manueller Rauchtest von `/terminal/{id}` und
`/rdp/{id}` vor dem Weiterbau zu Stufe F3/F4.
---
## 2) Stufe F2 -- FERTIG (eigene Sitzungs-API im Katalog-Router)
**Ziel (F.3.6):** eine Liste der eigenen offenen Sitzungen und ein Endpunkt
zum Beenden einer eigenen Sitzung -- zwingend auf `user_id = <eigene>`
beschraenkt, NICHT ueber `GET /admin/sessions` (zeigt fremde Sitzungen,
Benutzernamen, Aufzeichnungspfade).
### Umgesetzt
* **Migration `0020_session_user_ended_index.sql`**: zusammengesetzter Index
`idx_sessions_user_ended ON sessions(user_id, ended_at)` -- deckt per
SQLite-Index-Praefix-Regel sowohl "alle eigenen Sitzungen" als auch
"eigene noch offene Sitzungen" ab. `idx_sessions_user` (nur `user_id`,
aus 0001) bewusst nicht geloescht (Projektkonvention, siehe 0019) -- wird
weiterhin von anderen Abfragen (u.a. `GET /admin/sessions`) genutzt.
* **`app/catalog/routes.py`**: `GET /catalog/sessions` (Parameter
`active_only`, Standard `true`) und `POST /catalog/sessions/{id}/terminate`.
Beide Endpunkte filtern hart auf `user.id` -- sowohl beim DB-Zeilenzugriff
als auch (beim Beenden) zusaetzlich bei der `active_sessions`-Registry
(`entry.user_id != user.id` als zweite, unabhaengige Pruefung). Eine
fremde ODER nicht existierende `session_id` liefert in BEIDEN Faellen 404
(nicht 403 -- ein abweichender Code wuerde die Existenz einer fremden
Sitzung verraten). Bereits beendete eigene Sitzung erneut beenden -> 409.
Erfolgreiches Beenden schreibt ein Audit-Ereignis
`session_terminated_by_owner` (Pendant zu `session_terminated_by_admin`
im Admin-Router) und committet.
* **`tests/test_teil_f_schritt2.py`** (4 Tests, alle bestanden): eigene vs.
fremde Sitzungen in der Liste; `active_only`-Filterung; **Rechteumgehungs-
Testfall aus der F.5-Risikotabelle** (`POST .../terminate` auf fremde
`session_id` -> 404, nicht 403/200); echtes Beenden mit `asyncio.Task`
(Cancellation wird abgewartet, nicht nur "einmal nachgeben") inkl.
Audit-Eintrag- und Hash-Ketten-Pruefung; 409 bei bereits beendeter eigener
Sitzung.
### Verifikation
* `py_compile` fuer `app/catalog/routes.py` und den neuen Testfile.
* `tests/test_teil_f_schritt2.py` einzeln: **4/4 bestanden**.
* Voller Regressionslauf ueber alle 20 Testdateien (19 aus F1 + der neue
F2-Testfile), erneut im 6-Gruppen-Split wegen der device_bash-Zeitgrenze:
| Gruppe | Ergebnis |
|---|---|
| admin_crud/admin_groups_tokens/audit_chain/auth_flow/csp_compliance/phase9 | 46 passed, 1 failed |
| passwords_totp/pentest_security/phase10/phase11 | 37 passed, 2 failed |
| phase12/phase13 | 22 passed, 6 failed |
| phase14/phase15 | 12 passed, 7 failed |
| rbac/teil_d_schritt5/teil_d_schritt6/session_recorder | 16 passed, 0 failed |
| session_reaper | 1 passed, 2 failed |
| teil_f_schritt2 (neu) | 4 passed, 0 failed |
| **Summe** | **138 passed, 18 failed, 156 total** |
156 = 152 (Stand nach Teil D/F1) + 4 neue F2-Tests. Die 18 Fehlschlaege
sind unveraendert exakt dieselben wie nach F1 (u.a. wieder
`test_user_update_deactivate_and_delete_semantics`,
`test_password_change_invalidates_old_session_everywhere`,
`test_build_rdp_params_...`, die vier `test_rdp_params_...` aus
phase12, `test_session_reaper.py` mit der bekannten
Transaktions-Verschachtelungs-Meldung). Migration 0020 wurde in JEDEM
Testlauf sichtbar sauber angewendet (Log-Zeile "Wende Migration an:
0020_session_user_ended_index.sql"), `PRAGMA foreign_key_check` (Teil der
Migrations-Pipeline, haelt bei Verletzung hart an) lief in keinem Lauf an.
**Stufe F2 hat null neue Testfehlschlaege verursacht.**
## 3) Stufe F3 -- FERTIG (Arbeitsflaeche /workspace, eine Sitzung gleichzeitig)
**Ziel (F.3, F.4-Tabelle):** dauerhafte Arbeitsflaeche mit Seitenleiste;
diese Stufe haelt bewusst nur EINE verbundene Sitzung gleichzeitig (F4 baut
"mehrere gleichzeitig, Umschalten" darauf auf, siehe F.4-Stufentabelle).
### Umgesetzt
* **`app/main.py`**: `GET /workspace` -- wie `/dashboard`/`/admin` reines
statisches Markup, Auth clientseitig ueber `GET /auth/me`.
* **`templates/workspace.html`**: Topbar (Benutzername, Link zurueck zum
Dashboard, Abmelden) + zweispaltiges Layout (Seitenleiste/Hauptbereich).
Bindet `terminal.js` UND `rdp.js` gleichzeitig ein -- deren
Bootstrap-Bloecke (siehe F1) bleiben hier wirkungslos, da kein Element mit
ID `session-container` existiert; `workspace.js` erzeugt Instanzen
ausschliesslich selbst. Enthaelt einen staendig sichtbaren Hinweis in der
Seitenleiste, dass ein Reload jede offene Sitzung beendet (F.3.7 /
Betreiberentscheidung #8 -- **klar beschriftet statt verschwiegen**, siehe
Abschnitt 0 Punkt 3). Bewusst KEIN `beforeunload`-Bestaetigungsdialog
(native Browser-Warnung) zusaetzlich eingebaut -- der Dauerhinweis in der
Seitenleiste wurde als ausreichend fuer "klar beschriftet" bewertet; ein
Reload-Warndialog waere eine zusaetzliche UX-Entscheidung, die die Spec
nicht verlangt und die eine kuenftige Session bei Bedarf ergaenzen kann.
* **`static/js/workspace.js`**: orchestriert drei Zustaende im Hauptbereich
(Hostkatalog / eine aktive Sitzung / Wechsel-Bestaetigung) und die
Seitenleiste (Kacheln). Kernentscheidungen:
* **Kein Server-seitiges "welche Sitzung bin ich"-Signal noetig:** die
aktive Kachel im eigenen Tab wird direkt aus dem lokalen
`SshTerminalSession`/`RdpSession`-Objekt gespeist (Status-Callback aus
F1, Klick-Handler ruft direkt `dispose()`) -- kein Abgleich mit der
server-seitigen `session_id` noetig, da terminal_ws.py/ws_tunnel.py
diese ID bislang nirgends an den Client zurueckmelden (kein Frame dafuer
im Protokoll). **Bewusste Vereinfachung, dokumentiert als Limitierung**
unten.
* **`GET /catalog/sessions` (F2) wird NUR beim Laden der Seite und direkt
nach dem Beenden der eigenen Sitzung abgefragt**, nicht fortlaufend
waehrend eine eigene Sitzung aktiv ist. Vermeidet dadurch das Problem,
die eigene (im DOM bereits als "aktive Kachel" dargestellte) Sitzung von
denselben Zeilen in der API-Antwort unterscheiden zu muessen (kein
Duplikat-Risiko an den beiden Abfragezeitpunkten, da die eigene Sitzung
dort entweder noch nicht existiert oder bereits beendet ist). **Kehrseite
(dokumentierte Limitierung):** Sitzungen, die WAEHREND eine eigene
Sitzung offen ist in einem ANDEREN Tab/Fenster geoeffnet oder beendet
werden, erscheinen/verschwinden in dieser Seitenleiste nicht live --
erst nach dem naechsten Laden/Beenden. Kein Polling in dieser Stufe
eingebaut (bewusst, um die Sitzungs-Identitaets-Frage nicht ungeloest
mitzuschleppen); ein spaeterer periodischer Abgleich (mit echtem
Sitzungs-ID-Abgleich, s.u.) ist Backlog fuer F4/F5.
* **Sitzungswechsel ist ehrlich, nicht still:** ein Klick auf "Verbinden"
bei bereits offener eigener Sitzung fuehrt NICHT automatisch zum
Trennen -- ein eigenes Bestaetigungspanel (`.banner.error` +
Beenden-und-wechseln/Abbrechen, kein natives `confirm()` -- passt zum
bestehenden Stil des Projekts, das durchgehend eigene Overlays statt
Browser-Dialoge nutzt) erklaert, dass diese Stufe nur eine gleichzeitige
Sitzung haelt.
* `?host=<id>` (von `dashboard.js` gesetzt) verbindet beim Laden direkt,
ohne den Katalog zwischenzuschalten.
* **`static/js/dashboard.js`**: "Verbinden" fuehrt jetzt zu
`/workspace?host={id}` statt direkt zu `/terminal/{id}`/`/rdp/{id}`.
Weiterhin `target="_blank"` (eigener Tab) -- das Dashboard bleibt dadurch
erreichbar, waehrend die Arbeitsflaeche laeuft; von dort fuehrt der
Topbar-Link "Dashboard" zurueck. Die duennen Rahmen `/terminal/{id}`,
`/rdp/{id}` selbst bleiben unveraendert erreichbar (F.2: Lesezeichen/
Fehlersuche).
* **`static/css/app.css`**: neue `.workspace-*`-Regeln (Seitenleiste,
Kacheln, Statuspunkt-Farben per Klasse, Hauptbereich). Wichtig:
`.workspace-main .session-instance .session-shell { height: 100%; }`
ueberschreibt die `height: 100vh`-Regel aus F1 (dort fuer den
Ein-Instanz-Vollseiten-Fall gedacht) -- sonst wuerde die Sitzung in der
Arbeitsflaeche die Seitenleiste ueberdecken.
* **Bewusst NICHT in F3 uebernommen** (gehoert nicht zum Katalog-Funktions-
umfang von F.3): die "Zugangsdaten anzeigen"-Funktion des Dashboards
(`showCredentials()` in `dashboard.js`) wurde NICHT in den
Arbeitsflaechen-Hostkatalog uebernommen -- bleibt eine reine
Dashboard-Funktion. Spart Komplexitaet, ohne eine F.3-Anforderung zu
verfehlen (die verlangt nur, dass "Neue Sitzung" den Hostkatalog zum
Verbinden zeigt).
### Bekannte Limitierung (fuer F4/F5 vorzumerken)
Der Server meldet die von ihm vergebene `session_id` (SSH: `terminal_ws.py`,
RDP: `ws_tunnel.py`) aktuell an keiner Stelle an den Client zurueck -- weder
per JSON-Frame (SSH-Protokoll koennte das leicht ergaenzen) noch per
Guacamole-Instruktion (RDP-Protokoll ist reines Guacamole-Binaerprotokoll,
eine eigene Instruktion dort einzuschleusen waere riskanter, da
guacamole-common-js nur gueltige Guacamole-Instruktionen erwartet). F3 kommt
bewusst ohne diese ID aus (s.o.). Sollte F4/F5 einen echten Abgleich
"welche Kachel entspricht welcher Server-Sitzung" brauchen (z.B. fuer
Live-Polling waehrend mehrerer offener Sitzungen), muss zuerst entschieden
werden, ob/wie die SSH- und RDP-Wege dafuer je einen zusaetzlichen
Mechanismus bekommen -- **Betreiberentscheidung/Design-Frage fuer eine
kuenftige Session, nicht in dieser Stufe geloest.**
### Verifikation
* `node --check` fuer `workspace.js` -- fehlerfrei.
* **Kein manueller Browser-/SSH-/RDP-Rauchtest moeglich** (siehe bereits bei
F1 dokumentierte Einschraenkung -- keine echte SSH-/RDP-Zielumgebung in
dieser Sandbox). Die Arbeitsflaeche selbst (Seitenleiste, Katalogwechsel,
Wechsel-Bestaetigung) wurde NICHT im echten Browser gesehen -- nur durch
Codelesen/CSP-Tests/Backend-Tests abgesichert. **Empfehlung unveraendert:
vor dem Weiterbau zu F4 einen manuellen Rauchtest mit echtem SSH-/RDP-Ziel
durchfuehren.**
* **`tests/test_teil_f_schritt3.py`** (2 Tests, neu): `GET /workspace`
liefert die erwarteten Bausteine (Seitenleiste, Hauptbereich, Neue-
Sitzung-Knopf, terminal.js/rdp.js/workspace.js eingebunden, KEIN
`#session-container` -- sonst wuerde der F1-Bootstrap-Block automatisch
eine Sitzung starten); `/catalog/hosts` und `/catalog/sessions` bleiben
fuer einen frisch angemeldeten Benutzer erreichbar (leere Sitzungsliste).
* **`tests/test_csp_compliance.py`**: `/workspace` in die
`test_rendered_pages_contain_no_inline_style_or_script`-Parametrisierung
aufgenommen (jetzt 11 statt 10 Tests in der Datei) -- kein Inline-
style/-script auf der neuen Seite.
* Voller Regressionslauf ueber alle 22 Testdateien (20 aus F1+F2 + der neue
F3-Testfile, `test_csp_compliance.py` mit einem zusaetzlichen
Parametrisierungsfall):
| Gruppe | Ergebnis |
|---|---|
| admin_crud/admin_groups_tokens/audit_chain/auth_flow/phase9 | 36 passed, 1 failed |
| passwords_totp/pentest_security/phase10/phase11 | 37 passed, 2 failed |
| phase12/phase13 | 22 passed, 6 failed |
| phase14/phase15 | 12 passed, 7 failed |
| rbac/teil_d_schritt5/teil_d_schritt6/teil_f_schritt2/session_recorder | 20 passed, 0 failed |
| session_reaper | 1 passed, 2 failed |
| csp_compliance (11) + teil_f_schritt3 (2, neu) | 13 passed, 0 failed |
| **Summe** | **141 passed, 18 failed, 159 total** |
159 = 156 (Stand nach F2) + 2 neue F3-Tests + 1 neuer Parametrisierungsfall
in `test_csp_compliance.py`. Die 18 Fehlschlaege sind wieder unveraendert
dieselben wie nach F1/F2. **Stufe F3 hat null neue Testfehlschlaege
verursacht.**
## 4) Stufe F4 -- FERTIG (mehrere gleichzeitige Sitzungen, Umschalten, Fokus-/Tastaturtrennung)
**Ziel (F.3.2, F.3.3):** mehrere Sitzungen gleichzeitig offen halten,
zwischen ihnen umschalten ohne Verbindungsabbruch, Eingaben landen nur in
der sichtbaren Sitzung, Groesse stimmt nach jedem Umschalten.
### Geloeste Design-Frage aus F3 (Server-Sitzungs-ID-Zuordnung)
F3 hatte offen gelassen, wie eine Kachel im Browser einer server-seitigen
`sessions`-Zeile zugeordnet wird, da `terminal_ws.py`/`ws_tunnel.py` die
`session_id` an keiner Stelle an den Client zurueckmelden. Entscheidung
dieser Stufe: **kein Backend-Protokoll-Eingriff** (ein SSH-JSON-Frame waere
zwar risikoarm gewesen, aber eine passende Aenderung am RDP-Weg haette eine
eigene Instruktion in den Guacamole-Binaerprotokoll-Tunnel einschleusen
muessen -- ohne echten Browser/RDP-Testaufbau in dieser Sandbox als zu
riskant bewertet, um es ungetestet zu aendern). Stattdessen: **`static/js/
workspace.js::claimServerSessionId()`** -- nach `instance.connect()` ruft
jede lokal geoeffnete Sitzung bis zu 5x (250ms Abstand) `GET
/catalog/sessions?active_only=true` ab und beansprucht die juengste,
Host-ID-passende, noch nicht beanspruchte Zeile fuer sich
(`claimedSessionIds`-Set). Damit werden eigene Kacheln korrekt aus der
"laeuft anderswo"-Liste herausgefiltert -- **das Duplikat-/Verwechslungs-
Risiko, das F3 noch offen liess, ist damit fuer den Normalfall geloest.**
Dokumentiertes Restrisiko: ein echter Wettlauf (zwei Tabs oeffnen fast
zeitgleich eine Sitzung zu genau demselben Host) koennte die Zuordnung in
seltenen Faellen vertauschen -- rein kosmetisch (betrifft nur, welche
Kachel als "eigene" vs. "anderswo" angezeigt wird, nicht Zugriffsrechte:
Beenden bleibt ueber `POST /catalog/sessions/{id}/terminate`, Stufe F2,
weiterhin hart auf den eigenen Benutzer beschraenkt, unabhaengig von dieser
Heuristik).
### Umgesetzt
* **`static/js/workspace.js`** komplett auf Mehrfach-Sitzungen umgebaut:
* `openSessions`-Array statt einer einzelnen `activeSession`-Variable;
jede Instanz bleibt nach dem Erzeugen dauerhaft in
`#workspace-sessions-area` im DOM (wird nie durch Umschalten entfernt,
nur ein-/ausgeblendet) -- erst `dispose()` (Sitzung schliessen) entfernt
sie wirklich.
* `showSession(uid)`: blendet alle anderen Instanzen aus (`.hidden`-Klasse,
app.css, derselbe CSP-konforme Sichtbarkeits-Toggle wie ueberall im
Projekt) und die Zielinstanz ein, ruft `setActive(true)` auf ihr und
`setActive(false)` auf allen anderen -- genau EIN `_active=true`
gleichzeitig.
* `showCatalog()`: blendet ALLE Sitzungen aus (`setActive(false)` auf
jede), zeigt den Hostkatalog -- Sitzungen bleiben dabei voll verbunden
im Hintergrund (Betreiberentscheidung #7).
* Der bisherige F3-"Wechsel-Bestaetigung"-Dialog (eine Sitzung ersetzt die
andere) entfaellt ersatzlos -- F4 erlaubt echte Koexistenz, "Verbinden"
im Katalog oeffnet jetzt einfach eine zusaetzliche Kachel.
* Sidebar-Kacheln unterscheiden jetzt drei Zustaende optisch:
`workspace-tile-active` (gerade sichtbar), `workspace-tile-background`
(offen, verbunden, aber ausgeblendet -- anklickbar zum Umschalten,
inkl. Tastatur-Bedienung ueber `tabindex`/Enter/Leertaste),
`workspace-tile-remote` (laeuft in einem anderen Tab, unveraendert aus
F3).
* **`static/js/rdp.js`** (F.3.2, Fokus-/Tastatursicherheit):
`setActive(active)` ruft jetzt `this.keyboard.reset()`
(guacamole-common-js-API, dieselbe Methode, die der offizielle Client bei
Fensterfokusverlust nutzt -- per Feature-Check `typeof ... ===
"function"` abgesichert) sowohl beim Deaktivieren als auch defensiv beim
Aktivieren auf, damit keine haengengebliebenen Modifiertasten (Strg/Alt)
von einer verlassenen Sitzung in die naechste durchschlagen. Die
eigentliche Weiterleitungs-Sperre (`this._active`-Gate in
`keyboard.onkeydown`/`onkeyup`/dem `paste`-Listener) stammt bereits aus
F1 und musste hier nicht mehr geaendert werden -- sie greift jetzt einfach
echt, weil erstmals mehrere `RdpSession`-Instanzen gleichzeitig existieren
koennen.
* **`static/js/rdp.js` und `static/js/terminal.js`** (F.3.3, Refit beim
Sichtbarwerden): der dokumentweite `resize`/`fullscreenchange`-Listener
jeder Instanz reagiert jetzt NUR noch, wenn `this._active` wahr ist --
vorher haette ein Browser-Resize bei mehreren gleichzeitig existierenden,
aber ausgeblendeten (`display:none`, Groesse 0) Instanzen `fitAddon.fit()`
bzw. `sendSize()` mit bedeutungslosen Nullgroessen ausgeloest. Der
eigentliche Refit beim Umschalten passiert weiterhin explizit in
`setActive(true)` (unveraendert aus F1: `sendResize()` bzw.
`_sendCurrentSize()`).
* **`templates/workspace.html`**: `#workspace-main` in zwei feste
Unterbereiche aufgeteilt -- `#workspace-catalog-view` und
`#workspace-sessions-area` -- statt vorher `innerHTML` bei jedem
Zustandswechsel neu zu schreiben (das haette in F4 alle offenen
Sitzungs-Elemente mitsamt ihrer aktiven WebSockets zerstoert).
* **`static/css/app.css`**: `.workspace-tile-background`/`-remote`-Stile;
`.workspace-sessions-area .session-instance` (statt vorher direkt auf
`.session-instance` im Hauptbereich) fuer die Hoehe; der F3-Uebergangsstil
fuer die Wechsel-Bestaetigung (`.workspace-replace-confirm` usw.) entfernt,
da der Dialog entfaellt.
### Bewusst NICHT in Stufe F4 (folgt in F5)
* F.3.8 (Obergrenze in der Oberflaeche sichtbar -- "Neue Sitzung"
deaktivieren) -- ein Ueberschreiten zeigt sich in F4 nur reaktiv als
Fehlerstatus auf der neuen Kachel (Server lehnt mit WS-Code 4429 ab, siehe
`app/ssh_proxy/terminal_ws.py`/`app/rdp_proxy/ws_tunnel.py`, E.4 aus Teil
E) -- funktional korrekt, aber nicht die von F.3.8 verlangte
*vorherige* Kennzeichnung.
* Live-Aktualisierung der Seitenleiste fuer Sitzungen, die in ANDEREN Tabs
waehrend einer laufenden eigenen Sitzung geoeffnet/beendet werden (siehe
oben, unveraendert aus F3 -- die Sitzungs-ID-Zuordnung loest das
Duplikat-Problem, aber es wird weiterhin nicht periodisch neu abgefragt).
### Verifikation
* `node --check` fuer `terminal.js`, `rdp.js`, `workspace.js`, `dashboard.js`
-- alle vier fehlerfrei.
* **Weiterhin kein manueller Browser-/SSH-/RDP-Rauchtest moeglich** (keine
echte Zielumgebung in dieser Sandbox). Das betrifft F4 staerker als F1-F3,
weil hier die eigentliche, in der F.5-Risikotabelle als groesstes Risiko
benannte Eigenschaft entsteht ("Eingaben landen in der falschen
Sitzung"). Die Absicherung stuetzt sich in dieser Stufe ausschliesslich
auf Codelesen/-gegenueberstellung (SSH: `xterm.js` bindet ohnehin an sein
eigenes Containerelement, kein Aenderungsbedarf; RDP: das bereits in F1
angelegte `_active`-Gate plus das neue `keyboard.reset()`) -- **dringende
Empfehlung fuer die naechste Session mit Zugriff auf echte SSH-/RDP-Ziele:
das F.6-Abnahmeszenario (vier Sitzungen, mehrfach umschalten, 30 Minuten
offen lassen) tatsaechlich im Browser durchspielen, bevor F5 als
"fertig" gilt.**
* Keine neuen Backend-Testdateien in dieser Stufe (F4 ist eine reine
Frontend-/JS-Aenderung ohne neue Endpunkte, Migrationen oder
Schema-Aenderungen -- die bestehenden F1-F3-Tests decken weiterhin
denselben Backend-Vertrag ab, auf dem `workspace.js` aufbaut). Voller
Regressionslauf ueber alle 22 Testdateien (unveraendert gegenueber F3):
| Gruppe | Ergebnis |
|---|---|
| csp_compliance/teil_f_schritt2/teil_f_schritt3 | 17 passed, 0 failed |
| admin_crud/admin_groups_tokens/audit_chain/auth_flow/phase9 | 36 passed, 1 failed |
| passwords_totp/pentest_security/phase10/phase11 | 37 passed, 2 failed |
| phase12/phase13 | 22 passed, 6 failed |
| phase14/phase15 | 12 passed, 7 failed |
| rbac/teil_d_schritt5/teil_d_schritt6/session_recorder | 16 passed, 0 failed |
| session_reaper | 1 passed, 2 failed |
| **Summe** | **141 passed, 18 failed, 159 total** |
Identische Zahlen wie am Ende von F3 (159 Tests insgesamt, dieselben 18
bereits bekannten Fehlschlaege) -- erwartungsgemaess, da F4 keine
Backend-/Testdatei-Aenderungen enthielt. **Stufe F4 hat null neue
Testfehlschlaege verursacht**, wohlgemerkt bei einer Stufe, deren
eigentliche Kernaussage (Eingabe-Isolation) mit den vorhandenen
Backend-Tests strukturell gar nicht pruefbar ist -- siehe
Rauchtest-Empfehlung oben.
## 5) Stufe F5 -- FERTIG
Ziel (F.4 der Spec-Tabelle, Cowork-Task #21): Obergrenzen in der
Oberflaeche sichtbar machen (F.3.8), Sammelzustand-Kennzeichnung bei
Hintergrundfehlern/-trennungen, Feinschliff.
### F.3.8 -- Obergrenzen sichtbar
Bisher (F1-F4) war die Durchsetzung der Obergrenzen (Teil E.4,
`app/ssh_proxy/terminal_ws.py`/`app/rdp_proxy/ws_tunnel.py`, VOR jedem
Ressourcenverbrauch geprueft, WS-Code 4429 bei Ueberschreitung) bereits
vollstaendig vorhanden und wirksam -- F.3.8 verlangt zusaetzlich, dass die
Grenze VOR dem Verbindungsversuch sichtbar ist, nicht erst als
Fehlerstatus danach.
* **`app/catalog/routes.py`**: neuer Endpunkt `GET /catalog/session-limits`
(Abschnitt "Sitzungs-Obergrenzen (F5, Teil F.3.8)"). Liest ausschliesslich
bereits vorhandene Quellen -- `app/config.py` (`max_sessions_per_user`,
`max_sessions_global`) und `app/security/active_sessions.py`
(`count_for_user()`, `count_total()`) -- und bildet **exakt dieselbe
Schwellenlogik** wie die Durchsetzung nach: Admins sind von der
Je-Nutzer-Grenze ausgenommen (`user_limit_applies`/`at_user_limit` nur bei
`not user.is_admin`), nicht aber von der globalen Grenze
(`at_global_limit` gilt fuer jeden). Absichtlich keine neue
Zustandsmaschine, keine Verdopplung der Durchsetzung -- reine
Lesefunktion. Liefert ausschliesslich aggregierte Zahlen, keine
Details zu fremden Sitzungen (user_id/hostname anderer Nutzer bleiben
wie beim gesamten Katalog-Router unsichtbar, siehe Moduldoc).
* **`static/js/workspace.js`**: `refreshSessionLimits()`/`atAnyLimit()`/
`renderLimitHint()` -- ruft den neuen Endpunkt beim Laden (`main()`), nach
dem Start einer Sitzung und nach dem Schliessen (jeweils mit derselben
300ms-Verzoegerung wie der bestehende `refreshRemoteSessions()`-Aufruf,
da `active_sessions.register()`/`unregister()` serverseitig erst nach dem
WS-Handshake bzw. nach dem `finally`-Block laufen). `renderLimitHint()`
zeigt "Eigene Sitzungen: X/Y" (nur wenn die Grenze fuer diesen Nutzer
gilt) und "Server gesamt: X/Y" im Sidebar-Header und deaktiviert
`+ Neue Sitzung`, sobald eine der beiden Grenzen erreicht ist.
`hostCard()` deaktiviert zusaetzlich jeden einzelnen
"Verbinden"-Knopf im Hostkatalog unter derselben Bedingung.
Serverseitige Durchsetzung (WS-Code 4429) bleibt unveraendert die
verbindliche Kontrolle -- diese Anzeige ist bewusst nur informativ und
kann durch die Race zwischen Anzeige-Refresh und tatsaechlichem
Verbindungsaufbau kurzzeitig veralten (dieselbe Klasse von
Eventual-Consistency wie bei `claimServerSessionId` aus F4).
* **`templates/workspace.html`**: neues Element
`<p id="workspace-limit-hint">` im Sidebar-Header.
* **`static/css/app.css`**: `.workspace-limit-hint--at-limit` (Warnfarbe bei
erreichter Grenze) sowie ein neues globales
`button:disabled { opacity: 0.5; cursor: not-allowed; }` (gab es vorher
nicht -- deaktivierte Knoepfe sahen bislang optisch aktiv aus).
### Sammelzustand-Kennzeichnung bei Hintergrundfehlern/-trennungen
* **`static/js/workspace.js`**: `hasAttention(record)` (Hilfsfunktion neben
`statusClass()`) erkennt eine Hintergrundsitzung mit Fehler- oder
Trennungsstatus. `buildLocalTile()` markiert eine solche Kachel zusaetzlich
mit der Klasse `workspace-tile-attention` und ersetzt den generischen Text
"im Hintergrund verbunden" durch "im Hintergrund: Fehler" bzw.
"im Hintergrund: getrennt". `renderSidebar()` fasst zusaetzlich alle
betroffenen Hintergrundsitzungen (die gerade aktive zaehlt nicht mit, ihr
Zustand ist ohnehin direkt sichtbar) in einer Sammel-Zeile ueber der
Kachelliste zusammen ("⚠ N Sitzungen im Hintergrund mit Problem"), damit
ein Problem nicht erst durch Umschalten auf jede einzelne Kachel entdeckt
wird.
* **`templates/workspace.html`**: neues Element
`<div id="workspace-attention-summary" class="hidden">`.
* **`static/css/app.css`**: `.workspace-tile-attention` (roter Rahmen) und
`.workspace-attention-summary` (Warnbanner-Stil).
### Feinschliff
* Globales `button:disabled`-Stil (siehe oben) -- kam durch F.3.8 zum
ersten Mal ueberhaupt im Projekt zum Einsatz (vorher gab es keine
deaktivierten Knoepfe in dieser Form), war aber vorher schlicht nicht
vorhanden und wird jetzt konsequent von allen drei neuen
deaktivierbaren Knoepfen (`+ Neue Sitzung`, jeder Katalog-"Verbinden") genutzt.
### Bewusst NICHT in Stufe F5
* Keine Aenderung an der serverseitigen Durchsetzung selbst (E.4) -- F5 ist
ausschliesslich Anzeige/UX auf Basis der bestehenden, bereits in
frueheren Sessions verifizierten Logik.
* Keine Push-/Live-Aktualisierung der Obergrenzen-Anzeige zwischen Tabs
(dieselbe bekannte Einschraenkung wie die Sitzungsliste selbst seit F3 --
kein Polling, nur bei den bereits vorhandenen Refresh-Zeitpunkten dieser
Seite).
### Verifikation
* `node --check` fuer `workspace.js` (auf dem Geraet) -- fehlerfrei.
`python3 -m py_compile` fuer `app/catalog/routes.py` -- fehlerfrei.
* Neue Testdatei `tests/test_teil_f_schritt5.py` (5 Tests, alle bestehen):
Obergrenzen entsprechen `app/config.py` bei leerer Registry;
`current_user_count` zaehlt ausschliesslich eigene Registry-Eintraege
(fremde Sitzung beeinflusst NUR den globalen, nicht den eigenen Zaehler
-- F.5-Risikotabelle-Prinzip wie in `test_teil_f_schritt2.py`);
`at_user_limit` wird bei Erreichen von `max_sessions_per_user` wahr;
die Admin-Asymmetrie aus `terminal_ws.py` (ausgenommen von der
Je-Nutzer-Grenze, NICHT von der globalen) wird exakt nachgebildet;
der Endpunkt verlangt eine Anmeldung (401 sonst).
* Voller Regressionslauf ueber alle 23 Testdateien:
| Gruppe | Ergebnis |
|---|---|
| csp_compliance/teil_f_schritt2/teil_f_schritt3/teil_f_schritt5 | 22 passed, 0 failed |
| admin_crud/admin_groups_tokens/audit_chain/auth_flow/phase9 | 36 passed, 1 failed |
| passwords_totp/pentest_security/phase10/phase11 | 37 passed, 2 failed |
| phase12/phase13 | 22 passed, 6 failed |
| phase14/phase15 | 12 passed, 7 failed |
| rbac/teil_d_schritt5/teil_d_schritt6/session_recorder | 16 passed, 0 failed |
| session_reaper | 1 passed, 2 failed |
| **Summe** | **146 passed, 18 failed, 164 total** |
Gegenueber F4 (141/18/159): +5 neue, bestehende Tests, dieselben 18
bereits bekannten, dokumentierten Fehlschlaege (siehe
`FORTSETZUNG_Teil_C.md`), **null neue Regressionen**.
## 6) Abnahme (F.6) -- Code-Stand fertig, Live-Szenario ausstehend
Wortlaut F.6 (Umsetzungsauftrag_Sonnet5.md): "Ein Benutzer oeffnet vier
Sitzungen (zwei SSH, zwei RDP) in der Arbeitsflaeche, schaltet mehrfach
zwischen ihnen um und laesst sie 30 Minuten offen. Danach gilt: alle vier
sind noch verbunden, in jeder ist die zuletzt gesehene Ausgabe vollstaendig
vorhanden, Eingaben landen ausschliesslich in der sichtbaren Sitzung, die
Groesse stimmt nach jedem Umschalten, es existieren genau vier
`sessions`-Zeilen mit vier Aufzeichnungen, und ein zweiter Benutzer arbeitet
waehrenddessen unbeeintraechtigt."
### Beim Vorbereiten dieses Abschnitts gefundene und behobene Luecke
Bei der Pruefung von "30 Minuten offen, alle vier noch verbunden" gegen den
tatsaechlichen Code fiel auf: **F.3.4 ("Hintergrundsitzungen duerfen nicht
sterben") war fuer eine echt untaetige SSH-Sitzung NICHT vollstaendig
erfuellt.** `app/config.py::ssh_idle_timeout_s` steht auf 900s (15 Minuten);
E10 (`app/ssh_proxy/terminal_ws.py`) zaehlt zwar sowohl Eingabe als auch
Zielausgabe als Aktivitaet (deckt z.B. `tail -f`/einen laufenden Build ab),
aber eine Sitzung mit einer wirklich stillen Shell (kein Tastendruck, keine
spontane Ausgabe) haette nach 15 Minuten den Idle-Timeout ausgeloest --
still im Hintergrund, waehrend die Kachel in der Seitenleiste weiter
"verbunden" zeigte. Das widerspricht direkt Betreiberentscheidung #7
("voll verbunden bleiben") und haette das F.6-Kriterium selbst beim
einfachsten denkbaren Testlauf (vier Sitzungen oeffnen, nichts weiter tun,
30 Minuten warten) zum Scheitern gebracht. RDP (`app/rdp_proxy/ws_tunnel.py`)
hat ueberhaupt keinen Idle-Timeout, ist also nicht betroffen.
Behoben rein clientseitig, ohne die serverseitige Logik zu aendern:
`static/js/terminal.js::SshTerminalSession.connect()` sendet ab jetzt alle
4 Minuten (deutlich unter den 15 Minuten) einen inhaltsleeren
`{"type": "ping"}` ueber die offene WebSocket-Verbindung, unabhaengig davon,
ob die Sitzung gerade sichtbar ist oder im Hintergrund liegt. Das ist keine
neue Sonderbehandlung auf dem Server: `terminal_ws.py` aktualisiert
`activity["t"]` bereits fuer JEDE empfangene Nachricht, noch VOR der
`type`-Weiche (`if msg.get("type") == "input": ... elif ... == "resize":
...`) -- ein unbekannter `type` wie `"ping"` faellt danach einfach durch,
ohne Fehler und ohne dass eine der beiden Zweige ausgefuehrt wird. Timer
wird in `dispose()` und im `close`-Handler aufgeraeumt (kein Leck bei
Sitzungsende). `node --check` fehlerfrei; der volle Regressionslauf
(csp_compliance/teil_f_schritt2/3/5, siehe unten) zeigt weiterhin 22/22 --
erwartungsgemaess, da die Aenderung rein clientseitig ist und die
Server-Verzweigung fuer unbekannte `type`-Werte bereits vor dieser Aenderung
bestand (kein neuer, testbar isolierter Serverpfad entstanden).
**Kein Backend-Test fuer diese Aenderung**: die Idle-Timeout-Schleife liegt
inline in der grossen `ssh_terminal()`-Route (nicht in einer eigenstaendigen
Funktion) und haengt an einer echten `asyncssh`-Verbindung -- ein
Zeit-basierter WS-Integrationstest dafuer waere ein eigenes, nicht triviales
Vorhaben (aehnliche Komplexitaet wie die bereits bestehenden, teils
fehlschlagenden Mock-Tests in `test_phase15.py`) und wurde hier bewusst
nicht nachgezogen, um die Codepruefung nicht mit einem unsicheren neuen Test
zu vermischen. Die Absicherung stuetzt sich auf Codelesen (oben) --
**die eigentliche Bestaetigung braucht den echten 30-Minuten-Lauf.**
### Kriterium-fuer-Kriterium-Stand
| Kriterium (F.6) | Stand |
|---|---|
| Vier Sitzungen (2 SSH + 2 RDP) gleichzeitig oeffnen | Code vorhanden (F4: mehrere gleichzeitige `SshTerminalSession`/`RdpSession`-Instanzen, keine Zerstoerung beim Umschalten); Standard-Obergrenze 6/Nutzer laesst vier zu. **Live-Test aussteht.** |
| Mehrfaches Umschalten | Code vorhanden (F1/F4: `setActive()`, `.hidden`-Toggle, Refit/`sendSize()` beim Sichtbarwerden). **Live-Test aussteht.** |
| 30 Minuten offen, danach alle vier noch verbunden | Betreiberentscheidung #7 umgesetzt; SSH-Idle-Timeout-Luecke oben gefunden UND behoben; RDP ohne Idle-Timeout unproblematisch. **Live-Test aussteht** (einzige Moeglichkeit, echte 15+ Minuten Stille zuverlaessig zu pruefen). |
| Ausgabe in jeder Sitzung vollstaendig vorhanden | Kein Datenverlust durch die Architektur zu erwarten (jede Instanz haelt ihren eigenen `xterm.js`/Guacamole-Zustand, wird nie zerstoert) -- aber nur am echten Terminal pruefbar. **Live-Test aussteht.** |
| Eingaben nur in der sichtbaren Sitzung | Code vorhanden: SSH bindet ohnehin an sein eigenes Containerelement (kein Aenderungsbedarf); RDP nutzt das `_active`-Gate (F1) plus `keyboard.reset()` beim Umschalten (F4). Laut F.5-Risikotabelle das groesste Einzelrisiko der ganzen Stufe. **Live-Test aussteht -- dringendste Einzelpruefung vor einer echten Abnahme.** |
| Groesse stimmt nach jedem Umschalten | Code vorhanden: `setActive(true)` loest `sendResize()`/`_sendCurrentSize()` aus (F1), Hintergrund-Resize-Events sind seit F4 per `_active`-Gate unterdrueckt. **Live-Test aussteht.** |
| Genau vier `sessions`-Zeilen mit vier Aufzeichnungen | Jede Sitzung erzeugt beim Verbindungsaufbau ihre eigene DB-Zeile + `SessionRecorder` (unveraendert seit vor Teil F, F.3.10 als harte Regel nie angetastet) -- vier gleichzeitige Instanzen ergeben strukturell vier Zeilen. **Live-Test aussteht** (Stichprobe in der DB nach dem Lauf). |
| Zweiter Benutzer unbeeintraechtigt | Jede neue Katalog-/Sitzungs-API in F2/F5 ist zwingend auf `user_id = eigene` beschraenkt (siehe Moduldoc `app/catalog/routes.py`, dedizierte Tests in `test_teil_f_schritt2.py`/`test_teil_f_schritt5.py` fuer Fremdsitzungs-Isolation). `active_sessions`-Registry ist bereits seit Teil E pro Sitzung, nicht pro Nutzer, geteilt. **Live-Test aussteht** (echter zweiter Login parallel). |
### Ehrliches Fazit
Der komplette Code-Stand fuer Teil F (F1-F5) ist fertig, regressionsfrei
(146 bestehende + 5 neue Tests bestehen, dieselben 18 laengst bekannten,
dokumentierten Fehlschlaege aus `FORTSETZUNG_Teil_C.md` unveraendert) und
gegen jedes einzelne F.6-Kriterium durchgesehen -- dabei wurde eine echte,
bis dahin unentdeckte Luecke gefunden und behoben (SSH-Idle-Timeout fuer
Hintergrundsitzungen, oben). Was in dieser Sandbox **nicht** moeglich war
und **fuer keine Stufe von F1 bis F5** nachgeholt werden konnte: ein
echter Browser mit echten SSH-/RDP-Zielsystemen. Alle sieben Kriterien in
der Tabelle oben sind auf Code-Ebene plausibel, aber keines ist tatsaechlich
durchgespielt worden. **Teil F sollte erst dann als im Sinne von F.6
"abgenommen" gelten, wenn eine Session mit Zugriff auf echte
SSH-/RDP-Testziele und einen echten Browser das Szenario tatsaechlich einmal
durchspielt** -- mit besonderem Augenmerk auf Eingabe-Isolation (groesstes
Risiko laut F.5) und den 30-Minuten-Idle-Fall (siehe oben).

View File

@ -131,7 +131,7 @@ Ziel ist ein zentraler **Jumphost/Bastion-Gateway**, über den sämtlicher admin
### 4.6 Rollen, Hostgruppen & Rechte (RBAC) ### 4.6 Rollen, Hostgruppen & Rechte (RBAC)
- **Hostgruppen** bündeln Zielsysteme (z. B. „Prod-Linux-DB", „Windows-Fileserver", „DMZ"). - **Hostgruppen** bündeln Zielsysteme (z. B. „Prod-Linux-DB", „Windows-Fileserver", „DMZ").
- **Rollen** definieren, was ein Nutzer/eine Gruppe innerhalb einer Hostgruppe darf: `ssh_connect`, `rdp_connect`, `file_transfer`, `clipboard`, `session_recording_view` (Playback fremder Sessions, z. B. für Auditoren), `admin_hostgroup`. - **Rollen** definieren, was ein Nutzer/eine Gruppe innerhalb einer Hostgruppe darf: `ssh_connect`, `rdp_connect`, `file_transfer`, `credentials_view`/`credentials_manage` (Zugangsdaten-Zuordnung, siehe Phase 8/9). Copy & Paste ist bewusst **kein** eigenes Rollen-Recht, sondern rein host-global über `hosts.clipboard_enabled` steuerbar; Sitzungs-Playback ist bewusst dauerhaft ausschließlich globalen Admins vorbehalten (kein delegiertes Auditor-Recht); eine delegierte Hostgruppen-Admin-Rolle gibt es nicht (Betreiberentscheidung, Teil D.6 Schritt 1 in `Umsetzungsauftrag_Sonnet5.md` — die drei ursprünglich vorgesehenen, nie durchgesetzten Rollen `clipboard`/`session_recording_view`/`admin_hostgroup` wurden mit Migration `0015_remove_dead_roles.sql` entfernt statt ausimplementiert).
- Zuordnung erfolgt über eine Verknüpfungstabelle User/Gruppe × Hostgruppe × Rolle (siehe Datenmodell), sodass granular z. B. „Team A darf SSH auf Hostgruppe X, aber kein Copy & Paste und keinen Filetransfer" abgebildet werden kann. - Zuordnung erfolgt über eine Verknüpfungstabelle User/Gruppe × Hostgruppe × Rolle (siehe Datenmodell), sodass granular z. B. „Team A darf SSH auf Hostgruppe X, aber kein Copy & Paste und keinen Filetransfer" abgebildet werden kann.
- Optional vorbereitet: zeitlich befristete/Just-in-Time-Freigaben (Access-Requests mit Ablaufzeit) als Erweiterung. - Optional vorbereitet: zeitlich befristete/Just-in-Time-Freigaben (Access-Requests mit Ablaufzeit) als Erweiterung.

View File

@ -49,7 +49,8 @@ noetig. Fuer RDP muss zusaetzlich ein laufender `guacd` erreichbar sein (siehe
`JUMPHOST_GUACD_HOST`/`_PORT`). `JUMPHOST_GUACD_HOST`/`_PORT`).
Die SQLite-Migrationen 0004 (`user_groups`), 0005 (`api_tokens`), 0006 Die SQLite-Migrationen 0004 (`user_groups`), 0005 (`api_tokens`), 0006
(`tenants`) und 0007 (`crud_extras`) werden beim naechsten Start automatisch (`tenants`, seit Migration 0014 wieder vollstaendig zurueckgebaut) und 0007
(`crud_extras`) werden beim naechsten Start automatisch
angewendet (`app/db.py`, `_apply_migrations`) — kein manueller angewendet (`app/db.py`, `_apply_migrations`) — kein manueller
Migrationsschritt noetig, auch nicht bei einem bestehenden Datenbestand. Migrationsschritt noetig, auch nicht bei einem bestehenden Datenbestand.
@ -105,30 +106,21 @@ rendert Endpunkte gruppiert nach Tag, ausschliesslich per
`document.createElement`/`textContent` (kein `innerHTML`, keine Inline- `document.createElement`/`textContent` (kein `innerHTML`, keine Inline-
Styles/-Scripts, siehe `tests/test_csp_compliance.py`). Styles/-Scripts, siehe `tests/test_csp_compliance.py`).
## Phase 8: Mandantenfaehigkeit, CRUD-Vervollstaendigung, Dateitransfer, Mehrfachrollen ## Phase 8: CRUD-Vervollstaendigung, Dateitransfer, Mehrfachrollen (historisch: Mandantenfaehigkeit)
Diese Session hat die Admin-Oberflaeche um sieben zusammenhaengende Phase 8 fuehrte testweise Mandantenfaehigkeit ein (Migration
`0006_tenants.sql`); diese wurde in Teil C des Umsetzungsauftrags
vollstaendig zurueckgebaut (Migration `0014_drop_tenants.sql`) — siehe dort
fuer die Begruendung. Die CRUD-Vervollstaendigung, der Dateitransfer und
die Mehrfachrollen-Vergabe aus dieser Phase bleiben bestehen. Historische
`tenant_created`/`tenant_updated`/`tenant_deleted`/`tenant_admin_granted`/
`tenant_admin_revoked`/`tenant_admin_promoted_to_admin`-Audit-Log-Eintraege
bleiben bewusst dauerhaft im manipulationssicheren, Hash-verketteten Log
erhalten und wurden vom Rueckbau nicht angetastet. Diese Session hat die
Admin-Oberflaeche seinerzeit um sechs zusammenhaengende
Erweiterungen ergaenzt: Erweiterungen ergaenzt:
**1) Mandantenfaehigkeit ("volle Isolation + Mandanten-Admins", Migration **1) CRUD-Vervollstaendigung**: jede Ressource, die die Admin-Oberflaeche
`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. anlegen kann, ist jetzt auch bearbeitbar und loeschbar — Benutzer (inkl.
Passwort setzen, deaktivieren, reaktivieren), Benutzergruppen, Hostgruppen, Passwort setzen, deaktivieren, reaktivieren), Benutzergruppen, Hostgruppen,
Hosts, SSH-Keys (inkl. Rotation). Zwei bewusste Sicherheits-/Integritaets- Hosts, SSH-Keys (inkl. Rotation). Zwei bewusste Sicherheits-/Integritaets-
@ -143,12 +135,12 @@ Katalog beruecksichtigt); `DELETE /admin/hosts/{id}?hard=true` versucht
zusaetzlich ein echtes Entfernen, faellt aber automatisch auf Soft-Delete zusaetzlich ein echtes Entfernen, faellt aber automatisch auf Soft-Delete
zurueck, falls der Host bereits Sitzungshistorie hat. zurueck, falls der Host bereits Sitzungshistorie hat.
**3) Login-Verlauf**: neuer Tab "Login-Verlauf" filtert clientseitig aus dem **2) Login-Verlauf**: neuer Tab "Login-Verlauf" filtert clientseitig aus dem
bestehenden Audit-Log-Feed (`GET /admin/audit-log`) gezielt Login-/Logout-/ bestehenden Audit-Log-Feed (`GET /admin/audit-log`) gezielt Login-/Logout-/
Fehlversuch-Ereignisse heraus — kein neuer Backend-Endpunkt noetig, da die Fehlversuch-Ereignisse heraus — kein neuer Backend-Endpunkt noetig, da die
Rohdaten bereits vorhanden waren, nur bisher nicht dediziert sichtbar. Rohdaten bereits vorhanden waren, nur bisher nicht dediziert sichtbar.
**4) "Details"-Fix fuer Hosts**: der defekte "Details"-Button in der Hosts- **3) "Details"-Fix fuer Hosts**: der defekte "Details"-Button in der Hosts-
Tabelle wurde durch einen neuen Endpunkt `GET /admin/hosts/{id}` (liefert den Tabelle wurde durch einen neuen Endpunkt `GET /admin/hosts/{id}` (liefert den
vollstaendigen, aktuellen Datensatz inkl. zugeordneter SSH-Keys und ob vollstaendigen, aktuellen Datensatz inkl. zugeordneter SSH-Keys und ob
RDP-Zugangsdaten hinterlegt sind) sowie eine neu geschriebene, robuste RDP-Zugangsdaten hinterlegt sind) sowie eine neu geschriebene, robuste
@ -160,12 +152,12 @@ Ursache nicht direkt reproduzierbar — bitte nach dem Update pruefen, ob der
Button jetzt zuverlaessig funktioniert, oder bei einem verbleibenden Fehler Button jetzt zuverlaessig funktioniert, oder bei einem verbleibenden Fehler
die genaue Browser-Konsolenmeldung mitteilen. die genaue Browser-Konsolenmeldung mitteilen.
**5) Eigener "Zugangsdaten"-Tab**: buendelt SSH-Keys UND RDP/Windows- **4) Eigener "Zugangsdaten"-Tab**: buendelt SSH-Keys UND RDP/Windows-
Passwoerter (bisher Teil des Hosts-Formulars) an einer Stelle, inkl. Uebersicht, Passwoerter (bisher Teil des Hosts-Formulars) an einer Stelle, inkl. Uebersicht,
welche RDP-Hosts bereits ein Passwort hinterlegt haben (`GET welche RDP-Hosts bereits ein Passwort hinterlegt haben (`GET
/admin/rdp-credentials`). /admin/rdp-credentials`).
**6) Dateitransfer-Fenster (SSH-Terminal)**: der bisherige, `prompt()`- **5) Dateitransfer-Fenster (SSH-Terminal)**: der bisherige, `prompt()`-
basierte Einzel-Upload-Knopf in der Terminal-Sitzung (`templates/terminal.html`, basierte Einzel-Upload-Knopf in der Terminal-Sitzung (`templates/terminal.html`,
`static/js/terminal.js`) wurde durch ein eigenstaendiges Dateitransfer-Panel `static/js/terminal.js`) wurde durch ein eigenstaendiges Dateitransfer-Panel
ersetzt, das **beide Richtungen** abdeckt — Upload (wie zuvor, jetzt mit ersetzt, das **beide Richtungen** abdeckt — Upload (wie zuvor, jetzt mit
@ -177,7 +169,7 @@ verlassen). Das Panel fuehrt zusaetzlich ein kurzes Transfer-Log der
laufenden Sitzung. Gilt bewusst nur fuer SSH (RDP hat in dieser laufenden Sitzung. Gilt bewusst nur fuer SSH (RDP hat in dieser
Implementierung keinen eigenen Dateitransfer-Endpunkt). Implementierung keinen eigenen Dateitransfer-Endpunkt).
**7) Mehrfachauswahl bei Rollenvergabe**: `POST /admin/roles/grant` und **6) Mehrfachauswahl bei Rollenvergabe**: `POST /admin/roles/grant` und
`POST /admin/group-roles/grant` akzeptieren jetzt `role_names` (Liste, 1-6 `POST /admin/group-roles/grant` akzeptieren jetzt `role_names` (Liste, 1-6
Rollen) statt einer einzelnen `role_name` — ein Benutzer bzw. eine Rollen) statt einer einzelnen `role_name` — ein Benutzer bzw. eine
Benutzergruppe kann damit in einem Schritt mehrere Rollen auf derselben Benutzergruppe kann damit in einem Schritt mehrere Rollen auf derselben
@ -214,8 +206,8 @@ Verbindungsaufbau (`jumphost.*`-Logger werden beim Start auf DEBUG gesetzt,
siehe `app/security/log_stream.py`). Bewusst ein reiner In-Memory-Ring-Buffer siehe `app/security/log_stream.py`). Bewusst ein reiner In-Memory-Ring-Buffer
(letzte 1000 Zeilen) + Pub/Sub ohne DB-Persistenz — ein Live-Tail wie (letzte 1000 Zeilen) + Pub/Sub ohne DB-Persistenz — ein Live-Tail wie
`journalctl -f`, kein durchsuchbares Archiv. Nur fuer Super-Admins sichtbar/ `journalctl -f`, kein durchsuchbares Archiv. Nur fuer Super-Admins sichtbar/
erreichbar, da die Logs mandantenuebergreifend technische Details preisgeben erreichbar, da die Logs technische Details zum Verbindungsaufbau aller
koennen. Der Endpunkt liegt bewusst als eigener Top-Level-Router unter Hosts preisgeben koennen. Der Endpunkt liegt bewusst als eigener Top-Level-Router unter
`/ws/logs` (siehe `app/admin/log_ws.py`) statt unter `/admin/...` — der `/ws/logs` (siehe `app/admin/log_ws.py`) statt unter `/admin/...` — der
nginx-Reverse-Proxy setzt die fuer WebSockets noetigen Upgrade-Header nur nginx-Reverse-Proxy setzt die fuer WebSockets noetigen Upgrade-Header nur
fuer die `location /ws/ { ... }` (siehe fuer die `location /ws/ { ... }` (siehe
@ -225,7 +217,7 @@ Handshake wuerde hinter dem Proxy fehlschlagen (Status bliebe dauerhaft
"getrennt", ohne dass ein Fehler in der Anwendung selbst sichtbar wird). "getrennt", ohne dass ein Fehler in der Anwendung selbst sichtbar wird).
**4) Sessionview-Dashboard (nur Super-Admin)**: neuer Tab "Sessions" + **4) Sessionview-Dashboard (nur Super-Admin)**: neuer Tab "Sessions" +
`GET /admin/sessions` (aktive + historische Sitzungen ueber alle Mandanten), `GET /admin/sessions` (aktive + historische Sitzungen),
`POST /admin/sessions/{id}/terminate` (zwangsweises Trennen einer laufenden `POST /admin/sessions/{id}/terminate` (zwangsweises Trennen einer laufenden
Sitzung) und `GET /admin/sessions/{id}/recording` (Integritaetspruefung der Sitzung) und `GET /admin/sessions/{id}/recording` (Integritaetspruefung der
Aufzeichnung). "Beenden" nutzt eine neue prozesslokale Registry Aufzeichnung). "Beenden" nutzt eine neue prozesslokale Registry
@ -246,8 +238,8 @@ getrennt).
`ssh_connect`/`rdp_connect`/`file_transfer` — pro Hostgruppe an einzelne User `ssh_connect`/`rdp_connect`/`file_transfer` — pro Hostgruppe an einzelne User
oder Benutzergruppen vergeben werden koennen. Damit koennen auch NICHT-Admins oder Benutzergruppen vergeben werden koennen. Damit koennen auch NICHT-Admins
gezielt Zugangsdaten (RDP-Passwort setzen/entfernen, SSH-Key-Zuordnung, nie gezielt Zugangsdaten (RDP-Passwort setzen/entfernen, SSH-Key-Zuordnung, nie
der Klartext selbst) fuer Hosts "ihrer" Hostgruppe verwalten, ohne Admin oder der Klartext selbst) fuer Hosts "ihrer" Hostgruppe verwalten, ohne Admin
Mandanten-Admin sein zu muessen (neue Dependency sein zu muessen (neue Dependency
`require_admin_scope_or_host_role` in `app/auth/deps.py`, neuer Endpunkt `require_admin_scope_or_host_role` in `app/auth/deps.py`, neuer Endpunkt
`GET /admin/hosts/{id}/credentials`, Dashboard zeigt einen "Zugangsdaten"- `GET /admin/hosts/{id}/credentials`, Dashboard zeigt einen "Zugangsdaten"-
Knopf bei Hosts mit dieser Rolle). Knopf bei Hosts mit dieser Rolle).
@ -425,7 +417,7 @@ falschen Objekt: der Anmeldename ist Teil der Zugangsdaten. Ab jetzt:
den Host-Wert zurueck, bis ein Admin den Namen am Schluessel setzt (die den Host-Wert zurueck, bis ein Admin den Namen am Schluessel setzt (die
Oberflaeche weist darauf hin). Oberflaeche weist darauf hin).
* Die alten Spalten bleiben als Fallback lesbar (ein Spalten-Drop erzwaenge in * Die alten Spalten bleiben als Fallback lesbar (ein Spalten-Drop erzwaenge in
SQLite einen Tabellen-Rebuild, siehe Begruendung in `0006_tenants.sql`), die SQLite einen Tabellen-Rebuild, siehe Begruendung in `0014_drop_tenants.sql`), die
API nimmt sie als `deprecated` weiterhin entgegen. API nimmt sie als `deprecated` weiterhin entgegen.
### Nach dem Deployment zu tun ### Nach dem Deployment zu tun
@ -458,21 +450,20 @@ Admin-only-Durchsetzung fuer alle neuen Endpunkte, Token-Scope-Durchsetzung
Scope), dass ein Token niemals andere Tokens verwalten kann (Rechte- Scope), dass ein Token niemals andere Tokens verwalten kann (Rechte-
Eskalationsschutz), sowie Admin-Gating von `/docs` und `/openapi.json`. Eskalationsschutz), sowie Admin-Gating von `/docs` und `/openapi.json`.
Details, Vorgehen und Ergebnisse der urspruenglichen 27 Tests: siehe Details, Vorgehen und Ergebnisse der urspruenglichen 27 Tests: siehe
`Pentest_Report.md`. 15 weitere neue Tests zu Phase 8 `Pentest_Report.md`. 9 der urspruenglich zu Phase 8 hinzugekommenen Tests
(`tests/test_tenants.py`) decken zusaetzlich ab: Tenant-CRUD (Super-Admin (`tests/test_admin_crud.py`, Nachfolger von `tests/test_tenants.py` — die
only, Mandanten-Admin explizit ausgeschlossen), volle Mandanten-Isolation 6 reinen Tenant-CRUD-/-Isolationstests entfielen mit dem Rueckbau in Teil C)
(Hostgruppen/Hosts/Benutzer/SSH-Keys/Audit-Log — fremde Mandanten-IDs decken weiterhin ab: Mehrfachauswahl bei Einzel- UND Gruppen-Rollenvergabe,
liefern 404), Mehrfachauswahl bei Einzel- UND Gruppen-Rollenvergabe,
Update/Deactivate/Delete fuer Benutzer (inkl. Anonymisieren-statt-Hart- Update/Deactivate/Delete fuer Benutzer (inkl. Anonymisieren-statt-Hart-
Loeschen bei vorhandener Audit-Historie vs. echtem Hard-Delete ohne), Loeschen bei vorhandener Audit-Historie vs. echtem Hard-Delete ohne),
Update/Delete fuer Hostgruppen (blockiert solange Hosts enthalten sind), Update/Delete fuer Hostgruppen (blockiert solange Hosts enthalten sind),
Hosts (Soft- vs. Hard-Delete), SSH-Keys (inkl. Rotation) und Benutzergruppen, Hosts (Soft- vs. Hard-Delete), SSH-Keys (inkl. Rotation) und Benutzergruppen,
sowie den neuen `GET /admin/hosts/{id}`-Detailendpunkt inkl. SSH-Key- sowie den `GET /admin/hosts/{id}`-Detailendpunkt inkl. SSH-Key-Zuordnungen
Zuordnungen und RDP-Zugangsdaten-Status. 7 weitere neue Tests zu Phase 9 und RDP-Zugangsdaten-Status. 7 weitere neue Tests zu Phase 9
(`tests/test_phase9.py`) decken zusaetzlich ab: dass "Host-Key ermitteln" bei (`tests/test_phase9.py`) decken zusaetzlich ab: dass "Host-Key ermitteln" bei
einem echten Verbindungsfehler 502 statt 500 liefert und bei einem Auth-Fehler einem echten Verbindungsfehler 502 statt 500 liefert und bei einem Auth-Fehler
NACH erfolgreichem Key-Exchange weiterhin als Erfolg gilt; dass NACH erfolgreichem Key-Exchange weiterhin als Erfolg gilt; dass
`/admin/sessions*` ausschliesslich Super-Admins erlaubt ist (Mandanten-Admin `/admin/sessions*` ausschliesslich Super-Admins erlaubt ist (Nicht-Admin
= 403) und `terminate`/`recording` fuer nicht (mehr) laufende bzw. nicht = 403) und `terminate`/`recording` fuer nicht (mehr) laufende bzw. nicht
aufgezeichnete Sitzungen sauber 409/404 statt einen internen Fehler liefern; aufgezeichnete Sitzungen sauber 409/404 statt einen internen Fehler liefern;
dass die neuen Rollen `credentials_view`/`credentials_manage` Nicht-Admins dass die neuen Rollen `credentials_view`/`credentials_manage` Nicht-Admins
@ -486,9 +477,12 @@ Payload-Feld `role_name` (Einzahl) an `/admin/group-roles/grant`, das
mehr kennt (erwartet `role_names`, eine Liste) — ein beim Phase-8-Umbau mehr kennt (erwartet `role_names`, eine Liste) — ein beim Phase-8-Umbau
liegen gebliebener Regressionsfehler, jetzt auf `role_names` korrigiert. liegen gebliebener Regressionsfehler, jetzt auf `role_names` korrigiert.
> **Hinweis:** Alle 41 in dieser und den beiden vorherigen Sessions neu > **Hinweis:** Von den 41 in dieser und den beiden vorherigen Sessions neu
> hinzugekommenen Tests (5 CSP + 14 Admin/Gruppen/Token + 15 Mandanten/CRUD + > hinzugekommenen Tests (5 CSP + 14 Admin/Gruppen/Token + 15 Tenant-CRUD/
> 7 Phase 9) konnten in der verwendeten Cloud-Sandbox nicht mit `pytest -q` > -Isolation + 7 Phase 9) sind nach dem Mandanten-Rueckbau in Teil C nur noch
> 35 relevant (9 CRUD-/Rollen-Tests aus den urspruenglichen 15 blieben als
> `tests/test_admin_crud.py` erhalten, 6 reine Tenant-Tests entfielen). Sie
> konnten in der damals verwendeten Cloud-Sandbox nicht mit `pytest -q`
> ausgefuehrt werden, da diese Sandbox keinen Netzwerkzugriff auf PyPI hat und > ausgefuehrt werden, da diese Sandbox keinen Netzwerkzugriff auf PyPI hat und
> `fastapi`/`aiosqlite`/`argon2`/`asyncssh` dort nicht vorinstalliert sind > `fastapi`/`aiosqlite`/`argon2`/`asyncssh` dort nicht vorinstalliert sind
> (auch nicht ueber die Geraete-Bruecke zum lokalen Rechner erreichbar). > (auch nicht ueber die Geraete-Bruecke zum lokalen Rechner erreichbar).
@ -499,10 +493,12 @@ liegen gebliebener Regressionsfehler, jetzt auf `role_names` korrigiert.
> `async`-Interface) erlauben es, die **echten** Endpunkt-Funktionen aus > `async`-Interface) erlauben es, die **echten** Endpunkt-Funktionen aus
> `app/admin/routes.py` direkt (ohne HTTP-Layer) gegen eine echte > `app/admin/routes.py` direkt (ohne HTTP-Layer) gegen eine echte
> In-Memory-`sqlite3`-Datenbank mit allen 7 Migrationen auszufuehren — damit > In-Memory-`sqlite3`-Datenbank mit allen 7 Migrationen auszufuehren — damit
> wurden ueber 20 Integrations-Assertions (Mandanten-Isolation, > wurden ueber 20 Integrations-Assertions (u.a. Tenant-Isolation — seither
> Rollen-Mehrfachvergabe, Delete-Semantik) tatsaechlich lauffaehig > mit Migration 0014 wieder entfernt, siehe oben —, Rollen-Mehrfachvergabe,
> verifiziert, nicht nur simulierte SQL-Queries. Der neue `tests/test_tenants.py` > Delete-Semantik) tatsaechlich lauffaehig verifiziert, nicht nur simulierte
> selbst nutzt weiterhin `httpx.AsyncClient` gegen die echte ASGI-App (wie > SQL-Queries. Der damalige `tests/test_tenants.py` (heute Nachfolger
> `tests/test_admin_crud.py`) selbst nutzte weiterhin `httpx.AsyncClient`
> gegen die echte ASGI-App (wie
> alle anderen Testdateien) und wurde daher zeilenweise gegen die tatsaechliche > alle anderen Testdateien) und wurde daher zeilenweise gegen die tatsaechliche
> Endpunkt-Implementierung gegengeprueft (Pfade, Payload-Felder, Statuscodes), > Endpunkt-Implementierung gegengeprueft (Pfade, Payload-Felder, Statuscodes),
> aber nicht selbst mit `pytest` ausgefuehrt. Fuer Phase 9 zusaetzlich: die > aber nicht selbst mit `pytest` ausgefuehrt. Fuer Phase 9 zusaetzlich: die
@ -626,7 +622,7 @@ ist die massgebliche, aktuelle Fassung dieser Liste. Kurzfassung:
-> automatisch deaktivieren"-Routine. -> automatisch deaktivieren"-Routine.
11. Ein erneuter `bandit`/`pip-audit`-Lauf ueber die in dieser und der 11. Ein erneuter `bandit`/`pip-audit`-Lauf ueber die in dieser und der
Phase-8-Session neu hinzugekommenen Dateien (Admin-Oberflaeche, Phase-8-Session neu hinzugekommenen Dateien (Admin-Oberflaeche,
Gruppen-RBAC, API-Tokens, Mandantenfaehigkeit, Dateitransfer-Panel) Gruppen-RBAC, API-Tokens, Dateitransfer-Panel)
steht noch aus (keine neuen Fremdabhaengigkeiten hinzugekommen, siehe steht noch aus (keine neuen Fremdabhaengigkeiten hinzugekommen, siehe
oben). oben).
12. **Phase 8 / "Details"-Fix**: der Root-Cause des urspruenglich gemeldeten 12. **Phase 8 / "Details"-Fix**: der Root-Cause des urspruenglich gemeldeten

View File

@ -0,0 +1,942 @@
# Umsetzungsauftrag für Sonnet 5 — Jumphost
Stand: 2026-08-31 · Codestand: Commit `afe6719` (Arbeitsbaum == HEAD)
**Zielsystem: Debian 12 (bookworm)** — geändert gegenüber der ersten Fassung, siehe Teil A.2.
Grundlage: Analyse von `app/**`, `static/js/**`, `templates/**`, `app/db/migrations/**`,
`ansible/**`, `tests/**` sowie des Vorgängerberichts `RDP_Diagnose_Phase16.md`.
Dieses Dokument beschreibt **was** zu tun ist und **warum**, mit genauen Fundstellen.
Es enthält bewusst **keinen fertigen Code** — die Implementierung ist Aufgabe von Sonnet 5.
---
## 0. Auftrag in fünf Sätzen
1. **RDP zu Windows-Zielen funktioniert nicht** (Bildschirm bleibt schwarz, Log meldet
`[Errno 111] Connection refused`). Ursache finden, beheben, und vor allem: die
Fehlermeldungen so umbauen, dass die nächste Störung in einer Minute statt einem Tag
erklärbar ist. **Das Zielsystem ist jetzt Debian 12 — dort muss RDP funktionieren; das ist
die Abnahmebedingung, keine Option.**
2. **Mehrere Benutzer müssen gleichzeitig Sitzungen offen haben können** (Teil E). Es gibt
heute keine harte Sperre dagegen, aber mehrere Stellen, die unter Parallelbetrieb die
Anwendung ausbremsen oder Daten beschädigen — allen voran eine Race Condition in der
Audit-Hash-Kette.
3. **Ein Benutzer soll mehrere Sitzungen gleichzeitig offen haben und über eine Seitenleiste
zwischen ihnen umschalten können** (Teil F). Das ist heute nur über mehrere Browser-Tabs
möglich; eine Oberfläche dafür gibt es nicht.
4. **Das Mandanten-Konzept (Tenants) wird vollständig entfernt.** Es bringt für diese
Anwendung keinen Nutzen und verteuert jede weitere Änderung.
5. **Berechtigungen auf Server, Hostgruppen und Zugangsdaten laufen künftig ausschließlich
über Benutzergruppen.** Die Direktvergabe pro Benutzer entfällt; für Zugangsdaten wird
erstmals eine Berechtigungsebene eingeführt.
### Arbeitsregeln
* Reihenfolge einhalten: **Teil A/B (RDP + Fehlermeldungen) → Teil E (Parallelbetrieb) →
Teil C (Mandanten) → Teil D (Berechtigungen) → Teil F (Multi-Session-Oberfläche)**.
Teil D setzt Teil C voraus (`rdp_credentials.tenant_id` ist heute `NOT NULL` und würde in
die neuen Freigabetabellen hineinregieren). Teil F setzt Teil E voraus — eine Oberfläche mit
fünf gleichzeitig offenen Sitzungen auf einem Event-Loop, der schon bei zwei RDP-Sitzungen
einbricht, wäre eine Verschlechterung. Zwei Punkte aus Teil E sind **sofort** zu erledigen,
unabhängig von allem anderen: die Audit-Race (E1) und der Startup-Reaper (E7) — beide
richten bereits heute im Mehrbenutzerbetrieb Schaden an.
* Migrationen sind **vorwärts-only**. Bestehende Dateien in `app/db/migrations/` niemals
editieren — der Runner (`app/db.py:41-56`) führt über `schema_migrations` Buch. Neue
Schritte bekommen neue Nummern ab `0013`.
* Jede Teilstufe muss für sich lauffähig sein: Dienst startet, Login funktioniert,
bestehende SSH-Sitzungen brechen nicht.
* Die Testsuite ist laut README nie vollständig gelaufen. Sie soll am Ende von Teil D
grün sein; Abweichungen davon explizit dokumentieren.
* Vor jeder Schemaänderung: Backup der SQLite-Datei inkl. `-wal`/`-shm` bei gestopptem
Dienst.
---
# TEIL A — Warum RDP nicht funktioniert
## A.0 Die Kernaussage zum gemeldeten Log
```
DEBUG RDP-Sitzung 28 gestartet: user=admin host=DBPortal-12.250 (10.0.0.12:3389)
WARNING RDP-Sessionfehler (session_id=28): [Errno 111] Connection refused
DEBUG RDP-Sitzung 28 beendet: reason=error
```
**Dieser Fehler hat nichts mit `10.0.0.12:3389` zu tun.** Die Anwendung öffnet auf dem
gesamten RDP-Pfad genau **einen** ausgehenden Socket: `asyncio.open_connection(guacd_host,
guacd_port)` in `app/rdp_proxy/guacd_client.py:134`, aufgerufen aus
`app/rdp_proxy/ws_tunnel.py:235-238` mit `settings.guacd_host`/`settings.guacd_port`
(Defaults `127.0.0.1:4822`, `app/config.py:68-69`). Zum Windows-Ziel verbindet **guacd**,
nicht die App — ein Fehler dort käme als Guacamole-`error`-Instruktion zurück und würde in
`guacd_client.py:157-159` zu einem `GuacamoleProtocolError`, nicht zu einem `OSError`.
`ConnectionRefusedError` wird in `ws_tunnel.py:256` gefangen und in `:257` geloggt.
**Schlussfolgerung: Auf `127.0.0.1:4822` lauscht nichts.** Die vorangehende DEBUG-Zeile mit
dem Zielhost (`ws_tunnel.py:212-215`) ist in diesem Kontext irreführendes Rauschen — sie ist
mit ein Grund, warum die Meldung „nicht vielsagend" ist (siehe Teil B).
Die Firewall scheidet als Erklärung aus: `ansible/roles/firewall_nftables/templates/jumphost.nft.j2:8`
(`iif lo accept`) und `:31` (`oif lo accept`) lassen Loopback durch, und ein `policy drop`
liefert `EPERM`, nicht `ECONNREFUSED`.
## A.1 Sofortdiagnose auf dem Jumphost (vor jeder Codeänderung)
In dieser Reihenfolge, das Ergebnis entscheidet über A.2:
1. `systemctl status guacd` und `journalctl -u guacd -n 50`
2. `ss -ltnp | grep 4822`
3. `ls /usr/lib/*/libguac-client-rdp.so* /usr/local/lib/libguac-client-rdp.so*`
4. `nft list ruleset | grep daddr`
5. Im Adminbereich beim betroffenen Host prüfen, ob als Benutzername der Platzhalter
`(kein Benutzername)` eingetragen ist (siehe A.3, Punkt C2).
## A.2 Warum guacd nicht lauscht — auf Debian 12 (bookworm)
**Geänderte Rahmenbedingung (2026-08-31): Zielsystem ist jetzt Debian 12 (bookworm), nicht
mehr Debian 13.** Damit fällt die schwerwiegendste Ursache der bisherigen Analyse weg: der
Konflikt zwischen guacamole-server und FreeRDP 3.x (GUACAMOLE-2146), an dem der Quellcode-Build
auf Debian 13 scheiterte, existiert unter bookworm nicht — dort liegt FreeRDP 2.x
(`freerdp2-dev`, in den Point-Releases auf 2.11.x aktualisiert), und guacamole-server 1.5.5 ist
genau dagegen geschrieben.
**Wichtig — hier keine falsche Erwartung aufbauen: Debian hat auch in bookworm KEIN
`guacd`-Paket.** Die Quelle `guacamole-server` wurde im Januar 2022 aus Testing entfernt und
ist zuletzt in bullseye (1.3.0-1) enthalten gewesen; in bookworm und trixie gibt es sie nicht.
Nur Ubuntu paketiert guacd (Komponente „universe"). Der Quellcode-Build bleibt auf Debian 12
also der Weg — und genau das tut die Rolle bereits: `ansible/roles/guacd/tasks/main.yml:44-61`
wählt für Debian 12 automatisch `_guacd_install_mode: source`, `_guacd_build_mode: release`
(Tarball 1.5.5, Checksum-verifiziert) und `freerdp2-dev`. **Der Umstieg auf Debian 12
erfordert an dieser Weiche keine Änderung; er macht den vorhandenen Pfad zum ersten Mal
lauffähig.** Der git-`main`-Pfad (`:138-157`) wird auf Debian 12 nie betreten und kann
unangetastet bleiben.
**Vor dem ersten Ansible-Lauf trotzdem einmal auf dem Zielsystem gegenprüfen** (Versionen
nicht raten): `apt policy freerdp2-dev libfreerdp2-2` und, falls doch ein Paketweg gewünscht
ist, `apt-cache policy guacd`. Die Rolle soll gegen das prüfen, was das System tatsächlich
anbietet, und mit einer klaren Meldung abbrechen statt in einen halben Build zu laufen.
### Die verbleibenden Ursachen — alle unverändert gültig
| # | Ursache | Fundstelle | Warum das gilt |
|---|---|---|---|
| 1 | **Kein `service: state=started enabled=true` für guacd** | ganze Rolle `ansible/roles/guacd/tasks/main.yml` (kein `service:`-Task; nur der Handler `handlers/main.yml:6-9`) | guacd wird nur bei einer Änderung neu gestartet. Ein zweiter Ansible-Lauf ohne Änderung meldet „ok", während der Dienst tot ist. **Nach dem Wegfall des Build-Problems ist das die wahrscheinlichste verbleibende Ursache für Errno 111.** Vergleiche `jumphost_app/tasks/main.yml:71-76`, wo es richtig gemacht ist. |
| 2 | **Keine Startprüfung** | dieselbe Rolle | Kein `wait_for` auf `{{ guacd_port }}`, keine Existenzprüfung auf das RDP-Plugin. Ein fehlgeschlagener Start bleibt bis zum ersten Benutzerversuch unbemerkt. |
| 3 | **`--sysconfdir` fehlt beim `configure`** | `tasks/main.yml:159-165` (`./configure --with-systemd-dir=/etc/systemd/system --disable-guacenc --disable-guaclog`) gegen `tasks/main.yml:197-211` | Ohne `--sysconfdir=/etc` ist das Konfigurationsverzeichnis des Builds `/usr/local/etc`. guacd liest dann `/usr/local/etc/guacamole/guacd.conf` — die Rolle schreibt aber nach `/etc/guacamole/guacd.conf`. Heute unauffällig, weil guacd ohne Konfiguration ohnehin auf `127.0.0.1:4822` lauscht; **sobald `guacd_port` oder `bind_host` geändert wird, ist Errno 111 die exakte Folge.** Entweder `--sysconfdir=/etc` ergänzen oder die Konfiguration dorthin schreiben, wo der Build sie sucht — und danach verifizieren, dass der tatsächlich lauschende Port dem entspricht, was die App konfiguriert hat. |
| 4 | **`/etc/guacamole` wird nicht angelegt** | `tasks/main.yml:197-211` (`lineinfile ... create: true`) | `lineinfile` legt die Datei an, nicht das Verzeichnis. Existiert `/etc/guacamole` nicht, bricht die Rolle hier ab — vor dem Start-Handler. Gehört zusammen mit Punkt 3 gelöst. |
| 5 | **Das Hardening-Drop-in kann den Dienst am Start hindern** | `templates/guacd.service.j2:11` (`ProtectSystem=strict`), `:24` (`ReadWritePaths=` nur `rdp-drives`), `:8` (`User=guacd`), ausgerollt in `tasks/main.yml:229-237` | Das Drop-in überschreibt die vom Build installierte Unit. `ProtectSystem=strict` macht das gesamte Dateisystem außer den `ReadWritePaths` schreibgeschützt — inklusive eines etwaigen PID-/Laufzeitverzeichnisses. **Vorgehen: `systemctl cat guacd` lesen, das Drop-in gezielt darauf abstimmen** (Benutzer, `Type=`/`PIDFile=`, Laufzeit- und Schreibpfade), nicht blind übernehmen. `systemd-analyze security guacd` zeigt anschließend, ob die Härtung noch greift. |
| 6 | **Port-Divergenz App ↔ guacd** | `ansible/inventory/group_vars/all.yml:16` (`guacd_port: 4822`), `jumphost-app.service.j2:14-15`, `app/config.py:68-69` | Solange beide Seiten auf 4822 stehen, unauffällig — aber nur zufällig, siehe Punkt 3. Ein Test „App-Port == tatsächlich lauschender Port" gehört in die Rolle. |
| 7 | **Reihenfolge** | `ansible/site.yml:20-21`, `jumphost-app.service.j2:3-4` (`After=`/`Wants=guacd.service`) | Der Handler `restart guacd` wird erst am Play-Ende geflusht, also nach dem Start der App. `Wants=` mildert das, hilft aber nicht bei einer `failed` Unit. Besser: expliziter Start plus Portprüfung **vor** der Rolle `jumphost_app`. |
### Zu tun (Ansible-Rolle `guacd`, Debian-12-Fassung)
* Weiche und Build-Pfad bleiben wie sie sind (Release-Tarball 1.5.5 + `freerdp2-dev`); den
Kommentarblock `tasks/main.yml:1-42` und `defaults/main.yml:1-26` auf den neuen Zielstand
eindampfen, damit niemand später wieder nach einem Debian-Paket sucht.
* Expliziter Start-/Enable-Task **plus** `wait_for` auf den konfigurierten Port mit
sprechender Fehlermeldung.
* Prüfung, dass das RDP-Plugin gebaut und installiert wurde (`libguac-client-rdp.so` im
Bibliothekspfad), sonst harter Abbruch mit Klartext — sonst meldet guacd erst zur Laufzeit
`Support for protocol "rdp" is not installed`. Der Build überspringt das RDP-Plugin
stillschweigend, wenn die FreeRDP-Header fehlen; genau dieser Fall muss auffallen.
* `--sysconfdir` und das Konfigurationsverzeichnis geradeziehen (Punkte 3 und 4).
* Das systemd-Drop-in gegen die tatsächlich installierte Unit prüfen (Punkt 5).
* Sicherstellen, dass der Benutzer, unter dem guacd läuft, den Laufwerkspfad erreichen kann
(siehe A.3, Befund B6, und Befund E6 in Teil E zur sitzungsbezogenen Trennung dieses Pfads).
* Nach dem Ansible-Lauf ein Smoke-Test-Task: TCP-Verbindung auf den guacd-Port und Abgleich
mit dem in der App konfigurierten Port.
## A.3 Weitere RDP-Blocker
Alle 18 Befunde aus `RDP_Diagnose_Phase16.md` sind **unverändert offen**. Der einzige Commit
nach dem Bericht (`8ddb3ae`) ändert zwei kosmetische Zeilen am Exit-Button in
`static/js/rdp.js`. Die folgende Tabelle ist die verbindliche Arbeitsliste.
| ID | Datei:Zeile | Problem | Auswirkung |
|---|---|---|---|
| A1 | `app/rdp_proxy/guacd_client.py:44-66` | `_read_until()`/`_read_chars()` lesen mit `readexactly(1)` **byteweise** | ~1 MB/s Obergrenze bei voller Event-Loop-Blockade; ein RDP-Bildstrom besteht fast nur aus base64-`blob`-Instruktionen. Phase 16 misst Faktor 644x gegenüber gepuffertem Lesen. Bild friert ein, Maus verzögert, **alle** anderen SSH-/RDP-Sitzungen und die Admin-UI stehen mit. Umbau auf `readuntil(b".")` + `readexactly(len)`, dazu `limit` am `open_connection` erhöhen, damit große Elemente kein `LimitOverrunError` auslösen. Die Zeichen- statt Byte-Semantik aus Phase 10 muss erhalten bleiben. |
| A2 | `app/rdp_proxy/ws_tunnel.py:235-246` | `await open_tunnel(...)` läuft **vor** dem Start der Pumpen-Tasks | Während des gesamten guacd-/RDP-Handshakes liest niemand vom WebSocket. guacamole-common-js bricht nach `receiveTimeout = 15000` (`static/js/vendor/guacamole-common.js:14605`) ohne empfangenen Frame mit „Server timeout." ab. Effektiv **15 Sekunden für den kompletten Windows-Anmeldevorgang** inkl. NLA/CredSSP. Leseschleife vor dem Tunnelaufbau starten bzw. den Client während des Aufbaus bedienen. |
| A3 | `app/rdp_proxy/guacd_client.py:134`, `:139`, `:157` | Weder Verbindungsaufbau noch die beiden `read_instruction`-Aufrufe haben ein Timeout; `app/config.py` kennt keine guacd-Timeout-Einstellung | Antwortet guacd nach dem TCP-Accept nicht mehr, hängt die Coroutine unbegrenzt: `sessions`-Zeile ohne `ended_at`, offene Recording-Datei, Eintrag in `active_sessions`, belegtes WebSocket. Konfigurierbares Timeout je Phase einführen. |
| C1 | `app/rdp_proxy/guacd_client.py:142` + `:153` | `arg_names = args_instr[1:]` — das erste Element ist bei guacd ≥ 1.5 das Versions-Token `VERSION_1_5_0`, dafür wird ein Leerstring gesendet | guacd stuft auf Protokoll 1.0.0 zurück. Verloren gehen `timezone`, `required` (Nachfordern von Zugangsdaten) und `msg`. Versions-Token erkennen und korrekt beantworten. |
| C2 | `app/db/migrations/0012_rdp_credential_sets.sql:51` | Migration schreibt `'(kein Benutzername)'` als echten Benutzernamen | Die Leerprüfung in `guacd_client.py:204-209` greift nicht, der Platzhalter geht als Anmeldename an Windows → „Anmeldung fehlgeschlagen" statt klarer Meldung. Datenkorrektur per neuer Migration plus Prüfung im Anmeldepfad. |
| C3 | `app/rdp_proxy/guacd_client.py:213-228` | In der `connect`-Instruktion fehlen `timezone`, `client-name`, `width`/`height`/`dpi` | Windows übernimmt die Serverzeitzone; die Auflösung funktioniert nur, weil guacd bei leeren Werten auf eine Default-Größe zurückfällt. |
| B5 | `ansible/.../jumphost.nft.j2:28-40`, `ansible/inventory/group_vars/all.yml:30-31` | Output-Chain `policy drop`, ausgehend nur `target_networks` (Default `10.20.0.0/16`) erlaubt | **Das gemeldete Ziel `10.0.0.12` liegt nicht in diesem Netz.** Sobald guacd wieder läuft, werden dessen RDP-Pakete still verworfen (kein Reject, kein Log) → TCP-Timeout → in Kombination mit A2 sieht der Benutzer „Server timeout" statt „Ziel nicht erreichbar". **Zweitwichtigster Blocker nach guacd selbst.** `target_networks` an die tatsächlichen Zielnetze anpassen und im Fehlerfall ein Logging-/Reject-Verhalten vorsehen, das diagnostizierbar ist. |
| B6 | `app/rdp_proxy/guacd_client.py:222-224`, `ansible/roles/sqlite_init/tasks/main.yml:6-12`, `app/db.py:25`, `guacd.service.j2:8` | `drive-path` liegt unter `/var/lib/jumphost`, das `0700 jumphost:jumphost` ist; guacd läuft als `guacd` | Laufwerksumleitung schlägt bei jedem Host mit `file_transfer_enabled` mit EACCES fehl. Verzeichnisrechte bzw. Gruppenzugehörigkeit klären. |
| D1 | `app/catalog/routes.py:48`, `:56` | Filter auf `r.name IN ('ssh_connect','rdp_connect')` ohne Abgleich mit `h.protocol` | Ein Benutzer mit nur `ssh_connect` sieht Windows-Hosts inkl. „Verbinden"-Knopf und landet in der Ablehnung 4403 (`ws_tunnel.py:145-149`). Siehe auch Teil D, S6. |
| D2 | `app/recordings/recorder.py:40-41` | `write()` + `flush()` bei **jeder** Instruktion, synchron im Event-Loop | Der RDP-Bildstrom wird 1:1 als JSONL gespiegelt. Wenige Stunden füllen `/var/lib/jumphost/recordings` mit GB; läuft die Partition voll, sterben SSH-Sitzungen und DB mit. Verstärkt zusätzlich A1. Puffern, Rotation und Größenbegrenzung vorsehen. |
| D3 | `app/main.py:91` | CSP `img-src 'self' data:` — kein `blob:` | guacamole-common-js bevorzugt `createImageBitmap()` (`vendor/guacamole-common.js:4854`), fällt aber auf `URL.createObjectURL()` zurück (`:4876`). Auf einem Browser ohne `createImageBitmap` bleibt das Bild **wirklich** schwarz, weil die CSP die Blob-URL blockt. |
| D4 | `static/js/rdp.js` (gesamt) | `client.sendSize()` wird nie aufgerufen, kein `resize`-Listener — obwohl `resize-method: display-update` gesetzt ist (`guacd_client.py:227`) | Auflösung bleibt auf dem Fenstermaß beim Verbindungsaufbau eingefroren. |
| D5 | `app/rdp_proxy/ws_tunnel.py:96-118` | `parse_instruction_text()` parst nur die **erste** Instruktion einer WS-Nachricht (`guacd_client.py:99-100` bricht bei `;` ab), weitergeleitet wird die **komplette** Nachricht (`:117`) | Der Clipboard-Filter (`:113-114`) greift nur auf die erste Instruktion — eine `clipboard`-Instruktion an zweiter Stelle passiert ungefiltert. Policy-Umgehung, sicherheitsrelevant. |
### Explizit korrekt — hier nicht suchen
Handshake-Reihenfolge `select → args → size/audio/video/image → connect → ready`
(`guacd_client.py:136-159`); Subprotokoll-Bestätigung (`ws_tunnel.py:52-59`); Tunnel-UUID als
erste Instruktion (`:160-161`); Ping-Spiegelung (`:109-110`); Zeichen- statt Byte-Längen
(`guacd_client.py:41`, `:53-65`); `security=nla` / `ignore-cert=true` als Defaults
(`guacd_client.py:219-220`); Passwortentschlüsselung in `try/finally` (`ws_tunnel.py:231`);
leeres RDP-Passwort ist per Schema ausgeschlossen (`app/models/schemas.py`); nginx reicht
`/ws/` mit Upgrade und 3600 s durch.
---
# TEIL B — Fehlermeldungen und Diagnostizierbarkeit
Der Nutzer nennt das ausdrücklich als eigenes Problem: *„auch sind die Fehlermeldungen nicht
vielsagend"*. Das ist kein Komfortthema — die aktuelle Störung wäre mit brauchbaren Meldungen
in einer Minute erklärt gewesen.
## B.1 Warum der Bildschirm „schwarz bleibt"
`static/js/rdp.js:43-53` schreibt Fehler in ein 0,85 rem großes rotes `<span>` in der
Toolbar, während `#rdp-display { flex: 1; background: black; }` (`static/css/app.css:198`)
die restliche Seite ganzflächig schwarz färbt. Zusätzlich liefert
`parseInt(event.reason)` in `vendor/guacamole-common.js:15556` für den deutschen Text `NaN`
— der Statuscode geht verloren.
**Es gibt also eine Meldung, sie ist nur eine Randnotiz auf schwarzem Grund.** Nötig ist ein
Overlay-Panel über `#rdp-display` mit Titel, Klartext, Angabe von guacd- und Zieladresse,
Zeitpunkt, Sitzungs-ID und einem Wiederholen-Knopf.
## B.2 Mängelliste Server-Seite
| Datei:Zeile | Problem | Was stattdessen gemeldet werden muss |
|---|---|---|
| `app/rdp_proxy/ws_tunnel.py:256-257` | `except (GuacamoleProtocolError, ConnectionError, OSError)` → eine Warnung mit Sitzungs-ID und blankem Exception-Text. Kein Schritt, kein Endpunkt, kein Stacktrace. Die Zeile darüber nennt den *Zielhost* und lenkt damit aktiv in die falsche Richtung. | Zwei Fehlerklassen sauber trennen: (a) „guacd unter `<host:port>` nicht erreichbar — Dienst prüfen", (b) „guacd meldet `<Klartext>` (Code `<n>`) für Ziel `<host:port>`". Schritt (`connect-guacd` / `handshake` / `ready` / `stream`) und die guacd-Adresse gehören in **jede** Meldung. |
| `app/rdp_proxy/guacd_client.py:134` | `open_connection` ohne `try/except`, der `OSError` propagiert nackt | In einen eigenen Fehlertyp mit `guacd_host:guacd_port` im Text umschließen; `ConnectionRefusedError` und `TimeoutError` unterscheiden. |
| `app/rdp_proxy/guacd_client.py:159` | `raise GuacamoleProtocolError(f"Verbindungsaufbau fehlgeschlagen: {ready_instr}")` → im Browser landet ein Python-Listen-`repr` | Guacamole-Statuscodes auf deutsche Klartexte abbilden (769 Anmeldung fehlgeschlagen, 519 Ziel nicht erreichbar, 512 Serverfehler, 776/777 Zertifikat/TLS) und die Meldung von guacd verwenden. |
| `app/rdp_proxy/ws_tunnel.py:269-277` | `except Exception` → der Browser bekommt nur den Klassennamen. Hier landet u. a. `IncompleteReadError` (guacd hat abgebrochen — die häufigste Folge eines FreeRDP-Absturzes), weil `EOFError` kein `OSError` ist | `IncompleteReadError`/`EOFError` explizit fangen: „guacd hat die Verbindung unerwartet beendet". |
| `app/rdp_proxy/ws_tunnel.py:296-308` | Fehlertext wird auf 123 Byte gekürzt (`:300`) und **nur** als WS-Close-Reason gesendet — nach drei `await conn.execute(...)`. Schlägt eine davon fehl, erfährt der Browser gar nichts. Das abschließende `except Exception` (`:304`) loggt nur auf DEBUG | Vor dem Schließen eine vollständige Guacamole-`error`-Instruktion über den offenen Tunnel senden — dann greift der Handler in `vendor/guacamole-common.js:3101-3110` und die Länge ist nicht begrenzt. Close-Reason nur als Fallback. |
| `app/rdp_proxy/ws_tunnel.py:96-98` | `except (…): continue` — ungültige Clientnachrichten werden völlig lautlos verworfen | Mindestens ein DEBUG-Log mit gekürztem Nachrichtenpräfix. |
| `app/rdp_proxy/ws_tunnel.py:212-215`, `:239` | Die einzigen aussagekräftigen Kontextzeilen sind DEBUG. Im Journal (`basicConfig(level=INFO)`, `app/main.py:28`) sieht man **nur** die Warnung ohne jeden Kontext; sichtbar sind sie nur, weil `app/security/log_stream.py:56` den `jumphost`-Namespace global auf DEBUG hebt | Sitzungsstart/-ende und Tunnelaufbau auf INFO. |
| gesamter RDP-Pfad | **Keine Correlation-ID.** `session_id` existiert erst ab `ws_tunnel.py:202`; alle Ablehnungen davor (`:135-136`, `:145-149`, `:166`, `:170-174`, `:190-196`) haben kein Korrelationsmerkmal, `_reject` (`:76`) loggt nicht einmal `host_id` oder Benutzer | Eine Verbindungs-UUID beim WS-Accept erzeugen (die `tunnel_uuid` aus `:160` bietet sich an) und in **jede** Logzeile sowie in die Meldung an den Browser aufnehmen. |
| `app/main.py:136-138` | `/healthz` liefert statisch „ok" — prüft weder DB noch guacd | Tiefe Variante mit TCP-Probe auf `guacd_host:guacd_port`, damit „guacd tot" ohne Sitzungsversuch sichtbar wird. |
| `ansible/roles/guacd/tasks/main.yml` | keinerlei Verifikation nach der Installation | siehe A.2. |
## B.3 Abnahmekriterium für Teil B
Drei Störfälle künstlich herbeiführen und prüfen, dass Log **und** Browser jeweils
unterscheidbar und in Klartext berichten:
1. guacd gestoppt → „guacd nicht erreichbar", mit Adresse und Hinweis auf den Dienst.
2. Zielhost nicht erreichbar/geblockt → „Ziel `<host:port>` nicht erreichbar", nicht
„Server timeout".
3. Falsches Passwort am Windows-Ziel → „Anmeldung fehlgeschlagen (769)".
---
# TEIL C — Mandanten (Tenants) vollständig entfernen
## C.1 Was Mandanten heute tatsächlich tun
Mandanten sind **kein Routing-Konzept**: es gibt weder Header-, Subdomain- noch
Cookie-Auflösung und keinen Default-Tenant-Fallback. Der Mandant wird ausschließlich aus dem
authentifizierten Principal abgeleitet (`app/auth/deps.py:276-290`, `effective_tenant_ids()`):
Super-Admin → „alle", Benutzer mit Zeilen in `tenant_admins` → genau diese IDs, API-Token →
exakt `api_tokens.tenant_id`. Daraus baut `app/admin/routes.py:94` einen `TenantScope`
(`app/tenancy.py:22-56`), der zwei Dinge tut: ein SQL-Fragment für Listen-Endpunkte liefern
und bei fremden IDs bewusst **404 statt 403** werfen.
Fachlich hängt der Mandant an fünf Objekttypen: Hostgruppe, Benutzergruppe, SSH-Key,
RDP-Zugangsdatensatz, API-Token. **Hosts haben keine eigene `tenant_id`** — sie erben sie über
die Hostgruppe (`app/tenancy.py:65-71`). `users.home_tenant_id` ist rein informativ; die
Sichtbarkeit eines Benutzers wird dynamisch über `tenant_user_ids()` (`app/tenancy.py:74-97`)
berechnet.
**Das Rollenmodell (`app/rbac.py`) ist vollständig mandantenfrei.** Auch Dashboard/Katalog,
SSH-Proxy, RDP-Proxy, SFTP, Recordings und alle `app/security/*`-Module enthalten null
Tenant-Bezüge. Mandantenfähigkeit ist ein reiner Aufsatz auf der Admin-API plus deren UI —
der Rückbau ist deshalb gut beherrschbar.
## C.2 Inventar Schema
**Eigene Tabellen (komplett entfernbar):** `tenants` (`0006_tenants.sql:17-23`, Seed
`(1,'Standard')` in `:24-25`), `tenant_admins` (`:32-38`) samt Index `:39`.
**Spalten in Fremdtabellen:**
| Tabelle | Spalte | NOT NULL | FK | Index | Quelle |
|---|---|---|---|---|---|
| `host_groups` | `tenant_id` | nein (Backfill 1) | `tenants(id)` | `idx_host_groups_tenant` | `0006:57-58, 87` |
| `user_groups` | `tenant_id` | nein (Backfill 1) | `tenants(id)` | `idx_user_groups_tenant` | `0006:60-61, 88` |
| `ssh_keys` | `tenant_id` | nein (Backfill 1) | `tenants(id)` | `idx_ssh_keys_tenant` | `0006:63-64, 89` |
| `api_tokens` | `tenant_id` | nein (nur anwendungsseitig erzwungen) | `tenants(id)` | `idx_api_tokens_tenant` | `0006:66-76, 90` |
| `users` | `home_tenant_id` | nein, kein Backfill | `tenants(id)` | `idx_users_home_tenant` | `0006:78-85, 91` |
| `rdp_credentials` | `tenant_id` | **ja** | `tenants(id)` | `idx_rdp_credentials_tenant` | `0012:19-29` |
**Wichtigstes Einzelergebnis: es gibt nirgends ein `UNIQUE(tenant_id, name)`.** Alle
Namensspalten sind seit jeher global eindeutig (`host_groups.name`, `user_groups.name`,
`users.username`, `roles.name`, `api_tokens.token_hash`); `ssh_keys.label` und
`rdp_credentials.label` sind gar nicht eindeutig. Der Wegfall von `tenant_id` **verschärft
also keine einzige Eindeutigkeitsbedingung — Duplikat-Kollisionen sind ausgeschlossen.**
(Umgekehrt gelesen war das ein Konstruktionsfehler: zwei Mandanten konnten nie eine
gleichnamige Hostgruppe haben, ein Mandanten-Admin konnte über eine 409-Antwort fremde
Namen erraten — genau das, was `TenantScope` mit 404 verhindern wollte.)
Views gibt es keine. Trigger nur zwei, beide auf `audit_log` (`0001:136-139`, append-only,
ohne Tenant-Bezug) — relevant für C.5.
## C.3 Inventar Code und UI
**Komplett löschbar:** `app/tenancy.py` (97 Zeilen, einziger Importeur `app/admin/routes.py:83`).
**`app/auth/deps.py`:** `CurrentUser.tenant_admin_ids`/`.token_tenant_id` (`:25-32`),
`is_any_admin` (`:34-37`), `_load_tenant_admin_ids()` (`:40-43`) und der Aufruf `:81-82`,
Kommentarblock `:160-171`, `at.tenant_id` im Token-SELECT (`:180-189`, `:204`),
Autorisierung über `is_any_admin` (`:233`, `:269`), `effective_tenant_ids()` (`:276-290`).
**`app/auth/routes.py:329-344`:** `tenant_admin_of[]` und `is_any_admin` aus `GET /auth/me`.
**`app/admin/routes.py` (179 Treffer):** Helfer `_scope()` `:94-95`, `_tenant_exists()`
`:98-100`, `_resolve_write_tenant()` `:103-121`; kompletter Tenant-CRUD-Block `:124-261`
(POST/GET/PUT/DELETE `/admin/tenants`, GET/POST/DELETE `/admin/tenants/{id}/admins`);
Scoping in den Fachendpunkten: Users `:274-279, :290-292, :307-329, :335-343`,
Benutzergruppen `:482-493, :503-515, :521-527`, Hostgruppen `:637-645, :655-663, :668-673`,
Hosts `:765-783, :789-793, :810-841`, RDP-Zugangsdaten `:1173-1179, :1197-1216, :1224-1231`,
SSH-Keys `:1501-1518, :1556-1569, :1581-1586`, Rollen `:1360-1368, :1444-1452`,
API-Tokens `:1745-1749, :1767-1789, :1802-1815, :1826-1831`, Audit-Log `:1846-1875`,
Kommentare `:1882-1883, :1891-1893`. `app/admin/log_ws.py:20` nur Kommentar.
**`app/models/schemas.py`:** Klassen `TenantCreateRequest` (`:81-84`), `TenantUpdateRequest`
(`:86-90`), `TenantAdminAssignRequest` (`:92-93`); Felder `UserCreateRequest.home_tenant_id`
(`:61-65`), `HostGroupCreateRequest.tenant_id` (`:99-101`), `UserGroupCreateRequest.tenant_id`
(`:182`), `ApiTokenCreateRequest.tenant_id` (`:212-214`), `SshKeyCreateRequest.tenant_id`
(`:222`), `RdpCredentialCreateRequest.tenant_id` (`:267`).
**`templates/admin.html`:** Tab-Button `:32`; Panel `#tab-tenants` `:622-679`; fünf
Mandanten-Auswahlblöcke `:107-109`, `:173-175`, `:364-366`, `:466-468`, `:592-594`; Spalte
„Mandant" in sieben Tabellenköpfen `:66, :120, :182, :254, :392, :480, :614`; Hinweistext `:582`.
**`static/js/admin.js`:** `meInfo` `:10`; Tab-Loader `:114`; `populateTenantSelect`/
`cachedTenants` `:143-172`; `tenantName()` `:176-180`; Vorladen `:183-185`;
Select-Befüllung `:280-281, :407-408, :763-767, :1138-1139`; Tabellenzellen
`:220, :295, :423, :475, :543, :783, :935, :1169`; `tenant_id` in fünf Request-Bodies
`:370-372, :643-645, :902/911, :1001/1007, :1186/1192`; kompletter Mandanten-Tab `:1208-1325`;
Rollen-Label und Tab-Sichtbarkeit `:1905-1909`.
**Nicht betroffen (verifiziert):** `app/rbac.py`, `app/catalog/routes.py`, `app/ssh_proxy/*`,
`app/rdp_proxy/*`, `app/recordings/*`, `app/security/*`, `app/main.py`, `app/config.py`,
`app/db.py`, `scripts/`, `ansible/**` (0 Treffer), `templates/dashboard.html`, alle
Dashboard-/Terminal-/RDP-JS-Dateien. `Jumphost_Konzept.md` und `Pentest_Report.md` haben
**0 Treffer** und brauchen keine Änderung.
## C.4 Das zentrale Risiko: stille Rechteausweitung
`is_any_admin` (`app/auth/deps.py:34-37`) ist heute `is_admin ODER tenant_admin_ids ≠ ∅`.
**Alle** Admin-Endpunkte prüfen nur `is_any_admin`; die eigentliche Beschränkung eines
Mandanten-Admins entsteht ausschließlich durch `TenantScope` im Endpunkt selbst.
**Wird `TenantScope` ersatzlos entfernt, ohne vorher das Rollenmodell zu bereinigen, wird
jeder Benutzer mit einer Zeile in `tenant_admins` schlagartig zum Voll-Admin** — Zugriff auf
alle Hosts, alle Zugangsdaten-Metadaten, alle Benutzer, das komplette Audit-Log. Das ist die
gefährlichste Einzelstelle des gesamten Rückbaus.
Ebenso: jedes API-Token ist heute hart auf einen Mandanten begrenzt (`deps.py:180-204`). Nach
dem Rückbau sieht ein bestehendes Token alles, was sein Scope erlaubt.
Entfallende Filter (was sichtbar würde): `GET /admin/users` (`:307-329`), `/admin/user-groups`
(`:503`), `/admin/host-groups` (`:655`), `/admin/hosts` (`:765`), `/admin/hosts/{id}` (`:790`),
`/admin/ssh-keys` (`:1556`), `/admin/rdp-credentials` (`:1197`), `/admin/roles` (`:1360`),
`/admin/group-roles` (`:1444`), `/admin/tokens` (`:1802`), `/admin/audit-log` (`:1853-1871`).
## C.5 SQLite-Mechanik und Audit-Kette
`ALTER TABLE … DROP COLUMN` ist für **keine** der sechs Spalten nutzbar — SQLite verweigert
es, wenn die Spalte indiziert ist oder in einem Fremdschlüssel steht; beides trifft überall
zu, und selbst nach `DROP INDEX` bleibt die FK-Klausel im DDL. **Voller Tabellen-Rebuild
(12-Schritte-Verfahren) für `host_groups`, `user_groups`, `ssh_keys`, `api_tokens`,
`rdp_credentials`** — und je nach Entscheidung auch `users`.
Sonderfall `users`: Ziel von FKs aus mindestens `recovery_codes`, `user_hostgroup_roles` (2×),
`group_hostgroup_roles`, `user_group_members` (2×), `sessions`, `audit_log`, `api_tokens` (2×),
`ssh_keys.owner_user_id`, `tenant_admins`. Ein Rebuild ist hier das riskanteste Einzelstück.
Alternative: `home_tenant_id` als tote Spalte stehen lassen (Projektkonvention, vgl.
`0010:9-14`, `0012:12-17`) — dann verhindert die verwaiste FK aber `DROP TABLE tenants`.
**Diese Entscheidung dem Betreiber vorlegen**; sauberer ist der einmalige Rebuild.
Weitere Fallstricke:
* `PRAGMA foreign_keys` muss für den Rebuild aus sein. `app/db.py:31` setzt sie beim
Verbindungsaufbau auf ON, und `_apply_migrations()` (`:41-56`) fährt jede Migration per
`executescript()` auf derselben Verbindung. Die Migration muss die PRAGMA selbst setzen und
am Ende zurücksetzen, ohne sich auf ein umschließendes BEGIN zu verlassen. Danach
`PRAGMA foreign_key_check`.
* `DROP TABLE tenants` **erst ganz zuletzt**, nachdem alle FK-Klauseln verschwunden sind.
* Beim Rename-Schritt `PRAGMA legacy_alter_table` beachten — seit SQLite 3.25 schreibt
`ALTER TABLE … RENAME TO` Referenzen in anderen Tabellen mit um, was beim
12-Schritte-Verfahren unerwünscht ist (betrifft besonders `ssh_keys`, `rdp_credentials`,
`host_groups`, `user_groups`).
* `rdp_credentials.tenant_id` ist `NOT NULL` — der Code darf dort **nicht vor** dem Schema
aufhören zu schreiben.
* **Audit-Kette:** `audit_log` hat keine Tenant-Spalte, enthält aber historische Events
`tenant_created`/`tenant_updated`/`tenant_deleted`/`tenant_admin_granted`/`…_revoked`.
Diese Zeilen **dürfen nicht angefasst werden** — die Trigger `no_audit_update`/
`no_audit_delete` (`0001:136-139`) verweigern jedes UPDATE/DELETE, und jede Änderung bräche
die Hash-Kette. Historische Tenant-Events bleiben dauerhaft im Log; das gehört ins README.
## C.6 Rückbauplan
**Phase 0 — entscheiden und sichern**
1. Ist-Erhebung auf der Produktiv-DB: Anzahl `tenants`, Anzahl `tenant_admins`, `DISTINCT
tenant_id` je betroffener Tabelle, Benutzer mit `home_tenant_id` ≠ 1. Steht überall nur
die 1, ist der Rückbau eine reine Aufräumaktion. Steht dort mehr, ist er eine **bewusste
Zusammenlegung getrennter Datenbestände** und braucht eine dokumentierte Freigabe.
2. Entscheidung zu `tenant_admins`: Beförderung zu `is_admin = 1` oder Degradierung zum
normalen Benutzer. Ohne diese Entscheidung darf Phase 1 nicht starten (C.4).
3. Entscheidung zu `users.home_tenant_id`: echter Rebuild oder tote Spalte.
4. Entscheidung zu bestehenden API-Tokens: pauschal widerrufen (empfohlen) oder bewusst
weiterlaufen lassen.
5. Vollbackup bei gestopptem Dienst. Der Rückbau ist nicht rückwärtskompatibel.
**Phase 1 — Rollenmodell bereinigen (eigener kleiner Schritt).** Beschluss aus 0.2 auf der
DB umsetzen, mit Audit-Event. Danach verhält sich die Anwendung noch exakt wie vorher, aber
niemand hängt mehr rechtlich an `tenant_admins`; ab hier ist Phase 2 sicherheitsneutral.
**Phase 2 — Code zuerst, Schema danach, in einem Wartungsfenster.** Schema-zuerst würde
jeden Listen-Endpunkt sofort mit „no such column" auf 500 werfen. Einzige Ausnahme:
`rdp_credentials.tenant_id` ist `NOT NULL`. Bei einem Single-Node-Jumphost mit einer
SQLite-Datei ist ein kurzes Wartungsfenster (Dienst stoppen → Code deployen → Migration →
starten) die sichere Variante.
6. `app/tenancy.py` löschen.
7. `app/auth/deps.py` bereinigen (Liste in C.3). `is_any_admin` durch `is_admin` ersetzen.
**Bewusst entscheiden:** `require_admin_session` als eigenständige „nur Session, kein
Token"-Schranke **erhalten** — das ist der Privilege-Escalation-Schutz der
Token-Verwaltung und hat mit Mandanten nichts zu tun. `require_global_admin` bleibt für
`/admin/sessions*` und `/docs`, nur die Docstrings werden entkoppelt.
8. `app/admin/routes.py`: Block `:124-261` ersatzlos löschen, die drei Helfer entfernen, in
den gelisteten Fachendpunkten SQL-Fragment, `JOIN tenants`, Ergebnisfelder, INSERT-Spalte
und Scope-Aufrufe entfernen. **Die `_assert_*_in_scope`-Helfer nicht komplett löschen**,
sondern auf die reine Existenzprüfung reduzieren — sie liefern auch das 404 für „gibt es
nicht", und dieses Verhalten muss erhalten bleiben.
9. `app/models/schemas.py`: drei Klassen und sechs Felder entfernen. Hinweis zur
API-Kompatibilität: ohne `extra="forbid"` ignorieren die Modelle unbekannte Felder — ein
Altclient, der weiterhin `tenant_id` sendet, bekommt kein 422. Das ist als weiche
Migration gewollt; harte Ablehnung müsste explizit konfiguriert werden.
10. `app/auth/routes.py:329-344` anpassen — **Breaking Change für `static/js/admin.js`**,
deshalb zwingend gemeinsam deployen.
11. `templates/admin.html` und `static/js/admin.js` bereinigen (Listen in C.3). **Spaltenzahl
in `<thead>` und Tabellenzeilen müssen paarweise passen** — jede vergessene Hälfte
verschiebt die ganze Tabelle. `tests/test_csp_compliance.py` prüft, dass kein Inline-JS
entsteht.
12. Neue Migration `0013_drop_tenants.sql`: Fremdschlüsselprüfung aus → fünf `idx_*_tenant`
droppen → Rebuild der fünf (bzw. sechs) Tabellen → `idx_tenant_admins_user` droppen →
`tenant_admins` und `tenants` droppen → `foreign_key_check` → Fremdschlüsselprüfung
wieder an. Kein UNIQUE-Konflikt zu erwarten (C.2), der `foreign_key_check` am Ende ist
trotzdem Pflicht.
13. Nach der Migration `GET /admin/audit-log/verify` aufrufen: die Hash-Kette muss unverändert
intakt sein.
**Phase 3 — Tests und Doku**
14. `tests/test_tenants.py` (634 Zeilen): die sechs reinen Mandanten-Tests `:93, :123, :150,
:179, :246, :304` löschen; die neun allgemeinen CRUD-/Rollen-Tests `:334-634`
**behalten** und ent-mandantisieren (Helfer `:48`, `:74-76`, Payloads `:160, :190, :192,
:613`); Datei sinnvoll umbenennen, z. B. zu `test_admin_crud.py`.
15. `tests/test_phase9.py:127-138` löschen (baut Mandanten-Admin per Direkt-SQL), `:22`
Doc-Zeile, `:218-219` Payload; `tests/test_phase12.py:104, 113, 365, 373, 389, 397, 410`
(Spalte streichen); `tests/test_phase13.py:114, :245, :317-343`.
16. **Erwarteter Nebeneffekt:** `tests/test_admin_groups_tokens.py:78, 112, 139` und
`tests/test_pentest_security.py:238` senden heute Gruppen-Anlagen ohne `tenant_id` und
laufen deshalb gegen den 400 aus `_resolve_write_tenant()` — das sind bereits jetzt
latent rote Tests, die der Rückbau **repariert**.
17. `README.md`: `:52`, Abschnitt `:108-129` („Phase 8: Mandantenfaehigkeit"), `:228`, `:250`,
`:462-475`, `:490`, `:502-504`, `:629` nachziehen; den Verweis in `:428` auf die
SQLite-Rebuild-Begründung in `0006_tenants.sql` auf die neue Migration umhängen; ergänzen,
dass historische `tenant_*`-Audit-Events bewusst im Log verbleiben.
---
# TEIL D — Berechtigungen ausschließlich über Benutzergruppen
## D.1 Ist-Modell
**Rollen** (`0001_initial.sql:29-39` und `0008_credentials_roles.sql:17-19`; Whitelist
`app/models/schemas.py:18-28`, UI-Liste `static/js/admin.js:4-7`):
| ID | Name | Gedachte Bedeutung | Tatsächlich durchgesetzt? |
|---|---|---|---|
| 1 | `ssh_connect` | SSH-Sitzung auf Hosts der Hostgruppe | ja — `app/ssh_proxy/terminal_ws.py:92-97` |
| 2 | `rdp_connect` | RDP-Sitzung | ja — `app/rdp_proxy/ws_tunnel.py:142-150` |
| 3 | `file_transfer` | SFTP Up-/Download | ja — `app/ssh_proxy/sftp.py:39-46` |
| 4 | `clipboard` | Copy & Paste in der Sitzung | **nein — tote Rolle**; Clipboard hängt nur an `hosts.clipboard_enabled` (`ws_tunnel.py:113, 240`), SSH gar nicht |
| 5 | `session_recording_view` | Playback fremder Sitzungen | **nein — tote Rolle**; Playback ist `require_global_admin` (`app/admin/routes.py:1971, 1996`) |
| 6 | `admin_hostgroup` | delegierte Verwaltung einer Hostgruppe | **nein — tote Rolle**, kein einziger Codepfad prüft sie |
| 7 | `credentials_view` | sehen, welche Keys/Sets an einem Host hängen | ja — `app/admin/routes.py:1114`, `app/catalog/routes.py:91` |
| 8 | `credentials_manage` | zusätzlich zuordnen/entfernen | ja — `app/admin/routes.py:1000, 1038, 1061, 1095, 1697, 1725` |
**Nur 5 von 8 Rollen wirken.** Die drei toten Rollen sind vergebbar und erzeugen eine falsche
Sicherheitserwartung — `clipboard` besonders, weil `Jumphost_Konzept.md:134-135` damit wirbt.
**Prüf-Logik** (`app/rbac.py`, 47 Zeilen): `user_has_role()` (`:13-37`) ist ein einziges
Statement mit zwei per `UNION` verodere Zweigen — Zweig A über `user_hostgroup_roles`
(`:18-23`), Zweig B über `group_hostgroup_roles` JOIN `user_group_members` (`:25-31`), beide
mit Ablaufprüfung auf `expires_at`. **Kombination ist eine reine Vereinigung: keine Priorität,
keine Deny-Regel.** `user_has_role_for_host()` (`:40-47`) liest `hosts.host_group_id` in einer
Extra-Query und delegiert. `is_admin` umgeht RBAC überall.
**Zwei gleichrangige Vergabewege:** direkt pro Benutzer über `user_hostgroup_roles`
(`0001:85-94`; API `app/admin/routes.py:1301-1380`) und über Benutzergruppen via
`group_hostgroup_roles` (`0004:25-35`; API `:1385-1465`). Im UI stehen beide Formulare
optisch gleichwertig untereinander (`templates/admin.html:515-546` und `:548-576`, Handler
`static/js/admin.js:1077-1094` und `:1096-1113`).
**Rechte gibt es ausschließlich auf Hostgruppen-Ebene.** Es existiert keine Tabelle für
Rechte auf einzelne Server; host-bezogene Prüfungen sind reine Umleitungen über die
Hostgruppe. **Ein Host liegt in genau einer Hostgruppe** (`hosts.host_group_id NOT NULL`,
`0001_initial.sql:49`) — 1:n, keine n:m-Tabelle. Die Hostgruppe ist damit gleichzeitig
Ordnungs- und einzige Rechteeinheit; ein Host umzusortieren ändert schlagartig, wer ihn
erreicht.
**Zugangsdaten haben heute keinerlei Berechtigungsebene.** Die Zuordnung läuft ausschließlich
Host ↔ Credential: `host_ssh_key_map` (n:m), `ssh_password_credentials` (1 je Host),
`host_rdp_credential_map` (PK auf `host_id`, also 1 Set je Host). Die Auswahl im Proxy ist
**benutzerblind**: `app/ssh_proxy/proxy.py:311-316` selektiert per `LIMIT 1` **ohne
`ORDER BY`** und ohne Bezug zum anfragenden Benutzer; `app/rdp_proxy/ws_tunnel.py:183-196`
ebenso. Konsequenz: **jeder Benutzer mit `ssh_connect`/`rdp_connect` auf der Hostgruppe meldet
sich zwangsläufig mit demselben, gemeinsam genutzten Zielkonto an.** „Benutzer A darf sich als
`svc-app` anmelden, Benutzer B nur als `readonly`" ist im heutigen Datenmodell nicht
abbildbar. `0008_credentials_roles.sql` führt **keine** Credential-Rechteebene ein, sondern
nur zwei weitere Rollen auf derselben Achse Hostgruppe. `ssh_keys.owner_user_id` (`0001:70`)
wäre der einzige Ansatz — die Spalte ist tot (kein Lese-/Schreibpfad; `tests/test_phase13.py:233-258`
verankert das sogar).
## D.2 Schwachstellen des Ist-Zustands
| ID | Schwachstelle |
|---|---|
| S1 | **Zwei gleichrangige Vergabepfade.** Effektive Rechte sind nirgends an einer Stelle ablesbar; weder API noch UI kennen eine „Effektive Berechtigungen für Benutzer X"-Sicht. Ein Entzug über `/admin/roles/revoke` kann wirkungslos bleiben, weil die Gruppe dasselbe Recht weiterliefert — das UI meldet trotzdem Erfolg (`admin.js:1041-1046`). |
| S2 | **Keine Deny-Regeln, keine Priorität.** `UNION` = das permissivste Ergebnis gewinnt. „Team-Recht, aber für diesen einen Benutzer nicht" ist nicht modellierbar. |
| S3 | **Keine Rechteebene für Zugangsdaten** (D.1). Größte fachliche Lücke. |
| S4 | **Nicht-Admin mit `credentials_manage` kann fremde Zugangsdaten anziehen.** In `app/admin/routes.py:1701-1708` und `:1011-1020` wird die Zugehörigkeit nur für Admins geprüft; im Rollen-Pfad läuft nur eine **Existenzprüfung**. Ein Benutzer mit `credentials_manage` auf seiner Hostgruppe kann einen beliebigen fremden Schlüssel bzw. RDP-Satz auf einen seiner Hosts legen und sich regulär damit verbinden. **Sicherheitslücke, unabhängig vom Umbau zu beheben.** |
| S5 | **Drei tote Rollen** (D.1). |
| S6 | **Protokoll-Lücke.** `app/catalog/routes.py:48, 56` filtert Rollennamen ohne Abgleich mit `h.protocol` und setzt `can_connect` pauschal (`:31, 105`). Siehe A.3/D1. |
| S7 | `file_transfer` wird doppelt geprüft (Rolle + `hosts.file_transfer_enabled`), `clipboard` nur host-global; RDP-Laufwerksumleitung ist gar nicht rechtebehaftet. |
| S8 | **Löschen einer Benutzergruppe = stille Massen-Rechteentziehung.** `app/admin/routes.py:560` räumt via CASCADE `user_group_members` **und** `group_hostgroup_roles` ab — ohne Vorab-Zähler, ohne 409-Sperre (anders als bei Hostgruppen, `:707-714`), und das Audit-Event enthält nur die ID (`:563`). Nach dem Umbau ist die Gruppe der einzige Rechteträger, damit wiegt das doppelt. |
| S9 | **Karteileichen nach Benutzer-Löschung.** Regelfall ist Soft-Delete (`:452-459`), also kein CASCADE: Rollen- und Mitgliedschaftszeilen bleiben. `GET /admin/roles` (`:1361-1372`) joint `users` ohne `deleted_at IS NULL` und listet `deleted_user_<id>`-Vergaben. |
| S10 | **Auditierbarkeit.** Eine Mitgliedschaftsänderung ändert Zugriffsrechte, ohne dass das Ereignis das ausdrückt; `INSERT OR REPLACE` (`:1315`) überschreibt ein bestehendes `expires_at`, ohne den alten Wert festzuhalten. |
| S11 | **Performance/Duplizierung.** `user_has_role_for_host` = zwei Roundtrips; `require_admin_scope_or_host_role` (`app/auth/deps.py:236-238`) schleift mit je eigener Query; `user_hostgroup_roles` hat nur einen Index auf `user_id` (`0001:94`), `group_hostgroup_roles` dagegen auf beiden FK-Spalten (`0004:34-35`). |
| S12 | `revoke_role` (`:1331-1344`) prüft die Hostgruppe, aber nicht den Benutzer-Scope; `revoke_group_role` (`:1415-1428`) prüft die Benutzergruppe nicht. Beim Umbau nicht mitkopieren. |
| S13 | **RBAC-Logik dreifach implementiert:** `app/rbac.py`, `app/catalog/routes.py:36-102` (baut die Vereinigung dreimal von Hand nach) und `app/tenancy.py:90-92`. Jede Modelländerung muss überall nachgezogen werden. |
## D.3 Soll-Modell
**Grundsatz:** Ein Benutzer erhält Rechte **nur** über seine Mitgliedschaft in
Benutzergruppen. Zwei Achsen:
* **Achse A — Zugriff:** Benutzergruppe × Hostgruppe × Rolle (bereits vorhanden,
`group_hostgroup_roles`).
* **Achse B — Zugangsdaten (neu):** Benutzergruppe × Zugangsdatensatz. Beantwortet erstmals,
*womit* sich eine Gruppe anmeldet.
Eine Verbindung kommt nur zustande, wenn ein Zugangsdatensatz existiert, der **sowohl** dem
Host zugeordnet **als auch** einer Gruppe des Benutzers freigegeben ist.
**Entfällt:** `user_hostgroup_roles` als Schreibpfad (Tabelle nach Migration leeren bzw. als
`*_legacy` behalten — Droppen ist im Projekt ohnehin unüblich, vgl. `0010:9-14`, `0012:12-17`).
Ebenso die drei toten Rollen: entweder ausimplementieren oder aus `roles` und beiden
Whitelists streichen — eine bewusste Entscheidung je Rolle, kein Sammelbeschluss.
**Bleibt unverändert:** `roles`, `host_groups`, `hosts`, `user_groups`, `user_group_members`,
`group_hostgroup_roles`, `ssh_keys`, `rdp_credentials`, `ssh_password_credentials`,
`host_ssh_key_map`, `host_rdp_credential_map`.
**Neu (Achse B):** drei schmale Freigabetabellen — Gruppe × SSH-Key, Gruppe ×
RDP-Zugangsdatensatz, Gruppe × SSH-Passwort-Zugangsdaten. Aufbau analog zu
`group_hostgroup_roles`: zusammengesetzter PK, FK auf `user_groups` mit `ON DELETE CASCADE`,
FK auf das Credential-Objekt, dazu `granted_by`, `granted_at`, `expires_at`. Empfehlung:
`ssh_password_credentials` vorher analog zu Migration 0012 in ein eigenständiges,
wiederverwendbares Objekt mit eigener ID umbauen, damit alle drei Credential-Arten dieselbe
Form haben.
**Kardinalität Host ↔ Hostgruppe:** bei 1:n belassen. Eine n:m-Zuordnung würde die Prüfung
erneut auf „irgendeine Gruppe" und damit auf die permissivste Auflösung zurückwerfen.
**Prüf-Queries:** `user_has_role()` schrumpft auf **einen** Zweig — der komplette `UNION`
samt Zweig A (`app/rbac.py:18-24`) entfällt. Die nötigen Indizes existieren bereits
(`0004:23, 35`). Neu hinzu kommen zwei Funktionen in `app/rbac.py`: eine Prüfung „darf dieser
Benutzer diesen Zugangsdatensatz nutzen" und eine Auflösung „welcher Satz gilt für diesen
Benutzer auf diesem Host". Letztere ersetzt die benutzerblinden `LIMIT 1`-Queries in
`app/ssh_proxy/proxy.py:311-316` und `app/rdp_proxy/ws_tunnel.py:183-187`. **Wichtig: eine
deterministische Auswahlregel festlegen.** Bei mehreren Treffern lieber ein klarer Fehler
oder eine explizite Auswahl im Katalog als das heutige `LIMIT 1` ohne `ORDER BY`.
`app/catalog/routes.py:36-107` wird auf dieselbe Gruppenlogik reduziert, zusätzlich gegen
`h.protocol` abgeglichen (S6), und `can_connect` sollte künftig auch abbilden, ob überhaupt
ein für diesen Benutzer nutzbarer Zugangsdatensatz existiert. `app/rbac.py` wird die einzige
Wahrheitsquelle; `catalog` benutzt sie, statt die Logik nachzubauen (S13).
## D.4 Migration bestehender Direktvergaben
Empfohlen: **automatisch erzeugte persönliche Gruppe je betroffenem Benutzer**, danach
manuelle Konsolidierung. Das ist rechteerhaltend — niemand verliert oder gewinnt beim
Umstieg etwas.
1. Für jeden Benutzer mit mindestens einer Zeile in `user_hostgroup_roles` eine
Benutzergruppe anlegen. Wegen `UNIQUE(user_groups.name)` und möglicher Umbenennungen ist
ein eigenes Kennzeichen (Spalte „ist persönliche Gruppe") den ALTER-Aufwand wert — eine
reine Namenskonvention reicht nicht.
2. Den Benutzer als einziges Mitglied eintragen.
3. Alle seine Direktvergaben 1:1 nach `group_hostgroup_roles` übernehmen — **inklusive
`granted_by`, `granted_at` und `expires_at`**, damit die Ablaufsemantik erhalten bleibt.
Konflikte mit bereits bestehenden identischen Gruppenvergaben ignorieren.
4. `user_hostgroup_roles` leeren bzw. umbenennen.
5. Achse B rechteneutral vorbefüllen: für jeden Host mit zugeordnetem Zugangsdatensatz jede
Gruppe freigeben, die auf der Hostgruppe dieses Hosts `ssh_connect` bzw. `rdp_connect`
hat. Das reproduziert exakt das heutige Verhalten.
6. Admin-Report „persönliche Gruppen mit identischem Rechteprofil" als Grundlage für die
Zusammenlegung zu echten Teams, danach restriktives Aufräumen der Vorbefüllung aus
Schritt 5.
Die Alternative „rein manuelle Zuordnung" liefert ein saubereres Ergebnis, bedeutet aber ein
Zeitfenster mit Rechteverlust und ist ab einer gewissen Benutzerzahl nicht praktikabel.
## D.5 Auswirkungen auf API und UI
* `POST /admin/roles/grant` und `/admin/roles/revoke` (`app/admin/routes.py:1301-1353`)
**entfallen**. Übergangsweise mit HTTP 410 und Verweis auf `/admin/group-roles/*`
antworten, damit bestehende Token-Integrationen einen sprechenden Fehler bekommen.
* `GET /admin/roles` wird zur **abgeleiteten** Sicht „effektive Rechte je Benutzer" —
Join über Mitgliedschaft und Gruppenvergabe, mit Spalte „über welche Gruppe". Das schließt S1.
* `/admin/group-roles/*` bleibt der einzige Schreibpfad; im Revoke-Pfad die fehlende
Scope-/Existenzprüfung nachziehen (S12).
* **Neu:** Endpunkte zum Freigeben und Entziehen von Zugangsdaten je Gruppe (Achse B) plus
eine Leseansicht.
* Die Zuordnungs-Endpunkte für Host-Credentials bleiben, aber die Prüfung im Nicht-Admin-Zweig
(`:1011-1020`, `:1701-1708`) wird von „existiert" auf „ist einer meiner Gruppen freigegeben"
umgestellt — das behebt S4.
* `POST /admin/user-groups/{id}/members` (`:584-625`) wird der sicherheitskritischste Endpunkt
des Systems und braucht ein Audit-Event, das die dadurch gewonnenen Rechte mitschreibt (S10).
* UI: Formular und Tabelle der Direktvergabe (`templates/admin.html:517-546`,
`static/js/admin.js:1077-1094`) entfernen; die Gruppenvergabe rückt an die erste Stelle;
die Rollentabelle bleibt als **nicht editierbare** Effektiv-Ansicht mit Spalte „Gruppe".
Neuer Block „Zugangsdaten für Gruppe freigeben". Im Benutzer-Tab pro Benutzer
Mitgliedschaften und die daraus abgeleiteten Rechte anzeigen — das ist die neue Antwort auf
„warum kommt X auf Server Y". `ROLE_NAMES` (`static/js/admin.js:4-7`) von der
Hartkodierung auf `GET /admin/roles/names` umstellen. Hinweistext `templates/admin.html:117`
und `Jumphost_Konzept.md:132-135` nachziehen.
## D.6 Reihenfolge
1. **Vorarbeit, rechteneutral:** tote Rollen entscheiden; `catalog` gegen `h.protocol`
absichern (S6); Scope-Lücken S4 und S12 schließen; `app/rbac.py` als einzige
Wahrheitsquelle etablieren und `catalog` darauf umstellen (S13).
2. **Teil C (Mandanten-Rückbau) muss abgeschlossen sein** — `rdp_credentials.tenant_id` ist
`NOT NULL` und würde sonst in die neuen Freigabetabellen hineinregieren.
3. **Migration:** persönliche Gruppen erzeugen, Direktvergaben spiegeln, Achse-B-Tabellen
anlegen und rechteneutral vorbefüllen (D.4, Schritte 13 und 5).
4. **Lesepfade umstellen:** `rbac.py` auf einen Zweig, `catalog` auf Gruppenlogik, beide
Proxies auf die neue Credential-Auflösung. Ab hier ist Achse B scharf.
5. **Schreibpfade abschalten:** Direktvergabe-Endpunkte auf 410, UI-Formular entfernen,
`user_hostgroup_roles` leeren/umbenennen.
6. **Konsolidierung:** Report über persönliche Gruppen, Zusammenlegung zu Teams, restriktives
Aufräumen der Achse-B-Vorbefüllung.
7. **Tests:** `tests/test_rbac.py` schreibt heute direkt in `user_hostgroup_roles`
(`:27-29`) und muss vollständig neu geschrieben werden. Ebenfalls betroffen:
`tests/test_phase9.py:210, 261, 290`, `tests/test_phase13.py:201`,
`tests/test_tenants.py:347-393`, `tests/test_admin_groups_tokens.py:87-145`.
## D.7 Risiken
| Risiko | Ursache | Gegenmaßnahme |
|---|---|---|
| Rechteverlust beim Abschalten der Direktvergabe | Migration übersieht ablaufende Grants oder Metadaten | Schritt 3 vor Schritt 5; `expires_at` 1:1 übernehmen; Vorher/Nachher-Diff der effektiven Rechte je Benutzer als verpflichtende Migrationsprüfung |
| Rechteausweitung über persönliche Gruppen | eine persönliche Gruppe bekommt später weitere Mitglieder | Kennzeichen „persönlich" plus serverseitige Sperre „max. 1 Mitglied", bis ein Admin sie bewusst in eine echte Gruppe überführt |
| Rechteausweitung durch die Achse-B-Vorbefüllung | Schritt 5 gibt jeder verbindungsberechtigten Gruppe alle Host-Credentials frei | Gegenüber heute bewusst rechteneutral (heute nutzen ohnehin alle dasselbe Konto), aber sofort als Aufräum-Backlog dokumentieren — sonst zementiert die Migration den Ist-Zustand |
| Zugriffsausfall durch Achse B | nach Schritt 4 scheitert jede Verbindung, für die keine Freigabe existiert | Vorab-Report „Hosts mit Zugangsdaten ohne Gruppenfreigabe"; Schritt 4 erst nach dessen Bereinigung |
| Stille Massenentziehung | Gruppenlöschung wird zum einzigen Rechteträger und cascadet zwei Tabellen (S8) | 409-Sperre analog Hostgruppen; Audit-Event mit Vollinhalt der entfernten Vergaben |
| Karteileichen | Soft-Delete räumt Mitgliedschaften nicht ab (S9) | beim Soft-Delete Mitgliedschaften explizit entfernen; `deleted_at IS NULL` in alle Rollenlisten aufnehmen |
| Verhaltensänderung bei der Credential-Auswahl | `LIMIT 1` ohne `ORDER BY` wird durch benutzerabhängige Auswahl ersetzt | Mehrdeutigkeit hart als Fehler behandeln; vorab prüfen, welche Hosts mehr als einen Key in `host_ssh_key_map` haben |
| Notfallzugang | ohne Direktvergabe braucht eine dringende Einzelfreigabe eine Gruppe | bewusst entscheiden und dokumentieren; ggf. eine „Break-Glass"-Gruppe mit befristeter Mitgliedschaft — dafür wäre ein Ablaufdatum an der Mitgliedschaft neu, das gibt es heute nicht |
---
# TEIL E — Mehrere Benutzer gleichzeitig
Der Nutzer verlangt ausdrücklich: *„es muss sichergestellt sein, dass mehrere Benutzer
gleichzeitig eine Session offen haben können"*. Das ist **keine** neue Funktion — es gibt
keine Stelle im Code, die eine zweite gleichzeitige Anmeldung oder eine zweite Sitzung
verbietet. Die Arbeit liegt woanders: die Anwendung ist an mehreren Stellen so gebaut, dass
sie unter Parallelbetrieb entweder **alle** Benutzer gleichzeitig ausbremst oder Daten
beschädigt. Ein Zweitbenutzer merkt heute nichts von einer Sperre — er merkt, dass sein
Terminal stockt, während jemand anders eine Datei hochlädt.
## E.0 Die eine Rahmenbedingung, aus der fast alles folgt
Die Anwendung läuft in **einem** uvicorn-Prozess, mit **einem** Event-Loop
(`ansible/roles/jumphost_app/templates/jumphost-app.service.j2:28-32` — kein `--workers`) und
**einer** globalen SQLite-Verbindung (`app/db.py:20`, `:29`, `isolation_level=None`). Alle
Sitzungen aller Benutzer, die Admin-UI und jeder API-Aufruf teilen sich diesen einen Loop.
Daraus folgt die Faustregel für Teil E: **jede synchrone Operation im Request-/Session-Pfad
ist eine Sperre für sämtliche Benutzer.** Nicht „langsam für den Verursacher" — steht für
alle. Das ist auch der Grund, warum A1 (byteweises Lesen von guacd) in dieser Liste
mitzählt: eine einzige RDP-Sitzung blockiert damit die SSH-Sitzungen aller anderen.
Der zweite Teil der Rahmenbedingung: **prozesslokaler Zustand.** Die Registry laufender
Sitzungen (`app/security/active_sessions.py:40`), der Ringpuffer des Live-Verbindungslogs
(`app/security/log_stream.py`) und der Login-Rate-Limiter
(`app/security/rate_limit.py:19`) liegen im Prozessspeicher. Das ist bei einem Prozess
korrekt und in den Modulen sauber dokumentiert — es heißt aber auch: **die naheliegende
„Lösung" für Parallelität, nämlich `--workers 4`, ist keine.** Sie würde „Sitzung beenden",
die Live-Mitschau und das Rate-Limit still zerlegen. Siehe E.4.
## E.1 Sofort — die zwei Punkte, die heute schon Schaden anrichten
| ID | Fundstelle | Problem | Auswirkung |
|---|---|---|---|
| **E1** | `app/security/audit.py:41-53` | Zwischen `SELECT entry_hash ... ORDER BY id DESC LIMIT 1` und dem `INSERT` liegen mehrere `await`. Es gibt weder eine Transaktion (`app/db.py:29` setzt `isolation_level=None`, jede Anweisung committet einzeln — die `conn.commit()`-Aufrufe an den Aufrufstellen sind wirkungslos) noch eine Sperre. | **Race Condition in der Audit-Hash-Kette.** Zwei gleichzeitig startende oder endende Sitzungen lesen denselben `prev_hash` und schreiben beide darauf. `verify_chain()` (`:56-70`) meldet die Kette ab dieser Stelle als gebrochen — und zwar dauerhaft, denn DB-Trigger verbieten UPDATE/DELETE auf `audit_log`. Der Nachweis der Unverfälschtheit, also der Zweck des ganzen Konstrukts, ist damit im Mehrbenutzerbetrieb **zufällig**. Zu tun: Lesen+Schreiben atomar machen (prozessweite `asyncio.Lock` **und** `BEGIN IMMEDIATE`), plus ein Test, der n Ereignisse parallel schreibt und danach `verify_chain()` prüft. |
| **E7** | `app/main.py:51-60` (`lifespan` ruft nur `init_db()`) | Kein Reaper beim Start: Sitzungen, die beim Absturz/Neustart offen waren, behalten `ended_at IS NULL` für immer. | Nach jedem Neustart wachsen die „laufenden" Sitzungen an. Die Sessionview (`app/admin/routes.py:1897-1940`) zeigt sie als aktiv, aber nicht beendbar (`killable: false`), weil kein Task mehr existiert. Jede künftige Zählung „wie viele Sitzungen hat dieser Benutzer offen" (E6, Teil F) rechnet mit Geistern. Zu tun: beim Start alle offenen Sitzungen mit `end_reason='server_restart'` schließen und **ein** Audit-Ereignis mit der Anzahl schreiben. |
Diese beiden Punkte sind unabhängig von allem anderen und gehören vor Teil C/D erledigt.
## E.2 Blockierende Operationen im Event-Loop
| ID | Fundstelle | Problem | Auswirkung |
|---|---|---|---|
| **E2** | `app/recordings/recorder.py:27` (`open`), `:40-41` (`write` + `flush` je Instruktion) | Synchrone Dateizugriffe direkt im Event-Loop, ein `flush()` pro Frame. | Bei RDP entspricht das dem kompletten Bildstrom (siehe D2 in Teil A). Jeder Frame jeder Sitzung hält den Loop an — für alle anderen Benutzer. Zu tun: puffern, Schreiben in einen Executor/Task auslagern, Größenbegrenzung und Rotation (D2). Die Hash-Verkettung der Aufzeichnung muss dabei erhalten bleiben. |
| **E3** | `app/ssh_proxy/sftp.py:87` → `app/security/av_scan.py` (`subprocess.run(..., timeout=30)`) | Der AV-Scan läuft **synchron** in einer `async`-Route. | Bis zu 30 Sekunden, in denen kein anderer Benutzer eine Taste absetzen kann, kein Bild ankommt und kein Login durchgeht. Zu tun: in einen Thread auslagern (`asyncio.to_thread`/`run_in_threadpool`). |
| **E4** | `app/ssh_proxy/sftp.py:83` (`file.read(MAX_UPLOAD_BYTES + 1)`), `:98` (`sha256` über alles), `:163` (`remote_file.read()` komplett), `:193-195` | Uploads und Downloads werden vollständig im Arbeitsspeicher gehalten, 200 MiB pro Transfer. | Drei parallele Transfers = 600 MiB RSS zusätzlich, plus Hashing im Loop. Auf einer typischen VM ist das der OOM-Killer für den gesamten Dienst — alle Sitzungen weg. Zu tun: strömend lesen/schreiben, Hash inkrementell, Transfergröße und gleichzeitige Transfers begrenzen. |
| **E5** | `app/db.py:20`, `:29` | Eine einzige Verbindung für Lese- und Schreibzugriffe; alle Abfragen serialisieren über deren Thread. | Eine unglückliche Admin-Abfrage verzögert jeden WebSocket-Handshake. Die Pagination ist immerhin gedeckelt (`app/admin/routes.py:1847-1857`, `:1899-1917`, `limit` ≤ 1000) — beim Rückbau (Teil C/D) darf keine neue ungebremste Abfrage entstehen. Prüfen, ob `PRAGMA synchronous` und `busy_timeout` (`:32`, 5000 ms) für den Zielbetrieb passen; die WAL-Konfiguration selbst ist richtig. |
## E.3 Trennung zwischen Benutzern
| ID | Fundstelle | Problem | Auswirkung |
|---|---|---|---|
| **E6** | `app/rdp_proxy/guacd_client.py:222-224` (`drive-path: /var/lib/jumphost/rdp-drives/{host_id}`) | Das umgeleitete Laufwerk ist **pro Host** angelegt, nicht pro Sitzung oder Benutzer. | Zwei Benutzer auf demselben Windows-Ziel teilen sich ein Verzeichnis: A sieht die Dateien von B, überschreibt sie, liest sie mit. Das ist im Mehrbenutzerbetrieb ein Vertraulichkeitsproblem, kein Komfortthema — und es gibt keine Aufräumung. Zu tun: Pfad je Sitzung (oder mindestens je Benutzer **und** Host), beim Sitzungsende entfernen; zusammen mit Befund B6 lösen (`/var/lib/jumphost` ist `0700 jumphost:jumphost`, `ansible/roles/sqlite_init/tasks/main.yml:6-12`, guacd läuft als `guacd` — heute kommt guacd gar nicht erst in das Verzeichnis hinein). |
| **E8** | `app/security/rate_limit.py:32-34` (10 Logins/Minute je Quell-IP), `ansible/roles/nginx_proxy/templates/jumphost.conf.j2:12` + `:38` (10r/m je IP), `ansible/roles/fail2ban/tasks/main.yml` (`maxretry = 8`, `findtime = 300`, `bantime = 1800`) | Alle drei Bremsen greifen **pro Quell-IP**. | Sitzen zehn Benutzer hinter einer NAT-Adresse — der Normalfall in einem Firmennetz —, teilen sie sich ein Budget von 10 Anmeldeversuchen pro Minute, und acht Tippfehler beliebiger Kollegen sperren die ganze Firma für 30 Minuten aus. Genau das kollidiert mit der Anforderung „mehrere Benutzer gleichzeitig". Zu tun: die kontenbezogene Sperre (`app/auth/routes.py`, `max_failed_logins`) bleibt die scharfe Bremse; das IP-Budget an die erwartete Benutzerzahl anpassen bzw. für bekannte interne Netze anheben, und die fail2ban-Schwelle entsprechend. Bewusst entscheiden und im Betriebshandbuch festhalten. |
| **E9** | `app/auth/deps.py:70-79` gegen `:85-105` | Der gleitende Idle-Timeout wird nur bei **HTTP**-Anfragen verlängert; der WebSocket-Handshake tut das nicht (kann er auch nicht). `settings.session_idle_timeout_s` steht auf 900 s. | Ein Benutzer, der eine Stunde ausschließlich im Terminal arbeitet, hat ein abgelaufenes Cookie: die laufende Sitzung überlebt, aber jede weitere Aktion — zweite Sitzung öffnen, Dateitransfer, Katalog laden — endet mit „Session abgelaufen". Mit mehreren gleichzeitigen Sitzungen fällt das ständig auf. Zu tun: leichter Keepalive aus der Sitzungsoberfläche auf einen billigen HTTP-Endpunkt, oder Auffrischung von `last_seen` durch WS-Aktivität. Voraussetzung für Teil F. |
| **E10** | `app/ssh_proxy/terminal_ws.py:39` + `:147` (`IDLE_TIMEOUT_SECONDS = 15 * 60` auf `websocket.receive_json()`) | Der Timeout misst **nur Eingaben des Benutzers**, nicht Aktivität der Sitzung. | Ein laufendes `tail -f`, ein langer Build, eine Sitzung im Hintergrundtab: nach 15 Minuten ohne Tastendruck getrennt. Im Mehrsitzungsbetrieb (Teil F) trifft das systematisch jede nicht gerade sichtbare Sitzung. Zu tun: Inaktivität in **beiden** Richtungen messen, Wert konfigurierbar machen, und den Grund als Klartext senden (Teil B). |
| **E11** | `app/ssh_proxy/terminal_ws.py:38` (`MAX_SESSION_SECONDS = 8 * 3600`) | Die Konstante wird nirgends verwendet. | Es gibt keine absolute Obergrenze für die Dauer einer Sitzung — anders als beim Anmelde-Cookie (`app/config.py:72`). Zu tun: entweder umsetzen (mit Vorwarnung an den Benutzer) oder die Konstante entfernen; nichts ist schlimmer als ein Sicherheitsversprechen, das nur als toter Bezeichner existiert. |
## E.4 Obergrenzen und Ressourcen
Heute gibt es **keine** Grenze für die Zahl gleichzeitiger Sitzungen — weder je Benutzer noch
global. Solange Sitzungen in eigenen Browser-Tabs geöffnet werden, fällt das nicht auf; mit
der Seitenleiste aus Teil F wird es zur Normalität, dass ein Benutzer fünf Sitzungen hält.
Fehlende Grenzen an drei Stellen:
* **Anwendung:** kein Zähler je Benutzer, kein globaler Zähler. Einzuführen als
konfigurierbare Werte, mit einer Ablehnung im Klartext („Sie haben bereits N Sitzungen
offen") statt eines wortlosen Verbindungsendes — Teil B gilt auch hier.
* **uvicorn:** kein `--limit-concurrency`, kein `--backlog`
(`jumphost-app.service.j2:28-32`).
* **systemd:** kein `LimitNOFILE` in der Unit. Jede Sitzung kostet mindestens zwei Sockets
(Browser-WS, Ziel), RDP zusätzlich die guacd-Verbindung; dazu die Aufzeichnungsdatei. Der
Standardwert reicht für den geplanten Betrieb, aber der Wert gehört gesetzt und dokumentiert,
statt geerbt zu werden.
Ebenfalls zu klären: guacd startet je RDP-Sitzung einen eigenen Prozess. Speicher- und
CPU-Bedarf je gleichzeitiger RDP-Sitzung einmal messen (Auflösung, Farbtiefe) und daraus die
Obergrenze ableiten — nicht schätzen.
## E.5 Was ausdrücklich NICHT getan werden soll
**Keine zusätzlichen uvicorn-Worker.** Das ist die naheliegende, falsche Antwort auf
„mehrere Benutzer". Sie würde drei Funktionen still beschädigen:
* „Sitzung beenden" und die Live-Mitschau wirken nur innerhalb des eigenen Prozesses
(`app/security/active_sessions.py:12-26`, `app/admin/routes.py:1920-1940`) — ein Superadmin
könnte fremde Sitzungen weder sehen noch beenden.
* Das Login-Rate-Limit (`app/security/rate_limit.py:4-7`) würde sich mit der Zahl der Worker
multiplizieren.
* Das Live-Verbindungslog zeigte nur die Meldungen eines Workers.
Der Weg zu mehr Gleichzeitigkeit führt über E1E11 (nichts Blockierendes im Loop), nicht über
mehr Prozesse. Sollte der Betrieb später doch mehrere Prozesse verlangen, ist das ein eigenes
Vorhaben mit einem gemeinsamen Zustandsspeicher für genau diese drei Dinge.
## E.6 Abnahmekriterium für Teil E
Ein reproduzierbarer Lasttest gehört zum Ergebnis, nicht ein „läuft bei mir":
1. Drei Benutzer, gleichzeitig angemeldet: zwei SSH-Sitzungen und eine RDP-Sitzung parallel.
2. Während die RDP-Sitzung Vollbildinhalte überträgt, muss die Eingabe in beiden
SSH-Sitzungen ohne wahrnehmbare Verzögerung ankommen (Zielwert festlegen und messen,
z. B. Echo-Zeit < 150 ms im 95. Perzentil).
3. Während eines 100-MiB-Uploads mit aktivem AV-Scan bleiben alle Sitzungen bedienbar.
4. Nach dem Test: `verify_chain()` über das Audit-Log ist intakt, `sessions` enthält keine
Zeile ohne `ended_at`, deren Task nicht mehr existiert.
5. Neustart des Dienstes mit drei offenen Sitzungen: danach steht keine dieser Sitzungen mehr
als aktiv" in der Sessionview.
---
# TEIL F — Mehrere Sitzungen je Benutzer mit Seitenleiste
Anforderung des Nutzers: *„ein Benutzer kann mehrere Sessions offen haben, mit z. B. einer
Seitenleiste zum Switchen der offenen Session"*. Technisch ist das heute bereits möglich
nur eben als Stapel von Browser-Tabs, ohne Übersicht, ohne gemeinsamen Rahmen, und mit einem
Server, der von diesen Sitzungen nichts weiß, was er dem Benutzer zeigen könnte.
**Teil F setzt Teil E voraus.** Eine Oberfläche, die fünf Sitzungen gleichzeitig sichtbar
hält, auf einem Event-Loop, der schon bei zwei RDP-Sitzungen einbricht, wäre eine
Verschlechterung gegenüber dem heutigen Zustand.
## F.1 Ist-Zustand
* Jede Sitzung ist eine **eigene Seite**: `GET /terminal/{host_id}` bzw. `GET /rdp/{host_id}`
(`app/main.py:126-133`), gerendert aus `templates/terminal.html` / `templates/rdp.html`.
* Das Dashboard öffnet sie in einem neuen Tab: `static/js/dashboard.js:54-62`
(`connect.target = "_blank"`).
* Beide Sitzungsskripte sind **Singletons auf Dokumentebene**: sie greifen über feste IDs auf
genau ein Element zu (`static/js/terminal.js:18-30`, `static/js/rdp.js:14-17`), hängen
Tastatur, Zwischenablage und Fenstergröße global ans Dokument (`rdp.js:78-89`,
`terminal.js:113`) und beenden sich per `window.close()` (`rdp.js:111-120`, analog in
`terminal.js`).
* Der Server kennt zwar alle Sitzungen (`sessions`-Tabelle,
`app/security/active_sessions.py`), stellt sie aber **nur Superadmins** zur Verfügung
(`app/admin/routes.py:1897`). Ein normaler Benutzer hat keine Möglichkeit zu erfahren,
welche Sitzungen er selbst offen hat.
* Es gibt keinen Wiederanschluss: die WebSocket-Verbindung **ist** die Sitzung. Der
`finally`-Block in `app/ssh_proxy/terminal_ws.py:223-246` beendet SSH-Verbindung,
Aufzeichnung und Datenbankzeile, sobald das Socket schließt. Ein Seiten-Reload beendet die
Sitzung und beginnt eine neue.
## F.2 Sollbild
Eine dauerhafte Arbeitsfläche (Vorschlag: `GET /workspace`), die den Rahmen stellt und die
Sitzungen darin hält:
* **Seitenleiste** mit einer Kachel je offener Sitzung: Hostname, Protokollsymbol,
Statuspunkt (verbinde / verbunden / getrennt / Fehler), Laufzeit, Schließen-Knopf.
* **Umschalten ohne Verbindungsabbruch** der wichtigste Punkt überhaupt. Die inaktive
Sitzung wird ausgeblendet, nicht abgebaut.
* **„Neue Sitzung"** öffnet den Hostkatalog (`GET /catalog/hosts`) direkt in der Arbeitsfläche.
* **Sammelzustand**: sichtbare Kennzeichnung, wenn eine Hintergrundsitzung einen Fehler oder
eine Trennung meldet sonst bemerkt der Benutzer es erst beim Umschalten.
* Die Werkzeugleiste (Vollbild, Dateitransfer, Strg+Alt+Entf, Exit) gehört **je Sitzung**,
nicht global.
* Das Dashboard bleibt als Einstieg erhalten; Verbinden" führt künftig in die Arbeitsfläche
statt in einen neuen Tab. Der bisherige Direkteinstieg über `/terminal/{id}` und `/rdp/{id}`
sollte weiterbestehen (Lesezeichen, Fehlersuche) dann aber als dünner Rahmen um denselben
Sitzungsbaustein, nicht als zweite Implementierung.
## F.3 Was das technisch bedeutet
Das ist der eigentliche Umbau. Die Reihenfolge ist bewusst so gewählt, dass jede Stufe für
sich lauffähig bleibt.
1. **Aus Skripten werden Bausteine.** `terminal.js` und `rdp.js` müssen von ein Skript pro
Seite" zu eine Instanz pro Sitzung, in einem übergebenen Container" werden mit
`connect()`, `dispose()`, `setActive(bool)` und einem Statusrückruf für die Seitenleiste.
Feste Element-IDs (`session-shell`, `status`, `terminal`, `rdp-display`,
`fullscreen-btn`, `ft-*` in `templates/terminal.html:11-47`) sind dabei durch
instanzbezogene Referenzen zu ersetzen. Das ist der größte Einzelposten in Teil F.
2. **Tastatur und Zwischenablage dürfen nur die aktive Sitzung erreichen.**
`new Guacamole.Keyboard(document)` (`rdp.js:78`) und der `paste`-Listener am Dokument
(`:82`) würden sonst Eingaben an **alle** offenen RDP-Sitzungen gleichzeitig schicken
ein Tastendruck landet auf drei Windows-Servern. Ebenso `window.addEventListener("resize")`
in `terminal.js:113`. Beim Umschalten müssen gedrückte Modifiertasten zurückgesetzt werden
(sonst bleibt Strg/Alt in der verlassenen Sitzung hängen").
3. **Sichtbarwerden heißt neu einpassen.** Eine ausgeblendete Sitzung hat Größe 0. Beim
Umschalten muss xterm.js neu eingepasst und die neue Größe gesendet werden
(`terminal.js:107-112`), und bei RDP `client.sendSize()` das existiert bis heute
überhaupt nicht (Befund D4 in Teil A). **F.3.3 setzt D4 voraus.**
4. **Hintergrundsitzungen dürfen nicht sterben.** Siehe E10: der Idle-Timeout misst nur
Tastatureingaben. Ohne die Korrektur aus Teil E trennt der Server jede Sitzung, die
15 Minuten lang nur im Hintergrund lag die Seitenleiste wäre eine Liste toter Kacheln.
5. **Das Anmelde-Cookie muss am Leben bleiben.** Siehe E9: eine Arbeitsfläche, die nur
WebSockets spricht, lässt das Cookie nach 15 Minuten verfallen; Neue Sitzung" scheitert
dann mit Session abgelaufen", während drei Sitzungen sichtbar laufen.
6. **Eine eigene API für die eigenen Sitzungen.** Neu, im Katalog-Router (nicht im
Admin-Router!): eine Liste der eigenen offenen Sitzungen und ein Endpunkt zum Beenden einer
eigenen Sitzung. Zwingend auf `user_id = <eigene>` eingeschränkt `GET /admin/sessions`
(`app/admin/routes.py:1897-1940`) darf dafür **nicht** geöffnet werden, es zeigt fremde
Sitzungen, Benutzernamen und Aufzeichnungspfade. Für die Abfrage eigene, noch offene
Sitzungen" empfiehlt sich ein passender Index (`sessions(user_id, ended_at)`; heute gibt es
nur `idx_sessions_user`, `app/db/migrations/0001_initial.sql:107`).
7. **Ehrlich bleiben beim Reload.** Eine Kachel darf nicht suggerieren, dass eine Sitzung
einen Seiten-Reload überlebt sie tut es nicht (F.1, letzter Punkt). Entweder die
Oberfläche sagt das klar („Sitzung wurde beendet, erneut verbinden"), oder es wird ein
eigenes Vorhaben daraus (serverseitiges Halten der SSH-Verbindung samt Ausgabepuffer über
eine Trennung hinweg). **Das ist kein Nebenbei-Feature und gehört nicht in Teil F
hineingeschmuggelt.** Empfehlung: Stufe 1 ohne Wiederanschluss, klar beschriftet.
8. **Obergrenze auch in der Oberfläche.** Die Grenze aus E.4 muss sichtbar sein: die
Schaltfläche Neue Sitzung" wird deaktiviert und begründet, statt eine Verbindung zu
öffnen, die der Server dann ablehnt.
9. **Ressourcen im Browser.** Jede RDP-Kachel ist ein eigener Canvas mit laufendem Bildstrom;
der Server sendet auch an unsichtbare Sitzungen weiter. Zu entscheiden und zu dokumentieren:
bleiben Hintergrundsitzungen voll verbunden (einfach, teuer) oder werden sie nach einer
Weile in einen Halte-/Pausenzustand versetzt (sparsam, aber neuer Zustand im Protokoll).
Empfehlung für Stufe 1: voll verbunden bleiben, mit niedriger Obergrenze.
10. **Audit und Aufzeichnung bleiben unverändert pro Sitzung.** Jede Kachel ist weiterhin eine
eigene `sessions`-Zeile mit eigener Aufzeichnung. Es darf keine Sammelsitzung" der
Arbeitsfläche entstehen die Nachvollziehbarkeit je Zielsystem ist der Kern des Konzepts.
11. **Keine neuen externen Abhängigkeiten.** Die CSP erlaubt ausschließlich `'self'`
(`app/main.py:87-96`), Vendor-Bibliotheken werden lokal gebaut
(`ansible/roles/frontend_assets`). Die Arbeitsfläche wird mit dem bestehenden Bestand
gebaut kein Framework, kein CDN.
## F.4 Vorgeschlagener Schnitt
| Stufe | Inhalt | Danach lauffähig? |
|---|---|---|
| F1 | `terminal.js`/`rdp.js` zu instanzierbaren Bausteinen umbauen, bestehende Seiten `/terminal/{id}` und `/rdp/{id}` auf diese Bausteine umstellen | ja Verhalten unverändert, reiner Umbau |
| F2 | API für eigene Sitzungen (F.3.6) + Index | ja |
| F3 | Arbeitsfläche `/workspace` mit Seitenleiste, eine Sitzung gleichzeitig | ja |
| F4 | Mehrere gleichzeitige Sitzungen, Umschalten, Fokus-/Tastaturtrennung (F.3.2, F.3.3) | ja |
| F5 | Obergrenzen, Statusanzeige, Sammelfehler, Feinschliff | ja |
Nach F1 lohnt ein Zwischenhalt: dieser Umbau ist die Stelle, an der sich die bisherigen
RDP-Korrekturen aus Teil A bewähren müssen.
## F.5 Risiken
| Risiko | Ursache | Gegenmaßnahme |
|---|---|---|
| Eingaben landen in der falschen Sitzung | dokumentweite Tastatur-/Paste-Listener (F.3.2) | Fokusmodell zuerst bauen, dann erst mehrere Sitzungen zulassen; expliziter Test mit zwei RDP-Sitzungen |
| Hintergrundsitzungen brechen ab | E10, E9 | Teil E vorher abschließen; Statuspunkt je Kachel macht Abbrüche sofort sichtbar |
| Browser wird langsam/instabil | mehrere Bildströme gleichzeitig (F.3.9) | niedrige Obergrenze in Stufe 1, gemessen statt geschätzt |
| Verlust der Nachvollziehbarkeit | Versuch, mehrere Ziele über eine Sitzung zu führen | F.3.10 als harte Regel; Abnahme prüft eine `sessions`-Zeile je Kachel |
| Rechteumgehung über die neue Sitzungs-API | Wiederverwendung des Admin-Endpunkts | eigener Endpunkt im Katalog-Router mit fester Einschränkung auf den eigenen Benutzer; Test mit fremder `session_id` |
## F.6 Abnahmekriterium für Teil F
Ein Benutzer öffnet vier Sitzungen (zwei SSH, zwei RDP) in der Arbeitsfläche, schaltet
mehrfach zwischen ihnen um und lässt sie 30 Minuten offen. Danach gilt: alle vier sind noch
verbunden, in jeder ist die zuletzt gesehene Ausgabe vollständig vorhanden, Eingaben landen
ausschließlich in der sichtbaren Sitzung, die Größe stimmt nach jedem Umschalten, es
existieren genau vier `sessions`-Zeilen mit vier Aufzeichnungen, und ein zweiter Benutzer
arbeitet währenddessen unbeeinträchtigt.
---
# Offene Entscheidungen für den Betreiber
Diese acht Punkte sind **keine** Implementierungsdetails und müssen vor der jeweiligen Phase
beantwortet werden:
1. **guacd-Bezugsquelle auf Debian 12** Quellcode-Build 1.5.5 gegen `freerdp2-dev`
(heutiger Rollen-Default, funktioniert auf bookworm) oder Ausweichen auf Ubuntu bzw. einen
Container, um den Build ganz zu vermeiden (Teil A.2). Ein Debian-Paket ist **keine**
Option: `guacamole-server` ist seit 2022 nicht mehr in Debian.
2. **Umgang mit bestehenden Mandanten-Admins** Beförderung zu Voll-Admin oder Degradierung
(Teil C.4/C.6, Phase 0.2). Ohne Antwort darf Teil C nicht starten.
3. **`users.home_tenant_id`** echter Tabellen-Rebuild oder tote Spalte, mit der Folge, dass
`tenants` als leere Tabelle stehen bleibt (Teil C.5).
4. **Bestehende API-Tokens** pauschal widerrufen (empfohlen) oder weiterlaufen lassen
(Teil C.6, Phase 0.4).
5. **Die drei toten Rollen** je Rolle ausimplementieren oder entfernen (Teil D.3).
6. **Obergrenze gleichzeitiger Sitzungen** je Benutzer und global (Teil E.4). Ohne Zahl
kann Teil F die Grenze weder durchsetzen noch anzeigen.
7. **Verhalten von Hintergrundsitzungen** voll verbunden lassen oder nach einer Frist in
einen Pausenzustand versetzen (Teil F.3.9).
8. **Wiederanschluss nach Seiten-Reload** als eigenes Vorhaben planen oder bewusst
ausschließen und in der Oberfläche so benennen (Teil F.3.7).
# Abnahmekriterien
* **Teil A:** RDP-Sitzung zu einem Windows-Ziel baut auf und zeigt ein Bild; Fensterwechsel
und Scrollen bleiben flüssig; parallel laufende SSH-Sitzungen werden dabei nicht langsamer.
* **Teil B:** die drei Störfälle aus B.3 liefern in Log **und** Browser unterscheidbare
Klartextmeldungen mit Adresse, Schritt und Korrelations-ID.
* **Teil C:** `grep -ri tenant` findet außerhalb von `app/db/migrations/0006*`, `0012*` und den
historischen Audit-Einträgen nichts mehr; `PRAGMA foreign_key_check` ist leer; die
Audit-Hash-Kette ist intakt.
* **Teil E:** der Lasttest aus E.6 läuft durch; die Audit-Kette bleibt unter Parallellast
intakt; nach einem Neustart existiert keine Geistersitzung.
* **Teil F:** das Szenario aus F.6 läuft durch; Eingaben landen nachweislich nur in der
sichtbaren Sitzung; je Kachel genau eine `sessions`-Zeile mit Aufzeichnung.
* **Teil D:** kein Schreibpfad mehr auf `user_hostgroup_roles`; der Vorher/Nachher-Diff der
effektiven Rechte je Benutzer ist leer; für jeden Host mit Zugangsdaten existiert mindestens
eine Gruppenfreigabe; die Testsuite läuft vollständig grün.

View File

@ -27,8 +27,45 @@ acme_email: "admin@example.com"
# --- Firewall ---------------------------------------------------------------- # --- Firewall ----------------------------------------------------------------
ssh_admin_access_cidr: "10.10.1.0/24" # Management-Netz fuer SSH-Zugriff AUF den Jumphost selbst ssh_admin_access_cidr: "10.10.1.0/24" # Management-Netz fuer SSH-Zugriff AUF den Jumphost selbst
target_networks: # Netze der Zielsysteme (fuer ausgehende Regeln) # Netze der Zielsysteme (fuer ausgehende Regeln der Output-Chain, siehe
- "10.20.0.0/16" # ansible/roles/firewall_nftables/templates/jumphost.nft.j2). War bislang
# 10.20.0.0/16 -- die tatsaechlichen RDP-/SSH-Ziele liegen in 10.0.0.0/8
# (Befund B5, Umsetzungsauftrag_Sonnet5.md Teil A.3): stand die Adresse
# ausserhalb dieses Bereichs, wurden guacds ausgehende RDP-Pakete von der
# Output-Chain (policy drop) still verworfen -- kein Reject, kein Log --
# und der Benutzer sah nur "Server timeout" statt eines erklaerbaren
# Fehlers. An das tatsaechliche Zielnetz anpassen, falls es abweicht.
target_networks:
- "10.0.0.0/8"
# --- Login-Rate-Limiting (E8, Umsetzungsauftrag Teil E) ----------------------
# Drei unabhaengige Bremsen, alle IP-basiert (siehe app/security/rate_limit.py,
# app/config.py login_rate_limit_*): App-Rate-Limiter (JUMPHOST_LOGIN_RATE_
# LIMIT_MAX_EVENTS/_WINDOW_S), dieses nginx-limit_req und die fail2ban-
# Schwelle unten. ACHTUNG: greifen pro Quell-IP -- sitzen mehrere interne
# Benutzer hinter derselben NAT-Adresse (typischer Buerofall), teilen sie
# sich dieses Budget, und ein paar Tippfehler beliebiger Kollegen koennen
# die ganze Gruppe aussperren. Der kontenbezogene Lockout
# (JUMPHOST_MAX_FAILED_LOGINS) bleibt davon unberuehrt und ist die
# eigentliche scharfe Bremse. Bewusst an die erwartete Umgebung anpassen
# (z.B. hier anheben und/oder bekannte interne Netze in der Firewall-Regel
# gesondert behandeln) und die Entscheidung im Betriebshandbuch festhalten
# -- NICHT ungeprueft mit den unten stehenden Standardwerten produktiv gehen,
# wenn mehrere Benutzer hinter derselben Adresse sitzen koennen.
jumphost_login_rate_limit: "10r/m" # nginx limit_req_zone rate=
jumphost_fail2ban_maxretry: 8 # fail2ban [jumphost-login] maxretry
# --- Obergrenzen gleichzeitiger Sitzungen (E.4, Umsetzungsauftrag Teil E) ----
# jumphost_uvicorn_limit_concurrency ist die AEUSSERSTE Bremse (uvicorn lehnt
# neue Verbindungen ab, bevor die Anwendung ueberhaupt beteiligt ist) und
# sollte daher spuerbar UEBER JUMPHOST_MAX_SESSIONS_GLOBAL liegen (siehe
# app/config.py) -- sie deckt zusaetzlich kurzlebige HTTP-Anfragen
# (Katalog, Dateitransfer, Adminbereich) mit ab, nicht nur WS-Sitzungen.
# jumphost_limit_nofile mit Marge fuer: 2x Sockets je Sitzung + guacd-
# Verbindung (RDP) + Aufzeichnungsdatei + normale Prozess-FDs.
jumphost_uvicorn_limit_concurrency: 150
jumphost_uvicorn_backlog: 100
jumphost_limit_nofile: 4096
# --- Backup -------------------------------------------------------------------- # --- Backup --------------------------------------------------------------------
backup_dir: /var/backups/jumphost backup_dir: /var/backups/jumphost

View File

@ -28,11 +28,16 @@
group: root group: root
mode: "0644" mode: "0644"
content: | content: |
# E8 (Umsetzungsauftrag Teil E): jumphost_fail2ban_maxretry ist ein
# group_vars-Wert (siehe ansible/inventory/group_vars/all.yml) --
# greift wie das nginx-limit_req und der App-Rate-Limiter pro
# Quell-IP und muss zusammen mit diesen als bewusste
# Betriebsentscheidung festgelegt werden.
[jumphost-login] [jumphost-login]
enabled = true enabled = true
filter = jumphost-login filter = jumphost-login
logpath = /var/log/jumphost/access.log logpath = /var/log/jumphost/access.log
maxretry = 8 maxretry = {{ jumphost_fail2ban_maxretry }}
findtime = 300 findtime = 300
bantime = 1800 bantime = 1800
notify: restart fail2ban notify: restart fail2ban

View File

@ -42,5 +42,14 @@ table inet filter {
# ACME/interne PKI (tls_certificates-Rolle) und OS-Paketquellen # ACME/interne PKI (tls_certificates-Rolle) und OS-Paketquellen
tcp dport 443 accept tcp dport 443 accept
tcp dport 80 accept tcp dport 80 accept
# Befund B5 (Umsetzungsauftrag_Sonnet5.md Teil A.3): ohne diese Zeile
# verwirft "policy drop" alles Uebrige lautlos -- z.B. guacds RDP-Pakete
# zu einem Ziel ausserhalb von target_networks. Das sieht der Benutzer
# nur als "Server timeout" (guacamole-common-js), im Journal steht dazu
# nichts. Rate-limitiert geloggt, damit ein Fehlkonfigurationsfall
# (falsches/fehlendes target_networks-Netz) in Sekunden statt Tagen
# auffaellt, ohne das Journal bei einem Portscan zu fluten.
limit rate 10/second log prefix "jumphost-output-drop: " counter drop
} }
} }

View File

@ -157,9 +157,17 @@
when: _guacd_install_mode == 'source' and _guacd_build_mode == 'git' and _guacd_build_needed when: _guacd_install_mode == 'source' and _guacd_build_mode == 'git' and _guacd_build_needed
- name: guacamole-server konfigurieren (nur RDP-Plugin, kein VNC/Telnet/guacenc) - name: guacamole-server konfigurieren (nur RDP-Plugin, kein VNC/Telnet/guacenc)
# --sysconfdir=/etc ist Pflicht (Befund 3, Umsetzungsauftrag_Sonnet5.md
# Teil A.2): ohne diesen Schalter sucht guacd sein Konfigverzeichnis unter
# /usr/local/etc/guacamole, waehrend die Tasks weiter unten unveraendert
# nach /etc/guacamole schreiben. Solange guacd_port/bind_host auf den
# (unwirksamen) Defaults stehen faellt das nicht auf; sobald einer davon
# abweicht, lauscht guacd auf dem falschen Port/Host und die App bekommt
# "[Errno 111] Connection refused".
ansible.builtin.command: ansible.builtin.command:
cmd: >- cmd: >-
./configure --with-systemd-dir=/etc/systemd/system ./configure --with-systemd-dir=/etc/systemd/system
--sysconfdir=/etc
--disable-guacenc --disable-guaclog --disable-guacenc --disable-guaclog
chdir: "{{ _guacd_src_dir }}" chdir: "{{ _guacd_src_dir }}"
when: _guacd_install_mode == 'source' and _guacd_build_needed when: _guacd_install_mode == 'source' and _guacd_build_needed
@ -185,14 +193,67 @@
when: _guacd_install_mode == 'source' and _guacd_build_needed when: _guacd_install_mode == 'source' and _guacd_build_needed
changed_when: false changed_when: false
- name: Pruefen, dass das RDP-Plugin tatsaechlich gebaut und installiert wurde
# Der Build ueberspringt das RDP-Plugin STILLSCHWEIGEND, wenn die
# FreeRDP-Header (_guacd_freerdp_pkg) beim configure-Lauf fehlten oder
# nicht gefunden wurden -- "make"/"make install" laufen trotzdem
# fehlerfrei durch. Ohne diese Pruefung faellt das erst zur Laufzeit auf,
# als "Support for protocol "rdp" is not installed" von guacd, mitten in
# einem Verbindungsversuch (Umsetzungsauftrag_Sonnet5.md Teil A.2, "Zu
# tun"-Liste). Beide moeglichen Installationspfade des Plugins abdecken:
# /usr/local/lib direkt (aeltere guacamole-server-Versionen) und
# /usr/local/lib/guacamole (aktuelle Versionen, eigenes Plugin-Verzeichnis).
ansible.builtin.find:
paths:
- /usr/local/lib
- /usr/local/lib/guacamole
patterns: "libguac-client-rdp.so*"
recurse: false
register: _guacd_rdp_plugin
when: _guacd_install_mode == 'source'
- name: Abbrechen, wenn das RDP-Plugin fehlt
ansible.builtin.fail:
msg: >-
guacd wurde gebaut, aber libguac-client-rdp.so* wurde weder unter
/usr/local/lib noch /usr/local/lib/guacamole gefunden. Vermutliche
Ursache: die FreeRDP-Entwicklungspakete ({{ _guacd_freerdp_pkg }})
waren beim './configure'-Lauf nicht vollstaendig installiert, das
RDP-Plugin wurde daraufhin stillschweigend uebersprungen. Pruefen:
'apt policy {{ _guacd_freerdp_pkg }}' auf dem Zielsystem, danach den
Build-Ordner ({{ _guacd_src_dir }}) loeschen und die Rolle erneut
ausfuehren, damit configure/make sauber neu laufen.
when: _guacd_install_mode == 'source' and (_guacd_rdp_plugin.matched | default(0)) == 0
# --- Gemeinsame Konfiguration (beide Pfade) --------------------------------- # --- Gemeinsame Konfiguration (beide Pfade) ---------------------------------
- name: Dedizierten guacd-User sicherstellen (apt-Paket legt ihn i.d.R. bereits an) - name: Dedizierten guacd-User sicherstellen (apt-Paket legt ihn i.d.R. bereits an)
# Befund B6: als Zweitgruppe "{{ jumphost_app_group }}" (append, nicht
# ersetzen) bekommt guacd das Traversal-Bit auf {{ jumphost_data_dir }}
# (siehe Rolle sqlite_init) und kann damit ueberhaupt erst bis zu
# rdp-drives/ vordringen. Ohne diese Zeile schlaegt jede RDP-Sitzung mit
# aktivierter Laufwerksumleitung mit EACCES fehl.
ansible.builtin.user: ansible.builtin.user:
name: guacd name: guacd
system: true system: true
shell: /usr/sbin/nologin shell: /usr/sbin/nologin
create_home: false create_home: false
groups: "{{ jumphost_app_group }}"
append: true
notify: restart guacd # neue Gruppenmitgliedschaft wirkt erst nach einem Neustart des Prozesses
- name: Konfigurationsverzeichnis /etc/guacamole anlegen
# Befund 4 (Umsetzungsauftrag_Sonnet5.md Teil A.2): lineinfile mit
# create:true legt nur die FEHLENDE DATEI an, nicht das Verzeichnis --
# ohne diesen Task bricht die Rolle bei den beiden folgenden lineinfile-
# Tasks ab, wenn /etc/guacamole noch nicht existiert (frischer Host, oder
# apt-Pfad ohne vorherigen Paketaufruf).
ansible.builtin.file:
path: /etc/guacamole
state: directory
owner: root
group: root
mode: "0755"
- name: guacd nur an localhost binden - name: guacd nur an localhost binden
ansible.builtin.lineinfile: ansible.builtin.lineinfile:
@ -236,3 +297,67 @@
notify: notify:
- reload systemd - reload systemd
- restart guacd - restart guacd
# --- Start-/Erreichbarkeitspruefung (Befunde 1, 2, 7 aus Umsetzungsauftrag_Sonnet5.md Teil A.2) ---
- name: Anstehende Handler (z.B. "restart guacd" nach Config-/Unit-Aenderung) sofort anwenden
# Befund 7: Handler werden von Ansible standardmaessig erst am ENDE DES
# GESAMTEN PLAYS geflusht, nicht am Ende dieser Rolle. Ohne dieses meta
# wuerde "restart guacd" erst NACH dem ersten Start von jumphost_app
# laufen (site.yml ordnet die Rollen zwar guacd -> jumphost_app, aber der
# Handler-Flush ist rollenuebergreifend ans Play-Ende gebunden) -- der
# allererste App-Start faende dann einen noch alten/toten guacd-Prozess vor.
ansible.builtin.meta: flush_handlers
- name: guacd aktivieren und starten (unabhaengig davon, ob gerade ein Handler ausgeloest wurde)
# Befund 1: die Rolle hatte bislang KEINEN expliziten service-Task, nur
# den Handler "restart guacd" (laeuft ausschliesslich bei einer
# Aenderung). Ein zweiter Ansible-Lauf ohne Aenderung meldete bisher
# ueberall "ok", auch wenn guacd zwischenzeitlich (Absturz, manuelles
# "systemctl stop", OOM-Kill) tot war -- das ist nach dem Debian-12-Wechsel
# die wahrscheinlichste verbleibende Ursache fuer
# "[Errno 111] Connection refused" auf einem sonst funktionierenden Build.
ansible.builtin.service:
name: guacd
state: started
enabled: true
- name: Warten, bis guacd tatsaechlich auf dem konfigurierten Port lauscht
# Befund 2: keine Startpruefung. Ohne wait_for bleibt ein fehlgeschlagener
# Start unbemerkt, bis der erste Benutzer eine RDP-Sitzung versucht --
# genau das Symptom aus dem gemeldeten Vorfall (Teil A.0).
ansible.builtin.wait_for:
host: 127.0.0.1
port: "{{ guacd_port }}"
timeout: 15
state: started
register: _guacd_port_check
ignore_errors: true
- name: Abbrechen mit Klartext, wenn guacd nicht auf dem konfigurierten Port lauscht
ansible.builtin.fail:
msg: >-
guacd lauscht nach dem Start NICHT auf 127.0.0.1:{{ guacd_port }}
(Timeout nach 15s). Auf dem Zielsystem pruefen: 'systemctl status
guacd', 'journalctl -u guacd -n 50', 'ss -ltnp | grep {{ guacd_port }}'.
Haeufigste Ursachen laut Umsetzungsauftrag_Sonnet5.md Teil A.2: das
systemd-Hardening-Drop-in (ProtectSystem=strict) blockiert ein von
guacd benoetigtes Laufzeit-/Schreibverzeichnis, oder
/etc/guacamole/guacd.conf wird wegen eines fehlenden
--sysconfdir=/etc beim configure-Lauf gar nicht erst gelesen.
when: _guacd_port_check is failed
- name: Smoke-Test - lauschenden Port gegen die App-Konfiguration abgleichen
# Letzter Punkt aus der "Zu tun"-Liste (Teil A.2): der oben erfolgreich
# geprüfte Port ist bereits exakt derselbe Wert wie guacd_port, aus dem
# sowohl diese Rolle als auch app/config.py::settings.guacd_port (ueber
# die Rolle jumphost_app) gespeist werden -- ein Auseinanderlaufen ist
# durch die gemeinsame Variable strukturell ausgeschlossen. Trotzdem
# explizit sichtbar machen, damit ein kuenftiger Review sofort sieht, WO
# dieser Abgleich stattfindet, falls App- und guacd-Port je entkoppelt
# werden sollten.
ansible.builtin.debug:
msg: >-
guacd-Smoke-Test erfolgreich: guacd lauscht auf
127.0.0.1:{{ guacd_port }}, identisch mit dem von der App erwarteten
Port (Variable guacd_port, siehe app/config.py::settings.guacd_port).

View File

@ -24,16 +24,33 @@ LoadCredentialEncrypted=jumphost_session_secret:/etc/jumphost/jumphost_session_s
EnvironmentFile=/etc/jumphost/env EnvironmentFile=/etc/jumphost/env
{% endif %} {% endif %}
# E.4 (Umsetzungsauftrag Teil E): --limit-concurrency/--backlog waren
# vorher gar nicht gesetzt (uvicorn-Standardwerte). Ohne Obergrenze kann ein
# Ansturm an Verbindungen (absichtlich oder nicht) den einzigen Worker-
# Prozess ueberlasten, bevor die anwendungsseitigen Sitzungsgrenzen
# (settings.max_sessions_*, app/security/active_sessions.py) je greifen
# koennen -- die laufen erst NACH Annahme der Verbindung. Werte ueber
# group_vars konfigurierbar (siehe ansible/inventory/group_vars/all.yml).
{% if enable_nginx_proxy %} {% if enable_nginx_proxy %}
ExecStart={{ jumphost_venv }}/bin/uvicorn app.main:app --uds {{ jumphost_listen_uds }} ExecStart={{ jumphost_venv }}/bin/uvicorn app.main:app --uds {{ jumphost_listen_uds }} \
--limit-concurrency {{ jumphost_uvicorn_limit_concurrency }} --backlog {{ jumphost_uvicorn_backlog }}
{% else %} {% else %}
ExecStart={{ jumphost_venv }}/bin/uvicorn app.main:app --host 0.0.0.0 --port {{ jumphost_app_port }} \ ExecStart={{ jumphost_venv }}/bin/uvicorn app.main:app --host 0.0.0.0 --port {{ jumphost_app_port }} \
--ssl-certfile /etc/jumphost/tls/server.crt --ssl-keyfile /etc/jumphost/tls/server.key --ssl-certfile /etc/jumphost/tls/server.crt --ssl-keyfile /etc/jumphost/tls/server.key \
--limit-concurrency {{ jumphost_uvicorn_limit_concurrency }} --backlog {{ jumphost_uvicorn_backlog }}
{% endif %} {% endif %}
Restart=on-failure Restart=on-failure
RestartSec=5 RestartSec=5
# E.4 (Umsetzungsauftrag Teil E): kein LimitNOFILE gesetzt bedeutet den
# ererbten systemd-Standard (i.d.R. 1024) statt eines bewusst gesetzten,
# dokumentierten Werts. Jede Sitzung kostet mindestens zwei Sockets
# (Browser-WebSocket, Ziel), RDP zusaetzlich die guacd-Verbindung, dazu
# die Aufzeichnungsdatei -- der Standardwert reicht fuer den geplanten
# Betrieb rechnerisch aus, gehoert aber explizit gesetzt statt geerbt.
LimitNOFILE={{ jumphost_limit_nofile }}
# --- Hardening (Konzept 6.7/8.1) --------------------------------------------- # --- Hardening (Konzept 6.7/8.1) ---------------------------------------------
NoNewPrivileges=true NoNewPrivileges=true
ProtectSystem=strict ProtectSystem=strict

View File

@ -9,7 +9,13 @@ real_ip_header X-Forwarded-For;
real_ip_recursive on; real_ip_recursive on;
{% endif %} {% endif %}
limit_req_zone $binary_remote_addr zone=jumphost_login:10m rate=10r/m; # E8 (Umsetzungsauftrag Teil E): jumphost_login_rate_limit ist ein
# group_vars-Wert (siehe ansible/inventory/group_vars/all.yml), keine
# feste Konstante -- muss zusammen mit app.config.settings.
# login_rate_limit_* und der fail2ban-Schwelle (ansible/roles/fail2ban)
# als bewusste Betriebsentscheidung festgelegt werden (IP-basiertes
# Limit, betrifft alle Benutzer hinter derselben NAT-Adresse gemeinsam).
limit_req_zone $binary_remote_addr zone=jumphost_login:10m rate={{ jumphost_login_rate_limit }};
server { server {
{% if tls_mode == 'external_reverse_proxy' and internal_hop_plaintext_accepted %} {% if tls_mode == 'external_reverse_proxy' and internal_hop_plaintext_accepted %}

View File

@ -4,12 +4,23 @@
# Start korrekt sind und legt optional den initialen Admin-User an. # Start korrekt sind und legt optional den initialen Admin-User an.
- name: Datenverzeichnis-Rechte sicherstellen - name: Datenverzeichnis-Rechte sicherstellen
# Befund B6 (Umsetzungsauftrag_Sonnet5.md Teil A.3): guacd (Rolle "guacd")
# laeuft als eigener Systemuser "guacd", nicht als "{{ jumphost_app_user }}",
# braucht aber Zugriff auf {{ jumphost_data_dir }}/rdp-drives fuer die
# RDP-Laufwerksumleitung. Mit reinem 0700 kommt guacd nicht einmal an
# diesem Verzeichnis VORBEI (Traversal-Rechte fehlen), unabhaengig vom
# systemd-ReadWritePaths-Hardening der guacd-Unit -- jeder Host mit
# file_transfer_enabled scheitert dann mit EACCES. 0710 gibt der Gruppe
# ausschliesslich das Traversal-Bit (kein Lesen/Auflisten von recordings/
# oder der DB-Datei) -- die guacd-Rolle nimmt den guacd-User dafuer in
# diese Gruppe auf. rdp-drives selbst bleibt zusaetzlich mit eigenen,
# engeren Rechten geschuetzt (guacd:guacd 0750, siehe Rolle "guacd").
ansible.builtin.file: ansible.builtin.file:
path: "{{ jumphost_data_dir }}" path: "{{ jumphost_data_dir }}"
state: directory state: directory
owner: "{{ jumphost_app_user }}" owner: "{{ jumphost_app_user }}"
group: "{{ jumphost_app_group }}" group: "{{ jumphost_app_group }}"
mode: "0700" mode: "0710"
- name: Pruefen, ob bereits eine DB existiert - name: Pruefen, ob bereits eine DB existiert
ansible.builtin.stat: ansible.builtin.stat:

View File

@ -16,8 +16,8 @@ ebenfalls Top-Level-Router unter /ws/ sind.
Reiner Live-Tail der Anwendungslogs dieses Server-Prozesses (siehe Reiner Live-Tail der Anwendungslogs dieses Server-Prozesses (siehe
app/security/log_stream.py) -- kein durchsuchbares Archiv, keine app/security/log_stream.py) -- kein durchsuchbares Archiv, keine
Persistenz. Nur Super-Admin (require_global_admin-Semantik), da die Logs Persistenz. Nur Admin (require_global_admin-Semantik), da die Logs
ueber alle Mandanten hinweg technische Details preisgeben koennen. technische Details preisgeben koennen.
""" """
from __future__ import annotations from __future__ import annotations

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
from dataclasses import dataclass, field from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
from fastapi import Cookie, Depends, Header, HTTPException, Request, Response, WebSocket, status from fastapi import Cookie, Depends, Header, HTTPException, Request, Response, WebSocket, status
@ -22,25 +22,21 @@ from app.security.sessions import (
class CurrentUser: class CurrentUser:
id: int id: int
username: str username: str
is_admin: bool # Super-Admin (siehe Migration 0006, Mandantenfaehigkeit): is_admin: bool # Admin: sieht/verwaltet alle Ressourcen. Ein Token ist NIE
# sieht/verwaltet ALLE Mandanten. Ein Token ist NIE is_admin=True, auch # is_admin=True, auch wenn der zugehoerige User selbst Admin ist (siehe unten).
# wenn der zugehoerige User selbst Super-Admin ist (siehe unten). is_token: bool = False
tenant_admin_ids: frozenset[int] = field(default_factory=frozenset) # Teil D Schritt 5 (S4-Loesung): unterscheidet einen echten Nicht-Admin-
# Nur bei Token-Auth gesetzt: das Token ist zusaetzlich auf GENAU diesen # Session-User (is_admin=False, is_token=False -- kommt ueber eine
# einen Mandanten eingeschraenkt (siehe app/db/migrations/0006_tenants.sql: # Hostgruppen-Rolle durch require_admin_scope_or_host_role) von einem
# api_tokens.tenant_id ist NOT NULL -- jedes Token gehoert einem Mandanten). # API-Token mit passendem Scope (is_admin=False, is_token=True --
token_tenant_id: int | None = None # laut Docstring dort admin-aequivalent FUER SEINEN SCOPE, "kein Token
# traegt je eine Hostgruppen-Rolle"). Vor dieser Unterscheidung liessen
@property # sich beide Faelle in einer Dependency nicht auseinanderhalten -- siehe
def is_any_admin(self) -> bool: # FORTSETZUNG_Teil_D.md Abschnitt "Bewusst NICHT in Schritt 1 erledigt:
"""Super-Admin ODER Mandanten-Admin (mind. eines Mandanten).""" # S4" fuer die damals bewusst aufgeschobene Begruendung. Wird in
return self.is_admin or bool(self.tenant_admin_ids) # app/admin/routes.py (map_ssh_key_to_host/assign_rdp_credential_to_host)
# verwendet, um die Achse-B-Freigabepruefung NUR fuer echte Nicht-Admin-
# Session-User zu erzwingen, nicht fuer Tokens.
async def _load_tenant_admin_ids(conn, user_id: int) -> frozenset[int]:
cursor = await conn.execute("SELECT tenant_id FROM tenant_admins WHERE user_id = ?", (user_id,))
rows = await cursor.fetchall()
return frozenset(r[0] for r in rows)
async def get_current_user( async def get_current_user(
@ -78,8 +74,7 @@ async def get_current_user(
path="/", path="/",
) )
tenant_admin_ids = await _load_tenant_admin_ids(conn, row[0]) return CurrentUser(id=row[0], username=row[1], is_admin=bool(row[2]))
return CurrentUser(id=row[0], username=row[1], is_admin=bool(row[2]), tenant_admin_ids=tenant_admin_ids)
async def get_current_user_ws(websocket: WebSocket) -> CurrentUser | None: async def get_current_user_ws(websocket: WebSocket) -> CurrentUser | None:
@ -106,18 +101,20 @@ async def get_current_user_ws(websocket: WebSocket) -> CurrentUser | None:
async def require_global_admin(user: CurrentUser = Depends(get_current_user)) -> CurrentUser: async def require_global_admin(user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
"""NUR Super-Admin (Session, kein Token). Fuer die maechtigsten Aktionen: """Nur Admin-Session (kein Token). Fuer die maechtigsten Aktionen:
Mandanten anlegen/loeschen, Mandanten-Admins ernennen, API-Dokumentation.""" Sitzungsverwaltung (/admin/sessions*), API-Dokumentation (/docs)."""
if not user.is_admin: if not user.is_admin:
raise HTTPException(status.HTTP_403_FORBIDDEN, "Super-Admin-Rechte erforderlich") raise HTTPException(status.HTTP_403_FORBIDDEN, "Admin-Rechte erforderlich")
return user return user
async def require_admin_session(user: CurrentUser = Depends(get_current_user)) -> CurrentUser: async def require_admin_session(user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
"""Super-Admin ODER Mandanten-Admin, aber ausschliesslich per Session """Admin, aber ausschliesslich per Session (NIE per Token) -- fuer
(NIE per Token) -- fuer Aktionen, die bewusst keinen Token-Bypass erlauben Aktionen, die bewusst keinen Token-Bypass erlauben sollen (z.B. die
sollen (z.B. Token-Verwaltung selbst, Mandanten-Admin-Zuweisung lesen).""" Token-Verwaltung selbst). Das ist der Privilege-Escalation-Schutz der
if not user.is_any_admin: Token-Verwaltung: ein geleaktes Token darf sich damit nicht selbst
weitere/staerkere Tokens ausstellen."""
if not user.is_admin:
raise HTTPException(status.HTTP_403_FORBIDDEN, "Admin-Rechte erforderlich") raise HTTPException(status.HTTP_403_FORBIDDEN, "Admin-Rechte erforderlich")
return user return user
@ -163,12 +160,11 @@ def require_host_role(role_name: str):
# User, Rollen, ...), NICHT fuer SSH/RDP/SFTP-Sitzungen selbst -- die bleiben # User, Rollen, ...), NICHT fuer SSH/RDP/SFTP-Sitzungen selbst -- die bleiben
# ausschliesslich an eine eingeloggte, TOTP-verifizierte Browser-Session # ausschliesslich an eine eingeloggte, TOTP-verifizierte Browser-Session
# gebunden. Ein Token ist nie maechtiger als sein explizit vergebener Scope, # gebunden. Ein Token ist nie maechtiger als sein explizit vergebener Scope,
# auch wenn der zugehoerige User Admin ist (kein automatisches is_admin), UND # auch wenn der zugehoerige User Admin ist (kein automatisches is_admin). Die
# ist seit Migration 0006 zusaetzlich auf GENAU EINEN Mandanten eingeschraenkt # Tokenverwaltung selbst (/admin/tokens/*) laeuft ausschliesslich ueber
# (token_tenant_id). Die Tokenverwaltung selbst (/admin/tokens/*) laeuft # require_admin_session (reine Session-Aktion), niemals ueber ein Token -- ein
# ausschliesslich ueber require_admin_session (Session, Super- ODER # geleaktes Token darf sich damit nicht selbst weitere/staerkere Tokens
# Mandanten-Admin), niemals ueber ein Token -- ein geleaktes Token darf sich # ausstellen.
# damit nicht selbst weitere/staerkere Tokens ausstellen.
async def _validate_api_token(token: str, *, resource: str, action: str) -> CurrentUser | None: async def _validate_api_token(token: str, *, resource: str, action: str) -> CurrentUser | None:
@ -177,7 +173,7 @@ async def _validate_api_token(token: str, *, resource: str, action: str) -> Curr
conn = get_db() conn = get_db()
token_hash = hash_token(token) token_hash = hash_token(token)
cursor = await conn.execute( cursor = await conn.execute(
"SELECT at.id, at.user_id, at.scopes_json, at.expires_at, at.revoked_at, at.tenant_id, " "SELECT at.id, at.user_id, at.scopes_json, at.expires_at, at.revoked_at, "
"u.username, u.is_active " "u.username, u.is_active "
"FROM api_tokens at JOIN users u ON u.id = at.user_id " "FROM api_tokens at JOIN users u ON u.id = at.user_id "
"WHERE at.token_hash = ? AND u.deleted_at IS NULL", "WHERE at.token_hash = ? AND u.deleted_at IS NULL",
@ -186,7 +182,7 @@ async def _validate_api_token(token: str, *, resource: str, action: str) -> Curr
row = await cursor.fetchone() row = await cursor.fetchone()
if row is None: if row is None:
return None return None
token_id, user_id, scopes_json, expires_at, revoked_at, tenant_id, username, is_active = row token_id, user_id, scopes_json, expires_at, revoked_at, username, is_active = row
if revoked_at is not None or not is_active: if revoked_at is not None or not is_active:
return None return None
now_str = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ") now_str = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
@ -201,7 +197,7 @@ async def _validate_api_token(token: str, *, resource: str, action: str) -> Curr
(token_id,), (token_id,),
) )
await conn.commit() await conn.commit()
return CurrentUser(id=user_id, username=username, is_admin=False, token_tenant_id=tenant_id) return CurrentUser(id=user_id, username=username, is_admin=False, is_token=True)
def require_admin_scope_or_host_role(resource: str, action: str, role_names: tuple[str, ...]): def require_admin_scope_or_host_role(resource: str, action: str, role_names: tuple[str, ...]):
@ -230,7 +226,7 @@ def require_admin_scope_or_host_role(resource: str, action: str, role_names: tup
return principal return principal
user = await get_current_user(response, jh_session) user = await get_current_user(response, jh_session)
if user.is_any_admin: if user.is_admin:
return user return user
conn = get_db() conn = get_db()
for role_name in role_names: for role_name in role_names:
@ -243,12 +239,9 @@ def require_admin_scope_or_host_role(resource: str, action: str, role_names: tup
def require_admin_or_scope(resource: str, action: str): def require_admin_or_scope(resource: str, action: str):
"""Dependency-Factory fuer die Admin-API: erlaubt Zugriff entweder ueber """Dependency-Factory fuer die Admin-API: erlaubt Zugriff entweder ueber
eine eingeloggte Admin-Session (Super- ODER Mandanten-Admin, Cookie) ODER eine eingeloggte Admin-Session (Cookie) ODER ueber ein API-Token mit
ueber ein API-Token mit passendem Scope (Header 'Authorization: Bearer passendem Scope (Header 'Authorization: Bearer <token>'). 'write'
<token>'). 'write' schliesst 'read' auf derselben Ressource ein (siehe schliesst 'read' auf derselben Ressource ein (siehe token_has_scope)."""
token_has_scope). Mandanten-Scoping selbst erfolgt NICHT hier, sondern im
jeweiligen Endpunkt ueber app/tenancy.py::TenantScope, basierend auf
effective_tenant_ids() des zurueckgegebenen CurrentUser."""
async def _dep( async def _dep(
response: Response, response: Response,
@ -266,25 +259,8 @@ def require_admin_or_scope(resource: str, action: str):
return principal return principal
user = await get_current_user(response, jh_session) user = await get_current_user(response, jh_session)
if not user.is_any_admin: if not user.is_admin:
raise HTTPException(status.HTTP_403_FORBIDDEN, "Admin-Rechte erforderlich") raise HTTPException(status.HTTP_403_FORBIDDEN, "Admin-Rechte erforderlich")
return user return user
return _dep return _dep
def effective_tenant_ids(admin: CurrentUser) -> set[int] | None:
"""None bedeutet 'alle Mandanten' (Super-Admin-Session, nicht ueber ein
mandantengebundenes Token authentifiziert). Sonst ein Set mit genau den
Mandanten-IDs, auf die der Principal beschraenkt ist:
- Token-Auth: immer genau {token_tenant_id} (Tokens sind nie
Mandanten-UEBERGREIFEND einsetzbar, siehe Migration 0006).
- Mandanten-Admin-Session: die Menge ihrer tenant_admin_ids.
- Ein regulaerer (Nicht-Admin-)User wuerde require_admin_or_scope /
require_admin_session ohnehin nie passieren -- kommt hier praktisch
nicht an."""
if admin.token_tenant_id is not None:
return {admin.token_tenant_id}
if admin.is_admin:
return None
return set(admin.tenant_admin_ids)

View File

@ -272,6 +272,13 @@ async def logout(request: Request, response: Response, user: CurrentUser = Depen
conn, event_type="logout", user_id=user.id, client_ip=_client_ip(request), details={} conn, event_type="logout", user_id=user.id, client_ip=_client_ip(request), details={}
) )
await conn.commit() await conn.commit()
# Siehe change_password() unten fuer die ausfuehrliche Begruendung: die
# get_current_user()-Dependency hat bereits ein Refresh-Set-Cookie
# geschrieben, das hier entfernte werden muss, bevor delete_cookie()
# selbst noch ein Set-Cookie (mit Max-Age=0) anhaengt -- sonst zwei
# Set-Cookie-Kopfzeilen in einer Antwort.
if "set-cookie" in response.headers:
del response.headers["set-cookie"]
response.delete_cookie(SESSION_COOKIE_NAME, path="/") response.delete_cookie(SESSION_COOKIE_NAME, path="/")
return {"status": "ok"} return {"status": "ok"}
@ -289,6 +296,9 @@ async def logout_everywhere(
conn, event_type="logout_everywhere", user_id=user.id, client_ip=_client_ip(request), details={} conn, event_type="logout_everywhere", user_id=user.id, client_ip=_client_ip(request), details={}
) )
await conn.commit() await conn.commit()
# Siehe change_password() unten fuer die ausfuehrliche Begruendung.
if "set-cookie" in response.headers:
del response.headers["set-cookie"]
response.delete_cookie(SESSION_COOKIE_NAME, path="/") response.delete_cookie(SESSION_COOKIE_NAME, path="/")
return {"status": "ok"} return {"status": "ok"}
@ -318,6 +328,27 @@ async def change_password(
) )
await conn.commit() await conn.commit()
# Bug-Fix (FORTSETZUNG_Teil_C.md Abschnitt 3 Punkt 6): get_current_user()
# (siehe app/auth/deps.py) hat als Dependency VOR dieser Routenfunktion
# bereits ein eigenes Set-Cookie in `response` geschrieben (gleitender
# Idle-Timeout-Refresh) -- MIT DER ALTEN session_version, weil die DB zu
# dem Zeitpunkt noch nicht aktualisiert war. Ohne diese Zeile wuerden
# ZWEI "Set-Cookie: jh_session=..."-Header in derselben Antwort stehen
# (die alte Refresh-Cookie UND die neue unten) -- welche davon ein Client
# am Ende tatsaechlich speichert, ist zwischen Browsern/HTTP-Bibliotheken
# nicht einheitlich definiert (in der Praxis eher "die letzte gewinnt",
# aber verlassen sollte man sich darauf nicht). Ein per-Request frischer
# Client akzeptiert die alte Sitzung danach zwar bereits korrekt nicht
# mehr (session_version-Pruefung in deps.py greift unabhaengig davon),
# aber die doppelte Kopfzeile bleibt unsauber und macht Tests, die den
# Cookie-Jar wiederverwenden, unnoetig fragil (siehe der dadurch lange
# falsch als "Sicherheitsluecke" vermutete Testfehlschlag). Die alte
# Set-Cookie-Kopfzeile wird deshalb hier explizit entfernt, bevor die
# neue, autoritative gesetzt wird -- `del response.headers["set-cookie"]`
# entfernt ALLE bisherigen Vorkommen dieses Headers (Starlette
# MutableHeaders.__delitem__), nicht nur das erste.
if "set-cookie" in response.headers:
del response.headers["set-cookie"]
token = create_session_token(user.id, new_version) token = create_session_token(user.id, new_version)
response.set_cookie( response.set_cookie(
SESSION_COOKIE_NAME, token, httponly=True, secure=True, samesite="strict", path="/" SESSION_COOKIE_NAME, token, httponly=True, secure=True, samesite="strict", path="/"
@ -327,19 +358,8 @@ async def change_password(
@router.get("/me") @router.get("/me")
async def me(user: CurrentUser = Depends(get_current_user)): async def me(user: CurrentUser = Depends(get_current_user)):
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 { return {
"id": user.id, "id": user.id,
"username": user.username, "username": user.username,
"is_admin": user.is_admin, "is_admin": user.is_admin,
"is_any_admin": user.is_any_admin,
"tenant_admin_of": tenant_admin_of,
} }

View File

@ -1,21 +1,39 @@
"""Sicht fuer normale Nutzer: nur die Hosts/Aktionen, fuer die RBAC eine """Sicht fuer normale Nutzer: nur die Hosts/Aktionen, fuer die RBAC eine
Rolle in der jeweiligen Hostgruppe vergeben hat -- entweder direkt (Konzept Rolle in der jeweiligen Hostgruppe vergeben hat -- entweder direkt (Konzept
4.6) oder ueber eine Benutzergruppe, in der der User Mitglied ist (volle 4.6) oder ueber eine Benutzergruppe, in der der User Mitglied ist (volle
Rollen-Vererbung, siehe app/rbac.py).""" Rollen-Vererbung, siehe app/rbac.py).
S13 (Umsetzungsauftrag_Sonnet5.md Teil D.2): app/rbac.py ist die einzige
Wahrheitsquelle fuer die Vereinigung aus Direkt- und Gruppenvergabe. Dieses
Modul baut sie nicht mehr selbst per UNION/EXISTS nach, sondern ruft
user_host_group_ids_with_any_role() auf und filtert/verknuepft in Python.
F2 (Umsetzungsauftrag_Sonnet5.md Teil F.3.6): eigene Sitzungs-API. Bewusst
HIER und NICHT im Admin-Router -- GET /admin/sessions (app/admin/routes.py)
zeigt fremde Sitzungen, Benutzernamen und Aufzeichnungspfade und darf dafuer
nicht geoeffnet werden. Jede Abfrage/Aktion hier ist zwingend auf
user_id = <eigene angemeldete> beschraenkt."""
from __future__ import annotations from __future__ import annotations
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends, HTTPException, Request, status
from app.auth.deps import CurrentUser, get_current_user from app.auth.deps import CurrentUser, get_current_user
from app.config import settings
from app.db import get_db from app.db import get_db
from app.rbac import user_host_group_ids_with_any_role
from app.security import active_sessions
from app.security.audit import write_audit_event
router = APIRouter(prefix="/catalog", tags=["catalog"]) router = APIRouter(prefix="/catalog", tags=["catalog"])
def _client_ip(request: Request) -> str:
return request.client.host if request.client else "unknown"
@router.get("/hosts") @router.get("/hosts")
async def my_hosts(user: CurrentUser = Depends(get_current_user)): async def my_hosts(user: CurrentUser = Depends(get_current_user)):
conn = get_db() conn = get_db()
if user.is_admin:
cursor = await conn.execute( cursor = await conn.execute(
"SELECT h.id, h.hostname, h.address, h.protocol, h.os_type, h.host_group_id, " "SELECT h.id, h.hostname, h.address, h.protocol, h.os_type, h.host_group_id, "
"g.name, h.clipboard_enabled, h.file_transfer_enabled " "g.name, h.clipboard_enabled, h.file_transfer_enabled "
@ -27,82 +45,146 @@ async def my_hosts(user: CurrentUser = Depends(get_current_user)):
("id", "hostname", "address", "protocol", "os_type", "host_group_id", "host_group_name", ("id", "hostname", "address", "protocol", "os_type", "host_group_id", "host_group_name",
"clipboard_enabled", "file_transfer_enabled"), r "clipboard_enabled", "file_transfer_enabled"), r
)) for r in rows] )) for r in rows]
if user.is_admin:
for h in hosts: for h in hosts:
h["can_connect"] = True h["can_connect"] = True
h["can_file_transfer"] = True h["can_file_transfer"] = True
h["can_view_credentials"] = True h["can_view_credentials"] = True
return hosts return hosts
cursor = await conn.execute( # Protokoll-Abgleich (S6, Umsetzungsauftrag_Sonnet5.md D.1/D.2): ssh_connect
""" # zaehlt nur fuer SSH-Hosts, rdp_connect nur fuer RDP-Hosts -- getrennte
SELECT h.id, h.hostname, h.address, h.protocol, h.os_type, h.host_group_id, # Gruppenmengen je Rolle, kein pauschales "irgendeine der beiden Rollen".
g.name, h.clipboard_enabled, h.file_transfer_enabled ssh_groups = await user_host_group_ids_with_any_role(conn, user_id=user.id, role_names=("ssh_connect",))
FROM hosts h rdp_groups = await user_host_group_ids_with_any_role(conn, user_id=user.id, role_names=("rdp_connect",))
JOIN host_groups g ON g.id = h.host_group_id ft_groups = await user_host_group_ids_with_any_role(conn, user_id=user.id, role_names=("file_transfer",))
WHERE h.is_active = 1 cred_groups = await user_host_group_ids_with_any_role(
AND ( conn, user_id=user.id, role_names=("credentials_view", "credentials_manage")
EXISTS (
SELECT 1 FROM user_hostgroup_roles uhr
JOIN roles r ON r.id = uhr.role_id
WHERE uhr.user_id = ? AND uhr.host_group_id = h.host_group_id
AND r.name IN ('ssh_connect', 'rdp_connect')
AND (uhr.expires_at IS NULL OR uhr.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now'))
) )
OR EXISTS (
SELECT 1 FROM group_hostgroup_roles ghr
JOIN roles r ON r.id = ghr.role_id
JOIN user_group_members ugm ON ugm.user_group_id = ghr.user_group_id
WHERE ugm.user_id = ? AND ghr.host_group_id = h.host_group_id
AND r.name IN ('ssh_connect', 'rdp_connect')
AND (ghr.expires_at IS NULL OR ghr.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now'))
)
)
ORDER BY g.name, h.hostname
""",
(user.id, user.id),
)
rows = await cursor.fetchall()
hosts = [dict(zip(
("id", "hostname", "address", "protocol", "os_type", "host_group_id", "host_group_name",
"clipboard_enabled", "file_transfer_enabled"), r
)) for r in rows]
ft_cursor = await conn.execute(
"""
SELECT uhr.host_group_id FROM user_hostgroup_roles uhr
JOIN roles r ON r.id = uhr.role_id
WHERE uhr.user_id = ? AND r.name = 'file_transfer'
AND (uhr.expires_at IS NULL OR uhr.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now'))
UNION
SELECT ghr.host_group_id FROM group_hostgroup_roles ghr
JOIN roles r ON r.id = ghr.role_id
JOIN user_group_members ugm ON ugm.user_group_id = ghr.user_group_id
WHERE ugm.user_id = ? AND r.name = 'file_transfer'
AND (ghr.expires_at IS NULL OR ghr.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now'))
""",
(user.id, user.id),
)
ft_groups = {row[0] for row in await ft_cursor.fetchall()}
cred_cursor = await conn.execute(
"""
SELECT uhr.host_group_id FROM user_hostgroup_roles uhr
JOIN roles r ON r.id = uhr.role_id
WHERE uhr.user_id = ? AND r.name IN ('credentials_view', 'credentials_manage')
AND (uhr.expires_at IS NULL OR uhr.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now'))
UNION
SELECT ghr.host_group_id FROM group_hostgroup_roles ghr
JOIN roles r ON r.id = ghr.role_id
JOIN user_group_members ugm ON ugm.user_group_id = ghr.user_group_id
WHERE ugm.user_id = ? AND r.name IN ('credentials_view', 'credentials_manage')
AND (ghr.expires_at IS NULL OR ghr.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now'))
""",
(user.id, user.id),
)
cred_groups = {row[0] for row in await cred_cursor.fetchall()}
visible = []
for h in hosts: for h in hosts:
can_connect = (
(h["protocol"] == "ssh" and h["host_group_id"] in ssh_groups)
or (h["protocol"] == "rdp" and h["host_group_id"] in rdp_groups)
)
if not can_connect:
continue
h["can_connect"] = True h["can_connect"] = True
h["can_file_transfer"] = h["host_group_id"] in ft_groups and bool(h["file_transfer_enabled"]) h["can_file_transfer"] = h["host_group_id"] in ft_groups and bool(h["file_transfer_enabled"])
h["can_view_credentials"] = h["host_group_id"] in cred_groups h["can_view_credentials"] = h["host_group_id"] in cred_groups
return hosts visible.append(h)
return visible
# --- Eigene Sitzungen (F2, Umsetzungsauftrag Teil F.3.6) --------------------
#
# Grundlage fuer die kuenftige Arbeitsflaeche /workspace (Stufe F3): eine
# Seitenleiste braucht eine Liste der eigenen offenen Sitzungen, um Kacheln
# zu zeichnen, und einen Weg, eine davon (z.B. ueber den Schliessen-Knopf
# einer Kachel) zu beenden. Referenzmuster: app/admin/routes.py::
# list_sessions/terminate_session -- hier aber zwingend auf den eigenen
# Benutzer beschraenkt und ohne die dort zusaetzlich gelieferten sensiblen
# Felder (Client-IP, Aufzeichnungspfad, fremde Benutzernamen).
@router.get("/sessions")
async def my_sessions(active_only: bool = True, user: CurrentUser = Depends(get_current_user)):
"""Eigene Sitzungen (Standard: nur die noch offenen). Nutzt den
zusammengesetzten Index idx_sessions_user_ended (Migration 0020) --
deckt per Index-Praefix-Regel sowohl diese Abfrage (user_id, ended_at)
als auch active_only=false (user_id allein) ab."""
conn = get_db()
where = "WHERE s.user_id = ? AND s.ended_at IS NULL" if active_only else "WHERE s.user_id = ?"
cursor = await conn.execute(
f"""
SELECT s.id, s.host_id, h.hostname, hg.name, s.protocol, s.started_at, s.ended_at, s.end_reason
FROM sessions s
JOIN hosts h ON h.id = s.host_id
JOIN host_groups hg ON hg.id = h.host_group_id
{where}
ORDER BY s.started_at DESC
""",
(user.id,),
)
rows = await cursor.fetchall()
active_ids = active_sessions.all_ids()
return [
{
"id": r[0], "host_id": r[1], "hostname": r[2], "host_group_name": r[3],
"protocol": r[4], "started_at": r[5], "ended_at": r[6], "end_reason": r[7],
"is_active": r[6] is None,
# Wie bei der Admin-Sessionview: nur auf DIESEM Worker-Prozess
# beendbar (app/security/active_sessions.py, Moduldoc).
"killable": r[0] in active_ids,
}
for r in rows
]
@router.post("/sessions/{session_id}/terminate")
async def terminate_my_session(session_id: int, request: Request, user: CurrentUser = Depends(get_current_user)):
"""Beendet eine EIGENE laufende Sitzung. Zwingend auf user_id = user.id
beschraenkt -- sowohl beim DB-Zeilen- als auch beim Registry-Zugriff
(F.5-Risikotabelle: 'Rechteumgehung ueber die neue Sitzungs-API' /
'Wiederverwendung des Admin-Endpunkts'). Eine fremde oder nicht
existierende session_id liefert in BEIDEN Faellen 404 -- die Existenz
einer fremden Sitzung wird nicht ueber einen abweichenden Statuscode
(z.B. 403 nur bei existierender, aber fremder ID) verraten."""
conn = get_db()
row = await (await conn.execute(
"SELECT ended_at, user_id, host_id FROM sessions WHERE id = ?", (session_id,)
)).fetchone()
if row is None or row[1] != user.id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Sitzung nicht gefunden")
if row[0] is not None:
raise HTTPException(status.HTTP_409_CONFLICT, "Sitzung ist bereits beendet")
entry = active_sessions.get(session_id)
if entry is None or entry.user_id != user.id:
raise HTTPException(
status.HTTP_409_CONFLICT,
"Sitzung laeuft nicht (mehr) auf diesem Server-Prozess und kann von hier aus nicht beendet werden",
)
entry.task.cancel()
await write_audit_event(
conn, event_type="session_terminated_by_owner", user_id=user.id, client_ip=_client_ip(request),
details={"session_id": session_id, "host_id": row[2]},
)
await conn.commit()
return {"status": "ok"}
# --- Sitzungs-Obergrenzen (F5, Umsetzungsauftrag Teil F.3.8) ----------------
#
# Die Durchsetzung selbst existiert bereits seit Teil E.4 und bleibt dort
# die alleinige, verbindliche Kontrolle (app/ssh_proxy/terminal_ws.py,
# app/rdp_proxy/ws_tunnel.py -- vor jedem Ressourcenverbrauch, WS-Code 4429
# bei Ueberschreitung). Dieser Endpunkt liefert nur die Zahlen dazu, damit
# die Arbeitsflaeche (static/js/workspace.js) den Zustand VORHER anzeigen
# und "+ Neue Sitzung" deaktivieren kann, statt den Nutzer erst beim
# WS-Verbindungsversuch abzuweisen (F.3.8 verlangt sichtbare Obergrenzen,
# nicht nur eine funktionierende Durchsetzung -- die gab es bereits).
#
# Absichtlich nur aggregierte Zahlen, keine Details zu FREMDEN Sitzungen
# (kein user_id/hostname anderer Nutzer) -- sonst waere dies ein Fall fuer
# den Admin-Router (siehe Moduldoc oben).
#
# Admins sind wie bei der Durchsetzung selbst von der Je-Nutzer-Grenze
# ausgenommen, nicht aber von der globalen Grenze -- exakt dieselbe Logik
# wie in terminal_ws.py/ws_tunnel.py. Das MUSS synchron bleiben: sonst zeigt
# die Oberflaeche "frei", obwohl der Server ablehnen wuerde, oder umgekehrt.
@router.get("/session-limits")
async def my_session_limits(user: CurrentUser = Depends(get_current_user)):
user_count = active_sessions.count_for_user(user.id)
global_count = active_sessions.count_total()
user_limit_applies = not user.is_admin
return {
"max_per_user": settings.max_sessions_per_user,
"current_user_count": user_count,
"user_limit_applies": user_limit_applies,
"at_user_limit": user_limit_applies and user_count >= settings.max_sessions_per_user,
"max_global": settings.max_sessions_global,
"current_global_count": global_count,
"at_global_limit": global_count >= settings.max_sessions_global,
}

View File

@ -67,12 +67,73 @@ class Settings:
listen_uds: str = os.environ.get("JUMPHOST_LISTEN_UDS", "/run/jumphost/app.sock") listen_uds: str = os.environ.get("JUMPHOST_LISTEN_UDS", "/run/jumphost/app.sock")
guacd_host: str = os.environ.get("JUMPHOST_GUACD_HOST", "127.0.0.1") guacd_host: str = os.environ.get("JUMPHOST_GUACD_HOST", "127.0.0.1")
guacd_port: int = int(os.environ.get("JUMPHOST_GUACD_PORT", "4822")) guacd_port: int = int(os.environ.get("JUMPHOST_GUACD_PORT", "4822"))
# Befund A3 (Umsetzungsauftrag_Sonnet5.md Teil A.3): weder der
# TCP-Verbindungsaufbau zu guacd noch die beiden read_instruction()-
# Aufrufe im guac-Handshake (args/ready) hatten ein Timeout -- antwortet
# guacd nach dem TCP-Accept nicht mehr, haengt die Coroutine unbegrenzt
# (offene sessions-Zeile ohne ended_at, offene Aufzeichnungsdatei,
# belegtes WebSocket). Je Phase konfigurierbar statt eines Werts fuer
# beides, da ein TCP-Connect-Timeout und ein Protokoll-Handshake-Timeout
# unterschiedliche Fehlerbilder abdecken (guacd-Prozess tot vs. guacd
# haengt mitten im Protokoll).
guacd_connect_timeout_s: float = float(os.environ.get("JUMPHOST_GUACD_CONNECT_TIMEOUT", "10"))
guacd_handshake_timeout_s: float = float(os.environ.get("JUMPHOST_GUACD_HANDSHAKE_TIMEOUT", "10"))
session_idle_timeout_s: int = int(os.environ.get("JUMPHOST_SESSION_IDLE_TIMEOUT", "900")) session_idle_timeout_s: int = int(os.environ.get("JUMPHOST_SESSION_IDLE_TIMEOUT", "900"))
session_absolute_timeout_s: int = int(os.environ.get("JUMPHOST_SESSION_ABS_TIMEOUT", "28800")) session_absolute_timeout_s: int = int(os.environ.get("JUMPHOST_SESSION_ABS_TIMEOUT", "28800"))
max_failed_logins: int = int(os.environ.get("JUMPHOST_MAX_FAILED_LOGINS", "5")) max_failed_logins: int = int(os.environ.get("JUMPHOST_MAX_FAILED_LOGINS", "5"))
lockout_base_seconds: int = int(os.environ.get("JUMPHOST_LOCKOUT_BASE_SECONDS", "30")) lockout_base_seconds: int = int(os.environ.get("JUMPHOST_LOCKOUT_BASE_SECONDS", "30"))
# Sitzungsaufzeichnung (Umsetzungsauftrag Teil A/D2, Teil E/E2): Puffer-
# Zeitfenster, Groesse je Teildatei (Rotation) und Gesamtgroesse je
# Sitzung (harte Obergrenze -- Aufzeichnung wird danach mit einem
# Abschluss-Eintrag abgeschnitten, die Sitzung selbst laeuft normal
# weiter). Siehe app/recordings/recorder.py.
recording_flush_interval_s: float = float(os.environ.get("JUMPHOST_RECORDING_FLUSH_INTERVAL_S", "1.0"))
recording_max_part_bytes: int = int(os.environ.get("JUMPHOST_RECORDING_MAX_PART_BYTES", str(50 * 1024 * 1024)))
recording_max_total_bytes: int = int(os.environ.get("JUMPHOST_RECORDING_MAX_TOTAL_BYTES", str(500 * 1024 * 1024)))
# Dateitransfer (Umsetzungsauftrag Teil E, E4): Obergrenze gleichzeitig
# laufender Uploads+Downloads ueber alle Benutzer hinweg. Ohne Grenze
# koennten mehrere parallele 200-MiB-Transfers den Prozess in den
# OOM-Killer treiben (siehe app/ssh_proxy/sftp.py).
max_concurrent_transfers: int = int(os.environ.get("JUMPHOST_MAX_CONCURRENT_TRANSFERS", "8"))
# SSH-Terminal-Sitzungen (Umsetzungsauftrag Teil E):
# E10 -- Inaktivitaet wird in BEIDEN Richtungen gemessen (Eingaben UND
# Ausgaben vom Ziel, z.B. ein laufendes `tail -f`), nicht mehr nur an
# websocket.receive_json() (Benutzereingaben). Vorher wurde eine
# inhaltlich aktive, aber eingabelose Sitzung nach 15 Minuten getrennt.
ssh_idle_timeout_s: int = int(os.environ.get("JUMPHOST_SSH_IDLE_TIMEOUT", "900"))
# E11 -- vorher als MAX_SESSION_SECONDS-Konstante in
# app/ssh_proxy/terminal_ws.py definiert, aber nirgends verwendet: es gab
# de facto GAR KEINE absolute Obergrenze fuer eine SSH-Sitzung, anders
# als beim Anmelde-Cookie (session_absolute_timeout_s oben). Jetzt aktiv
# durchgesetzt, mit Vorwarnung (siehe ssh_max_session_warning_s).
ssh_max_session_seconds: int = int(os.environ.get("JUMPHOST_SSH_MAX_SESSION_SECONDS", str(8 * 3600)))
ssh_max_session_warning_s: int = int(os.environ.get("JUMPHOST_SSH_MAX_SESSION_WARNING_S", "60"))
# E8 (Umsetzungsauftrag Teil E): IP-basiertes Login-Rate-Limit. Bewusst
# NICHT automatisch an eine erkannte Benutzerzahl angepasst -- ob mehrere
# interne Benutzer hinter derselben NAT-Adresse sitzen (typischer
# Buero-Fall) ist eine Betriebsentscheidung, die zusammen mit dem
# nginx-limit_req (ansible/roles/nginx_proxy) und der fail2ban-Schwelle
# (ansible/roles/fail2ban) getroffen werden muss -- siehe deren
# jumphost_login_rate_limit_* group_vars. Der kontenbezogene Lockout
# (max_failed_logins oben) bleibt in jedem Fall die scharfe, IP-
# unabhaengige Bremse.
login_rate_limit_max_events: int = int(os.environ.get("JUMPHOST_LOGIN_RATE_LIMIT_MAX_EVENTS", "10"))
login_rate_limit_window_s: int = int(os.environ.get("JUMPHOST_LOGIN_RATE_LIMIT_WINDOW_S", "60"))
# E.4 (Umsetzungsauftrag Teil E): Obergrenzen fuer gleichzeitige SSH/RDP-
# Sitzungen. Vorher gab es WEDER einen Zaehler je Benutzer NOCH einen
# globalen Zaehler -- mit der Mehrsitzungs-Seitenleiste (Teil F) wird es
# zur Normalitaet, dass ein Benutzer mehrere Sitzungen gleichzeitig haelt,
# jede kostet mindestens einen zusaetzlichen Socket zum Ziel (RDP
# zusaetzlich die guacd-Verbindung) plus eine Aufzeichnungsdatei.
max_sessions_per_user: int = int(os.environ.get("JUMPHOST_MAX_SESSIONS_PER_USER", "6"))
max_sessions_global: int = int(os.environ.get("JUMPHOST_MAX_SESSIONS_GLOBAL", "100"))
def __post_init__(self) -> None: def __post_init__(self) -> None:
self.db_path = self.data_dir / "jumphost.db" self.db_path = self.data_dir / "jumphost.db"
self.recordings_dir = self.data_dir / "recordings" self.recordings_dir = self.data_dir / "recordings"

View File

@ -30,6 +30,19 @@ async def init_db() -> None:
await _connection.execute("PRAGMA journal_mode = WAL;") await _connection.execute("PRAGMA journal_mode = WAL;")
await _connection.execute("PRAGMA foreign_keys = ON;") await _connection.execute("PRAGMA foreign_keys = ON;")
await _connection.execute("PRAGMA busy_timeout = 5000;") await _connection.execute("PRAGMA busy_timeout = 5000;")
# E5 (Umsetzungsauftrag Teil E): ohne explizites PRAGMA synchronous
# verwendet SQLite seinen kompilierten Standard FULL -- das fsynct bei
# JEDEM COMMIT, unabhaengig vom (an sich korrekten) WAL-Journal-Modus.
# aiosqlite bedient alle Anfragen ueber EINEN gemeinsamen Hintergrund-
# Thread (die einzige Verbindung der Anwendung, siehe E.0/E5-Befund) --
# ein fsync dort serialisiert sich vor JEDE andere wartende Anfrage
# jedes anderen Benutzers. NORMAL ist die von SQLite fuer WAL-Betrieb
# dokumentierte Empfehlung: fsynct nur noch an Checkpoint-Grenzen,
# bleibt dabei garantiert unbeschaedigt bei einem Anwendungsabsturz
# (der hier relevante Fall) -- nur bei einem Stromausfall/OS-Crash
# koennen die letzten, unmittelbar davor committeten Transaktionen
# verloren gehen (nicht: die DB korrumpieren).
await _connection.execute("PRAGMA synchronous = NORMAL;")
await _apply_migrations(_connection) await _apply_migrations(_connection)
try: try:
@ -51,6 +64,22 @@ async def _apply_migrations(conn: aiosqlite.Connection) -> None:
logger.info("Wende Migration an: %s", migration_file.name) logger.info("Wende Migration an: %s", migration_file.name)
sql = migration_file.read_text(encoding="utf-8") sql = migration_file.read_text(encoding="utf-8")
await conn.executescript(sql) await conn.executescript(sql)
# Fremdschluesselpruefung NACH jeder Migration: PRAGMA foreign_key_check
# liefert nur eine Ergebnismenge zurueck, die ein per executescript()
# ausgefuehrtes Skript NICHT auswertet (das PRAGMA innerhalb des
# Skripts selbst ist daher wirkungslos) -- deshalb wird es hier,
# ausserhalb von executescript(), explizit erneut ausgefuehrt und
# das Ergebnis geprueft. Verletzungen brechen den Start hart ab,
# statt eine inkonsistente Datenbank stillschweigend zu uebernehmen
# (siehe 0014_drop_tenants.sql-Kommentar).
violations = await (await conn.execute("PRAGMA foreign_key_check")).fetchall()
if violations:
raise RuntimeError(
f"Migration {migration_file.name} hat Fremdschluessel-Verletzungen "
f"hinterlassen, Start abgebrochen: {violations!r}"
)
await conn.execute( await conn.execute(
"INSERT INTO schema_migrations (filename) VALUES (?)", (migration_file.name,) "INSERT INTO schema_migrations (filename) VALUES (?)", (migration_file.name,)
) )

View File

@ -0,0 +1,21 @@
-- Migration 0013: Befund C2 (Umsetzungsauftrag_Sonnet5.md Teil A.3).
--
-- Migration 0012 hat fuer RDP-Zugangsdatensaetze ohne Benutzername am alten
-- Host-Datensatz den Platzhaltertext '(kein Benutzername)' eingetragen,
-- statt die Spalte leer zu lassen. Die Leerpruefung in
-- app/rdp_proxy/guacd_client.py::build_rdp_params() greift nur bei einem
-- LEEREN String -- der Platzhalter ist nicht leer und wurde bislang als
-- echter RDP-Anmeldename an Windows gesendet. Das Ziel meldet daraufhin
-- "Anmeldung fehlgeschlagen" (bzw. guacd Status 769), was wie ein falsches
-- Passwort aussieht statt wie das eigentliche Problem: es fehlt schlicht
-- ein Benutzername.
--
-- Ruecksetzung auf einen leeren String macht die vorhandene, bereits
-- korrekte Leerpruefung wieder wirksam -- inkl. ihres Fallbacks auf
-- hosts.rdp_username (Altbestand) und ihrer sprechenden Fehlermeldung,
-- wenn auch das leer ist. Betroffen sind ausschliesslich Datensaetze mit
-- exakt diesem Migrations-Platzhalter; von Hand vergebene, echte
-- Benutzernamen bleiben unberuehrt.
UPDATE rdp_credentials
SET username = ''
WHERE username = '(kein Benutzername)';

View File

@ -0,0 +1,196 @@
-- Umsetzungsauftrag_Sonnet5.md Teil C, Phase 2, Schritt 12: Mandantenfaehigkeit
-- vollstaendig aus dem Schema entfernen.
--
-- Betreiberentscheidungen (Phase 0, per AskUserQuestion eingeholt):
-- * tenant_admins: Benutzer wurden bereits VOR dieser Migration per
-- scripts/promote_tenant_admins.py auf is_admin=1
-- befoerdert (Phase 1, mit eigenem Audit-Event je
-- Benutzer) -- diese Migration setzt das nur noch
-- schematisch um (Tabelle faellt weg).
-- * users.home_tenant_id: VOLLSTAENDIGER Rebuild (nicht als tote Spalte
-- belassen) -- daher ist 'users' hier mit dabei,
-- zusaetzlich zu den fuenf primaer betroffenen
-- Tabellen.
-- * api_tokens: bestehende Tokens bleiben gueltig (KEIN
-- Massenwiderruf) -- diese Migration entfernt nur
-- die tenant_id-Spalte, ruehrt token_hash/
-- revoked_at/expires_at nicht an.
--
-- ALTER TABLE ... DROP COLUMN scheidet fuer alle sechs Spalten aus (SQLite
-- verweigert das Droppen indizierter/fremdschluesselbehafteter Spalten) --
-- deshalb voller Tabellen-Rebuild (12-Schritte-Verfahren) fuer host_groups,
-- user_groups, ssh_keys, api_tokens, rdp_credentials und users.
--
-- Reihenfolge wichtig: erst die fuenf idx_*_tenant-Indizes droppen, dann
-- rebuilden, DANN tenant_admins/tenants droppen -- erst wenn keine
-- REFERENCES tenants(id)-Klausel mehr im Schema steht, verschwindet die
-- letzte Abhaengigkeit, die DROP TABLE tenants verhindern wuerde.
--
-- Kein Tabellen-Rename der ALTEN Tabelle (z.B. "host_groups RENAME TO
-- host_groups_old") -- das wuerde seit SQLite 3.25 (legacy_alter_table=OFF)
-- automatisch alle REFERENCES host_groups(...)-Klauseln in ANDEREN Tabellen
-- (z.B. hosts.host_group_id) auf den neuen Namen umschreiben, was hier
-- explizit unerwuenscht ist. Stattdessen: neue Tabelle unter Platzhalter-
-- namen anlegen, Daten kopieren, ALTE Tabelle direkt droppen (kein Rename),
-- NEUE Tabelle auf den Zielnamen umbenennen -- der Platzhaltername taucht in
-- keiner REFERENCES-Klausel auf, dieser Rename loest also keinen
-- Seiteneffekt aus.
--
-- Fremdschluesselgeprueft wird NICHT hier inline per PRAGMA foreign_key_check
-- (das PRAGMA liefert nur eine Ergebnismenge zurueck, die ein per
-- executescript() ausgefuehrtes Skript nicht auswertet) -- app/db.py::
-- _apply_migrations() fuehrt diese Pruefung nach JEDER Migration
-- programmatisch aus und bricht den Start mit RuntimeError ab, falls
-- Verletzungen gefunden werden.
--
-- Audit-Log: historische tenant_created/tenant_updated/tenant_deleted/
-- tenant_admin_granted/tenant_admin_revoked/tenant_admin_promoted_to_admin-
-- Eintraege werden NICHT angefasst (audit_log hat ohnehin keine
-- Tenant-Spalte) -- die Trigger no_audit_update/no_audit_delete
-- (0001_initial.sql) wuerden das ohnehin verweigern. Sie bleiben dauerhaft
-- im (manipulationssicheren) Log erhalten.
--
-- Verifiziert (Testharness gegen synthetischen Zwei-Mandanten-Datensatz,
-- ausserhalb dieses Repos, da hier keine echte Produktiv-DB verfuegbar
-- ist): Zeilenzahlen, Datenwerte, foreign_key_check, integrity_check,
-- Audit-Hash-Kette und die Phase-1-Beforderung ueberstehen die Migration
-- unveraendert.
PRAGMA foreign_keys = OFF;
DROP INDEX IF EXISTS idx_host_groups_tenant;
DROP INDEX IF EXISTS idx_user_groups_tenant;
DROP INDEX IF EXISTS idx_ssh_keys_tenant;
DROP INDEX IF EXISTS idx_api_tokens_tenant;
DROP INDEX IF EXISTS idx_rdp_credentials_tenant;
DROP INDEX IF EXISTS idx_users_home_tenant;
BEGIN IMMEDIATE;
-- --- host_groups ------------------------------------------------------------
CREATE TABLE host_groups_new (
id INTEGER PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
description TEXT
);
INSERT INTO host_groups_new (id, name, description)
SELECT id, name, description FROM host_groups;
DROP TABLE host_groups;
ALTER TABLE host_groups_new RENAME TO host_groups;
-- --- user_groups --------------------------------------------------------
CREATE TABLE user_groups_new (
id INTEGER PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
description TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
INSERT INTO user_groups_new (id, name, description, created_at)
SELECT id, name, description, created_at FROM user_groups;
DROP TABLE user_groups;
ALTER TABLE user_groups_new RENAME TO user_groups;
-- --- ssh_keys -------------------------------------------------------------
CREATE TABLE ssh_keys_new (
id INTEGER PRIMARY KEY,
label TEXT NOT NULL,
owner_user_id INTEGER REFERENCES users(id),
private_key_enc BLOB NOT NULL,
public_key TEXT NOT NULL,
key_type TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
rotated_at TEXT,
expires_at TEXT,
passphrase_enc BLOB,
username TEXT
);
INSERT INTO ssh_keys_new (
id, label, owner_user_id, private_key_enc, public_key, key_type,
created_at, rotated_at, expires_at, passphrase_enc, username
)
SELECT id, label, owner_user_id, private_key_enc, public_key, key_type,
created_at, rotated_at, expires_at, passphrase_enc, username
FROM ssh_keys;
DROP TABLE ssh_keys;
ALTER TABLE ssh_keys_new RENAME TO ssh_keys;
-- --- api_tokens -----------------------------------------------------------
CREATE TABLE api_tokens_new (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
label TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
token_prefix TEXT NOT NULL,
scopes_json TEXT NOT NULL,
created_by INTEGER REFERENCES users(id),
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
expires_at TEXT,
last_used_at TEXT,
revoked_at TEXT
);
INSERT INTO api_tokens_new (
id, user_id, label, token_hash, token_prefix, scopes_json,
created_by, created_at, expires_at, last_used_at, revoked_at
)
SELECT id, user_id, label, token_hash, token_prefix, scopes_json,
created_by, created_at, expires_at, last_used_at, revoked_at
FROM api_tokens;
DROP TABLE api_tokens;
ALTER TABLE api_tokens_new RENAME TO api_tokens;
CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON api_tokens(user_id);
-- --- rdp_credentials --------------------------------------------------------
CREATE TABLE rdp_credentials_new (
id INTEGER PRIMARY KEY,
label TEXT NOT NULL,
username TEXT NOT NULL,
domain TEXT,
password_enc BLOB NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
rotated_at TEXT
);
INSERT INTO rdp_credentials_new (id, label, username, domain, password_enc, created_at, rotated_at)
SELECT id, label, username, domain, password_enc, created_at, rotated_at
FROM rdp_credentials;
DROP TABLE rdp_credentials;
ALTER TABLE rdp_credentials_new RENAME TO rdp_credentials;
-- --- users (Betreiberentscheidung: voller Rebuild statt tote Spalte) -----
CREATE TABLE users_new (
id INTEGER PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
totp_secret_enc BLOB,
totp_enrolled INTEGER NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1,
is_admin INTEGER NOT NULL DEFAULT 0,
failed_logins INTEGER NOT NULL DEFAULT 0,
locked_until TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
password_changed_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
must_change_password INTEGER NOT NULL DEFAULT 1,
session_version INTEGER NOT NULL DEFAULT 1,
deleted_at TEXT
);
INSERT INTO users_new (
id, username, password_hash, totp_secret_enc, totp_enrolled, is_active,
is_admin, failed_logins, locked_until, created_at, password_changed_at,
must_change_password, session_version, deleted_at
)
SELECT id, username, password_hash, totp_secret_enc, totp_enrolled, is_active,
is_admin, failed_logins, locked_until, created_at, password_changed_at,
must_change_password, session_version, deleted_at
FROM users;
DROP TABLE users;
ALTER TABLE users_new RENAME TO users;
-- --- tenant_admins/tenants zuletzt -- erst jetzt existiert keine
-- REFERENCES tenants(id)-Klausel mehr im Schema (alle sechs Tabellen oben
-- wurden bereits ohne tenant_id/home_tenant_id neu angelegt).
DROP INDEX IF EXISTS idx_tenant_admins_user;
DROP TABLE tenant_admins;
DROP TABLE tenants;
COMMIT;
PRAGMA foreign_keys = ON;

View File

@ -0,0 +1,34 @@
-- Teil D.6 Schritt 1 (Umsetzungsauftrag_Sonnet5.md): drei nie durchgesetzte
-- ("tote") Rollen entfernen, je nach Betreiberentscheidung. Der Betreiber
-- hat sich fuer alle drei fuer "Entfernen" statt "Ausimplementieren"
-- entschieden (Rueckfrage vom 2026-08-31):
--
-- * clipboard -- Clipboard bleibt ausschliesslich ueber das
-- host-globale hosts.clipboard_enabled
-- gesteuert (heutiges Verhalten, unveraendert).
-- * session_recording_view -- Sitzungs-Playback bleibt dauerhaft
-- ausschliesslich globalen Admins vorbehalten
-- (require_global_admin, unveraendert).
-- * admin_hostgroup -- keine delegierte Hostgruppen-Admin-Ebene;
-- es bleibt bei genau zwei Stufen (globaler
-- Admin vs. granulare Rollen ueber Gruppen).
--
-- Diese drei Rollen waren seit 0001_initial.sql vergebbar, aber kein
-- einziger Codepfad hat sie je geprueft (siehe D.1 im Umsetzungsauftrag) --
-- das Entfernen aendert daher am tatsaechlichen Verhalten NICHTS, schliesst
-- aber die falsche Sicherheitserwartung, dass eine Vergabe dieser Rollen
-- irgendeine Wirkung haette.
--
-- Reihenfolge wichtig: zuerst alle Vergaben dieser Rollen loeschen (FK
-- role_id -> roles(id) hat keine eigene ON DELETE-Klausel, siehe
-- 0001_initial.sql:88/0004_user_groups.sql:28), erst danach die
-- roles-Zeilen selbst.
DELETE FROM user_hostgroup_roles
WHERE role_id IN (SELECT id FROM roles WHERE name IN ('clipboard', 'session_recording_view', 'admin_hostgroup'));
DELETE FROM group_hostgroup_roles
WHERE role_id IN (SELECT id FROM roles WHERE name IN ('clipboard', 'session_recording_view', 'admin_hostgroup'));
DELETE FROM roles
WHERE name IN ('clipboard', 'session_recording_view', 'admin_hostgroup');

View File

@ -0,0 +1,49 @@
-- Migration 0016: SSH-Passwort-Zugangsdaten als eigenstaendiges Objekt mit
-- eigener ID -- Vorarbeit fuer Teil D.6 Schritt 3 (Umsetzungsauftrag_Sonnet5.md
-- D.3): Achse B ("Benutzergruppe x Zugangsdatensatz") braucht fuer alle drei
-- Credential-Arten (SSH-Key, RDP, SSH-Passwort) dieselbe Form -- eine
-- Freigabetabelle referenziert eine ID, keine host_id. SSH-Keys und (seit
-- Migration 0012) RDP-Zugangsdaten haben das bereits; SSH-Passwoerter noch
-- nicht (0011_ssh_password_credentials.sql: host_id war PRIMARY KEY, ein
-- Datensatz ausschliesslich 1:1 an genau einem Host).
--
-- Vorgehen exakt analog zu Migration 0012 (siehe dortiger Kommentar fuer die
-- ausfuehrliche Begruendung): alte 1:1-Tabelle bleibt als Datenquelle
-- erhalten, wird umbenannt; neues eigenstaendiges Objekt plus 1:1-
-- Zuordnungstabelle (bewusst weiterhin PRIMARY KEY auf host_id -- diese
-- Migration fuehrt NICHT die Mehrfachzuweisung-an-mehrere-Hosts-UI ein, die
-- RDP inzwischen hat; das waere ein eigener, spaeterer Schritt. Hier geht es
-- ausschliesslich darum, dass das Objekt eine stabile ID hat, auf die eine
-- Achse-B-Freigabetabelle verweisen kann).
--
-- Korrelation zwischen den beiden folgenden INSERTs laeuft wie bei 0012
-- ueber password_enc statt ueber eine temporaere ID-Spalte: jede
-- Verschluesselung verwendet einen frischen Zufalls-Nonce (app/security/
-- crypto.py::encrypt_secret), zwei Zeilen der Alttabelle koennen also nie
-- denselben password_enc-Wert haben.
ALTER TABLE ssh_password_credentials RENAME TO ssh_password_credentials_legacy;
CREATE TABLE IF NOT EXISTS ssh_password_credentials (
id INTEGER PRIMARY KEY,
label TEXT NOT NULL,
username TEXT NOT NULL,
password_enc BLOB NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
rotated_at TEXT
);
CREATE TABLE IF NOT EXISTS host_ssh_password_credential_map (
host_id INTEGER PRIMARY KEY REFERENCES hosts(id) ON DELETE CASCADE,
ssh_password_credential_id INTEGER NOT NULL REFERENCES ssh_password_credentials(id)
);
INSERT INTO ssh_password_credentials (label, username, password_enc, created_at)
SELECT 'Migriert: ' || h.hostname, spcl.username, spcl.password_enc, spcl.updated_at
FROM ssh_password_credentials_legacy spcl
JOIN hosts h ON h.id = spcl.host_id;
INSERT INTO host_ssh_password_credential_map (host_id, ssh_password_credential_id)
SELECT spcl.host_id, spc.id
FROM ssh_password_credentials_legacy spcl
JOIN ssh_password_credentials spc ON spc.password_enc = spcl.password_enc;

View File

@ -0,0 +1,64 @@
-- Migration 0017: persoenliche Gruppen als rechteerhaltende Migration weg
-- von Direktvergaben (user_hostgroup_roles) -- Teil D.6 Schritt 3, D.4
-- (Umsetzungsauftrag_Sonnet5.md). Empfohlener Ansatz dort: "automatisch
-- erzeugte persoenliche Gruppe je betroffenem Benutzer, danach manuelle
-- Konsolidierung" -- niemand verliert oder gewinnt beim Umstieg etwas.
--
-- user_hostgroup_roles bleibt nach dieser Migration bestehen (Projekt-
-- konvention: Altes bleibt lesbar erhalten statt geloescht, siehe 0010/0012)
-- und wird auch weiterhin von app/rbac.py ausgewertet -- das Leeren/
-- Umbenennen dieser Tabelle ist D.6 Schritt 5 (Schreibpfade abschalten),
-- NICHT dieser Schritt. Diese Migration fuegt nur die Gruppen-Spiegelung
-- HINZU, sie nimmt niemandem etwas weg.
-- D.7-Risiko "Rechteausweitung ueber persoenliche Gruppen": Kennzeichen
-- "persoenlich" (fuer UI/Reports in Schritt 6) plus Trigger-Sperre "max. 1
-- Mitglied" weiter unten. ADD COLUMN mit konstantem Default ist in SQLite
-- ohne Tabellen-Rebuild moeglich.
ALTER TABLE user_groups ADD COLUMN is_personal INTEGER NOT NULL DEFAULT 0;
CREATE TRIGGER IF NOT EXISTS enforce_personal_group_single_member
BEFORE INSERT ON user_group_members
FOR EACH ROW
WHEN (SELECT is_personal FROM user_groups WHERE id = NEW.user_group_id) = 1
AND (SELECT COUNT(*) FROM user_group_members WHERE user_group_id = NEW.user_group_id) >= 1
BEGIN
SELECT RAISE(ABORT, 'Persoenliche Gruppe darf nur ein Mitglied haben');
END;
-- Eine persoenliche Gruppe je Benutzer mit mindestens einer Direktvergabe.
-- Name enthaelt bewusst die User-ID (nicht nur den Benutzernamen), um jede
-- Kollision mit einer bereits existierenden, gleichnamigen Gruppe
-- auszuschliessen (user_groups.name ist UNIQUE NOT NULL) -- ein Fehlschlag
-- hier wuerde die gesamte Migration abbrechen. Explizite Transaktion fuer
-- Atomaritaet (alle drei INSERTs oder keiner) -- anders als 0014 wird hier
-- nichts umgebaut/gedroppt, PRAGMA foreign_keys = OFF ist daher nicht noetig
-- (keine der drei INSERT-Anweisungen kann eine gueltige Fremdschluessel-
-- Referenz verletzen).
BEGIN IMMEDIATE;
INSERT INTO user_groups (name, description, is_personal)
SELECT 'Persoenlich: ' || u.username || ' (#' || u.id || ')',
'Automatisch erzeugt bei der Migration auf ausschliesslich '
|| 'gruppenbasierte Berechtigungen (Teil D.6 Schritt 3, Migration '
|| '0017) -- Spiegel der vormaligen Direktvergaben dieses Benutzers '
|| 'aus user_hostgroup_roles. Kann spaeter bewusst in eine echte '
|| 'Team-Gruppe ueberfuehrt werden (D.6 Schritt 6).',
1
FROM users u
WHERE u.id IN (SELECT DISTINCT user_id FROM user_hostgroup_roles)
AND u.deleted_at IS NULL;
INSERT INTO user_group_members (user_group_id, user_id, added_by, added_at)
SELECT ug.id, u.id, NULL, strftime('%Y-%m-%dT%H:%M:%fZ','now')
FROM user_groups ug
JOIN users u ON ug.name = 'Persoenlich: ' || u.username || ' (#' || u.id || ')'
WHERE ug.is_personal = 1;
INSERT INTO group_hostgroup_roles (user_group_id, host_group_id, role_id, granted_by, granted_at, expires_at)
SELECT ug.id, uhr.host_group_id, uhr.role_id, uhr.granted_by, uhr.granted_at, uhr.expires_at
FROM user_hostgroup_roles uhr
JOIN users u ON u.id = uhr.user_id
JOIN user_groups ug ON ug.name = 'Persoenlich: ' || u.username || ' (#' || u.id || ')' AND ug.is_personal = 1;
COMMIT;

View File

@ -0,0 +1,91 @@
-- Migration 0018: Achse B (Umsetzungsauftrag_Sonnet5.md Teil D.3) --
-- "Benutzergruppe x Zugangsdatensatz". Beantwortet erstmals, WOMIT sich eine
-- Gruppe anmeldet: eine Verbindung soll kuenftig nur zustande kommen, wenn
-- ein Zugangsdatensatz existiert, der SOWOHL dem Host zugeordnet ALS AUCH
-- einer Gruppe des Benutzers freigegeben ist (Achse A -- ssh_connect/
-- rdp_connect je Hostgruppe -- bleibt unveraendert und bestimmt weiterhin,
-- OB ueberhaupt verbunden werden darf).
--
-- Drei schmale Freigabetabellen, eine je Credential-Art, Aufbau analog zu
-- group_hostgroup_roles (zusammengesetzter PK, FK auf user_groups mit ON
-- DELETE CASCADE, FK auf das jeweilige Credential-Objekt, granted_by/
-- granted_at/expires_at). Alle drei Credential-Arten haben seit Migration
-- 0012 (RDP) bzw. 0016 (SSH-Passwort, diese Session) eine eigene stabile ID
-- -- SSH-Keys hatten sie schon immer (0001_initial.sql).
--
-- WICHTIG: diese Migration schafft nur das Schema UND befuellt es
-- rechteneutral (siehe unten) -- app/rbac.py/die Proxies pruefen diese
-- Tabellen noch NICHT (das ist D.6 Schritt 4, "Lesepfade umstellen", noch
-- nicht umgesetzt). Bis dahin bleibt das heutige Verhalten (jeder Nutzer mit
-- ssh_connect/rdp_connect auf der Hostgruppe nutzt automatisch das am Host
-- haengende Credential) unveraendert in Kraft -- diese Migration allein
-- aendert also NICHTS am Laufzeitverhalten.
CREATE TABLE IF NOT EXISTS group_ssh_key_grants (
user_group_id INTEGER NOT NULL REFERENCES user_groups(id) ON DELETE CASCADE,
ssh_key_id INTEGER NOT NULL REFERENCES ssh_keys(id),
granted_by INTEGER REFERENCES users(id),
granted_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
expires_at TEXT,
PRIMARY KEY (user_group_id, ssh_key_id)
);
CREATE INDEX IF NOT EXISTS idx_gskg_group ON group_ssh_key_grants(user_group_id);
CREATE INDEX IF NOT EXISTS idx_gskg_key ON group_ssh_key_grants(ssh_key_id);
CREATE TABLE IF NOT EXISTS group_rdp_credential_grants (
user_group_id INTEGER NOT NULL REFERENCES user_groups(id) ON DELETE CASCADE,
rdp_credential_id INTEGER NOT NULL REFERENCES rdp_credentials(id),
granted_by INTEGER REFERENCES users(id),
granted_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
expires_at TEXT,
PRIMARY KEY (user_group_id, rdp_credential_id)
);
CREATE INDEX IF NOT EXISTS idx_grcg_group ON group_rdp_credential_grants(user_group_id);
CREATE INDEX IF NOT EXISTS idx_grcg_cred ON group_rdp_credential_grants(rdp_credential_id);
CREATE TABLE IF NOT EXISTS group_ssh_password_credential_grants (
user_group_id INTEGER NOT NULL REFERENCES user_groups(id) ON DELETE CASCADE,
ssh_password_credential_id INTEGER NOT NULL REFERENCES ssh_password_credentials(id),
granted_by INTEGER REFERENCES users(id),
granted_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
expires_at TEXT,
PRIMARY KEY (user_group_id, ssh_password_credential_id)
);
CREATE INDEX IF NOT EXISTS idx_gspcg_group ON group_ssh_password_credential_grants(user_group_id);
CREATE INDEX IF NOT EXISTS idx_gspcg_cred ON group_ssh_password_credential_grants(ssh_password_credential_id);
-- Rechteneutrale Vorbefuellung (D.4 Schritt 5): fuer jeden Host mit
-- zugeordnetem Zugangsdatensatz wird jede Gruppe freigeschaltet, die auf der
-- Hostgruppe dieses Hosts die passende Connect-Rolle haelt -- reproduziert
-- exakt das heutige (Achse-A-only) Verhalten, bei dem JEDER Nutzer mit
-- ssh_connect/rdp_connect automatisch das am Host haengende Credential
-- benutzen darf. INSERT OR IGNORE, weil dieselbe (Gruppe, Credential)-
-- Kombination ueber mehrere Hosts hinweg mehrfach auftreten kann (ein
-- SSH-Key kann an mehreren Hosts derselben Hostgruppe haengen).
--
-- Setzt group_hostgroup_roles NACH der Personengruppen-Spiegelung aus
-- Migration 0017 voraus (Reihenfolge 0017 vor 0018 ist deshalb zwingend) --
-- sonst wuerden Nutzer, die ihre Verbindungsrechte bisher nur direkt (nicht
-- ueber eine Gruppe) hatten, hier uebergangen.
INSERT OR IGNORE INTO group_ssh_key_grants (user_group_id, ssh_key_id, granted_by, granted_at)
SELECT DISTINCT ghr.user_group_id, hskm.ssh_key_id, NULL, strftime('%Y-%m-%dT%H:%M:%fZ','now')
FROM host_ssh_key_map hskm
JOIN hosts h ON h.id = hskm.host_id
JOIN group_hostgroup_roles ghr ON ghr.host_group_id = h.host_group_id
JOIN roles r ON r.id = ghr.role_id AND r.name = 'ssh_connect';
INSERT OR IGNORE INTO group_rdp_credential_grants (user_group_id, rdp_credential_id, granted_by, granted_at)
SELECT DISTINCT ghr.user_group_id, hrcm.rdp_credential_id, NULL, strftime('%Y-%m-%dT%H:%M:%fZ','now')
FROM host_rdp_credential_map hrcm
JOIN hosts h ON h.id = hrcm.host_id
JOIN group_hostgroup_roles ghr ON ghr.host_group_id = h.host_group_id
JOIN roles r ON r.id = ghr.role_id AND r.name = 'rdp_connect';
INSERT OR IGNORE INTO group_ssh_password_credential_grants
(user_group_id, ssh_password_credential_id, granted_by, granted_at)
SELECT DISTINCT ghr.user_group_id, hspcm.ssh_password_credential_id, NULL, strftime('%Y-%m-%dT%H:%M:%fZ','now')
FROM host_ssh_password_credential_map hspcm
JOIN hosts h ON h.id = hspcm.host_id
JOIN group_hostgroup_roles ghr ON ghr.host_group_id = h.host_group_id
JOIN roles r ON r.id = ghr.role_id AND r.name = 'ssh_connect';

View File

@ -0,0 +1,26 @@
-- Migration 0019: Direktvergabe von Hostgruppen-Rollen retiriert (Teil D.6
-- Schritt 5, Umsetzungsauftrag_Sonnet5.md D.4 Punkt 4: "user_hostgroup_roles
-- leeren bzw. umbenennen").
--
-- Ab diesem Codestand ist `user_hostgroup_roles` von KEINEM Lese- oder
-- Schreibpfad der Anwendung mehr aus erreichbar:
-- * Lesepfade: app/rbac.py liest die Tabelle bereits seit Migration 0017 +
-- Schritt 4 (Codeaenderung, keine eigene Migration) nicht mehr -- jede
-- vormals direkte Vergabe wurde durch Migration 0017 1:1 in eine
-- persoenliche Gruppe gespiegelt (group_hostgroup_roles).
-- * Schreibpfade: POST /admin/roles/grant und /admin/roles/revoke
-- (app/admin/routes.py) antworten seit derselben Codeaenderung mit HTTP
-- 410 statt in die Tabelle zu schreiben.
--
-- Projektkonvention (siehe 0010:9-14, 0012:12-17, 0016 oben): eine Tabelle
-- wird beim Rueckbau nicht gedroppt, sondern umbenannt und als historische
-- Datenquelle aufbewahrt -- fuer eine spaetere Session, die z.B. pruefen
-- will, wann/von wem eine bestimmte Rolle urspruenglich direkt vergeben
-- wurde, bevor Migration 0017 sie gespiegelt hat. Der zugehoerige Index
-- (0001_initial.sql:94) bleibt ueber die Umbenennung hinweg automatisch an
-- die Tabelle gebunden -- SQLite aktualisiert die interne Referenz.
--
-- Keine FK verweist auf user_hostgroup_roles (sie ist ein reines Blatt im
-- Schema), daher genuegt eine einfache Umbenennung ohne Neuaufbau-Tanz.
ALTER TABLE user_hostgroup_roles RENAME TO user_hostgroup_roles_legacy;

View File

@ -0,0 +1,22 @@
-- Migration 0020: Index fuer die eigene-Sitzungen-Abfrage (Teil F.3.6,
-- Umsetzungsauftrag_Sonnet5.md): GET /catalog/sessions (app/catalog/
-- routes.py) filtert immer nach user_id UND (im Standardfall
-- active_only=true) zusaetzlich nach ended_at IS NULL.
--
-- Bisher existierte nur idx_sessions_user (user_id allein,
-- app/db/migrations/0001_initial.sql:107) -- fuer eine wachsende
-- sessions-Tabelle waere "meine noch offenen Sitzungen" damit ein
-- Index-Scan ueber ALLE Sitzungen dieses Benutzers mit anschliessendem
-- Filter auf ended_at, statt direkt auf die (typischerweise sehr kleine)
-- Teilmenge der offenen Sitzungen zuzugreifen.
--
-- Ein zusammengesetzter Index (user_id, ended_at) deckt per
-- SQLite-Index-Praefix-Regel sowohl "alle meine Sitzungen" (user_id
-- allein) als auch "meine offenen Sitzungen" (user_id + ended_at) ab und
-- macht idx_sessions_user damit redundant -- bewusst NICHT geloescht
-- (Projektkonvention: Indizes/Tabellen werden beim Rueckbau nicht
-- gedroppt, siehe 0019_retire_direct_role_grants.sql; ausserdem nutzen
-- andere Abfragen im Adminbereich, z.B. GET /admin/sessions, weiterhin
-- ausschliesslich user_id ueber JOINs, nicht ueber diesen Index).
CREATE INDEX IF NOT EXISTS idx_sessions_user_ended ON sessions(user_id, ended_at);

View File

@ -4,10 +4,12 @@ Auslieferung zusammen (siehe Konzept 6.6 Web-Anwendungs-Hardening).
""" """
from __future__ import annotations from __future__ import annotations
import asyncio
import logging import logging
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import Depends, FastAPI, Request from fastapi import Depends, FastAPI, Query, Request
from fastapi.responses import JSONResponse
from fastapi.openapi.utils import get_openapi from fastapi.openapi.utils import get_openapi
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
@ -18,9 +20,11 @@ from app.admin.routes import router as admin_router
from app.auth.deps import CurrentUser, require_global_admin from app.auth.deps import CurrentUser, require_global_admin
from app.auth.routes import router as auth_router from app.auth.routes import router as auth_router
from app.catalog.routes import router as catalog_router from app.catalog.routes import router as catalog_router
from app.db import close_db, init_db from app.config import settings
from app.db import close_db, get_db, init_db
from app.rdp_proxy.ws_tunnel import router as rdp_ws_router from app.rdp_proxy.ws_tunnel import router as rdp_ws_router
from app.security import log_stream from app.security import log_stream
from app.security.session_reaper import reap_orphaned_sessions
from app.ssh_proxy.proxy import bcrypt_kdf_available from app.ssh_proxy.proxy import bcrypt_kdf_available
from app.ssh_proxy.sftp import router as sftp_router from app.ssh_proxy.sftp import router as sftp_router
from app.ssh_proxy.terminal_ws import router as ssh_ws_router from app.ssh_proxy.terminal_ws import router as ssh_ws_router
@ -56,6 +60,13 @@ async def lifespan(app: FastAPI):
log_stream.install() log_stream.install()
_check_optional_dependencies() _check_optional_dependencies()
await init_db() await init_db()
# E7 (Umsetzungsauftrag_Sonnet5.md Teil E.1): Sitzungen, die beim letzten
# Absturz/Neustart offen waren, haben ended_at IS NULL fuer immer -- es
# gibt keinen Task mehr, der sie regulaer schliessen wuerde. Muss NACH
# init_db() laufen (Migrationen/Tabellen muessen existieren) und VOR dem
# ersten Request, damit die Sessionview und kuenftige
# "wie viele Sitzungen offen"-Zaehlungen (Teil F) nie mit Geistern rechnen.
await reap_orphaned_sessions(get_db())
yield yield
await close_db() await close_db()
@ -88,7 +99,7 @@ async def security_headers_middleware(request: Request, call_next):
"default-src 'self'; " "default-src 'self'; "
"script-src 'self'; " "script-src 'self'; "
"style-src 'self'; " "style-src 'self'; "
"img-src 'self' data:; " "img-src 'self' data: blob:; "
"connect-src 'self' ws: wss:; " "connect-src 'self' ws: wss:; "
"frame-ancestors 'none'; " "frame-ancestors 'none'; "
"base-uri 'self'; " "base-uri 'self'; "
@ -114,6 +125,15 @@ async def dashboard(request: Request):
return templates.TemplateResponse(request, "dashboard.html", {}) return templates.TemplateResponse(request, "dashboard.html", {})
@app.get("/workspace", response_class=HTMLResponse)
async def workspace_page(request: Request):
# F3 (Umsetzungsauftrag_Sonnet5.md Teil F.3): dauerhafte Arbeitsflaeche
# mit Seitenleiste -- wie /dashboard rein statisches Markup, Auth
# clientseitig ueber GET /auth/me, Sitzungs-/Katalogdaten ueber die
# bestehenden /catalog/*-Endpunkte (app/catalog/routes.py).
return templates.TemplateResponse(request, "workspace.html", {})
@app.get("/admin", response_class=HTMLResponse) @app.get("/admin", response_class=HTMLResponse)
async def admin_page(request: Request): async def admin_page(request: Request):
# Wie /dashboard: Seite selbst ist statisches Markup ohne Secrets, die # Wie /dashboard: Seite selbst ist statisches Markup ohne Secrets, die
@ -134,9 +154,56 @@ async def rdp_page(request: Request, host_id: int):
@app.get("/healthz") @app.get("/healthz")
async def healthz(): async def healthz(deep: bool = Query(default=False)):
"""Flache Variante (Standard): Prozess laeuft, sonst nichts -- schnell
genug fuer haeufige Load-Balancer-/Monitoring-Abfragen.
Tiefe Variante (`?deep=true`, Befund B.2 in Umsetzungsauftrag_Sonnet5.md
Teil B): prueft zusaetzlich die DB-Verbindung (billiges `SELECT 1` auf
der einen gemeinsamen Verbindung, siehe app/db.py) und guacd per
TCP-Verbindungsversuch mit kurzem Timeout. Vorher lieferte /healthz
IMMER "ok", auch wenn guacd tot war -- das fiel erst auf, wenn ein
Benutzer eine RDP-Sitzung versuchte. Bewusst NICHT als Standardverhalten,
damit ein ausgefallenes guacd nicht jede flache Liveness-Pruefung (die
ueblicherweise sehr haeufig laeuft) mit in den Fehlerzustand reisst.
"""
if not deep:
return {"status": "ok"} return {"status": "ok"}
checks: dict[str, str] = {}
healthy = True
try:
conn = get_db()
await asyncio.wait_for(conn.execute("SELECT 1"), timeout=2.0)
checks["db"] = "ok"
except Exception as exc:
checks["db"] = f"fehler: {exc.__class__.__name__}"
healthy = False
try:
reader, writer = await asyncio.wait_for(
asyncio.open_connection(settings.guacd_host, settings.guacd_port), timeout=2.0
)
writer.close()
try:
await writer.wait_closed()
except Exception:
pass
checks["guacd"] = "ok"
except Exception as exc:
checks["guacd"] = (
f"nicht erreichbar unter {settings.guacd_host}:{settings.guacd_port} "
f"({exc.__class__.__name__})"
)
healthy = False
status_code = 200 if healthy else 503
return JSONResponse(
status_code=status_code,
content={"status": "ok" if healthy else "fehler", "checks": checks},
)
# --- API-Dokumentation -------------------------------------------------------- # --- API-Dokumentation --------------------------------------------------------
# #

View File

@ -16,8 +16,7 @@ USERNAME_RE = re.compile(r"^[a-zA-Z0-9._-]{3,64}$")
HOSTNAME_LABEL_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$") HOSTNAME_LABEL_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$")
ROLE_NAME = Literal[ ROLE_NAME = Literal[
"ssh_connect", "rdp_connect", "file_transfer", "clipboard", "ssh_connect", "rdp_connect", "file_transfer",
"session_recording_view", "admin_hostgroup",
# Hinzugefuegt fuer RBAC-Erweiterung 'Credentials ins RBAC-Modell' (Migration # Hinzugefuegt fuer RBAC-Erweiterung 'Credentials ins RBAC-Modell' (Migration
# 0008_credentials_roles.sql): erlaubt Nicht-Admins mit dieser Rolle auf # 0008_credentials_roles.sql): erlaubt Nicht-Admins mit dieser Rolle auf
# einer Hostgruppe gezielten Zugriff auf Zugangsdaten (SSH-Keys, RDP- # einer Hostgruppe gezielten Zugriff auf Zugangsdaten (SSH-Keys, RDP-
@ -25,6 +24,13 @@ ROLE_NAME = Literal[
# require_admin_scope_or_host_role und admin/routes.py (rdp-credentials, # require_admin_scope_or_host_role und admin/routes.py (rdp-credentials,
# ssh-keys-Zuordnung, GET /admin/hosts/{id}/credentials). # ssh-keys-Zuordnung, GET /admin/hosts/{id}/credentials).
"credentials_view", "credentials_manage", "credentials_view", "credentials_manage",
# 'clipboard', 'session_recording_view' und 'admin_hostgroup' wurden mit
# Migration 0015_remove_dead_roles.sql entfernt (Teil D.6 Schritt 1,
# Umsetzungsauftrag_Sonnet5.md) -- sie waren seit 0001_initial.sql
# vergebbar, aber kein Codepfad hat sie je geprueft. Betreiberentscheidung
# (2026-08-31): Clipboard bleibt host-global (hosts.clipboard_enabled),
# Sitzungs-Playback bleibt global-admin-only, keine delegierte
# Hostgruppen-Admin-Ebene.
] ]
@ -58,11 +64,6 @@ class UserCreateRequest(BaseModel):
username: str = Field(min_length=3, max_length=64) username: str = Field(min_length=3, max_length=64)
initial_password: str = Field(min_length=12, max_length=256) initial_password: str = Field(min_length=12, max_length=256)
is_admin: bool = False is_admin: bool = False
# Nur relevant, wenn der anlegende Principal Mandanten-Admin ist: wird
# serverseitig ohnehin auf den/die erlaubten Mandanten erzwungen (siehe
# create_user() in admin/routes.py) -- ein Mandanten-Admin kann hierueber
# KEINEN User einem fremden Mandanten zuordnen. Fuer Super-Admins optional.
home_tenant_id: int | None = None
@field_validator("username") @field_validator("username")
@classmethod @classmethod
@ -78,27 +79,9 @@ class UserUpdateRequest(BaseModel):
new_password: str | None = Field(default=None, min_length=12, max_length=256) new_password: str | None = Field(default=None, min_length=12, max_length=256)
class TenantCreateRequest(BaseModel):
name: str = Field(min_length=1, max_length=128)
description: str | None = Field(default=None, max_length=1024)
class TenantUpdateRequest(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=128)
description: str | None = Field(default=None, max_length=1024)
is_active: bool | None = None
class TenantAdminAssignRequest(BaseModel):
user_id: int
class HostGroupCreateRequest(BaseModel): class HostGroupCreateRequest(BaseModel):
name: str = Field(min_length=1, max_length=128) name: str = Field(min_length=1, max_length=128)
description: str | None = Field(default=None, max_length=1024) description: str | None = Field(default=None, max_length=1024)
# Nur fuer Super-Admins relevant (Mandanten-Admins werden serverseitig
# ohnehin auf ihren eigenen Mandanten festgelegt, siehe admin/routes.py).
tenant_id: int | None = None
class HostGroupUpdateRequest(BaseModel): class HostGroupUpdateRequest(BaseModel):
@ -179,7 +162,6 @@ class RoleRevokeRequest(BaseModel):
class UserGroupCreateRequest(BaseModel): class UserGroupCreateRequest(BaseModel):
name: str = Field(min_length=1, max_length=128) name: str = Field(min_length=1, max_length=128)
description: str | None = Field(default=None, max_length=1024) description: str | None = Field(default=None, max_length=1024)
tenant_id: int | None = None
class UserGroupUpdateRequest(BaseModel): class UserGroupUpdateRequest(BaseModel):
@ -204,14 +186,29 @@ class GroupRoleRevokeRequest(BaseModel):
role_name: ROLE_NAME role_name: ROLE_NAME
class GroupCredentialGrantRequest(BaseModel):
"""Achse B (Teil D Schritt 3+5, Umsetzungsauftrag_Sonnet5.md): Freigabe
EINES Zugangsdatensatzes (SSH-Key, RDP-Zugangsdaten oder SSH-Passwort --
die Art steht im Endpunktpfad, nicht im Body, siehe app/admin/routes.py)
an eine Benutzergruppe. Struktur bewusst identisch zu
GroupRoleGrantRequest (Achse A), nur mit `credential_id` statt
`host_group_id`/`role_names` -- unterschiedliche Achse, gleiches
Vergabemuster (Gruppe + Ziel + optionales Ablaufdatum)."""
user_group_id: int
credential_id: int
expires_at: str | None = None
class GroupCredentialRevokeRequest(BaseModel):
user_group_id: int
credential_id: int
class ApiTokenCreateRequest(BaseModel): class ApiTokenCreateRequest(BaseModel):
label: str = Field(min_length=1, max_length=128) label: str = Field(min_length=1, max_length=128)
user_id: int user_id: int
scopes: list[str] = Field(min_length=1, max_length=32) scopes: list[str] = Field(min_length=1, max_length=32)
expires_at: str | None = None expires_at: str | None = None
# Nur fuer Super-Admins relevant -- ein Mandanten-Admin wird serverseitig
# ohnehin auf seinen eigenen Mandanten festgelegt.
tenant_id: int | None = None
class SshKeyCreateRequest(BaseModel): class SshKeyCreateRequest(BaseModel):
@ -219,7 +216,6 @@ class SshKeyCreateRequest(BaseModel):
private_key_pem: str = Field(min_length=1, max_length=32_768) private_key_pem: str = Field(min_length=1, max_length=32_768)
public_key: str = Field(min_length=1, max_length=8192) public_key: str = Field(min_length=1, max_length=8192)
key_type: Literal["ed25519", "rsa-3072", "rsa-4096", "ca-cert"] key_type: Literal["ed25519", "rsa-3072", "rsa-4096", "ca-cert"]
tenant_id: int | None = None
# Anmeldename am Zielsystem. Gehoert zum Schluessel, nicht zum Host # Anmeldename am Zielsystem. Gehoert zum Schluessel, nicht zum Host
# (Migration 0010): ein Schluessel ist immer "der Zugang von Benutzer X". # (Migration 0010): ein Schluessel ist immer "der Zugang von Benutzer X".
# Fuer mehrere Konten wird je ein Schluesseleintrag angelegt. # Fuer mehrere Konten wird je ein Schluesseleintrag angelegt.
@ -264,7 +260,6 @@ class RdpCredentialCreateRequest(BaseModel):
username: str = Field(min_length=1, max_length=128) username: str = Field(min_length=1, max_length=128)
domain: str | None = Field(default=None, max_length=128) domain: str | None = Field(default=None, max_length=128)
password: str = Field(min_length=1, max_length=512) password: str = Field(min_length=1, max_length=512)
tenant_id: int | None = None
class RdpCredentialUpdateRequest(BaseModel): class RdpCredentialUpdateRequest(BaseModel):

View File

@ -1,27 +1,87 @@
"""RBAC-Durchsetzung: Rolle × Hostgruppe (siehe Konzept 4.6). """RBAC-Durchsetzung: Rolle × Hostgruppe (siehe Konzept 4.6) sowie, seit
Teil D Schritt 4, Achse B (Benutzergruppe × Zugangsdatensatz).
Eine Rolle gilt fuer einen User entweder, wenn sie ihm DIREKT vergeben wurde Eine Rolle gilt fuer einen User NUR NOCH ueber seine Mitgliedschaft in einer
(user_hostgroup_roles), ODER wenn sie einer Benutzergruppe (user_groups) Benutzergruppe (user_groups/user_group_members), der die Rolle auf der
vergeben wurde, in der der User Mitglied ist (group_hostgroup_roles) -- Hostgruppe vergeben wurde (group_hostgroup_roles). Der frueher zusaetzlich
volle Rollen-Vererbung: jedes Gruppenmitglied erhaelt automatisch alle der vorhandene Direktvergabe-Zweig ueber user_hostgroup_roles ist mit Teil D
Gruppe gewaehrten Rollen, ohne individuellen Eintrag.""" Schritt 4 entfallen (Umsetzungsauftrag_Sonnet5.md, D.3: "user_has_role()
schrumpft auf einen Zweig"). Das ist erst SICHER, seit Teil D Schritt 3
(Migration 0017_personal_groups.sql) jeden Benutzer mit vormals direkten
Vergaben in eine automatisch erzeugte persoenliche Gruppe mit identischen
Vergaben gespiegelt hat -- siehe FORTSETZUNG_Teil_D.md Abschnitt 1a. Die
Tabelle user_hostgroup_roles selbst existiert als Altlast weiter (Migration
0015 hat nur die drei toten Rollen daraus entfernt) und wird erst in
Schritt 5 geleert/umbenannt; sie wird ab hier von keiner Leselogik mehr
konsultiert.
Achse B (group_ssh_key_grants, group_rdp_credential_grants,
group_ssh_password_credential_grants, angelegt in Migration
0018_credential_group_grants.sql) beantwortet eine zweite, unabhaengige
Frage: nicht "darf dieser Benutzer sich mit Host X verbinden" (Achse A,
oben), sondern "WOMIT darf er sich anmelden". user_can_use_credential() und
resolve_credential_for_user_on_host() sind die dafuer vorgesehenen zwei
neuen Funktionen (D.3: "eine Pruefung 'darf dieser Benutzer diesen
Zugangsdatensatz nutzen' und eine Auflösung 'welcher Satz gilt fuer diesen
Benutzer auf diesem Host'")."""
from __future__ import annotations from __future__ import annotations
from typing import Literal
import aiosqlite import aiosqlite
CredentialKind = Literal["ssh_key", "rdp_credential", "ssh_password_credential"]
# Achse-B-Freigabetabelle je Credential-Art (Migration 0018) --
# (Tabellenname, Spaltenname der Credential-ID).
_GRANT_TABLE: dict[CredentialKind, tuple[str, str]] = {
"ssh_key": ("group_ssh_key_grants", "ssh_key_id"),
"rdp_credential": ("group_rdp_credential_grants", "rdp_credential_id"),
"ssh_password_credential": (
"group_ssh_password_credential_grants",
"ssh_password_credential_id",
),
}
# Host<->Credential-Zuordnungstabelle je Credential-Art (unveraendert seit
# vor Teil D) -- (Tabellenname, Spaltenname der Credential-ID). ssh_key ist
# n:m (mehrere Schluessel je Host moeglich); die beiden anderen sind laut
# Schema 1:1 (PK auf host_id), Mehrdeutigkeit kann dort strukturell nicht
# auftreten.
_MAP_TABLE: dict[CredentialKind, tuple[str, str]] = {
"ssh_key": ("host_ssh_key_map", "ssh_key_id"),
"rdp_credential": ("host_rdp_credential_map", "rdp_credential_id"),
"ssh_password_credential": (
"host_ssh_password_credential_map",
"ssh_password_credential_id",
),
}
class AmbiguousCredentialError(Exception):
"""Mehr als ein fuer den Benutzer nutzbarer Zugangsdatensatz derselben Art
an einem Host. Bewusst ein harter Fehler statt einer stillen Auswahl
(Umsetzungsauftrag_Sonnet5.md D.3: "Bei mehreren Treffern lieber ein
klarer Fehler ... als das heutige LIMIT 1 ohne ORDER BY"). Kann nur bei
kind="ssh_key" auftreten (n:m-Zuordnung); bei den beiden anderen Arten
ist die Host-Zuordnung strukturell 1:1."""
def __init__(self, *, host_id: int, kind: CredentialKind, credential_ids: list[int]):
self.host_id = host_id
self.kind = kind
self.credential_ids = credential_ids
super().__init__(
f"Mehrdeutige Zugangsdaten (kind={kind}) fuer Host {host_id}: "
f"mehrere fuer den Benutzer nutzbare Eintraege {credential_ids} -- "
"keine automatische Auswahl."
)
async def user_has_role( async def user_has_role(
conn: aiosqlite.Connection, *, user_id: int, host_group_id: int, role_name: str conn: aiosqlite.Connection, *, user_id: int, host_group_id: int, role_name: str
) -> bool: ) -> bool:
cursor = await conn.execute( cursor = await conn.execute(
""" """
SELECT 1 FROM user_hostgroup_roles uhr
JOIN roles r ON r.id = uhr.role_id
WHERE uhr.user_id = ?
AND uhr.host_group_id = ?
AND r.name = ?
AND (uhr.expires_at IS NULL OR uhr.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now'))
UNION
SELECT 1 FROM group_hostgroup_roles ghr SELECT 1 FROM group_hostgroup_roles ghr
JOIN roles r ON r.id = ghr.role_id JOIN roles r ON r.id = ghr.role_id
JOIN user_group_members ugm ON ugm.user_group_id = ghr.user_group_id JOIN user_group_members ugm ON ugm.user_group_id = ghr.user_group_id
@ -31,7 +91,7 @@ async def user_has_role(
AND (ghr.expires_at IS NULL OR ghr.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now')) AND (ghr.expires_at IS NULL OR ghr.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now'))
LIMIT 1 LIMIT 1
""", """,
(user_id, host_group_id, role_name, user_id, host_group_id, role_name), (user_id, host_group_id, role_name),
) )
row = await cursor.fetchone() row = await cursor.fetchone()
return row is not None return row is not None
@ -45,3 +105,86 @@ async def user_has_role_for_host(
if row is None: if row is None:
return False return False
return await user_has_role(conn, user_id=user_id, host_group_id=row[0], role_name=role_name) return await user_has_role(conn, user_id=user_id, host_group_id=row[0], role_name=role_name)
async def user_host_group_ids_with_any_role(
conn: aiosqlite.Connection, *, user_id: int, role_names: tuple[str, ...]
) -> set[int]:
"""Wie user_has_role(), aber fuer eine oder mehrere Rollen auf einmal und
ueber ALLE Hostgruppen hinweg statt eine einzelne host_group_id zu
pruefen -- fuer Uebersichtsabfragen (z.B. den Katalog, app/catalog/
routes.py), die sonst pro Hostgruppe/Host eine Einzelabfrage bräuchten.
S13 (Umsetzungsauftrag_Sonnet5.md Teil D.2): app/rbac.py ist die einzige
Stelle, die RBAC ausserhalb hier nachbaut -- Aufrufer bauen sie nicht
mehr selbst nach."""
if not role_names:
return set()
placeholders = ",".join("?" for _ in role_names)
cursor = await conn.execute(
f"""
SELECT ghr.host_group_id FROM group_hostgroup_roles ghr
JOIN roles r ON r.id = ghr.role_id
JOIN user_group_members ugm ON ugm.user_group_id = ghr.user_group_id
WHERE ugm.user_id = ? AND r.name IN ({placeholders})
AND (ghr.expires_at IS NULL OR ghr.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now'))
""",
(user_id, *role_names),
)
rows = await cursor.fetchall()
return {row[0] for row in rows}
async def user_can_use_credential(
conn: aiosqlite.Connection, *, user_id: int, kind: CredentialKind, credential_id: int
) -> bool:
"""Achse B: darf dieser Benutzer diesen konkreten Zugangsdatensatz
benutzen -- ueber die Mitgliedschaft in mindestens einer Benutzergruppe,
der der Datensatz freigegeben wurde (group_*_grants, Migration 0018)."""
table, col = _GRANT_TABLE[kind]
cursor = await conn.execute(
f"""
SELECT 1 FROM {table} g
JOIN user_group_members ugm ON ugm.user_group_id = g.user_group_id
WHERE ugm.user_id = ? AND g.{col} = ?
AND (g.expires_at IS NULL OR g.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now'))
LIMIT 1
""",
(user_id, credential_id),
)
row = await cursor.fetchone()
return row is not None
async def resolve_credential_for_user_on_host(
conn: aiosqlite.Connection, *, user_id: int, host_id: int, kind: CredentialKind
) -> int | None:
"""Achse B: welcher Zugangsdatensatz der angegebenen Art gilt fuer diesen
Benutzer auf diesem Host -- ersetzt die frueher benutzerblinden
`LIMIT 1`-Abfragen (app/ssh_proxy/proxy.py, app/rdp_proxy/ws_tunnel.py).
Liefert die Credential-ID, die (a) dem Host ueber die jeweilige
host_*_map-Tabelle zugeordnet UND (b) mindestens einer Benutzergruppe
dieses Users ueber Achse B freigegeben ist. None, wenn keine passt (kann
zwei Ursachen haben: kein Credential dieser Art am Host ODER eines ist
zugeordnet, aber keiner Gruppe des Benutzers freigegeben -- Aufrufer
formulieren die Fehlermeldung entsprechend). Wirft
AmbiguousCredentialError, wenn mehr als eine Credential-ID passt."""
map_table, map_col = _MAP_TABLE[kind]
grant_table, grant_col = _GRANT_TABLE[kind]
cursor = await conn.execute(
f"""
SELECT DISTINCT m.{map_col} FROM {map_table} m
JOIN {grant_table} g ON g.{grant_col} = m.{map_col}
JOIN user_group_members ugm ON ugm.user_group_id = g.user_group_id
WHERE m.host_id = ? AND ugm.user_id = ?
AND (g.expires_at IS NULL OR g.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now'))
""",
(host_id, user_id),
)
rows = await cursor.fetchall()
ids = [row[0] for row in rows]
if not ids:
return None
if len(ids) > 1:
raise AmbiguousCredentialError(host_id=host_id, kind=kind, credential_ids=ids)
return ids[0]

View File

@ -18,12 +18,206 @@ from __future__ import annotations
import asyncio import asyncio
import codecs import codecs
import logging import logging
import re
from pathlib import Path
from app.config import settings
logger = logging.getLogger("jumphost.rdp_proxy.guacd") logger = logging.getLogger("jumphost.rdp_proxy.guacd")
def rdp_drive_path(host_id: int, session_id: int) -> Path:
"""Pfad des pro Sitzung umgeleiteten RDP-Laufwerks (Befund E6,
Umsetzungsauftrag Teil E).
Vorher war der Pfad NUR je Host angelegt (`.../rdp-drives/{host_id}`,
fuer alle Sitzungen auf diesem Host identisch) -- zwei gleichzeitige
Benutzer auf demselben Windows-Ziel teilten sich damit ein Verzeichnis:
A sah/ueberschrieb/las die Dateien von B. Je-Sitzung-Pfade schliessen das
aus; Aufraeumen beim Sitzungsende siehe app/rdp_proxy/ws_tunnel.py
(`_cleanup_rdp_drive_path`).
"""
return settings.data_dir / "rdp-drives" / str(host_id) / str(session_id)
# Der von "connect"/"select" bis zur eigentlichen Bilduebertragung genutzte
# StreamReader-Puffer muss groesser als der asyncio-Default (64 KiB) sein --
# einzelne Guacamole-Instruktionen (z.B. ein base64-kodierter Bild-Blob) sind
# regelmaessig groesser. Ohne diesen erhoehten Wert wirft readuntil()/
# readexactly() ein LimitOverrunError, sobald mehr als 64 KiB unverarbeitet
# im Puffer stehen (Umsetzungsauftrag_Sonnet5.md Befund A1).
GUACD_STREAM_LIMIT = 4 * 1024 * 1024
# Guacamole-Protokoll-Versions-Token, das guacd >= 1.1.0 als ERSTEN Eintrag
# der "args"-Instruktion mitschickt (Befund C1). Es sieht wie ein normaler
# Parametername aus ("VERSION_1_5_0"), ist aber keiner -- es signalisiert die
# hoechste vom Server unterstuetzte Protokollversion und muss vom Client als
# ERSTER Wert der "connect"-Instruktion bestaetigt werden, sonst faellt guacd
# auf Protokoll 1.0.0 zurueck (verliert dabei timezone/required/msg).
_VERSION_TOKEN_RE = re.compile(r"VERSION_\d+_\d+_\d+")
# Guacamole-Protokoll-Statuscodes (dezimal) -> deutsche Klartexte (Teil B.2).
# Quelle: Guacamole.Status.Code in guacamole-common-js (Status.js), Werte
# dort hexadezimal definiert. Nicht gelistete Codes fallen auf einen
# generischen Text zurueck, der den Rohcode weiterhin sichtbar macht -- ein
# unbekannter Code ist damit trotzdem diagnostizierbar, nicht stumm.
GUAC_STATUS_MESSAGES: dict[int, str] = {
0: "Erfolgreich",
256: "Nicht unterstuetzte Operation",
512: "Serverfehler (guacd/FreeRDP intern)",
513: "guacd ist ausgelastet, bitte spaeter erneut versuchen",
514: "Das Zielsystem antwortet nicht (Zeitueberschreitung)",
515: "Das Zielsystem meldet einen Fehler",
516: "Angeforderte Ressource nicht gefunden",
517: "Ressourcenkonflikt (Ressource bereits in Verwendung)",
518: "Ressource ist bereits geschlossen",
519: "Ziel nicht erreichbar",
520: "Ziel derzeit nicht verfuegbar",
521: "Sitzung durch eine andere Sitzung auf dem Ziel beendet",
522: "Sitzung wegen Inaktivitaet auf dem Ziel beendet",
523: "Sitzung auf dem Ziel zwangsweise beendet",
768: "Ungueltige Verbindungsparameter",
769: "Anmeldung fehlgeschlagen (Benutzername/Passwort pruefen)",
771: "Zugriff verweigert (auch nach erfolgreicher Anmeldung nicht erlaubt)",
776: "Zeitueberschreitung beim Verbindungsaufbau (Zertifikat/TLS-Handshake pruefen)",
781: "Zu viele Daten gesendet",
783: "Nicht unterstuetzter Datentyp gesendet",
797: "Zu viele gleichzeitige Ressourcen angefordert",
}
def guac_status_text(code: int) -> str:
"""Deutscher Klartext zu einem Guacamole-Statuscode, mit Rohcode als Fallback."""
text = GUAC_STATUS_MESSAGES.get(code)
if text:
return f"{text} (Code {code})"
return f"guacd meldet unbekannten Status {code}"
class GuacamoleProtocolError(Exception): class GuacamoleProtocolError(Exception):
pass """Fehler auf Ebene des Guacamole-Instruktionsprotokolls (kaputte
Laengenangabe, unerwartetes Trennzeichen, unerwartete Instruktion an
einer Stelle, an der eine andere erwartet wurde)."""
class GuacdUnreachableError(Exception):
"""guacd ist unter guacd_host:guacd_port nicht erreichbar oder antwortet
nicht innerhalb der konfigurierten Zeit (Befund B.2, Teil B)."""
def __init__(self, guacd_host: str, guacd_port: int, cause: BaseException) -> None:
self.guacd_host = guacd_host
self.guacd_port = guacd_port
self.cause_type = cause.__class__.__name__
if isinstance(cause, (asyncio.TimeoutError, TimeoutError)):
detail = "keine Antwort innerhalb der konfigurierten Zeit (Timeout)"
elif isinstance(cause, ConnectionRefusedError):
detail = "Verbindung abgelehnt -- vermutlich laeuft/lauscht guacd nicht"
else:
detail = str(cause) or cause.__class__.__name__
super().__init__(f"guacd unter {guacd_host}:{guacd_port} nicht erreichbar: {detail}")
class GuacdDisconnectedError(GuacamoleProtocolError):
"""guacd hat die Verbindung mitten im Protokoll unerwartet beendet (EOF).
Haeufigste Ursache: ein Absturz des FreeRDP-Plugins in guacd."""
class GuacdStatusError(GuacamoleProtocolError):
"""guacd hat eine 'error'-Instruktion gesendet (Protokoll-Referenz:
Argumente sind [message, status] in dieser Reihenfolge)."""
def __init__(self, status_code: int, guac_message: str) -> None:
self.status_code = status_code
self.guac_message = guac_message
super().__init__(guac_status_text(status_code))
async def _read_length_prefix(reader: asyncio.StreamReader) -> int:
"""Liest die Zeichen-Laengenangabe vor dem '.' einer Instruktion.
Nutzt StreamReader.readuntil() (gepuffert, C-implementiert) statt der
fruehren Byte-fuer-Byte-Schleife -- fuer eine Laengenangabe von hoechstens
einigen Ziffern ist der Unterschied klein, aber jede Vereinfachung hier
zahlt auf denselben Umbau wie _read_chars() ein (Befund A1).
"""
try:
raw = await reader.readuntil(b".")
except asyncio.IncompleteReadError as exc:
raise GuacdDisconnectedError(
"guacd hat die Verbindung unerwartet beendet (EOF beim Lesen einer Laengenangabe)"
) from exc
except asyncio.LimitOverrunError as exc:
raise GuacamoleProtocolError(
"Laengenangabe ueberschreitet den Lesepuffer -- vermutlich kaputtes Protokoll"
) from exc
length_bytes = raw[:-1] # Trennzeichen "." abschneiden
try:
return int(length_bytes)
except ValueError as exc:
raise GuacamoleProtocolError(f"Ungueltige Laengenangabe: {length_bytes!r}") from exc
async def _read_chars(reader: asyncio.StreamReader, count: int) -> str:
"""Liest genau `count` ZEICHEN (nicht Bytes) UTF-8-kodiert vom Stream.
Befund A1 (Umsetzungsauftrag_Sonnet5.md Teil A.3): die vorherige Fassung
rief readexactly(1) einmal PRO ZEICHEN auf -- bei einem base64-kodierten
Bild-Blob (der weit ueberwiegende Anteil des RDP-Bildstroms) waren das
ohne weiteres hunderttausende einzelne awaits fuer eine einzige
Instruktion, mit entsprechendem Event-Loop-Overhead (Phase 16: Faktor
644x gegenueber gepuffertem Lesen).
Ausnutzung der Guacamole-Invariante "Laenge zaehlt Zeichen, nicht Bytes":
jedes UTF-8-Zeichen belegt MINDESTENS ein Byte, `count` Bytes liefern
also NIE mehr als `count` Zeichen. `count` Bytes auf einmal zu lesen ist
damit immer eine sichere (nie zu grosse) Untergrenze fuer die noch
fehlende Byteanzahl:
- Reiner ASCII-Inhalt (praktisch der gesamte Bildstrom, da Base64 nur
ASCII-Zeichen kennt): EIN readexactly() statt `count` einzelner --
der Normalfall, fuer den dieser Umbau gemacht ist.
- Mehrbyte-Zeichen (z.B. ein Umlaut in einem Passwort/Hostnamen/einer
Zwischenablage-Uebertragung): der inkrementelle Decoder puffert dann
intern unvollstaendige Byte-Reste, `chars` waechst langsamer als
`count` Bytes gelesen wurden, die Schleife liest gezielt genau den
fehlenden Rest nach -- korrekt und weiterhin ohne Byte-fuer-Byte-Awaits.
Ein readexactly() kann dabei nie ueber das Ende des aktuellen Elements
hinaus in den nachfolgenden Trenner/das naechste Element lesen: aus
`remaining` Bytes koennen hoechstens `remaining` Zeichen entstehen, die
Schleife fordert also nie mehr Bytes an, als fuer die noch fehlenden
Zeichen mindestens noetig sind.
"""
decoder = codecs.getincrementaldecoder("utf-8")()
chars: list[str] = []
remaining = count
while remaining > 0:
try:
chunk = await reader.readexactly(remaining)
except asyncio.IncompleteReadError as exc:
raise GuacdDisconnectedError(
"guacd hat die Verbindung unerwartet beendet (EOF mitten in einer Instruktion)"
) from exc
decoded = decoder.decode(chunk)
if decoded:
chars.append(decoded)
remaining = count - sum(len(c) for c in chars)
return "".join(chars)
async def read_instruction(reader: asyncio.StreamReader) -> list[str]:
elements: list[str] = []
while True:
length = await _read_length_prefix(reader)
content = await _read_chars(reader, length)
elements.append(content)
try:
sep = await reader.readexactly(1)
except asyncio.IncompleteReadError as exc:
raise GuacdDisconnectedError(
"guacd hat die Verbindung unerwartet beendet (EOF nach einem Element, vor dem Trennzeichen)"
) from exc
if sep == b";":
return elements
if sep != b",":
raise GuacamoleProtocolError(f"Unerwartetes Trennzeichen: {sep!r}")
def encode_instruction(*elements: str) -> str: def encode_instruction(*elements: str) -> str:
@ -41,72 +235,71 @@ def encode_instruction(*elements: str) -> str:
return ",".join(f"{len(element)}.{element}" for element in elements) + ";" return ",".join(f"{len(element)}.{element}" for element in elements) + ";"
async def _read_until(reader: asyncio.StreamReader, delimiter: bytes) -> bytes:
buf = bytearray()
while True:
b = await reader.readexactly(1)
if b == delimiter:
return bytes(buf)
buf += b
async def _read_chars(reader: asyncio.StreamReader, count: int) -> str:
"""Liest genau `count` ZEICHEN (nicht Bytes) UTF-8-kodiert vom Stream.
Gegenstueck zur Laengenzaehlung in encode_instruction(): ein
`readexactly(count)` haette bei Mehrbyte-Zeichen zu wenig Bytes gelesen
und den Parser mitten in einem Zeichen stehen lassen."""
decoder = codecs.getincrementaldecoder("utf-8")()
chars: list[str] = []
while len(chars) < count:
chunk = decoder.decode(await reader.readexactly(1))
if chunk:
chars.extend(chunk)
return "".join(chars)
async def read_instruction(reader: asyncio.StreamReader) -> list[str]:
elements: list[str] = []
while True:
length_bytes = await _read_until(reader, b".")
try:
length = int(length_bytes)
except ValueError as exc:
raise GuacamoleProtocolError(f"Ungueltige Laengenangabe: {length_bytes!r}") from exc
content = await _read_chars(reader, length)
elements.append(content)
sep = await reader.readexactly(1)
if sep == b";":
return elements
if sep != b",":
raise GuacamoleProtocolError(f"Unerwartetes Trennzeichen: {sep!r}")
def parse_instruction_text(text: str) -> list[str]: def parse_instruction_text(text: str) -> list[str]:
"""Parst genau EINE Instruktion aus einem bereits vollstaendig vorliegenden """Parst genau EINE Instruktion vom Anfang eines Strings (Kompatibilitaets-
String (z.B. eine einzelne WebSocket-Textnachricht vom Browser).""" Wrapper um parse_instructions_text() fuer Aufrufer, die garantiert nur
eine einzelne Instruktion erwarten)."""
return parse_instructions_text(text)[0]
def parse_instructions_text(text: str) -> list[list[str]]:
"""Parst ALLE Instruktionen aus einem bereits vollstaendig vorliegenden
String (z.B. eine einzelne WebSocket-Textnachricht vom Browser).
Befund D5 (Umsetzungsauftrag_Sonnet5.md Teil A.3): guacamole-common-js
kann mehrere Instruktionen in EINER WebSocket-Textnachricht buendeln
(z.B. beim Leeren einer Sendewarteschlange). Die vorherige Fassung
parste nur die ERSTE Instruktion, leitete aber die KOMPLETTE Nachricht
unveraendert weiter -- der Zwischenablage-Filter in _ws_to_guacd() prueft
daher nur instr[0]; eine 'clipboard'-Instruktion an zweiter oder
spaeterer Stelle in derselben Nachricht wurde ungefiltert durchgereicht
(Policy-Umgehung). Aufrufer muessen jede zurueckgegebene Instruktion
einzeln pruefen/filtern und die ueberlebenden Instruktionen mit
encode_instruction() neu zusammensetzen, statt den Originaltext erneut
zu versenden.
"""
instructions: list[list[str]] = []
elements: list[str] = [] elements: list[str] = []
i = 0 i = 0
n = len(text) n = len(text)
if n == 0:
raise GuacamoleProtocolError("Leere Instruktionsnachricht")
while i < n: while i < n:
try:
dot = text.index(".", i) dot = text.index(".", i)
except ValueError as exc:
raise GuacamoleProtocolError(f"Fehlender '.' in Laengenangabe ab Position {i} in {text!r}") from exc
try:
length = int(text[i:dot]) length = int(text[i:dot])
except ValueError as exc:
raise GuacamoleProtocolError(f"Ungueltige Laengenangabe in {text!r} an Position {i}") from exc
start = dot + 1 start = dot + 1
end = start + length end = start + length
if end > n:
raise GuacamoleProtocolError(f"Laengenangabe {length} reicht ueber das Nachrichtenende hinaus in {text!r}")
elements.append(text[start:end]) elements.append(text[start:end])
sep = text[end] if end < n else "" sep = text[end] if end < n else ""
i = end + 1 i = end + 1
if sep == ";": if sep == ";":
break instructions.append(elements)
if sep != ",": elements = []
elif sep != ",":
raise GuacamoleProtocolError(f"Unerwartetes Trennzeichen in {text!r} an Position {end}") raise GuacamoleProtocolError(f"Unerwartetes Trennzeichen in {text!r} an Position {end}")
return elements if elements:
# Nachricht endete ohne abschliessendes ';' -- unvollstaendige
# Instruktion, kein gueltiger Zustand fuer eine einzelne, komplett
# vorliegende WS-Textnachricht.
raise GuacamoleProtocolError(f"Unvollstaendige Instruktion (fehlendes ';') in {text!r}")
return instructions
class GuacdTunnel: class GuacdTunnel:
def __init__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, connection_id: str) -> None: def __init__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, connection_id: str = "") -> None:
self.reader = reader self.reader = reader
self.writer = writer self.writer = writer
# Wird von der Lese-Pumpe (app/rdp_proxy/ws_tunnel.py::_guacd_to_ws)
# beim Empfang der 'ready'-Instruktion nachtraeglich gesetzt -- siehe
# open_tunnel(), das NICHT mehr selbst auf 'ready' wartet (Befund A2).
self.connection_id = connection_id self.connection_id = connection_id
async def close(self) -> None: async def close(self) -> None:
@ -130,16 +323,59 @@ async def open_tunnel(
screen_width: int = 1024, screen_width: int = 1024,
screen_height: int = 768, screen_height: int = 768,
dpi: int = 96, dpi: int = 96,
connect_timeout: float | None = 10.0,
handshake_timeout: float | None = 10.0,
) -> GuacdTunnel: ) -> GuacdTunnel:
reader, writer = await asyncio.open_connection(guacd_host, guacd_port) """Baut den guacd-Tunnel auf und sendet 'select'/Groessen-/'connect'-
Instruktionen. Wartet bewusst NICHT auf 'ready' (Befund A2, Teil A.3):
guacd startet nach 'connect' im Hintergrund den eigentlichen RDP-/NLA-
Handshake zum Windows-Ziel; der kann je nach Ziel/Netz mehrere Sekunden
dauern. Wuerde hier synchron auf 'ready' gewartet, empfaengt der Browser
in dieser ganzen Zeit KEINE Daten -- guacamole-common-js bricht nach
receiveTimeout (Standard 15s, vendor/guacamole-common.js) mit
"Server timeout" ab, noch bevor der Windows-Anmeldevorgang ueberhaupt
abgeschlossen ist. Aufrufer starten die Lese-Pumpe direkt im Anschluss
an diese Funktion (siehe app/rdp_proxy/ws_tunnel.py::rdp_tunnel); 'ready'
(und ein etwaiges 'error') werden dort wie jede andere Instruktion
empfangen und behandelt (_guacd_to_ws) -- ab dem Senden von 'connect'
ist also durchgehend jemand am Lesen, unabhaengig davon, wie lange guacd
fuer den eigentlichen RDP-Handshake braucht.
guacd_host/guacd_port stecken bei jedem Fehler in der Exception-Nachricht
(Befund B.2): die fruehere Fassung liess einen nackten OSError
durchreichen, ohne wohin die Anwendung ueberhaupt verbinden wollte.
"""
try:
reader, writer = await asyncio.wait_for(
asyncio.open_connection(guacd_host, guacd_port, limit=GUACD_STREAM_LIMIT),
timeout=connect_timeout,
)
except (OSError, asyncio.TimeoutError) as exc:
raise GuacdUnreachableError(guacd_host, guacd_port, exc) from exc
try:
writer.write(encode_instruction("select", protocol).encode("utf-8")) writer.write(encode_instruction("select", protocol).encode("utf-8"))
await writer.drain() await writer.drain()
args_instr = await read_instruction(reader) try:
if args_instr[0] != "args": args_instr = await asyncio.wait_for(read_instruction(reader), timeout=handshake_timeout)
raise GuacamoleProtocolError(f"Erwartete 'args', erhalten: {args_instr[0]}") except asyncio.TimeoutError as exc:
arg_names = args_instr[1:] raise GuacdUnreachableError(guacd_host, guacd_port, exc) from exc
if not args_instr or args_instr[0] != "args":
raise GuacamoleProtocolError(
f"Erwartete 'args', erhalten: {args_instr[0] if args_instr else '(leer)'}"
)
# C1: Versions-Token erkennen und aus den echten Parameternamen
# herausloesen (siehe _VERSION_TOKEN_RE oben).
raw_arg_names = args_instr[1:]
version_token: str | None = None
if raw_arg_names and _VERSION_TOKEN_RE.fullmatch(raw_arg_names[0]):
version_token = raw_arg_names[0]
arg_names = raw_arg_names[1:]
else:
arg_names = raw_arg_names
handshake = ( handshake = (
encode_instruction("size", str(screen_width), str(screen_height), str(dpi)) encode_instruction("size", str(screen_width), str(screen_height), str(dpi))
@ -150,24 +386,39 @@ async def open_tunnel(
writer.write(handshake.encode("utf-8")) writer.write(handshake.encode("utf-8"))
await writer.drain() await writer.drain()
values = [params.get(name, "") for name in arg_names] # C3: width/height/dpi zusaetzlich als benannte connect-Parameter
# mitgeben, sofern guacd sie in seiner args-Liste anbietet -- FreeRDP
# nutzt sie fuer die initiale Aufloesungs-/DPI-Aushandlung mit dem
# Windows-Ziel, unabhaengig von der separaten 'size'-Instruktion oben.
effective_params = dict(params)
effective_params.setdefault("width", str(screen_width))
effective_params.setdefault("height", str(screen_height))
effective_params.setdefault("dpi", str(dpi))
values = [effective_params.get(name, "") for name in arg_names]
if version_token is not None:
# Der Client bestaetigt exakt das von guacd angebotene
# Versions-Token, statt (wie zuvor) implizit einen Leerstring zu
# senden und damit auf Protokoll 1.0.0 herabgestuft zu werden.
values = [version_token, *values]
writer.write(encode_instruction("connect", *values).encode("utf-8")) writer.write(encode_instruction("connect", *values).encode("utf-8"))
await writer.drain() await writer.drain()
except BaseException:
writer.close()
raise
ready_instr = await read_instruction(reader) return GuacdTunnel(reader, writer)
if ready_instr[0] != "ready":
raise GuacamoleProtocolError(f"Verbindungsaufbau fehlgeschlagen: {ready_instr}")
connection_id = ready_instr[1] if len(ready_instr) > 1 else ""
return GuacdTunnel(reader, writer, connection_id)
def build_rdp_params( def build_rdp_params(
host: dict, host: dict,
password: str, password: str,
*, *,
session_id: int,
username: str | None = None, username: str | None = None,
domain: str | None = None, domain: str | None = None,
client_name: str | None = None,
timezone: str | None = None,
) -> dict[str, str]: ) -> dict[str, str]:
"""Baut die Parameter-Map fuer die connect-Instruktion aus dem Host-Datensatz. """Baut die Parameter-Map fuer die connect-Instruktion aus dem Host-Datensatz.
@ -181,6 +432,16 @@ def build_rdp_params(
Benutzername gehoert zur Anmeldung, nicht zum Zielsystem. Fehlen sie, wird Benutzername gehoert zur Anmeldung, nicht zum Zielsystem. Fehlen sie, wird
auf hosts.rdp_username/rdp_domain zurueckgegriffen (Altbestand). auf hosts.rdp_username/rdp_domain zurueckgegriffen (Altbestand).
`client_name` (Befund C3) identifiziert die Sitzung auf dem Windows-Ziel
selbst (z.B. in Taskmanager/`qwinsta`) -- ohne ihn zeigt Windows nur einen
generischen FreeRDP-Bezeichner, was die Zuordnung "welche Sitzung gehoert
zu welchem Jumphost-Benutzer" beim Ziel-Admin erschwert.
`timezone` (Befund C3) wird, sofern vom Client uebermittelt (siehe
app/rdp_proxy/ws_tunnel.py, Query-Parameter), an FreeRDP durchgereicht --
ohne diesen Parameter uebernimmt Windows die Zeitzone des JUMPHOST-
Servers statt der des Benutzers.
WICHTIG: `host` muss aus app.ssh_proxy.proxy.load_host() stammen. Diese WICHTIG: `host` muss aus app.ssh_proxy.proxy.load_host() stammen. Diese
Funktion liest rdp_require_nla/clipboard_enabled/rdp_ignore_cert -- genau Funktion liest rdp_require_nla/clipboard_enabled/rdp_ignore_cert -- genau
diese Spalten hat load_host() frueher NICHT selektiert, wodurch hier still diese Spalten hat load_host() frueher NICHT selektiert, wodurch hier still
@ -200,7 +461,20 @@ def build_rdp_params(
# Benutzername/Domaene kommen seit Migration 0010 aus den Zugangsdaten # Benutzername/Domaene kommen seit Migration 0010 aus den Zugangsdaten
# (rdp_credentials). Der Wert am Host wird nur noch als Fallback fuer # (rdp_credentials). Der Wert am Host wird nur noch als Fallback fuer
# Datensaetze gelesen, die vor der Migration angelegt wurden. # Datensaetze gelesen, die vor der Migration angelegt wurden.
effective_username = (username or "").strip() or (host.get("rdp_username") or "").strip() #
# Befund C2: Migration 0012 hat fehlende Benutzernamen frueher mit dem
# Platzhaltertext '(kein Benutzername)' statt eines leeren Strings
# befuellt (inzwischen per Migration 0013 rueckwirkend korrigiert). Der
# Platzhalter wird hier zusaetzlich defensiv wie ein leerer String
# behandelt, falls er (z.B. durch manuelle Eingabe im Adminbereich)
# erneut auftaucht -- sonst ginge er unbemerkt als echter RDP-Anmelde-
# name an Windows und erzeugt dort ein irrefuehrendes "Anmeldung
# fehlgeschlagen" statt der eigentlichen Ursache.
_PLACEHOLDER_USERNAME = "(kein Benutzername)"
raw_username = (username or "").strip()
if raw_username == _PLACEHOLDER_USERNAME:
raw_username = ""
effective_username = raw_username or (host.get("rdp_username") or "").strip()
if not effective_username: if not effective_username:
raise GuacamoleProtocolError( raise GuacamoleProtocolError(
"Fuer diesen Host ist kein RDP-Benutzername hinterlegt. Der Benutzername " "Fuer diesen Host ist kein RDP-Benutzername hinterlegt. Der Benutzername "
@ -210,7 +484,7 @@ def build_rdp_params(
effective_domain = (domain or "").strip() or (host.get("rdp_domain") or "").strip() effective_domain = (domain or "").strip() or (host.get("rdp_domain") or "").strip()
clipboard_enabled = bool(host.get("clipboard_enabled")) clipboard_enabled = bool(host.get("clipboard_enabled"))
return { params = {
"hostname": host["address"], "hostname": host["address"],
"port": str(host["port"]), "port": str(host["port"]),
"username": effective_username, "username": effective_username,
@ -220,9 +494,13 @@ def build_rdp_params(
"ignore-cert": "true" if host.get("rdp_ignore_cert", True) else "false", "ignore-cert": "true" if host.get("rdp_ignore_cert", True) else "false",
"disable-audio": "true", "disable-audio": "true",
"enable-drive": "true" if host.get("file_transfer_enabled") else "false", "enable-drive": "true" if host.get("file_transfer_enabled") else "false",
"drive-path": f"/var/lib/jumphost/rdp-drives/{host['id']}", "drive-path": str(rdp_drive_path(host["id"], session_id)),
"create-drive-path": "true", "create-drive-path": "true",
"disable-copy": "false" if clipboard_enabled else "true", "disable-copy": "false" if clipboard_enabled else "true",
"disable-paste": "false" if clipboard_enabled else "true", "disable-paste": "false" if clipboard_enabled else "true",
"resize-method": "display-update", "resize-method": "display-update",
"client-name": (client_name or "jumphost").strip()[:64],
} }
if timezone:
params["timezone"] = timezone
return params

View File

@ -11,6 +11,8 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import re
import shutil
import uuid as uuid_mod import uuid as uuid_mod
from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect
@ -18,17 +20,22 @@ from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect
from app.auth.deps import get_current_user_ws from app.auth.deps import get_current_user_ws
from app.config import settings from app.config import settings
from app.db import get_db from app.db import get_db
from app.rbac import user_has_role_for_host from app.rbac import AmbiguousCredentialError, resolve_credential_for_user_on_host, user_has_role_for_host
from app.recordings.recorder import SessionRecorder from app.recordings.recorder import SessionRecorder
from app.security import active_sessions from app.security import active_sessions
from app.security.audit import write_audit_event from app.security.audit import write_audit_event
from app.security.crypto import decrypt_secret from app.security.crypto import decrypt_secret
from app.rdp_proxy.guacd_client import ( from app.rdp_proxy.guacd_client import (
GuacamoleProtocolError, GuacamoleProtocolError,
GuacdDisconnectedError,
GuacdStatusError,
GuacdUnreachableError,
build_rdp_params, build_rdp_params,
encode_instruction, encode_instruction,
guac_status_text,
open_tunnel, open_tunnel,
parse_instruction_text, parse_instructions_text,
rdp_drive_path,
read_instruction, read_instruction,
) )
from app.ssh_proxy.proxy import HostNotConfiguredError, load_host from app.ssh_proxy.proxy import HostNotConfiguredError, load_host
@ -48,6 +55,28 @@ INTERNAL_DATA_OPCODE = ""
# zustande und es erreichte kein einziger Frame die Anwendung. # zustande und es erreichte kein einziger Frame die Anwendung.
GUACAMOLE_SUBPROTOCOL = "guacamole" GUACAMOLE_SUBPROTOCOL = "guacamole"
# Grobe Plausibilitaetspruefung fuer eine vom Client mitgeschickte IANA-
# Zeitzone (z.B. "Europe/Vienna") -- kein Anspruch auf Vollstaendigkeit gegen
# die tz-Datenbank, nur ein Schutz gegen offensichtlich falsche Werte, bevor
# sie als RDP-connect-Parameter an guacd/FreeRDP weitergereicht werden.
_TIMEZONE_RE = re.compile(r"^[A-Za-z0-9_+\-]+(/[A-Za-z0-9_+\-]+)*$")
async def _cleanup_rdp_drive_path(host_id: int, session_id: int) -> None:
"""Entfernt das pro Sitzung umgeleitete RDP-Laufwerk beim Sitzungsende
(Befund E6, Umsetzungsauftrag Teil E) -- sonst sammeln sich pro Sitzung
Verzeichnisse unbegrenzt an. shutil.rmtree ist blockierendes Datei-I/O
(siehe E.0) und laeuft deshalb in einem Thread statt direkt im
Event-Loop."""
path = rdp_drive_path(host_id, session_id)
try:
await asyncio.to_thread(shutil.rmtree, path, True)
except OSError:
logger.warning(
"Konnte RDP-Laufwerksverzeichnis fuer Sitzung %s (Host %s) nicht entfernen: %s",
session_id, host_id, path, exc_info=True,
)
async def _accept(websocket: WebSocket) -> None: async def _accept(websocket: WebSocket) -> None:
"""Nimmt die Verbindung an und bestaetigt dabei das Subprotokoll, sofern """Nimmt die Verbindung an und bestaetigt dabei das Subprotokoll, sofern
@ -59,53 +88,126 @@ async def _accept(websocket: WebSocket) -> None:
await websocket.accept() await websocket.accept()
async def _reject(websocket: WebSocket, code: int, reason: str, *, accepted: bool) -> None: async def _send_error_instruction(websocket: WebSocket, message: str, status_code: int) -> None:
"""Sendet eine vollstaendige Guacamole-'error'-Instruktion ueber den noch
offenen Tunnel, BEVOR die Verbindung geschlossen wird (Befund B.2,
vormals ws_tunnel.py:296-308): die WebSocket-Close-Reason ist nach RFC
6455 auf 123 Byte begrenzt und war bisher der EINZIGE Uebertragungsweg
fuer den Fehlertext. Eine 'error'-Instruktion im normalen Tunnel-
Datenstrom hat kein derartiges Laengenlimit und wird von
guacamole-common-js (vendor/guacamole-common.js, Guacamole.Tunnel-
Instruktions-Handler) automatisch verarbeitet -- die Close-Reason bleibt
als Fallback fuer den Fall, dass dieser Sendeversuch selbst scheitert
(z.B. weil der Client bereits getrennt hat).
"""
try:
await websocket.send_text(encode_instruction("error", message, str(status_code)))
except Exception:
logger.debug("Konnte 'error'-Instruktion nicht mehr senden (Tunnel bereits zu)", exc_info=True)
async def _reject(
websocket: WebSocket,
code: int,
reason: str,
*,
accepted: bool,
correlation_id: str,
host_id: int | None = None,
username: str | None = None,
) -> None:
"""Beendet eine Sitzung vor ihrem eigentlichen Beginn -- mit Logeintrag """Beendet eine Sitzung vor ihrem eigentlichen Beginn -- mit Logeintrag
und mit einem fuer den Benutzer lesbaren Grund. und mit einem fuer den Benutzer lesbaren Grund.
Bisher endeten alle diese Pfade in einem nackten `websocket.close(code=...)` Bisher endeten alle diese Pfade in einem nackten `websocket.close(code=...)`
ohne jede Protokollierung. Im Verbindungslog war deshalb ueberhaupt nichts ohne jede Protokollierung. Im Verbindungslog war deshalb ueberhaupt nichts
zu sehen, wenn eine RDP-Sitzung an einer dieser Vorbedingungen scheiterte. zu sehen, wenn eine RDP-Sitzung an einer dieser Vorbedingungen scheiterte.
Befund B.2 ("gesamter RDP-Pfad"): zusaetzlich fehlten strukturierte
Felder (host_id, Benutzer) UND eine Korrelations-ID -- man sah zwar DASS
etwas abgelehnt wurde, aber die Ablehnung liess sich nicht mit einer
spaeteren Nutzerrueckfrage ("bei mir ging um 14:03 nichts") verknuepfen.
Der Grundtext wird als WebSocket-Close-Reason mitgegeben: Der Grundtext wird als WebSocket-Close-Reason UND als vollstaendige
guacamole-common-js reicht ihn (siehe close_tunnel()) als Guacamole-'error'-Instruktion mitgegeben (siehe _send_error_instruction);
Guacamole.Status.message an client.onerror weiter, wo static/js/rdp.js ihn letztere unterliegt nicht der 123-Byte-Grenze der Close-Reason.
direkt anzeigt. Voraussetzung dafuer ist ein zustande gekommener
Handshake -- vor `accept()` sieht der Browser nur einen HTTP-Fehler.
""" """
logger.warning("RDP-Verbindung abgelehnt (code=%s): %s", code, reason) logger.warning(
# Close-Reason ist auf 123 Byte begrenzt (RFC 6455). "RDP-Verbindung abgelehnt [corr=%s] (code=%s, host_id=%s, user=%s): %s",
reason_bytes = reason.encode("utf-8")[:123] correlation_id, code, host_id, username, reason,
)
full_reason = f"{reason} (Ref: {correlation_id})"
if not accepted: if not accepted:
await _accept(websocket) await _accept(websocket)
await _send_error_instruction(websocket, full_reason, code)
reason_bytes = full_reason.encode("utf-8")[:123]
await websocket.close(code=code, reason=reason_bytes.decode("utf-8", errors="ignore")) await websocket.close(code=code, reason=reason_bytes.decode("utf-8", errors="ignore"))
async def _guacd_to_ws(tunnel, websocket: WebSocket, recorder: SessionRecorder) -> None: async def _guacd_to_ws(tunnel, websocket: WebSocket, recorder: SessionRecorder) -> None:
"""Liest Instruktionen von guacd und reicht sie an den Browser weiter.
Befund A2: laeuft ab direkt nach open_tunnel() (die NICHT mehr selbst auf
'ready' wartet), somit ist ab dem Senden von 'connect' durchgehend jemand
am Lesen -- unabhaengig davon, wie lange guacd fuer den eigentlichen
RDP-/NLA-Handshake braucht.
'ready' wird hier (statt in open_tunnel()) erkannt, um die connection_id
festzuhalten, und dann wie jede andere Instruktion normal weitergereicht
-- guacamole-common-js erwartet 'ready' ohnehin als regulaeren Teil des
Instruktionsstroms. Eine 'error'-Instruktion (Befund B.2/C1) wird NICHT
weitergereicht, sondern als GuacdStatusError geworfen: der aufrufende
Code in rdp_tunnel() bildet daraus die deutsche Klartextmeldung fuer Log
und Browser (guac_status_text/_send_error_instruction), statt dass der
Browser den rohen Guacamole-Statuscode selbst interpretieren muss.
"""
while True: while True:
instr = await read_instruction(tunnel.reader) instr = await read_instruction(tunnel.reader)
if instr and instr[0] == "ready":
tunnel.connection_id = instr[1] if len(instr) > 1 else ""
elif instr and instr[0] == "error":
message = instr[1] if len(instr) > 1 else ""
try:
code = int(instr[2]) if len(instr) > 2 else 512
except ValueError:
code = 512
raise GuacdStatusError(code, message)
text = encode_instruction(*instr) text = encode_instruction(*instr)
recorder.record("output", text) recorder.record("output", text)
await websocket.send_text(text) await websocket.send_text(text)
async def _ws_to_guacd(tunnel, websocket: WebSocket, recorder: SessionRecorder, *, clipboard_enabled: bool) -> None: async def _ws_to_guacd(tunnel, websocket: WebSocket, recorder: SessionRecorder, *, clipboard_enabled: bool, correlation_id: str) -> None:
while True: while True:
message = await websocket.receive_text() message = await websocket.receive_text()
try: try:
instr = parse_instruction_text(message) instructions = parse_instructions_text(message)
except (GuacamoleProtocolError, ValueError, IndexError): except GuacamoleProtocolError:
continue # ungueltige Clientnachricht ignorieren statt die Verbindung zu killen # Ungueltige Clientnachricht ignorieren statt die Verbindung zu
# killen -- aber (Befund B.2, ws_tunnel.py:96-98) nicht mehr
# lautlos: ein gekuerzter Nachrichtenausschnitt landet als DEBUG
# im Log, damit ein wiederkehrendes Muster (z.B. ein kaputter
# Client) ueberhaupt auffallen kann.
logger.debug(
"[corr=%s] Ungueltige WS-Nachricht ignoriert: %r",
correlation_id, message[:200],
)
continue
# Befund D5: eine einzelne WebSocket-Textnachricht kann MEHRERE
# Guacamole-Instruktionen buendeln. Jede wird einzeln gegen den
# Zwischenablage-Filter geprueft (statt nur instr[0] der Nachricht)
# und nur die ueberlebenden Instruktionen werden neu kodiert
# weitergereicht -- eine gefilterte 'clipboard'-Instruktion darf sich
# nicht mehr an zweiter/spaeterer Stelle in derselben Nachricht
# vorbeischmuggeln koennen.
forward: list[str] = []
for instr in instructions:
if instr and instr[0] == INTERNAL_DATA_OPCODE: if instr and instr[0] == INTERNAL_DATA_OPCODE:
# Tunnelinterne Instruktion von guacamole-common-js (leerer # Tunnelinterne Instruktion von guacamole-common-js (leerer
# Opcode), z.B. "0.,4.ping,13.<timestamp>;". Diese gehoert dem # Opcode), z.B. "0.,4.ping,13.<timestamp>;". Gehoert dem
# Tunnel, NICHT guacd -- bisher wurde sie unbesehen an guacd # Tunnel, NICHT guacd. Ein ping wird gespiegelt: der Browser
# weitergereicht, das damit nichts anfangen kann. Ein ping wird # bricht den Tunnel nach tunnel.receiveTimeout (Standard 15s)
# gespiegelt: der Browser bricht den Tunnel nach # ohne empfangene Daten mit "Server timeout" ab.
# tunnel.receiveTimeout (Standard 15 s) ohne empfangene Daten mit
# "Server timeout" ab, eine stille Sitzung liefe also in einen
# Fehler.
if len(instr) > 1 and instr[1] == "ping": if len(instr) > 1 and instr[1] == "ping":
await websocket.send_text(encode_instruction(*instr)) await websocket.send_text(encode_instruction(*instr))
continue continue
@ -113,8 +215,14 @@ async def _ws_to_guacd(tunnel, websocket: WebSocket, recorder: SessionRecorder,
if not clipboard_enabled and instr and instr[0] == "clipboard": if not clipboard_enabled and instr and instr[0] == "clipboard":
continue # Defense-in-Depth: Clipboard serverseitig blocken continue # Defense-in-Depth: Clipboard serverseitig blocken
recorder.record("input", message) forward.append(encode_instruction(*instr))
tunnel.writer.write(message.encode("utf-8"))
if not forward:
continue
combined = "".join(forward)
recorder.record("input", combined)
tunnel.writer.write(combined.encode("utf-8"))
await tunnel.writer.drain() await tunnel.writer.drain()
@ -125,14 +233,28 @@ async def rdp_tunnel(
width: int = Query(default=1280, ge=320, le=7680), width: int = Query(default=1280, ge=320, le=7680),
height: int = Query(default=800, ge=240, le=4320), height: int = Query(default=800, ge=240, le=4320),
dpi: int = Query(default=96, ge=48, le=384), dpi: int = Query(default=96, ge=48, le=384),
timezone: str | None = Query(default=None, max_length=64),
): ):
# Befund B.2 ("gesamter RDP-Pfad"): Korrelations-ID VOR jeder moeglichen
# Ablehnung erzeugen (nicht erst nach dem RBAC-Check) -- damit hat
# WIRKLICH JEDE Logzeile zu dieser Verbindung, inklusive der fruehesten
# Ablehnungen, ein gemeinsames Merkmal. Wird nach erfolgreichem accept()
# zusaetzlich als tunnel_uuid an den Browser gesendet (siehe unten) --
# dieselbe ID dient also sowohl der Server-Log-Korrelation als auch der
# Client-seitigen Tunnel-Identifikation.
correlation_id = str(uuid_mod.uuid4())
user = await get_current_user_ws(websocket) user = await get_current_user_ws(websocket)
if user is None: if user is None:
# Einziger Pfad, der bewusst OHNE vorheriges accept() schliesst: eine # Einziger Pfad, der bewusst OHNE vorheriges accept() schliesst: eine
# nicht authentifizierte Verbindung soll gar nicht erst zustande # nicht authentifizierte Verbindung soll gar nicht erst zustande
# kommen. Alle folgenden Ablehnungen laufen ueber _reject(), damit der # kommen (vor accept() sieht der Browser ohnehin nur einen
# Benutzer im Browser den tatsaechlichen Grund zu sehen bekommt. # HTTP-Fehler, eine Guacamole-'error'-Instruktion kann also nicht
logger.warning("RDP-Verbindung abgelehnt: keine gueltige Sitzung (host_id=%s)", host_id) # ankommen). Alle folgenden Ablehnungen laufen ueber _reject().
logger.warning(
"RDP-Verbindung abgelehnt [corr=%s]: keine gueltige Sitzung (host_id=%s)",
correlation_id, host_id,
)
await websocket.close(code=4401) await websocket.close(code=4401)
return return
@ -145,7 +267,30 @@ async def rdp_tunnel(
await _reject( await _reject(
websocket, 4403, websocket, 4403,
f"Keine Berechtigung 'rdp_connect' fuer Host {host_id}", f"Keine Berechtigung 'rdp_connect' fuer Host {host_id}",
accepted=False, accepted=False, correlation_id=correlation_id, host_id=host_id, username=user.username,
)
return
# E.4 (Umsetzungsauftrag Teil E): Obergrenzen je Benutzer und global,
# VOR dem DB-INSERT und jedem Ressourcenverbrauch geprueft -- vorher gab
# es keine der beiden Grenzen, mit der Mehrsitzungs-Seitenleiste (Teil F)
# wird es zur Normalitaet, dass ein Benutzer mehrere Sitzungen haelt.
# Klartext-Ablehnung statt eines wortlosen Verbindungsendes (Teil B gilt
# auch hier).
if not user.is_admin and active_sessions.count_for_user(user.id) >= settings.max_sessions_per_user:
await _reject(
websocket, 4429,
f"Sie haben bereits {settings.max_sessions_per_user} Sitzungen offen "
"(Obergrenze je Benutzer erreicht).",
accepted=False, correlation_id=correlation_id, host_id=host_id, username=user.username,
)
return
if active_sessions.count_total() >= settings.max_sessions_global:
await _reject(
websocket, 4429,
"Der Server hat die maximale Anzahl gleichzeitiger Sitzungen erreicht, "
"bitte spaeter erneut versuchen.",
accepted=False, correlation_id=correlation_id, host_id=host_id, username=user.username,
) )
return return
@ -156,21 +301,27 @@ async def rdp_tunnel(
# Empfang der ersten Instruktion auf OPEN und startet damit seine # Empfang der ersten Instruktion auf OPEN und startet damit seine
# Timeoutueberwachung neu -- ohne diesen Frame blieb der Client bis zum # Timeoutueberwachung neu -- ohne diesen Frame blieb der Client bis zum
# ersten Bild von guacd in "Warte auf Server ..." haengen und lief bei # ersten Bild von guacd in "Warte auf Server ..." haengen und lief bei
# einem langsamen RDP-Handshake in den 15-Sekunden-Timeout. # einem langsamen RDP-Handshake in den 15-Sekunden-Timeout. Dieselbe ID
tunnel_uuid = str(uuid_mod.uuid4()) # wie correlation_id (siehe oben) -- damit ist ein vom Benutzer im UI
# angezeigter Fehler direkt mit den Server-Logzeilen dieser Verbindung
# verknuepfbar.
tunnel_uuid = correlation_id
await websocket.send_text(encode_instruction(INTERNAL_DATA_OPCODE, tunnel_uuid)) await websocket.send_text(encode_instruction(INTERNAL_DATA_OPCODE, tunnel_uuid))
try: try:
host = await load_host(conn, host_id) host = await load_host(conn, host_id)
except HostNotConfiguredError as exc: except HostNotConfiguredError as exc:
await _reject(websocket, 4404, str(exc), accepted=True) await _reject(
websocket, 4404, str(exc), accepted=True,
correlation_id=correlation_id, host_id=host_id, username=user.username,
)
return return
if host["protocol"] != "rdp": if host["protocol"] != "rdp":
await _reject( await _reject(
websocket, 4400, websocket, 4400,
f"Host {host['hostname']} ist kein RDP-Ziel (protocol={host['protocol']})", f"Host {host['hostname']} ist kein RDP-Ziel (protocol={host['protocol']})",
accepted=True, accepted=True, correlation_id=correlation_id, host_id=host_id, username=user.username,
) )
return return
@ -179,11 +330,40 @@ async def rdp_tunnel(
# Beschreibung des Zielsystems. Seit Migration 0012 sind RDP-Zugangsdaten # Beschreibung des Zielsystems. Seit Migration 0012 sind RDP-Zugangsdaten
# zudem ein eigenstaendiges, wiederverwendbares Objekt (analog SSH-Keys), # zudem ein eigenstaendiges, wiederverwendbares Objekt (analog SSH-Keys),
# das einem Host ueber host_rdp_credential_map zugewiesen wird, statt # das einem Host ueber host_rdp_credential_map zugewiesen wird, statt
# 1:1 am Host zu haengen. # 1:1 am Host zu haengen. Seit Teil D Schritt 4 (Achse B) laeuft die
# Auswahl NICHT mehr blind ueber den Host allein:
# app.rbac.resolve_credential_for_user_on_host() beruecksichtigt
# zusaetzlich, ob DIESER Benutzer ueber eine seiner Gruppen
# (group_rdp_credential_grants) Zugriff auf den zugeordneten
# Zugangsdatensatz hat. AmbiguousCredentialError kann hier strukturell
# nicht auftreten (host_rdp_credential_map hat PK auf host_id, also
# hoechstens ein Treffer) -- trotzdem defensiv abgefangen, statt
# unbehandelt durchzureichen.
try:
rdp_credential_id = await resolve_credential_for_user_on_host(
conn, user_id=user.id, host_id=host_id, kind="rdp_credential"
)
except AmbiguousCredentialError as exc:
logger.error("Unerwartete Mehrdeutigkeit bei RDP-Zugangsdaten: %s", exc)
await _reject(
websocket, 4404,
f"Fuer Host {host['hostname']} sind mehrdeutige RDP-Zugangsdaten hinterlegt "
"(Adminbereich -> Zugangsdaten).",
accepted=True, correlation_id=correlation_id, host_id=host_id, username=user.username,
)
return
if rdp_credential_id is None:
await _reject(
websocket, 4404,
f"Fuer Host {host['hostname']} ist kein RDP-Passwort hinterlegt oder keines Ihrer "
"Benutzergruppen freigegeben (Adminbereich -> Zugangsdaten bzw. "
"Benutzergruppen -> Zugangsdaten).",
accepted=True, correlation_id=correlation_id, host_id=host_id, username=user.username,
)
return
cred_cursor = await conn.execute( cred_cursor = await conn.execute(
"SELECT rc.password_enc, rc.username, rc.domain FROM host_rdp_credential_map m " "SELECT password_enc, username, domain FROM rdp_credentials WHERE id = ?",
"JOIN rdp_credentials rc ON rc.id = m.rdp_credential_id WHERE m.host_id = ?", (rdp_credential_id,),
(host_id,),
) )
cred_row = await cred_cursor.fetchone() cred_row = await cred_cursor.fetchone()
if cred_row is None: if cred_row is None:
@ -191,7 +371,7 @@ async def rdp_tunnel(
websocket, 4404, websocket, 4404,
f"Fuer Host {host['hostname']} ist kein RDP-Passwort hinterlegt " f"Fuer Host {host['hostname']} ist kein RDP-Passwort hinterlegt "
"(Adminbereich -> Zugangsdaten).", "(Adminbereich -> Zugangsdaten).",
accepted=True, accepted=True, correlation_id=correlation_id, host_id=host_id, username=user.username,
) )
return return
@ -206,17 +386,22 @@ async def rdp_tunnel(
) )
await write_audit_event( await write_audit_event(
conn, event_type="rdp_session_start", user_id=user.id, client_ip=client_ip, conn, event_type="rdp_session_start", user_id=user.id, client_ip=client_ip,
details={"host_id": host_id, "hostname": host["hostname"], "session_id": session_id}, details={"host_id": host_id, "hostname": host["hostname"], "session_id": session_id, "corr": correlation_id},
) )
await conn.commit() await conn.commit()
logger.debug( # Befund B.2 (ws_tunnel.py:212-215): Sitzungsstart/-ende gehoeren auf
"RDP-Sitzung %s gestartet: user=%s host=%s (%s:%s) client_ip=%s", # INFO, nicht DEBUG -- im Produktivbetrieb (basicConfig(level=INFO),
session_id, user.username, host["hostname"], host["address"], host["port"], client_ip, # app/main.py) waren sie damit ohne den globalen DEBUG-Override in
# app/security/log_stream.py schlicht NICHT sichtbar.
logger.info(
"RDP-Sitzung %s gestartet [corr=%s]: user=%s host=%s (%s:%s) client_ip=%s",
session_id, correlation_id, user.username, host["hostname"], host["address"], host["port"], client_ip,
) )
active_sessions.register(session_id, asyncio.current_task()) active_sessions.register(session_id, asyncio.current_task(), user.id)
end_reason = "logout" end_reason = "logout"
error_text: str | None = None error_text: str | None = None
error_code = 512
tunnel = None tunnel = None
tasks: list[asyncio.Task] = [] tasks: list[asyncio.Task] = []
password = None password = None
@ -229,19 +414,26 @@ async def rdp_tunnel(
# riss ein Fehler hier VOR jeglicher Protokollierung durch und die # riss ein Fehler hier VOR jeglicher Protokollierung durch und die
# Sitzung verschwand spurlos (schwarzer Bildschirm, kein Log-Eintrag). # Sitzung verschwand spurlos (schwarzer Bildschirm, kein Log-Eintrag).
password = decrypt_secret(cred_row[0], associated_data=b"rdp_password") password = decrypt_secret(cred_row[0], associated_data=b"rdp_password")
safe_timezone = timezone if (timezone and _TIMEZONE_RE.fullmatch(timezone)) else None
params = build_rdp_params( params = build_rdp_params(
host, password.decode(), username=cred_row[1], domain=cred_row[2] host, password.decode(), session_id=session_id, username=cred_row[1], domain=cred_row[2],
client_name=f"jumphost-{user.username}", timezone=safe_timezone,
) )
tunnel = await open_tunnel( tunnel = await open_tunnel(
guacd_host=settings.guacd_host, guacd_port=settings.guacd_port, guacd_host=settings.guacd_host, guacd_port=settings.guacd_port,
protocol="rdp", params=params, screen_width=width, screen_height=height, dpi=dpi, protocol="rdp", params=params, screen_width=width, screen_height=height, dpi=dpi,
connect_timeout=settings.guacd_connect_timeout_s,
handshake_timeout=settings.guacd_handshake_timeout_s,
)
logger.info(
"RDP-Sitzung %s [corr=%s]: guacd-Tunnel zu %s:%s aufgebaut, 'connect' fuer %s gesendet",
session_id, correlation_id, settings.guacd_host, settings.guacd_port, host["hostname"],
) )
logger.debug("RDP-Sitzung %s: guacd-Tunnel zu %s aufgebaut", session_id, host["hostname"])
clipboard_enabled = bool(host.get("clipboard_enabled", True)) clipboard_enabled = bool(host.get("clipboard_enabled", True))
tasks = [ tasks = [
asyncio.create_task(_guacd_to_ws(tunnel, websocket, recorder)), asyncio.create_task(_guacd_to_ws(tunnel, websocket, recorder)),
asyncio.create_task( asyncio.create_task(
_ws_to_guacd(tunnel, websocket, recorder, clipboard_enabled=clipboard_enabled) _ws_to_guacd(tunnel, websocket, recorder, clipboard_enabled=clipboard_enabled, correlation_id=correlation_id)
), ),
] ]
done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_EXCEPTION) done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_EXCEPTION)
@ -253,11 +445,37 @@ async def rdp_tunnel(
raise exc raise exc
except WebSocketDisconnect: except WebSocketDisconnect:
end_reason = "logout" end_reason = "logout"
except GuacdUnreachableError as exc:
# Befund B.2: eigene Fehlerklasse statt eines rohen OSError --
# unterscheidbar von "guacd erreichbar, aber Ziel/Anmeldung fehlerhaft".
logger.warning("RDP-Sessionfehler (session_id=%s) [corr=%s]: %s", session_id, correlation_id, exc)
end_reason = "error"
error_text = str(exc)
error_code = 514 # UPSTREAM_TIMEOUT als naechstliegender Guacamole-Code fuer "guacd nicht erreichbar"
except GuacdStatusError as exc:
# guacd hat eine 'error'-Instruktion gesendet (haeufigster Fall:
# falsches Passwort/Zielsystem nicht erreichbar/Zertifikatsproblem).
logger.warning(
"RDP-Sessionfehler (session_id=%s) [corr=%s]: guacd meldet Status %s: %s",
session_id, correlation_id, exc.status_code, exc.guac_message,
)
end_reason = "error"
error_text = f"{guac_status_text(exc.status_code)}: {exc.guac_message}" if exc.guac_message else guac_status_text(exc.status_code)
error_code = exc.status_code
except GuacdDisconnectedError as exc:
# EOF mitten im Protokoll -- typischerweise ein Absturz von guacd/
# FreeRDP. Vorher landete das unbehandelt im generischen
# (GuacamoleProtocolError, ConnectionError, OSError)-Zweig als
# IncompleteReadError mit rohem Python-Klassennamen im Browser.
logger.warning("RDP-Sessionfehler (session_id=%s) [corr=%s]: %s", session_id, correlation_id, exc)
end_reason = "error"
error_text = "guacd hat die Verbindung unerwartet beendet"
error_code = 515
except (GuacamoleProtocolError, ConnectionError, OSError) as exc: except (GuacamoleProtocolError, ConnectionError, OSError) as exc:
logger.warning("RDP-Sessionfehler (session_id=%s): %s", session_id, exc) logger.warning("RDP-Sessionfehler (session_id=%s) [corr=%s]: %s", session_id, correlation_id, exc)
end_reason = "error" end_reason = "error"
# Grund an den Browser durchreichen (guacamole-common-js zeigt die # Grund an den Browser durchreichen (guacamole-common-js zeigt die
# Close-Reason als Guacamole.Status.message an, siehe _reject). # 'error'-Instruktion/Close-Reason an, siehe _send_error_instruction).
# Betrifft u.a. den haeufigsten Konfigurationsfehler: kein # Betrifft u.a. den haeufigsten Konfigurationsfehler: kein
# RDP-Benutzername am Host, siehe build_rdp_params(). # RDP-Benutzername am Host, siehe build_rdp_params().
error_text = str(exc) or exc.__class__.__name__ error_text = str(exc) or exc.__class__.__name__
@ -272,9 +490,10 @@ async def rdp_tunnel(
# verschluesselten RDP-Passwort) ab, die vorher unbehandelt bis vor # verschluesselten RDP-Passwort) ab, die vorher unbehandelt bis vor
# das erste await in dieser Funktion durchriss und die Sitzung ohne # das erste await in dieser Funktion durchriss und die Sitzung ohne
# jede Fehlermeldung/Protokollierung sofort beendete. # jede Fehlermeldung/Protokollierung sofort beendete.
logger.exception("Unerwarteter Fehler in RDP-Sitzung %s: %s", session_id, exc) logger.exception("Unerwarteter Fehler in RDP-Sitzung %s [corr=%s]: %s", session_id, correlation_id, exc)
end_reason = "error" end_reason = "error"
error_text = f"Interner Fehler: {exc.__class__.__name__}" error_text = f"Interner Fehler: {exc.__class__.__name__}"
error_code = 512
finally: finally:
active_sessions.unregister(session_id) active_sessions.unregister(session_id)
del password # Klartext-Passwort so schnell wie moeglich freigeben del password # Klartext-Passwort so schnell wie moeglich freigeben
@ -282,8 +501,13 @@ async def rdp_tunnel(
task.cancel() task.cancel()
if tunnel: if tunnel:
await tunnel.close() await tunnel.close()
recorder.close() await recorder.aclose()
logger.debug("RDP-Sitzung %s beendet: reason=%s", session_id, end_reason) # E6 (Umsetzungsauftrag Teil E): das je Sitzung umgeleitete
# RDP-Laufwerk (siehe build_rdp_params()/rdp_drive_path()) wird beim
# Sitzungsende entfernt -- sonst sammeln sich pro Sitzung
# Verzeichnisse unbegrenzt auf der Platte an.
await _cleanup_rdp_drive_path(host_id, session_id)
logger.info("RDP-Sitzung %s beendet [corr=%s]: reason=%s", session_id, correlation_id, end_reason)
await conn.execute( await conn.execute(
"UPDATE sessions SET ended_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), end_reason = ? " "UPDATE sessions SET ended_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), end_reason = ? "
"WHERE id = ?", "WHERE id = ?",
@ -291,13 +515,15 @@ async def rdp_tunnel(
) )
await write_audit_event( await write_audit_event(
conn, event_type="rdp_session_end", user_id=user.id, client_ip=client_ip, conn, event_type="rdp_session_end", user_id=user.id, client_ip=client_ip,
details={"host_id": host_id, "session_id": session_id, "reason": end_reason}, details={"host_id": host_id, "session_id": session_id, "reason": end_reason, "corr": correlation_id},
) )
await conn.commit() await conn.commit()
try: try:
if error_text: if error_text:
full_reason = f"{error_text} (Ref: {correlation_id})"
await _send_error_instruction(websocket, full_reason, error_code)
await websocket.close( await websocket.close(
code=4500, reason=error_text.encode("utf-8")[:123].decode("utf-8", errors="ignore") code=4500, reason=full_reason.encode("utf-8")[:123].decode("utf-8", errors="ignore")
) )
else: else:
await websocket.close() await websocket.close()

View File

@ -1,52 +1,278 @@
""" """
Session-Aufzeichnung mit Hash-Verkettung (siehe Konzept 6.5). Session-Aufzeichnung mit Hash-Verkettung (siehe Konzept 6.5).
Jede Session schreibt eine eigene JSONL-Datei unter settings.recordings_dir. Jede Session schreibt eine oder mehrere JSONL-Dateien unter
Jede Zeile verkettet sich mit der vorherigen (gleiches Prinzip wie das settings.recordings_dir. Jede Zeile verkettet sich mit der vorherigen
Audit-Log, app/security/audit.py), damit nachtraegliche Manipulation der (gleiches Prinzip wie das Audit-Log, app/security/audit.py), damit
Aufzeichnung erkennbar ist. nachtraegliche Manipulation der Aufzeichnung erkennbar ist.
Umsetzungsauftrag Teil A (D2) / Teil E (E2): `record()` wurde vorher
synchron im Event-Loop aufgerufen und hat bei JEDER Instruktion
`write()` + `flush()` gemacht -- beim RDP-Bildstrom also praktisch bei
jedem Frame. Das haelt den einzigen Event-Loop der Anwendung fuer ALLE
Benutzer an (siehe E.0) und kann bei laufenden Sitzungen unbegrenzt
Plattenplatz verbrauchen.
Design der Behebung -- zwei klar getrennte Zustaendigkeiten:
* Event-Loop-Thread (synchron, `record()`): berechnet die Hash-Kette
UND entscheidet ueber Rotation/Groessenbegrenzung -- beides reine
CPU-/Zaehlerarbeit ohne I/O, daher unbedenklich synchron. Jede
fertige Zeile landet mit ihrem Ziel-Teildateiindex in einem
In-Memory-Puffer.
* Executor-Thread (`asyncio.to_thread`, `_flush_loop`): fuehrt NUR
noch das eigentliche blockierende Datei-I/O aus (open/write/flush/
close) -- trifft keine Entscheidungen und teilt sich daher keinen
veraenderlichen Zustand mit dem Event-Loop-Thread ausser den fertig
vorbereiteten (Teilindex, Zeile)-Tupeln.
Damit bleibt die Hash-Kette deterministisch in Aufrufreihenfolge
korrekt, unabhaengig davon, wann tatsaechlich geschrieben wird. Jede
Teildatei (Rotation) hat ihre eigene, in sich geschlossene Kette ab
GENESIS_HASH -- das genuegt fuer Manipulationserkennung je Datei und
vermeidet krossen Zustand zwischen den Threads.
Bekannte, bewusst akzeptierte Einschraenkung: `close()`/`aclose()`
versucht einen Abschluss-Flush, aber bei hartem Prozessabsturz (kein
regulaeres Sitzungsende) koennen die letzten <= FLUSH_INTERVAL_S
Sekunden bzw. <= MAX_BUFFERED_ENTRIES gepufferte Eintraege verloren
gehen. Die Kette selbst bleibt in jedem Fall bis zum letzten
geschriebenen Eintrag gueltig (kein "gebrochener" Zustand wie beim
alten Audit-Log-Bug E1) -- es fehlt hoechstens ein Rest am Ende.
""" """
from __future__ import annotations from __future__ import annotations
import asyncio
import hashlib import hashlib
import json import json
import logging
import time import time
from pathlib import Path from pathlib import Path
from app.config import settings from app.config import settings
logger = logging.getLogger("jumphost.recordings")
GENESIS_HASH = "0" * 64 GENESIS_HASH = "0" * 64
# Grobe Rahmenkosten (JSON-Huelle {"entry":...,"prev_hash":...,"hash":...}
# plus Zeilenumbruch) fuer die Vorab-Groessenschaetzung in record() --
# muss nicht exakt sein, nur konservativ genug, um Rotation/Truncation
# rechtzeitig auszuloesen, bevor eine Teildatei stark ueberschritten wird.
_FRAME_OVERHEAD_BYTES = 96
class SessionRecorder: class SessionRecorder:
#: Wie oft der Hintergrund-Flush hoechstens schlaeft, bevor er den
#: Puffer erneut prueft (er wacht frueher auf, wenn der Puffer voll wird).
FLUSH_INTERVAL_S: float = settings.recording_flush_interval_s
#: Ab wie vielen gepufferten Eintraegen sofort (statt erst nach
#: FLUSH_INTERVAL_S) geflusht wird -- begrenzt den Speicherbedarf des
#: Puffers bei sehr schnellen Sitzungen (RDP-Bildstrom).
MAX_BUFFERED_ENTRIES: int = 200
def __init__(self, session_id: int) -> None: def __init__(self, session_id: int) -> None:
self.session_id = session_id self.session_id = session_id
self.path = settings.recordings_dir / f"session_{session_id}.jsonl" self._base_path = settings.recordings_dir / f"session_{session_id}.jsonl"
self._prev_hash = GENESIS_HASH # Nach aussen (DB recording_path, Admin-UI) bleibt dies der stabile
# "Ankerpfad" der Sitzung -- Teil 1 der ggf. rotierten Sequenz.
self.path = self._base_path
self._max_part_bytes = settings.recording_max_part_bytes
self._max_total_bytes = settings.recording_max_total_bytes
self._start_ts = time.time() self._start_ts = time.time()
self._fh = open(self.path, "a", encoding="utf-8") self._prev_hash = GENESIS_HASH
self._part_index = 1
self._part_queued_bytes = 0
self._total_queued_bytes = 0
self._truncated = False
self._closed = False
# Nur vom Event-Loop-Thread beruehrt (record()/_enqueue*).
self._buffer: list[tuple[int, str]] = []
# Nur vom Executor-Thread beruehrt (_write_batch_sync und Freunde).
self._writer_fh = self._open_part(self._part_index)
self._writer_part_index = self._part_index
self._flush_event = asyncio.Event()
self._flush_task: asyncio.Task | None = asyncio.get_event_loop().create_task(
self._flush_loop(), name=f"recorder-flush-{session_id}"
)
# -- Pfade -----------------------------------------------------------
def _part_path(self, part_index: int) -> Path:
if part_index == 1:
return self._base_path
return self._base_path.with_suffix(self._base_path.suffix + f".{part_index}")
def _open_part(self, part_index: int):
path = self._part_path(part_index)
fh = open(path, "a", encoding="utf-8")
try: try:
self.path.chmod(0o600) path.chmod(0o600)
except OSError: except OSError:
pass pass
return fh
# -- Event-Loop-Seite (synchron, kein I/O) ----------------------------
def record(self, direction: str, data: str) -> None: def record(self, direction: str, data: str) -> None:
"""direction: 'input' (Tastatureingabe) oder 'output' (Terminal-/RDP-Ausgabe).""" """direction: 'input' (Tastatureingabe) oder 'output' (Terminal-/RDP-Ausgabe).
Bleibt bewusst eine SYNCHRONE Methode (kein `async def`/`await`):
alle bestehenden Aufrufstellen (app/rdp_proxy/ws_tunnel.py,
app/ssh_proxy/terminal_ws.py) rufen sie ohne `await` aus
Hot-Path-Code auf. Sie darf daher niemals blockierendes I/O
ausfuehren -- das eigentliche Schreiben passiert ausschliesslich
im Hintergrund-Task (_flush_loop) im Executor.
"""
if self._closed or self._truncated:
return
offset = round(time.time() - self._start_ts, 4) offset = round(time.time() - self._start_ts, 4)
entry = {"t": offset, "dir": direction, "data": data} entry = {"t": offset, "dir": direction, "data": data}
entry_json = json.dumps(entry, ensure_ascii=False, sort_keys=True) entry_json = json.dumps(entry, ensure_ascii=False, sort_keys=True)
estimated_bytes = len(entry_json.encode("utf-8")) + _FRAME_OVERHEAD_BYTES
if self._total_queued_bytes + estimated_bytes > self._max_total_bytes:
self._enqueue_truncation_marker()
return
if self._part_queued_bytes > 0 and self._part_queued_bytes + estimated_bytes > self._max_part_bytes:
# Rotation: naechste Teildatei beginnt mit einer eigenen,
# frischen Genesis-Hash-Kette -- unabhaengig von der vorherigen.
self._part_index += 1
self._part_queued_bytes = 0
self._prev_hash = GENESIS_HASH
logger.info(
"Sitzungsaufzeichnung %s: rotiere auf Teil %d (Groessenbegrenzung %d Bytes erreicht)",
self.session_id, self._part_index, self._max_part_bytes,
)
self._append_line(entry, entry_json)
def _append_line(self, entry: dict, entry_json: str) -> None:
entry_hash = hashlib.sha256((self._prev_hash + "|" + entry_json).encode()).hexdigest() entry_hash = hashlib.sha256((self._prev_hash + "|" + entry_json).encode()).hexdigest()
line = json.dumps({"entry": entry, "prev_hash": self._prev_hash, "hash": entry_hash}) line = json.dumps({"entry": entry, "prev_hash": self._prev_hash, "hash": entry_hash})
self._fh.write(line + "\n") line_bytes = len(line.encode("utf-8")) + 1
self._fh.flush()
self._prev_hash = entry_hash self._prev_hash = entry_hash
self._part_queued_bytes += line_bytes
self._total_queued_bytes += line_bytes
self._buffer.append((self._part_index, line))
if len(self._buffer) >= self.MAX_BUFFERED_ENTRIES:
self._flush_event.set()
def close(self) -> None: def _enqueue_truncation_marker(self) -> None:
if not self._fh.closed: if self._truncated:
self._fh.close() return
self._truncated = True
logger.warning(
"Sitzungsaufzeichnung %s: Gesamtgroessenbegrenzung (%d Bytes) erreicht, "
"Aufzeichnung wird ab hier abgeschnitten (Sitzung laeuft normal weiter).",
self.session_id, self._max_total_bytes,
)
offset = round(time.time() - self._start_ts, 4)
entry = {"t": offset, "dir": "system", "data": "[recording truncated: max size reached]"}
entry_json = json.dumps(entry, ensure_ascii=False, sort_keys=True)
self._append_line(entry, entry_json)
self._flush_event.set()
# -- Executor-Seite (blockierendes Datei-I/O, nie im Event-Loop) -----
def _write_batch_sync(self, items: list[tuple[int, str]]) -> None:
"""Laeuft ausschliesslich via asyncio.to_thread; die einzige Stelle,
die self._writer_fh/self._writer_part_index anfasst -- sequentiell,
da _flush_loop jeden to_thread-Aufruf abwartet, bevor der naechste
beginnt (kein zweiter Executor-Thread gleichzeitig fuer denselben
Recorder)."""
try:
for part_index, line in items:
if part_index != self._writer_part_index:
self._writer_fh.close()
self._writer_part_index = part_index
self._writer_fh = self._open_part(part_index)
self._writer_fh.write(line + "\n")
self._writer_fh.flush()
except OSError:
logger.exception(
"Sitzungsaufzeichnung %s: Schreibfehler im Aufzeichnungs-Flush", self.session_id
)
# -- Hintergrund-Task --------------------------------------------------
async def _flush_loop(self) -> None:
try:
while True:
try:
await asyncio.wait_for(self._flush_event.wait(), timeout=self.FLUSH_INTERVAL_S)
except asyncio.TimeoutError:
pass
self._flush_event.clear()
await self._drain()
if self._closed and not self._buffer:
return
except asyncio.CancelledError:
# Letzter bestmoeglicher Abschluss-Flush vor dem Beenden des Tasks.
await self._drain()
raise
async def _drain(self) -> None:
if not self._buffer:
return
items, self._buffer = self._buffer, []
await asyncio.to_thread(self._write_batch_sync, items)
# -- Abschluss -----------------------------------------------------------
async def aclose(self) -> None:
"""Beendet die Aufzeichnung: signalisiert dem Hintergrund-Task das
Ende, wartet auf dessen letzten Flush (bis zu FLUSH_INTERVAL_S plus
Schreibzeit) und schliesst die Datei. MUSS aus einer Coroutine
aufgerufen werden (siehe Aufrufstellen in ws_tunnel.py/terminal_ws.py,
beide bereits in einem `finally:`-Block einer async-Funktion)."""
if self._closed:
return
self._closed = True
self._flush_event.set()
if self._flush_task is not None:
try:
await asyncio.wait_for(self._flush_task, timeout=self.FLUSH_INTERVAL_S + 5.0)
except asyncio.TimeoutError:
logger.warning(
"Sitzungsaufzeichnung %s: Abschluss-Flush-Task reagierte nicht rechtzeitig, "
"breche ab (letzte gepufferte Eintraege koennen fehlen).", self.session_id,
)
self._flush_task.cancel()
except asyncio.CancelledError:
pass
try:
self._writer_fh.flush()
self._writer_fh.close()
except OSError:
pass
def _iter_part_paths(base_path: Path):
"""Liefert alle Teildateien einer (ggf. rotierten) Aufzeichnung in
Reihenfolge: zuerst base_path selbst, danach .2, .3, ... solange sie
existieren."""
if base_path.exists():
yield base_path
part_index = 2
while True:
candidate = base_path.with_suffix(base_path.suffix + f".{part_index}")
if not candidate.exists():
return
yield candidate
part_index += 1
def verify_recording(path: Path) -> bool: def verify_recording(path: Path) -> bool:
"""Prueft die Hash-Kette EINER Teildatei (Rueckwaertskompatibel: eine
unrotierte Aufzeichnung besteht nur aus dieser einen Datei). Fuer eine
vollstaendig rotierte Aufzeichnung siehe verify_recording_set()."""
prev_hash = GENESIS_HASH prev_hash = GENESIS_HASH
with open(path, encoding="utf-8") as fh: with open(path, encoding="utf-8") as fh:
for line in fh: for line in fh:
@ -61,3 +287,32 @@ def verify_recording(path: Path) -> bool:
return False return False
prev_hash = row["hash"] prev_hash = row["hash"]
return True return True
def verify_recording_set(base_path: Path) -> bool:
"""Prueft ALLE Teildateien einer (ggf. rotierten) Aufzeichnung. Jede
Teildatei hat ihre eigene, unabhaengige Hash-Kette (siehe Moduldoc) --
das Gesamtergebnis ist gueltig, wenn jede einzelne Teildatei fuer sich
gueltig ist."""
parts = list(_iter_part_paths(base_path))
if not parts:
return False
return all(verify_recording(p) for p in parts)
def iter_recording_entries(base_path: Path):
"""Liefert die geparsten 'entry'-Objekte ALLER Teildateien in
chronologischer Reihenfolge (Teildateien sind bereits zeitlich
fortlaufend, da Rotation nur beim Erreichen der Groessenbegrenzung
ausgeloest wird)."""
for part_path in _iter_part_paths(base_path):
with open(part_path, encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if not line:
continue
yield json.loads(line)["entry"]
def count_recording_entries(base_path: Path) -> int:
return sum(1 for _ in iter_recording_entries(base_path))

View File

@ -0,0 +1,63 @@
"""
Session-Aufzeichnung mit Hash-Verkettung (siehe Konzept 6.5).
Jede Session schreibt eine eigene JSONL-Datei unter settings.recordings_dir.
Jede Zeile verkettet sich mit der vorherigen (gleiches Prinzip wie das
Audit-Log, app/security/audit.py), damit nachtraegliche Manipulation der
Aufzeichnung erkennbar ist.
"""
from __future__ import annotations
import hashlib
import json
import time
from pathlib import Path
from app.config import settings
GENESIS_HASH = "0" * 64
class SessionRecorder:
def __init__(self, session_id: int) -> None:
self.session_id = session_id
self.path = settings.recordings_dir / f"session_{session_id}.jsonl"
self._prev_hash = GENESIS_HASH
self._start_ts = time.time()
self._fh = open(self.path, "a", encoding="utf-8")
try:
self.path.chmod(0o600)
except OSError:
pass
def record(self, direction: str, data: str) -> None:
"""direction: 'input' (Tastatureingabe) oder 'output' (Terminal-/RDP-Ausgabe)."""
offset = round(time.time() - self._start_ts, 4)
entry = {"t": offset, "dir": direction, "data": data}
entry_json = json.dumps(entry, ensure_ascii=False, sort_keys=True)
entry_hash = hashlib.sha256((self._prev_hash + "|" + entry_json).encode()).hexdigest()
line = json.dumps({"entry": entry, "prev_hash": self._prev_hash, "hash": entry_hash})
self._fh.write(line + "\n")
self._fh.flush()
self._prev_hash = entry_hash
def close(self) -> None:
if not self._fh.closed:
self._fh.close()
def verify_recording(path: Path) -> bool:
prev_hash = GENESIS_HASH
with open(path, encoding="utf-8") as fh:
for line in fh:
if not line.strip():
continue
row = json.loads(line)
if row["prev_hash"] != prev_hash:
return False
entry_json = json.dumps(row["entry"], ensure_ascii=False, sort_keys=True)
expected = hashlib.sha256((prev_hash + "|" + entry_json).encode()).hexdigest()
if expected != row["hash"]:
return False
prev_hash = row["hash"]
return True

View File

@ -34,14 +34,15 @@ from dataclasses import dataclass, field
class ActiveSession: class ActiveSession:
session_id: int session_id: int
task: asyncio.Task task: asyncio.Task
user_id: int
watchers: set[asyncio.Queue] = field(default_factory=set) watchers: set[asyncio.Queue] = field(default_factory=set)
_active: dict[int, ActiveSession] = {} _active: dict[int, ActiveSession] = {}
def register(session_id: int, task: asyncio.Task) -> None: def register(session_id: int, task: asyncio.Task, user_id: int) -> None:
_active[session_id] = ActiveSession(session_id=session_id, task=task) _active[session_id] = ActiveSession(session_id=session_id, task=task, user_id=user_id)
def unregister(session_id: int) -> None: def unregister(session_id: int) -> None:
@ -56,6 +57,22 @@ def all_ids() -> set[int]:
return set(_active.keys()) return set(_active.keys())
def count_total() -> int:
"""Anzahl aller laufenden Sitzungen auf diesem Prozess (E.4,
Umsetzungsauftrag Teil E: globale Obergrenze)."""
return len(_active)
def count_for_user(user_id: int) -> int:
"""Anzahl laufender Sitzungen eines Benutzers auf diesem Prozess (E.4:
Obergrenze je Benutzer). Bewusst prozesslokal wie die gesamte Registry
(siehe Moduldoc) -- bei einem einzelnen uvicorn-Worker (Standard-
Deployment) ist das gleichbedeutend mit "insgesamt", bei mehreren
Workern zaehlt es nur die auf DIESEM Worker laufenden Sitzungen dieses
Benutzers."""
return sum(1 for entry in _active.values() if entry.user_id == user_id)
def add_watcher(session_id: int) -> asyncio.Queue | None: def add_watcher(session_id: int) -> asyncio.Queue | None:
"""Meldet einen Beobachter fuer eine laufende Sitzung an. Gibt None """Meldet einen Beobachter fuer eine laufende Sitzung an. Gibt None
zurueck, wenn die Sitzung nicht (mehr) auf diesem Prozess laeuft -- zurueck, wenn die Sitzung nicht (mehr) auf diesem Prozess laeuft --

View File

@ -15,6 +15,8 @@ import json
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any from typing import Any
import asyncio
import aiosqlite import aiosqlite
GENESIS_HASH = "0" * 64 GENESIS_HASH = "0" * 64
@ -25,6 +27,22 @@ def _entry_hash(prev_hash: str, ts: str, event_type: str, details_json: str) ->
return hashlib.sha256(payload).hexdigest() return hashlib.sha256(payload).hexdigest()
# E1 (Umsetzungsauftrag_Sonnet5.md Teil E.1): Lesen von prev_hash und das
# nachfolgende INSERT muessen atomar sein. Ohne Schutz koennen zwei
# gleichzeitig laufende Aufrufe (z.B. zwei Sitzungen, die im selben Moment
# starten/enden) denselben prev_hash lesen und beide darauf aufbauen -- die
# Kette bricht dann dauerhaft, weil UPDATE/DELETE auf audit_log durch
# DB-Trigger verboten sind (0001_initial.sql). Zwei Schutzschichten:
# 1. Ein prozessweiter asyncio.Lock serialisiert alle Aufrufe innerhalb
# dieses (einzigen) Event-Loops -- ausreichend, solange die Anwendung in
# einem uvicorn-Worker mit einer globalen Verbindung laeuft (E.0/E.5).
# 2. BEGIN IMMEDIATE/COMMIT als zweite, von der Prozesstopologie
# unabhaengige Schutzschicht: falls doch einmal mehrere Verbindungen auf
# dieselbe Datei schreiben, sorgt die IMMEDIATE-Schreibsperre dafuer,
# dass SELECT und INSERT nicht auseinandergerissen werden.
_write_lock = asyncio.Lock()
async def write_audit_event( async def write_audit_event(
conn: aiosqlite.Connection, conn: aiosqlite.Connection,
*, *,
@ -37,8 +55,16 @@ async def write_audit_event(
Muss innerhalb derselben Transaktion wie die fachliche Aktion laufen (oder Muss innerhalb derselben Transaktion wie die fachliche Aktion laufen (oder
zumindest unmittelbar danach), damit kein Ereignis unauditiert bleibt. zumindest unmittelbar danach), damit kein Ereignis unauditiert bleibt.
Lesen des prev_hash und das INSERT sind durch einen Lock plus eine
BEGIN-IMMEDIATE-Transaktion atomar (siehe E1 in Umsetzungsauftrag_Sonnet5.md).
""" """
cursor = await conn.execute("SELECT entry_hash FROM audit_log ORDER BY id DESC LIMIT 1") async with _write_lock:
await conn.execute("BEGIN IMMEDIATE")
try:
cursor = await conn.execute(
"SELECT entry_hash FROM audit_log ORDER BY id DESC LIMIT 1"
)
row = await cursor.fetchone() row = await cursor.fetchone()
prev_hash = row[0] if row else GENESIS_HASH prev_hash = row[0] if row else GENESIS_HASH
@ -51,6 +77,11 @@ async def write_audit_event(
"VALUES (?, ?, ?, ?, ?, ?, ?)", "VALUES (?, ?, ?, ?, ?, ?, ?)",
(ts, user_id, client_ip, event_type, details_json, prev_hash, entry_hash), (ts, user_id, client_ip, event_type, details_json, prev_hash, entry_hash),
) )
except BaseException:
await conn.execute("ROLLBACK")
raise
else:
await conn.execute("COMMIT")
async def verify_chain(conn: aiosqlite.Connection) -> tuple[bool, int | None]: async def verify_chain(conn: aiosqlite.Connection) -> tuple[bool, int | None]:

View File

@ -11,6 +11,8 @@ from __future__ import annotations
import time import time
from collections import defaultdict, deque from collections import defaultdict, deque
from app.config import settings
class SlidingWindowRateLimiter: class SlidingWindowRateLimiter:
def __init__(self, max_events: int, window_seconds: int) -> None: def __init__(self, max_events: int, window_seconds: int) -> None:
@ -29,6 +31,16 @@ class SlidingWindowRateLimiter:
return True return True
# Max. 10 Login-Versuche pro Minute und Quell-IP; ergaenzt den # E8 (Umsetzungsauftrag Teil E): Werte konfigurierbar (settings.login_rate_
# Account-basierten Lockout in app/auth/routes.py. # limit_*, siehe app/config.py) statt fest verdrahtet -- ergaenzt den
login_rate_limiter = SlidingWindowRateLimiter(max_events=10, window_seconds=60) # Account-basierten Lockout in app/auth/routes.py. ACHTUNG: greift pro
# Quell-IP. Sitzen mehrere interne Benutzer hinter derselben NAT-Adresse
# (typischer Buero-Fall), teilen sie sich dieses Budget; das ist eine
# bewusste Betriebsentscheidung und muss zusammen mit dem nginx-limit_req
# und der fail2ban-Schwelle (beide ebenfalls konfigurierbar, siehe
# ansible/roles/nginx_proxy und ansible/roles/fail2ban) im Betriebshandbuch
# festgehalten werden.
login_rate_limiter = SlidingWindowRateLimiter(
max_events=settings.login_rate_limit_max_events,
window_seconds=settings.login_rate_limit_window_s,
)

View File

@ -0,0 +1,83 @@
"""
Startup-Reaper fuer verwaiste Sitzungen (Umsetzungsauftrag_Sonnet5.md, E7).
app/security/active_sessions.py ist bewusst rein prozesslokal -- bei einem
Absturz oder Neustart des uvicorn-Prozesses geht diese Registry verloren,
aber die zugehoerigen Zeilen in der Tabelle 'sessions' bleiben mit
ended_at IS NULL stehen, weil kein Task mehr existiert, der den regulaeren
finally-Cleanup-Pfad (Update + Audit-Event) durchlaeuft.
Ohne Korrektur wachsen diese 'Geistersitzungen' mit jedem Neustart an:
die Superadmin-Sessionview zeigt sie als aktiv, aber nicht beendbar
(killable: false), und jede kuenftige Zaehlung 'wie viele Sitzungen hat
dieser Benutzer offen' (E6, Teil F) rechnet mit ihnen.
reap_orphaned_sessions() wird einmalig beim Start aufgerufen (app/main.py::
lifespan, NACH init_db() -- die Migrationen muessen gelaufen sein) und
schliesst alle offenen Sitzungen mit end_reason='server_restart'. Es ist zu
diesem Zeitpunkt garantiert korrekt, JEDE offene Sitzung so zu behandeln:
der Prozess startet gerade erst, es kann also noch keine echte, laufende
Sitzung dieses neuen Prozesses geben.
"""
from __future__ import annotations
import logging
import aiosqlite
from app.security.audit import write_audit_event
logger = logging.getLogger("jumphost.session_reaper")
async def reap_orphaned_sessions(conn: aiosqlite.Connection) -> int:
"""Schliesst alle beim Start noch offenen Sitzungen. Gibt die Anzahl zurueck."""
cursor = await conn.execute(
"SELECT id FROM sessions WHERE ended_at IS NULL ORDER BY id"
)
rows = await cursor.fetchall()
orphaned_ids = [row[0] for row in rows]
if not orphaned_ids:
logger.info("Keine verwaisten Sitzungen beim Start gefunden.")
return 0
await conn.execute(
"UPDATE sessions SET ended_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), "
"end_reason = 'server_restart' WHERE ended_at IS NULL"
)
# Bug-Fix (FORTSETZUNG_Teil_C.md Abschnitt 3 Punkt 3): explizit committen,
# BEVOR write_audit_event() unten aufgerufen wird. write_audit_event()
# oeffnet immer ein eigenes "BEGIN IMMEDIATE" (app/security/audit.py, E1)
# -- ohne diesen Commit bleibt das UPDATE oben (je nach isolation_level
# der uebergebenen Connection) als offene Transaktion stehen, und das
# BEGIN IMMEDIATE schlaegt mit "cannot start a transaction within a
# transaction" fehl. In der Produktion faellt das nicht auf, weil
# app/db.py::init_db() die einzige echte Connection bewusst mit
# isolation_level=None (Autocommit) oeffnet -- dort committet das UPDATE
# bereits von selbst. reap_orphaned_sessions() bekommt seine Connection
# aber als Parameter uebergeben und darf sich auf dieses Detail des
# Aufrufers nicht verlassen (siehe tests/test_session_reaper.py, das
# bewusst eine eigene :memory:-Connection MIT Standard-isolation_level
# verwendet und den Fehler dadurch reproduzierbar machte).
await conn.commit()
logger.warning(
"Startup-Reaper: %d verwaiste Sitzung(en) aus vorherigem Prozess geschlossen "
"(end_reason=server_restart): %s",
len(orphaned_ids),
orphaned_ids,
)
# Ein einziges Sammel-Audit-Ereignis statt eines je Sitzung (E7) -- die
# Anzahl und betroffenen IDs sind das, was fuer die Nachvollziehbarkeit
# gebraucht wird; ein Ereignis pro Zeile waere Rauschen im Log.
await write_audit_event(
conn,
event_type="session_reaper_server_restart",
user_id=None,
client_ip=None,
details={"count": len(orphaned_ids), "session_ids": orphaned_ids},
)
return len(orphaned_ids)

View File

@ -15,6 +15,7 @@ import logging
import asyncssh import asyncssh
import aiosqlite import aiosqlite
from app.rbac import AmbiguousCredentialError, resolve_credential_for_user_on_host
from app.security.crypto import decrypt_secret from app.security.crypto import decrypt_secret
logger = logging.getLogger("jumphost.ssh_proxy") logger = logging.getLogger("jumphost.ssh_proxy")
@ -215,6 +216,10 @@ SSH_SETUP_ERRORS: tuple[type[Exception], ...] = (
HostKeyMismatchError, HostKeyMismatchError,
HostKeyDiscoveryError, HostKeyDiscoveryError,
PrivateKeyUnusableError, PrivateKeyUnusableError,
# Teil D Schritt 4 (Achse B): mehr als ein fuer den Benutzer nutzbarer
# SSH-Key an einem Host -- eine Konfigurationsfrage, keine interne
# Ausnahme, gehoert deshalb in dieselbe Klasse wie die Eintraege oben.
AmbiguousCredentialError,
) )
@ -229,6 +234,13 @@ def describe_connection_error(exc: Exception) -> str:
) )
if isinstance(exc, HostKeyDiscoveryError): if isinstance(exc, HostKeyDiscoveryError):
return f"Das Ziel ist nicht erreichbar: {exc.reason}" return f"Das Ziel ist nicht erreichbar: {exc.reason}"
if isinstance(exc, AmbiguousCredentialError):
return (
f"Mehrdeutige Zugangsdaten fuer diesen Host: mehrere freigegebene SSH-Schluessel "
f"(IDs {exc.credential_ids}) treffen fuer Sie zu, keine automatische Auswahl "
"moeglich. Bitte im Adminbereich bereinigen (ueberzaehligen Schluessel entfernen "
"oder Gruppenfreigabe einschraenken)."
)
return str(exc) return str(exc)
@ -294,13 +306,22 @@ async def load_host(conn: aiosqlite.Connection, host_id: int) -> dict:
async def load_ssh_key_credential_for_host( async def load_ssh_key_credential_for_host(
conn: aiosqlite.Connection, host_id: int conn: aiosqlite.Connection, host_id: int, *, user_id: int
) -> tuple[asyncssh.SSHKey, str | None] | None: ) -> tuple[asyncssh.SSHKey, str | None] | None:
"""Laedt den dem Host zugeordneten Schluessel, entschluesselt ihn und gibt """Laedt den fuer DIESEN Benutzer geltenden, dem Host zugeordneten
ihn zusammen mit dem am Schluessel hinterlegten Benutzernamen zurueck. Schluessel, entschluesselt ihn und gibt ihn zusammen mit dem am
Gibt None zurueck (statt zu werfen), wenn kein Schluessel zugeordnet ist Schluessel hinterlegten Benutzernamen zurueck. Gibt None zurueck (statt
-- der Aufrufer (connect_to_host) faellt dann auf ein SSH-Passwort zu werfen), wenn kein Schluessel zutrifft -- der Aufrufer
zurueck, falls eines hinterlegt ist (Migration 0011). (connect_to_host) faellt dann auf ein SSH-Passwort zurueck, falls eines
hinterlegt ist (Migration 0011).
Seit Teil D Schritt 4 (Achse B) laeuft die Auswahl NICHT mehr blind ueber
den Host allein: app.rbac.resolve_credential_for_user_on_host()
beruecksichtigt zusaetzlich, welcher der dem Host zugeordneten
Schluessel ueber group_ssh_key_grants mindestens einer Benutzergruppe
dieses Users freigegeben ist. Wirft AmbiguousCredentialError, wenn mehr
als ein Schluessel zutrifft (bewusst kein stilles LIMIT 1 mehr, siehe
dortige Docstring).
Der Benutzername gehoert seit Migration 0010 zu den Zugangsdaten Der Benutzername gehoert seit Migration 0010 zu den Zugangsdaten
(ssh_keys.username) und nicht mehr zum Host: er ist Teil der Anmeldung, (ssh_keys.username) und nicht mehr zum Host: er ist Teil der Anmeldung,
@ -308,11 +329,14 @@ async def load_ssh_key_credential_for_host(
Passphrase hinterlegt (ssh_keys.passphrase_enc, Migration 0009), wird sie Passphrase hinterlegt (ssh_keys.passphrase_enc, Migration 0009), wird sie
ebenfalls entschluesselt und an asyncssh uebergeben. ebenfalls entschluesselt und an asyncssh uebergeben.
""" """
ssh_key_id = await resolve_credential_for_user_on_host(
conn, user_id=user_id, host_id=host_id, kind="ssh_key"
)
if ssh_key_id is None:
return None
cursor = await conn.execute( cursor = await conn.execute(
"SELECT sk.private_key_enc, sk.passphrase_enc, sk.username FROM ssh_keys sk " "SELECT private_key_enc, passphrase_enc, username FROM ssh_keys WHERE id = ?",
"JOIN host_ssh_key_map m ON m.ssh_key_id = sk.id " (ssh_key_id,),
"WHERE m.host_id = ? LIMIT 1",
(host_id,),
) )
row = await cursor.fetchone() row = await cursor.fetchone()
if row is None: if row is None:
@ -332,14 +356,25 @@ async def load_ssh_key_credential_for_host(
async def load_ssh_password_credential_for_host( async def load_ssh_password_credential_for_host(
conn: aiosqlite.Connection, host_id: int conn: aiosqlite.Connection, host_id: int, *, user_id: int
) -> tuple[str, str] | None: ) -> tuple[str, str] | None:
"""Laedt das (Passwort, Benutzername)-Paar fuer einen Host OHNE """Laedt das (Passwort, Benutzername)-Paar, das fuer DIESEN Benutzer auf
zugeordneten SSH-Key (ssh_password_credentials, Migration 0011). Gibt einem Host OHNE (fuer ihn nutzbaren) SSH-Key gilt (ssh_password_credentials,
None zurueck, wenn kein SSH-Passwort hinterlegt ist.""" Migration 0011, seit Migration 0016 ueber host_ssh_password_credential_map
referenziert -- eigenstaendiges Objekt mit eigener ID, siehe dortiger
Kommentar). Gibt None zurueck, wenn kein SSH-Passwort zutrifft. Wie beim
SSH-Key laeuft die Auswahl seit Teil D Schritt 4 ueber
app.rbac.resolve_credential_for_user_on_host() (Achse B); strukturell
kann hier keine Mehrdeutigkeit auftreten (host_ssh_password_credential_map
hat PK auf host_id, also hoechstens ein Treffer)."""
credential_id = await resolve_credential_for_user_on_host(
conn, user_id=user_id, host_id=host_id, kind="ssh_password_credential"
)
if credential_id is None:
return None
cursor = await conn.execute( cursor = await conn.execute(
"SELECT password_enc, username FROM ssh_password_credentials WHERE host_id = ?", "SELECT password_enc, username FROM ssh_password_credentials WHERE id = ?",
(host_id,), (credential_id,),
) )
row = await cursor.fetchone() row = await cursor.fetchone()
if row is None: if row is None:
@ -351,12 +386,15 @@ async def load_ssh_password_credential_for_host(
del password del password
async def load_private_key_for_host(conn: aiosqlite.Connection, host_id: int) -> asyncssh.SSHKey: async def load_private_key_for_host(
conn: aiosqlite.Connection, host_id: int, *, user_id: int
) -> asyncssh.SSHKey:
"""Rueckwaertskompatible Fassung ohne Benutzername (siehe """Rueckwaertskompatible Fassung ohne Benutzername (siehe
load_ssh_key_credential_for_host). Wirft HostNotConfiguredError, wenn kein load_ssh_key_credential_for_host). Wirft HostNotConfiguredError, wenn
Schluessel zugeordnet ist -- anders als connect_to_host beruecksichtigt kein fuer diesen Benutzer nutzbarer Schluessel zutrifft -- anders als
diese Fassung KEIN SSH-Passwort als Alternative.""" connect_to_host beruecksichtigt diese Fassung KEIN SSH-Passwort als
result = await load_ssh_key_credential_for_host(conn, host_id) Alternative."""
result = await load_ssh_key_credential_for_host(conn, host_id, user_id=user_id)
if result is None: if result is None:
raise HostNotConfiguredError(f"Kein SSH-Schluessel fuer Host {host_id} hinterlegt") raise HostNotConfiguredError(f"Kein SSH-Schluessel fuer Host {host_id} hinterlegt")
key, _username = result key, _username = result
@ -484,7 +522,9 @@ def _public_key_body(openssh_line: str) -> tuple[str, str]:
return (parts[0], parts[1]) return (parts[0], parts[1])
async def connect_to_host(conn: aiosqlite.Connection, host_id: int) -> asyncssh.SSHClientConnection: async def connect_to_host(
conn: aiosqlite.Connection, host_id: int, *, user_id: int
) -> asyncssh.SSHClientConnection:
host = await load_host(conn, host_id) host = await load_host(conn, host_id)
if host["protocol"] != "ssh": if host["protocol"] != "ssh":
raise HostNotConfiguredError("Host ist kein SSH-Ziel") raise HostNotConfiguredError("Host ist kein SSH-Ziel")
@ -496,13 +536,23 @@ async def connect_to_host(conn: aiosqlite.Connection, host_id: int) -> asyncssh.
# SSH-Key hat immer Vorrang vor einem SSH-Passwort (Migration 0011, # SSH-Key hat immer Vorrang vor einem SSH-Passwort (Migration 0011,
# "Linux kann statt SSH-Key auch Passwort haben" -- explizit als # "Linux kann statt SSH-Key auch Passwort haben" -- explizit als
# Alternative gewuenscht, nicht als gleichrangige zweite Option: ist ein # Alternative gewuenscht, nicht als gleichrangige zweite Option: ist ein
# Schluessel zugeordnet, wird er benutzt, unabhaengig davon, ob zusaetzlich # fuer DIESEN Benutzer nutzbarer Schluessel zugeordnet, wird er benutzt,
# ein Passwort hinterlegt ist). # unabhaengig davon, ob zusaetzlich ein Passwort hinterlegt ist). Beide
key_credential = await load_ssh_key_credential_for_host(conn, host_id) # Aufloesungen sind seit Teil D Schritt 4 benutzerabhaengig (Achse B) --
password_credential = None if key_credential is not None else await load_ssh_password_credential_for_host(conn, host_id) # siehe load_ssh_key_credential_for_host()/load_ssh_password_credential_for_host().
key_credential = await load_ssh_key_credential_for_host(conn, host_id, user_id=user_id)
password_credential = (
None
if key_credential is not None
else await load_ssh_password_credential_for_host(conn, host_id, user_id=user_id)
)
if key_credential is None and password_credential is None: if key_credential is None and password_credential is None:
raise HostNotConfiguredError( raise HostNotConfiguredError(
f"Weder ein SSH-Schluessel noch ein SSH-Passwort fuer Host {host_id} hinterlegt" f"Weder ein SSH-Schluessel noch ein SSH-Passwort fuer Host {host_id} hinterlegt "
"oder Ihrer Benutzergruppe freigegeben. Falls Sie ueber eine Gruppe "
"'ssh_connect' fuer diesen Host haben, aber trotzdem diese Meldung sehen: "
"die Zugangsdaten sind zwar zugeordnet, aber keiner Ihrer Gruppen ueber "
"Achse B freigegeben (Adminbereich -> Benutzergruppen -> Zugangsdaten)."
) )
if key_credential is not None: if key_credential is not None:

View File

@ -5,6 +5,7 @@ Groessenlimit, Sha256-Hashing und optionaler AV-Scan sind Pflicht (Konzept
""" """
from __future__ import annotations from __future__ import annotations
import asyncio
import hashlib import hashlib
import logging import logging
@ -13,6 +14,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request, UploadFil
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from app.auth.deps import CurrentUser, get_current_user from app.auth.deps import CurrentUser, get_current_user
from app.config import settings
from app.db import get_db from app.db import get_db
from app.rbac import user_has_role_for_host from app.rbac import user_has_role_for_host
from app.security.audit import write_audit_event from app.security.audit import write_audit_event
@ -31,6 +33,20 @@ router = APIRouter(prefix="/ssh", tags=["file-transfer"])
MAX_UPLOAD_BYTES = 200 * 1024 * 1024 # 200 MiB, ueber Ansible-Variable konfigurierbar (siehe Konzept) MAX_UPLOAD_BYTES = 200 * 1024 * 1024 # 200 MiB, ueber Ansible-Variable konfigurierbar (siehe Konzept)
# E4 (Umsetzungsauftrag Teil E): Ein-/Auslesen in Bloecken statt in einem
# einzigen file.read(MAX_UPLOAD_BYTES + 1)/remote_file.read()-Aufruf --
# sowohl fuers Streaming zum/vom SFTP-Ziel als auch fuer inkrementelles
# Hashing (ein einzelner hashlib.sha256(<200 MiB>)-Aufruf haelt den
# Event-Loop zwar nur kurz, aber synchron und ohne jede Zwischen-await-
# Gelegenheit an -- inkrementelles Update() je Chunk verteilt das).
TRANSFER_CHUNK_BYTES = 1 * 1024 * 1024 # 1 MiB
# E4: Obergrenze gleichzeitig laufender Transfers -- Prozess-weiter
# In-Memory-Zustand wie login_rate_limiter/active_sessions (siehe E.0),
# bewusst NICHT pro Worker (es gibt ohnehin nur einen, --workers waere laut
# E.0 ohnehin keine Option).
_transfer_semaphore = asyncio.Semaphore(settings.max_concurrent_transfers)
def _client_ip(request: Request) -> str: def _client_ip(request: Request) -> str:
return request.client.host if request.client else "unknown" return request.client.host if request.client else "unknown"
@ -80,11 +96,36 @@ async def upload_file(
host=Depends(_require_file_transfer), host=Depends(_require_file_transfer),
user: CurrentUser = Depends(get_current_user), user: CurrentUser = Depends(get_current_user),
): ):
data = await file.read(MAX_UPLOAD_BYTES + 1) # E4 (Umsetzungsauftrag Teil E): in Bloecken statt in einem einzigen
if len(data) > MAX_UPLOAD_BYTES: # file.read(MAX_UPLOAD_BYTES + 1)-Aufruf lesen. Der AV-Scan (scan_bytes)
# braucht weiterhin den vollstaendigen Inhalt in einem Stueck -- das
# Limit wird deshalb WAEHREND des Einlesens durchgesetzt (frueher
# Abbruch, sobald es ueberschritten ist, statt still bis MAX+1 zu lesen),
# und das Hashing laeuft inkrementell mit, statt als ein einzelner
# blockierender hashlib.sha256(<gesamte Datei>)-Aufruf am Ende.
hasher = hashlib.sha256()
chunks: list[bytes] = []
total = 0
while True:
chunk = await file.read(TRANSFER_CHUNK_BYTES)
if not chunk:
break
total += len(chunk)
if total > MAX_UPLOAD_BYTES:
raise HTTPException(status.HTTP_413_CONTENT_TOO_LARGE, "Datei zu gross") raise HTTPException(status.HTTP_413_CONTENT_TOO_LARGE, "Datei zu gross")
hasher.update(chunk)
chunks.append(chunk)
data = b"".join(chunks)
del chunks
sha256 = hasher.hexdigest()
av_result = scan_bytes(data) async with _transfer_semaphore:
# E3 (Umsetzungsauftrag Teil E): scan_bytes() ruft synchron
# subprocess.run(..., timeout=30) auf -- bis zu 30 Sekunden, in denen
# der EINZIGE Event-Loop der Anwendung fuer ALLE Benutzer stillstand
# (siehe E.0). asyncio.to_thread() lagert den blockierenden Aufruf in
# einen Worker-Thread aus.
av_result = await asyncio.to_thread(scan_bytes, data)
if av_result.startswith("infected"): if av_result.startswith("infected"):
conn = get_db() conn = get_db()
await write_audit_event( await write_audit_event(
@ -95,14 +136,26 @@ async def upload_file(
await conn.commit() await conn.commit()
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"Datei durch AV-Scan blockiert: {av_result}") raise HTTPException(status.HTTP_400_BAD_REQUEST, f"Datei durch AV-Scan blockiert: {av_result}")
sha256 = hashlib.sha256(data).hexdigest()
conn = get_db() conn = get_db()
try: try:
ssh_conn = await connect_to_host(conn, host_id) ssh_conn = await connect_to_host(conn, host_id, user_id=user.id)
try: try:
async with ssh_conn.start_sftp_client() as sftp: async with ssh_conn.start_sftp_client() as sftp:
async with sftp.open(remote_path, "wb") as remote_file: async with sftp.open(remote_path, "wb") as remote_file:
await remote_file.write(data) # In Bloecken schreiben statt eines einzelnen
# write(<gesamte Datei>): der AV-Scan (scan_bytes)
# braucht den vollstaendigen Inhalt vor dem Schreiben
# in einem Stueck, `data` haelt die Datei deshalb
# weiterhin komplett im Speicher (unveraendert
# gegenueber vorher) -- echtes speicherbegrenztes
# Streaming ist beim Upload durch das "erst scannen,
# dann schreiben"-Erfordernis inhaerent nicht moeglich,
# ohne den AV-Scan selbst auf Datei-/Stream-Basis
# umzustellen. Der chunk-weise write() vermeidet
# zumindest einen einzelnen sehr grossen asyncssh-
# Aufruf und haelt jeden einzelnen await kurz.
for offset in range(0, len(data), TRANSFER_CHUNK_BYTES):
await remote_file.write(data[offset:offset + TRANSFER_CHUNK_BYTES])
finally: finally:
ssh_conn.close() ssh_conn.close()
except SSH_SETUP_ERRORS as exc: except SSH_SETUP_ERRORS as exc:
@ -151,23 +204,44 @@ async def download_file(
host=Depends(_require_file_transfer), host=Depends(_require_file_transfer),
user: CurrentUser = Depends(get_current_user), user: CurrentUser = Depends(get_current_user),
): ):
# E4 (Umsetzungsauftrag Teil E): echtes Streaming statt
# remote_file.read() der kompletten Datei in einen einzigen `data`-Puffer
# gefolgt von StreamingResponse(yield data) (das war effektiv KEIN
# Streaming -- der gesamte Speicher-Peak war identisch zu vorher, nur in
# eine andere Form verpackt). Anders als beim Upload gibt es beim
# Download KEINEN AV-Scan-Zwang, der den vollstaendigen Inhalt vor dem
# Weiterreichen braucht -- echtes chunkweises Streaming direkt in die
# HTTP-Antwort ist hier also tatsaechlich moeglich und sinnvoll.
#
# Verbindungsaufbau + stat() laufen bewusst NOCH VOR der
# StreamingResponse (mit vollstaendiger Fehlerbehandlung wie bisher) --
# sobald die Antwort einmal zu streamen begonnen hat, kann der
# HTTP-Statuscode nicht mehr geaendert werden. sftp/remote_file bleiben
# ueber die gesamte Dauer des Streams offen und werden erst im
# finally-Block des Generators geschlossen.
conn = get_db() conn = get_db()
await _transfer_semaphore.acquire()
try: try:
ssh_conn = await connect_to_host(conn, host_id) ssh_conn = await connect_to_host(conn, host_id, user_id=user.id)
try:
sftp = await ssh_conn.start_sftp_client()
try: try:
async with ssh_conn.start_sftp_client() as sftp:
stat = await sftp.stat(remote_path) stat = await sftp.stat(remote_path)
if stat.size and stat.size > MAX_UPLOAD_BYTES: if stat.size and stat.size > MAX_UPLOAD_BYTES:
raise HTTPException(status.HTTP_413_CONTENT_TOO_LARGE, "Datei zu gross") raise HTTPException(status.HTTP_413_CONTENT_TOO_LARGE, "Datei zu gross")
async with sftp.open(remote_path, "rb") as remote_file: remote_file = await sftp.open(remote_path, "rb")
data = await remote_file.read() except Exception:
finally: sftp.exit()
raise
except Exception:
ssh_conn.close() ssh_conn.close()
raise
except SSH_SETUP_ERRORS as exc: except SSH_SETUP_ERRORS as exc:
# Alles, was den Verbindungsaufbau verhindert und in der Konfiguration # Alles, was den Verbindungsaufbau verhindert und in der Konfiguration
# begruendet ist (fehlender Benutzername/Schluessel, unbrauchbarer # begruendet ist (fehlender Benutzername/Schluessel, unbrauchbarer
# Schluessel, nicht gepinnter oder abweichender Host-Key, Ziel nicht # Schluessel, nicht gepinnter oder abweichender Host-Key, Ziel nicht
# erreichbar): 400 mit Klartext statt eines unbehandelten 500. # erreichbar): 400 mit Klartext statt eines unbehandelten 500.
_transfer_semaphore.release()
logger.warning("Dateitransfer fuer Host %s nicht moeglich: %s", host_id, exc) logger.warning("Dateitransfer fuer Host %s nicht moeglich: %s", host_id, exc)
raise HTTPException(status.HTTP_400_BAD_REQUEST, describe_connection_error(exc)) raise HTTPException(status.HTTP_400_BAD_REQUEST, describe_connection_error(exc))
except asyncssh.Error as exc: except asyncssh.Error as exc:
@ -175,26 +249,66 @@ async def download_file(
# Bugfix: SFTP-Fehler nach erfolgreicher Anmeldung (z.B. Datei nicht # Bugfix: SFTP-Fehler nach erfolgreicher Anmeldung (z.B. Datei nicht
# gefunden, keine Leseberechtigung) liefen bisher unbehandelt bis zu # gefunden, keine Leseberechtigung) liefen bisher unbehandelt bis zu
# Starlettes Klartext-500 durch. # Starlettes Klartext-500 durch.
_transfer_semaphore.release()
logger.warning("SFTP-Fehler bei Host %s: %s", host_id, exc) logger.warning("SFTP-Fehler bei Host %s: %s", host_id, exc)
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"SFTP-Fehler: {exc}") raise HTTPException(status.HTTP_400_BAD_REQUEST, f"SFTP-Fehler: {exc}")
except HTTPException: except HTTPException:
_transfer_semaphore.release()
raise raise
except Exception: except Exception:
_transfer_semaphore.release()
logger.exception("Unerwarteter Fehler beim Datei-Download fuer Host %s", host_id) logger.exception("Unerwarteter Fehler beim Datei-Download fuer Host %s", host_id)
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Unerwarteter Fehler beim Dateitransfer") raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Unerwarteter Fehler beim Dateitransfer")
# Ab hier (kein except griff) ist der Stream erfolgreich eroeffnet --
# das Semaphor wird bewusst NICHT hier freigegeben, sondern erst im
# finally-Block von _stream() unten, sobald der komplette Download
# abgeschlossen (oder abgebrochen) ist.
sha256 = hashlib.sha256(data).hexdigest()
filename = remote_path.rsplit("/", 1)[-1] filename = remote_path.rsplit("/", 1)[-1]
async def _stream():
hasher = hashlib.sha256()
total = 0
try:
while True:
chunk = await remote_file.read(TRANSFER_CHUNK_BYTES)
if not chunk:
break
total += len(chunk)
hasher.update(chunk)
yield chunk
finally:
try:
# SFTPClientFile wird andernorts in diesem Modul immer als
# `async with sftp.open(...) as remote_file:` verwendet --
# das bedeutet close() ist dort eine Koroutine (__aexit__
# ruft sie awaited auf). Hier manuell dasselbe nachgebildet,
# da der Dateihandle ueber die gesamte Stream-Dauer offen
# bleiben muss und daher nicht in einem `async with` um nur
# den Lesevorgang herum verwaltet werden kann.
await remote_file.close()
except Exception:
pass
sftp.exit()
ssh_conn.close()
_transfer_semaphore.release()
try:
await _log_transfer( await _log_transfer(
conn, user=user, host_id=host_id, client_ip=_client_ip(request), direction="download", conn, user=user, host_id=host_id, client_ip=_client_ip(request), direction="download",
filename=filename, size=len(data), sha256=sha256, av_result="not_applicable_download", filename=filename, size=total, sha256=hasher.hexdigest(), av_result="not_applicable_download",
)
except Exception:
# Der Download selbst ist zu diesem Zeitpunkt beim Client
# bereits (teilweise) angekommen -- ein fehlgeschlagener
# Logeintrag darf den bereits laufenden Stream nicht mehr
# rueckwirkend als Fehler erscheinen lassen, muss aber sichtbar
# sein.
logger.exception(
"Download-Protokollierung fuer Host %s / %s fehlgeschlagen", host_id, remote_path
) )
def _iter():
yield data
return StreamingResponse( return StreamingResponse(
_iter(), _stream(),
media_type="application/octet-stream", media_type="application/octet-stream",
headers={"Content-Disposition": f'attachment; filename="{filename}"'}, headers={"Content-Disposition": f'attachment; filename="{filename}"'},
) )

View File

@ -13,11 +13,13 @@ from __future__ import annotations
import asyncio import asyncio
import base64 import base64
import logging import logging
import time
import asyncssh import asyncssh
from fastapi import APIRouter, WebSocket, WebSocketDisconnect from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from app.auth.deps import get_current_user_ws from app.auth.deps import get_current_user_ws
from app.config import settings
from app.db import get_db from app.db import get_db
from app.rbac import user_has_role_for_host from app.rbac import user_has_role_for_host
from app.recordings.recorder import SessionRecorder from app.recordings.recorder import SessionRecorder
@ -35,8 +37,17 @@ from app.ssh_proxy.proxy import (
logger = logging.getLogger("jumphost.ssh_proxy.ws") logger = logging.getLogger("jumphost.ssh_proxy.ws")
router = APIRouter() router = APIRouter()
MAX_SESSION_SECONDS = 8 * 3600 # E10/E11 (Umsetzungsauftrag Teil E): frueher feste Modul-Konstanten, jetzt
IDLE_TIMEOUT_SECONDS = 15 * 60 # konfigurierbar (siehe app/config.py). MAX_SESSION_SECONDS wurde vorher
# nirgends ausgewertet -- es gab de facto GAR KEINE absolute Obergrenze.
IDLE_TIMEOUT_SECONDS = settings.ssh_idle_timeout_s
MAX_SESSION_SECONDS = settings.ssh_max_session_seconds
MAX_SESSION_WARNING_S = settings.ssh_max_session_warning_s
# Wie oft die Haupt-Schleife hoechstens "blind" auf eine Benutzereingabe
# wartet, bevor sie den gemeinsamen Aktivitaets-/Laufzeitstand neu prueft.
# Niedrig genug, um Idle-Timeout und Sitzungsobergrenze zeitnah durchsetzen
# zu koennen, aber hoch genug, um nicht sinnlos oft zu pollen.
_POLL_INTERVAL_S = 20
async def _reject(websocket: WebSocket, code: int, reason: str, *, accepted: bool) -> None: async def _reject(websocket: WebSocket, code: int, reason: str, *, accepted: bool) -> None:
@ -57,7 +68,8 @@ async def _reject(websocket: WebSocket, code: int, reason: str, *, accepted: boo
async def _pump_ssh_to_ws( async def _pump_ssh_to_ws(
process: asyncssh.SSHClientProcess, websocket: WebSocket, recorder: SessionRecorder, session_id: int process: asyncssh.SSHClientProcess, websocket: WebSocket, recorder: SessionRecorder, session_id: int,
activity: dict,
): ):
try: try:
while True: while True:
@ -66,6 +78,12 @@ async def _pump_ssh_to_ws(
break break
if isinstance(data, str): if isinstance(data, str):
data = data.encode("utf-8", errors="replace") data = data.encode("utf-8", errors="replace")
# E10 (Umsetzungsauftrag Teil E): Ausgabe vom Ziel zaehlt genauso
# als Aktivitaet wie eine Benutzereingabe -- ein laufendes
# `tail -f`/langer Build haelt die Sitzung damit am Leben, auch
# wenn niemand tippt. `activity` wird mit der Haupt-Schleife
# unten geteilt (dieselbe Coroutine-Ausfuehrung, kein Lock noetig).
activity["t"] = time.monotonic()
recorder.record("output", base64.b64encode(data).decode()) recorder.record("output", base64.b64encode(data).decode())
# Live-Mitschau (GET /ws/sessions/{id}/watch, siehe unten): jeder # Live-Mitschau (GET /ws/sessions/{id}/watch, siehe unten): jeder
# Chunk geht zusaetzlich an alle aktuell zuschauenden Superadmins. # Chunk geht zusaetzlich an alle aktuell zuschauenden Superadmins.
@ -99,6 +117,25 @@ async def ssh_terminal(websocket: WebSocket, host_id: int):
) )
return return
# E.4 (Umsetzungsauftrag Teil E): Obergrenzen je Benutzer und global,
# VOR jedem Ressourcenverbrauch geprueft (analog app/rdp_proxy/ws_tunnel.py).
if not user.is_admin and active_sessions.count_for_user(user.id) >= settings.max_sessions_per_user:
await _reject(
websocket, 4429,
f"Sie haben bereits {settings.max_sessions_per_user} Sitzungen offen "
"(Obergrenze je Benutzer erreicht).",
accepted=False,
)
return
if active_sessions.count_total() >= settings.max_sessions_global:
await _reject(
websocket, 4429,
"Der Server hat die maximale Anzahl gleichzeitiger Sitzungen erreicht, "
"bitte spaeter erneut versuchen.",
accepted=False,
)
return
await websocket.accept() await websocket.accept()
client_ip = websocket.client.host if websocket.client else "unknown" client_ip = websocket.client.host if websocket.client else "unknown"
@ -130,25 +167,91 @@ async def ssh_terminal(websocket: WebSocket, host_id: int):
"SSH-Sitzung %s gestartet: user=%s host=%s (%s:%s) client_ip=%s", "SSH-Sitzung %s gestartet: user=%s host=%s (%s:%s) client_ip=%s",
session_id, user.username, host["hostname"], host["address"], host["port"], client_ip, session_id, user.username, host["hostname"], host["address"], host["port"], client_ip,
) )
active_sessions.register(session_id, asyncio.current_task()) active_sessions.register(session_id, asyncio.current_task(), user.id)
end_reason = "logout" end_reason = "logout"
ssh_conn = None ssh_conn = None
process = None process = None
pump_task = None pump_task = None
session_start = time.monotonic()
# E10: von _pump_ssh_to_ws() UND der Haupt-Schleife hier gemeinsam
# aktualisiert -- ein dict-Eintrag statt einer einfachen Variable, damit
# beide Coroutinen denselben veraenderlichen Zustand sehen (Closures
# koennen keine Nicht-lokalen einfachen Namen neu binden). Kein Lock
# noetig: reine Zuweisungen im selben Event-Loop-Thread.
activity = {"t": session_start}
warned_max_duration = False
try: try:
ssh_conn = await connect_to_host(conn, host_id) ssh_conn = await connect_to_host(conn, host_id, user_id=user.id)
logger.debug("SSH-Sitzung %s: Verbindung zu %s hergestellt", session_id, host["hostname"]) logger.debug("SSH-Sitzung %s: Verbindung zu %s hergestellt", session_id, host["hostname"])
process = await ssh_conn.create_process(term_type="xterm-256color") process = await ssh_conn.create_process(term_type="xterm-256color")
pump_task = asyncio.create_task(_pump_ssh_to_ws(process, websocket, recorder, session_id)) pump_task = asyncio.create_task(_pump_ssh_to_ws(process, websocket, recorder, session_id, activity))
while True: while True:
try: try:
msg = await asyncio.wait_for(websocket.receive_json(), timeout=IDLE_TIMEOUT_SECONDS) msg = await asyncio.wait_for(websocket.receive_json(), timeout=_POLL_INTERVAL_S)
except asyncio.TimeoutError: except asyncio.TimeoutError:
msg = None
now = time.monotonic()
if msg is not None:
activity["t"] = now
# E10: Inaktivitaet wird ueber `activity` in BEIDEN Richtungen
# gemessen (Benutzereingabe hier, Zielausgabe in
# _pump_ssh_to_ws()) -- vorher zaehlte nur receive_json(), ein
# rein ausgabelastiges `tail -f` o.ae. wurde nach 15 Minuten ohne
# Tastendruck getrennt, obwohl die Sitzung erkennbar aktiv war.
if now - activity["t"] > IDLE_TIMEOUT_SECONDS:
end_reason = "idle_timeout" end_reason = "idle_timeout"
try:
await websocket.send_json({
"type": "error",
"message": (
f"Sitzung wegen Inaktivitaet beendet "
f"(> {IDLE_TIMEOUT_SECONDS // 60} Minuten ohne Ein-/Ausgabe)."
),
})
except Exception:
logger.debug("Idle-Timeout-Meldung konnte nicht mehr gesendet werden", exc_info=True)
break break
# E11: MAX_SESSION_SECONDS war vorher eine definierte, aber
# nirgends ausgewertete Konstante -- es gab de facto GAR KEINE
# absolute Obergrenze fuer eine SSH-Sitzung. Jetzt aktiv
# durchgesetzt, mit Vorwarnung statt eines ueberraschenden
# sofortigen Abbruchs.
running_for = now - session_start
if running_for > MAX_SESSION_SECONDS:
end_reason = "max_duration_exceeded"
try:
await websocket.send_json({
"type": "error",
"message": (
f"Sitzung nach Erreichen der maximalen Sitzungsdauer "
f"({MAX_SESSION_SECONDS // 3600} Stunden) beendet."
),
})
except Exception:
logger.debug("Sitzungsende-Meldung konnte nicht mehr gesendet werden", exc_info=True)
break
if not warned_max_duration and running_for > MAX_SESSION_SECONDS - MAX_SESSION_WARNING_S:
warned_max_duration = True
try:
await websocket.send_json({
"type": "warning",
"message": (
f"Diese Sitzung wird in ca. {MAX_SESSION_WARNING_S} Sekunden wegen der "
"maximalen Sitzungsdauer automatisch beendet."
),
})
except Exception:
logger.debug("Vorwarnung konnte nicht mehr gesendet werden", exc_info=True)
if msg is None:
continue
if msg.get("type") == "input": if msg.get("type") == "input":
raw = base64.b64decode(msg.get("data", "")) raw = base64.b64decode(msg.get("data", ""))
recorder.record("input", base64.b64encode(raw).decode()) recorder.record("input", base64.b64encode(raw).decode())
@ -228,7 +331,7 @@ async def ssh_terminal(websocket: WebSocket, host_id: int):
process.close() process.close()
if ssh_conn: if ssh_conn:
ssh_conn.close() ssh_conn.close()
recorder.close() await recorder.aclose()
logger.debug("SSH-Sitzung %s beendet: reason=%s", session_id, end_reason) logger.debug("SSH-Sitzung %s beendet: reason=%s", session_id, end_reason)
await conn.execute( await conn.execute(
"UPDATE sessions SET ended_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), end_reason = ? " "UPDATE sessions SET ended_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), end_reason = ? "

View File

@ -1,3 +1,4 @@
[pytest] [pytest]
asyncio_mode = auto asyncio_mode = auto
asyncio_default_fixture_loop_scope = function asyncio_default_fixture_loop_scope = function
norecursedirs = _to_delete .venv .git

View File

@ -0,0 +1,147 @@
#!/usr/bin/env python3
"""D.7-Pflichtcheck (Umsetzungsauftrag_Sonnet5.md, Teil D): "Vorher/Nachher-
Diff der effektiven Rechte je Benutzer" als Migrationspruefung.
Berechnet fuer jeden aktiven (nicht soft-geloeschten) Benutzer und jede
bekannte Rolle (dynamisch aus app.models.schemas.ROLE_NAME gelesen, damit
das Skript nicht bei jeder Rollenaenderung von Hand nachgezogen werden
muss) die Menge der Hostgruppen-IDs, fuer die die Rolle effektiv gilt --
ueber exakt dieselbe Logik wie die Anwendung selbst
(app.rbac.user_host_group_ids_with_any_role, die einzige Wahrheitsquelle
fuer RBAC-Aufloesung, siehe FORTSETZUNG_Teil_D.md Abschnitt 1, S13).
Zwei Betriebsarten:
1. Snapshot exportieren (vor UND nach einer Migration je einmal aufrufen):
<venv>/bin/python scripts/diff_effective_rights.py \\
--dump /pfad/zur.db --out vorher.json
2. Zwei bestehende SQLite-Dateien direkt gegeneinander diffen (z.B. eine
Kopie der Produktions-DB vor dem Wartungsfenster gegen die DB danach):
<venv>/bin/python scripts/diff_effective_rights.py \\
--before vorher.db --after nachher.db
Exit-Code 0: identische effektive Rechte (erwartetes Ergebnis fuer eine
rechteneutrale Migration wie D.4/D.6-Schritt-3). Exit-Code 1: mindestens
eine Abweichung gefunden -- Details werden ausgegeben, das Wartungsfenster
sollte dann NICHT fortgesetzt werden, ohne die Abweichung verstanden zu
haben.
Kann beliebig oft und gefahrlos gegen eine DB-Kopie aufgerufen werden --
rein lesend (oeffnet SQLite explizit read-only ueber die URI-Form
`file:...?mode=ro`), schreibt nichts.
"""
from __future__ import annotations
import argparse
import asyncio
import json
import sys
import typing
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import aiosqlite # noqa: E402
from app.models.schemas import ROLE_NAME # noqa: E402
from app.rbac import user_host_group_ids_with_any_role # noqa: E402
ROLES: tuple[str, ...] = typing.get_args(ROLE_NAME)
async def snapshot(db_path: Path) -> dict[str, dict]:
"""Liest den DB-Pfad read-only und liefert
{user_id_str: {"username": ..., "rights": {role_name: [host_group_id, ...]}}}
-- nur Rollen mit mindestens einer Hostgruppe werden aufgefuehrt, damit
der Diff bei Uebereinstimmung leer/kompakt bleibt."""
uri = f"file:{db_path}?mode=ro"
async with aiosqlite.connect(uri, uri=True) as conn:
cursor = await conn.execute(
"SELECT id, username FROM users WHERE deleted_at IS NULL ORDER BY id"
)
users = await cursor.fetchall()
result: dict[str, dict] = {}
for user_id, username in users:
rights: dict[str, list[int]] = {}
for role in ROLES:
host_group_ids = await user_host_group_ids_with_any_role(
conn, user_id=user_id, role_names=(role,)
)
if host_group_ids:
rights[role] = sorted(host_group_ids)
result[str(user_id)] = {"username": username, "rights": rights}
return result
def diff_snapshots(before: dict[str, dict], after: dict[str, dict]) -> list[str]:
"""Liefert eine Liste menschenlesbarer Abweichungszeilen; leere Liste ==
identische effektive Rechte."""
lines: list[str] = []
all_ids = sorted(set(before) | set(after), key=lambda x: int(x))
for uid in all_ids:
b = before.get(uid)
a = after.get(uid)
if b is None:
lines.append(f"user_id={uid} ({a['username']!r}): NEU nach der Migration (vorher nicht aktiv/vorhanden)")
continue
if a is None:
lines.append(f"user_id={uid} ({b['username']!r}): VERSCHWUNDEN nach der Migration (vorher aktiv)")
continue
b_rights, a_rights = b["rights"], a["rights"]
roles = sorted(set(b_rights) | set(a_rights))
for role in roles:
b_set = set(b_rights.get(role, []))
a_set = set(a_rights.get(role, []))
if b_set != a_set:
gained = sorted(a_set - b_set)
lost = sorted(b_set - a_set)
detail = []
if gained:
detail.append(f"NEU auf Hostgruppen {gained}")
if lost:
detail.append(f"VERLOREN auf Hostgruppen {lost}")
lines.append(
f"user_id={uid} ({b['username']!r}), Rolle={role!r}: "
+ "; ".join(detail)
)
return lines
async def main() -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--dump", type=Path, help="DB-Datei, deren Rechte-Snapshot exportiert werden soll")
parser.add_argument("--out", type=Path, help="Zieldatei fuer --dump (JSON)")
parser.add_argument("--before", type=Path, help="DB-Datei VOR der Migration")
parser.add_argument("--after", type=Path, help="DB-Datei NACH der Migration")
args = parser.parse_args()
if args.dump:
if not args.out:
parser.error("--dump erfordert --out")
data = await snapshot(args.dump)
args.out.write_text(json.dumps(data, indent=2, sort_keys=True, ensure_ascii=False), encoding="utf-8")
print(f"Snapshot von {args.dump} -> {args.out} geschrieben ({len(data)} aktive Benutzer).")
return 0
if args.before and args.after:
before = await snapshot(args.before)
after = await snapshot(args.after)
diffs = diff_snapshots(before, after)
if not diffs:
print(f"KEINE Abweichung: effektive Rechte fuer {len(before)} Benutzer identisch vor/nach der Migration.")
return 0
print(f"{len(diffs)} Abweichung(en) gefunden:")
for line in diffs:
print(f" - {line}")
return 1
parser.error("entweder --dump zusammen mit --out, oder --before zusammen mit --after angeben")
return 2
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))

View File

@ -0,0 +1,147 @@
#!/usr/bin/env python3
"""Pflicht-Vorab-Reports vor Teil D Schritt 4 (Umsetzungsauftrag_Sonnet5.md,
D.6 Schritt 4 und D.7-Risikotabelle: "Zugriffsausfall durch Achse B" und
"Verhaltensaenderung bei der Credential-Auswahl").
Zwei unabhaengige Reports gegen eine SQLite-Datei (Produktions-DB-Kopie oder
die aktuelle Dev-DB):
1. "Hosts mit Zugangsdaten ohne Gruppenfreigabe" -- ein Host hat einen SSH-
Schluessel, ein SSH-Passwort oder einen RDP-Zugangsdatensatz zugeordnet
(host_*_map), aber KEINE Benutzergruppe hat diesen Datensatz ueber Achse
B (group_*_grants, Migration 0018) freigegeben. Nach Schritt 4 kann sich
niemand mehr darueber anmelden -- das MUSS vor Schritt 4 bereinigt sein
(z.B. durch eine nachtraegliche manuelle Achse-B-Freigabe), sonst ist der
Host fuer alle Benutzer unerreichbar, ungeachtet ihrer ssh_connect/
rdp_connect-Rolle.
2. "Hosts mit mehrdeutigen SSH-Keys" -- mehr als ein Eintrag in
host_ssh_key_map fuer denselben Host. Strukturell nur bei SSH-Keys
moeglich (n:m-Zuordnung); RDP- und SSH-Passwort-Zuordnung sind laut
Schema 1:1 (PK auf host_id). Ab Schritt 4 wirft
app.rbac.resolve_credential_for_user_on_host() einen harten
AmbiguousCredentialError, sobald ein Benutzer ueber mehr als eine seiner
Gruppen Zugriff auf mehr als einen der zugeordneten Schluessel hat --
dieser Report zeigt, wo das ueberhaupt strukturell moeglich ist, damit
vorab geklaert werden kann, ob das gewollt ist (z.B. Umbenennen/
Entfernen des ueberzaehligen Schluessels).
Aufruf:
<venv>/bin/python scripts/pre_schritt4_checks.py --db /pfad/zur.db
Exit-Code 0: beide Reports leer (unbedenklich, Schritt 4 kann bedenkenlos
scharf geschaltet werden). Exit-Code 1: mindestens ein Fund in einem der
beiden Reports -- Details werden ausgegeben.
Rein lesend (SQLite read-only via `file:...?mode=ro`), schreibt nichts.
"""
from __future__ import annotations
import argparse
import asyncio
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import aiosqlite # noqa: E402
# (kind, host_map_table, host_map_credential_col, grant_table, grant_credential_col)
_CHECKS = [
("ssh_key", "host_ssh_key_map", "ssh_key_id", "group_ssh_key_grants", "ssh_key_id"),
(
"rdp_credential",
"host_rdp_credential_map",
"rdp_credential_id",
"group_rdp_credential_grants",
"rdp_credential_id",
),
(
"ssh_password_credential",
"host_ssh_password_credential_map",
"ssh_password_credential_id",
"group_ssh_password_credential_grants",
"ssh_password_credential_id",
),
]
async def report_orphaned_credential_mappings(conn: aiosqlite.Connection) -> list[str]:
"""Report 1: host_*_map-Zeilen, deren Credential-ID in keiner Zeile der
zugehoerigen group_*_grants-Tabelle vorkommt (unabhaengig von expires_at
-- ein abgelaufener Grant zaehlt hier bewusst NICHT als Deckung)."""
lines: list[str] = []
for kind, map_table, map_col, grant_table, grant_col in _CHECKS:
cursor = await conn.execute(
f"""
SELECT m.host_id, h.hostname, m.{map_col}
FROM {map_table} m
JOIN hosts h ON h.id = m.host_id
WHERE NOT EXISTS (
SELECT 1 FROM {grant_table} g
WHERE g.{grant_col} = m.{map_col}
AND (g.expires_at IS NULL OR g.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now'))
)
ORDER BY m.host_id
"""
)
rows = await cursor.fetchall()
for host_id, hostname, credential_id in rows:
lines.append(
f"kind={kind}: Host {host_id} ({hostname!r}) hat {kind}={credential_id} "
"zugeordnet, aber KEINE Gruppe hat ihn ueber Achse B freigegeben."
)
return lines
async def report_ambiguous_ssh_keys(conn: aiosqlite.Connection) -> list[str]:
"""Report 2: Hosts mit mehr als einem Eintrag in host_ssh_key_map."""
cursor = await conn.execute(
"""
SELECT m.host_id, h.hostname, COUNT(*) AS n, GROUP_CONCAT(m.ssh_key_id)
FROM host_ssh_key_map m
JOIN hosts h ON h.id = m.host_id
GROUP BY m.host_id
HAVING COUNT(*) > 1
ORDER BY m.host_id
"""
)
rows = await cursor.fetchall()
return [
f"Host {host_id} ({hostname!r}): {n} SSH-Keys zugeordnet (ssh_key_ids={key_ids}) "
"-- Mehrdeutigkeit strukturell moeglich, sobald mehr als einer davon derselben "
"Benutzergruppe freigegeben wird."
for host_id, hostname, n, key_ids in rows
]
async def main() -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--db", type=Path, required=True, help="Pfad zur zu pruefenden SQLite-Datei")
args = parser.parse_args()
uri = f"file:{args.db}?mode=ro"
async with aiosqlite.connect(uri, uri=True) as conn:
orphaned = await report_orphaned_credential_mappings(conn)
ambiguous = await report_ambiguous_ssh_keys(conn)
print("=== Report 1: Hosts mit Zugangsdaten ohne Gruppenfreigabe ===")
if not orphaned:
print(" Keine Funde.")
else:
for line in orphaned:
print(f" - {line}")
print()
print("=== Report 2: Hosts mit mehrdeutigen SSH-Keys ===")
if not ambiguous:
print(" Keine Funde.")
else:
for line in ambiguous:
print(f" - {line}")
return 1 if (orphaned or ambiguous) else 0
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))

View File

@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""Einmaliger Datenmigrationsschritt fuer TEIL C, Phase 1 des
Umsetzungsauftrag_Sonnet5.md ("Rollenmodell bereinigen").
Betreiberentscheidung (Phase 0.2): jeder Benutzer mit einer Zeile in
tenant_admins wird zu is_admin=1 befoerdert (Voll-Admin), STATT zum
normalen Benutzer degradiert zu werden. Muss AUSGEFUEHRT WERDEN, BEVOR die
Phase-2-Codeaenderungen (TenantScope-Entfernung) deployt werden -- siehe
C.4: wird TenantScope entfernt, ohne vorher jeden tenant_admins-Eintrag
explizit auf is_admin=1 zu heben, waere die Rechteausweitung fuer diese
Benutzer zwar am Ende dieselbe, aber implizit und ohne Audit-Spur zum
Zeitpunkt der eigentlichen Entscheidung. Nach diesem Schritt verhaelt sich
die Anwendung noch exakt wie vorher (Phase 1 ist bewusst noch KEINE
Codeaenderung) -- ein bereits is_admin=1 gesetzter Benutzer sieht/kann
nichts anderes als vorher, ein bisheriger reiner Mandanten-Admin sieht ab
sofort (noch ueber den unveraenderten is_any_admin-Pfad) alle Mandanten
statt nur seiner/seine eigenen. Das ist die in Phase 0.2 getroffene,
gewollte Entscheidung -- keine Nebenwirkung.
Idempotent: bereits befoerderte oder inzwischen geloeschte/deaktivierte
Benutzer werden uebersprungen; mehrfacher Aufruf ist gefahrlos.
Aufruf (vor dem Wartungsfenster aus Phase 2, mit noch unveraendertem Code):
<venv>/bin/python scripts/promote_tenant_admins.py [--dry-run]
"""
from __future__ import annotations
import argparse
import asyncio
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from app.db import close_db, get_db, init_db # noqa: E402
from app.security.audit import write_audit_event # noqa: E402
async def main(dry_run: bool) -> None:
await init_db()
conn = get_db()
try:
cursor = await conn.execute(
"SELECT DISTINCT ta.user_id, u.username, u.is_admin "
"FROM tenant_admins ta JOIN users u ON u.id = ta.user_id "
"WHERE u.deleted_at IS NULL "
"ORDER BY u.username"
)
rows = await cursor.fetchall()
if not rows:
print("Keine tenant_admins-Eintraege vorhanden -- nichts zu tun.")
return
to_promote = [(uid, uname) for uid, uname, is_admin in rows if not is_admin]
already_admin = [uname for _uid, uname, is_admin in rows if is_admin]
print(f"Gefunden: {len(rows)} Benutzer mit mind. einer tenant_admins-Zeile.")
if already_admin:
print(f" Bereits is_admin=1 (unveraendert): {', '.join(already_admin)}")
if not to_promote:
print("Alle betroffenen Benutzer sind bereits is_admin=1 -- nichts zu tun.")
return
print(f" Wird auf is_admin=1 befoerdert: {', '.join(u for _i, u in to_promote)}")
if dry_run:
print("--dry-run: keine Aenderung vorgenommen.")
return
for user_id, username in to_promote:
await conn.execute("UPDATE users SET is_admin = 1 WHERE id = ?", (user_id,))
await write_audit_event(
conn,
event_type="tenant_admin_promoted_to_admin",
user_id=user_id,
client_ip=None,
details={
"reason": (
"Umsetzungsauftrag_Sonnet5.md Teil C, Phase 0.2/1: "
"Rueckbau der Mandantenfaehigkeit -- bestehende "
"tenant_admins-Zuweisung wird zu vollem is_admin=1 "
"befoerdert (Betreiberentscheidung, keine Degradierung)."
),
"username": username,
},
)
await conn.commit()
print(f"{len(to_promote)} Benutzer befoerdert, je ein Audit-Event geschrieben.")
finally:
await close_db()
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--dry-run", action="store_true", help="Nur anzeigen, was passieren wuerde, nichts schreiben."
)
args = parser.parse_args()
asyncio.run(main(args.dry_run))

View File

@ -53,6 +53,8 @@ button {
cursor: pointer; cursor: pointer;
} }
button:hover { filter: brightness(1.1); } button:hover { filter: brightness(1.1); }
button:disabled { opacity: 0.5; cursor: not-allowed; }
button:disabled:hover { filter: none; }
.error { color: var(--danger); font-size: 0.85rem; margin-top: 0.75rem; min-height: 1em; } .error { color: var(--danger); font-size: 0.85rem; margin-top: 0.75rem; min-height: 1em; }
.hint { color: var(--muted); font-size: 0.8rem; margin-top: 0.5rem; } .hint { color: var(--muted); font-size: 0.8rem; margin-top: 0.5rem; }
.qr { display: block; margin: 1rem auto; border-radius: 6px; } .qr { display: block; margin: 1rem auto; border-radius: 6px; }
@ -187,16 +189,90 @@ pre.log-view {
.endpoint-row .summary { color: var(--muted); font-size: 0.8rem; margin-left: auto; } .endpoint-row .summary { color: var(--muted); font-size: 0.8rem; margin-left: auto; }
.endpoint-detail { padding: 0 0 0.75rem 4.1rem; } .endpoint-detail { padding: 0 0 0.75rem 4.1rem; }
.session-shell { display: flex; flex-direction: column; height: 100vh; } /* Aufgabe #11: Anker fuer .term-paste-menu weiter unten (position:absolute
dagegen, statt gegen den ganzen Viewport) -- rein CSS-basierte
Positionierung, siehe terminal.js::_showPasteMenu(). */
.session-shell { display: flex; flex-direction: column; height: 100vh; position: relative; }
.session-toolbar { .session-toolbar {
display: flex; align-items: center; gap: 0.75rem; padding: 0.5rem 0.9rem; display: flex; align-items: center; gap: 0.75rem; padding: 0.5rem 0.9rem;
background: var(--panel); border-bottom: 1px solid var(--border); background: var(--panel); border-bottom: 1px solid var(--border);
} }
.session-toolbar button { width: auto; margin: 0; padding: 0.35rem 0.8rem; font-size: 0.8rem; } .session-toolbar button { width: auto; margin: 0; padding: 0.35rem 0.8rem; font-size: 0.8rem; }
.session-toolbar select { width: auto; margin: 0; padding: 0.3rem 0.5rem; font-size: 0.8rem; }
.session-toolbar .spacer { flex: 1; } .session-toolbar .spacer { flex: 1; }
.session-toolbar .status { font-size: 0.8rem; color: var(--muted); } .session-toolbar .status { font-size: 0.8rem; color: var(--muted); }
#terminal, #rdp-display { flex: 1; background: black; }
#rdp-display canvas { display: block; margin: 0 auto; } /* Aufgabe #11 (SSH Copy-Paste): Ruecksicherungs-Kontextmenue fuer Einfuegen
per Rechtsklick, nur wenn der direkte Clipboard-Zugriff ohne explizite
Benutzergeste verweigert wurde (terminal.js::_attemptDirectPaste()). Vier
feste Ecken statt Cursor-Koordinaten -- CSP (style-src 'self') verbietet
JS-gesetzte element.style-Werte, siehe Kommentar dort. */
.term-paste-menu {
position: absolute; z-index: 50; background: var(--panel); border: 1px solid var(--border);
border-radius: 6px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); padding: 0.25rem; min-width: 140px;
}
.term-paste-menu-top { top: 3.2rem; }
.term-paste-menu-bottom { bottom: 0.5rem; }
.term-paste-menu-left { left: 0.5rem; }
.term-paste-menu-right { right: 0.5rem; }
.term-paste-menu button {
width: 100%; margin: 0; padding: 0.4rem 0.7rem; text-align: left; background: transparent;
color: var(--text); font-weight: 400; font-size: 0.85rem; border-radius: 4px;
}
.term-paste-menu button:hover { background: var(--border); filter: none; }
.session-toolbar .status.warning { color: #f5b942; }
/* F1 (Umsetzungsauftrag_Sonnet5.md Teil F.3.1): vormals #terminal/#rdp-display
(feste, dokumentweit eindeutige IDs -- eine Seite hatte immer genau eine
Sitzung). Jetzt Klassen, weil static/js/terminal.js bzw. rdp.js ihr DOM
dynamisch je Instanz bauen (SshTerminalSession/RdpSession) -- mit
mehreren gleichzeitigen Instanzen (kuenftige Arbeitsflaeche /workspace,
Teil F.3, spaetere Stufe) waeren feste IDs nicht mehr eindeutig. */
.session-terminal, .session-rdp-display { flex: 1; background: black; }
.session-rdp-display canvas { display: block; margin: 0 auto; }
/* B.1 (Umsetzungsauftrag Teil B): Fehler-Overlay ueber .session-rdp-display,
statt einer 0,85rem-Randnotiz in der Toolbar auf sonst komplett schwarzem
Grund (siehe .error weiter oben, .status.error unten). */
.session-rdp-display { position: relative; }
.rdp-error-overlay {
position: absolute;
inset: 0;
z-index: 20;
display: flex;
align-items: center;
justify-content: center;
background: rgba(15, 17, 21, 0.92);
padding: 1rem;
}
.rdp-error-panel {
background: var(--panel);
border: 1px solid var(--danger);
border-radius: 10px;
padding: 1.75rem 2rem;
width: 100%;
max-width: 480px;
}
.rdp-error-panel h2 {
margin: 0 0 0.75rem;
font-size: 1.15rem;
color: var(--danger);
}
.rdp-error-message {
margin: 0 0 1rem;
font-size: 0.95rem;
line-height: 1.4;
word-break: break-word;
}
.rdp-error-meta {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.25rem 0.75rem;
margin: 0 0 1.25rem;
font-size: 0.8rem;
}
.rdp-error-meta dt { color: var(--muted); }
.rdp-error-meta dd { margin: 0; font-family: monospace; word-break: break-all; }
.rdp-error-panel button { margin-top: 0; }
/* --- Dateitransfer-Panel (Terminal-Sitzung) ---------------------------- */ /* --- Dateitransfer-Panel (Terminal-Sitzung) ---------------------------- */
@ -242,3 +318,67 @@ pre.log-view {
.rec-controls input[type=range] { flex: 1; } .rec-controls input[type=range] { flex: 1; }
.rec-controls select { width: auto; padding: 0.3rem 0.4rem; } .rec-controls select { width: auto; padding: 0.3rem 0.4rem; }
.rec-controls .hint { margin: 0; white-space: nowrap; } .rec-controls .hint { margin: 0; white-space: nowrap; }
/* --- Arbeitsflaeche (F3, Umsetzungsauftrag_Sonnet5.md Teil F.3) --------- */
.workspace-layout { display: flex; height: calc(100vh - 53px); /* 53px ~ .topbar */ }
.workspace-sidebar {
width: 280px; flex-shrink: 0; background: var(--panel); border-right: 1px solid var(--border);
display: flex; flex-direction: column; overflow-y: auto;
}
.workspace-sidebar-header { padding: 1rem 1rem 0.5rem; }
.workspace-sidebar-header h2 {
font-size: 0.9rem; margin: 0 0 0.4rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted);
}
.workspace-sidebar-header .hint { margin: 0; font-size: 0.75rem; }
/* F5 (F.3.8): Obergrenzen-Anzeige -- normal wie jeder andere Hinweistext,
nur bei erreichter Grenze in Warnfarbe (dann ist auch "+ Neue Sitzung"
deaktiviert, siehe workspace.js renderLimitHint()). */
.workspace-limit-hint { margin-top: 0.3rem; }
.workspace-limit-hint--at-limit { color: var(--danger); }
/* F5: Sammelzustand-Kennzeichnung -- fasst Hintergrundsitzungen mit Fehler
oder Trennung zusammen, damit man sie nicht erst durch Umschalten auf
jede einzelne Kachel entdeckt. */
.workspace-attention-summary {
margin: 0 0.5rem 0.5rem; padding: 0.4rem 0.6rem; border-radius: 6px;
background: rgba(239, 68, 68, 0.15); border: 1px solid var(--danger);
color: var(--danger); font-size: 0.75rem;
}
.workspace-tiles { flex: 1; padding: 0.5rem; display: flex; flex-direction: column; gap: 0.5rem; }
.workspace-empty-hint { padding: 0.5rem; }
.workspace-tile {
background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 0.65rem 0.75rem;
}
.workspace-tile-active { border-color: var(--accent); }
/* F4: eine offene, aber gerade nicht sichtbare Sitzung -- anklickbar
(Umschalten), optisch etwas zurueckhaltender als die aktive Kachel. */
.workspace-tile-background { cursor: pointer; }
.workspace-tile-background:hover { border-color: var(--muted); }
/* F5: eine Hintergrundsitzung mit Fehler/Trennung -- sichtbar markiert,
ohne dass man erst umschalten muss (siehe workspace.js hasAttention()). */
.workspace-tile-attention { border-color: var(--danger); box-shadow: 0 0 0 1px var(--danger); }
.workspace-tile-remote { opacity: 0.85; }
.workspace-tile-top { display: flex; align-items: center; gap: 0.4rem; }
.workspace-tile-hostname { font-weight: 600; font-size: 0.85rem; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.workspace-status-dot {
width: 0.55rem; height: 0.55rem; border-radius: 50%; flex-shrink: 0; background: var(--muted);
}
.workspace-status-dot.connecting { background: #f5b942; }
.workspace-status-dot.connected { background: var(--ok); }
.workspace-status-dot.error { background: var(--danger); }
.workspace-status-dot.disconnected { background: var(--muted); }
.workspace-tile-meta {
display: flex; justify-content: space-between; gap: 0.5rem; color: var(--muted); font-size: 0.75rem;
margin: 0.3rem 0 0.5rem;
}
.workspace-tile-close-btn { width: 100%; margin: 0; }
.workspace-new-btn { margin: 0.5rem; width: calc(100% - 1rem); }
.workspace-main { flex: 1; overflow: auto; background: var(--bg); position: relative; height: 100%; }
.workspace-catalog-view, .workspace-sessions-area { height: 100%; }
/* F4: mehrere Sitzungs-Elemente liegen gleichzeitig in
#workspace-sessions-area -- jedes einzelne wird ueber die globale
.hidden-Klasse (siehe oben) ein-/ausgeblendet (auf dem Element selbst,
von workspace.js gesetzt), nicht der gemeinsame Container. */
.workspace-sessions-area .session-instance { height: 100%; }
.workspace-sessions-area .session-instance .session-shell { height: 100%; }
.workspace-catalog { padding-top: 1.5rem; }

View File

@ -1,13 +1,14 @@
(() => { (() => {
"use strict"; "use strict";
const ROLE_NAMES = [ // Wird beim ersten Aufruf von loadRolesTab() aus GET /admin/roles/names
"ssh_connect", "rdp_connect", "file_transfer", "clipboard", // gefuellt (Teil D.6 Schritt 1: keine Hartkodierung mehr, siehe
"session_recording_view", "admin_hostgroup", "credentials_view", "credentials_manage", // Umsetzungsauftrag_Sonnet5.md D.5 -- die Rollenliste soll aus der DB
]; // kommen, nicht aus dem Frontend-Code dupliziert werden).
let ROLE_NAMES = [];
const bannerBox = document.getElementById("banner-box"); const bannerBox = document.getElementById("banner-box");
let meInfo = { is_admin: false, tenant_admin_of: [] }; let meInfo = { is_admin: false };
function showBanner(message, type) { function showBanner(message, type) {
const div = document.createElement("div"); const div = document.createElement("div");
@ -111,7 +112,6 @@
credentials: loadCredentialsTab, credentials: loadCredentialsTab,
roles: loadRolesTab, roles: loadRolesTab,
tokens: loadTokensTab, tokens: loadTokensTab,
tenants: loadTenantsTab,
sessions: loadSessionsTab, sessions: loadSessionsTab,
connlog: loadConnLogTab, connlog: loadConnLogTab,
audit: loadAuditTab, audit: loadAuditTab,
@ -139,28 +139,6 @@
} }
}); });
// ---------------------------------------------------------------------
// Mandanten-Auswahlfelder (fuer Super-Admins Dropdown aller Mandanten,
// fuer Mandanten-Admins nur die eigenen -- kein Server-Request noetig,
// steht bereits in meInfo.tenant_admin_of).
// ---------------------------------------------------------------------
let cachedTenants = [];
async function populateTenantSelect(select) {
if (meInfo.is_admin) {
if (cachedTenants.length === 0) {
const result = await getJson("/admin/tenants");
cachedTenants = result;
}
select.replaceChildren(...optionsFromList(cachedTenants, "id", (t) => `${t.name} (#${t.id})`));
} else {
select.replaceChildren(
...optionsFromList(meInfo.tenant_admin_of, "id", (t) => `${t.name} (#${t.id})`)
);
}
}
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
// Benutzer // Benutzer
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
@ -173,16 +151,7 @@
let cachedUsers = []; let cachedUsers = [];
let editingUserId = null; let editingUserId = null;
function tenantName(tenantId) {
if (tenantId === null || tenantId === undefined) return "-";
const t = cachedTenants.find((x) => x.id === tenantId);
return t ? t.name : `#${tenantId}`;
}
async function refreshUsers() { async function refreshUsers() {
if (meInfo.is_admin && cachedTenants.length === 0) {
cachedTenants = await getJson("/admin/tenants");
}
cachedUsers = await getJson("/admin/users"); cachedUsers = await getJson("/admin/users");
const tbody = document.querySelector("#users-table tbody"); const tbody = document.querySelector("#users-table tbody");
fillTable( fillTable(
@ -217,7 +186,6 @@
el("td", { textContent: u.is_admin ? "ja" : "nein" }), el("td", { textContent: u.is_admin ? "ja" : "nein" }),
el("td", { textContent: u.is_active ? "ja" : "nein" }), el("td", { textContent: u.is_active ? "ja" : "nein" }),
el("td", { textContent: u.totp_enrolled ? "ja" : "nein" }), el("td", { textContent: u.totp_enrolled ? "ja" : "nein" }),
el("td", { textContent: tenantName(u.home_tenant_id) }),
el("td", { textContent: u.created_at }), el("td", { textContent: u.created_at }),
actions, actions,
]); ]);
@ -277,8 +245,6 @@
let editingGroupId = null; let editingGroupId = null;
async function loadGroupsTab() { async function loadGroupsTab() {
document.getElementById("gc-tenant-box").classList.toggle("hidden", !meInfo.is_admin && meInfo.tenant_admin_of.length <= 1);
await populateTenantSelect(document.getElementById("gc-tenant"));
await refreshGroups(); await refreshGroups();
} }
@ -292,7 +258,6 @@
el("td", { textContent: String(g.id) }), el("td", { textContent: String(g.id) }),
el("td", { textContent: g.name }), el("td", { textContent: g.name }),
el("td", { textContent: g.description || "" }), el("td", { textContent: g.description || "" }),
el("td", { textContent: g.tenant_name }),
el("td", { textContent: String(g.member_count) }), el("td", { textContent: String(g.member_count) }),
el("td", {}, [ el("td", {}, [
actionButton("Mitglieder", "btn-secondary", () => showGroupMembers(g)), actionButton("Mitglieder", "btn-secondary", () => showGroupMembers(g)),
@ -367,9 +332,7 @@
try { try {
const name = document.getElementById("gc-name").value.trim(); const name = document.getElementById("gc-name").value.trim();
const description = document.getElementById("gc-description").value.trim() || null; const description = document.getElementById("gc-description").value.trim() || null;
const tenantSelect = document.getElementById("gc-tenant"); await sendJson("/admin/user-groups", "POST", { name, description });
const tenant_id = tenantSelect.value ? Number(tenantSelect.value) : null;
await sendJson("/admin/user-groups", "POST", { name, description, tenant_id });
showBanner(`Benutzergruppe '${name}' angelegt.`, "ok"); showBanner(`Benutzergruppe '${name}' angelegt.`, "ok");
ev.target.reset(); ev.target.reset();
await refreshGroups(); await refreshGroups();
@ -400,12 +363,11 @@
let cachedHosts = []; let cachedHosts = [];
let cachedSshKeys = []; let cachedSshKeys = [];
let cachedRdpCredentials = []; let cachedRdpCredentials = [];
let cachedSshPasswordCredentials = [];
let activeHostId = null; let activeHostId = null;
let editingHostGroupId = null; let editingHostGroupId = null;
async function loadHostsTab() { async function loadHostsTab() {
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(), refreshRdpCredentialsCache()]); await Promise.all([refreshHostGroups(), refreshSshKeysCache(), refreshRdpCredentialsCache()]);
await refreshHosts(); await refreshHosts();
} }
@ -420,7 +382,6 @@
el("td", { textContent: String(g.id) }), el("td", { textContent: String(g.id) }),
el("td", { textContent: g.name }), el("td", { textContent: g.name }),
el("td", { textContent: g.description || "" }), el("td", { textContent: g.description || "" }),
el("td", { textContent: g.tenant_name }),
el("td", {}, [ el("td", {}, [
actionButton("Bearbeiten", "btn-secondary", () => showHostGroupEdit(g)), actionButton("Bearbeiten", "btn-secondary", () => showHostGroupEdit(g)),
actionButton("Loeschen", "btn-danger", async () => { actionButton("Loeschen", "btn-danger", async () => {
@ -472,7 +433,6 @@
el("td", { textContent: h.protocol }), el("td", { textContent: h.protocol }),
el("td", { textContent: h.os_type }), el("td", { textContent: h.os_type }),
el("td", { textContent: groupName(h.host_group_id) }), el("td", { textContent: groupName(h.host_group_id) }),
el("td", { textContent: h.tenant_name }),
el("td", {}, [el("span", { className: `badge ${h.is_active ? "ok" : "danger"}`, textContent: h.is_active ? "aktiv" : "inaktiv" })]), el("td", {}, [el("span", { className: `badge ${h.is_active ? "ok" : "danger"}`, textContent: h.is_active ? "aktiv" : "inaktiv" })]),
el("td", {}, [ el("td", {}, [
actionButton("Details", "btn-secondary", () => showHostDetail(h.id)), actionButton("Details", "btn-secondary", () => showHostDetail(h.id)),
@ -501,6 +461,10 @@
cachedRdpCredentials = await getJson("/admin/rdp-credentials"); cachedRdpCredentials = await getJson("/admin/rdp-credentials");
} }
async function refreshSshPasswordCredentialsCache() {
cachedSshPasswordCredentials = await getJson("/admin/ssh-password-credentials");
}
function setDetailBanner(message, type) { function setDetailBanner(message, type) {
const box = document.getElementById("host-detail-banner"); const box = document.getElementById("host-detail-banner");
if (!message) { if (!message) {
@ -540,7 +504,7 @@
const host = await getJson(`/admin/hosts/${hostId}`); const host = await getJson(`/admin/hosts/${hostId}`);
activeHostId = host.id; activeHostId = host.id;
document.getElementById("host-detail-panel").classList.remove("hidden"); document.getElementById("host-detail-panel").classList.remove("hidden");
document.getElementById("host-detail-name").textContent = `${host.hostname} (#${host.id}, ${host.tenant_name})`; document.getElementById("host-detail-name").textContent = `${host.hostname} (#${host.id})`;
document.getElementById("he-hostname").value = host.hostname; document.getElementById("he-hostname").value = host.hostname;
document.getElementById("he-address").value = host.address; document.getElementById("he-address").value = host.address;
@ -640,9 +604,7 @@
try { try {
const name = document.getElementById("hgc-name").value.trim(); const name = document.getElementById("hgc-name").value.trim();
const description = document.getElementById("hgc-description").value.trim() || null; const description = document.getElementById("hgc-description").value.trim() || null;
const tenantSelect = document.getElementById("hgc-tenant"); await sendJson("/admin/host-groups", "POST", { name, description });
const tenant_id = tenantSelect.value ? Number(tenantSelect.value) : null;
await sendJson("/admin/host-groups", "POST", { name, description, tenant_id });
showBanner(`Hostgruppe '${name}' angelegt.`, "ok"); showBanner(`Hostgruppe '${name}' angelegt.`, "ok");
ev.target.reset(); ev.target.reset();
await refreshHostGroups(); await refreshHostGroups();
@ -760,12 +722,6 @@
let editingSshKeyId = null; let editingSshKeyId = null;
async function loadCredentialsTab() { async function loadCredentialsTab() {
document.getElementById("skc-tenant-box").classList.toggle("hidden", !meInfo.is_admin && meInfo.tenant_admin_of.length <= 1);
document.getElementById("rcc-tenant-box").classList.toggle("hidden", !meInfo.is_admin && meInfo.tenant_admin_of.length <= 1);
await Promise.all([
populateTenantSelect(document.getElementById("skc-tenant")),
populateTenantSelect(document.getElementById("rcc-tenant")),
]);
await Promise.all([refreshSshKeys(), refreshRdpCredentials()]); await Promise.all([refreshSshKeys(), refreshRdpCredentials()]);
} }
@ -780,7 +736,6 @@
el("td", { textContent: k.label }), el("td", { textContent: k.label }),
el("td", { textContent: k.username || "-" }), el("td", { textContent: k.username || "-" }),
el("td", { textContent: k.key_type }), el("td", { textContent: k.key_type }),
el("td", { textContent: k.tenant_name }),
el("td", { textContent: k.has_passphrase ? "hinterlegt" : "-" }), el("td", { textContent: k.has_passphrase ? "hinterlegt" : "-" }),
el("td", { textContent: k.created_at }), el("td", { textContent: k.created_at }),
el("td", { textContent: k.rotated_at || "-" }), el("td", { textContent: k.rotated_at || "-" }),
@ -899,7 +854,6 @@
document.getElementById("ssh-key-create-form").addEventListener("submit", async (ev) => { document.getElementById("ssh-key-create-form").addEventListener("submit", async (ev) => {
ev.preventDefault(); ev.preventDefault();
try { try {
const tenantSelect = document.getElementById("skc-tenant");
const payload = { const payload = {
label: document.getElementById("skc-label").value.trim(), label: document.getElementById("skc-label").value.trim(),
username: document.getElementById("skc-username").value.trim() || null, username: document.getElementById("skc-username").value.trim() || null,
@ -908,7 +862,6 @@
public_key: document.getElementById("skc-public").value, public_key: document.getElementById("skc-public").value,
// Leeres Feld -> null: der Schluessel gilt dann als unverschluesselt. // Leeres Feld -> null: der Schluessel gilt dann als unverschluesselt.
passphrase: document.getElementById("skc-passphrase").value || null, passphrase: document.getElementById("skc-passphrase").value || null,
tenant_id: tenantSelect.value ? Number(tenantSelect.value) : null,
}; };
await sendJson("/admin/ssh-keys", "POST", payload); await sendJson("/admin/ssh-keys", "POST", payload);
showBanner(`SSH-Key '${payload.label}' angelegt.`, "ok"); showBanner(`SSH-Key '${payload.label}' angelegt.`, "ok");
@ -932,7 +885,6 @@
el("td", { textContent: r.label }), el("td", { textContent: r.label }),
el("td", { textContent: r.username || "-" }), el("td", { textContent: r.username || "-" }),
el("td", { textContent: r.domain || "-" }), el("td", { textContent: r.domain || "-" }),
el("td", { textContent: r.tenant_name }),
el("td", { el("td", {
textContent: r.assigned_hosts.length textContent: r.assigned_hosts.length
? r.assigned_hosts.map((h) => h.hostname).join(", ") ? r.assigned_hosts.map((h) => h.hostname).join(", ")
@ -998,13 +950,11 @@
document.getElementById("rdp-cred-create-form").addEventListener("submit", async (ev) => { document.getElementById("rdp-cred-create-form").addEventListener("submit", async (ev) => {
ev.preventDefault(); ev.preventDefault();
try { try {
const tenantSelect = document.getElementById("rcc-tenant");
const payload = { const payload = {
label: document.getElementById("rcc-label").value.trim(), label: document.getElementById("rcc-label").value.trim(),
username: document.getElementById("rcc-username").value.trim(), username: document.getElementById("rcc-username").value.trim(),
domain: document.getElementById("rcc-domain").value.trim() || null, domain: document.getElementById("rcc-domain").value.trim() || null,
password: document.getElementById("rcc-password").value, password: document.getElementById("rcc-password").value,
tenant_id: tenantSelect.value ? Number(tenantSelect.value) : null,
}; };
await sendJson("/admin/rdp-credentials", "POST", payload); await sendJson("/admin/rdp-credentials", "POST", payload);
showBanner(`RDP-Zugangsdaten '${payload.label}' angelegt.`, "ok"); showBanner(`RDP-Zugangsdaten '${payload.label}' angelegt.`, "ok");
@ -1020,13 +970,21 @@
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
async function loadRolesTab() { async function loadRolesTab() {
roleCheckboxGrid(document.getElementById("rg-role-grid")); if (ROLE_NAMES.length === 0) {
ROLE_NAMES = await getJson("/admin/roles/names");
}
roleCheckboxGrid(document.getElementById("grg-role-grid")); roleCheckboxGrid(document.getElementById("grg-role-grid"));
await refreshSharedSelects(); await refreshSharedSelects();
await Promise.all([refreshRoleGrants(), refreshGroupRoleGrants()]); await Promise.all([refreshSshKeysCache(), refreshRdpCredentialsCache(), refreshSshPasswordCredentialsCache()]);
populateCredentialSelect();
await Promise.all([refreshRoleGrants(), refreshGroupRoleGrants(), refreshGroupCredentialGrants()]);
} }
async function refreshRoleGrants() { async function refreshRoleGrants() {
// Teil D Schritt 5: abgeleitete Sicht (GET /admin/roles liefert seither
// effektive Rechte statt einer Vergabe-Tabelle) -- nicht editierbar,
// daher keine Aktionen-Spalte mehr. Aendern laeuft ausschliesslich ueber
// die Gruppenvergabe (refreshGroupRoleGrants() unten).
const grants = await getJson("/admin/roles"); const grants = await getJson("/admin/roles");
const tbody = document.querySelector("#role-grants-table tbody"); const tbody = document.querySelector("#role-grants-table tbody");
fillTable( fillTable(
@ -1036,15 +994,8 @@
el("td", { textContent: g.username }), el("td", { textContent: g.username }),
el("td", { textContent: g.host_group_name }), el("td", { textContent: g.host_group_name }),
el("td", { textContent: g.role_name }), el("td", { textContent: g.role_name }),
el("td", { textContent: g.via_group_name }),
el("td", { textContent: g.expires_at || "-" }), el("td", { textContent: g.expires_at || "-" }),
el("td", {}, [
actionButton("Entziehen", "btn-danger", async () => {
await sendJson("/admin/roles/revoke", "POST", {
user_id: g.user_id, host_group_id: g.host_group_id, role_name: g.role_name,
});
await refreshRoleGrants();
}),
]),
]) ])
) )
); );
@ -1074,25 +1025,6 @@
); );
} }
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_names,
expires_at: document.getElementById("rg-expires").value.trim() || null,
});
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");
}
});
document.getElementById("group-role-grant-form").addEventListener("submit", async (ev) => { document.getElementById("group-role-grant-form").addEventListener("submit", async (ev) => {
ev.preventDefault(); ev.preventDefault();
try { try {
@ -1112,6 +1044,84 @@
} }
}); });
// ---------------------------------------------------------------------
// Zugangsdaten-Freigabe an Benutzergruppen (Achse B, Teil D.6 Schritt 5)
// ---------------------------------------------------------------------
const CREDENTIAL_KIND_LABELS = {
ssh_key: "SSH-Schluessel",
rdp_credential: "RDP-Zugangsdaten",
ssh_password_credential: "SSH-Passwort",
};
function credentialListForKind(kind) {
if (kind === "ssh_key") return cachedSshKeys;
if (kind === "rdp_credential") return cachedRdpCredentials;
return cachedSshPasswordCredentials;
}
function credentialLabel(kind, credentialId) {
const item = credentialListForKind(kind).find((c) => c.id === credentialId);
return item ? `${item.label} (#${item.id})` : `#${credentialId}`;
}
function populateCredentialSelect() {
const kind = document.getElementById("gcg-kind").value;
const select = document.getElementById("gcg-credential");
select.replaceChildren(...optionsFromList(credentialListForKind(kind), "id", (c) => `${c.label} (#${c.id})`));
}
document.getElementById("gcg-kind").addEventListener("change", populateCredentialSelect);
async function refreshGroupCredentialGrants() {
const kinds = ["ssh_key", "rdp_credential", "ssh_password_credential"];
const perKind = await Promise.all(kinds.map((kind) => getJson(`/admin/group-credentials/${kind}`)));
const rows = [];
kinds.forEach((kind, idx) => {
for (const g of perKind[idx]) rows.push({ kind, ...g });
});
rows.sort((a, b) => a.user_group_name.localeCompare(b.user_group_name) || a.kind.localeCompare(b.kind));
const tbody = document.querySelector("#group-credential-grants-table tbody");
fillTable(
tbody,
rows.map((g) =>
el("tr", {}, [
el("td", { textContent: g.user_group_name }),
el("td", { textContent: CREDENTIAL_KIND_LABELS[g.kind] || g.kind }),
el("td", { textContent: credentialLabel(g.kind, g.credential_id) }),
el("td", { textContent: g.expires_at || "-" }),
el("td", {}, [
actionButton("Entziehen", "btn-danger", async () => {
await sendJson(`/admin/group-credentials/${g.kind}/revoke`, "POST", {
user_group_id: g.user_group_id, credential_id: g.credential_id,
});
await refreshGroupCredentialGrants();
}),
]),
])
)
);
}
document.getElementById("group-credential-grant-form").addEventListener("submit", async (ev) => {
ev.preventDefault();
try {
const kind = document.getElementById("gcg-kind").value;
const credentialId = Number(document.getElementById("gcg-credential").value);
if (!credentialId) throw new Error("Zugangsdatensatz auswaehlen.");
await sendJson(`/admin/group-credentials/${kind}/grant`, "POST", {
user_group_id: Number(document.getElementById("gcg-group").value),
credential_id: credentialId,
expires_at: document.getElementById("gcg-expires").value.trim() || null,
});
showBanner(`Zugangsdatensatz freigegeben: ${CREDENTIAL_KIND_LABELS[kind]}.`, "ok");
document.getElementById("gcg-expires").value = "";
await refreshGroupCredentialGrants();
} catch (err) {
showBanner(err.message, "error");
}
});
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
// API-Tokens // API-Tokens
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
@ -1135,8 +1145,6 @@
}) })
); );
} }
document.getElementById("tc-tenant-box").classList.toggle("hidden", !meInfo.is_admin && meInfo.tenant_admin_of.length <= 1);
await populateTenantSelect(document.getElementById("tc-tenant"));
await refreshSharedSelects(); await refreshSharedSelects();
await refreshTokens(); await refreshTokens();
} }
@ -1166,7 +1174,6 @@
el("td", { textContent: t.label }), el("td", { textContent: t.label }),
el("td", { textContent: t.username }), el("td", { textContent: t.username }),
el("td", { textContent: t.prefix }), el("td", { textContent: t.prefix }),
el("td", { textContent: t.tenant_name }),
scopesCell, scopesCell,
el("td", { textContent: t.last_used_at || "nie" }), el("td", { textContent: t.last_used_at || "nie" }),
el("td", { textContent: status }), el("td", { textContent: status }),
@ -1183,13 +1190,11 @@
if (scopes.length === 0) { if (scopes.length === 0) {
throw new Error("Mindestens einen Scope auswaehlen."); throw new Error("Mindestens einen Scope auswaehlen.");
} }
const tenantSelect = document.getElementById("tc-tenant");
const payload = { const payload = {
label: document.getElementById("tc-label").value.trim(), label: document.getElementById("tc-label").value.trim(),
user_id: Number(document.getElementById("tc-user").value), user_id: Number(document.getElementById("tc-user").value),
scopes, scopes,
expires_at: document.getElementById("tc-expires").value.trim() || null, expires_at: document.getElementById("tc-expires").value.trim() || null,
tenant_id: tenantSelect.value ? Number(tenantSelect.value) : null,
}; };
const result = await sendJson("/admin/tokens", "POST", payload); const result = await sendJson("/admin/tokens", "POST", payload);
const box = document.getElementById("token-reveal-box"); const box = document.getElementById("token-reveal-box");
@ -1204,125 +1209,6 @@
} }
}); });
// ---------------------------------------------------------------------
// 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");
}
});
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
// Sessions (nur Super-Admin) -- aktive + historische Sitzungen, Beenden, // Sessions (nur Super-Admin) -- aktive + historische Sitzungen, Beenden,
// Link zur Aufzeichnung. // Link zur Aufzeichnung.
@ -1874,12 +1760,10 @@
const hostGroupOpts = () => optionsFromList(cachedHostGroups, "id", (g) => `${g.name} (#${g.id})`); const hostGroupOpts = () => optionsFromList(cachedHostGroups, "id", (g) => `${g.name} (#${g.id})`);
const groupOpts = () => optionsFromList(cachedGroups, "id", (g) => `${g.name} (#${g.id})`); const groupOpts = () => optionsFromList(cachedGroups, "id", (g) => `${g.name} (#${g.id})`);
const rgUser = document.getElementById("rg-user");
if (rgUser) rgUser.replaceChildren(...userOpts());
const rgHostgroup = document.getElementById("rg-hostgroup");
if (rgHostgroup) rgHostgroup.replaceChildren(...hostGroupOpts());
const grgGroup = document.getElementById("grg-group"); const grgGroup = document.getElementById("grg-group");
if (grgGroup) grgGroup.replaceChildren(...groupOpts()); if (grgGroup) grgGroup.replaceChildren(...groupOpts());
const gcgGroup = document.getElementById("gcg-group");
if (gcgGroup) gcgGroup.replaceChildren(...groupOpts());
const grgHostgroup = document.getElementById("grg-hostgroup"); const grgHostgroup = document.getElementById("grg-hostgroup");
if (grgHostgroup) grgHostgroup.replaceChildren(...hostGroupOpts()); if (grgHostgroup) grgHostgroup.replaceChildren(...hostGroupOpts());
const tcUser = document.getElementById("tc-user"); const tcUser = document.getElementById("tc-user");
@ -1897,16 +1781,12 @@
async function main() { async function main() {
const me = await getJson("/auth/me"); const me = await getJson("/auth/me");
if (!me.is_any_admin) { if (!me.is_admin) {
window.location.href = "/dashboard"; window.location.href = "/dashboard";
return; return;
} }
meInfo = me; meInfo = me;
const roleLabel = me.is_admin document.getElementById("whoami").textContent = `${me.username} (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);
document.getElementById("sessions-tab-btn").classList.toggle("hidden", !me.is_admin); document.getElementById("sessions-tab-btn").classList.toggle("hidden", !me.is_admin);
document.getElementById("connlog-tab-btn").classList.toggle("hidden", !me.is_admin); document.getElementById("connlog-tab-btn").classList.toggle("hidden", !me.is_admin);
loadedTabs.add("users"); loadedTabs.add("users");

View File

@ -52,12 +52,17 @@
actions.className = "actions"; actions.className = "actions";
const connect = document.createElement("a"); const connect = document.createElement("a");
connect.href = host.protocol === "ssh" ? `/terminal/${host.id}` : `/rdp/${host.id}`; // F3 (Umsetzungsauftrag_Sonnet5.md Teil F.2): "Verbinden" fuehrt in die
// Arbeitsflaeche statt (wie vor Teil F) direkt in einen neuen Tab mit
// /terminal/{id} bzw. /rdp/{id} -- diese Seiten bleiben als duenner
// Rahmen fuer Lesezeichen/Fehlersuche bestehen (F.2), sind aber nicht
// mehr der reguraere Einstiegspunkt. ?host= wird von workspace.js beim
// Laden ausgewertet und startet die Sitzung dort direkt.
connect.href = `/workspace?host=${host.id}`;
connect.textContent = "Verbinden"; connect.textContent = "Verbinden";
// Oeffnet die Sitzung in einem eigenen Tab statt im Dashboard-Tab zu // Weiterhin ein eigener Tab: das Dashboard bleibt dadurch erreichbar,
// navigieren -- das Dashboard bleibt dadurch erreichbar, waehrend die // waehrend die Arbeitsflaeche laeuft (dort fuehrt "Dashboard" im
// Sitzung laeuft, und der Exit-Button der Sitzung (session-toolbar in // Topbar-Link zurueck, falls im selben Tab genavigiert wird).
// terminal.html/rdp.html) kann genau diesen Tab wieder schliessen.
connect.target = "_blank"; connect.target = "_blank";
actions.appendChild(connect); actions.appendChild(connect);

View File

@ -1,5 +1,29 @@
/* /*
* RDP-Session-Client: guacamole-common-js <-> /ws/rdp/{host_id}. * RDP-Sitzungs-Baustein: guacamole-common-js <-> /ws/rdp/{host_id}.
*
* F1 (Umsetzungsauftrag_Sonnet5.md Teil F.3.1): vorher ein Dokument-Singleton
* (ein Skript pro Seite, feste Element-IDs). Jetzt eine Klasse
* (RdpSession) mit genau einer Instanz pro Sitzung: eigenes, dynamisch
* gebautes DOM-Fragment (this.element), connect(), dispose(), setActive(bool)
* und ein Statusrueckruf (options.onStatusChange). Die eigenstaendige Seite
* /rdp/{host_id} (templates/rdp.html) ist ab jetzt nur noch ein duenner
* Rahmen, der GENAU EINE Instanz erzeugt (F.2, letzter Absatz).
*
* ACHTUNG Tastatur/Zwischenablage (F.3.2, noch NICHT geloest -- folgt in
* Stufe F4): guacamole-common-js' Guacamole.Keyboard haengt sich immer an
* ein von aussen uebergebenes Element -- bisher `document`, und das bleibt
* in dieser Stufe zunaechst so, weil die Bibliothek keine saubere
* Abmeldung/Umschaltung bietet. Bei mehreren GLEICHZEITIGEN RdpSession-
* Instanzen wuerden ohne Gegenmassnahme alle Tastatureingaben an ALLE
* Instanzen gehen. Deshalb: jede Instanz filtert selbst ueber setActive() --
* Tastatur-/Paste-Ereignisse werden nur weitergeleitet, wenn genau diese
* Instanz aktiv ist (this._active). Das ist die Grundlage fuer F.3.2, aber
* noch NICHT die vollstaendige Loesung (fehlende Modifiertasten-Ruecksetzung
* beim Umschalten, siehe F.3.2 Satz 2) -- die kommt mit der eigentlichen
* Mehrfach-Sitzungs-Umschaltung in Stufe F4, wenn es ueberhaupt zwei
* gleichzeitig existierende Instanzen geben kann. Bei genau einer Instanz
* (heutiger Stand: Stufe F1/F2/F3, dort niemals zwei RdpSession gleichzeitig
* auf derselben Seite) ist das Verhalten exakt wie vorher.
* *
* Vollbild: native Browser-Fullscreen-API (Konzept 4.3). * Vollbild: native Browser-Fullscreen-API (Konzept 4.3).
* Copy & Paste: bidirektionale Synchronisation ueber Guacamole.Client * Copy & Paste: bidirektionale Synchronisation ueber Guacamole.Client
@ -11,11 +35,97 @@
(() => { (() => {
"use strict"; "use strict";
const shell = document.getElementById("session-shell"); let _nextInstanceId = 1;
const hostId = shell.dataset.hostId;
const statusEl = document.getElementById("status");
const displayDiv = document.getElementById("rdp-display");
class RdpSession {
/**
* @param {number|string} hostId
* @param {object} [options]
* @param {(text: string, isError: boolean) => void} [options.onStatusChange]
* @param {() => void} [options.onExitRequested]
* Wird aufgerufen, NACHDEM der interne Exit-Knopf dispose()
* bereits ausgeloest hat.
*/
constructor(hostId, options = {}) {
this.hostId = hostId;
this.options = options;
this._uid = _nextInstanceId++;
this._active = false;
this._disposed = false;
this._intentionalDisconnect = false;
this._resizeDebounceHandle = null;
this.element = this._buildElement();
this._cacheRefs();
this._wireToolbar();
}
// --- Aufbau -------------------------------------------------------
_buildElement() {
const wrapper = document.createElement("div");
wrapper.className = "session-instance";
wrapper.innerHTML = `
<div class="session-shell">
<div class="session-toolbar">
<button type="button" class="fullscreen-btn">Vollbild</button>
<button type="button" class="ctrlaltdel-btn">Strg+Alt+Entf</button>
<span class="spacer"></span>
<span class="status">Verbinde ...</span>
<button type="button" class="exit-btn btn-danger btn-small" title="Sitzung beenden">Exit</button>
</div>
<div class="session-rdp-display">
<!-- B.1 (Umsetzungsauftrag Teil B): Overlay-Panel fuer
Verbindungsfehler, siehe rdp-error-overlay unten. -->
<div class="rdp-error-overlay" hidden>
<div class="rdp-error-panel">
<h2 class="rdp-error-title">Verbindungsfehler</h2>
<p class="rdp-error-message"></p>
<dl class="rdp-error-meta">
<dt>Sitzungs-ID</dt><dd class="rdp-error-session">-</dd>
<dt>Zeitpunkt</dt><dd class="rdp-error-time">-</dd>
<dt>Ziel-Host</dt><dd class="rdp-error-host">-</dd>
</dl>
<button type="button" class="rdp-error-retry-btn">Wiederholen</button>
</div>
</div>
</div>
</div>
`;
return wrapper;
}
_cacheRefs() {
const el = this.element;
this.shellEl = el.querySelector(".session-shell");
this.statusEl = el.querySelector(".status");
this.displayDiv = el.querySelector(".session-rdp-display");
this.fullscreenBtn = el.querySelector(".fullscreen-btn");
this.ctrlAltDelBtn = el.querySelector(".ctrlaltdel-btn");
this.exitBtn = el.querySelector(".exit-btn");
this.errorOverlay = el.querySelector(".rdp-error-overlay");
this.errorTitleEl = el.querySelector(".rdp-error-title");
this.errorMessageEl = el.querySelector(".rdp-error-message");
this.errorSessionEl = el.querySelector(".rdp-error-session");
this.errorTimeEl = el.querySelector(".rdp-error-time");
this.errorHostEl = el.querySelector(".rdp-error-host");
this.errorRetryBtn = el.querySelector(".rdp-error-retry-btn");
}
_setStatus(text, isError) {
this.statusEl.textContent = text;
this.statusEl.classList.toggle("error", !!isError);
if (this.options.onStatusChange) {
try {
this.options.onStatusChange(text, !!isError);
} catch (_) { /* Aufrufer-Fehler duerfen die Sitzung nicht stoeren */ }
}
}
// --- Verbindungsaufbau ------------------------------------------------
connect() {
if (this.client) return; // bereits verbunden/am Verbinden
const proto = window.location.protocol === "https:" ? "wss:" : "ws:"; const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
const width = Math.round(window.innerWidth); const width = Math.round(window.innerWidth);
const height = Math.round(window.innerHeight - 40); const height = Math.round(window.innerHeight - 40);
@ -24,37 +134,56 @@
// WICHTIG: die Verbindungsparameter gehoeren NICHT in die Tunnel-URL. // WICHTIG: die Verbindungsparameter gehoeren NICHT in die Tunnel-URL.
// Guacamole.WebSocketTunnel.connect(data) baut die Socket-URL selbst als // Guacamole.WebSocketTunnel.connect(data) baut die Socket-URL selbst als
// `tunnelURL + "?" + data` zusammen. Standen die Parameter schon in der // `tunnelURL + "?" + data` zusammen. Standen die Parameter schon in der
// URL, entstand daraus `...?width=1280&height=800&dpi=96?undefined` -- der // URL, entstand daraus `...?width=1280&height=800&dpi=96?undefined` --
// letzte Query-Parameter war damit kein gueltiger Integer mehr, FastAPI // der letzte Query-Parameter war damit kein gueltiger Integer mehr,
// wies den WebSocket noch vor dem Routenhandler ab (HTTP 422) und im // FastAPI wies den WebSocket noch vor dem Routenhandler ab (HTTP 422).
// Verbindungslog des Servers tauchte kein einziger Eintrag auf. const tunnelUrl = `${proto}//${window.location.host}/ws/rdp/${this.hostId}`;
const tunnelUrl = `${proto}//${window.location.host}/ws/rdp/${hostId}`;
const connectParams = `width=${width}&height=${height}&dpi=${dpi}`; const connectParams = `width=${width}&height=${height}&dpi=${dpi}`;
const tunnel = new Guacamole.WebSocketTunnel(tunnelUrl); this.tunnel = new Guacamole.WebSocketTunnel(tunnelUrl);
const client = new Guacamole.Client(tunnel); this.client = new Guacamole.Client(this.tunnel);
displayDiv.appendChild(client.getDisplay().getElement()); this.displayDiv.appendChild(this.client.getDisplay().getElement());
client.onstatechange = (state) => { this.client.onstatechange = (state) => {
// 0=idle,1=connecting,2=waiting,3=connected,4=disconnecting,5=disconnected // 0=idle,1=connecting,2=waiting,3=connected,4=disconnecting,5=disconnected
const labels = ["Idle", "Verbinde ...", "Warte auf Server ...", "Verbunden", "Trenne ...", "Getrennt"]; const labels = ["Idle", "Verbinde ...", "Warte auf Server ...", "Verbunden", "Trenne ...", "Getrennt"];
statusEl.textContent = labels[state] || `Status ${state}`; this._setStatus(labels[state] || `Status ${state}`, false);
}; };
const showError = (err) => { const showError = (err) => {
// Der Server gibt den Abbruchgrund als WebSocket-Close-Reason mit // Ein vom Benutzer ueber den Exit-Knopf ausgeloestes client.disconnect()
// (siehe _reject() in app/rdp_proxy/ws_tunnel.py); guacamole-common-js // schliesst den Tunnel sauber (Guacamole.Status.Code.SUCCESS) und loest
// reicht ihn als Guacamole.Status.message hierher durch. // damit laut guacamole-common-js KEIN onerror aus -- dieser Schutz greift
statusEl.textContent = "Fehler: " + ((err && err.message) || "unbekannt"); // trotzdem zusaetzlich, falls sich das je aendert.
statusEl.classList.add("error"); if (this._intentionalDisconnect) return;
// Troubleshooting: Statuscode + Rohobjekt zusaetzlich in die
// Browser-Konsole, fuer den Fall, dass die Klartextmeldung allein nicht const message = (err && err.message) || "Unbekannter Fehler (keine Meldung vom Server empfangen)";
// reicht (z.B. guacd/FreeRDP-interne Codes). this._setStatus("Fehler: " + message, true);
this.errorTitleEl.textContent = "Verbindungsfehler";
this.errorMessageEl.textContent = message;
this.errorSessionEl.textContent = this.tunnel.uuid || "unbekannt";
this.errorTimeEl.textContent = new Date().toLocaleString("de-AT");
this.errorHostEl.textContent = `Host-ID ${this.hostId}`;
this.errorOverlay.hidden = false;
console.error("RDP-Sitzung beendet/Fehler:", err); console.error("RDP-Sitzung beendet/Fehler:", err);
}; };
client.onerror = showError; this.client.onerror = showError;
tunnel.onerror = showError; this.tunnel.onerror = showError;
client.onclipboard = (stream, mimetype) => { this.errorRetryBtn.addEventListener("click", () => {
// Einfachste zuverlaessige Wiederholung: die Sitzung (Tunnel, Client,
// Maus-/Tastatur-Listener) neu aufzubauen wuerde denselben Zustand wie
// ein frischer Seitenaufruf erfordern -- ein Reload erreicht das direkt
// und erzeugt serverseitig eine neue Korrelations-ID/Sitzung. Betrifft
// bei /workspace (Stufe F3/F4) nur diese eine Kachel -- ein voller
// Seiten-Reload dort wuerde ALLE offenen Sitzungen der Arbeitsflaeche
// beenden (siehe F.3.7: kein Wiederanschluss nach Reload).
window.location.reload();
});
this.client.onclipboard = (stream, mimetype) => {
if (!mimetype.startsWith("text/")) return; if (!mimetype.startsWith("text/")) return;
const reader = new Guacamole.StringReader(stream); const reader = new Guacamole.StringReader(stream);
let data = ""; let data = "";
@ -66,31 +195,100 @@
}; };
}; };
client.connect(connectParams); this.client.connect(connectParams);
window.addEventListener("beforeunload", () => client.disconnect()); this._onBeforeUnload = () => { try { this.client.disconnect(); } catch (_) { /* ignorieren */ } };
window.addEventListener("beforeunload", this._onBeforeUnload);
const mouse = new Guacamole.Mouse(client.getDisplay().getElement()); // D4 (Umsetzungsauftrag Teil A): der Verbindungsaufbau setzt bereits
mouse.onmousedown = mouse.onmouseup = mouse.onmousemove = (mouseState) => { // "resize-method: display-update" -- das RDP-Ziel unterstuetzt damit
client.sendMouseState(mouseState); // dynamische Aufloesungsaenderungen waehrend der laufenden Sitzung.
// Debounce: "resize" kann waehrend eines Fenster-Drags sehr haeufig
// feuern -- ohne Verzoegerung wuerde jede Zwischengroesse eine eigene
// "size"-Instruktion ueber den Tunnel schicken.
// F4 (Umsetzungsauftrag_Sonnet5.md Teil F.3.3): mehrere Instanzen
// koennen jetzt gleichzeitig existieren (Arbeitsflaeche /workspace) --
// eine ausgeblendete Instanz hat Groesse 0 (display:none). Ein Refit
// waehrend ausgeblendet waere sinnlos und wuerde bei Sichtbarwerden
// ohnehin durch setActive(true) -> _sendCurrentSize() nachgeholt --
// daher nur reagieren, wenn diese Instanz gerade sichtbar ist.
this._onWindowResize = () => { if (this._active) this._scheduleResize(); };
window.addEventListener("resize", this._onWindowResize);
document.addEventListener("fullscreenchange", this._onWindowResize);
this.mouse = new Guacamole.Mouse(this.client.getDisplay().getElement());
this.mouse.onmousedown = this.mouse.onmouseup = this.mouse.onmousemove = (mouseState) => {
this.client.sendMouseState(mouseState);
}; };
const keyboard = new Guacamole.Keyboard(document); // Siehe Moduldoc oben: dokumentweit angeheftet (Bibliotheksgrenze),
keyboard.onkeydown = (keysym) => client.sendKeyEvent(1, keysym); // Weiterleitung aber ueber this._active gefiltert.
keyboard.onkeyup = (keysym) => client.sendKeyEvent(0, keysym); this.keyboard = new Guacamole.Keyboard(document);
this.keyboard.onkeydown = (keysym) => { if (this._active) this.client.sendKeyEvent(1, keysym); };
this.keyboard.onkeyup = (keysym) => { if (this._active) this.client.sendKeyEvent(0, keysym); };
document.addEventListener("paste", (ev) => { this._onPaste = (ev) => {
if (!this._active) return;
const text = (ev.clipboardData || window.clipboardData).getData("text"); const text = (ev.clipboardData || window.clipboardData).getData("text");
if (!text) return; if (!text) return;
const stream = client.createClipboardStream("text/plain"); const stream = this.client.createClipboardStream("text/plain");
const writer = new Guacamole.StringWriter(stream); const writer = new Guacamole.StringWriter(stream);
writer.sendText(text); writer.sendText(text);
writer.sendEnd(); writer.sendEnd();
}); };
document.addEventListener("paste", this._onPaste);
}
document.getElementById("fullscreen-btn").addEventListener("click", () => { _sendCurrentSize() {
const newWidth = Math.round(this.displayDiv.clientWidth || window.innerWidth);
const newHeight = Math.round(this.displayDiv.clientHeight || (window.innerHeight - 40));
if (newWidth > 0 && newHeight > 0) {
this.client.sendSize(newWidth, newHeight);
}
}
_scheduleResize() {
if (this._resizeDebounceHandle !== null) {
window.clearTimeout(this._resizeDebounceHandle);
}
this._resizeDebounceHandle = window.setTimeout(() => {
this._resizeDebounceHandle = null;
this._sendCurrentSize();
}, 250);
}
/** Wird von einer kuenftigen Seitenleiste (Teil F.3/F.4) beim Umschalten
* aufgerufen. Steuert, ob diese Instanz Tastatur-/Paste-Ereignisse
* entgegennimmt (siehe Moduldoc) -- bei genau einer Instanz (heutiger
* Stand) ohne sichtbaren Effekt ausser dem anfaenglichen Aktivieren. */
setActive(active) {
const wasActive = this._active;
this._active = active;
// F4 (F.3.2, Satz 2): beim Umschalten muessen gedrueckte Modifiertasten
// zurueckgesetzt werden -- sonst bleibt z.B. Strg in der verlassenen
// Sitzung haengen (dort kommt ja kein keyup mehr an, sobald diese
// Instanz keine Ereignisse mehr weiterleitet). Keyboard.reset() ist
// Teil der guacamole-common-js-API (dieselbe Methode, die der
// offizielle Client bei Fokusverlust des Fensters aufruft) -- per
// Feature-Check abgesichert, falls eine kuenftige Bibliotheksversion
// sie umbenennt.
if (!active && wasActive && this.keyboard && typeof this.keyboard.reset === "function") {
this.keyboard.reset();
}
if (active && this.client) {
this._sendCurrentSize();
if (this.keyboard && typeof this.keyboard.reset === "function") {
this.keyboard.reset();
}
}
}
// --- Toolbar --------------------------------------------------------
_wireToolbar() {
this.fullscreenBtn.addEventListener("click", () => {
if (!document.fullscreenElement) { if (!document.fullscreenElement) {
shell.requestFullscreen().catch(() => {}); this.shellEl.requestFullscreen().catch(() => {});
} else { } else {
document.exitFullscreen(); document.exitFullscreen();
} }
@ -102,20 +300,72 @@
// die drei Tasten einzeln als synthetische Key-Events senden (X11- // die drei Tasten einzeln als synthetische Key-Events senden (X11-
// Keysyms: Ctrl=0xFFE3, Alt=0xFFE9, Delete=0xFFFF), erst alle drei // Keysyms: Ctrl=0xFFE3, Alt=0xFFE9, Delete=0xFFFF), erst alle drei
// herunter- dann in umgekehrter Reihenfolge wieder hochdruecken. // herunter- dann in umgekehrter Reihenfolge wieder hochdruecken.
document.getElementById("ctrlaltdel-btn").addEventListener("click", () => { this.ctrlAltDelBtn.addEventListener("click", () => {
if (!this.client) return;
const keys = [0xffe3, 0xffe9, 0xffff]; const keys = [0xffe3, 0xffe9, 0xffff];
for (const keysym of keys) client.sendKeyEvent(1, keysym); for (const keysym of keys) this.client.sendKeyEvent(1, keysym);
for (const keysym of keys.slice().reverse()) client.sendKeyEvent(0, keysym); for (const keysym of keys.slice().reverse()) this.client.sendKeyEvent(0, keysym);
}); });
document.getElementById("exit-btn").addEventListener("click", () => { this.exitBtn.addEventListener("click", () => {
statusEl.textContent = "Sitzung wird beendet ..."; this._intentionalDisconnect = true;
statusEl.classList.remove("error"); this._setStatus("Sitzung wird beendet ...", false);
try { client.disconnect(); } catch (_) { /* bereits getrennt */ } this.dispose();
if (this.options.onExitRequested) this.options.onExitRequested();
});
}
// --- Abbau ------------------------------------------------------------
/** Idempotent. Siehe terminal.js::SshTerminalSession.dispose() fuer die
* gleiche Begruendung. Einschraenkung: guacamole-common-js bietet keine
* saubere Abmeldung fuer Guacamole.Keyboard(document) -- der zugrunde-
* liegende DOM-Listener bleibt technisch bestehen, sendet aber nach
* dispose() nichts mehr (this._active bleibt false). Das ist bei genau
* einer Instanz (heutiger Stand) folgenlos; bei mehreren gleichzeitigen
* Instanzen (Stufe F4) muss dieser Punkt beim Kachel-Schliessen erneut
* betrachtet werden. */
dispose() {
if (this._disposed) return;
this._disposed = true;
try {
if (this.client) this.client.disconnect();
} catch (_) { /* bereits getrennt */ }
if (this._onWindowResize) {
window.removeEventListener("resize", this._onWindowResize);
document.removeEventListener("fullscreenchange", this._onWindowResize);
}
if (this._onBeforeUnload) window.removeEventListener("beforeunload", this._onBeforeUnload);
if (this._onPaste) document.removeEventListener("paste", this._onPaste);
if (this._resizeDebounceHandle !== null) {
window.clearTimeout(this._resizeDebounceHandle);
this._resizeDebounceHandle = null;
}
if (this.element.parentNode) this.element.parentNode.removeChild(this.element);
}
}
window.RdpSession = RdpSession;
// --- Bootstrap fuer die eigenstaendige Seite /rdp/{host_id} --------------
//
// F1: templates/rdp.html liefert nur noch einen leeren Container mit
// data-host-id -- diese Seite bleibt als duenner Rahmen um denselben
// Baustein bestehen (F.2), keine zweite Implementierung.
const container = document.getElementById("session-container");
if (container) {
const hostId = container.dataset.hostId;
const session = new RdpSession(hostId, {
onExitRequested: () => {
window.close(); window.close();
// Siehe terminal.js fuer die ausfuehrliche Begruendung: window.close() // Siehe terminal.js fuer die ausfuehrliche Begruendung: window.close()
// schliesst den Tab nur unter bestimmten Voraussetzungen lautlos -- // schliesst den Tab nur unter bestimmten Voraussetzungen lautlos --
// deshalb zusaetzlich immer ein Fallback zum Dashboard. // deshalb zusaetzlich immer ein Fallback zum Dashboard.
window.setTimeout(() => { window.location.href = "/dashboard"; }, 300); window.setTimeout(() => { window.location.href = "/dashboard"; }, 300);
},
}); });
container.appendChild(session.element);
session.connect();
session.setActive(true);
}
})(); })();

View File

@ -0,0 +1,37 @@
/*
* E9 (Umsetzungsauftrag Teil E): der gleitende Idle-Timeout des Session-
* Cookies (siehe app/auth/deps.py::get_current_user, refresh_session_token)
* wird nur bei regulaeren HTTP-Anfragen verlaengert. Ein WebSocket-Handshake
* kann das nicht (WebSockets erlauben nach dem Handshake kein Set-Cookie
* mehr, siehe get_current_user_ws()) -- ein Benutzer, der eine Stunde
* ausschliesslich in einem offenen Terminal- oder RDP-Tab arbeitet, haette
* sonst ein abgelaufenes Cookie: die laufende Sitzung selbst laeuft zwar
* weiter, aber jede WEITERE Aktion (zweite Sitzung oeffnen, Dateitransfer,
* Katalog neu laden) scheitert mit "Session abgelaufen".
*
* Fix: waehrend ein Sitzungstab (Terminal/RDP) offen ist, periodisch einen
* billigen, bereits authentifizierten HTTP-Endpunkt aufrufen (GET /auth/me,
* siehe app/auth/routes.py) -- das durchlaeuft dieselbe get_current_user()-
* Dependency wie jede andere Anfrage und verlaengert den Cookie ganz
* regulaer, ohne einen eigenen Endpunkt oder Serveraenderungen zu brauchen.
*
* Intervall: deutlich unter dem serverseitig konfigurierten
* session_idle_timeout_s (Standard 900s/15min, app/config.py) -- 3 Minuten
* Sicherheitsmarge reichen fuer jede sinnvolle Konfiguration.
*/
(() => {
"use strict";
const KEEPALIVE_INTERVAL_MS = 3 * 60 * 1000;
function ping() {
fetch("/auth/me", { method: "GET", credentials: "same-origin" }).catch(() => {
// Absichtlich stumm: ein einzelner fehlgeschlagener Keepalive-Ping
// (z.B. kurzer Netzwerkaussetzer) soll die laufende Sitzung nicht
// stoeren -- schlaegt die Session tatsaechlich fehl, faellt das beim
// naechsten regulaeren Request (Dateitransfer, Reload, ...) ohnehin auf.
});
}
window.setInterval(ping, KEEPALIVE_INTERVAL_MS);
})();

View File

@ -1,35 +1,75 @@
/* /*
* SSH-Terminal-Client: xterm.js <-> /ws/ssh/{host_id}. * SSH-Terminal-Baustein: xterm.js <-> /ws/ssh/{host_id}.
* *
* Vollbild: native Browser-Fullscreen-API auf dem Session-Container (Konzept 4.3). * F1 (Umsetzungsauftrag_Sonnet5.md Teil F.3.1): vorher ein Dokument-Singleton
* Copy & Paste: xterm.js liefert dies bei SSH bereits nativ ueber die * (ein Skript pro Seite, feste Element-IDs). Jetzt eine Klasse
* System-Zwischenablage (Markieren-zum-Kopieren / Strg+Umschalt+V) -- keine * (SshTerminalSession) mit genau einer Instanz pro Sitzung: eigenes,
* serverseitige Sonderbehandlung noetig, im Gegensatz zu RDP (siehe rdp.js). * dynamisch gebautes DOM-Fragment (this.element), connect(), dispose(),
* setActive(bool) und ein Statusrueckruf (options.onStatusChange) fuer eine
* kuenftige Seitenleiste (Teil F.3: /workspace, noch nicht Teil dieser
* Stufe). Die eigenstaendige Seite /terminal/{host_id} (templates/
* terminal.html) ist ab jetzt nur noch ein duenner Rahmen, der GENAU EINE
* Instanz erzeugt -- keine zweite Implementierung (F.2, letzter Absatz).
*
* Vollbild: native Browser-Fullscreen-API auf dem Instanz-Wurzelelement
* (Konzept 4.3).
* Copy & Paste: xterm.js liefert Markieren-zum-Kopieren + Strg+Umschalt+V
* bereits nativ. Aufgabe #11 (vom Nutzer nachtraeglich hinzugefuegt, siehe
* FORTSETZUNG_Aufgabe11.md) ergaenzt zwei konkret gewuenschte Wege:
* `copyOnSelect: true` kopiert eine Markierung sofort in die
* System-Zwischenablage (kein Strg+C/Cmd+C mehr noetig -- Strg+C bleibt
* ohne Markierung weiterhin SIGINT, xterm.js' Standardverhalten). Rechtsklick
* fuegt ein: zuerst wird direkt versucht, ueber die Clipboard-API einzufuegen
* (funktioniert in den meisten Chromium-Browsern, da ein Rechtsklick eine
* "transiente Aktivierung" ist); schlaegt das fehl (Firefox/Safari ohne
* Berechtigung, unsicherer Kontext), erscheint ersatzweise ein winziges
* Kontextmenue mit einem "Einfuegen"-Eintrag -- der Klick darauf ist selbst
* eine Benutzergeste und hat dadurch nochmal bessere Chancen auf die
* Berechtigung (siehe _wireClipboard() unten). Keine serverseitige
* Sonderbehandlung noetig, im Gegensatz zu RDP (siehe rdp.js).
* Markieren mit gedrueckter Umschalttaste: xterm.js reicht Mausklicks an ein
* Vollbild-Zielprogramm mit eigener Maussteuerung (tmux, vim, htop, mc, ...)
* weiter, sobald dieses per ANSI-Escape-Sequenz Maus-Tracking anfordert --
* dann faengt ein normaler Linksklick+Ziehen KEINE lokale Textmarkierung
* mehr ein (Standardverhalten jedes echten Terminals, kein Bug). Umschalt
* (Shift)+Ziehen erzwingt die lokale Markierung trotzdem, xterm.js
* unterstuetzt das nativ, ohne Zusatzcode. Als `title`-Tooltip auf
* `.session-terminal` dokumentiert (siehe _buildElement()), da das sonst
* leicht wie ein kaputtes Kopieren aussieht.
* Farbschema: per <select> in der Toolbar waehlbar (TERMINAL_THEMES unten),
* Wahl wird in localStorage gemerkt (schluessel THEME_STORAGE_KEY) und beim
* naechsten Sitzungsaufbau (auch in anderen Tabs) als Vorgabe uebernommen --
* kein Server-Roundtrip, rein clientseitige Praeferenz wie ueblich fuer
* Darstellungseinstellungen dieser Art. AENDERT NICHTS an bereits anderen,
* gleichzeitig offenen Sitzungen (F4: mehrere Instanzen) -- dieselbe Klasse
* von Eventual-Consistency wie an anderer Stelle im Projekt (kein Live-Sync
* zwischen Instanzen, nur beim naechsten Aufbau wirksam).
* Tastatur: xterm.js haengt seine Tastatur-Erfassung bereits an sein EIGENES
* Containerelement (nicht an document) -- die in F.3.2 beschriebene Gefahr
* ("Tastendruck landet auf drei Servern") betrifft daher nur rdp.js
* (Guacamole.Keyboard(document)), nicht dieses Modul. window resize/
* fullscreenchange bleiben dokumentweite Listener (siehe dispose()) -- bei
* genau einer aktiven Instanz (heutiger Stand: Stufe F1/F2/F3) unveraendert
* zum bisherigen Verhalten; die Mehrfach-Sitzungs-Feinheiten aus F.3.2/F.3.3
* (Refit erst beim Sichtbarwerden, Fokusmodell) folgen in Stufe F4.
* *
* Dateitransfer: eigenes Panel (statt frueherem prompt()-basiertem Upload) * Dateitransfer: eigenes Panel (statt frueherem prompt()-basiertem Upload)
* mit Formularen fuer Upload UND Download in beide Richtungen. Der Download * mit Formularen fuer Upload UND Download in beide Richtungen. Der Download
* laeuft ueber fetch() + Blob + synthetischen <a download>-Link, damit * laeuft ueber fetch() + Blob + synthetischen <a download>-Link, damit
* Fehler inline im Panel angezeigt werden koennen statt die Seite zu * Fehler inline im Panel angezeigt werden koennen statt die Seite zu
* verlassen (siehe GET /ssh/{host_id}/files/download). * verlassen (siehe GET /ssh/{host_id}/files/download).
*
* F5-Nachtrag (Umsetzungsauftrag_Sonnet5.md Teil F.3.4): periodischer
* Leerlauf-Ping (this._pingTimer, alle 4 Minuten, siehe connect()) haelt
* eine wirklich untaetige Hintergrundsitzung am Leben -- ohne ihn trennt
* der server-seitige Idle-Timeout (E10, IDLE_TIMEOUT_SECONDS, Standard 15
* Minuten) eine Sitzung ohne jede Ein-/Ausgabe, obwohl Betreiberentscheidung
* #7 (FORTSETZUNG_Teil_F.md) "voll verbunden bleiben" verlangt. Betrifft nur
* SSH -- RDP (ws_tunnel.py) hat keinen Idle-Timeout.
*/ */
(() => { (() => {
"use strict"; "use strict";
const shell = document.getElementById("session-shell");
const hostId = shell.dataset.hostId;
const statusEl = document.getElementById("status");
const term = new Terminal({
cursorBlink: true,
fontFamily: "Menlo, Consolas, monospace",
fontSize: 14,
theme: { background: "#000000" },
});
const fitAddon = new FitAddon.FitAddon();
term.loadAddon(fitAddon);
term.open(document.getElementById("terminal"));
fitAddon.fit();
function b64encode(str) { function b64encode(str) {
return btoa(unescape(encodeURIComponent(str))); return btoa(unescape(encodeURIComponent(str)));
} }
@ -53,25 +93,261 @@
return bytes; return bytes;
} }
// Troubleshooting: der Server liefert bei einem Fehlschlag entweder eine function formatSize(bytes) {
// {"type":"error",...}-Nachricht ODER (wenn die Sitzung nie so weit kam, if (bytes < 1024) return `${bytes} B`;
// dass eine Nachricht gesendet werden konnte -- z.B. fehlende if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
// Berechtigung) eine WebSocket-Close-Reason (siehe return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
// app/ssh_proxy/terminal_ws.py::_reject). Beides wird angezeigt statt }
// eines generischen "Verbindung beendet" ohne jeden Grund.
let lastErrorMessage = null;
const proto = window.location.protocol === "https:" ? "wss:" : "ws:"; // --- Farbschemata (Aufgabe #11) -----------------------------------------
const ws = new WebSocket(`${proto}//${window.location.host}/ws/ssh/${hostId}`); //
// Bewusst eine kleine, feste Auswahl statt eines vollen Theme-Editors --
// deckt den geaeusserten Wunsch ("vom Benutzer waehlbar") ab, ohne eine
// neue Einstellungsseite o.ae. einzufuehren. Felder folgen xterm.js'
// ITheme-Schnittstelle (siehe vendor/xterm.js, OptionsService/"theme").
const TERMINAL_THEMES = {
standard: {
label: "Standard (dunkel)",
theme: { background: "#000000", foreground: "#e6e8eb", cursor: "#e6e8eb" },
},
dunkel_kontrastreich: {
label: "Dunkel, kontrastreich",
theme: {
background: "#0d1117", foreground: "#c9d1d9", cursor: "#58a6ff",
selectionBackground: "#264f78",
black: "#484f58", red: "#ff7b72", green: "#3fb950", yellow: "#d29922",
blue: "#58a6ff", magenta: "#bc8cff", cyan: "#39c5cf", white: "#b1bac4",
brightBlack: "#6e7681", brightRed: "#ffa198", brightGreen: "#56d364",
brightYellow: "#e3b341", brightBlue: "#79c0ff", brightMagenta: "#d2a8ff",
brightCyan: "#56d4dd", brightWhite: "#f0f6fc",
},
},
solarized_dark: {
label: "Solarized Dark",
theme: {
background: "#002b36", foreground: "#839496", cursor: "#93a1a1",
selectionBackground: "#073642",
black: "#073642", red: "#dc322f", green: "#859900", yellow: "#b58900",
blue: "#268bd2", magenta: "#d33682", cyan: "#2aa198", white: "#eee8d5",
brightBlack: "#002b36", brightRed: "#cb4b16", brightGreen: "#586e75",
brightYellow: "#657b83", brightBlue: "#839496", brightMagenta: "#6c71c4",
brightCyan: "#93a1a1", brightWhite: "#fdf6e3",
},
},
solarized_light: {
label: "Solarized Light",
theme: {
background: "#fdf6e3", foreground: "#657b83", cursor: "#586e75",
selectionBackground: "#eee8d5",
black: "#073642", red: "#dc322f", green: "#859900", yellow: "#b58900",
blue: "#268bd2", magenta: "#d33682", cyan: "#2aa198", white: "#eee8d5",
brightBlack: "#002b36", brightRed: "#cb4b16", brightGreen: "#586e75",
brightYellow: "#657b83", brightBlue: "#839496", brightMagenta: "#6c71c4",
brightCyan: "#93a1a1", brightWhite: "#fdf6e3",
},
},
};
const DEFAULT_THEME_NAME = "standard";
const THEME_STORAGE_KEY = "jumphost_terminal_theme";
ws.addEventListener("open", () => { function loadStoredThemeName() {
statusEl.textContent = "Verbunden"; try {
sendResize(); const stored = window.localStorage.getItem(THEME_STORAGE_KEY);
if (stored && TERMINAL_THEMES[stored]) return stored;
} catch (_) { /* localStorage blockiert (privater Modus o.ae.) -- Standard */ }
return DEFAULT_THEME_NAME;
}
function storeThemeName(name) {
try {
window.localStorage.setItem(THEME_STORAGE_KEY, name);
} catch (_) { /* siehe oben -- Auswahl gilt dann nur fuer diese Instanz */ }
}
let _nextInstanceId = 1;
class SshTerminalSession {
/**
* @param {number|string} hostId
* @param {object} [options]
* @param {(text: string, isError: boolean) => void} [options.onStatusChange]
* Statusrueckruf fuer eine kuenftige Seitenleiste (Teil F.3).
* @param {() => void} [options.onExitRequested]
* Wird aufgerufen, NACHDEM der interne Exit-Knopf dispose()
* bereits ausgeloest hat -- die Seite entscheidet, was danach
* passiert (Tab schliessen bei /terminal/{id}, Kachel aus der
* Seitenleiste entfernen bei /workspace).
*/
constructor(hostId, options = {}) {
this.hostId = hostId;
this.options = options;
this._uid = _nextInstanceId++;
this._active = false;
this._disposed = false;
this._lastErrorMessage = null;
this._transfers = [];
this._resizeDebounce = null;
this._pasteMenuEl = null;
this.element = this._buildElement();
this._cacheRefs();
this._setupTerminal();
this._wireToolbar();
this._wireClipboard();
this._wireFileTransfer();
}
// --- Aufbau -------------------------------------------------------
_buildElement() {
const uploadPathId = `ssh-ft-upload-path-${this._uid}`;
const downloadPathId = `ssh-ft-download-path-${this._uid}`;
const themeOptions = Object.keys(TERMINAL_THEMES)
.map((key) => `<option value="${key}">${TERMINAL_THEMES[key].label}</option>`)
.join("");
const wrapper = document.createElement("div");
wrapper.className = "session-instance";
wrapper.innerHTML = `
<div class="session-shell">
<div class="session-toolbar">
<button type="button" class="fullscreen-btn">Vollbild</button>
<button type="button" class="filetransfer-btn">Dateitransfer</button>
<select class="theme-select" title="Farbschema">${themeOptions}</select>
<span class="spacer"></span>
<span class="status">Verbinde ...</span>
<button type="button" class="exit-btn btn-danger btn-small" title="Sitzung beenden">Exit</button>
</div>
<div class="session-terminal" title="Markieren: Klicken+Ziehen. In Vollbild-Programmen mit eigener Maussteuerung (tmux, vim, htop, mc, weniger/less --mouse, ...) Umschalt+Ziehen verwenden, sonst geht der Klick an das Programm statt an die Markierung."></div>
</div>
<div class="ft-overlay hidden">
<div class="ft-panel">
<h2>Dateitransfer
<button type="button" class="btn-secondary btn-small ft-close-btn">Schliessen</button>
</h2>
<h3>Hochladen</h3>
<label for="${uploadPathId}">Zielpfad auf dem Server</label>
<input type="text" id="${uploadPathId}" class="ft-upload-remote-path" placeholder="/tmp/datei.txt">
<input type="file" class="ft-upload-file-input hidden">
<div class="ft-btn-row">
<button type="button" class="btn-secondary btn-small ft-upload-pick-btn">Datei auswaehlen</button>
<span class="hint ft-upload-filename">Keine Datei ausgewaehlt</span>
</div>
<button type="button" class="btn-small ft-upload-submit-btn">Hochladen</button>
<h3>Herunterladen</h3>
<label for="${downloadPathId}">Pfad auf dem Server</label>
<input type="text" id="${downloadPathId}" class="ft-download-remote-path" placeholder="/etc/hosts">
<button type="button" class="btn-small ft-download-submit-btn">Herunterladen</button>
<div class="error ft-status"></div>
<h3>Letzte Transfers</h3>
<div class="ft-list"></div>
</div>
</div>
`;
return wrapper;
}
_cacheRefs() {
const el = this.element;
this.shellEl = el.querySelector(".session-shell");
this.statusEl = el.querySelector(".status");
this.terminalContainer = el.querySelector(".session-terminal");
this.fullscreenBtn = el.querySelector(".fullscreen-btn");
this.filetransferBtn = el.querySelector(".filetransfer-btn");
this.themeSelect = el.querySelector(".theme-select");
this.exitBtn = el.querySelector(".exit-btn");
this.ftOverlay = el.querySelector(".ft-overlay");
this.ftStatus = el.querySelector(".ft-status");
this.ftList = el.querySelector(".ft-list");
this.ftCloseBtn = el.querySelector(".ft-close-btn");
this.uploadRemotePathInput = el.querySelector(".ft-upload-remote-path");
this.uploadFileInput = el.querySelector(".ft-upload-file-input");
this.uploadFilenameLabel = el.querySelector(".ft-upload-filename");
this.uploadPickBtn = el.querySelector(".ft-upload-pick-btn");
this.uploadSubmitBtn = el.querySelector(".ft-upload-submit-btn");
this.downloadRemotePathInput = el.querySelector(".ft-download-remote-path");
this.downloadSubmitBtn = el.querySelector(".ft-download-submit-btn");
}
_setStatus(text, isError) {
this.statusEl.textContent = text;
this.statusEl.classList.toggle("error", !!isError);
if (this.options.onStatusChange) {
try {
this.options.onStatusChange(text, !!isError);
} catch (_) { /* Aufrufer-Fehler duerfen die Sitzung nicht stoeren */ }
}
}
// --- Terminal/WebSocket --------------------------------------------
_setupTerminal() {
this._themeName = loadStoredThemeName();
this.term = new Terminal({
cursorBlink: true,
fontFamily: "Menlo, Consolas, monospace",
fontSize: 14,
theme: TERMINAL_THEMES[this._themeName].theme,
// Aufgabe #11: eine Markierung wird sofort in die System-
// Zwischenablage kopiert -- kein zusaetzliches Strg+C/Cmd+C noetig.
// Strg+C OHNE Markierung bleibt unveraendert SIGINT (xterm.js'
// Standardverhalten, wird von copyOnSelect nicht beruehrt).
copyOnSelect: true,
}); });
ws.addEventListener("close", (ev) => { this.fitAddon = new FitAddon.FitAddon();
this.term.loadAddon(this.fitAddon);
this.term.open(this.terminalContainer);
if (this.themeSelect) this.themeSelect.value = this._themeName;
this.term.onData((data) => {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type: "input", data: b64encode(data) }));
}
});
}
/** Verbindungsaufbau -- getrennt vom Konstruktor, damit eine kuenftige
* Seitenleiste (Teil F.3) eine Kachel erst anzeigen und DANACH verbinden
* kann (z.B. um zuerst die Ressourcen-/Obergrenzen-Pruefung serverseitig
* abzuwarten). */
connect() {
if (this.ws) return; // bereits verbunden/am Verbinden
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
this.ws = new WebSocket(`${proto}//${window.location.host}/ws/ssh/${this.hostId}`);
this.ws.addEventListener("open", () => {
this._setStatus("Verbunden", false);
this.sendResize();
// F5-Nachtrag (Umsetzungsauftrag Teil F.3.4): E10 (terminal_ws.py)
// trennt eine Sitzung nach IDLE_TIMEOUT_SECONDS (Standard 15 Min.)
// ohne JEDE Ein-/Ausgabe. Vor F4 war das unproblematisch -- es gab
// immer nur eine Sitzung, entweder aktiv genutzt oder der Benutzer
// war ohnehin weg. Seit mehrere Sitzungen still im Hintergrund
// liegen koennen (F.3.4: "duerfen nicht sterben"), reicht ein
// wirklich untaetiger Hintergrund-Prompt (kein tail -f, kein Build)
// aus, um das F.6-Abnahmekriterium ("nach 30 Minuten alle vier noch
// verbunden") zu verfehlen. Ein periodischer, inhaltsloser Ping
// haelt jede offene Sitzung am Leben -- der Server zaehlt JEDE
// empfangene Nachricht als Aktivitaet (terminal_ws.py, noch vor der
// type-Weiche), ein unbekannter type wird danach folgenlos
// ignoriert. Laeuft unabhaengig von _active, auch im Hintergrund.
this._pingTimer = window.setInterval(() => {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type: "ping" }));
}
}, 240000); // 4 Minuten, deutlich unter IDLE_TIMEOUT_SECONDS (Standard 900s)
});
this.ws.addEventListener("close", (ev) => {
if (this._pingTimer) {
window.clearInterval(this._pingTimer);
this._pingTimer = null;
}
let text; let text;
if (lastErrorMessage) { if (this._lastErrorMessage) {
text = "Verbindung beendet: " + lastErrorMessage; text = "Verbindung beendet: " + this._lastErrorMessage;
} else if (ev.reason) { } else if (ev.reason) {
text = `Verbindung beendet (Code ${ev.code}): ${ev.reason}`; text = `Verbindung beendet (Code ${ev.code}): ${ev.reason}`;
} else if (ev.code && ev.code !== 1000) { } else if (ev.code && ev.code !== 1000) {
@ -79,116 +355,257 @@
} else { } else {
text = "Verbindung beendet"; text = "Verbindung beendet";
} }
statusEl.textContent = text; this._setStatus(text, true);
statusEl.classList.add("error"); console.error("SSH-Sitzung beendet:", { code: ev.code, reason: ev.reason, lastErrorMessage: this._lastErrorMessage });
console.error("SSH-Sitzung beendet:", { code: ev.code, reason: ev.reason, lastErrorMessage });
}); });
ws.addEventListener("error", (ev) => { this.ws.addEventListener("error", (ev) => {
statusEl.textContent = "Verbindungsfehler (WebSocket) -- Details siehe Browser-Konsole"; this._setStatus("Verbindungsfehler (WebSocket) -- Details siehe Browser-Konsole", true);
statusEl.classList.add("error");
console.error("SSH-WebSocket-Fehler:", ev); console.error("SSH-WebSocket-Fehler:", ev);
}); });
this.ws.addEventListener("message", (ev) => {
ws.addEventListener("message", (ev) => {
const msg = JSON.parse(ev.data); const msg = JSON.parse(ev.data);
if (msg.type === "output") { if (msg.type === "output") {
term.write(b64decodeBytes(msg.data)); this.term.write(b64decodeBytes(msg.data));
} else if (msg.type === "error") { } else if (msg.type === "error") {
lastErrorMessage = msg.message; this._lastErrorMessage = msg.message;
statusEl.textContent = "Fehler: " + msg.message; this._setStatus("Fehler: " + msg.message, true);
statusEl.classList.add("error"); } else if (msg.type === "warning") {
// E11 (Umsetzungsauftrag Teil E): Vorwarnung vor dem automatischen
// Trennen bei Erreichen der maximalen Sitzungsdauer. Nicht
// destruktiv -- der Statustext wird nach kurzer Zeit wieder auf
// den vorherigen Wert zurueckgesetzt, damit er nicht dauerhaft
// "Verbunden" verdraengt.
const previousStatus = this.statusEl.textContent;
const previousWasError = this.statusEl.classList.contains("error");
this.statusEl.textContent = "Hinweis: " + msg.message;
this.statusEl.classList.add("warning");
window.setTimeout(() => {
if (this._disposed) return;
this.statusEl.textContent = previousStatus;
this.statusEl.classList.remove("warning");
if (!previousWasError) this.statusEl.classList.remove("error");
}, 10000);
} }
}); });
term.onData((data) => { // F4 (Umsetzungsauftrag_Sonnet5.md Teil F.3.3): mehrere Instanzen
if (ws.readyState === WebSocket.OPEN) { // koennen jetzt gleichzeitig existieren (Arbeitsflaeche /workspace) --
ws.send(JSON.stringify({ type: "input", data: b64encode(data) })); // eine ausgeblendete Instanz hat Groesse 0 (display:none); fitAddon.fit()
// wuerde dort mit Cols/Rows nahe 0 enden. Sichtbarwerden holt den Refit
// ueber setActive(true) -> sendResize() ohnehin nach.
this._onWindowResize = () => { if (this._active) this.sendResize(); };
window.addEventListener("resize", this._onWindowResize);
document.addEventListener("fullscreenchange", this._onWindowResize);
} }
});
function sendResize() { sendResize() {
fitAddon.fit(); this.fitAddon.fit();
if (ws.readyState === WebSocket.OPEN) { if (this.ws && this.ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows })); this.ws.send(JSON.stringify({ type: "resize", cols: this.term.cols, rows: this.term.rows }));
} }
} }
window.addEventListener("resize", sendResize);
document.getElementById("fullscreen-btn").addEventListener("click", () => { /** Wird von einer kuenftigen Seitenleiste (Teil F.3/F.4) beim Umschalten
* aufgerufen. Bei genau einer Instanz (heutiger Stand) ohne sichtbaren
* Effekt ausser einem Refit+Fokus. */
setActive(active) {
this._active = active;
if (active) {
this.sendResize();
this.term.focus();
} else {
this._closePasteMenu();
}
}
// --- Toolbar --------------------------------------------------------
_wireToolbar() {
this.fullscreenBtn.addEventListener("click", () => {
if (!document.fullscreenElement) { if (!document.fullscreenElement) {
shell.requestFullscreen().catch(() => {}); this.shellEl.requestFullscreen().catch(() => {});
} else { } else {
document.exitFullscreen(); document.exitFullscreen();
} }
}); });
document.addEventListener("fullscreenchange", sendResize);
document.getElementById("exit-btn").addEventListener("click", () => { this.themeSelect.addEventListener("change", () => {
// Sofortiges sichtbares Feedback, damit erkennbar ist, dass der Klick const name = TERMINAL_THEMES[this.themeSelect.value] ? this.themeSelect.value : DEFAULT_THEME_NAME;
// ueberhaupt ankam -- unabhaengig davon, ob window.close() gleich this._themeName = name;
// klappt oder der Fallback (siehe unten) erst nach der Wartezeit greift. this.term.options.theme = TERMINAL_THEMES[name].theme;
statusEl.textContent = "Sitzung wird beendet ..."; storeThemeName(name);
statusEl.classList.remove("error"); this.term.focus();
try {
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
ws.close(1000, "Sitzung durch Benutzer beendet");
}
} catch (_) { /* bereits getrennt */ }
window.close();
// window.close() schliesst den Tab nur, wenn er per Skript/Link mit
// target="_blank" geoeffnet wurde (siehe dashboard.js) UND noch keine
// weitere Navigation darin stattfand -- sonst verweigern Browser das
// lautlos, ohne Fehler oder Rueckgabewert. Deshalb IMMER zusaetzlich
// nach kurzer Wartezeit zum Dashboard weiterleiten: war das Schliessen
// erfolgreich, existiert die Seite dann laengst nicht mehr und der
// Timeout laeuft ins Leere; sonst bleibt so ein Weg zurueck.
window.setTimeout(() => { window.location.href = "/dashboard"; }, 300);
}); });
// --- Dateitransfer-Panel ------------------------------------------------ this.exitBtn.addEventListener("click", () => {
// Sofortiges sichtbares Feedback, damit erkennbar ist, dass der Klick
const ftOverlay = document.getElementById("ft-overlay"); // ueberhaupt ankam -- unabhaengig davon, was der Aufrufer danach tut
const ftStatus = document.getElementById("ft-status"); // (Tab schliessen bei /terminal/{id}, Kachel entfernen bei /workspace).
const ftList = document.getElementById("ft-list"); this._setStatus("Sitzung wird beendet ...", false);
const transfers = []; this.dispose();
if (this.options.onExitRequested) this.options.onExitRequested();
function openFt() { });
ftOverlay.classList.remove("hidden");
ftStatus.textContent = "";
} }
function closeFt() {
ftOverlay.classList.add("hidden"); // --- Zwischenablage (Aufgabe #11) --------------------------------------
//
// Kopieren laeuft ueber copyOnSelect (siehe _setupTerminal()) -- hier nur
// Einfuegen per Rechtsklick. Zwei Stufen, wie im Datei-Kopfkommentar
// beschrieben: direkter Versuch, dann ein winziges Kontextmenue als
// Ruecksicherung, wenn der Browser die Berechtigung ohne explizite
// Benutzergeste verweigert.
_wireClipboard() {
this.terminalContainer.addEventListener("contextmenu", (ev) => {
ev.preventDefault();
this._attemptDirectPaste(ev.clientX, ev.clientY);
});
} }
document.getElementById("filetransfer-btn").addEventListener("click", openFt);
document.getElementById("ft-close-btn").addEventListener("click", closeFt); async _attemptDirectPaste(x, y) {
if (!navigator.clipboard || !navigator.clipboard.readText) {
this._showPasteMenu(x, y);
return;
}
try {
const text = await navigator.clipboard.readText();
this.term.paste(text);
} catch (_) {
// Kein Zugriff ohne eine Benutzergeste, die NACH diesem Handler
// beginnt (z.B. Firefox/Safari) -- Ruecksicherung unten, deren
// Klick selbst wieder eine frische Geste ist.
this._showPasteMenu(x, y);
}
}
_closePasteMenu() {
if (this._pasteMenuEl) {
this._pasteMenuEl.remove();
this._pasteMenuEl = null;
}
if (this._pasteMenuCleanup) {
this._pasteMenuCleanup();
this._pasteMenuCleanup = null;
}
}
_showPasteMenu(x, y) {
this._closePasteMenu();
// CSP (style-src 'self', kein 'unsafe-inline', siehe app.css-
// Kopfkommentar zu .hidden) verbietet JS-gesetzte element.style-
// Aenderungen genauso wie style="..."-Attribute -- daher KEINE
// pixelgenaue Positionierung ueber this.menu.style.left/top, sondern
// vier feste CSS-Klassen fuer das Viertel von .session-shell, in dem
// rechtsgeklickt wurde (siehe app.css .term-paste-menu-*).
const rect = this.shellEl.getBoundingClientRect();
const isRight = x > rect.left + rect.width / 2;
const isBottom = y > rect.top + rect.height / 2;
const menu = document.createElement("div");
menu.className =
"term-paste-menu " +
(isBottom ? "term-paste-menu-bottom" : "term-paste-menu-top") + " " +
(isRight ? "term-paste-menu-right" : "term-paste-menu-left");
const pasteBtn = document.createElement("button");
pasteBtn.type = "button";
pasteBtn.textContent = "Einfuegen";
pasteBtn.addEventListener("click", async () => {
this._closePasteMenu();
try {
const text = await navigator.clipboard.readText();
this.term.paste(text);
} catch (err) {
console.error("Einfuegen aus der Zwischenablage nicht moeglich:", err);
this._setStatus(
"Einfuegen nicht moeglich -- Browser verweigert Zwischenablage-Zugriff. Bitte Strg+Umschalt+V verwenden.",
true
);
}
this.term.focus();
});
menu.appendChild(pasteBtn);
// Als Kind von .session-shell (nicht .session-terminal) angehaengt --
// dort mischt sich nichts mit xterm.js' eigenem DOM-Teilbaum, das
// dieses Element seit term.open() exklusiv verwaltet.
this.shellEl.appendChild(menu);
this._pasteMenuEl = menu;
// Schliessen bei Klick daneben, Escape oder Scrollen -- Standard-
// verhalten fuer ein solches Mini-Kontextmenue (siehe auch
// workspace.js fuer dasselbe Muster bei anderen Overlays).
const onDocClick = (ev) => {
if (!menu.contains(ev.target)) this._closePasteMenu();
};
const onKeydown = (ev) => {
if (ev.key === "Escape") this._closePasteMenu();
};
const onScroll = () => this._closePasteMenu();
// capture:true fuer den Klick-Listener, damit er VOR einem moeglichen
// contextmenu-Listener eines anderen Elements greift; im naechsten
// Tick registriert, sonst schliesst der Rechtsklick, der das Menue
// gerade erst geoeffnet hat, es sofort wieder (derselbe Event).
window.setTimeout(() => {
document.addEventListener("click", onDocClick, true);
document.addEventListener("keydown", onKeydown);
window.addEventListener("scroll", onScroll, true);
}, 0);
this._pasteMenuCleanup = () => {
document.removeEventListener("click", onDocClick, true);
document.removeEventListener("keydown", onKeydown);
window.removeEventListener("scroll", onScroll, true);
};
}
// --- Dateitransfer-Panel ---------------------------------------------
_wireFileTransfer() {
const openFt = () => {
this.ftOverlay.classList.remove("hidden");
this.ftStatus.textContent = "";
};
const closeFt = () => {
this.ftOverlay.classList.add("hidden");
};
this.filetransferBtn.addEventListener("click", openFt);
this.ftCloseBtn.addEventListener("click", closeFt);
// Klick auf den abgedunkelten Hintergrund (nicht auf das Panel selbst) // Klick auf den abgedunkelten Hintergrund (nicht auf das Panel selbst)
// schliesst das Overlay ebenfalls -- Standardverhalten fuer solche Panels. // schliesst das Overlay ebenfalls -- Standardverhalten fuer solche Panels.
ftOverlay.addEventListener("click", (ev) => { this.ftOverlay.addEventListener("click", (ev) => {
if (ev.target === ftOverlay) closeFt(); if (ev.target === this.ftOverlay) closeFt();
}); });
function formatSize(bytes) { this._renderTransfers();
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; this.uploadPickBtn.addEventListener("click", () => this.uploadFileInput.click());
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; this.uploadFileInput.addEventListener("change", () => {
const file = this.uploadFileInput.files[0];
this.uploadFilenameLabel.textContent = file ? file.name : "Keine Datei ausgewaehlt";
if (file && !this.uploadRemotePathInput.value) {
this.uploadRemotePathInput.value = `/tmp/${file.name}`;
}
});
this.uploadSubmitBtn.addEventListener("click", () => this._doUpload());
this.downloadSubmitBtn.addEventListener("click", () => this._doDownload());
} }
function recordTransfer(direction, filename, size, ok, message) { _recordTransfer(direction, filename, size, ok, message) {
transfers.unshift({ direction, filename, size, ok, message }); this._transfers.unshift({ direction, filename, size, ok, message });
if (transfers.length > 20) transfers.length = 20; if (this._transfers.length > 20) this._transfers.length = 20;
renderTransfers(); this._renderTransfers();
} }
function renderTransfers() { _renderTransfers() {
ftList.innerHTML = ""; this.ftList.innerHTML = "";
if (transfers.length === 0) { if (this._transfers.length === 0) {
const empty = document.createElement("div"); const empty = document.createElement("div");
empty.className = "hint"; empty.className = "hint";
empty.textContent = "Noch keine Transfers in dieser Sitzung."; empty.textContent = "Noch keine Transfers in dieser Sitzung.";
ftList.appendChild(empty); this.ftList.appendChild(empty);
return; return;
} }
for (const t of transfers) { for (const t of this._transfers) {
const row = document.createElement("div"); const row = document.createElement("div");
row.className = "ft-list-row"; row.className = "ft-list-row";
@ -209,82 +626,63 @@
row.appendChild(size); row.appendChild(size);
} }
ftList.appendChild(row); this.ftList.appendChild(row);
} }
} }
renderTransfers();
// Upload async _doUpload() {
const uploadRemotePathInput = document.getElementById("ft-upload-remote-path"); const file = this.uploadFileInput.files[0];
const uploadFileInput = document.getElementById("ft-upload-file-input"); const remotePath = this.uploadRemotePathInput.value.trim();
const uploadFilenameLabel = document.getElementById("ft-upload-filename"); this.ftStatus.textContent = "";
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) { if (!file) {
ftStatus.textContent = "Bitte zuerst eine Datei auswaehlen."; this.ftStatus.textContent = "Bitte zuerst eine Datei auswaehlen.";
return; return;
} }
if (!remotePath) { if (!remotePath) {
ftStatus.textContent = "Bitte einen Zielpfad angeben."; this.ftStatus.textContent = "Bitte einen Zielpfad angeben.";
return; return;
} }
const formData = new FormData(); const formData = new FormData();
formData.append("file", file); formData.append("file", file);
ftStatus.textContent = `Lade ${file.name} hoch ...`; this.ftStatus.textContent = `Lade ${file.name} hoch ...`;
try { try {
const res = await fetch( const res = await fetch(
`/ssh/${hostId}/files/upload?remote_path=${encodeURIComponent(remotePath)}`, `/ssh/${this.hostId}/files/upload?remote_path=${encodeURIComponent(remotePath)}`,
{ method: "POST", credentials: "same-origin", body: formData } { method: "POST", credentials: "same-origin", body: formData }
); );
// Robust gegen Nicht-JSON-Antworten (z.B. ein unbehandelter serverseitiger // Robust gegen Nicht-JSON-Antworten (z.B. ein unbehandelter serverseitiger
// Fehler, den Starlette als Klartext "Internal Server Error" statt JSON // Fehler, den Starlette als Klartext "Internal Server Error" statt JSON
// ausliefert) -- sonst crasht hier res.json() mit einer verwirrenden // ausliefert) -- sonst crasht hier res.json() mit einer verwirrenden
// "Unexpected token 'I' ..."-Meldung statt der eigentlichen Fehlerursache // "Unexpected token 'I' ..."-Meldung statt der eigentlichen Fehlerursache.
// (siehe GET .../files/download, das dasselbe Muster schon nutzt).
let data = null; let data = null;
try { try {
data = await res.json(); data = await res.json();
} catch (_) { /* Antwort war kein JSON */ } } catch (_) { /* Antwort war kein JSON */ }
if (!res.ok) throw new Error((data && data.detail) || `Upload fehlgeschlagen (HTTP ${res.status})`); if (!res.ok) throw new Error((data && data.detail) || `Upload fehlgeschlagen (HTTP ${res.status})`);
ftStatus.textContent = `Upload abgeschlossen (AV: ${data.av_scan_result})`; this.ftStatus.textContent = `Upload abgeschlossen (AV: ${data.av_scan_result})`;
recordTransfer("upload", file.name, data.size, true, ""); this._recordTransfer("upload", file.name, data.size, true, "");
uploadFileInput.value = ""; this.uploadFileInput.value = "";
uploadFilenameLabel.textContent = "Keine Datei ausgewaehlt"; this.uploadFilenameLabel.textContent = "Keine Datei ausgewaehlt";
uploadRemotePathInput.value = ""; this.uploadRemotePathInput.value = "";
} catch (err) { } catch (err) {
ftStatus.textContent = "Upload-Fehler: " + err.message; this.ftStatus.textContent = "Upload-Fehler: " + err.message;
recordTransfer("upload", file.name, 0, false, err.message); this._recordTransfer("upload", file.name, 0, false, err.message);
}
} }
});
// Download async _doDownload() {
const downloadRemotePathInput = document.getElementById("ft-download-remote-path"); const remotePath = this.downloadRemotePathInput.value.trim();
this.ftStatus.textContent = "";
document.getElementById("ft-download-submit-btn").addEventListener("click", async () => {
const remotePath = downloadRemotePathInput.value.trim();
ftStatus.textContent = "";
if (!remotePath) { if (!remotePath) {
ftStatus.textContent = "Bitte einen Pfad angeben."; this.ftStatus.textContent = "Bitte einen Pfad angeben.";
return; return;
} }
const filename = remotePath.split("/").filter(Boolean).pop() || "download"; const filename = remotePath.split("/").filter(Boolean).pop() || "download";
ftStatus.textContent = `Lade ${filename} herunter ...`; this.ftStatus.textContent = `Lade ${filename} herunter ...`;
try { try {
const res = await fetch( const res = await fetch(
`/ssh/${hostId}/files/download?remote_path=${encodeURIComponent(remotePath)}`, `/ssh/${this.hostId}/files/download?remote_path=${encodeURIComponent(remotePath)}`,
{ method: "GET", credentials: "same-origin" } { method: "GET", credentials: "same-origin" }
); );
if (!res.ok) { if (!res.ok) {
@ -305,12 +703,73 @@
link.remove(); link.remove();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
ftStatus.textContent = `Download abgeschlossen: ${filename}`; this.ftStatus.textContent = `Download abgeschlossen: ${filename}`;
recordTransfer("download", filename, blob.size, true, ""); this._recordTransfer("download", filename, blob.size, true, "");
downloadRemotePathInput.value = ""; this.downloadRemotePathInput.value = "";
} catch (err) { } catch (err) {
ftStatus.textContent = "Download-Fehler: " + err.message; this.ftStatus.textContent = "Download-Fehler: " + err.message;
recordTransfer("download", filename, 0, false, err.message); this._recordTransfer("download", filename, 0, false, err.message);
} }
}
// --- Abbau ------------------------------------------------------------
/** Idempotent: schliesst WebSocket + Terminal, entfernt dokumentweite
* Listener und das eigene DOM-Element. Sicher mehrfach aufrufbar (z.B.
* einmal ueber den Exit-Knopf, danach nochmal defensiv vom Aufrufer). */
dispose() {
if (this._disposed) return;
this._disposed = true;
if (this._pingTimer) {
window.clearInterval(this._pingTimer);
this._pingTimer = null;
}
this._closePasteMenu();
try {
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
this.ws.close(1000, "Sitzung durch Benutzer beendet");
}
} catch (_) { /* bereits getrennt */ }
if (this._onWindowResize) {
window.removeEventListener("resize", this._onWindowResize);
document.removeEventListener("fullscreenchange", this._onWindowResize);
}
try {
this.term.dispose();
} catch (_) { /* ignorieren */ }
if (this.element.parentNode) this.element.parentNode.removeChild(this.element);
}
}
window.SshTerminalSession = SshTerminalSession;
// --- Bootstrap fuer die eigenstaendige Seite /terminal/{host_id} --------
//
// F1: templates/terminal.html liefert nur noch einen leeren Container mit
// data-host-id -- diese Seite bleibt als duenner Rahmen um denselben
// Baustein bestehen (F.2), keine zweite Implementierung. Die kuenftige
// Arbeitsflaeche /workspace (Teil F.3, spaetere Stufe) hat KEIN Element
// mit dieser ID und erzeugt ihre Instanzen stattdessen selbst -- dieser
// Bootstrap-Block bleibt dafuer wirkungslos, wenn terminal.js dort
// eingebunden wird.
const container = document.getElementById("session-container");
if (container) {
const hostId = container.dataset.hostId;
const session = new SshTerminalSession(hostId, {
onExitRequested: () => {
window.close();
// window.close() schliesst den Tab nur, wenn er per Skript/Link mit
// target="_blank" geoeffnet wurde (siehe dashboard.js) UND noch keine
// weitere Navigation darin stattfand -- sonst verweigern Browser das
// lautlos, ohne Fehler oder Rueckgabewert. Deshalb IMMER zusaetzlich
// nach kurzer Wartezeit zum Dashboard weiterleiten: war das Schliessen
// erfolgreich, existiert die Seite dann laengst nicht mehr und der
// Timeout laeuft ins Leere; sonst bleibt so ein Weg zurueck.
window.setTimeout(() => { window.location.href = "/dashboard"; }, 300);
},
}); });
container.appendChild(session.element);
session.connect();
session.setActive(true);
}
})(); })();

532
static/js/workspace.js Normal file
View File

@ -0,0 +1,532 @@
/*
* Arbeitsflaeche (Umsetzungsauftrag_Sonnet5.md Teil F.3): Seitenleiste +
* Hauptbereich. Nutzt dieselben Bausteine wie /terminal/{id} und /rdp/{id}
* (SshTerminalSession/RdpSession aus terminal.js/rdp.js, Stufe F1) -- keine
* zweite Implementierung (F.2).
*
* Stufe F4 (Umsetzungsauftrag_Sonnet5.md Teil F.3.2-F.3.4): mehrere
* gleichzeitige Sitzungen. Jede geoeffnete Sitzung bleibt als eigene
* SshTerminalSession/RdpSession-Instanz im DOM bestehen (this.element wird
* NICHT entfernt, wenn man wegschaltet) -- nur ueber die bestehende
* `.hidden`-Klasse (app.css, CSP-konformer Sichtbarkeits-Toggle wie an
* anderer Stelle im Projekt) ein-/ausgeblendet. Genau eine ist gleichzeitig
* sichtbar/aktiv (setActive(true)); alle anderen bleiben im Hintergrund
* voll verbunden (Betreiberentscheidung dieser Session, siehe
* FORTSETZUNG_Teil_F.md Abschnitt 0 Punkt 2 -- kein Pausenzustand).
*
* Bekannter Rest-Umfang dieser Stufe (siehe FORTSETZUNG_Teil_F.md):
* - Server-Sitzungs-ID-Zuordnung ist "best effort" (claimServerSessionId
* unten), weil terminal_ws.py/ws_tunnel.py dem Client seine eigene
* session_id an keiner Stelle mitteilen (siehe F3-Limitierung). Loest
* das Duplikat-/Verwechslungsrisiko fuer den Normalfall zuverlaessig,
* mit einem sehr kleinen, dokumentierten Restrisiko bei echten
* Wettlaeufen (siehe unten).
* - Keine Live-Aktualisierung der Seitenleiste fuer Sitzungen, die in
* ANDEREN Tabs geoeffnet/beendet werden, waehrend diese Seite laeuft --
* nur bei Laden/Sitzungsende dieser Seite neu abgefragt (kein Polling).
*
* Stufe F5 (Umsetzungsauftrag_Sonnet5.md Teil F.3.8 + Feinschliff):
* - Obergrenzen sichtbar: GET /catalog/session-limits liefert die
* konfigurierten Werte (app/config.py) plus den aktuellen Stand: dieselbe
* Logik wie die serverseitige Durchsetzung in terminal_ws.py/
* ws_tunnel.py (E.4), NICHT neu erfunden -- nur zum Anzeigen VOR dem
* Verbindungsversuch. "+ Neue Sitzung" und jeder "Verbinden"-Knopf im
* Katalog werden deaktiviert, sobald eine der beiden Grenzen erreicht
* ist. Die serverseitige Durchsetzung (WS-Code 4429) bleibt die
* verbindliche Kontrolle -- diese Anzeige ist rein informativ und kann
* durch die inhaerente Race zwischen Anzeige-Refresh und tatsaechlichem
* Verbindungsaufbau leicht veralten (siehe unten, wie bei
* claimServerSessionId: gleiche Klasse von Eventual-Consistency).
* - Sammelzustand-Kennzeichnung: eine Hintergrundsitzung mit Fehler oder
* Trennung wird auf ihrer Kachel UND in einer Sammel-Zeile ueber der
* Kachelliste markiert, damit man das Problem nicht erst durch
* Umschalten auf jede einzelne Sitzung entdeckt.
*/
(() => {
"use strict";
const tilesEl = document.getElementById("workspace-tiles");
const catalogViewEl = document.getElementById("workspace-catalog-view");
const sessionsAreaEl = document.getElementById("workspace-sessions-area");
const newBtn = document.getElementById("workspace-new-btn");
const whoamiEl = document.getElementById("whoami");
const limitHintEl = document.getElementById("workspace-limit-hint");
const attentionSummaryEl = document.getElementById("workspace-attention-summary");
let cachedHosts = [];
let remoteSessions = [];
const claimedSessionIds = new Set();
const openSessions = []; // { uid, instance, hostId, hostname, protocol, startedAtMs, statusText, isError, elapsedSpan, tileEl, serverId }
let activeUid = null; // uid der gerade sichtbaren Sitzung, oder null (Katalog sichtbar)
let nextUid = 1;
let elapsedTimer = null;
let sessionLimits = null; // { max_per_user, current_user_count, user_limit_applies, at_user_limit, max_global, current_global_count, at_global_limit }
async function getJson(url, opts) {
const res = await fetch(url, { credentials: "same-origin", ...(opts || {}) });
if (res.status === 401) {
window.location.href = "/";
throw new Error("nicht angemeldet");
}
return res.json();
}
function findOpenSession(uid) {
return openSessions.find((s) => s.uid === uid) || null;
}
// --- Server-Sitzungs-ID best effort zuordnen -------------------------
async function claimServerSessionId(record) {
for (let attempt = 0; attempt < 5; attempt++) {
try {
const rows = await getJson("/catalog/sessions?active_only=true");
const candidates = rows
.filter((r) => r.host_id === record.hostId && !claimedSessionIds.has(r.id))
.sort((a, b) => new Date(b.started_at) - new Date(a.started_at));
if (candidates.length > 0) {
record.serverId = candidates[0].id;
claimedSessionIds.add(candidates[0].id);
return;
}
} catch (err) {
console.error("Sitzungs-ID konnte nicht abgefragt werden:", err);
}
await new Promise((resolve) => window.setTimeout(resolve, 250));
}
console.warn(
`Server-Sitzungs-ID fuer ${record.hostname} konnte nicht ermittelt werden (best effort -- ` +
"siehe FORTSETZUNG_Teil_F.md, Stufe F4, bekannte Limitierung)."
);
}
// --- Obergrenzen (F5, F.3.8) -------------------------------------------
//
// Nur eine Anzeige der bereits bestehenden serverseitigen Durchsetzung
// (E.4, terminal_ws.py/ws_tunnel.py) -- siehe Datei-Kopfkommentar.
async function refreshSessionLimits() {
try {
sessionLimits = await getJson("/catalog/session-limits");
} catch (err) {
console.error("Obergrenzen konnten nicht geladen werden:", err);
sessionLimits = null;
}
}
function atAnyLimit() {
return !!sessionLimits && (sessionLimits.at_user_limit || sessionLimits.at_global_limit);
}
function renderLimitHint() {
if (!limitHintEl) return;
if (!sessionLimits) {
limitHintEl.textContent = "";
limitHintEl.classList.remove("workspace-limit-hint--at-limit");
} else {
const parts = [];
if (sessionLimits.user_limit_applies) {
parts.push(`Eigene Sitzungen: ${sessionLimits.current_user_count}/${sessionLimits.max_per_user}`);
}
parts.push(`Server gesamt: ${sessionLimits.current_global_count}/${sessionLimits.max_global}`);
limitHintEl.textContent = parts.join(" · ");
limitHintEl.classList.toggle("workspace-limit-hint--at-limit", atAnyLimit());
}
const atLimit = atAnyLimit();
newBtn.disabled = atLimit;
newBtn.title = atLimit
? "Obergrenze erreicht (F.3.8) -- erst eine bestehende Sitzung schliessen."
: "";
}
// --- Sidebar ----------------------------------------------------------
function statusClass(text, isError) {
if (isError) return "error";
if (text === "Verbunden") return "connected";
if (text === "Getrennt" || text.startsWith("Verbindung beendet")) return "disconnected";
return "connecting";
}
// F5: eine Hintergrundsitzung mit Fehler oder Trennung braucht
// Aufmerksamkeit, ohne dass man erst umschaltet, um es zu sehen.
function hasAttention(record) {
const cls = statusClass(record.statusText, record.isError);
return cls === "error" || cls === "disconnected";
}
function formatElapsed(startedAtMs) {
const secs = Math.max(0, Math.floor((Date.now() - startedAtMs) / 1000));
const m = Math.floor(secs / 60);
const s = secs % 60;
return `${m}:${String(s).padStart(2, "0")}`;
}
function buildLocalTile(record) {
const isActive = record.uid === activeUid;
const tile = document.createElement("div");
tile.className = "workspace-tile " + (isActive ? "workspace-tile-active" : "workspace-tile-background");
tile.tabIndex = 0;
const top = document.createElement("div");
top.className = "workspace-tile-top";
const dot = document.createElement("span");
dot.className = "workspace-status-dot " + statusClass(record.statusText, record.isError);
top.appendChild(dot);
const name = document.createElement("span");
name.className = "workspace-tile-hostname";
name.textContent = record.hostname;
top.appendChild(name);
const proto = document.createElement("span");
proto.className = "badge";
proto.textContent = record.protocol.toUpperCase();
top.appendChild(proto);
tile.appendChild(top);
const meta = document.createElement("div");
meta.className = "workspace-tile-meta";
const elapsedSpan = document.createElement("span");
elapsedSpan.className = "workspace-tile-elapsed";
elapsedSpan.textContent = formatElapsed(record.startedAtMs);
meta.appendChild(elapsedSpan);
const statusSpan = document.createElement("span");
let bgLabel = "im Hintergrund verbunden";
if (!isActive && hasAttention(record)) {
tile.classList.add("workspace-tile-attention");
bgLabel = statusClass(record.statusText, record.isError) === "error"
? "im Hintergrund: Fehler"
: "im Hintergrund: getrennt";
}
statusSpan.textContent = isActive ? record.statusText : bgLabel;
meta.appendChild(statusSpan);
tile.appendChild(meta);
const closeBtn = document.createElement("button");
closeBtn.type = "button";
closeBtn.className = "btn-secondary btn-small workspace-tile-close-btn";
closeBtn.textContent = "Schliessen";
closeBtn.addEventListener("click", (ev) => {
ev.stopPropagation();
closeLocalSession(record.uid);
});
tile.appendChild(closeBtn);
if (!isActive) {
tile.addEventListener("click", () => showSession(record.uid));
tile.addEventListener("keydown", (ev) => {
if (ev.key === "Enter" || ev.key === " ") showSession(record.uid);
});
}
record.elapsedSpan = elapsedSpan;
return tile;
}
function buildRemoteTile(session) {
const tile = document.createElement("div");
tile.className = "workspace-tile workspace-tile-remote";
const top = document.createElement("div");
top.className = "workspace-tile-top";
const dot = document.createElement("span");
dot.className = "workspace-status-dot connected";
top.appendChild(dot);
const name = document.createElement("span");
name.className = "workspace-tile-hostname";
name.textContent = session.hostname;
top.appendChild(name);
const proto = document.createElement("span");
proto.className = "badge";
proto.textContent = session.protocol.toUpperCase();
top.appendChild(proto);
tile.appendChild(top);
const meta = document.createElement("div");
meta.className = "workspace-tile-meta";
meta.textContent = "laeuft in einem anderen Tab/Fenster";
tile.appendChild(meta);
const closeBtn = document.createElement("button");
closeBtn.type = "button";
closeBtn.className = "btn-secondary btn-small workspace-tile-close-btn";
closeBtn.textContent = "Beenden";
closeBtn.addEventListener("click", async () => {
closeBtn.disabled = true;
closeBtn.textContent = "Wird beendet ...";
try {
const res = await fetch(`/catalog/sessions/${session.id}/terminate`, {
method: "POST", credentials: "same-origin",
});
if (!res.ok && res.status !== 409) throw new Error(`HTTP ${res.status}`);
} catch (err) {
console.error("Sitzung konnte nicht beendet werden:", err);
}
await Promise.all([refreshRemoteSessions(), refreshSessionLimits()]);
renderSidebar();
});
tile.appendChild(closeBtn);
return tile;
}
function renderSidebar() {
renderLimitHint();
// F5: Sammelzustand -- Hintergrundsitzungen mit Fehler/Trennung ueber
// der Kachelliste zusammenfassen (die aktive Sitzung zaehlt nicht mit,
// deren Zustand sieht man ohnehin direkt).
if (attentionSummaryEl) {
const attentionCount = openSessions.filter((r) => r.uid !== activeUid && hasAttention(r)).length;
if (attentionCount > 0) {
attentionSummaryEl.textContent = attentionCount === 1
? "⚠ 1 Sitzung im Hintergrund mit Problem (Fehler/getrennt)."
: `${attentionCount} Sitzungen im Hintergrund mit Problem (Fehler/getrennt).`;
attentionSummaryEl.classList.remove("hidden");
} else {
attentionSummaryEl.textContent = "";
attentionSummaryEl.classList.add("hidden");
}
}
tilesEl.innerHTML = "";
for (const record of openSessions) {
tilesEl.appendChild(buildLocalTile(record));
}
for (const s of remoteSessions) {
tilesEl.appendChild(buildRemoteTile(s));
}
if (openSessions.length === 0 && remoteSessions.length === 0) {
const empty = document.createElement("div");
empty.className = "hint workspace-empty-hint";
empty.textContent = "Keine offenen Sitzungen.";
tilesEl.appendChild(empty);
}
if (elapsedTimer) {
window.clearInterval(elapsedTimer);
elapsedTimer = null;
}
if (openSessions.length > 0) {
elapsedTimer = window.setInterval(() => {
for (const record of openSessions) {
if (record.elapsedSpan) record.elapsedSpan.textContent = formatElapsed(record.startedAtMs);
}
}, 1000);
}
}
async function refreshRemoteSessions() {
try {
const rows = await getJson("/catalog/sessions?active_only=true");
// Best-effort-Filter: alle bereits als lokal geoeffnet zugeordneten
// Server-Sitzungen (claimServerSessionId) duerfen NICHT zusaetzlich
// als "laeuft anderswo" auftauchen -- sonst erschiene jede eigene
// Sitzung dieser Seite doppelt.
remoteSessions = rows.filter((r) => !claimedSessionIds.has(r.id));
} catch (err) {
console.error("Eigene Sitzungen konnten nicht geladen werden:", err);
remoteSessions = [];
}
}
// --- Hauptbereich: Hostkatalog ---------------------------------------
function hostCard(host) {
const div = document.createElement("div");
div.className = "host-card";
const name = document.createElement("div");
name.className = "hostname";
name.textContent = host.hostname;
div.appendChild(name);
const meta = document.createElement("div");
meta.className = "meta";
meta.textContent = `${host.protocol.toUpperCase()} · ${host.os_type} · ${host.address}`;
div.appendChild(meta);
const actions = document.createElement("div");
actions.className = "actions";
const connectBtn = document.createElement("button");
connectBtn.type = "button";
connectBtn.textContent = "Verbinden";
if (atAnyLimit()) {
// F5 (F.3.8): Obergrenze bereits VOR dem Verbindungsversuch sichtbar
// machen, statt den Nutzer erst beim WS-Handshake mit Code 4429
// abzuweisen (serverseitige Durchsetzung bleibt unveraendert, siehe
// Datei-Kopfkommentar).
connectBtn.disabled = true;
connectBtn.title = "Obergrenze erreicht (F.3.8) -- erst eine bestehende Sitzung schliessen.";
}
connectBtn.addEventListener("click", () => startSession(host));
actions.appendChild(connectBtn);
div.appendChild(actions);
return div;
}
function renderCatalog() {
catalogViewEl.innerHTML = "";
const wrap = document.createElement("div");
wrap.className = "container wide workspace-catalog";
const heading = document.createElement("h2");
heading.textContent = "Host waehlen";
wrap.appendChild(heading);
if (cachedHosts.length === 0) {
const hint = document.createElement("p");
hint.className = "hint";
hint.textContent = "Keine Hosts zugewiesen. Bitte an einen Administrator wenden.";
wrap.appendChild(hint);
} else {
const list = document.createElement("div");
list.className = "host-list";
for (const host of cachedHosts) list.appendChild(hostCard(host));
wrap.appendChild(list);
}
catalogViewEl.appendChild(wrap);
}
// --- Umschalten zwischen Katalog und Sitzungen -------------------------
function showCatalog() {
activeUid = null;
for (const record of openSessions) {
record.instance.element.classList.add("hidden");
record.instance.setActive(false);
}
sessionsAreaEl.classList.add("hidden");
catalogViewEl.classList.remove("hidden");
renderCatalog();
renderSidebar();
}
function showSession(uid) {
const target = findOpenSession(uid);
if (!target) return;
activeUid = uid;
for (const record of openSessions) {
const visible = record.uid === uid;
record.instance.element.classList.toggle("hidden", !visible);
// F4 (F.3.2/F.3.3): setActive() steuert sowohl den Tastatur-/Paste-
// Filter (RdpSession) als auch den Refit beim Sichtbarwerden (beide
// Klassen) -- siehe terminal.js/rdp.js.
record.instance.setActive(visible);
}
catalogViewEl.classList.add("hidden");
sessionsAreaEl.classList.remove("hidden");
renderSidebar();
}
// --- Sitzung starten/beenden -------------------------------------------
function startSession(host) {
const uid = nextUid++;
const options = {
onStatusChange: (text, isError) => {
const record = findOpenSession(uid);
if (!record) return;
record.statusText = text;
record.isError = isError;
renderSidebar();
},
onExitRequested: () => {
// Der interne Exit-Knopf der Sitzung selbst wurde geklickt -- dessen
// dispose() lief bereits, hier nur noch den Arbeitsflaechen-Zustand
// nachziehen (kein zweiter dispose()-Aufruf).
handleSessionClosed(uid, /* alreadyDisposed */ true);
},
};
const instance = host.protocol === "rdp" ? new RdpSession(host.id, options) : new SshTerminalSession(host.id, options);
const record = {
uid, instance, hostId: host.id, hostname: host.hostname, protocol: host.protocol,
startedAtMs: Date.now(), statusText: "Verbinde ...", isError: false, serverId: null,
};
openSessions.push(record);
instance.element.classList.add("hidden");
sessionsAreaEl.appendChild(instance.element);
instance.connect();
claimServerSessionId(record);
showSession(uid);
// F5: Obergrenzen-Anzeige nachziehen -- der Server registriert die neue
// Sitzung (app/security/active_sessions.py::register) erst, nachdem der
// WS-Handshake durchgelaufen ist, daher dieselbe kleine Verzoegerung wie
// bei handleSessionClosed() unten statt eines sofortigen (noch
// veralteten) Refreshs.
window.setTimeout(() => {
refreshSessionLimits().then(() => renderSidebar());
}, 300);
}
function closeLocalSession(uid) {
const record = findOpenSession(uid);
if (!record) return;
record.instance.dispose(); // idempotent
handleSessionClosed(uid, /* alreadyDisposed */ true);
}
function handleSessionClosed(uid, alreadyDisposed) {
const idx = openSessions.findIndex((s) => s.uid === uid);
if (idx === -1) return;
const [record] = openSessions.splice(idx, 1);
if (!alreadyDisposed) record.instance.dispose();
if (record.serverId !== null) claimedSessionIds.delete(record.serverId);
if (activeUid === uid) {
const next = openSessions[openSessions.length - 1];
if (next) {
showSession(next.uid);
} else {
showCatalog();
}
} else {
renderSidebar();
}
// Kleine Verzoegerung: der serverseitige finally-Block (WS-Handler)
// setzt ended_at erst, nachdem der Socket-Close tatsaechlich verarbeitet
// wurde -- ohne Verzoegerung koennte die soeben beendete Sitzung noch
// kurz als aktiv gelistet werden (unkritisch, naechster Refresh raeumt
// es auf, siehe FORTSETZUNG_Teil_F.md).
window.setTimeout(() => {
Promise.all([refreshRemoteSessions(), refreshSessionLimits()]).then(() => renderSidebar());
}, 300);
}
newBtn.addEventListener("click", () => showCatalog());
// --- Start ------------------------------------------------------------
document.getElementById("logout-btn").addEventListener("click", async () => {
await fetch("/auth/logout", { method: "POST", credentials: "same-origin" });
window.location.href = "/";
});
async function main() {
const me = await getJson("/auth/me");
whoamiEl.textContent = `${me.username}${me.is_admin ? " (Admin)" : ""}`;
cachedHosts = await getJson("/catalog/hosts");
await refreshRemoteSessions();
await refreshSessionLimits();
renderCatalog();
renderSidebar();
// ?host=<id> (von dashboard.js "Verbinden" gesetzt): direkt verbinden
// statt erst den Katalog zu zeigen.
const params = new URLSearchParams(window.location.search);
const hostParam = params.get("host");
if (hostParam) {
const host = cachedHosts.find((h) => String(h.id) === hostParam);
if (host) {
startSession(host);
return;
}
}
showCatalog();
}
main().catch((err) => console.error(err));
})();

View File

@ -29,7 +29,6 @@
<button type="button" class="tab-btn" data-tab="credentials">Zugangsdaten</button> <button type="button" class="tab-btn" data-tab="credentials">Zugangsdaten</button>
<button type="button" class="tab-btn" data-tab="roles">Rollen</button> <button type="button" class="tab-btn" data-tab="roles">Rollen</button>
<button type="button" class="tab-btn" data-tab="tokens">API-Tokens</button> <button type="button" class="tab-btn" data-tab="tokens">API-Tokens</button>
<button type="button" class="tab-btn hidden" data-tab="tenants" id="tenants-tab-btn">Mandanten</button>
<button type="button" class="tab-btn hidden" data-tab="sessions" id="sessions-tab-btn">Sessions</button> <button type="button" class="tab-btn hidden" data-tab="sessions" id="sessions-tab-btn">Sessions</button>
<button type="button" class="tab-btn hidden" data-tab="connlog" id="connlog-tab-btn">Verbindungslog</button> <button type="button" class="tab-btn hidden" data-tab="connlog" id="connlog-tab-btn">Verbindungslog</button>
<button type="button" class="tab-btn" data-tab="audit">Audit-Log</button> <button type="button" class="tab-btn" data-tab="audit">Audit-Log</button>
@ -63,7 +62,7 @@
<thead> <thead>
<tr> <tr>
<th>ID</th><th>Benutzername</th><th>Admin</th><th>Aktiv</th> <th>ID</th><th>Benutzername</th><th>Admin</th><th>Aktiv</th>
<th>TOTP</th><th>Mandant</th><th>Erstellt</th><th></th> <th>TOTP</th><th>Erstellt</th><th></th>
</tr> </tr>
</thead> </thead>
<tbody></tbody> <tbody></tbody>
@ -104,10 +103,6 @@
<label for="gc-description">Beschreibung (optional)</label> <label for="gc-description">Beschreibung (optional)</label>
<input type="text" id="gc-description" maxlength="1024"> <input type="text" id="gc-description" maxlength="1024">
</div> </div>
<div id="gc-tenant-box">
<label for="gc-tenant">Mandant</label>
<select id="gc-tenant" required></select>
</div>
<button type="submit">Anlegen</button> <button type="submit">Anlegen</button>
</form> </form>
</div> </div>
@ -117,7 +112,7 @@
<p class="hint">Rollen fuer eine Gruppe (= Verbindungen mit der ganzen Gruppe teilen) werden im Tab "Rollen" vergeben.</p> <p class="hint">Rollen fuer eine Gruppe (= Verbindungen mit der ganzen Gruppe teilen) werden im Tab "Rollen" vergeben.</p>
<div class="table-wrap"> <div class="table-wrap">
<table class="data-table" id="groups-table"> <table class="data-table" id="groups-table">
<thead><tr><th>ID</th><th>Name</th><th>Beschreibung</th><th>Mandant</th><th>Mitglieder</th><th></th></tr></thead> <thead><tr><th>ID</th><th>Name</th><th>Beschreibung</th><th>Mitglieder</th><th></th></tr></thead>
<tbody></tbody> <tbody></tbody>
</table> </table>
</div> </div>
@ -170,16 +165,12 @@
<label for="hgc-description">Beschreibung (optional)</label> <label for="hgc-description">Beschreibung (optional)</label>
<input type="text" id="hgc-description" maxlength="1024"> <input type="text" id="hgc-description" maxlength="1024">
</div> </div>
<div id="hgc-tenant-box">
<label for="hgc-tenant">Mandant</label>
<select id="hgc-tenant" required></select>
</div>
<button type="submit">Anlegen</button> <button type="submit">Anlegen</button>
</form> </form>
<h3>Hostgruppen</h3> <h3>Hostgruppen</h3>
<div class="table-wrap"> <div class="table-wrap">
<table class="data-table" id="hostgroups-table"> <table class="data-table" id="hostgroups-table">
<thead><tr><th>ID</th><th>Name</th><th>Beschreibung</th><th>Mandant</th><th></th></tr></thead> <thead><tr><th>ID</th><th>Name</th><th>Beschreibung</th><th></th></tr></thead>
<tbody></tbody> <tbody></tbody>
</table> </table>
</div> </div>
@ -251,7 +242,7 @@
<div class="table-wrap"> <div class="table-wrap">
<table class="data-table" id="hosts-table"> <table class="data-table" id="hosts-table">
<thead> <thead>
<tr><th>ID</th><th>Hostname</th><th>Adresse</th><th>Protokoll</th><th>OS</th><th>Hostgruppe</th><th>Mandant</th><th>Status</th><th></th></tr> <tr><th>ID</th><th>Hostname</th><th>Adresse</th><th>Protokoll</th><th>OS</th><th>Hostgruppe</th><th>Status</th><th></th></tr>
</thead> </thead>
<tbody></tbody> <tbody></tbody>
</table> </table>
@ -361,10 +352,6 @@
<option value="ca-cert">ca-cert</option> <option value="ca-cert">ca-cert</option>
</select> </select>
</div> </div>
<div id="skc-tenant-box">
<label for="skc-tenant">Mandant</label>
<select id="skc-tenant" required></select>
</div>
<div class="full-width"> <div class="full-width">
<div class="ft-btn-row"> <div class="ft-btn-row">
<button type="button" class="btn-secondary btn-small" id="skc-generate-btn">Schluessel automatisch generieren</button> <button type="button" class="btn-secondary btn-small" id="skc-generate-btn">Schluessel automatisch generieren</button>
@ -389,7 +376,7 @@
<h3>Vorhandene SSH-Keys</h3> <h3>Vorhandene SSH-Keys</h3>
<div class="table-wrap"> <div class="table-wrap">
<table class="data-table" id="ssh-keys-table"> <table class="data-table" id="ssh-keys-table">
<thead><tr><th>ID</th><th>Label</th><th>Benutzer</th><th>Typ</th><th>Mandant</th><th>Passphrase</th><th>Erstellt</th><th>Zuletzt ersetzt</th><th></th></tr></thead> <thead><tr><th>ID</th><th>Label</th><th>Benutzer</th><th>Typ</th><th>Passphrase</th><th>Erstellt</th><th>Zuletzt ersetzt</th><th></th></tr></thead>
<tbody></tbody> <tbody></tbody>
</table> </table>
</div> </div>
@ -463,10 +450,6 @@
<label for="rcc-domain">Domaene (optional)</label> <label for="rcc-domain">Domaene (optional)</label>
<input type="text" id="rcc-domain" maxlength="128" autocomplete="off"> <input type="text" id="rcc-domain" maxlength="128" autocomplete="off">
</div> </div>
<div id="rcc-tenant-box">
<label for="rcc-tenant">Mandant</label>
<select id="rcc-tenant" required></select>
</div>
<div class="full-width"> <div class="full-width">
<label for="rcc-password">Passwort</label> <label for="rcc-password">Passwort</label>
<input type="text" id="rcc-password" required maxlength="512"> <input type="text" id="rcc-password" required maxlength="512">
@ -477,7 +460,7 @@
<h3>Vorhandene RDP-Zugangsdaten</h3> <h3>Vorhandene RDP-Zugangsdaten</h3>
<div class="table-wrap"> <div class="table-wrap">
<table class="data-table" id="rdp-creds-table"> <table class="data-table" id="rdp-creds-table">
<thead><tr><th>ID</th><th>Label</th><th>Benutzer</th><th>Domaene</th><th>Mandant</th><th>Zugewiesene Server</th><th>Zuletzt rotiert</th><th></th></tr></thead> <thead><tr><th>ID</th><th>Label</th><th>Benutzer</th><th>Domaene</th><th>Zugewiesene Server</th><th>Zuletzt rotiert</th><th></th></tr></thead>
<tbody></tbody> <tbody></tbody>
</table> </table>
</div> </div>
@ -515,30 +498,11 @@
<!-- ================= Rollen ================= --> <!-- ================= Rollen ================= -->
<div class="tab-panel hidden" id="tab-roles"> <div class="tab-panel hidden" id="tab-roles">
<div class="panel"> <div class="panel">
<h2>Rolle(n) an Benutzer vergeben</h2> <h2>Effektive Rollen je Benutzer</h2>
<p class="hint">Mehrfachauswahl moeglich -- alle ausgewaehlten Rollen werden in einem Schritt vergeben.</p> <p class="hint">Direktvergabe an einzelne Benutzer ist entfallen (Teil D) -- diese Tabelle ist eine ABGELEITETE, nicht editierbare Sicht: sie zeigt, welche Rolle ein Benutzer ueber welche Benutzergruppe auf welcher Hostgruppe aktuell haelt. Aendern/Entziehen laeuft ausschliesslich ueber die Gruppenvergabe unten -- steht ein Benutzer hier ueber mehr als eine Gruppe mit derselben Rolle/Hostgruppe, erscheinen entsprechend mehrere Zeilen.</p>
<form id="role-grant-form" class="form-grid">
<div>
<label for="rg-user">Benutzer</label>
<select id="rg-user" required></select>
</div>
<div>
<label for="rg-hostgroup">Hostgruppe</label>
<select id="rg-hostgroup" required></select>
</div>
<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>
<input type="text" id="rg-expires" placeholder="2026-12-31T00:00:00.000Z" maxlength="40">
</div>
<button type="submit">Vergeben</button>
</form>
<div class="table-wrap"> <div class="table-wrap">
<table class="data-table" id="role-grants-table"> <table class="data-table" id="role-grants-table">
<thead><tr><th>Benutzer</th><th>Hostgruppe</th><th>Rolle</th><th>Ablauf</th><th></th></tr></thead> <thead><tr><th>Benutzer</th><th>Hostgruppe</th><th>Rolle</th><th>Gruppe</th><th>Ablauf</th></tr></thead>
<tbody></tbody> <tbody></tbody>
</table> </table>
</div> </div>
@ -573,13 +537,47 @@
</table> </table>
</div> </div>
</div> </div>
<div class="panel">
<h2>Zugangsdaten fuer Gruppe freigeben (Achse B)</h2>
<p class="hint">Bestimmt WOMIT sich eine Gruppe anmelden darf -- unabhaengig davon, WORAUF sie laut Rollen oben zugreifen darf. Eine Verbindung kommt nur zustande, wenn BEIDES zutrifft: die Rolle (ssh_connect/rdp_connect) auf der Hostgruppe UND eine hier freigegebene, am Host haengende Zugangsdaten-Ressource.</p>
<form id="group-credential-grant-form" class="form-grid">
<div>
<label for="gcg-group">Benutzergruppe</label>
<select id="gcg-group" required></select>
</div>
<div>
<label for="gcg-kind">Art</label>
<select id="gcg-kind" required>
<option value="ssh_key">SSH-Schluessel</option>
<option value="rdp_credential">RDP-Zugangsdaten</option>
<option value="ssh_password_credential">SSH-Passwort</option>
</select>
</div>
<div>
<label for="gcg-credential">Zugangsdatensatz</label>
<select id="gcg-credential" required></select>
</div>
<div>
<label for="gcg-expires">Ablaufdatum (optional, ISO)</label>
<input type="text" id="gcg-expires" placeholder="2026-12-31T00:00:00.000Z" maxlength="40">
</div>
<button type="submit">Freigeben</button>
</form>
<div class="table-wrap">
<table class="data-table" id="group-credential-grants-table">
<thead><tr><th>Gruppe</th><th>Art</th><th>Zugangsdatensatz</th><th>Ablauf</th><th></th></tr></thead>
<tbody></tbody>
</table>
</div>
</div>
</div> </div>
<!-- ================= API-Tokens ================= --> <!-- ================= API-Tokens ================= -->
<div class="tab-panel hidden" id="tab-tokens"> <div class="tab-panel hidden" id="tab-tokens">
<div class="panel"> <div class="panel">
<h2>Neues API-Token erstellen</h2> <h2>Neues API-Token erstellen</h2>
<p class="hint">Tokens wirken NUR auf die Verwaltungs-API (Benutzer/Hosts/Rollen/...), nie auf SSH-/RDP-Sitzungen selbst. "rw" schliesst "ro" auf derselben Ressource automatisch mit ein. Jedes Token gehoert genau einem Mandanten.</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.</p>
<form id="token-create-form" class="form-grid"> <form id="token-create-form" class="form-grid">
<div> <div>
<label for="tc-label">Bezeichnung</label> <label for="tc-label">Bezeichnung</label>
@ -589,10 +587,6 @@
<label for="tc-user">Fuer Benutzer</label> <label for="tc-user">Fuer Benutzer</label>
<select id="tc-user" required></select> <select id="tc-user" required></select>
</div> </div>
<div id="tc-tenant-box">
<label for="tc-tenant">Mandant</label>
<select id="tc-tenant" required></select>
</div>
<div> <div>
<label for="tc-expires">Ablaufdatum (optional, ISO)</label> <label for="tc-expires">Ablaufdatum (optional, ISO)</label>
<input type="text" id="tc-expires" placeholder="2026-12-31T00:00:00.000Z" maxlength="40"> <input type="text" id="tc-expires" placeholder="2026-12-31T00:00:00.000Z" maxlength="40">
@ -611,7 +605,7 @@
<div class="table-wrap"> <div class="table-wrap">
<table class="data-table" id="tokens-table"> <table class="data-table" id="tokens-table">
<thead> <thead>
<tr><th>ID</th><th>Bezeichnung</th><th>Benutzer</th><th>Praefix</th><th>Mandant</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>Scopes</th><th>Zuletzt genutzt</th><th>Status</th><th></th></tr>
</thead> </thead>
<tbody></tbody> <tbody></tbody>
</table> </table>
@ -619,65 +613,6 @@
</div> </div>
</div> </div>
<!-- ================= Mandanten (nur Super-Admin) ================= -->
<div class="tab-panel hidden" id="tab-tenants">
<div class="panel">
<h2>Neuen Mandanten anlegen</h2>
<form id="tenant-create-form" class="form-grid">
<div>
<label for="tnc-name">Name</label>
<input type="text" id="tnc-name" required maxlength="128">
</div>
<div>
<label for="tnc-description">Beschreibung (optional)</label>
<input type="text" id="tnc-description" maxlength="1024">
</div>
<button type="submit">Anlegen</button>
</form>
<h3>Mandanten</h3>
<div class="table-wrap">
<table class="data-table" id="tenants-table">
<thead><tr><th>ID</th><th>Name</th><th>Beschreibung</th><th>Aktiv</th><th>Hostgruppen</th><th>Benutzergruppen</th><th></th></tr></thead>
<tbody></tbody>
</table>
</div>
</div>
<div class="panel hidden" id="tenant-edit-panel">
<h2>Mandant bearbeiten: <span id="tenant-edit-name"></span></h2>
<form id="tenant-edit-form" class="form-grid">
<div>
<label for="tne-name">Name</label>
<input type="text" id="tne-name" required maxlength="128">
</div>
<div>
<label for="tne-description">Beschreibung</label>
<input type="text" id="tne-description" maxlength="1024">
</div>
<div class="checkbox-row"><input type="checkbox" id="tne-active"><label for="tne-active">Aktiv</label></div>
<button type="submit">Speichern</button>
</form>
</div>
<div class="panel hidden" id="tenant-admins-panel">
<h2>Mandanten-Admins von <span id="tenant-admins-name"></span></h2>
<div class="table-wrap">
<table class="data-table" id="tenant-admins-table">
<thead><tr><th>User-ID</th><th>Benutzername</th><th>Seit</th><th></th></tr></thead>
<tbody></tbody>
</table>
</div>
<h3>Mandanten-Admin hinzufuegen</h3>
<form id="tenant-admin-add-form" class="form-grid">
<div>
<label for="ta-user-select">Benutzer</label>
<select id="ta-user-select" required></select>
</div>
<button type="submit">Hinzufuegen</button>
</form>
</div>
</div>
<!-- ================= Sessions (nur Super-Admin) ================= --> <!-- ================= Sessions (nur Super-Admin) ================= -->
<div class="tab-panel hidden" id="tab-sessions"> <div class="tab-panel hidden" id="tab-sessions">
<div class="panel"> <div class="panel">

View File

@ -7,18 +7,19 @@
<link rel="stylesheet" href="/static/css/app.css"> <link rel="stylesheet" href="/static/css/app.css">
</head> </head>
<body> <body>
<div class="session-shell" id="session-shell" data-host-id="{{ host_id }}"> <!--
<div class="session-toolbar"> F1 (Umsetzungsauftrag_Sonnet5.md Teil F.3.1): diese Seite baut das
<button id="fullscreen-btn">Vollbild</button> Sitzungs-DOM nicht mehr selbst -- static/js/rdp.js erzeugt beim Laden
<button id="ctrlaltdel-btn">Strg+Alt+Entf</button> GENAU EINE RdpSession-Instanz und haengt deren komplettes Markup
<span class="spacer"></span> (Toolbar, Anzeigeflaeche, Fehler-Overlay) hier ein. Duenner Rahmen um
<span class="status" id="status">Verbinde ...</span> denselben Baustein, den auch eine kuenftige Arbeitsflaeche (/workspace,
<button id="exit-btn" class="btn-danger btn-small" title="Sitzung beenden und Tab schliessen">Exit</button> Teil F.3, spaetere Stufe) verwendet -- keine zweite Implementierung
</div> (F.2, letzter Absatz).
<div id="rdp-display"></div> -->
</div> <div id="session-container" data-host-id="{{ host_id }}"></div>
<script src="/static/js/vendor/guacamole-common.js"></script> <script src="/static/js/vendor/guacamole-common.js"></script>
<script src="/static/js/session-keepalive.js"></script>
<script src="/static/js/rdp.js"></script> <script src="/static/js/rdp.js"></script>
</body> </body>
</html> </html>

View File

@ -8,47 +8,20 @@
<link rel="stylesheet" href="/static/js/vendor/xterm.css"> <link rel="stylesheet" href="/static/js/vendor/xterm.css">
</head> </head>
<body> <body>
<div class="session-shell" id="session-shell" data-host-id="{{ host_id }}"> <!--
<div class="session-toolbar"> F1 (Umsetzungsauftrag_Sonnet5.md Teil F.3.1): diese Seite baut das
<button id="fullscreen-btn">Vollbild</button> Sitzungs-DOM nicht mehr selbst -- static/js/terminal.js erzeugt beim
<button id="filetransfer-btn">Dateitransfer</button> Laden GENAU EINE SshTerminalSession-Instanz und haengt deren komplettes
<span class="spacer"></span> Markup (Toolbar, Terminal, Dateitransfer-Panel) hier ein. Duenner
<span class="status" id="status">Verbinde ...</span> Rahmen um denselben Baustein, den auch eine kuenftige Arbeitsflaeche
<button id="exit-btn" class="btn-danger btn-small" title="Sitzung beenden und Tab schliessen">Exit</button> (/workspace, Teil F.3, spaetere Stufe) verwendet -- keine zweite
</div> Implementierung (F.2, letzter Absatz).
<div id="terminal"></div> -->
</div> <div id="session-container" data-host-id="{{ host_id }}"></div>
<div class="ft-overlay hidden" id="ft-overlay">
<div class="ft-panel">
<h2>Dateitransfer
<button type="button" class="btn-secondary btn-small" id="ft-close-btn">Schliessen</button>
</h2>
<h3>Hochladen</h3>
<label for="ft-upload-remote-path">Zielpfad auf dem Server</label>
<input type="text" id="ft-upload-remote-path" placeholder="/tmp/datei.txt">
<input type="file" id="ft-upload-file-input" class="hidden">
<div class="ft-btn-row">
<button type="button" class="btn-secondary btn-small" id="ft-upload-pick-btn">Datei auswaehlen</button>
<span class="hint" id="ft-upload-filename">Keine Datei ausgewaehlt</span>
</div>
<button type="button" class="btn-small" id="ft-upload-submit-btn">Hochladen</button>
<h3>Herunterladen</h3>
<label for="ft-download-remote-path">Pfad auf dem Server</label>
<input type="text" id="ft-download-remote-path" placeholder="/etc/hosts">
<button type="button" class="btn-small" id="ft-download-submit-btn">Herunterladen</button>
<div class="error" id="ft-status"></div>
<h3>Letzte Transfers</h3>
<div class="ft-list" id="ft-list"></div>
</div>
</div>
<script src="/static/js/vendor/xterm.js"></script> <script src="/static/js/vendor/xterm.js"></script>
<script src="/static/js/vendor/xterm-addon-fit.js"></script> <script src="/static/js/vendor/xterm-addon-fit.js"></script>
<script src="/static/js/session-keepalive.js"></script>
<script src="/static/js/terminal.js"></script> <script src="/static/js/terminal.js"></script>
</body> </body>
</html> </html>

63
templates/workspace.html Normal file
View File

@ -0,0 +1,63 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Jumphost Arbeitsflaeche</title>
<link rel="stylesheet" href="/static/css/app.css">
<link rel="stylesheet" href="/static/js/vendor/xterm.css">
</head>
<body>
<!--
F3/F4/F5 (Umsetzungsauftrag_Sonnet5.md Teil F.3): dauerhafte Arbeitsflaeche.
static/js/workspace.js baut Seitenleiste, Hostkatalog und die Sitzungen
(ueber SshTerminalSession/RdpSession, denselben Baustein wie
/terminal/{id} bzw. /rdp/{id} aus Stufe F1 -- keine zweite
Implementierung, F.2). #workspace-catalog-view und #workspace-sessions-area
sind feste Rahmen, die per .hidden (app.css) umgeschaltet werden -- die
Sitzungs-Elemente selbst werden beim Umschalten NICHT entfernt, nur
innerhalb von #workspace-sessions-area ein-/ausgeblendet (F4: mehrere
gleichzeitige Sitzungen bleiben im Hintergrund verbunden). F5 (F.3.8):
#workspace-limit-hint zeigt die konfigurierten Obergrenzen (GET
/catalog/session-limits) und deaktiviert "+ Neue Sitzung"/"Verbinden",
bevor der Server eine neue Sitzung mit WS-Code 4429 ablehnen wuerde.
#workspace-attention-summary sammelt Hintergrundsitzungen mit Fehler/
Trennung, damit man sie nicht erst durch Umschalten entdeckt.
-->
<div class="topbar">
<div class="brand">Jumphost -- Arbeitsflaeche</div>
<div>
<span class="hint" id="whoami"></span>
<a href="/dashboard">Dashboard</a>
<button id="logout-btn">Abmelden</button>
</div>
</div>
<div class="workspace-layout">
<div class="workspace-sidebar">
<div class="workspace-sidebar-header">
<h2>Sitzungen</h2>
<p class="hint">
Sitzungen ueberleben keinen Seiten-Reload -- ein Neuladen dieser
Seite beendet alle gerade offenen Sitzungen (F.3.7).
</p>
<p class="hint workspace-limit-hint" id="workspace-limit-hint"></p>
</div>
<div class="workspace-attention-summary hidden" id="workspace-attention-summary"></div>
<div class="workspace-tiles" id="workspace-tiles"></div>
<button type="button" class="workspace-new-btn" id="workspace-new-btn">+ Neue Sitzung</button>
</div>
<div class="workspace-main">
<div class="workspace-catalog-view" id="workspace-catalog-view"></div>
<div class="workspace-sessions-area hidden" id="workspace-sessions-area"></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/vendor/guacamole-common.js"></script>
<script src="/static/js/session-keepalive.js"></script>
<script src="/static/js/terminal.js"></script>
<script src="/static/js/rdp.js"></script>
<script src="/static/js/workspace.js"></script>
</body>
</html>

397
tests/test_admin_crud.py Normal file
View File

@ -0,0 +1,397 @@
"""
Tests 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).
Vormals (bis Teil C des Umsetzungsauftrags) enthielt diese Datei zusaetzlich
Tests fuer die inzwischen vollstaendig entfernte Mandantenfaehigkeit
(app/tenancy.py, 0006_tenants.sql) -- siehe app/db/migrations/
0014_drop_tenants.sql fuer den Rueckbau. Diese Datei heisst deshalb nicht
mehr test_tenants.py.
Deckt ab:
1) Mehrfachauswahl bei Rollenvergabe (role_names) funktioniert fuer
Einzel-User UND Benutzergruppen.
2) 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).
3) 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
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ed25519
def _key_pem() -> str:
"""Echtes, unverschluesseltes Schluesselmaterial.
Seit Phase 10 prueft POST/PUT /admin/ssh-keys das Material sofort gegen
asyncssh (damit ein unbrauchbarer Schluessel nicht erst beim ersten
Verbindungsversuch auffaellt). Platzhalter wie "PEM" werden deshalb --
voellig korrekt -- mit HTTP 400 abgelehnt; diese Tests hier pruefen CRUD
und brauchen einen gueltigen Schluessel.
"""
return ed25519.Ed25519PrivateKey.generate().private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.OpenSSH,
serialization.NoEncryption(),
).decode()
async def _create_user(conn, username: str, password: str, *, is_admin: bool = False) -> int:
from app.security.passwords import hash_password
cursor = await conn.execute(
"INSERT INTO users (username, password_hash, is_admin, must_change_password) "
"VALUES (?, ?, ?, 0)",
(username, hash_password(password), int(is_admin)),
)
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, *, group_name="team-a", hostname="srv-a"):
cursor = await conn.execute(
"INSERT INTO host_groups (name) VALUES (?)", (group_name,)
)
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) Mehrfachauswahl bei Rollenvergabe (role_names)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_direct_multi_role_grant_for_individual_user_is_retired(client):
"""Teil D Schritt 5: Direktvergabe an einzelne Benutzer (auch mit
Mehrfachauswahl -- vormals test_multi_role_grant_for_individual_user,
siehe FORTSETZUNG_Teil_D.md Abschnitt 1c) ist ersatzlos entfallen. Diese
Faehigkeit existiert nur noch fuer Benutzergruppen, siehe
test_multi_role_grant_for_user_group() unten -- deshalb hier bewusst
KEIN Ersatztest fuer den Einzel-User-Pfad, sondern die Bestaetigung,
dass er tatsaechlich abgeschaltet ist (statt z.B. nur zu vergessen und
fuer immer gruen zu bleiben, obwohl der Pfad gar nicht mehr existiert)."""
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 == 410, resp.text
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 == 410, resp.text
# GET /admin/roles (die abgeleitete Effektiv-Sicht, Schritt 5) zeigt
# entsprechend keine Rolle fuer member_id -- der Grant-Versuch oben ist
# tatsaechlich folgenlos geblieben, nicht nur mit 410 quittiert worden.
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 granted_roles == set()
@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
# ---------------------------------------------------------------------------
# 2) 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": _key_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 (400 noch vor jeder
# Materialpruefung -- der Platzhalter hier ist deshalb Absicht).
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": _key_pem(), "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())
# ---------------------------------------------------------------------------
# 3) 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": _key_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
# Migration 0012: RDP-Zugangsdaten sind ein eigenstaendiges Objekt, das
# separat angelegt und dann dem Host zugewiesen wird (wie ein SSH-Key).
resp = await client.post(
"/admin/rdp-credentials",
json={"label": "detail-cred", "username": "Administrator",
"password": "Correct-Horse-Battery-Staple-O2"},
)
assert resp.status_code == 201, resp.text
credential_id = resp.json()["id"]
resp = await client.post(f"/admin/hosts/{host_id}/rdp-credentials/{credential_id}", json={})
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
assert data["rdp_credentials_id"] == credential_id
resp = await client.get("/admin/rdp-credentials")
assert resp.status_code == 200, resp.text
row = next(r for r in resp.json() if r["id"] == credential_id)
assert any(h["id"] == host_id for h in row["assigned_hosts"])
resp = await client.get("/admin/hosts/424242")
assert resp.status_code == 404

View File

@ -1,3 +1,5 @@
import asyncio
import pytest import pytest
import aiosqlite import aiosqlite
@ -50,6 +52,37 @@ async def test_tamper_detected_after_direct_update():
await conn.close() await conn.close()
@pytest.mark.asyncio
async def test_chain_intact_after_concurrent_writes():
"""E1 (Umsetzungsauftrag_Sonnet5.md Teil E.1): mehrere gleichzeitig
laufende Aufrufe (z.B. Sitzungsstart/-ende zweier Benutzer im selben
Moment) duerfen den prev_hash niemals doppelt vergeben -- sonst bricht
die Kette dauerhaft ab (audit_log erlaubt kein UPDATE/DELETE)."""
conn = await _fresh_db()
n = 40
async def _write(i: int) -> None:
await write_audit_event(
conn,
event_type="test_event",
user_id=None,
client_ip="127.0.0.1",
details={"i": i},
)
await asyncio.gather(*(_write(i) for i in range(n)))
cursor = await conn.execute("SELECT COUNT(*) FROM audit_log")
(count,) = await cursor.fetchone()
assert count == n
intact, broken_at = await verify_chain(conn)
assert intact is True
assert broken_at is None
await conn.close()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_append_only_trigger_blocks_update(): async def test_append_only_trigger_blocks_update():
conn = await _fresh_db() conn = await _fresh_db()

View File

@ -67,7 +67,7 @@ def _assert_no_inline_script(html: str, page: str) -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.parametrize( @pytest.mark.parametrize(
"path", "path",
["/", "/dashboard", "/terminal/1", "/rdp/1", "/admin"], ["/", "/dashboard", "/terminal/1", "/rdp/1", "/admin", "/workspace"],
) )
async def test_rendered_pages_contain_no_inline_style_or_script(client, path): async def test_rendered_pages_contain_no_inline_style_or_script(client, path):
resp = await client.get(path) resp = await client.get(path)
@ -132,7 +132,9 @@ async def test_login_page_hidden_sections_use_css_class_not_inline_style(client)
@pytest.mark.parametrize("path,expected_host_id", [("/terminal/42", "42"), ("/rdp/7", "7")]) @pytest.mark.parametrize("path,expected_host_id", [("/terminal/42", "42"), ("/rdp/7", "7")])
async def test_session_pages_expose_host_id_via_data_attribute(client, path, expected_host_id): async def test_session_pages_expose_host_id_via_data_attribute(client, path, expected_host_id):
"""host_id muss CSP-konform (kein Inline-<script>) an das Frontend-JS """host_id muss CSP-konform (kein Inline-<script>) an das Frontend-JS
uebergeben werden -- ueber data-host-id auf #session-shell.""" uebergeben werden -- ueber data-host-id auf #session-container (F1,
Umsetzungsauftrag_Sonnet5.md Teil F.3.1: static/js/terminal.js bzw. rdp.js
lesen es von dort und bauen #session-shell danach selbst dynamisch)."""
resp = await client.get(path) resp = await client.get(path)
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
assert f'data-host-id="{expected_host_id}"' in resp.text assert f'data-host-id="{expected_host_id}"' in resp.text

View File

@ -190,9 +190,36 @@ async def test_password_change_invalidates_old_session_everywhere(client):
# Altes Cookie (vor dem Passwortwechsel ausgestellt) erneut einspielen -- # Altes Cookie (vor dem Passwortwechsel ausgestellt) erneut einspielen --
# muss durch die session_version-Pruefung invalidiert sein (Konzept 6.2). # muss durch die session_version-Pruefung invalidiert sein (Konzept 6.2).
client.cookies.set("jh_session", old_cookie) #
resp = await client.get("/auth/me") # Bewusst NICHT ueber client.cookies.set(...)/cookies=<...> auf dem
assert resp.status_code == 401 # GETEILTEN `client`-Fixture: dessen Jar traegt an dieser Stelle bereits
# die NEUE, gueltige Sitzung aus der change-password-Antwort oben.
# client.cookies.set() kann je nach httpx-Version einen zweiten,
# mehrdeutigen Jar-Eintrag statt eines Ersatzes erzeugen: der Request
# wuerde dann weiterhin (auch) die noch gueltige NEUE Sitzung
# mitschicken. Der per-Request-Parameter `cookies=` ist fuer genau
# diesen Fall (Ueberschreiben einer bereits im Jar vorhandenen Cookie)
# KEINE zuverlaessige Abhilfe: httpx warnt selbst per
# DeprecationWarning, dass das Zusammenspiel mit einem bereits
# gesetzten Jar-Eintrag "ambiguous" ist -- ein eigener Testlauf hat das
# bestaetigt (der alte Cookie wurde NICHT wirksam, der Request nutzte
# weiterhin die neue Sitzung aus dem Jar, Test schlug trotz korrektem
# Server faelschlich fehl). Ein voellig frischer, jar-loser Client hat
# dagegen zuverlaessig funktioniert (siehe FORTSETZUNG_Teil_C.md
# Abschnitt 3 Punkt 6 fuer die vollstaendige Untersuchung) -- deshalb
# hier bewusst ein zweiter, eigener AsyncClient nur fuer diesen einen
# Replay-Request, unabhaengig vom Jar-Zustand des Fixture-Clients.
from httpx import ASGITransport, AsyncClient
from app.main import app as fastapi_app
async with AsyncClient(transport=ASGITransport(app=fastapi_app), base_url="https://testserver") as replay_client:
# .set() auf dem LEEREN Jar dieses frischen Clients ist eindeutig
# (kein vorhandener Eintrag, der ueberschrieben werden muesste) --
# folgt damit auch httpx' eigener Empfehlung in der oben erklaerten
# DeprecationWarning ("Set cookies directly on the client instance").
replay_client.cookies.set("jh_session", old_cookie)
resp = await replay_client.get("/auth/me")
assert resp.status_code == 401, resp.text
@pytest.mark.asyncio @pytest.mark.asyncio
@ -266,10 +293,15 @@ async def test_user_cannot_access_host_outside_granted_hostgroup(client):
"INSERT INTO hosts (id, host_group_id, hostname, address, protocol, port, os_type, " "INSERT INTO hosts (id, host_group_id, hostname, address, protocol, port, os_type, "
"file_transfer_enabled) VALUES (2, 2, 'b01', '10.0.0.2', 'ssh', 22, 'linux', 1)" "file_transfer_enabled) VALUES (2, 2, 'b01', '10.0.0.2', 'ssh', 22, 'linux', 1)"
) )
# judy bekommt file_transfer NUR auf Hostgruppe 1 (role_id 3 = file_transfer, siehe Migration 0001). # judy bekommt file_transfer NUR auf Hostgruppe 1 (role_id 3 = file_transfer,
# siehe Migration 0001) -- seit Teil D Schritt 4 ausschliesslich ueber
# eine Benutzergruppe moeglich (direkte Vergabe an einzelne Benutzer ist
# entfallen, siehe app/rbac.py-Modul-Docstring).
await conn.execute("INSERT INTO user_groups (id, name) VALUES (101, 'judy-team')")
await conn.execute("INSERT INTO user_group_members (user_group_id, user_id) VALUES (101, ?)", (user_id,))
await conn.execute( await conn.execute(
"INSERT INTO user_hostgroup_roles (user_id, host_group_id, role_id, granted_by) VALUES (?, 1, 3, ?)", "INSERT INTO group_hostgroup_roles (user_group_id, host_group_id, role_id, granted_by) VALUES (101, 1, 3, ?)",
(user_id, admin_id), (admin_id,),
) )
await conn.commit() await conn.commit()
@ -308,10 +340,15 @@ async def test_role_on_one_hostgroup_does_not_grant_different_permission_type(cl
"INSERT INTO hosts (id, host_group_id, hostname, address, protocol, port, os_type, " "INSERT INTO hosts (id, host_group_id, hostname, address, protocol, port, os_type, "
"file_transfer_enabled) VALUES (1, 1, 'c01', '10.0.0.3', 'ssh', 22, 'linux', 1)" "file_transfer_enabled) VALUES (1, 1, 'c01', '10.0.0.3', 'ssh', 22, 'linux', 1)"
) )
# role_id 1 = ssh_connect (siehe Migration 0001) -- explizit KEIN file_transfer. # role_id 1 = ssh_connect (siehe Migration 0001) -- explizit KEIN
# file_transfer. Seit Teil D Schritt 4 ausschliesslich ueber eine
# Benutzergruppe moeglich (siehe Kommentar in
# test_user_cannot_access_host_outside_granted_hostgroup oben).
await conn.execute("INSERT INTO user_groups (id, name) VALUES (102, 'mallory-team')")
await conn.execute("INSERT INTO user_group_members (user_group_id, user_id) VALUES (102, ?)", (user_id,))
await conn.execute( await conn.execute(
"INSERT INTO user_hostgroup_roles (user_id, host_group_id, role_id, granted_by) VALUES (?, 1, 1, ?)", "INSERT INTO group_hostgroup_roles (user_group_id, host_group_id, role_id, granted_by) VALUES (102, 1, 1, ?)",
(user_id, admin_id), (admin_id,),
) )
await conn.commit() await conn.commit()

View File

@ -186,6 +186,14 @@ async def test_load_private_key_for_host_uses_stored_passphrase(client):
from app.security.crypto import encrypt_secret from app.security.crypto import encrypt_secret
conn = get_db() conn = get_db()
from app.security.passwords import hash_password
cursor = await conn.execute(
"INSERT INTO users (username, password_hash) VALUES ('p10-user', ?)",
(hash_password("Correct-Horse-Battery-Staple-P10"),),
)
user_id = cursor.lastrowid
cursor = await conn.execute("INSERT INTO host_groups (name) VALUES ('p10-group')") cursor = await conn.execute("INSERT INTO host_groups (name) VALUES ('p10-group')")
hg_id = cursor.lastrowid hg_id = cursor.lastrowid
cursor = await conn.execute( cursor = await conn.execute(
@ -205,17 +213,31 @@ async def test_load_private_key_for_host_uses_stored_passphrase(client):
await conn.execute( await conn.execute(
"INSERT INTO host_ssh_key_map (host_id, ssh_key_id) VALUES (?, ?)", (host_id, key_id) "INSERT INTO host_ssh_key_map (host_id, ssh_key_id) VALUES (?, ?)", (host_id, key_id)
) )
# Teil D Schritt 4 (Achse B): load_private_key_for_host() loest seither
# NICHT mehr blind ueber den Host auf, sondern nur noch fuer einen
# Benutzer, dessen Gruppe den Schluessel ueber group_ssh_key_grants
# freigegeben bekommen hat (siehe Docstring von
# load_ssh_key_credential_for_host in app/ssh_proxy/proxy.py).
cursor = await conn.execute("INSERT INTO user_groups (name) VALUES ('p10-team')")
group_id = cursor.lastrowid
await conn.execute(
"INSERT INTO user_group_members (user_group_id, user_id) VALUES (?, ?)", (group_id, user_id)
)
await conn.execute(
"INSERT INTO group_ssh_key_grants (user_group_id, ssh_key_id) VALUES (?, ?)", (group_id, key_id)
)
await conn.commit() await conn.commit()
# Mit hinterlegter Passphrase laedt der Schluessel. # Mit hinterlegter Passphrase laedt der Schluessel.
assert await load_private_key_for_host(conn, host_id) is not None assert await load_private_key_for_host(conn, host_id, user_id=user_id) is not None
# Ohne sie: klare Meldung statt eines nach aussen durchschlagenden # Ohne sie: klare Meldung statt eines nach aussen durchschlagenden
# KeyImportError (das war der gemeldete Abbruch ohne Fehlermeldung). # KeyImportError (das war der gemeldete Abbruch ohne Fehlermeldung).
await conn.execute("UPDATE ssh_keys SET passphrase_enc = NULL WHERE id = ?", (key_id,)) await conn.execute("UPDATE ssh_keys SET passphrase_enc = NULL WHERE id = ?", (key_id,))
await conn.commit() await conn.commit()
with pytest.raises(PrivateKeyUnusableError) as excinfo: with pytest.raises(PrivateKeyUnusableError) as excinfo:
await load_private_key_for_host(conn, host_id) await load_private_key_for_host(conn, host_id, user_id=user_id)
assert "passphrasegeschuetzt" in str(excinfo.value).lower() assert "passphrasegeschuetzt" in str(excinfo.value).lower()
@ -257,25 +279,33 @@ def test_build_rdp_params_passes_username_and_cert_policy():
"rdp_domain": "CORP", "rdp_require_nla": True, "clipboard_enabled": True, "rdp_domain": "CORP", "rdp_require_nla": True, "clipboard_enabled": True,
"rdp_ignore_cert": True, "rdp_ignore_cert": True,
} }
params = build_rdp_params(host, "geheim") # Bug-Fix (FORTSETZUNG_Teil_C.md Abschnitt 3 Punkt 2): build_rdp_params()
# verlangt inzwischen session_id als Pflicht-Keyword-Argument (echte
# Signaturerweiterung, app/rdp_proxy/guacd_client.py) -- fuer diese Tests
# ist der konkrete Wert irrelevant, ein beliebiger int reicht.
params = build_rdp_params(host, "geheim", session_id=1)
assert params["username"] == "Administrator" assert params["username"] == "Administrator"
assert params["domain"] == "CORP" assert params["domain"] == "CORP"
assert params["security"] == "nla" assert params["security"] == "nla"
assert params["ignore-cert"] == "true" assert params["ignore-cert"] == "true"
assert params["disable-copy"] == "false" assert params["disable-copy"] == "false"
strict = build_rdp_params(dict(host, rdp_ignore_cert=False, clipboard_enabled=False), "geheim") strict = build_rdp_params(
dict(host, rdp_ignore_cert=False, clipboard_enabled=False), "geheim", session_id=1,
)
assert strict["ignore-cert"] == "false" assert strict["ignore-cert"] == "false"
assert strict["disable-copy"] == "true" and strict["disable-paste"] == "true" assert strict["disable-copy"] == "true" and strict["disable-paste"] == "true"
# Kein Benutzername -> klare Meldung statt stiller Fehlanmeldung am Ziel # Kein Benutzername -> klare Meldung statt stiller Fehlanmeldung am Ziel
with pytest.raises(GuacamoleProtocolError): with pytest.raises(GuacamoleProtocolError):
build_rdp_params(dict(host, rdp_username=""), "geheim") build_rdp_params(dict(host, rdp_username=""), "geheim", session_id=1)
# Unvollstaendiger Hostdatensatz (der alte load_host()-Zustand) # Unvollstaendiger Hostdatensatz (der alte load_host()-Zustand)
with pytest.raises(GuacamoleProtocolError): with pytest.raises(GuacamoleProtocolError):
build_rdp_params({k: host[k] for k in ("id", "hostname", "address", "port", build_rdp_params(
"file_transfer_enabled")}, "geheim") {k: host[k] for k in ("id", "hostname", "address", "port", "file_transfer_enabled")},
"geheim", session_id=1,
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@ -96,12 +96,33 @@ def _plain_key_pem() -> str:
).decode() ).decode()
# Teil D Schritt 4 (Achse B): connect_to_host()/load_private_key_for_host()
# loesen Zugangsdaten seither benutzerabhaengig auf -- _make_db() seedet
# daher immer einen Benutzer (TEST_USER_ID) samt Gruppe, und bei with_key=True
# zusaetzlich eine group_ssh_key_grants-Freigabe dieser Gruppe fuer den
# angelegten Schluessel (ohne die wuerde resolve_credential_for_user_on_host()
# keinen Treffer finden, siehe app/rbac.py).
TEST_USER_ID = 1
TEST_USER_GROUP_ID = 1
def _make_db(tmp_path, *, host_username=None, key_username="l4u", fingerprint=FINGERPRINT, def _make_db(tmp_path, *, host_username=None, key_username="l4u", fingerprint=FINGERPRINT,
host_key=PUBLIC_KEY, with_key=True) -> str: host_key=PUBLIC_KEY, with_key=True) -> str:
path = str(tmp_path / "jumphost.sqlite3") path = str(tmp_path / "jumphost.sqlite3")
db = sqlite3.connect(path) db = sqlite3.connect(path)
_apply_migrations(db) _apply_migrations(db)
db.execute("INSERT INTO host_groups (id, name, tenant_id) VALUES (1, 'gruppe', 1)") db.execute(
"INSERT INTO users (id, username, password_hash) VALUES (?, 'p12-user', 'x')",
(TEST_USER_ID,),
)
db.execute(
"INSERT INTO user_groups (id, name) VALUES (?, 'p12-team')", (TEST_USER_GROUP_ID,),
)
db.execute(
"INSERT INTO user_group_members (user_group_id, user_id) VALUES (?, ?)",
(TEST_USER_GROUP_ID, TEST_USER_ID),
)
db.execute("INSERT INTO host_groups (id, name) VALUES (1, 'gruppe')")
db.execute( db.execute(
"INSERT INTO hosts (id, host_group_id, hostname, address, protocol, port, os_type, " "INSERT INTO hosts (id, host_group_id, hostname, address, protocol, port, os_type, "
"ssh_host_key_fingerprint, ssh_host_key, ssh_username) " "ssh_host_key_fingerprint, ssh_host_key, ssh_username) "
@ -110,11 +131,15 @@ def _make_db(tmp_path, *, host_username=None, key_username="l4u", fingerprint=FI
) )
if with_key: if with_key:
db.execute( db.execute(
"INSERT INTO ssh_keys (id, label, private_key_enc, public_key, key_type, tenant_id, username) " "INSERT INTO ssh_keys (id, label, private_key_enc, public_key, key_type, username) "
"VALUES (1, 'testkey', ?, 'ssh-ed25519 AAAA', 'ed25519', 1, ?)", "VALUES (1, 'testkey', ?, 'ssh-ed25519 AAAA', 'ed25519', ?)",
(encrypt_secret(_plain_key_pem().encode(), associated_data=b"ssh_private_key"), key_username), (encrypt_secret(_plain_key_pem().encode(), associated_data=b"ssh_private_key"), key_username),
) )
db.execute("INSERT INTO host_ssh_key_map (host_id, ssh_key_id) VALUES (1, 1)") db.execute("INSERT INTO host_ssh_key_map (host_id, ssh_key_id) VALUES (1, 1)")
db.execute(
"INSERT INTO group_ssh_key_grants (user_group_id, ssh_key_id) VALUES (?, 1)",
(TEST_USER_GROUP_ID,),
)
db.commit() db.commit()
db.close() db.close()
return path return path
@ -206,7 +231,7 @@ async def test_verbindung_ohne_hinterlegten_hostkey_wird_abgelehnt(tmp_path, mon
monkeypatch.setattr(asyncssh, "connect", _fake_connect) monkeypatch.setattr(asyncssh, "connect", _fake_connect)
conn = FakeConnection(_make_db(tmp_path, fingerprint=None, host_key=None)) conn = FakeConnection(_make_db(tmp_path, fingerprint=None, host_key=None))
with pytest.raises(HostKeyNotPinnedError): with pytest.raises(HostKeyNotPinnedError):
await connect_to_host(conn, 1) await connect_to_host(conn, 1, user_id=TEST_USER_ID)
async def test_abweichender_hostkey_bricht_vor_der_anmeldung_ab(tmp_path, monkeypatch): async def test_abweichender_hostkey_bricht_vor_der_anmeldung_ab(tmp_path, monkeypatch):
@ -227,7 +252,7 @@ async def test_abweichender_hostkey_bricht_vor_der_anmeldung_ab(tmp_path, monkey
conn = FakeConnection(_make_db(tmp_path)) conn = FakeConnection(_make_db(tmp_path))
with pytest.raises(HostKeyMismatchError) as excinfo: with pytest.raises(HostKeyMismatchError) as excinfo:
await connect_to_host(conn, 1) await connect_to_host(conn, 1, user_id=TEST_USER_ID)
assert excinfo.value.expected == FINGERPRINT assert excinfo.value.expected == FINGERPRINT
assert versuche == [] assert versuche == []
@ -249,7 +274,7 @@ async def test_verbindung_nutzt_benutzernamen_der_zugangsdaten(tmp_path, monkeyp
monkeypatch.setattr(asyncssh, "get_server_host_key", _fake_get_host_key, raising=False) monkeypatch.setattr(asyncssh, "get_server_host_key", _fake_get_host_key, raising=False)
conn = FakeConnection(_make_db(tmp_path, host_username="alterWertAmHost", key_username="l4u")) conn = FakeConnection(_make_db(tmp_path, host_username="alterWertAmHost", key_username="l4u"))
await connect_to_host(conn, 1) await connect_to_host(conn, 1, user_id=TEST_USER_ID)
# Der Name aus den Zugangsdaten gewinnt gegen den Altwert am Host. # Der Name aus den Zugangsdaten gewinnt gegen den Altwert am Host.
assert aufrufe["username"] == "l4u" assert aufrufe["username"] == "l4u"
@ -270,7 +295,7 @@ async def test_hostkey_wechsel_nach_der_pruefung_beendet_die_sitzung(tmp_path, m
conn = FakeConnection(_make_db(tmp_path)) conn = FakeConnection(_make_db(tmp_path))
with pytest.raises(HostKeyMismatchError): with pytest.raises(HostKeyMismatchError):
await connect_to_host(conn, 1) await connect_to_host(conn, 1, user_id=TEST_USER_ID)
assert verbindung.aborted is True assert verbindung.aborted is True
@ -290,7 +315,7 @@ async def test_altbestand_ohne_gespeicherten_hostkey_wird_nachgetragen(tmp_path,
monkeypatch.setattr(asyncssh, "get_server_host_key", _fake_get_host_key, raising=False) monkeypatch.setattr(asyncssh, "get_server_host_key", _fake_get_host_key, raising=False)
conn = FakeConnection(_make_db(tmp_path, host_key=None)) conn = FakeConnection(_make_db(tmp_path, host_key=None))
await connect_to_host(conn, 1) await connect_to_host(conn, 1, user_id=TEST_USER_ID)
(stored,) = conn.raw.execute("SELECT ssh_host_key FROM hosts WHERE id = 1").fetchone() (stored,) = conn.raw.execute("SELECT ssh_host_key FROM hosts WHERE id = 1").fetchone()
assert stored == PUBLIC_KEY assert stored == PUBLIC_KEY
@ -323,9 +348,13 @@ def _rdp_host(**overrides):
def test_rdp_params_nehmen_benutzernamen_der_zugangsdaten(): def test_rdp_params_nehmen_benutzernamen_der_zugangsdaten():
# Bug-Fix (FORTSETZUNG_Teil_C.md Abschnitt 3 Punkt 2): build_rdp_params()
# verlangt inzwischen session_id als Pflicht-Keyword-Argument (echte
# Signaturerweiterung, app/rdp_proxy/guacd_client.py) -- fuer diese Tests
# ist der konkrete Wert irrelevant, ein beliebiger int reicht.
params = build_rdp_params( params = build_rdp_params(
_rdp_host(rdp_username="alt", rdp_domain="ALTEDOMAENE"), _rdp_host(rdp_username="alt", rdp_domain="ALTEDOMAENE"),
"geheim", username="Administrator", domain="CONTOSO", "geheim", username="Administrator", domain="CONTOSO", session_id=1,
) )
assert params["username"] == "Administrator" assert params["username"] == "Administrator"
assert params["domain"] == "CONTOSO" assert params["domain"] == "CONTOSO"
@ -333,20 +362,22 @@ def test_rdp_params_nehmen_benutzernamen_der_zugangsdaten():
def test_rdp_params_fallback_auf_altwert_am_host(): def test_rdp_params_fallback_auf_altwert_am_host():
params = build_rdp_params(_rdp_host(rdp_username="alt", rdp_domain="D"), "geheim") params = build_rdp_params(
_rdp_host(rdp_username="alt", rdp_domain="D"), "geheim", session_id=1,
)
assert (params["username"], params["domain"]) == ("alt", "D") assert (params["username"], params["domain"]) == ("alt", "D")
def test_rdp_params_ohne_benutzernamen_meldet_zugangsdaten(): def test_rdp_params_ohne_benutzernamen_meldet_zugangsdaten():
with pytest.raises(GuacamoleProtocolError) as excinfo: with pytest.raises(GuacamoleProtocolError) as excinfo:
build_rdp_params(_rdp_host(), "geheim") build_rdp_params(_rdp_host(), "geheim", session_id=1)
assert "Zugangsdaten" in str(excinfo.value) assert "Zugangsdaten" in str(excinfo.value)
def test_rdp_params_brauchen_die_hostspalte_nicht_mehr(): def test_rdp_params_brauchen_die_hostspalte_nicht_mehr():
"""Ein Hostdatensatz ohne rdp_username ist kein Fehler mehr -- der Name """Ein Hostdatensatz ohne rdp_username ist kein Fehler mehr -- der Name
kommt jetzt von woanders.""" kommt jetzt von woanders."""
params = build_rdp_params(_rdp_host(), "geheim", username="svc") params = build_rdp_params(_rdp_host(), "geheim", username="svc", session_id=1)
assert params["username"] == "svc" assert params["username"] == "svc"
@ -362,7 +393,7 @@ def _pre_0010_db(tmp_path) -> sqlite3.Connection:
def test_migration_uebernimmt_eindeutige_ssh_benutzernamen(tmp_path): def test_migration_uebernimmt_eindeutige_ssh_benutzernamen(tmp_path):
db = _pre_0010_db(tmp_path) db = _pre_0010_db(tmp_path)
db.execute("INSERT INTO host_groups (id, name, tenant_id) VALUES (1, 'g', 1)") db.execute("INSERT INTO host_groups (id, name) VALUES (1, 'g')")
for host_id in (1, 2): for host_id in (1, 2):
db.execute( db.execute(
"INSERT INTO hosts (id, host_group_id, hostname, address, protocol, port, os_type, ssh_username) " "INSERT INTO hosts (id, host_group_id, hostname, address, protocol, port, os_type, ssh_username) "
@ -370,8 +401,8 @@ def test_migration_uebernimmt_eindeutige_ssh_benutzernamen(tmp_path):
(host_id,), (host_id,),
) )
db.execute( db.execute(
"INSERT INTO ssh_keys (id, label, private_key_enc, public_key, key_type, tenant_id) " "INSERT INTO ssh_keys (id, label, private_key_enc, public_key, key_type) "
"VALUES (1, 'k', X'00', 'pub', 'ed25519', 1)" "VALUES (1, 'k', X'00', 'pub', 'ed25519')"
) )
db.execute("INSERT INTO host_ssh_key_map (host_id, ssh_key_id) VALUES (1, 1), (2, 1)") db.execute("INSERT INTO host_ssh_key_map (host_id, ssh_key_id) VALUES (1, 1), (2, 1)")
db.commit() db.commit()
@ -386,7 +417,7 @@ def test_migration_raet_nicht_bei_mehrdeutigen_benutzernamen(tmp_path):
jede automatische Wahl geraten -- also bleibt das Feld leer und der jede automatische Wahl geraten -- also bleibt das Feld leer und der
Fallback greift weiter.""" Fallback greift weiter."""
db = _pre_0010_db(tmp_path) db = _pre_0010_db(tmp_path)
db.execute("INSERT INTO host_groups (id, name, tenant_id) VALUES (1, 'g', 1)") db.execute("INSERT INTO host_groups (id, name) VALUES (1, 'g')")
for host_id, name in ((1, "root"), (2, "l4u")): for host_id, name in ((1, "root"), (2, "l4u")):
db.execute( db.execute(
"INSERT INTO hosts (id, host_group_id, hostname, address, protocol, port, os_type, ssh_username) " "INSERT INTO hosts (id, host_group_id, hostname, address, protocol, port, os_type, ssh_username) "
@ -394,8 +425,8 @@ def test_migration_raet_nicht_bei_mehrdeutigen_benutzernamen(tmp_path):
(host_id, name), (host_id, name),
) )
db.execute( db.execute(
"INSERT INTO ssh_keys (id, label, private_key_enc, public_key, key_type, tenant_id) " "INSERT INTO ssh_keys (id, label, private_key_enc, public_key, key_type) "
"VALUES (1, 'k', X'00', 'pub', 'ed25519', 1)" "VALUES (1, 'k', X'00', 'pub', 'ed25519')"
) )
db.execute("INSERT INTO host_ssh_key_map (host_id, ssh_key_id) VALUES (1, 1), (2, 1)") db.execute("INSERT INTO host_ssh_key_map (host_id, ssh_key_id) VALUES (1, 1), (2, 1)")
db.commit() db.commit()
@ -407,7 +438,7 @@ def test_migration_raet_nicht_bei_mehrdeutigen_benutzernamen(tmp_path):
def test_migration_uebernimmt_rdp_benutzer_und_domaene(tmp_path): def test_migration_uebernimmt_rdp_benutzer_und_domaene(tmp_path):
db = _pre_0010_db(tmp_path) db = _pre_0010_db(tmp_path)
db.execute("INSERT INTO host_groups (id, name, tenant_id) VALUES (1, 'g', 1)") db.execute("INSERT INTO host_groups (id, name) VALUES (1, 'g')")
db.execute( db.execute(
"INSERT INTO hosts (id, host_group_id, hostname, address, protocol, port, os_type, " "INSERT INTO hosts (id, host_group_id, hostname, address, protocol, port, os_type, "
"rdp_username, rdp_domain) " "rdp_username, rdp_domain) "

View File

@ -27,7 +27,7 @@ Tests fuer Phase 13 (diese Session -- Sammel-Feedback aus dem Live-Test):
Sitzungs-Wiedergabe (volle grafische RDP-Wiedergabe + SSH-Textwiedergabe): Sitzungs-Wiedergabe (volle grafische RDP-Wiedergabe + SSH-Textwiedergabe):
GET /admin/sessions/{id}/recording/entries -- nur require_global_admin GET /admin/sessions/{id}/recording/entries -- nur require_global_admin
(Mandanten-Admins bekommen 403, genau wie beim bereits bestehenden (nicht-Admin-Benutzer bekommen 403, genau wie beim bereits bestehenden
GET /admin/sessions/{id}/recording), liefert die entschluesselten/rohen GET /admin/sessions/{id}/recording), liefert die entschluesselten/rohen
Eintraege NICHT den Klartext einer Passphrase o.ae., sondern ausschliesslich Eintraege NICHT den Klartext einer Passphrase o.ae., sondern ausschliesslich
die bereits im Klartext aufgezeichneten Terminal-/Guacamole-Stroeme (siehe die bereits im Klartext aufgezeichneten Terminal-/Guacamole-Stroeme (siehe
@ -93,6 +93,25 @@ async def _setup_hostgroup_and_host(conn, *, group_name="p13-group", hostname="p
return hg_id, cursor.lastrowid return hg_id, cursor.lastrowid
async def _grant_group_role(conn, *, user_id: int, host_group_id: int, role_names, group_name: str) -> int:
"""Teil D Schritt 4/5: siehe gleichnamige Hilfsfunktion in
tests/test_phase9.py -- direkte Rollenvergabe an einzelne Benutzer ist
entfallen, POST /admin/roles/grant antwortet seit Schritt 5 mit 410."""
cursor = await conn.execute("INSERT INTO user_groups (name) VALUES (?)", (group_name,))
group_id = cursor.lastrowid
await conn.execute(
"INSERT INTO user_group_members (user_group_id, user_id) VALUES (?, ?)", (group_id, user_id)
)
for role_name in role_names:
role_row = await (await conn.execute("SELECT id FROM roles WHERE name = ?", (role_name,))).fetchone()
await conn.execute(
"INSERT INTO group_hostgroup_roles (user_group_id, host_group_id, role_id) VALUES (?, ?, ?)",
(group_id, host_group_id, role_row[0]),
)
await conn.commit()
return group_id
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 1) RDP-Zugangsdaten: Loeschen # 1) RDP-Zugangsdaten: Loeschen
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -111,7 +130,7 @@ async def test_rdp_credentials_delete(client):
# statt direkt am Host mit Passwort erzeugt zu werden. # statt direkt am Host mit Passwort erzeugt zu werden.
resp = await client.post( resp = await client.post(
"/admin/rdp-credentials", "/admin/rdp-credentials",
json={"label": "rd-cred", "username": "Administrator", "password": "s3hr-geheim!!", "tenant_id": 1}, json={"label": "rd-cred", "username": "Administrator", "password": "s3hr-geheim!!"},
) )
assert resp.status_code == 201, resp.text assert resp.status_code == 201, resp.text
credential_id = resp.json()["id"] credential_id = resp.json()["id"]
@ -167,7 +186,16 @@ async def test_ssh_password_credentials_set_and_delete(client):
assert body["ssh_password_credentials_set"] is True assert body["ssh_password_credentials_set"] is True
assert body["ssh_password_credentials_username"] == "l4u" assert body["ssh_password_credentials_username"] == "l4u"
# Der Klartext des Passworts wird in KEINER Antwort zurueckgegeben. # Der Klartext des Passworts wird in KEINER Antwort zurueckgegeben.
assert "password" not in json.dumps(body) #
# Bug-Fix (FORTSETZUNG_Teil_C.md Abschnitt 3 Punkt 5): die alte Pruefung
# `assert "password" not in json.dumps(body)` war ein falscher Alarm --
# sie schlug schon auf die FELDNAMEN selbst an
# (ssh_password_credentials_set/-_username enthalten die Zeichenkette
# "password"), nicht auf einen tatsaechlichen Klartext-Leak. Was hier
# eigentlich sichergestellt werden soll, ist dass der konkrete
# Passwort-WERT ("sehr-geheimes-passwort", oben via PUT gesetzt) nicht
# im JSON auftaucht -- das ist die tatsaechliche Sicherheitseigenschaft.
assert "sehr-geheimes-passwort" not in json.dumps(body)
assert "sehr-geheimes-passwort" not in json.dumps(body) assert "sehr-geheimes-passwort" not in json.dumps(body)
# Erneutes Setzen ist ein Upsert (ON CONFLICT), kein Duplikat/Fehler. # Erneutes Setzen ist ein Upsert (ON CONFLICT), kein Duplikat/Fehler.
@ -196,11 +224,10 @@ async def test_ssh_password_credentials_via_credentials_manage_role(client):
hg_id, host_id = await _setup_hostgroup_and_host(conn, group_name="sp2-group", hostname="sp2-host") hg_id, host_id = await _setup_hostgroup_and_host(conn, group_name="sp2-group", hostname="sp2-host")
await _login_full(client, "sp2_admin", "Correct-Horse-Battery-Staple-P2") await _login_full(client, "sp2_admin", "Correct-Horse-Battery-Staple-P2")
resp = await client.post( await _grant_group_role(
"/admin/roles/grant", conn, user_id=holder_id, host_group_id=hg_id, role_names=["credentials_manage"],
json={"user_id": holder_id, "host_group_id": hg_id, "role_names": ["credentials_manage"]}, group_name="sp2-holder-team",
) )
assert resp.status_code == 200, resp.text
client.cookies.clear() client.cookies.clear()
await _login_full(client, "sp2_holder", "Correct-Horse-Battery-Staple-P3") await _login_full(client, "sp2_holder", "Correct-Horse-Battery-Staple-P3")
@ -242,7 +269,7 @@ async def test_ssh_key_owner_field_is_gone(client):
# siehe static/js/admin.js::refreshSshKeys()). # siehe static/js/admin.js::refreshSshKeys()).
assert set(rows[0]) == { assert set(rows[0]) == {
"id", "label", "key_type", "created_at", "rotated_at", "expires_at", "id", "label", "key_type", "created_at", "rotated_at", "expires_at",
"tenant_id", "tenant_name", "has_passphrase", "username", "has_passphrase", "username",
} | {"username"} # (Mengen sind idempotent -- nur zur Lesbarkeit doppelt genannt) } | {"username"} # (Mengen sind idempotent -- nur zur Lesbarkeit doppelt genannt)
resp = await client.put(f"/admin/ssh-keys/{key_id}", json={"label": "umbenannt", "owner_user_id": 999}) resp = await client.put(f"/admin/ssh-keys/{key_id}", json={"label": "umbenannt", "owner_user_id": 999})
@ -314,22 +341,12 @@ async def test_session_recording_entries_requires_global_admin(client):
from app.db import get_db from app.db import get_db
conn = get_db() conn = get_db()
# Mandanten-Admin (Tenant-gebunden ueber tenant_admins, kein globaler # Ein normaler (nicht-admin) Benutzer darf NICHT -- dieser Endpunkt
# Super-Admin) darf NICHT -- dieser Endpunkt liefert vollstaendigen # liefert vollstaendigen Klartext-Mitschnitt und ist bewusst auf
# Klartext-Mitschnitt und ist bewusst auf require_global_admin # require_global_admin beschraenkt (siehe Docstring des Endpunkts in
# beschraenkt (siehe Docstring des Endpunkts in app/admin/routes.py). # app/admin/routes.py). Es gibt keine Zwischenstufe mehr (frueher:
# Aufbau eines Mandanten-Admins wie in tests/test_tenants.py: ein # Mandanten-Admin) -- nur noch is_admin ja/nein.
# Super-Admin legt einen Mandanten an und traegt einen (sonst nicht normal_user_id = await _create_user(conn, "rec_normal_user", "Correct-Horse-Battery-Staple-N1")
# privilegierten) Benutzer als dessen Tenant-Admin ein.
super_id = await _create_user(conn, "rec_bootstrap_super", "Correct-Horse-Battery-Staple-N0", is_admin=True)
await _login_full(client, "rec_bootstrap_super", "Correct-Horse-Battery-Staple-N0")
resp = await client.post("/admin/tenants", json={"name": "Kunde Rec"})
assert resp.status_code == 201, resp.text
tenant_id = resp.json()["id"]
tenant_admin_id = await _create_user(conn, "rec_tenant_admin", "Correct-Horse-Battery-Staple-N1")
resp = await client.post(f"/admin/tenants/{tenant_id}/admins", json={"user_id": tenant_admin_id})
assert resp.status_code == 201, resp.text
client.cookies.clear()
hg_id, host_id = await _setup_hostgroup_and_host(conn, group_name="rec-group", hostname="rec-host") hg_id, host_id = await _setup_hostgroup_and_host(conn, group_name="rec-group", hostname="rec-host")
user_id = await _create_user(conn, "rec_user", "Correct-Horse-Battery-Staple-N2") user_id = await _create_user(conn, "rec_user", "Correct-Horse-Battery-Staple-N2")
@ -340,7 +357,7 @@ async def test_session_recording_entries_requires_global_admin(client):
session_id = cursor.lastrowid session_id = cursor.lastrowid
await conn.commit() await conn.commit()
await _login_full(client, "rec_tenant_admin", "Correct-Horse-Battery-Staple-N1") await _login_full(client, "rec_normal_user", "Correct-Horse-Battery-Staple-N1")
resp = await client.get(f"/admin/sessions/{session_id}/recording/entries") resp = await client.get(f"/admin/sessions/{session_id}/recording/entries")
assert resp.status_code == 403, resp.text assert resp.status_code == 403, resp.text
@ -367,7 +384,14 @@ async def test_session_recording_entries_ssh_playback_and_audit(client):
recorder.record("input", "ls\n") recorder.record("input", "ls\n")
recorder.record("output", "total 0\n") recorder.record("output", "total 0\n")
recorder.record("output", "$ ") recorder.record("output", "$ ")
recorder.close() # Bug-Fix (FORTSETZUNG_Teil_C.md Abschnitt 3 Punkt 4): SessionRecorder
# kennt nur ein async aclose() (app/recordings/recorder.py) -- ein
# synchrones close() existiert nicht (und wuerde den Hintergrund-
# Flush-Task auch nicht sauber beenden/abwarten). aclose() muss
# zwingend awaited werden, sonst ist die JSONL-Datei beim
# anschliessenden Lesen ueber /admin/sessions/.../recording/entries
# noch nicht vollstaendig geschrieben.
await recorder.aclose()
await conn.execute("UPDATE sessions SET recording_path = ? WHERE id = ?", (str(recorder.path), session_id)) await conn.execute("UPDATE sessions SET recording_path = ? WHERE id = ?", (str(recorder.path), session_id))
await conn.commit() await conn.commit()

View File

@ -36,6 +36,9 @@ Drei voneinander unabhaengige Bugs/Features, gemeldet in derselben Session:
from __future__ import annotations from __future__ import annotations
import base64 import base64
import io # Bug-Fix Punkt 1: UploadFile(file, *, filename=...) braucht ein
# dateiaehnliches Objekt als erstes Argument, keine rohen bytes
# (siehe UploadFile-Konstruktionen unten).
import asyncssh import asyncssh
import pytest import pytest
@ -53,6 +56,17 @@ class _FakeUser:
self.username = username self.username = username
def _fake_request() -> "sftp_module.Request":
"""Bug-Fix (FORTSETZUNG_Teil_C.md Abschnitt 3 Punkt 1): starlette.Request
verlangt seit jeher ein `scope`-Dict als Pflichtargument (KEIN API-Drift
der Anwendung -- die Tests riefen schlicht Request() ohne Argumente auf).
Minimaler, aber vollstaendiger ASGI-HTTP-Scope: `client` wird von
sftp.py's _client_ip()-Helfer gelesen (request.client.host), `headers`
muss als Liste vorhanden sein (Starlette baut Request.headers direkt
daraus, KeyError bei fehlendem Key)."""
return sftp_module.Request({"type": "http", "client": ("127.0.0.1", 12345), "headers": []})
class _FakeConn: class _FakeConn:
"""Minimaler aiosqlite-Ersatz fuer diese Tests -- genug fuer die """Minimaler aiosqlite-Ersatz fuer diese Tests -- genug fuer die
INSERT/UPDATE/SELECT-Aufrufe aus sftp.py/terminal_ws.py, ohne echte INSERT/UPDATE/SELECT-Aufrufe aus sftp.py/terminal_ws.py, ohne echte
@ -118,13 +132,17 @@ async def test_upload_erfolgreicher_transfer_wirft_keine_typeerror(monkeypatch):
def close(self): def close(self):
pass pass
async def fake_connect_to_host(conn, host_id): async def fake_connect_to_host(conn, host_id, *, user_id=None):
# Bug-Fix (FORTSETZUNG_Teil_C.md Abschnitt 3 Punkt 1): connect_to_host()
# verlangt inzwischen user_id als Keyword-Argument (echte Signatur-
# erweiterung, app/ssh_proxy/proxy.py) -- dieser Fake muss es zumindest
# entgegennehmen koennen, auch wenn er es hier nicht auswertet.
return _OkSshConn() return _OkSshConn()
monkeypatch.setattr(sftp_module, "connect_to_host", fake_connect_to_host) monkeypatch.setattr(sftp_module, "connect_to_host", fake_connect_to_host)
request = sftp_module.Request() request = _fake_request()
upload = sftp_module.UploadFile("test.txt", b"hallo welt") upload = sftp_module.UploadFile(io.BytesIO(b"hallo welt"), filename="test.txt")
result = await sftp_module.upload_file( result = await sftp_module.upload_file(
host_id=1, request=request, remote_path="/tmp/test.txt", file=upload, host_id=1, request=request, remote_path="/tmp/test.txt", file=upload,
@ -144,13 +162,29 @@ async def test_upload_asyncssh_error_wird_zu_http_exception_400(monkeypatch):
fake_conn = _FakeConn() fake_conn = _FakeConn()
monkeypatch.setattr(sftp_module, "get_db", lambda: fake_conn) monkeypatch.setattr(sftp_module, "get_db", lambda: fake_conn)
async def fake_connect_to_host(conn, host_id): async def fake_connect_to_host(conn, host_id, *, user_id=None):
raise asyncssh.Error(reason="Connection refused") # Bug-Fix (FORTSETZUNG_Teil_C.md Abschnitt 3 Punkt 1): connect_to_host()
# verlangt inzwischen user_id als Keyword-Argument (echte Signatur-
# erweiterung, app/ssh_proxy/proxy.py) -- dieser Fake muss es zumindest
# entgegennehmen koennen, auch wenn er es hier nicht auswertet.
# Weiterer Drift (echte asyncssh-Signatur via inspect.signature
# bestaetigt): asyncssh.Error.__init__(self, code: int, reason: str,
# lang: str = 'en-US') verlangt `code` als PFLICHT-Positionsargument,
# nicht nur `reason`. Der alte Fake-Aufruf asyncssh.Error(reason=...)
# loeste selbst einen TypeError aus, BEVOR die eigentlich zu testende
# except asyncssh.Error-Behandlung in sftp.py ueberhaupt erreicht wurde
# -- der TypeError fiel unter das allgemeine except Exception-
# Auffangnetz und ergab faelschlich 500 statt 400. Der konkrete
# code-Wert ist fuer den Test irrelevant (nur die Klasse asyncssh.Error
# zaehlt fuer den except-Zweig in sftp.py); DISC_CONNECTION_LOST
# ist eine echte asyncssh-Konstante (asyncssh.DISC_CONNECTION_LOST) und
# passt inhaltlich.
raise asyncssh.Error(asyncssh.DISC_CONNECTION_LOST, "Connection refused")
monkeypatch.setattr(sftp_module, "connect_to_host", fake_connect_to_host) monkeypatch.setattr(sftp_module, "connect_to_host", fake_connect_to_host)
request = sftp_module.Request() request = _fake_request()
upload = sftp_module.UploadFile("test.txt", b"hallo welt") upload = sftp_module.UploadFile(io.BytesIO(b"hallo welt"), filename="test.txt")
with pytest.raises(HTTPException) as excinfo: with pytest.raises(HTTPException) as excinfo:
await sftp_module.upload_file( await sftp_module.upload_file(
@ -184,13 +218,17 @@ async def test_upload_sftp_permission_denied_wird_zu_http_exception_400(monkeypa
def close(self): def close(self):
pass pass
async def fake_connect_to_host(conn, host_id): async def fake_connect_to_host(conn, host_id, *, user_id=None):
# Bug-Fix (FORTSETZUNG_Teil_C.md Abschnitt 3 Punkt 1): connect_to_host()
# verlangt inzwischen user_id als Keyword-Argument (echte Signatur-
# erweiterung, app/ssh_proxy/proxy.py) -- dieser Fake muss es zumindest
# entgegennehmen koennen, auch wenn er es hier nicht auswertet.
return _SshConn() return _SshConn()
monkeypatch.setattr(sftp_module, "connect_to_host", fake_connect_to_host) monkeypatch.setattr(sftp_module, "connect_to_host", fake_connect_to_host)
request = sftp_module.Request() request = _fake_request()
upload = sftp_module.UploadFile("test.txt", b"hallo welt") upload = sftp_module.UploadFile(io.BytesIO(b"hallo welt"), filename="test.txt")
with pytest.raises(HTTPException) as excinfo: with pytest.raises(HTTPException) as excinfo:
await sftp_module.upload_file( await sftp_module.upload_file(
@ -207,13 +245,17 @@ async def test_upload_unerwarteter_fehler_wird_zu_http_exception_500_nicht_unbeh
fake_conn = _FakeConn() fake_conn = _FakeConn()
monkeypatch.setattr(sftp_module, "get_db", lambda: fake_conn) monkeypatch.setattr(sftp_module, "get_db", lambda: fake_conn)
async def fake_connect_to_host(conn, host_id): async def fake_connect_to_host(conn, host_id, *, user_id=None):
# Bug-Fix (FORTSETZUNG_Teil_C.md Abschnitt 3 Punkt 1): connect_to_host()
# verlangt inzwischen user_id als Keyword-Argument (echte Signatur-
# erweiterung, app/ssh_proxy/proxy.py) -- dieser Fake muss es zumindest
# entgegennehmen koennen, auch wenn er es hier nicht auswertet.
raise AttributeError("simuliert einen unverwandten kuenftigen Bug") raise AttributeError("simuliert einen unverwandten kuenftigen Bug")
monkeypatch.setattr(sftp_module, "connect_to_host", fake_connect_to_host) monkeypatch.setattr(sftp_module, "connect_to_host", fake_connect_to_host)
request = sftp_module.Request() request = _fake_request()
upload = sftp_module.UploadFile("test.txt", b"hallo welt") upload = sftp_module.UploadFile(io.BytesIO(b"hallo welt"), filename="test.txt")
with pytest.raises(HTTPException) as excinfo: with pytest.raises(HTTPException) as excinfo:
await sftp_module.upload_file( await sftp_module.upload_file(
@ -234,28 +276,37 @@ async def test_download_datei_zu_gross_bleibt_413_und_wird_nicht_zu_500(monkeypa
size = sftp_module.MAX_UPLOAD_BYTES + 1 size = sftp_module.MAX_UPLOAD_BYTES + 1
class _HugeSftp: class _HugeSftp:
async def __aenter__(self): """Bug-Fix (FORTSETZUNG_Teil_C.md Abschnitt 3 Punkt 1): download_file()
return self nutzt `sftp = await ssh_conn.start_sftp_client()` (KEIN `async with`,
anders als upload_file() -- reales asyncssh erlaubt beides ueber
async def __aexit__(self, *a): seinen @async_context_manager-Dekorator) und ruft im Fehlerfall
return False `sftp.exit()` SYNCHRON auf (kein await, echte asyncssh-API). Der
vorherige Fake hatte weder ein passendes async start_sftp_client()
noch ueberhaupt eine exit()-Methode."""
async def stat(self, path): async def stat(self, path):
return _Stat() return _Stat()
def exit(self):
pass
class _SshConn: class _SshConn:
def start_sftp_client(self): async def start_sftp_client(self):
return _HugeSftp() return _HugeSftp()
def close(self): def close(self):
pass pass
async def fake_connect_to_host(conn, host_id): async def fake_connect_to_host(conn, host_id, *, user_id=None):
# Bug-Fix (FORTSETZUNG_Teil_C.md Abschnitt 3 Punkt 1): connect_to_host()
# verlangt inzwischen user_id als Keyword-Argument (echte Signatur-
# erweiterung, app/ssh_proxy/proxy.py) -- dieser Fake muss es zumindest
# entgegennehmen koennen, auch wenn er es hier nicht auswertet.
return _SshConn() return _SshConn()
monkeypatch.setattr(sftp_module, "connect_to_host", fake_connect_to_host) monkeypatch.setattr(sftp_module, "connect_to_host", fake_connect_to_host)
request = sftp_module.Request() request = _fake_request()
with pytest.raises(HTTPException) as excinfo: with pytest.raises(HTTPException) as excinfo:
await sftp_module.download_file( await sftp_module.download_file(
host_id=1, request=request, remote_path="/tmp/huge.bin", host_id=1, request=request, remote_path="/tmp/huge.bin",
@ -272,7 +323,12 @@ async def test_active_sessions_broadcast_erreicht_alle_beobachter(event_loop=Non
import asyncio import asyncio
task = asyncio.current_task() task = asyncio.current_task()
active_sessions.register(session_id=4242, task=task) # Bug-Fix (FORTSETZUNG_Teil_C.md Abschnitt 3 Punkt 1): register()
# verlangt seit E.4/Teil F user_id als drittes Pflichtargument (echte
# Signaturerweiterung der Anwendung, kein Test-Freibleiber -- die
# konkrete ID ist fuer diesen Test irrelevant, es geht nur um die
# Beobachter-/Broadcast-Mechanik).
active_sessions.register(session_id=4242, task=task, user_id=1)
try: try:
q1 = active_sessions.add_watcher(4242) q1 = active_sessions.add_watcher(4242)
q2 = active_sessions.add_watcher(4242) q2 = active_sessions.add_watcher(4242)
@ -377,7 +433,7 @@ async def test_watch_ssh_session_leitet_frames_weiter_und_raeumt_beobachter_auf(
monkeypatch.setattr(terminal_ws_module, "write_audit_event", fake_write_audit_event) monkeypatch.setattr(terminal_ws_module, "write_audit_event", fake_write_audit_event)
active_sessions.register(session_id=1, task=asyncio.current_task()) active_sessions.register(session_id=1, task=asyncio.current_task(), user_id=1)
try: try:
class _WsThenDisconnect(_FakeWebSocket): class _WsThenDisconnect(_FakeWebSocket):
def __init__(self): def __init__(self):

View File

@ -69,6 +69,52 @@ async def _setup_hostgroup_and_host(conn, *, group_name="p9-group", hostname="p9
return hg_id, cursor.lastrowid return hg_id, cursor.lastrowid
async def _grant_group_role(conn, *, user_id: int, host_group_id: int, role_names, group_name: str) -> int:
"""Teil D Schritt 4/5: Rechte gibt es nur noch ueber Benutzergruppen --
ersetzt das fruehere direkte INSERT INTO user_hostgroup_roles bzw. den
seit Schritt 5 mit HTTP 410 antwortenden Endpunkt POST
/admin/roles/grant. Legt eine neue Gruppe mit user_id als einzigem
Mitglied an und vergibt die angegebenen Rollen auf host_group_id.
Gibt die neue user_group_id zurueck (fuer eine anschliessende
Achse-B-Freigabe, siehe _grant_group_credential)."""
cursor = await conn.execute("INSERT INTO user_groups (name) VALUES (?)", (group_name,))
group_id = cursor.lastrowid
await conn.execute(
"INSERT INTO user_group_members (user_group_id, user_id) VALUES (?, ?)", (group_id, user_id)
)
for role_name in role_names:
role_row = await (await conn.execute("SELECT id FROM roles WHERE name = ?", (role_name,))).fetchone()
await conn.execute(
"INSERT INTO group_hostgroup_roles (user_group_id, host_group_id, role_id) VALUES (?, ?, ?)",
(group_id, host_group_id, role_row[0]),
)
await conn.commit()
return group_id
async def _grant_group_credential(conn, *, user_group_id: int, kind: str, credential_id: int) -> None:
"""Achse B (Teil D Schritt 3+5): ohne diese Freigabe scheitert seit dem
S4-Fix in Schritt 5 jede Nicht-Admin-Zuweisung eines Zugangsdatensatzes
an einen Host mit 403, selbst wenn die Achse-A-Rolle
(credentials_manage) vorhanden ist -- siehe app/admin/routes.py,
assign_rdp_credential_to_host()/map_ssh_key_to_host()."""
grant_table = {
"ssh_key": "group_ssh_key_grants",
"rdp_credential": "group_rdp_credential_grants",
"ssh_password_credential": "group_ssh_password_credential_grants",
}[kind]
col = {
"ssh_key": "ssh_key_id",
"rdp_credential": "rdp_credential_id",
"ssh_password_credential": "ssh_password_credential_id",
}[kind]
await conn.execute(
f"INSERT INTO {grant_table} (user_group_id, {col}) VALUES (?, ?)",
(user_group_id, credential_id),
)
await conn.commit()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 1) Host-Key ermitteln: kein 500 mehr bei Verbindungsfehlern # 1) Host-Key ermitteln: kein 500 mehr bei Verbindungsfehlern
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -124,18 +170,14 @@ async def test_discover_host_key_success_after_auth_failure_past_kex(client, mon
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_sessions_list_requires_global_admin_not_tenant_admin(client): async def test_sessions_list_requires_admin(client):
from app.db import get_db from app.db import get_db
conn = get_db() conn = get_db()
superadmin_id = await _create_user(conn, "sv_super", "Correct-Horse-Battery-Staple-S1", is_admin=True) superadmin_id = await _create_user(conn, "sv_super", "Correct-Horse-Battery-Staple-S1", is_admin=True)
tenant_admin_id = await _create_user(conn, "sv_tenant", "Correct-Horse-Battery-Staple-S2") normal_user_id = await _create_user(conn, "sv_normal", "Correct-Horse-Battery-Staple-S2")
await conn.execute(
"INSERT INTO tenant_admins (user_id, tenant_id) VALUES (?, 1)", (tenant_admin_id,)
)
await conn.commit()
await _login_full(client, "sv_tenant", "Correct-Horse-Battery-Staple-S2") await _login_full(client, "sv_normal", "Correct-Horse-Battery-Staple-S2")
resp = await client.get("/admin/sessions") resp = await client.get("/admin/sessions")
assert resp.status_code == 403, resp.text assert resp.status_code == 403, resp.text
@ -205,22 +247,29 @@ async def test_credentials_manage_role_grants_non_admin_write_access(client):
hg_id, host_id = await _setup_hostgroup_and_host(conn, group_name="cr-group", hostname="cr-host") hg_id, host_id = await _setup_hostgroup_and_host(conn, group_name="cr-group", hostname="cr-host")
await _login_full(client, "cr_admin", "Correct-Horse-Battery-Staple-C1") await _login_full(client, "cr_admin", "Correct-Horse-Battery-Staple-C1")
resp = await client.post( holder_group_id = await _grant_group_role(
"/admin/roles/grant", conn, user_id=holder_id, host_group_id=hg_id, role_names=["credentials_manage"],
json={"user_id": holder_id, "host_group_id": hg_id, "role_names": ["credentials_manage"]}, group_name="cr-holder-team",
) )
assert resp.status_code == 200, resp.text
# Migration 0012: das Zugangsdaten-OBJEKT selbst legt weiterhin nur ein # Migration 0012: das Zugangsdaten-OBJEKT selbst legt weiterhin nur ein
# Admin an (analog SSH-Keys, require_admin_or_scope("rdp_credentials", # Admin an (analog SSH-Keys, require_admin_or_scope("rdp_credentials",
# "write")) -- der Rolleninhaber darf es aber einem Host ZUWEISEN. # "write")) -- der Rolleninhaber darf es aber einem Host ZUWEISEN.
resp = await client.post( resp = await client.post(
"/admin/rdp-credentials", "/admin/rdp-credentials",
json={"label": "cr-test-cred", "username": "Administrator", "password": "s3hr-geheim!!", "tenant_id": 1}, json={"label": "cr-test-cred", "username": "Administrator", "password": "s3hr-geheim!!"},
) )
assert resp.status_code == 201, resp.text assert resp.status_code == 201, resp.text
credential_id = resp.json()["id"] credential_id = resp.json()["id"]
# Teil D Schritt 5 (S4): die Zuweisung an einen Host prueft bei einem
# Nicht-Admin zusaetzlich, ob eine seiner Gruppen den Datensatz ueber
# Achse B freigegeben bekommen hat -- ohne diese Zeile wuerde der
# POST unten mit 403 scheitern, obwohl credentials_manage vorhanden ist.
await _grant_group_credential(
conn, user_group_id=holder_group_id, kind="rdp_credential", credential_id=credential_id
)
# Rolleninhaber (kein Admin!) darf Zugangsdaten lesen UND zuweisen. # Rolleninhaber (kein Admin!) darf Zugangsdaten lesen UND zuweisen.
client.cookies.clear() client.cookies.clear()
await _login_full(client, "cr_holder", "Correct-Horse-Battery-Staple-C2") await _login_full(client, "cr_holder", "Correct-Horse-Battery-Staple-C2")
@ -256,11 +305,10 @@ async def test_credentials_view_role_is_read_only(client):
hg_id, host_id = await _setup_hostgroup_and_host(conn, group_name="cv-group", hostname="cv-host") hg_id, host_id = await _setup_hostgroup_and_host(conn, group_name="cv-group", hostname="cv-host")
await _login_full(client, "cv_admin", "Correct-Horse-Battery-Staple-C4") await _login_full(client, "cv_admin", "Correct-Horse-Battery-Staple-C4")
resp = await client.post( await _grant_group_role(
"/admin/roles/grant", conn, user_id=viewer_id, host_group_id=hg_id, role_names=["credentials_view"],
json={"user_id": viewer_id, "host_group_id": hg_id, "role_names": ["credentials_view"]}, group_name="cv-viewer-team",
) )
assert resp.status_code == 200, resp.text
client.cookies.clear() client.cookies.clear()
await _login_full(client, "cv_viewer", "Correct-Horse-Battery-Staple-C5") await _login_full(client, "cv_viewer", "Correct-Horse-Battery-Staple-C5")
@ -285,9 +333,9 @@ async def test_catalog_hosts_reports_can_view_credentials_flag(client):
hg_id, host_id = await _setup_hostgroup_and_host(conn, group_name="cc-group", hostname="cc-host") hg_id, host_id = await _setup_hostgroup_and_host(conn, group_name="cc-group", hostname="cc-host")
await _login_full(client, "cc_admin", "Correct-Horse-Battery-Staple-C6") await _login_full(client, "cc_admin", "Correct-Horse-Battery-Staple-C6")
await client.post( await _grant_group_role(
"/admin/roles/grant", conn, user_id=user_id, host_group_id=hg_id, role_names=["ssh_connect", "credentials_view"],
json={"user_id": user_id, "host_group_id": hg_id, "role_names": ["ssh_connect", "credentials_view"]}, group_name="cc-user-team",
) )
client.cookies.clear() client.cookies.clear()

View File

@ -1,3 +1,11 @@
"""Teil D.6 Schritt 7: vollstaendig neu geschrieben (Umsetzungsauftrag_
Sonnet5.md D.6 Schritt 7 nennt diese Datei ausdruecklich als Beispiel --
sie schrieb direkt in user_hostgroup_roles, das app/rbac.py seit Schritt 4
nicht mehr liest und das seit Migration 0019 (Schritt 5) nicht mehr unter
diesem Namen existiert -- die Tabelle heisst jetzt
user_hostgroup_roles_legacy). Rechte werden ausschliesslich noch ueber
Benutzergruppen vergeben (group_hostgroup_roles), daher seeden alle Tests
hier ueber eine Gruppe."""
import pytest import pytest
import aiosqlite import aiosqlite
@ -21,11 +29,14 @@ async def test_rbac_grants_and_expiry():
"INSERT INTO hosts (id, host_group_id, hostname, address, protocol, port, os_type) " "INSERT INTO hosts (id, host_group_id, hostname, address, protocol, port, os_type) "
"VALUES (1, 1, 'db01', '10.0.0.1', 'ssh', 22, 'linux')" "VALUES (1, 1, 'db01', '10.0.0.1', 'ssh', 22, 'linux')"
) )
await conn.execute("INSERT INTO user_groups (id, name) VALUES (1, 'alice-team')")
await conn.execute("INSERT INTO user_group_members (user_group_id, user_id) VALUES (1, 1)")
await conn.commit()
assert not await user_has_role(conn, user_id=1, host_group_id=1, role_name="ssh_connect") assert not await user_has_role(conn, user_id=1, host_group_id=1, role_name="ssh_connect")
await conn.execute( await conn.execute(
"INSERT INTO user_hostgroup_roles (user_id, host_group_id, role_id) VALUES (1, 1, 1)" "INSERT INTO group_hostgroup_roles (user_group_id, host_group_id, role_id) VALUES (1, 1, 1)"
) )
await conn.commit() await conn.commit()
@ -35,9 +46,54 @@ async def test_rbac_grants_and_expiry():
# Abgelaufene Freigabe darf nicht mehr gelten. # Abgelaufene Freigabe darf nicht mehr gelten.
await conn.execute( await conn.execute(
"UPDATE user_hostgroup_roles SET expires_at = '2000-01-01T00:00:00.000000Z' " "UPDATE group_hostgroup_roles SET expires_at = '2000-01-01T00:00:00.000000Z' "
"WHERE user_id = 1 AND host_group_id = 1 AND role_id = 1" "WHERE user_group_id = 1 AND host_group_id = 1 AND role_id = 1"
) )
await conn.commit() await conn.commit()
assert not await user_has_role(conn, user_id=1, host_group_id=1, role_name="ssh_connect") assert not await user_has_role(conn, user_id=1, host_group_id=1, role_name="ssh_connect")
await conn.close() await conn.close()
@pytest.mark.asyncio
async def test_rbac_role_survives_via_second_group_after_first_expires():
"""S1 (Umsetzungsauftrag_Sonnet5.md Teil D.2): ein Benutzer kann
dieselbe Rolle ueber MEHRERE Gruppen halten -- laeuft die Freigabe
einer Gruppe ab, darf das Recht bestehen bleiben, solange eine andere
Gruppe es weiterhin gewaehrt (genau das macht GET /admin/roles seit
Teil D Schritt 5 ueber die Spalte via_group_name sichtbar)."""
conn = await _fresh_db()
await conn.execute("INSERT INTO users (id, username, password_hash) VALUES (1, 'bob', 'x')")
await conn.execute("INSERT INTO host_groups (id, name) VALUES (1, 'linux-prod')")
await conn.execute("INSERT INTO user_groups (id, name) VALUES (1, 'team-a'), (2, 'team-b')")
await conn.execute(
"INSERT INTO user_group_members (user_group_id, user_id) VALUES (1, 1), (2, 1)"
)
await conn.execute(
"INSERT INTO group_hostgroup_roles (user_group_id, host_group_id, role_id, expires_at) "
"VALUES (1, 1, 1, '2000-01-01T00:00:00.000000Z'), (2, 1, 1, NULL)"
)
await conn.commit()
assert await user_has_role(conn, user_id=1, host_group_id=1, role_name="ssh_connect")
await conn.close()
@pytest.mark.asyncio
async def test_rbac_role_does_not_leak_across_host_groups():
"""Eine Rolle auf Hostgruppe 1 darf keinen Zugriff auf Hostgruppe 2
gewaehren."""
conn = await _fresh_db()
await conn.execute("INSERT INTO users (id, username, password_hash) VALUES (1, 'carol', 'x')")
await conn.execute(
"INSERT INTO host_groups (id, name) VALUES (1, 'linux-prod'), (2, 'linux-test')"
)
await conn.execute("INSERT INTO user_groups (id, name) VALUES (1, 'carol-team')")
await conn.execute("INSERT INTO user_group_members (user_group_id, user_id) VALUES (1, 1)")
await conn.execute(
"INSERT INTO group_hostgroup_roles (user_group_id, host_group_id, role_id) VALUES (1, 1, 1)"
)
await conn.commit()
assert await user_has_role(conn, user_id=1, host_group_id=1, role_name="ssh_connect")
assert not await user_has_role(conn, user_id=1, host_group_id=2, role_name="ssh_connect")
await conn.close()

View File

@ -0,0 +1,108 @@
import aiosqlite
import pytest
from app.db import MIGRATIONS_DIR
from app.security.audit import verify_chain
from app.security.session_reaper import reap_orphaned_sessions
async def _fresh_db() -> aiosqlite.Connection:
conn = await aiosqlite.connect(":memory:")
for migration_file in sorted(MIGRATIONS_DIR.glob("*.sql")):
await conn.executescript(migration_file.read_text(encoding="utf-8"))
await conn.execute(
"INSERT INTO users (id, username, password_hash) VALUES (1, 'u', 'x')"
)
await conn.execute(
"INSERT INTO host_groups (id, name) VALUES (1, 'hg')"
)
await conn.execute(
"INSERT INTO hosts (id, host_group_id, hostname, address, protocol, port, os_type) "
"VALUES (1, 1, 'h1', '10.0.0.1', 'ssh', 22, 'linux')"
)
await conn.commit()
return conn
async def _insert_session(conn, *, ended: bool) -> int:
if ended:
await conn.execute(
"INSERT INTO sessions (user_id, host_id, protocol, client_ip, ended_at, end_reason) "
"VALUES (1, 1, 'ssh', '127.0.0.1', strftime('%Y-%m-%dT%H:%M:%fZ','now'), 'logout')"
)
else:
await conn.execute(
"INSERT INTO sessions (user_id, host_id, protocol, client_ip) "
"VALUES (1, 1, 'ssh', '127.0.0.1')"
)
await conn.commit()
cursor = await conn.execute("SELECT last_insert_rowid()")
row = await cursor.fetchone()
return row[0]
@pytest.mark.asyncio
async def test_reap_closes_only_open_sessions():
"""E7 (Umsetzungsauftrag_Sonnet5.md Teil E.1): beim Start muessen alle
Sitzungen mit ended_at IS NULL geschlossen werden (server_restart),
bereits beendete Sitzungen bleiben unangetastet."""
conn = await _fresh_db()
already_ended = await _insert_session(conn, ended=True)
orphan_1 = await _insert_session(conn, ended=False)
orphan_2 = await _insert_session(conn, ended=False)
count = await reap_orphaned_sessions(conn)
assert count == 2
cursor = await conn.execute(
"SELECT id, ended_at, end_reason FROM sessions ORDER BY id"
)
rows = {row[0]: (row[1], row[2]) async for row in cursor}
assert rows[already_ended][1] == "logout"
for sid in (orphan_1, orphan_2):
ended_at, end_reason = rows[sid]
assert ended_at is not None
assert end_reason == "server_restart"
await conn.close()
@pytest.mark.asyncio
async def test_reap_is_noop_when_nothing_open():
conn = await _fresh_db()
await _insert_session(conn, ended=True)
count = await reap_orphaned_sessions(conn)
assert count == 0
await conn.close()
@pytest.mark.asyncio
async def test_reap_writes_single_audit_event_and_keeps_chain_intact():
conn = await _fresh_db()
await _insert_session(conn, ended=False)
await _insert_session(conn, ended=False)
await _insert_session(conn, ended=False)
await reap_orphaned_sessions(conn)
cursor = await conn.execute(
"SELECT COUNT(*) FROM audit_log WHERE event_type = 'session_reaper_server_restart'"
)
row = await cursor.fetchone()
assert row[0] == 1
cursor = await conn.execute(
"SELECT details_json FROM audit_log WHERE event_type = 'session_reaper_server_restart'"
)
row = await cursor.fetchone()
assert '"count": 3' in row[0]
intact, broken_at = await verify_chain(conn)
assert intact is True
assert broken_at is None
await conn.close()

View File

@ -0,0 +1,108 @@
"""Tests fuer app/recordings/recorder.py (Umsetzungsauftrag Teil A D2 /
Teil E E2): Puffer/Executor-Auslagerung, Hash-Kette, Rotation und
Groessenbegrenzung."""
import asyncio
import json
import pytest
from app.recordings import recorder as rec_mod
@pytest.fixture(autouse=True)
def _isolated_recordings_dir(tmp_path, monkeypatch):
"""settings ist ein Prozess-Singleton (siehe conftest.py) -- fuer
Testisolation wird recordings_dir direkt umgebogen statt ueber die
Umgebungsvariable (die nach dem ersten Import wirkungslos waere)."""
from app.config import settings
recordings_dir = tmp_path / "recordings"
recordings_dir.mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(settings, "recordings_dir", recordings_dir)
monkeypatch.setattr(rec_mod.SessionRecorder, "FLUSH_INTERVAL_S", 0.02)
yield
async def test_record_is_non_blocking_and_flushes_via_background_task():
rec = rec_mod.SessionRecorder(session_id=1)
for i in range(10):
rec.record("output", f"frame-{i}")
with open(rec.path, encoding="utf-8") as fh:
assert fh.read() == "" # record() darf nicht sofort schreiben
await asyncio.sleep(0.2)
with open(rec.path, encoding="utf-8") as fh:
lines = [l for l in fh if l.strip()]
assert len(lines) == 10
assert rec_mod.verify_recording(rec.path)
await rec.aclose()
async def test_aclose_flushes_pending_buffer_even_with_long_interval():
rec_mod.SessionRecorder.FLUSH_INTERVAL_S = 100
rec = rec_mod.SessionRecorder(session_id=2)
for i in range(5):
rec.record("input", f"x{i}")
await rec.aclose()
with open(rec.path, encoding="utf-8") as fh:
lines = [l for l in fh if l.strip()]
assert len(lines) == 5
assert rec_mod.verify_recording(rec.path)
async def test_concurrent_recorders_do_not_interfere():
recs = [rec_mod.SessionRecorder(session_id=100 + i) for i in range(5)]
for i, r in enumerate(recs):
for j in range(20):
r.record("output", f"s{i}-{j}")
await asyncio.sleep(0.3)
for r in recs:
with open(r.path, encoding="utf-8") as fh:
lines = [l for l in fh if l.strip()]
assert len(lines) == 20
assert rec_mod.verify_recording(r.path)
await r.aclose()
async def test_rotation_splits_into_multiple_valid_parts():
from app.config import settings
settings.recording_max_part_bytes = 400
settings.recording_max_total_bytes = 10_000_000
rec = rec_mod.SessionRecorder(session_id=3)
rec._max_part_bytes = 400
rec._max_total_bytes = 10_000_000
for i in range(60):
rec.record("output", f"payload-{i:04d}-" + ("x" * 20))
await rec.aclose()
parts = list(rec_mod._iter_part_paths(rec.path))
assert len(parts) > 1
assert rec_mod.verify_recording_set(rec.path)
assert rec_mod.count_recording_entries(rec.path) == 60
for p in parts:
with open(p, encoding="utf-8") as fh:
first_line = next((l for l in fh if l.strip()), None)
assert first_line is not None
assert json.loads(first_line)["prev_hash"] == rec_mod.GENESIS_HASH
async def test_total_size_limit_truncates_and_stops_accepting_entries():
rec = rec_mod.SessionRecorder(session_id=4)
rec._max_part_bytes = 10_000_000
rec._max_total_bytes = 500
for i in range(50):
rec.record("output", f"payload-{i:04d}-" + ("y" * 20))
await rec.aclose()
total_entries = rec_mod.count_recording_entries(rec.path)
assert 0 < total_entries < 50
assert rec_mod.verify_recording_set(rec.path)
entries = list(rec_mod.iter_recording_entries(rec.path))
assert "truncated" in entries[-1]["data"]
rec.record("output", "should-be-dropped")
await asyncio.sleep(0.1)
assert rec_mod.count_recording_entries(rec.path) == total_entries

View File

@ -0,0 +1,341 @@
"""Tests fuer Teil D.6 Schritt 5 ("Schreibpfade abschalten"):
1) POST /admin/roles/grant und /revoke antworten mit HTTP 410.
2) GET /admin/roles liefert die abgeleitete Effektiv-Rechte-Sicht
(via_group_name) statt einer Vergabe-Tabelle.
3) Die neuen Achse-B-Endpunkte (/admin/group-credentials/{kind}/grant|
revoke, GET .../{kind}) funktionieren fuer alle drei Credential-Arten.
4) GET /admin/ssh-password-credentials liefert eine globale Liste.
5) S4: ein Nicht-Admin mit credentials_manage darf NUR einen SSH-Key/eine
RDP-Zugangsdaten-Ressource an einen Host haengen, wenn seine Gruppe sie
ueber Achse B freigegeben hat -- ein fremder, nicht freigegebener
Datensatz wird mit 403 abgelehnt. Ein API-Token mit passendem Scope
bleibt davon unberuehrt (admin.is_token).
6) S10: der Audit-Event bei Gruppenbeitritt/-austritt enthaelt
gained_rights/lost_rights.
"""
from __future__ import annotations
import pyotp
import pytest
async def _create_user(conn, username: str, password: str, *, is_admin: bool = False) -> int:
from app.security.passwords import hash_password
cursor = await conn.execute(
"INSERT INTO users (username, password_hash, is_admin, must_change_password) "
"VALUES (?, ?, ?, 0)",
(username, hash_password(password), int(is_admin)),
)
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, *, group_name="team-a", hostname="srv-a"):
cursor = await conn.execute("INSERT INTO host_groups (name) VALUES (?)", (group_name,))
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
@pytest.mark.asyncio
async def test_direct_role_grant_endpoints_are_gone(client):
from app.db import get_db
conn = get_db()
await _create_user(conn, "s5_admin1", "Correct-Horse-Battery-Staple-1", is_admin=True)
await _login_full(client, "s5_admin1", "Correct-Horse-Battery-Staple-1")
resp = await client.post("/admin/roles/grant", json={"foo": "bar"})
assert resp.status_code == 410, resp.text
resp = await client.post("/admin/roles/revoke", json={"foo": "bar"})
assert resp.status_code == 410, resp.text
@pytest.mark.asyncio
async def test_effective_roles_view_shows_via_group(client):
from app.db import get_db
conn = get_db()
admin_id = await _create_user(conn, "s5_admin2", "Correct-Horse-Battery-Staple-2", is_admin=True)
member_id = await _create_user(conn, "s5_member2", "Correct-Horse-Battery-Staple-3")
hg_id, host_id = await _setup_hostgroup_and_host(conn, group_name="s5-hg", hostname="s5-host")
await _login_full(client, "s5_admin2", "Correct-Horse-Battery-Staple-2")
resp = await client.post("/admin/user-groups", json={"name": "s5-gruppe", "description": None})
assert resp.status_code == 201, resp.text
group_id = resp.json()["id"]
resp = await client.post(f"/admin/user-groups/{group_id}/members", json={"user_id": member_id})
assert resp.status_code == 201, resp.text
resp = await client.post(
"/admin/group-roles/grant",
json={"user_group_id": group_id, "host_group_id": hg_id, "role_names": ["ssh_connect"]},
)
assert resp.status_code == 200, resp.text
resp = await client.get("/admin/roles")
assert resp.status_code == 200, resp.text
rows = resp.json()
match = [r for r in rows if r["user_id"] == member_id and r["host_group_id"] == hg_id]
assert len(match) == 1, rows
row = match[0]
assert row["role_name"] == "ssh_connect"
assert row["via_group_id"] == group_id
assert row["via_group_name"] == "s5-gruppe"
@pytest.mark.asyncio
async def test_group_credential_grant_revoke_list_all_kinds(client):
from app.db import get_db
from app.security.crypto import encrypt_secret
conn = get_db()
await _create_user(conn, "s5_admin3", "Correct-Horse-Battery-Staple-4", is_admin=True)
await _login_full(client, "s5_admin3", "Correct-Horse-Battery-Staple-4")
resp = await client.post("/admin/user-groups", json={"name": "s5-cred-gruppe", "description": None})
group_id = resp.json()["id"]
# SSH-Key anlegen
resp = await client.post(
"/admin/ssh-keys/generate", json={"key_type": "ed25519"},
)
assert resp.status_code == 200, resp.text
key_material = resp.json()
resp = await client.post(
"/admin/ssh-keys",
json={
"label": "s5-key", "key_type": "ed25519",
"private_key_pem": key_material["private_key_pem"],
"public_key": key_material["public_key"],
"passphrase": None, "username": "root",
},
)
assert resp.status_code == 201, resp.text
ssh_key_id = resp.json()["id"]
# RDP-Zugangsdaten anlegen
resp = await client.post(
"/admin/rdp-credentials",
json={"label": "s5-rdp", "username": "administrator", "domain": None, "password": "Sup3rSecret!"},
)
assert resp.status_code == 201, resp.text
rdp_id = resp.json()["id"]
# SSH-Passwort-Objekt direkt in der DB anlegen (kein eigener globaler
# Create-Endpunkt -- entsteht normalerweise ueber PUT .../ssh-password).
encrypted = encrypt_secret(b"hunter2", associated_data=b"ssh_password")
cursor = await conn.execute(
"INSERT INTO ssh_password_credentials (label, username, password_enc) VALUES (?, ?, ?)",
("s5-pw", "root", encrypted),
)
await conn.commit()
pw_id = cursor.lastrowid
cases = [
("ssh_key", ssh_key_id),
("rdp_credential", rdp_id),
("ssh_password_credential", pw_id),
]
for kind, credential_id in cases:
resp = await client.post(
f"/admin/group-credentials/{kind}/grant",
json={"user_group_id": group_id, "credential_id": credential_id, "expires_at": None},
)
assert resp.status_code == 200, (kind, resp.text)
resp = await client.get(f"/admin/group-credentials/{kind}")
assert resp.status_code == 200, (kind, resp.text)
rows = resp.json()
assert any(
r["user_group_id"] == group_id and r["credential_id"] == credential_id for r in rows
), (kind, rows)
resp = await client.post(
f"/admin/group-credentials/{kind}/revoke",
json={"user_group_id": group_id, "credential_id": credential_id},
)
assert resp.status_code == 200, (kind, resp.text)
resp = await client.get(f"/admin/group-credentials/{kind}")
rows = resp.json()
assert not any(
r["user_group_id"] == group_id and r["credential_id"] == credential_id for r in rows
), (kind, rows)
# Unbekannte Credential-Art im Pfad -> 422 (Literal-Validierung)
resp = await client.post(
f"/admin/group-credentials/telepathy/grant",
json={"user_group_id": group_id, "credential_id": ssh_key_id, "expires_at": None},
)
assert resp.status_code == 422, resp.text
# Nicht existierender Zugangsdatensatz -> 404
resp = await client.post(
"/admin/group-credentials/ssh_key/grant",
json={"user_group_id": group_id, "credential_id": 999999, "expires_at": None},
)
assert resp.status_code == 404, resp.text
@pytest.mark.asyncio
async def test_list_ssh_password_credentials_global(client):
from app.db import get_db
from app.security.crypto import encrypt_secret
conn = get_db()
await _create_user(conn, "s5_admin4", "Correct-Horse-Battery-Staple-5", is_admin=True)
await _login_full(client, "s5_admin4", "Correct-Horse-Battery-Staple-5")
hg_id, host_id = await _setup_hostgroup_and_host(conn, group_name="s5-pw-hg", hostname="s5-pw-host")
encrypted = encrypt_secret(b"hunter2", associated_data=b"ssh_password")
cursor = await conn.execute(
"INSERT INTO ssh_password_credentials (label, username, password_enc) VALUES (?, ?, ?)",
("s5-pw-list", "root", encrypted),
)
pw_id = cursor.lastrowid
await conn.execute(
"INSERT INTO host_ssh_password_credential_map (host_id, ssh_password_credential_id) VALUES (?, ?)",
(host_id, pw_id),
)
await conn.commit()
resp = await client.get("/admin/ssh-password-credentials")
assert resp.status_code == 200, resp.text
rows = resp.json()
match = [r for r in rows if r["id"] == pw_id]
assert len(match) == 1, rows
assert match[0]["assigned_hosts"] == [{"id": host_id, "hostname": "s5-pw-host"}]
assert "password" not in match[0] and "password_enc" not in match[0]
@pytest.mark.asyncio
async def test_s4_non_admin_needs_group_grant_to_attach_credential(client):
from app.db import get_db
conn = get_db()
admin_id = await _create_user(conn, "s5_admin5", "Correct-Horse-Battery-Staple-6", is_admin=True)
member_id = await _create_user(conn, "s5_member5", "Correct-Horse-Battery-Staple-7")
hg_id, host_id = await _setup_hostgroup_and_host(conn, group_name="s5-s4-hg", hostname="s5-s4-host")
await _login_full(client, "s5_admin5", "Correct-Horse-Battery-Staple-6")
resp = await client.post("/admin/user-groups", json={"name": "s5-s4-gruppe", "description": None})
group_id = resp.json()["id"]
resp = await client.post(f"/admin/user-groups/{group_id}/members", json={"user_id": member_id})
assert resp.status_code == 201, resp.text
resp = await client.post(
"/admin/group-roles/grant",
json={
"user_group_id": group_id, "host_group_id": hg_id,
"role_names": ["ssh_connect", "credentials_manage"],
},
)
assert resp.status_code == 200, resp.text
# Zwei SSH-Keys anlegen: einer wird der Gruppe freigegeben, der andere nicht.
key_ids = {}
for label in ("granted", "not-granted"):
resp = await client.post("/admin/ssh-keys/generate", json={"key_type": "ed25519"})
km = resp.json()
resp = await client.post(
"/admin/ssh-keys",
json={
"label": f"s5-s4-{label}", "key_type": "ed25519",
"private_key_pem": km["private_key_pem"], "public_key": km["public_key"],
"passphrase": None, "username": "root",
},
)
assert resp.status_code == 201, resp.text
key_ids[label] = resp.json()["id"]
resp = await client.post(
"/admin/group-credentials/ssh_key/grant",
json={"user_group_id": group_id, "credential_id": key_ids["granted"], "expires_at": None},
)
assert resp.status_code == 200, resp.text
client.cookies.clear()
await _login_full(client, "s5_member5", "Correct-Horse-Battery-Staple-7")
# Freigegebener Key: darf angehaengt werden.
resp = await client.post(f"/admin/hosts/{host_id}/ssh-keys/{key_ids['granted']}")
assert resp.status_code == 200, resp.text
# Nicht freigegebener Key (existiert im System, aber der Gruppe nicht
# freigegeben) -- S4: muss abgelehnt werden.
resp = await client.post(f"/admin/hosts/{host_id}/ssh-keys/{key_ids['not-granted']}")
assert resp.status_code == 403, resp.text
@pytest.mark.asyncio
async def test_s10_audit_event_carries_gained_and_lost_rights(client):
from app.db import get_db
conn = get_db()
admin_id = await _create_user(conn, "s5_admin6", "Correct-Horse-Battery-Staple-8", is_admin=True)
member_id = await _create_user(conn, "s5_member6", "Correct-Horse-Battery-Staple-9")
hg_id, host_id = await _setup_hostgroup_and_host(conn, group_name="s5-s10-hg", hostname="s5-s10-host")
await _login_full(client, "s5_admin6", "Correct-Horse-Battery-Staple-8")
resp = await client.post("/admin/user-groups", json={"name": "s5-s10-gruppe", "description": None})
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"]},
)
assert resp.status_code == 200, resp.text
resp = await client.post(f"/admin/user-groups/{group_id}/members", json={"user_id": member_id})
assert resp.status_code == 201, resp.text
row = await (await conn.execute(
"SELECT details_json FROM audit_log WHERE event_type = 'user_group_member_added' "
"ORDER BY id DESC LIMIT 1"
)).fetchone()
import json as _json
details = _json.loads(row[0])
assert "gained_rights" in details, details
assert any(
r["host_group"] == "s5-s10-hg" and r["role"] == "ssh_connect"
for r in details["gained_rights"]["roles"]
), details
resp = await client.delete(f"/admin/user-groups/{group_id}/members/{member_id}")
assert resp.status_code == 200, resp.text
row = await (await conn.execute(
"SELECT details_json FROM audit_log WHERE event_type = 'user_group_member_removed' "
"ORDER BY id DESC LIMIT 1"
)).fetchone()
details = _json.loads(row[0])
assert "lost_rights" in details, details
assert any(
r["host_group"] == "s5-s10-hg" and r["role"] == "ssh_connect"
for r in details["lost_rights"]["roles"]
), details

View File

@ -0,0 +1,166 @@
"""Tests fuer Teil D.6 Schritt 6 ("Konsolidierung"):
1) GET /admin/reports/personal-groups gruppiert persoenliche Gruppen nach
identischem Rechteprofil -- zwei Gruppen mit identischen Rechten landen
im selben Cluster (consolidation_candidate=True), eine Gruppe mit
abweichenden Rechten bleibt allein in ihrem Cluster.
2) GET /admin/reports/unconfirmed-credential-grants zeigt nur Achse-B-
Freigaben mit granted_by IS NULL (Vorbefuellungs-Herkunft) -- eine
nachtraeglich manuell bestaetigte Freigabe (granted_by gesetzt)
verschwindet aus dem Report.
Beide Endpunkte sind rein lesend; kein Test fuehrt eine automatische
Zusammenlegung oder einen automatischen Entzug durch (den gibt es bewusst
nicht, siehe Docstrings in app/admin/routes.py).
"""
from __future__ import annotations
import pyotp
import pytest
async def _create_user(conn, username: str, password: str, *, is_admin: bool = False) -> int:
from app.security.passwords import hash_password
cursor = await conn.execute(
"INSERT INTO users (username, password_hash, is_admin, must_change_password) "
"VALUES (?, ?, ?, 0)",
(username, hash_password(password), int(is_admin)),
)
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 _make_personal_group(conn, *, name: str, user_id: int) -> int:
cursor = await conn.execute(
"INSERT INTO user_groups (name, description, is_personal) VALUES (?, NULL, 1)",
(name,),
)
group_id = cursor.lastrowid
await conn.execute(
"INSERT INTO user_group_members (user_group_id, user_id, added_by) VALUES (?, ?, NULL)",
(group_id, user_id),
)
await conn.commit()
return group_id
@pytest.mark.asyncio
async def test_personal_groups_report_clusters_identical_rights(client):
from app.db import get_db
conn = get_db()
admin_id = await _create_user(conn, "s6_admin1", "Correct-Horse-Battery-Staple-1", is_admin=True)
u1 = await _create_user(conn, "s6_u1", "Correct-Horse-Battery-Staple-2")
u2 = await _create_user(conn, "s6_u2", "Correct-Horse-Battery-Staple-3")
u3 = await _create_user(conn, "s6_u3", "Correct-Horse-Battery-Staple-4")
await conn.execute("INSERT INTO host_groups (id, name) VALUES (5001, 's6-hg')")
role_row = await (await conn.execute("SELECT id FROM roles WHERE name = 'ssh_connect'")).fetchone()
role_id = role_row[0]
await conn.commit()
pg1 = await _make_personal_group(conn, name="s6-pg-1", user_id=u1)
pg2 = await _make_personal_group(conn, name="s6-pg-2", user_id=u2)
pg3 = await _make_personal_group(conn, name="s6-pg-3", user_id=u3)
# pg1 und pg2 bekommen identische Rechte (ssh_connect auf derselben
# Hostgruppe) -- Konsolidierungs-Kandidat. pg3 bleibt ohne jede Rolle
# und muss daher in einem eigenen (ebenfalls "identischen", aber
# einelementigen) Cluster landen.
for group_id in (pg1, pg2):
await conn.execute(
"INSERT INTO group_hostgroup_roles (user_group_id, host_group_id, role_id, granted_by) "
"VALUES (?, 5001, ?, ?)",
(group_id, role_id, admin_id),
)
await conn.commit()
await _login_full(client, "s6_admin1", "Correct-Horse-Battery-Staple-1")
resp = await client.get("/admin/reports/personal-groups")
assert resp.status_code == 200, resp.text
clusters = resp.json()
def cluster_containing(group_id):
for c in clusters:
if any(m["user_group_id"] == group_id for m in c["members"]):
return c
return None
c1 = cluster_containing(pg1)
c2 = cluster_containing(pg2)
c3 = cluster_containing(pg3)
assert c1 is c2, "pg1 und pg2 muessen im selben Cluster landen (identisches Rechteprofil)"
assert c1["consolidation_candidate"] is True
assert c1["group_count"] == 2
assert {m["user_group_id"] for m in c1["members"]} == {pg1, pg2}
assert c3 is not c1
assert c3["consolidation_candidate"] is False
assert c3["group_count"] == 1
@pytest.mark.asyncio
async def test_unconfirmed_credential_grants_report(client):
from app.db import get_db
conn = get_db()
admin_id = await _create_user(conn, "s6_admin2", "Correct-Horse-Battery-Staple-5", is_admin=True)
await _login_full(client, "s6_admin2", "Correct-Horse-Battery-Staple-5")
resp = await client.post("/admin/user-groups", json={"name": "s6-cred-gruppe", "description": None})
group_id = resp.json()["id"]
resp = await client.post("/admin/ssh-keys/generate", json={"key_type": "ed25519"})
km = resp.json()
resp = await client.post(
"/admin/ssh-keys",
json={
"label": "s6-key", "key_type": "ed25519",
"private_key_pem": km["private_key_pem"], "public_key": km["public_key"],
"passphrase": None, "username": "root",
},
)
assert resp.status_code == 201, resp.text
key_id = resp.json()["id"]
# Simuliert eine Vorbefuellungs-Zeile aus Migration 0018: granted_by IS NULL.
await conn.execute(
"INSERT INTO group_ssh_key_grants (user_group_id, ssh_key_id, granted_by) VALUES (?, ?, NULL)",
(group_id, key_id),
)
await conn.commit()
resp = await client.get("/admin/reports/unconfirmed-credential-grants")
assert resp.status_code == 200, resp.text
rows = resp.json()
match = [r for r in rows if r["user_group_id"] == group_id and r["credential_id"] == key_id]
assert len(match) == 1, rows
assert match[0]["kind"] == "ssh_key"
# Jetzt manuell ueber die API bestaetigen (granted_by wird auf admin.id
# gesetzt) -- die Zeile muss aus dem Report verschwinden.
resp = await client.post(
"/admin/group-credentials/ssh_key/grant",
json={"user_group_id": group_id, "credential_id": key_id, "expires_at": None},
)
assert resp.status_code == 200, resp.text
resp = await client.get("/admin/reports/unconfirmed-credential-grants")
rows = resp.json()
match = [r for r in rows if r["user_group_id"] == group_id and r["credential_id"] == key_id]
assert len(match) == 0, rows

View File

@ -0,0 +1,180 @@
"""Tests fuer Teil F.3.6 (Stufe F2, Umsetzungsauftrag_Sonnet5.md): eigene
Sitzungs-API im Katalog-Router.
1) GET /catalog/sessions liefert AUSSCHLIESSLICH die eigenen Sitzungen des
angemeldeten Benutzers -- eine fremde, gleichzeitig laufende Sitzung
taucht nicht auf.
2) active_only=true (Standard) blendet bereits beendete eigene Sitzungen
aus; active_only=false zeigt sie.
3) POST /catalog/sessions/{id}/terminate auf eine FREMDE session_id
liefert 404 (F.5-Risikotabelle: 'Rechteumgehung ueber die neue
Sitzungs-API' -- explizit geforderter Testfall), nicht etwa 403 (das
wuerde die Existenz einer fremden Sitzung verraten) und nicht 200.
4) POST .../terminate auf die EIGENE, tatsaechlich laufende Sitzung
funktioniert: der zugehoerige asyncio.Task wird abgebrochen, die
Sitzung landet als beendet in der DB, ein Audit-Ereignis
'session_terminated_by_owner' wird geschrieben.
5) POST .../terminate auf eine eigene, bereits beendete Sitzung liefert
409 (nicht 200 -- kein stiller Erfolg auf einer Sitzung, die gar nicht
mehr laeuft).
"""
from __future__ import annotations
import asyncio
import contextlib
import pyotp
import pytest
async def _create_user(conn, username: str, password: str) -> int:
from app.security.passwords import hash_password
cursor = await conn.execute(
"INSERT INTO users (username, password_hash, is_admin, must_change_password) "
"VALUES (?, ?, 0, 0)",
(username, hash_password(password)),
)
await conn.commit()
return cursor.lastrowid
async def _login_full(client, username: str, password: str) -> None:
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
async def _make_host(conn, *, hostname: str) -> int:
cursor = await conn.execute("INSERT INTO host_groups (name) VALUES (?)", (f"hg-{hostname}",))
group_id = cursor.lastrowid
cursor = await conn.execute(
"INSERT INTO hosts (hostname, address, port, protocol, os_type, host_group_id) "
"VALUES (?, '10.0.0.1', 22, 'ssh', 'linux', ?)",
(hostname, group_id),
)
await conn.commit()
return cursor.lastrowid
async def _make_session(conn, *, user_id: int, host_id: int, ended: bool) -> int:
cursor = await conn.execute(
"INSERT INTO sessions (user_id, host_id, protocol, client_ip, ended_at, end_reason) "
"VALUES (?, ?, 'ssh', '127.0.0.1', ?, ?)",
(user_id, host_id, "2026-01-01T00:00:00.000000Z" if ended else None, "logout" if ended else None),
)
await conn.commit()
return cursor.lastrowid
@pytest.mark.asyncio
async def test_my_sessions_shows_only_own_and_respects_active_only(client):
from app.db import get_db
conn = get_db()
u1 = await _create_user(conn, "f2_u1", "Correct-Horse-Battery-Staple-1")
u2 = await _create_user(conn, "f2_u2", "Correct-Horse-Battery-Staple-2")
host_id = await _make_host(conn, hostname="f2-host-1")
my_open = await _make_session(conn, user_id=u1, host_id=host_id, ended=False)
my_closed = await _make_session(conn, user_id=u1, host_id=host_id, ended=True)
other_open = await _make_session(conn, user_id=u2, host_id=host_id, ended=False)
await _login_full(client, "f2_u1", "Correct-Horse-Battery-Staple-1")
resp = await client.get("/catalog/sessions")
assert resp.status_code == 200, resp.text
ids = {row["id"] for row in resp.json()}
assert my_open in ids
assert my_closed not in ids, "aktive-only (Standard) muss beendete eigene Sitzungen ausblenden"
assert other_open not in ids, "fremde Sitzung darf NIE auftauchen"
resp = await client.get("/catalog/sessions", params={"active_only": "false"})
assert resp.status_code == 200, resp.text
ids = {row["id"] for row in resp.json()}
assert my_open in ids
assert my_closed in ids, "active_only=false muss auch beendete eigene Sitzungen zeigen"
assert other_open not in ids, "fremde Sitzung darf auch mit active_only=false nie auftauchen"
@pytest.mark.asyncio
async def test_terminate_foreign_session_returns_404_not_403(client):
from app.db import get_db
conn = get_db()
u1 = await _create_user(conn, "f2_u3", "Correct-Horse-Battery-Staple-3")
u2 = await _create_user(conn, "f2_u4", "Correct-Horse-Battery-Staple-4")
host_id = await _make_host(conn, hostname="f2-host-2")
foreign_session = await _make_session(conn, user_id=u2, host_id=host_id, ended=False)
await _login_full(client, "f2_u3", "Correct-Horse-Battery-Staple-3")
resp = await client.post(f"/catalog/sessions/{foreign_session}/terminate")
assert resp.status_code == 404, resp.text
resp = await client.post("/catalog/sessions/999999/terminate")
assert resp.status_code == 404, resp.text
@pytest.mark.asyncio
async def test_terminate_own_running_session_cancels_task_and_audits(client):
from app.db import get_db
from app.security import active_sessions
from app.security.audit import verify_chain
conn = get_db()
u1 = await _create_user(conn, "f2_u5", "Correct-Horse-Battery-Staple-5")
host_id = await _make_host(conn, hostname="f2-host-3")
session_id = await _make_session(conn, user_id=u1, host_id=host_id, ended=False)
async def _fake_long_running():
await asyncio.sleep(3600)
task = asyncio.create_task(_fake_long_running())
active_sessions.register(session_id=session_id, task=task, user_id=u1)
try:
await _login_full(client, "f2_u5", "Correct-Horse-Battery-Staple-5")
resp = await client.post(f"/catalog/sessions/{session_id}/terminate")
assert resp.status_code == 200, resp.text
# asyncio.Task.cancel() wirkt asynchron -- auf das tatsaechliche
# Ende der Task warten, statt nur einmal nachzugeben (Python 3.10:
# kein Task.cancelling(), daher ueber den Ausgang selbst pruefen).
with contextlib.suppress(asyncio.CancelledError):
await asyncio.wait_for(task, timeout=1)
assert task.cancelled()
row = await (await conn.execute(
"SELECT event_type, details_json FROM audit_log WHERE user_id = ? ORDER BY id DESC LIMIT 1",
(u1,),
)).fetchone()
assert row is not None
assert row[0] == "session_terminated_by_owner"
assert str(session_id) in row[1]
intact, _ = await verify_chain(conn)
assert intact
finally:
if not task.done():
task.cancel()
active_sessions.unregister(session_id)
@pytest.mark.asyncio
async def test_terminate_already_ended_own_session_returns_409(client):
from app.db import get_db
conn = get_db()
u1 = await _create_user(conn, "f2_u6", "Correct-Horse-Battery-Staple-6")
host_id = await _make_host(conn, hostname="f2-host-4")
ended_session = await _make_session(conn, user_id=u1, host_id=host_id, ended=True)
await _login_full(client, "f2_u6", "Correct-Horse-Battery-Staple-6")
resp = await client.post(f"/catalog/sessions/{ended_session}/terminate")
assert resp.status_code == 409, resp.text

View File

@ -0,0 +1,76 @@
"""Tests fuer Teil F.3 (Stufe F3, Umsetzungsauftrag_Sonnet5.md): die
dauerhafte Arbeitsflaeche /workspace.
Diese Stufe ist ueberwiegend Frontend (templates/workspace.html,
static/js/workspace.js) -- die serverseitige Flaeche ist bewusst klein
(rein statisches Markup wie /dashboard, Auth clientseitig ueber
GET /auth/me, siehe app/main.py::workspace_page). Die CSP-Konformitaet
(kein Inline-style/-script) wird zentral in tests/test_csp_compliance.py
mitgeprueft (/workspace wurde dort in die Parametrisierung aufgenommen).
Hier: die Route existiert, liefert die erwarteten Bausteine aus, und die
Backend-Bausteine, auf denen workspace.js aufbaut (GET /catalog/hosts,
GET /catalog/sessions), bleiben nutzbar."""
from __future__ import annotations
import pytest
@pytest.mark.asyncio
async def test_workspace_page_renders_without_login(client):
"""Wie /dashboard/-admin: die Seite selbst ist statisches Markup ohne
Secrets -- die eigentliche Zugriffskontrolle laeuft clientseitig ueber
GET /auth/me (401 -> Redirect zu /) UND serverseitig hart auf jedem
/catalog/*-API-Aufruf, den workspace.js danach macht."""
resp = await client.get("/workspace")
assert resp.status_code == 200, resp.text
html = resp.text
assert 'id="workspace-tiles"' in html
assert 'id="workspace-catalog-view"' in html
assert 'id="workspace-sessions-area"' in html
assert 'id="workspace-new-btn"' in html
assert '/static/js/workspace.js' in html
# F1-Bausteine werden mitgeladen (kein zweiter Sitzungs-Code, F.2).
assert '/static/js/terminal.js' in html
assert '/static/js/rdp.js' in html
# F3 baut das Sitzungs-DOM erst zur Laufzeit -- kein #session-container
# mit data-host-id wie bei /terminal/{id}, /rdp/{id} (die Seite haette
# sonst automatisch eine Sitzung bootstrapped, siehe terminal.js/rdp.js
# Bootstrap-Block).
assert 'id="session-container"' not in html
@pytest.mark.asyncio
async def test_workspace_backend_building_blocks_reachable_after_login(client):
"""workspace.js ruft beim Laden GET /catalog/hosts und
GET /catalog/sessions auf -- beide muessen fuer einen angemeldeten
Benutzer erreichbar sein (volle Auth-Pruefung liegt bereits in
test_teil_f_schritt2.py und den bestehenden Katalog-Tests; hier nur der
Zusammenhang mit der neuen Seite)."""
from app.db import get_db
from app.security.passwords import hash_password
import pyotp
conn = get_db()
await conn.execute(
"INSERT INTO users (username, password_hash, is_admin, must_change_password) "
"VALUES ('f3_user', ?, 0, 0)",
(hash_password("Correct-Horse-Battery-Staple-F3"),),
)
await conn.commit()
resp = await client.post("/auth/login", json={"username": "f3_user", "password": "Correct-Horse-Battery-Staple-F3"})
pending = resp.json()["pending_token"]
resp = await client.post("/auth/totp/enroll/start", json={"pending_token": pending})
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
resp = await client.get("/catalog/hosts")
assert resp.status_code == 200, resp.text
assert isinstance(resp.json(), list)
resp = await client.get("/catalog/sessions")
assert resp.status_code == 200, resp.text
assert resp.json() == [] # frisch angemeldeter Benutzer hat keine offenen Sitzungen

View File

@ -0,0 +1,170 @@
"""Tests fuer Teil F.5 (Umsetzungsauftrag_Sonnet5.md): Obergrenzen sichtbar
in der Arbeitsflaeche (F.3.8).
GET /catalog/session-limits liefert nur Zahlen -- die eigentliche
Durchsetzung existiert bereits seit Teil E.4 (app/ssh_proxy/terminal_ws.py,
app/rdp_proxy/ws_tunnel.py, WS-Code 4429) und wird hier NICHT verdoppelt,
sondern ueber dieselbe Registry (app/security/active_sessions.py) und
denselben app/config.py-Wert gelesen. Getestet wird deshalb v.a., dass die
Zahlen exakt der bereits vorhandenen Durchsetzungslogik entsprechen
(insbesondere die Admin-Ausnahme bei der Je-Nutzer-Grenze, NICHT bei der
globalen Grenze -- siehe terminal_ws.py Zeile ~120-136) und dass keine
Angaben zu FREMDEN Sitzungen durchsickern (F.5-Risikotabelle,
Rechteumgehung ueber die Katalog-API, dasselbe Prinzip wie in
test_teil_f_schritt2.py)."""
from __future__ import annotations
import asyncio
import pytest
from app.security import active_sessions
async def _make_user(client, username: str, is_admin: int = 0):
from app.db import get_db
from app.security.passwords import hash_password
import pyotp
conn = get_db()
await conn.execute(
"INSERT INTO users (username, password_hash, is_admin, must_change_password) "
"VALUES (?, ?, ?, 0)",
(username, hash_password("Correct-Horse-Battery-Staple-F5"), is_admin),
)
await conn.commit()
resp = await client.post("/auth/login", json={"username": username, "password": "Correct-Horse-Battery-Staple-F5"})
pending = resp.json()["pending_token"]
resp = await client.post("/auth/totp/enroll/start", json={"pending_token": pending})
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
row = await (await conn.execute("SELECT id FROM users WHERE username = ?", (username,))).fetchone()
return row[0]
class _FakeTask:
"""Ersatz fuer einen echten asyncio.Task in der Registry -- fuer diese
Tests wird nie cancel()/await ausgefuehrt, nur die Zaehlung geprueft."""
def cancel(self):
pass
@pytest.mark.asyncio
async def test_session_limits_reflects_config_when_no_sessions_open(client):
"""Frisch angemeldeter Nutzer ohne offene Sitzungen: current_*_count == 0,
max_* == die konfigurierten Werte aus app/config.py, keine Grenze
erreicht."""
from app.config import settings
await _make_user(client, "f5_user_empty")
resp = await client.get("/catalog/session-limits")
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["max_per_user"] == settings.max_sessions_per_user
assert body["max_global"] == settings.max_sessions_global
assert body["current_user_count"] == 0
assert body["current_global_count"] == 0
assert body["user_limit_applies"] is True
assert body["at_user_limit"] is False
assert body["at_global_limit"] is False
@pytest.mark.asyncio
async def test_session_limits_counts_only_own_sessions_for_user_count(client):
"""current_user_count zaehlt NUR die eigenen Registry-Eintraege --
Sitzungen eines anderen Nutzers duerfen den eigenen Zaehler nicht
beeinflussen (wohl aber current_global_count, das ist bewusst global)."""
user_a_id = await _make_user(client, "f5_user_a")
other_user_id = 9999 # fremd, muss NICHT in der DB existieren fuer die Registry
active_sessions.register(9001, _FakeTask(), other_user_id)
try:
resp = await client.get("/catalog/session-limits")
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["current_user_count"] == 0 # fremde Sitzung zaehlt NICHT
assert body["current_global_count"] == 1 # global schon
finally:
active_sessions.unregister(9001)
active_sessions.register(9002, _FakeTask(), user_a_id)
try:
resp = await client.get("/catalog/session-limits")
body = resp.json()
assert body["current_user_count"] == 1
assert body["current_global_count"] == 1
finally:
active_sessions.unregister(9002)
@pytest.mark.asyncio
async def test_session_limits_at_user_limit_true_when_reached(client):
"""Erreicht ein normaler Nutzer max_sessions_per_user, meldet der
Endpunkt at_user_limit=true -- exakt die Schwelle (>=), die auch
terminal_ws.py/ws_tunnel.py fuer die Ablehnung verwendet."""
from app.config import settings
user_id = await _make_user(client, "f5_user_at_limit")
ids = list(range(9100, 9100 + settings.max_sessions_per_user))
for sid in ids:
active_sessions.register(sid, _FakeTask(), user_id)
try:
resp = await client.get("/catalog/session-limits")
body = resp.json()
assert body["current_user_count"] == settings.max_sessions_per_user
assert body["at_user_limit"] is True
assert body["at_global_limit"] is False # weit unter max_sessions_global
finally:
for sid in ids:
active_sessions.unregister(sid)
@pytest.mark.asyncio
async def test_session_limits_admin_exempt_from_user_limit_not_from_global(client):
"""Deckt exakt die Asymmetrie aus terminal_ws.py Zeile ~120-136 ab: Admins
sind von der Je-Nutzer-Grenze ausgenommen ('not user.is_admin'-Gate),
aber NICHT von der globalen Grenze. Weicht die Anzeige davon ab, wuerde
sie einem Admin faelschlich 'gesperrt' oder faelschlich 'frei' zeigen."""
from app.config import settings
admin_id = await _make_user(client, "f5_admin", is_admin=1)
# Je-Nutzer-Grenze fuer den Admin selbst "erreicht" -- darf ihn laut
# Durchsetzungslogik trotzdem nicht ausbremsen.
ids = list(range(9200, 9200 + settings.max_sessions_per_user))
for sid in ids:
active_sessions.register(sid, _FakeTask(), admin_id)
try:
resp = await client.get("/catalog/session-limits")
body = resp.json()
assert body["user_limit_applies"] is False
assert body["at_user_limit"] is False # trotz current_user_count == max_per_user
assert body["current_user_count"] == settings.max_sessions_per_user
finally:
for sid in ids:
active_sessions.unregister(sid)
# Globale Grenze gilt hingegen auch fuer Admins.
global_ids = list(range(9300, 9300 + settings.max_sessions_global))
for sid in global_ids:
active_sessions.register(sid, _FakeTask(), 424242) # fremder Platzhalter-Nutzer
try:
resp = await client.get("/catalog/session-limits")
body = resp.json()
assert body["at_global_limit"] is True
finally:
for sid in global_ids:
active_sessions.unregister(sid)
@pytest.mark.asyncio
async def test_session_limits_requires_login(client):
resp = await client.get("/catalog/session-limits")
assert resp.status_code == 401