umbau 1.0
This commit is contained in:
169
FORTSETZUNG_Aufgabe11.md
Normal file
169
FORTSETZUNG_Aufgabe11.md
Normal 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
312
FORTSETZUNG_Teil_C.md
Normal 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
702
FORTSETZUNG_Teil_D.md
Normal 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
742
FORTSETZUNG_Teil_F.md
Normal 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).
|
||||||
@ -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.
|
||||||
|
|
||||||
|
|||||||
94
README.md
94
README.md
@ -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
|
||||||
|
|||||||
942
Umsetzungsauftrag_Sonnet5.md
Normal file
942
Umsetzungsauftrag_Sonnet5.md
Normal 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 1–3 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 E1–E11 (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.
|
||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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).
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 %}
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
1146
app/admin/routes.py
1146
app/admin/routes.py
File diff suppressed because it is too large
Load Diff
102
app/auth/deps.py
102
app/auth/deps.py
@ -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)
|
|
||||||
|
|||||||
@ -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,
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,65 +1,44 @@
|
|||||||
"""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(
|
|
||||||
"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 "
|
|
||||||
"FROM hosts h JOIN host_groups g ON g.id = h.host_group_id "
|
|
||||||
"WHERE h.is_active = 1 ORDER BY g.name, h.hostname"
|
|
||||||
)
|
|
||||||
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]
|
|
||||||
for h in hosts:
|
|
||||||
h["can_connect"] = True
|
|
||||||
h["can_file_transfer"] = True
|
|
||||||
h["can_view_credentials"] = True
|
|
||||||
return hosts
|
|
||||||
|
|
||||||
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
|
"FROM hosts h JOIN host_groups g ON g.id = h.host_group_id "
|
||||||
FROM hosts h
|
"WHERE h.is_active = 1 ORDER BY g.name, h.hostname"
|
||||||
JOIN host_groups g ON g.id = h.host_group_id
|
|
||||||
WHERE h.is_active = 1
|
|
||||||
AND (
|
|
||||||
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()
|
rows = await cursor.fetchall()
|
||||||
hosts = [dict(zip(
|
hosts = [dict(zip(
|
||||||
@ -67,42 +46,145 @@ async def my_hosts(user: CurrentUser = Depends(get_current_user)):
|
|||||||
"clipboard_enabled", "file_transfer_enabled"), r
|
"clipboard_enabled", "file_transfer_enabled"), r
|
||||||
)) for r in rows]
|
)) for r in rows]
|
||||||
|
|
||||||
ft_cursor = await conn.execute(
|
if user.is_admin:
|
||||||
"""
|
for h in hosts:
|
||||||
SELECT uhr.host_group_id FROM user_hostgroup_roles uhr
|
h["can_connect"] = True
|
||||||
JOIN roles r ON r.id = uhr.role_id
|
h["can_file_transfer"] = True
|
||||||
WHERE uhr.user_id = ? AND r.name = 'file_transfer'
|
h["can_view_credentials"] = True
|
||||||
AND (uhr.expires_at IS NULL OR uhr.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
return hosts
|
||||||
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(
|
# 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 uhr.host_group_id FROM user_hostgroup_roles uhr
|
# Gruppenmengen je Rolle, kein pauschales "irgendeine der beiden Rollen".
|
||||||
JOIN roles r ON r.id = uhr.role_id
|
ssh_groups = await user_host_group_ids_with_any_role(conn, user_id=user.id, role_names=("ssh_connect",))
|
||||||
WHERE uhr.user_id = ? AND r.name IN ('credentials_view', 'credentials_manage')
|
rdp_groups = await user_host_group_ids_with_any_role(conn, user_id=user.id, role_names=("rdp_connect",))
|
||||||
AND (uhr.expires_at IS NULL OR uhr.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
ft_groups = await user_host_group_ids_with_any_role(conn, user_id=user.id, role_names=("file_transfer",))
|
||||||
UNION
|
cred_groups = await user_host_group_ids_with_any_role(
|
||||||
SELECT ghr.host_group_id FROM group_hostgroup_roles ghr
|
conn, user_id=user.id, role_names=("credentials_view", "credentials_manage")
|
||||||
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,
|
||||||
|
}
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
29
app/db.py
29
app/db.py
@ -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,)
|
||||||
)
|
)
|
||||||
|
|||||||
21
app/db/migrations/0013_fix_placeholder_rdp_username.sql
Normal file
21
app/db/migrations/0013_fix_placeholder_rdp_username.sql
Normal 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)';
|
||||||
196
app/db/migrations/0014_drop_tenants.sql
Normal file
196
app/db/migrations/0014_drop_tenants.sql
Normal 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;
|
||||||
34
app/db/migrations/0015_remove_dead_roles.sql
Normal file
34
app/db/migrations/0015_remove_dead_roles.sql
Normal 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');
|
||||||
49
app/db/migrations/0016_ssh_password_credential_objects.sql
Normal file
49
app/db/migrations/0016_ssh_password_credential_objects.sql
Normal 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;
|
||||||
64
app/db/migrations/0017_personal_groups.sql
Normal file
64
app/db/migrations/0017_personal_groups.sql
Normal 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;
|
||||||
91
app/db/migrations/0018_credential_group_grants.sql
Normal file
91
app/db/migrations/0018_credential_group_grants.sql
Normal 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';
|
||||||
26
app/db/migrations/0019_retire_direct_role_grants.sql
Normal file
26
app/db/migrations/0019_retire_direct_role_grants.sql
Normal 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;
|
||||||
22
app/db/migrations/0020_session_user_ended_index.sql
Normal file
22
app/db/migrations/0020_session_user_ended_index.sql
Normal 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);
|
||||||
77
app/main.py
77
app/main.py
@ -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,8 +154,55 @@ 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)):
|
||||||
return {"status": "ok"}
|
"""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"}
|
||||||
|
|
||||||
|
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 --------------------------------------------------------
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
171
app/rbac.py
171
app/rbac.py
@ -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]
|
||||||
|
|||||||
@ -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:
|
||||||
dot = text.index(".", i)
|
try:
|
||||||
length = int(text[i:dot])
|
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])
|
||||||
|
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,44 +323,102 @@ 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):
|
||||||
|
|
||||||
writer.write(encode_instruction("select", protocol).encode("utf-8"))
|
guacd startet nach 'connect' im Hintergrund den eigentlichen RDP-/NLA-
|
||||||
await writer.drain()
|
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.
|
||||||
|
|
||||||
args_instr = await read_instruction(reader)
|
guacd_host/guacd_port stecken bei jedem Fehler in der Exception-Nachricht
|
||||||
if args_instr[0] != "args":
|
(Befund B.2): die fruehere Fassung liess einen nackten OSError
|
||||||
raise GuacamoleProtocolError(f"Erwartete 'args', erhalten: {args_instr[0]}")
|
durchreichen, ohne wohin die Anwendung ueberhaupt verbinden wollte.
|
||||||
arg_names = args_instr[1:]
|
"""
|
||||||
|
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
|
||||||
|
|
||||||
handshake = (
|
try:
|
||||||
encode_instruction("size", str(screen_width), str(screen_height), str(dpi))
|
writer.write(encode_instruction("select", protocol).encode("utf-8"))
|
||||||
+ encode_instruction("audio")
|
await writer.drain()
|
||||||
+ encode_instruction("video")
|
|
||||||
+ encode_instruction("image", "image/png", "image/jpeg")
|
|
||||||
)
|
|
||||||
writer.write(handshake.encode("utf-8"))
|
|
||||||
await writer.drain()
|
|
||||||
|
|
||||||
values = [params.get(name, "") for name in arg_names]
|
try:
|
||||||
writer.write(encode_instruction("connect", *values).encode("utf-8"))
|
args_instr = await asyncio.wait_for(read_instruction(reader), timeout=handshake_timeout)
|
||||||
await writer.drain()
|
except asyncio.TimeoutError as exc:
|
||||||
|
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)'}"
|
||||||
|
)
|
||||||
|
|
||||||
ready_instr = await read_instruction(reader)
|
# C1: Versions-Token erkennen und aus den echten Parameternamen
|
||||||
if ready_instr[0] != "ready":
|
# herausloesen (siehe _VERSION_TOKEN_RE oben).
|
||||||
raise GuacamoleProtocolError(f"Verbindungsaufbau fehlgeschlagen: {ready_instr}")
|
raw_arg_names = args_instr[1:]
|
||||||
connection_id = ready_instr[1] if len(ready_instr) > 1 else ""
|
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
|
||||||
|
|
||||||
return GuacdTunnel(reader, writer, connection_id)
|
handshake = (
|
||||||
|
encode_instruction("size", str(screen_width), str(screen_height), str(dpi))
|
||||||
|
+ encode_instruction("audio")
|
||||||
|
+ encode_instruction("video")
|
||||||
|
+ encode_instruction("image", "image/png", "image/jpeg")
|
||||||
|
)
|
||||||
|
writer.write(handshake.encode("utf-8"))
|
||||||
|
await writer.drain()
|
||||||
|
|
||||||
|
# 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"))
|
||||||
|
await writer.drain()
|
||||||
|
except BaseException:
|
||||||
|
writer.close()
|
||||||
|
raise
|
||||||
|
|
||||||
|
return GuacdTunnel(reader, writer)
|
||||||
|
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@ -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,62 +88,141 @@ 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
|
||||||
if instr and instr[0] == INTERNAL_DATA_OPCODE:
|
# lautlos: ein gekuerzter Nachrichtenausschnitt landet als DEBUG
|
||||||
# Tunnelinterne Instruktion von guacamole-common-js (leerer
|
# im Log, damit ein wiederkehrendes Muster (z.B. ein kaputter
|
||||||
# Opcode), z.B. "0.,4.ping,13.<timestamp>;". Diese gehoert dem
|
# Client) ueberhaupt auffallen kann.
|
||||||
# Tunnel, NICHT guacd -- bisher wurde sie unbesehen an guacd
|
logger.debug(
|
||||||
# weitergereicht, das damit nichts anfangen kann. Ein ping wird
|
"[corr=%s] Ungueltige WS-Nachricht ignoriert: %r",
|
||||||
# gespiegelt: der Browser bricht den Tunnel nach
|
correlation_id, message[:200],
|
||||||
# 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":
|
|
||||||
await websocket.send_text(encode_instruction(*instr))
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if not clipboard_enabled and instr and instr[0] == "clipboard":
|
# Befund D5: eine einzelne WebSocket-Textnachricht kann MEHRERE
|
||||||
continue # Defense-in-Depth: Clipboard serverseitig blocken
|
# 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:
|
||||||
|
# Tunnelinterne Instruktion von guacamole-common-js (leerer
|
||||||
|
# Opcode), z.B. "0.,4.ping,13.<timestamp>;". Gehoert dem
|
||||||
|
# Tunnel, NICHT guacd. Ein ping wird gespiegelt: der Browser
|
||||||
|
# bricht den Tunnel nach tunnel.receiveTimeout (Standard 15s)
|
||||||
|
# ohne empfangene Daten mit "Server timeout" ab.
|
||||||
|
if len(instr) > 1 and instr[1] == "ping":
|
||||||
|
await websocket.send_text(encode_instruction(*instr))
|
||||||
|
continue
|
||||||
|
|
||||||
recorder.record("input", message)
|
if not clipboard_enabled and instr and instr[0] == "clipboard":
|
||||||
tunnel.writer.write(message.encode("utf-8"))
|
continue # Defense-in-Depth: Clipboard serverseitig blocken
|
||||||
|
|
||||||
|
forward.append(encode_instruction(*instr))
|
||||||
|
|
||||||
|
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()
|
||||||
|
|||||||
@ -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))
|
||||||
|
|||||||
63
app/recordings/recorder.py.bak_1788208152
Normal file
63
app/recordings/recorder.py.bak_1788208152
Normal 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
|
||||||
@ -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 --
|
||||||
|
|||||||
@ -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,20 +55,33 @@ 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:
|
||||||
row = await cursor.fetchone()
|
await conn.execute("BEGIN IMMEDIATE")
|
||||||
prev_hash = row[0] if row else GENESIS_HASH
|
try:
|
||||||
|
cursor = await conn.execute(
|
||||||
|
"SELECT entry_hash FROM audit_log ORDER BY id DESC LIMIT 1"
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
prev_hash = row[0] if row else GENESIS_HASH
|
||||||
|
|
||||||
ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
|
ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
|
||||||
details_json = json.dumps(details, sort_keys=True, ensure_ascii=False)
|
details_json = json.dumps(details, sort_keys=True, ensure_ascii=False)
|
||||||
entry_hash = _entry_hash(prev_hash, ts, event_type, details_json)
|
entry_hash = _entry_hash(prev_hash, ts, event_type, details_json)
|
||||||
|
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
"INSERT INTO audit_log (ts, user_id, client_ip, event_type, details_json, prev_hash, entry_hash) "
|
"INSERT INTO audit_log (ts, user_id, client_ip, event_type, details_json, prev_hash, entry_hash) "
|
||||||
"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]:
|
||||||
|
|||||||
@ -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,
|
||||||
|
)
|
||||||
|
|||||||
83
app/security/session_reaper.py
Normal file
83
app/security/session_reaper.py
Normal 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)
|
||||||
@ -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:
|
||||||
|
|||||||
@ -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,66 +96,103 @@ 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)
|
||||||
raise HTTPException(status.HTTP_413_CONTENT_TOO_LARGE, "Datei zu gross")
|
# 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")
|
||||||
|
hasher.update(chunk)
|
||||||
|
chunks.append(chunk)
|
||||||
|
data = b"".join(chunks)
|
||||||
|
del chunks
|
||||||
|
sha256 = hasher.hexdigest()
|
||||||
|
|
||||||
|
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"):
|
||||||
|
conn = get_db()
|
||||||
|
await write_audit_event(
|
||||||
|
conn, event_type="file_transfer_blocked_malware", user_id=user.id,
|
||||||
|
client_ip=_client_ip(request),
|
||||||
|
details={"host_id": host_id, "filename": file.filename, "av_scan_result": av_result},
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"Datei durch AV-Scan blockiert: {av_result}")
|
||||||
|
|
||||||
av_result = scan_bytes(data)
|
|
||||||
if av_result.startswith("infected"):
|
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
await write_audit_event(
|
|
||||||
conn, event_type="file_transfer_blocked_malware", user_id=user.id,
|
|
||||||
client_ip=_client_ip(request),
|
|
||||||
details={"host_id": host_id, "filename": file.filename, "av_scan_result": av_result},
|
|
||||||
)
|
|
||||||
await conn.commit()
|
|
||||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"Datei durch AV-Scan blockiert: {av_result}")
|
|
||||||
|
|
||||||
sha256 = hashlib.sha256(data).hexdigest()
|
|
||||||
conn = get_db()
|
|
||||||
try:
|
|
||||||
ssh_conn = await connect_to_host(conn, host_id)
|
|
||||||
try:
|
try:
|
||||||
async with ssh_conn.start_sftp_client() as sftp:
|
ssh_conn = await connect_to_host(conn, host_id, user_id=user.id)
|
||||||
async with sftp.open(remote_path, "wb") as remote_file:
|
try:
|
||||||
await remote_file.write(data)
|
async with ssh_conn.start_sftp_client() as sftp:
|
||||||
finally:
|
async with sftp.open(remote_path, "wb") as remote_file:
|
||||||
ssh_conn.close()
|
# In Bloecken schreiben statt eines einzelnen
|
||||||
except SSH_SETUP_ERRORS as exc:
|
# write(<gesamte Datei>): der AV-Scan (scan_bytes)
|
||||||
# Alles, was den Verbindungsaufbau verhindert und in der Konfiguration
|
# braucht den vollstaendigen Inhalt vor dem Schreiben
|
||||||
# begruendet ist (fehlender Benutzername/Schluessel, unbrauchbarer
|
# in einem Stueck, `data` haelt die Datei deshalb
|
||||||
# Schluessel, nicht gepinnter oder abweichender Host-Key, Ziel nicht
|
# weiterhin komplett im Speicher (unveraendert
|
||||||
# erreichbar): 400 mit Klartext statt eines unbehandelten 500.
|
# gegenueber vorher) -- echtes speicherbegrenztes
|
||||||
logger.warning("Dateitransfer fuer Host %s nicht moeglich: %s", host_id, exc)
|
# Streaming ist beim Upload durch das "erst scannen,
|
||||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, describe_connection_error(exc))
|
# dann schreiben"-Erfordernis inhaerent nicht moeglich,
|
||||||
except asyncssh.Error as exc:
|
# ohne den AV-Scan selbst auf Datei-/Stream-Basis
|
||||||
# Bugfix: SSH_SETUP_ERRORS deckt nur Fehler VOR der Anmeldung ab
|
# umzustellen. Der chunk-weise write() vermeidet
|
||||||
# (Pinning/Konfiguration). asyncssh.Error ist asyncssh's gemeinsame
|
# zumindest einen einzelnen sehr grossen asyncssh-
|
||||||
# Basisklasse -- das schliesst sowohl einen echten Verbindungsfehler
|
# Aufruf und haelt jeden einzelnen await kurz.
|
||||||
# WAEHREND asyncssh.connect() ein (z.B. Verbindung abgelehnt/abgebrochen,
|
for offset in range(0, len(data), TRANSFER_CHUNK_BYTES):
|
||||||
# von connect_to_host() bewusst unuebersetzt weitergereicht) als auch
|
await remote_file.write(data[offset:offset + TRANSFER_CHUNK_BYTES])
|
||||||
# SFTP-Fehler NACH erfolgreicher Anmeldung (z.B. SFTPNoSuchFile, wenn
|
finally:
|
||||||
# das Zielverzeichnis nicht existiert, oder SFTPPermissionDenied). Beides
|
ssh_conn.close()
|
||||||
# war hier bisher NICHT gefangen und lief unbehandelt bis Starlette
|
except SSH_SETUP_ERRORS as exc:
|
||||||
# durch, das bei einer unbehandelten Exception (debug=False) eine
|
# Alles, was den Verbindungsaufbau verhindert und in der Konfiguration
|
||||||
# KLARTEXT-500-Antwort "Internal Server Error" liefert statt JSON --
|
# begruendet ist (fehlender Benutzername/Schluessel, unbrauchbarer
|
||||||
# daher der Frontend-Fehler "Unexpected token 'I', 'Internal S'... is
|
# Schluessel, nicht gepinnter oder abweichender Host-Key, Ziel nicht
|
||||||
# not valid JSON" beim Upload.
|
# erreichbar): 400 mit Klartext statt eines unbehandelten 500.
|
||||||
logger.warning("SFTP-Fehler bei Host %s: %s", host_id, exc)
|
logger.warning("Dateitransfer fuer Host %s nicht moeglich: %s", host_id, exc)
|
||||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"SFTP-Fehler: {exc}")
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, describe_connection_error(exc))
|
||||||
except HTTPException:
|
except asyncssh.Error as exc:
|
||||||
raise
|
# Bugfix: SSH_SETUP_ERRORS deckt nur Fehler VOR der Anmeldung ab
|
||||||
except Exception:
|
# (Pinning/Konfiguration). asyncssh.Error ist asyncssh's gemeinsame
|
||||||
# Letztes Auffangnetz (gleiches Muster wie terminal_ws.py): niemals
|
# Basisklasse -- das schliesst sowohl einen echten Verbindungsfehler
|
||||||
# eine unbehandelte Ausnahme bis zu Starlettes Klartext-500 durchreichen
|
# WAEHREND asyncssh.connect() ein (z.B. Verbindung abgelehnt/abgebrochen,
|
||||||
# -- das Frontend erwartet hier immer eine JSON-Antwort.
|
# von connect_to_host() bewusst unuebersetzt weitergereicht) als auch
|
||||||
logger.exception("Unerwarteter Fehler beim Datei-Upload fuer Host %s", host_id)
|
# SFTP-Fehler NACH erfolgreicher Anmeldung (z.B. SFTPNoSuchFile, wenn
|
||||||
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Unerwarteter Fehler beim Dateitransfer")
|
# das Zielverzeichnis nicht existiert, oder SFTPPermissionDenied). Beides
|
||||||
|
# war hier bisher NICHT gefangen und lief unbehandelt bis Starlette
|
||||||
|
# durch, das bei einer unbehandelten Exception (debug=False) eine
|
||||||
|
# KLARTEXT-500-Antwort "Internal Server Error" liefert statt JSON --
|
||||||
|
# daher der Frontend-Fehler "Unexpected token 'I', 'Internal S'... is
|
||||||
|
# not valid JSON" beim Upload.
|
||||||
|
logger.warning("SFTP-Fehler bei Host %s: %s", host_id, exc)
|
||||||
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"SFTP-Fehler: {exc}")
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
# Letztes Auffangnetz (gleiches Muster wie terminal_ws.py): niemals
|
||||||
|
# eine unbehandelte Ausnahme bis zu Starlettes Klartext-500 durchreichen
|
||||||
|
# -- das Frontend erwartet hier immer eine JSON-Antwort.
|
||||||
|
logger.exception("Unerwarteter Fehler beim Datei-Upload fuer Host %s", host_id)
|
||||||
|
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Unerwarteter Fehler beim Dateitransfer")
|
||||||
|
|
||||||
await _log_transfer(
|
await _log_transfer(
|
||||||
conn, user=user, host_id=host_id, client_ip=_client_ip(request), direction="upload",
|
conn, user=user, host_id=host_id, client_ip=_client_ip(request), direction="upload",
|
||||||
filename=file.filename or remote_path, size=len(data), sha256=sha256, av_result=av_result,
|
filename=file.filename or remote_path, size=len(data), sha256=sha256, av_result=av_result,
|
||||||
)
|
)
|
||||||
return {"status": "ok", "sha256": sha256, "size": len(data), "av_scan_result": av_result}
|
return {"status": "ok", "sha256": sha256, "size": len(data), "av_scan_result": av_result}
|
||||||
|
|
||||||
|
|
||||||
@ -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:
|
try:
|
||||||
async with ssh_conn.start_sftp_client() as sftp:
|
sftp = await ssh_conn.start_sftp_client()
|
||||||
|
try:
|
||||||
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]
|
||||||
await _log_transfer(
|
|
||||||
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",
|
|
||||||
)
|
|
||||||
|
|
||||||
def _iter():
|
async def _stream():
|
||||||
yield data
|
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(
|
||||||
|
conn, user=user, host_id=host_id, client_ip=_client_ip(request), direction="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
|
||||||
|
)
|
||||||
|
|
||||||
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}"'},
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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 = ? "
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
147
scripts/diff_effective_rights.py
Normal file
147
scripts/diff_effective_rights.py
Normal 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()))
|
||||||
147
scripts/pre_schritt4_checks.py
Normal file
147
scripts/pre_schritt4_checks.py
Normal 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()))
|
||||||
99
scripts/promote_tenant_admins.py
Normal file
99
scripts/promote_tenant_admins.py
Normal 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))
|
||||||
@ -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; }
|
||||||
|
|||||||
@ -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");
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
456
static/js/rdp.js
456
static/js/rdp.js
@ -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,111 +35,337 @@
|
|||||||
(() => {
|
(() => {
|
||||||
"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");
|
|
||||||
|
|
||||||
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
class RdpSession {
|
||||||
const width = Math.round(window.innerWidth);
|
/**
|
||||||
const height = Math.round(window.innerHeight - 40);
|
* @param {number|string} hostId
|
||||||
const dpi = Math.round(window.devicePixelRatio * 96) || 96;
|
* @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;
|
||||||
|
|
||||||
// WICHTIG: die Verbindungsparameter gehoeren NICHT in die Tunnel-URL.
|
this.element = this._buildElement();
|
||||||
// Guacamole.WebSocketTunnel.connect(data) baut die Socket-URL selbst als
|
this._cacheRefs();
|
||||||
// `tunnelURL + "?" + data` zusammen. Standen die Parameter schon in der
|
this._wireToolbar();
|
||||||
// URL, entstand daraus `...?width=1280&height=800&dpi=96?undefined` -- der
|
|
||||||
// letzte Query-Parameter war damit kein gueltiger Integer mehr, FastAPI
|
|
||||||
// wies den WebSocket noch vor dem Routenhandler ab (HTTP 422) und im
|
|
||||||
// Verbindungslog des Servers tauchte kein einziger Eintrag auf.
|
|
||||||
const tunnelUrl = `${proto}//${window.location.host}/ws/rdp/${hostId}`;
|
|
||||||
const connectParams = `width=${width}&height=${height}&dpi=${dpi}`;
|
|
||||||
const tunnel = new Guacamole.WebSocketTunnel(tunnelUrl);
|
|
||||||
const client = new Guacamole.Client(tunnel);
|
|
||||||
|
|
||||||
displayDiv.appendChild(client.getDisplay().getElement());
|
|
||||||
|
|
||||||
client.onstatechange = (state) => {
|
|
||||||
// 0=idle,1=connecting,2=waiting,3=connected,4=disconnecting,5=disconnected
|
|
||||||
const labels = ["Idle", "Verbinde ...", "Warte auf Server ...", "Verbunden", "Trenne ...", "Getrennt"];
|
|
||||||
statusEl.textContent = labels[state] || `Status ${state}`;
|
|
||||||
};
|
|
||||||
const showError = (err) => {
|
|
||||||
// Der Server gibt den Abbruchgrund als WebSocket-Close-Reason mit
|
|
||||||
// (siehe _reject() in app/rdp_proxy/ws_tunnel.py); guacamole-common-js
|
|
||||||
// reicht ihn als Guacamole.Status.message hierher durch.
|
|
||||||
statusEl.textContent = "Fehler: " + ((err && err.message) || "unbekannt");
|
|
||||||
statusEl.classList.add("error");
|
|
||||||
// Troubleshooting: Statuscode + Rohobjekt zusaetzlich in die
|
|
||||||
// Browser-Konsole, fuer den Fall, dass die Klartextmeldung allein nicht
|
|
||||||
// reicht (z.B. guacd/FreeRDP-interne Codes).
|
|
||||||
console.error("RDP-Sitzung beendet/Fehler:", err);
|
|
||||||
};
|
|
||||||
client.onerror = showError;
|
|
||||||
tunnel.onerror = showError;
|
|
||||||
|
|
||||||
client.onclipboard = (stream, mimetype) => {
|
|
||||||
if (!mimetype.startsWith("text/")) return;
|
|
||||||
const reader = new Guacamole.StringReader(stream);
|
|
||||||
let data = "";
|
|
||||||
reader.ontext = (text) => { data += text; };
|
|
||||||
reader.onend = () => {
|
|
||||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
||||||
navigator.clipboard.writeText(data).catch(() => {});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
client.connect(connectParams);
|
|
||||||
|
|
||||||
window.addEventListener("beforeunload", () => client.disconnect());
|
|
||||||
|
|
||||||
const mouse = new Guacamole.Mouse(client.getDisplay().getElement());
|
|
||||||
mouse.onmousedown = mouse.onmouseup = mouse.onmousemove = (mouseState) => {
|
|
||||||
client.sendMouseState(mouseState);
|
|
||||||
};
|
|
||||||
|
|
||||||
const keyboard = new Guacamole.Keyboard(document);
|
|
||||||
keyboard.onkeydown = (keysym) => client.sendKeyEvent(1, keysym);
|
|
||||||
keyboard.onkeyup = (keysym) => client.sendKeyEvent(0, keysym);
|
|
||||||
|
|
||||||
document.addEventListener("paste", (ev) => {
|
|
||||||
const text = (ev.clipboardData || window.clipboardData).getData("text");
|
|
||||||
if (!text) return;
|
|
||||||
const stream = client.createClipboardStream("text/plain");
|
|
||||||
const writer = new Guacamole.StringWriter(stream);
|
|
||||||
writer.sendText(text);
|
|
||||||
writer.sendEnd();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById("fullscreen-btn").addEventListener("click", () => {
|
|
||||||
if (!document.fullscreenElement) {
|
|
||||||
shell.requestFullscreen().catch(() => {});
|
|
||||||
} else {
|
|
||||||
document.exitFullscreen();
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
// Strg+Alt+Entf: der Browser faengt diese Kombination selbst ab (Windows
|
// --- Aufbau -------------------------------------------------------
|
||||||
// reserviert sie systemweit), sie kommt also nie als normales Tastatur-
|
|
||||||
// Event beim RDP-Ziel an. Abhilfe wie im offiziellen Guacamole-Client:
|
|
||||||
// die drei Tasten einzeln als synthetische Key-Events senden (X11-
|
|
||||||
// Keysyms: Ctrl=0xFFE3, Alt=0xFFE9, Delete=0xFFFF), erst alle drei
|
|
||||||
// herunter- dann in umgekehrter Reihenfolge wieder hochdruecken.
|
|
||||||
document.getElementById("ctrlaltdel-btn").addEventListener("click", () => {
|
|
||||||
const keys = [0xffe3, 0xffe9, 0xffff];
|
|
||||||
for (const keysym of keys) client.sendKeyEvent(1, keysym);
|
|
||||||
for (const keysym of keys.slice().reverse()) client.sendKeyEvent(0, keysym);
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById("exit-btn").addEventListener("click", () => {
|
_buildElement() {
|
||||||
statusEl.textContent = "Sitzung wird beendet ...";
|
const wrapper = document.createElement("div");
|
||||||
statusEl.classList.remove("error");
|
wrapper.className = "session-instance";
|
||||||
try { client.disconnect(); } catch (_) { /* bereits getrennt */ }
|
wrapper.innerHTML = `
|
||||||
window.close();
|
<div class="session-shell">
|
||||||
// Siehe terminal.js fuer die ausfuehrliche Begruendung: window.close()
|
<div class="session-toolbar">
|
||||||
// schliesst den Tab nur unter bestimmten Voraussetzungen lautlos --
|
<button type="button" class="fullscreen-btn">Vollbild</button>
|
||||||
// deshalb zusaetzlich immer ein Fallback zum Dashboard.
|
<button type="button" class="ctrlaltdel-btn">Strg+Alt+Entf</button>
|
||||||
window.setTimeout(() => { window.location.href = "/dashboard"; }, 300);
|
<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 width = Math.round(window.innerWidth);
|
||||||
|
const height = Math.round(window.innerHeight - 40);
|
||||||
|
const dpi = Math.round(window.devicePixelRatio * 96) || 96;
|
||||||
|
|
||||||
|
// WICHTIG: die Verbindungsparameter gehoeren NICHT in die Tunnel-URL.
|
||||||
|
// Guacamole.WebSocketTunnel.connect(data) baut die Socket-URL selbst als
|
||||||
|
// `tunnelURL + "?" + data` zusammen. Standen die Parameter schon in der
|
||||||
|
// URL, entstand daraus `...?width=1280&height=800&dpi=96?undefined` --
|
||||||
|
// der letzte Query-Parameter war damit kein gueltiger Integer mehr,
|
||||||
|
// FastAPI wies den WebSocket noch vor dem Routenhandler ab (HTTP 422).
|
||||||
|
const tunnelUrl = `${proto}//${window.location.host}/ws/rdp/${this.hostId}`;
|
||||||
|
const connectParams = `width=${width}&height=${height}&dpi=${dpi}`;
|
||||||
|
this.tunnel = new Guacamole.WebSocketTunnel(tunnelUrl);
|
||||||
|
this.client = new Guacamole.Client(this.tunnel);
|
||||||
|
|
||||||
|
this.displayDiv.appendChild(this.client.getDisplay().getElement());
|
||||||
|
|
||||||
|
this.client.onstatechange = (state) => {
|
||||||
|
// 0=idle,1=connecting,2=waiting,3=connected,4=disconnecting,5=disconnected
|
||||||
|
const labels = ["Idle", "Verbinde ...", "Warte auf Server ...", "Verbunden", "Trenne ...", "Getrennt"];
|
||||||
|
this._setStatus(labels[state] || `Status ${state}`, false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const showError = (err) => {
|
||||||
|
// Ein vom Benutzer ueber den Exit-Knopf ausgeloestes client.disconnect()
|
||||||
|
// schliesst den Tunnel sauber (Guacamole.Status.Code.SUCCESS) und loest
|
||||||
|
// damit laut guacamole-common-js KEIN onerror aus -- dieser Schutz greift
|
||||||
|
// trotzdem zusaetzlich, falls sich das je aendert.
|
||||||
|
if (this._intentionalDisconnect) return;
|
||||||
|
|
||||||
|
const message = (err && err.message) || "Unbekannter Fehler (keine Meldung vom Server empfangen)";
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
this.client.onerror = showError;
|
||||||
|
this.tunnel.onerror = showError;
|
||||||
|
|
||||||
|
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;
|
||||||
|
const reader = new Guacamole.StringReader(stream);
|
||||||
|
let data = "";
|
||||||
|
reader.ontext = (text) => { data += text; };
|
||||||
|
reader.onend = () => {
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
|
navigator.clipboard.writeText(data).catch(() => {});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
this.client.connect(connectParams);
|
||||||
|
|
||||||
|
this._onBeforeUnload = () => { try { this.client.disconnect(); } catch (_) { /* ignorieren */ } };
|
||||||
|
window.addEventListener("beforeunload", this._onBeforeUnload);
|
||||||
|
|
||||||
|
// D4 (Umsetzungsauftrag Teil A): der Verbindungsaufbau setzt bereits
|
||||||
|
// "resize-method: display-update" -- das RDP-Ziel unterstuetzt damit
|
||||||
|
// 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);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Siehe Moduldoc oben: dokumentweit angeheftet (Bibliotheksgrenze),
|
||||||
|
// Weiterleitung aber ueber this._active gefiltert.
|
||||||
|
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); };
|
||||||
|
|
||||||
|
this._onPaste = (ev) => {
|
||||||
|
if (!this._active) return;
|
||||||
|
const text = (ev.clipboardData || window.clipboardData).getData("text");
|
||||||
|
if (!text) return;
|
||||||
|
const stream = this.client.createClipboardStream("text/plain");
|
||||||
|
const writer = new Guacamole.StringWriter(stream);
|
||||||
|
writer.sendText(text);
|
||||||
|
writer.sendEnd();
|
||||||
|
};
|
||||||
|
document.addEventListener("paste", this._onPaste);
|
||||||
|
}
|
||||||
|
|
||||||
|
_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) {
|
||||||
|
this.shellEl.requestFullscreen().catch(() => {});
|
||||||
|
} else {
|
||||||
|
document.exitFullscreen();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Strg+Alt+Entf: der Browser faengt diese Kombination selbst ab (Windows
|
||||||
|
// reserviert sie systemweit), sie kommt also nie als normales Tastatur-
|
||||||
|
// Event beim RDP-Ziel an. Abhilfe wie im offiziellen Guacamole-Client:
|
||||||
|
// die drei Tasten einzeln als synthetische Key-Events senden (X11-
|
||||||
|
// Keysyms: Ctrl=0xFFE3, Alt=0xFFE9, Delete=0xFFFF), erst alle drei
|
||||||
|
// herunter- dann in umgekehrter Reihenfolge wieder hochdruecken.
|
||||||
|
this.ctrlAltDelBtn.addEventListener("click", () => {
|
||||||
|
if (!this.client) return;
|
||||||
|
const keys = [0xffe3, 0xffe9, 0xffff];
|
||||||
|
for (const keysym of keys) this.client.sendKeyEvent(1, keysym);
|
||||||
|
for (const keysym of keys.slice().reverse()) this.client.sendKeyEvent(0, keysym);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.exitBtn.addEventListener("click", () => {
|
||||||
|
this._intentionalDisconnect = true;
|
||||||
|
this._setStatus("Sitzung wird beendet ...", false);
|
||||||
|
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();
|
||||||
|
// Siehe terminal.js fuer die ausfuehrliche Begruendung: window.close()
|
||||||
|
// schliesst den Tab nur unter bestimmten Voraussetzungen lautlos --
|
||||||
|
// deshalb zusaetzlich immer ein Fallback zum Dashboard.
|
||||||
|
window.setTimeout(() => { window.location.href = "/dashboard"; }, 300);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
container.appendChild(session.element);
|
||||||
|
session.connect();
|
||||||
|
session.setActive(true);
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
37
static/js/session-keepalive.js
Normal file
37
static/js/session-keepalive.js
Normal 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);
|
||||||
|
})();
|
||||||
File diff suppressed because it is too large
Load Diff
532
static/js/workspace.js
Normal file
532
static/js/workspace.js
Normal 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));
|
||||||
|
})();
|
||||||
@ -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">
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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
63
templates/workspace.html
Normal 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
397
tests/test_admin_crud.py
Normal 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
|
||||||
@ -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()
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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()
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@ -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) "
|
||||||
|
|||||||
@ -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()
|
||||||
|
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
108
tests/test_session_reaper.py
Normal file
108
tests/test_session_reaper.py
Normal 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()
|
||||||
108
tests/test_session_recorder.py
Normal file
108
tests/test_session_recorder.py
Normal 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
|
||||||
341
tests/test_teil_d_schritt5.py
Normal file
341
tests/test_teil_d_schritt5.py
Normal 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
|
||||||
166
tests/test_teil_d_schritt6.py
Normal file
166
tests/test_teil_d_schritt6.py
Normal 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
|
||||||
180
tests/test_teil_f_schritt2.py
Normal file
180
tests/test_teil_f_schritt2.py
Normal 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
|
||||||
76
tests/test_teil_f_schritt3.py
Normal file
76
tests/test_teil_f_schritt3.py
Normal 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
|
||||||
170
tests/test_teil_f_schritt5.py
Normal file
170
tests/test_teil_f_schritt5.py
Normal 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
|
||||||
Reference in New Issue
Block a user