add admin stuff
This commit is contained in:
187
README.md
187
README.md
@ -1,23 +1,23 @@
|
|||||||
# Jumphost Gateway — Implementierung
|
# Jumphost Gateway — Implementierung
|
||||||
|
|
||||||
Umsetzung des Konzepts `Jumphost_Konzept.md` (v0.2): browserbasiertes
|
Umsetzung des Konzepts `Jumphost_Konzept.md` (v0.2): browserbasiertes
|
||||||
SSH/RDP-Gateway mit TOTP-Pflicht, Hostgruppen-RBAC, manipulationssicherem
|
SSH/RDP-Gateway mit TOTP-Pflicht, Gruppen-/Hostgruppen-RBAC, manipulationssicherem
|
||||||
Audit-Log, SQLite/Python-Backend und Ansible-Deployment (mit/ohne nginx,
|
Audit-Log, SQLite/Python-Backend, browserbasierter Admin-Oberflaeche und
|
||||||
mehrere TLS-Modi).
|
Ansible-Deployment (mit/ohne nginx, mehrere TLS-Modi).
|
||||||
|
|
||||||
## Verzeichnisstruktur
|
## Verzeichnisstruktur
|
||||||
|
|
||||||
```
|
```
|
||||||
app/ Backend (FastAPI, Python 3.11+)
|
app/ Backend (FastAPI, Python 3.11+)
|
||||||
security/ Crypto, Passwoerter, TOTP, Sessions, Audit-Hash-Chain, AV-Scan
|
security/ Crypto, Passwoerter, TOTP, Sessions, Audit-Hash-Chain, AV-Scan, API-Tokens
|
||||||
auth/ Login-Flow, RBAC-Dependencies
|
auth/ Login-Flow, RBAC-/Token-Dependencies
|
||||||
admin/ Admin-API (User/Hosts/Hostgruppen/Rollen/SSH-Keys)
|
admin/ Admin-API (User/Gruppen/Hosts/Hostgruppen/Rollen/SSH-Keys/API-Tokens)
|
||||||
catalog/ Sicht fuer normale Nutzer (nur zugewiesene Hosts)
|
catalog/ Sicht fuer normale Nutzer (direkt und ueber Gruppen zugewiesene Hosts)
|
||||||
ssh_proxy/ SSH-Terminal-WebSocket + SFTP-Filetransfer
|
ssh_proxy/ SSH-Terminal-WebSocket + SFTP-Filetransfer
|
||||||
rdp_proxy/ Guacamole-Protokoll-Tunnel zu guacd
|
rdp_proxy/ Guacamole-Protokoll-Tunnel zu guacd
|
||||||
recordings/ Hash-verkettete Session-Aufzeichnung
|
recordings/ Hash-verkettete Session-Aufzeichnung
|
||||||
db/ SQLite-Migrationen
|
db/ SQLite-Migrationen (0001-0005, laufen automatisch beim Start)
|
||||||
static/, templates/ Frontend (Vanilla JS, xterm.js, guacamole-common-js)
|
static/, templates/ Frontend (Vanilla JS, xterm.js, guacamole-common-js, Admin-Oberflaeche, API-Doku)
|
||||||
scripts/ Betriebs-/Hilfsskripte (Admin anlegen, Assets bauen)
|
scripts/ Betriebs-/Hilfsskripte (Admin anlegen, Assets bauen)
|
||||||
ansible/ Deployment (Rollen, systemd-Unit-Templates)
|
ansible/ Deployment (Rollen, systemd-Unit-Templates)
|
||||||
tests/ pytest-Suite
|
tests/ pytest-Suite
|
||||||
@ -40,12 +40,70 @@ python scripts/create_admin.py --username admin # in zweitem Terminal
|
|||||||
```
|
```
|
||||||
|
|
||||||
Danach `http://127.0.0.1:8000/` oeffnen, anmelden, TOTP einrichten (QR-Code
|
Danach `http://127.0.0.1:8000/` oeffnen, anmelden, TOTP einrichten (QR-Code
|
||||||
scannen). Fuer echte SSH-/RDP-Sessions muessen zuvor ueber die Admin-API
|
scannen). Auf dem Dashboard erscheint fuer Admin-Konten oben rechts der Link
|
||||||
(`/admin/host-groups`, `/admin/hosts`, `/admin/ssh-keys`,
|
**"Admin-Bereich"** (`/admin`) — darueber lassen sich Benutzer, Benutzergruppen,
|
||||||
`/admin/hosts/{id}/ssh-keys/{id}`, `/admin/hosts/{id}/rdp-credentials`,
|
Hostgruppen/Hosts/SSH-Keys/RDP-Zugangsdaten, Rollenzuweisungen (direkt und
|
||||||
`/admin/roles/grant`) Hostgruppen, Hosts, Schluessel/Zugangsdaten und
|
gruppenweise) sowie API-Tokens vollstaendig ueber die Oberflaeche anlegen und
|
||||||
Rollenzuweisungen angelegt werden. Fuer RDP muss zusaetzlich ein laufender
|
verwalten; ein manueller Umweg ueber die rohe JSON-API ist dafuer nicht mehr
|
||||||
`guacd` erreichbar sein (siehe `JUMPHOST_GUACD_HOST`/`_PORT`).
|
noetig. Fuer RDP muss zusaetzlich ein laufender `guacd` erreichbar sein (siehe
|
||||||
|
`JUMPHOST_GUACD_HOST`/`_PORT`).
|
||||||
|
|
||||||
|
Die SQLite-Migrationen 0004 (`user_groups`) und 0005 (`api_tokens`) werden
|
||||||
|
beim naechsten Start automatisch angewendet (`app/db.py`, `_apply_migrations`)
|
||||||
|
— kein manueller Migrationsschritt noetig, auch nicht bei einem bestehenden
|
||||||
|
Datenbestand.
|
||||||
|
|
||||||
|
## Admin-Oberflaeche, Gruppen-RBAC, API-Tokens und API-Dokumentation
|
||||||
|
|
||||||
|
In dieser Session wurde die bis dahin nur per JSON-API bedienbare Verwaltung
|
||||||
|
um eine vollstaendige, CSP-konforme Web-Oberflaeche unter `/admin` erweitert
|
||||||
|
(`templates/admin.html`, `static/js/admin.js`), erreichbar nur fuer Admin-
|
||||||
|
Konten. Tabs: Benutzer, Benutzergruppen, Hosts & Verbindungen (inkl.
|
||||||
|
Connection-Anlage: Hostgruppe, Host, SSH-Key bzw. RDP-Zugangsdaten in einem
|
||||||
|
Formular), Rollen, API-Tokens, Audit-Log.
|
||||||
|
|
||||||
|
**Gruppen-RBAC (Migration `0004_user_groups.sql`)**: Neu sind
|
||||||
|
*Benutzergruppen* (`user_groups`/`user_group_members`) als Teams von
|
||||||
|
Personen, unabhaengig von den bestehenden *Hostgruppen* (Server-Gruppen).
|
||||||
|
Eine Rolle kann einer Benutzergruppe auf eine Hostgruppe gewaehrt werden
|
||||||
|
(`group_hostgroup_roles`) — das ist das "Sharen einer Verbindung mit einer
|
||||||
|
Gruppe". Es gilt **volle Rollen-Vererbung**: jedes Mitglied einer Gruppe hat
|
||||||
|
automatisch alle Rollen, die dieser Gruppe gewaehrt wurden, zusaetzlich zu
|
||||||
|
seinen individuell zugewiesenen (`user_hostgroup_roles`, unveraendert
|
||||||
|
bestehen geblieben). `app/rbac.py::user_has_role()` prueft dafuer beide
|
||||||
|
Zuweisungswege (UNION-Query); `app/catalog/routes.py` (Host-Liste fuer
|
||||||
|
normale Nutzer) beruecksichtigt beide ebenso.
|
||||||
|
|
||||||
|
**API-Tokens (Migration `0005_api_tokens.sql`, `app/security/api_tokens.py`)**:
|
||||||
|
Jeder Admin kann unter dem Tab "API-Tokens" persoenliche Tokens erzeugen
|
||||||
|
(`Authorization: Bearer <token>`), mit granularen **Read/Write-Scopes pro
|
||||||
|
Ressource** (z. B. `hosts:ro`, `hosts:rw`, `users:ro`, `groups:rw`, ...
|
||||||
|
vollstaendige Liste unter `GET /admin/scopes`). Tokens werden nur gehasht
|
||||||
|
gespeichert (Klartext ist nur einmal direkt nach dem Anlegen sichtbar) und
|
||||||
|
koennen jederzeit widerrufen werden. `app/auth/deps.py::require_admin_or_scope()`
|
||||||
|
akzeptiert entweder eine gueltige Admin-Session (Cookie, wie bisher) oder
|
||||||
|
einen Token mit passendem Scope — **niemals beides vermischt**: sobald ein
|
||||||
|
`Authorization`-Header vorhanden ist, wird ausschliesslich er geprueft, ein
|
||||||
|
evtl. noch gueltiges Session-Cookie wird dabei ignoriert. Aus Gruenden der
|
||||||
|
Rechte-Eskalation sind die Token-Verwaltungs-Endpunkte selbst
|
||||||
|
(`/admin/tokens/*`) bewusst **nicht** per Token nutzbar, sondern ausschliesslich
|
||||||
|
per Admin-Session (`require_global_admin`) — ein geleaktes Token kann also nie
|
||||||
|
weitere Tokens anlegen, auflisten oder widerrufen. Tokens sind ausschliesslich
|
||||||
|
fuer die Management-API vorgesehen; SSH-/RDP-/SFTP-Sitzungen bleiben
|
||||||
|
Session-Cookie-authentifiziert.
|
||||||
|
|
||||||
|
**API-Dokumentation (`/docs`, `/openapi.json`)**: Nur fuer eingeloggte Admins
|
||||||
|
sichtbar (`Depends(require_global_admin)` auf beiden Routen in `app/main.py`)
|
||||||
|
— das bestehende Haertungskonzept, `/docs`/`/redoc`/`/openapi` nicht oeffentlich
|
||||||
|
per FastAPI-Default auszuliefern (`docs_url=None, redoc_url=None,
|
||||||
|
openapi_url=None`), bleibt unangetastet. Die Ansicht selbst
|
||||||
|
(`templates/api_docs.html`, `static/js/api-docs.js`) ist bewusst **kein**
|
||||||
|
vendored/CDN-bezogenes Swagger-UI-Bundle, sondern ein selbstgebauter,
|
||||||
|
CSP-konformer Viewer ohne jede Laufzeit-Abhaengigkeit zu Drittanbietern: er
|
||||||
|
liest `/openapi.json` per `fetch()` (`credentials: "same-origin"`) und
|
||||||
|
rendert Endpunkte gruppiert nach Tag, ausschliesslich per
|
||||||
|
`document.createElement`/`textContent` (kein `innerHTML`, keine Inline-
|
||||||
|
Styles/-Scripts, siehe `tests/test_csp_compliance.py`).
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
@ -53,16 +111,34 @@ Rollenzuweisungen angelegt werden. Fuer RDP muss zusaetzlich ein laufender
|
|||||||
pytest -q
|
pytest -q
|
||||||
```
|
```
|
||||||
|
|
||||||
27 Tests decken ab: Argon2id/TOTP-Grundfunktionen, Audit-Hash-Chain (inkl.
|
46 Tests (vorher 27) decken ab: Argon2id/TOTP-Grundfunktionen, Audit-Hash-Chain
|
||||||
Manipulationserkennung und Trigger-Durchsetzung), RBAC-Logik inkl.
|
(inkl. Manipulationserkennung und Trigger-Durchsetzung), RBAC-Logik inkl.
|
||||||
Ablaufdaten, den vollstaendigen Login-Flow (Passwort -> TOTP-Enrollment ->
|
Ablaufdaten, den vollstaendigen Login-Flow (Passwort -> TOTP-Enrollment ->
|
||||||
Session-Cookie -> geschuetzte Endpunkte) gegen die echte FastAPI-App, sowie
|
Session-Cookie -> geschuetzte Endpunkte) gegen die echte FastAPI-App, 17
|
||||||
17 dedizierte Security-/Pentest-Tests (`tests/test_pentest_security.py`) zu
|
dedizierte Security-/Pentest-Tests (`tests/test_pentest_security.py`), 5
|
||||||
Auth-Bypass, Cookie-/Session-Manipulation, RBAC/IDOR, Injection-Versuchen,
|
CSP-Regressionstests (`tests/test_csp_compliance.py`, inkl. `/admin` und
|
||||||
Rate-Limiting/Lockout, Filetransfer-Haertung, Security-Headern und
|
`/docs`) sowie 14 neue Tests zu Gruppen-RBAC-Vererbung, Admin-Oberflaeche und
|
||||||
Audit-Vollstaendigkeit. Details, Vorgehen und Ergebnisse: siehe
|
API-Tokens (`tests/test_admin_groups_tokens.py`): Rollen-Vererbung ueber
|
||||||
|
Gruppenmitgliedschaft, dass Gruppenrollen NICHT auf Nicht-Mitglieder wirken,
|
||||||
|
Admin-only-Durchsetzung fuer alle neuen Endpunkte, Token-Scope-Durchsetzung
|
||||||
|
(read/write/implizites read-bei-write/widerrufen/abgelaufen/unbekannter
|
||||||
|
Scope), dass ein Token niemals andere Tokens verwalten kann (Rechte-
|
||||||
|
Eskalationsschutz), sowie Admin-Gating von `/docs` und `/openapi.json`.
|
||||||
|
Details, Vorgehen und Ergebnisse der urspruenglichen 27 Tests: siehe
|
||||||
`Pentest_Report.md`.
|
`Pentest_Report.md`.
|
||||||
|
|
||||||
|
> **Hinweis:** Die 19 in dieser Session neu hinzugekommenen Tests (5 CSP +
|
||||||
|
> 14 Admin/Gruppen/Token) konnten in der verwendeten Cloud-Sandbox nicht
|
||||||
|
> mit `pytest -q` ausgefuehrt werden, da diese Sandbox keinen Netzwerkzugriff
|
||||||
|
> auf PyPI hat und `fastapi`/`aiosqlite` dort nicht vorinstalliert sind.
|
||||||
|
> Stattdessen wurden alle neuen/gaenderten SQL-Queries gegen eine echte
|
||||||
|
> `sqlite3`-Instanz mit allen 5 Migrationen (inkl. der beiden neuen) manuell
|
||||||
|
> durchgespielt, `app/security/api_tokens.py` und die neuen Pydantic-Schemas
|
||||||
|
> direkt importiert und mit echten Assertions verifiziert, und alle
|
||||||
|
> geaenderten Python-/JS-Dateien mit `py_compile`/`node --check` auf
|
||||||
|
> Syntaxfehler geprueft. Bitte `pytest -q` lokal ausfuehren und Ergebnis
|
||||||
|
> melden.
|
||||||
|
|
||||||
Manuell zusaetzlich verifiziert (siehe Entwicklungs-Log dieser Session):
|
Manuell zusaetzlich verifiziert (siehe Entwicklungs-Log dieser Session):
|
||||||
Server-Start, Static-/Template-Auslieferung, Security-Header, vollstaendiger
|
Server-Start, Static-/Template-Auslieferung, Security-Header, vollstaendiger
|
||||||
Login+TOTP-Flow per curl, Admin-CRUD (Hostgruppe/Host anlegen), Audit-Log-
|
Login+TOTP-Flow per curl, Admin-CRUD (Hostgruppe/Host anlegen), Audit-Log-
|
||||||
@ -71,19 +147,23 @@ Chain-Verifikation per `/admin/audit-log/verify`.
|
|||||||
## Security-Tests & Haertungs-Nachweis
|
## Security-Tests & Haertungs-Nachweis
|
||||||
|
|
||||||
- **SAST**: `bandit -r app -c .bandit.yml` sowie `pip-audit` (Dependency-CVE-
|
- **SAST**: `bandit -r app -c .bandit.yml` sowie `pip-audit` (Dependency-CVE-
|
||||||
Scan) laufen sauber durch (0 Findings, 0 bekannte Schwachstellen) — Details
|
Scan) liefen zu Beginn dieser Implementierung sauber durch (0 Findings, 0
|
||||||
inkl. vorher/nachher-Tabellen und der dabei gefundenen und gefixten
|
bekannte Schwachstellen) — Details inkl. vorher/nachher-Tabellen und der
|
||||||
Starlette-Regression in `Pentest_Report.md`.
|
dabei gefundenen und gefixten Starlette-Regression in `Pentest_Report.md`.
|
||||||
- **DAST**: `tests/test_pentest_security.py` (17 Tests, s.o.) simuliert
|
Fuer die in dieser Session neu hinzugekommenen Dateien (Admin-Oberflaeche,
|
||||||
konkrete Angriffsmuster gegen die laufende ASGI-App.
|
Gruppen-RBAC, API-Tokens) steht ein erneuter `bandit`/`pip-audit`-Lauf noch
|
||||||
|
aus (keine neuen Fremdabhaengigkeiten hinzugekommen).
|
||||||
|
- **DAST**: `tests/test_pentest_security.py` (17 Tests) sowie
|
||||||
|
`tests/test_admin_groups_tokens.py` (14 Tests, s.o.) simulieren konkrete
|
||||||
|
Angriffsmuster gegen die laufende ASGI-App.
|
||||||
- **OS-Haertung (CIS/STIG)**: die `ansible/roles/os_hardening`-Rolle wurde
|
- **OS-Haertung (CIS/STIG)**: die `ansible/roles/os_hardening`-Rolle wurde
|
||||||
in dieser Session um ~10 zusaetzliche Task-Dateien vertieft (Kernel-Module,
|
in einer frueheren Session um ~10 zusaetzliche Task-Dateien vertieft
|
||||||
sysctl, PAM/Passwort-Policy, erweiterte auditd-Regeln, AIDE, rkhunter,
|
(Kernel-Module, sysctl, PAM/Passwort-Policy, erweiterte auditd-Regeln,
|
||||||
Banner, cron/at-Restriktion, Dateirechte/sudo-Logging, SSHD-Haertung).
|
AIDE, rkhunter, Banner, cron/at-Restriktion, Dateirechte/sudo-Logging,
|
||||||
Vollstaendiges Mapping auf CIS-Controls inkl. bewusst nicht automatisierter
|
SSHD-Haertung). Vollstaendiges Mapping auf CIS-Controls inkl. bewusst
|
||||||
Punkte (mit Begruendung, z.B. Partitionslayout, Bootloader-Passwort,
|
nicht automatisierter Punkte (mit Begruendung, z.B. Partitionslayout,
|
||||||
Volltextverschluesselung, physische Sicherheit) und bekannter
|
Bootloader-Passwort, Volltextverschluesselung, physische Sicherheit) und
|
||||||
Einschraenkungen: `ansible/roles/os_hardening/CIS_STIG_MAPPING.md`.
|
bekannter Einschraenkungen: `ansible/roles/os_hardening/CIS_STIG_MAPPING.md`.
|
||||||
- Abhaengigkeiten sind in `requirements.txt`/`requirements-dev.txt` exakt
|
- Abhaengigkeiten sind in `requirements.txt`/`requirements-dev.txt` exakt
|
||||||
auf gegen `pip-audit` gepruefte Versionen gepinnt (u.a. fastapi 0.141.1,
|
auf gegen `pip-audit` gepruefte Versionen gepinnt (u.a. fastapi 0.141.1,
|
||||||
starlette 1.6.0, cryptography 50.0.0) — bewusste Reproduzierbarkeits-/
|
starlette 1.6.0, cryptography 50.0.0) — bewusste Reproduzierbarkeits-/
|
||||||
@ -103,26 +183,28 @@ ansible-playbook -i inventory/production.ini site.yml --ask-vault-pass
|
|||||||
/ `acme_public`) steuern Proxy- und TLS-Verhalten, siehe Konzept Kap. 7.2/7.2a
|
/ `acme_public`) steuern Proxy- und TLS-Verhalten, siehe Konzept Kap. 7.2/7.2a
|
||||||
und `ansible/inventory/group_vars/all.yml`.
|
und `ansible/inventory/group_vars/all.yml`.
|
||||||
|
|
||||||
`ansible-playbook site.yml --syntax-check` laeuft sauber durch (in dieser
|
`ansible-playbook site.yml --syntax-check` laeuft sauber durch (in einer
|
||||||
Session verifiziert); ein voller `--check`-Lauf gegen eine echte
|
frueheren Session verifiziert); ein voller `--check`-Lauf gegen eine echte
|
||||||
Testumgebung (inkl. `apt`, `systemd`, `guacd`-Paketverfuegbarkeit auf der
|
Testumgebung (inkl. `apt`, `systemd`, `guacd`-Paketverfuegbarkeit auf der
|
||||||
Zieldistribution) steht noch aus.
|
Zieldistribution) steht noch aus. Ein Neustart des Dienstes (`systemctl
|
||||||
|
restart jumphost`) reicht aus, damit die Migrationen 0004/0005 angewendet
|
||||||
|
werden.
|
||||||
|
|
||||||
## Was bewusst noch offen ist
|
## Was bewusst noch offen ist
|
||||||
|
|
||||||
Diese Implementierung ist ein funktionsfaehiges, getestetes und in dieser
|
Diese Implementierung ist ein funktionsfaehiges, getestetes und in einer
|
||||||
Session per SAST+DAST geprueftes Grundgeruest, aber weiterhin **kein fertig
|
frueheren Session per SAST+DAST geprueftes Grundgeruest, aber weiterhin
|
||||||
auditiertes Produktivsystem**. Der vollstaendige Befund inkl. Methodik,
|
**kein fertig auditiertes Produktivsystem**. Der vollstaendige Befund inkl.
|
||||||
Vorgehen und Restrisikobewertung steht in `Pentest_Report.md` — die dortige
|
Methodik, Vorgehen und Restrisikobewertung steht in `Pentest_Report.md` — die
|
||||||
Abschnitt-4-Tabelle ("Restrisiko / vor Produktivbetrieb noch zu tun") ist die
|
dortige Abschnitt-4-Tabelle ("Restrisiko / vor Produktivbetrieb noch zu tun")
|
||||||
massgebliche, aktuelle Fassung dieser Liste. Kurzfassung:
|
ist die massgebliche, aktuelle Fassung dieser Liste. Kurzfassung:
|
||||||
|
|
||||||
1. **Echter Netzwerk-Penetrationstest** gegen eine laufende Instanz
|
1. **Echter Netzwerk-Penetrationstest** gegen eine laufende Instanz
|
||||||
(Portscan, TLS-Konfiguration live, Session-Isolation unter Last,
|
(Portscan, TLS-Konfiguration live, Session-Isolation unter Last,
|
||||||
Guacamole-Protokoll-Fuzzing) — in dieser Sandbox ohne Netzwerkzugriff auf
|
Guacamole-Protokoll-Fuzzing) — in dieser Sandbox ohne Netzwerkzugriff auf
|
||||||
ein reales Zielsystem nicht durchfuehrbar. Was stattdessen gemacht wurde:
|
ein reales Zielsystem nicht durchfuehrbar. Was stattdessen gemacht wurde:
|
||||||
SAST (bandit, pip-audit) und ein DAST-Testlauf gegen die App im Prozess
|
SAST (bandit, pip-audit) und ein DAST-Testlauf gegen die App im Prozess
|
||||||
(17 Security-Tests, siehe `Pentest_Report.md`).
|
(17+14 Security-/Admin-Tests, siehe `Pentest_Report.md` bzw. oben).
|
||||||
2. **RDP/guacd-Integrationstest gegen echte Zielsysteme** — die
|
2. **RDP/guacd-Integrationstest gegen echte Zielsysteme** — die
|
||||||
Guacamole-Protokoll-Implementierung (`app/rdp_proxy/guacd_client.py`) wurde
|
Guacamole-Protokoll-Implementierung (`app/rdp_proxy/guacd_client.py`) wurde
|
||||||
gegen die Protokollspezifikation implementiert und die Handshake-Logik
|
gegen die Protokollspezifikation implementiert und die Handshake-Logik
|
||||||
@ -135,10 +217,10 @@ massgebliche, aktuelle Fassung dieser Liste. Kurzfassung:
|
|||||||
`ansible-playbook site.yml --syntax-check` sowie YAML-/Jinja2-Parsing
|
`ansible-playbook site.yml --syntax-check` sowie YAML-/Jinja2-Parsing
|
||||||
verifiziert, siehe `ansible/roles/os_hardening/CIS_STIG_MAPPING.md`.
|
verifiziert, siehe `ansible/roles/os_hardening/CIS_STIG_MAPPING.md`.
|
||||||
4. **OpenSCAP-Compliance-Scan** (`oscap xccdf eval`) gegen das zutreffende
|
4. **OpenSCAP-Compliance-Scan** (`oscap xccdf eval`) gegen das zutreffende
|
||||||
CIS/STIG-Profil — die `os_hardening`-Rolle wurde in dieser Session um ca.
|
CIS/STIG-Profil — die `os_hardening`-Rolle wurde in einer frueheren
|
||||||
10 Task-Dateien vertieft (siehe CIS_STIG_MAPPING.md fuer das vollstaendige
|
Session um ca. 10 Task-Dateien vertieft (siehe CIS_STIG_MAPPING.md fuer
|
||||||
Mapping inkl. bewusst nicht automatisierter Punkte), ersetzt aber keinen
|
das vollstaendige Mapping inkl. bewusst nicht automatisierter Punkte),
|
||||||
zertifizierten Benchmark-Scan.
|
ersetzt aber keinen zertifizierten Benchmark-Scan.
|
||||||
5. **Verteiltes Rate-Limiting** — der aktuelle Login-Rate-Limiter ist
|
5. **Verteiltes Rate-Limiting** — der aktuelle Login-Rate-Limiter ist
|
||||||
In-Memory/Single-Process (siehe `app/security/rate_limit.py`); bei
|
In-Memory/Single-Process (siehe `app/security/rate_limit.py`); bei
|
||||||
horizontaler Skalierung durch einen geteilten Store ersetzen.
|
horizontaler Skalierung durch einen geteilten Store ersetzen.
|
||||||
@ -154,3 +236,12 @@ massgebliche, aktuelle Fassung dieser Liste. Kurzfassung:
|
|||||||
9. **Social Engineering / physische Sicherheit / Lastest (DoS)** wurden
|
9. **Social Engineering / physische Sicherheit / Lastest (DoS)** wurden
|
||||||
nicht getestet — ausserhalb des Scopes eines Code-/Konfigurations-Reviews
|
nicht getestet — ausserhalb des Scopes eines Code-/Konfigurations-Reviews
|
||||||
in dieser Sandbox-Umgebung.
|
in dieser Sandbox-Umgebung.
|
||||||
|
10. **API-Token-Rotation/-Ablaufrichtlinien**: Tokens koennen zwar mit
|
||||||
|
optionalem Ablaufdatum erzeugt und jederzeit widerrufen werden, aber es
|
||||||
|
gibt noch keine erzwungene maximale Laufzeit, keine automatische
|
||||||
|
Benachrichtigung vor Ablauf und keine "Token zuletzt benutzt vor X Tagen
|
||||||
|
-> automatisch deaktivieren"-Routine.
|
||||||
|
11. Ein erneuter `bandit`/`pip-audit`-Lauf ueber die in dieser Session neu
|
||||||
|
hinzugekommenen Dateien (Admin-Oberflaeche, Gruppen-RBAC, API-Tokens)
|
||||||
|
steht noch aus (siehe oben).
|
||||||
|
</content>
|
||||||
|
|||||||
@ -1,25 +1,44 @@
|
|||||||
"""
|
"""
|
||||||
Administrative CRUD-API: User, Hostgruppen, Hosts, Rollenvergabe, SSH-Keys.
|
Administrative CRUD-API: User, Benutzergruppen, Hostgruppen, Hosts,
|
||||||
|
Rollenvergabe (an User UND an Benutzergruppen), SSH-Keys, API-Tokens.
|
||||||
|
|
||||||
|
Zustandsaendernde/-lesende Endpunkte sind entweder auf eine eingeloggte
|
||||||
|
Admin-Session ODER ein API-Token mit passendem Scope beschraenkt
|
||||||
|
(`require_admin_or_scope`, siehe app/auth/deps.py) und schreiben einen
|
||||||
|
Audit-Log-Eintrag (Konzept 4.7). Die Token-Verwaltung selbst
|
||||||
|
(/admin/tokens/*) laeuft bewusst ausschliesslich ueber `require_global_admin`
|
||||||
|
(reine Session-Aktion, kein Token-Bypass -- Privilege-Escalation-Schutz).
|
||||||
|
|
||||||
Alle zustandsaendernden Endpunkte sind auf globale Admins beschraenkt
|
|
||||||
(`require_global_admin`) und schreiben einen Audit-Log-Eintrag (Konzept 4.7).
|
|
||||||
Eine feingranulare, auf `admin_hostgroup` beschraenkte Admin-Rolle ist im
|
Eine feingranulare, auf `admin_hostgroup` beschraenkte Admin-Rolle ist im
|
||||||
Datenmodell vorbereitet, wird hier aus Uebersichtlichkeitsgruenden aber nicht
|
Datenmodell vorbereitet, wird hier aus Uebersichtlichkeitsgruenden aber nicht
|
||||||
vollstaendig verdrahtet -- siehe TODO-Markierungen fuer den naechsten Ausbauschritt.
|
vollstaendig verdrahtet -- siehe TODO-Markierungen fuer den naechsten Ausbauschritt.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||||
|
|
||||||
from app.auth.deps import CurrentUser, require_global_admin
|
from app.auth.deps import CurrentUser, require_admin_or_scope, require_global_admin
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models.schemas import (
|
from app.models.schemas import (
|
||||||
|
ApiTokenCreateRequest,
|
||||||
|
GroupMemberRequest,
|
||||||
|
GroupRoleGrantRequest,
|
||||||
HostCreateRequest,
|
HostCreateRequest,
|
||||||
HostGroupCreateRequest,
|
HostGroupCreateRequest,
|
||||||
RdpCredentialsRequest,
|
RdpCredentialsRequest,
|
||||||
RoleGrantRequest,
|
RoleGrantRequest,
|
||||||
SshKeyCreateRequest,
|
SshKeyCreateRequest,
|
||||||
UserCreateRequest,
|
UserCreateRequest,
|
||||||
|
UserGroupCreateRequest,
|
||||||
|
)
|
||||||
|
from app.security.api_tokens import (
|
||||||
|
VALID_SCOPES,
|
||||||
|
generate_token,
|
||||||
|
hash_token,
|
||||||
|
token_prefix_for_display,
|
||||||
|
validate_scopes,
|
||||||
)
|
)
|
||||||
from app.security.audit import verify_chain, write_audit_event
|
from app.security.audit import verify_chain, write_audit_event
|
||||||
from app.security.crypto import encrypt_secret
|
from app.security.crypto import encrypt_secret
|
||||||
@ -37,7 +56,8 @@ def _client_ip(request: Request) -> str:
|
|||||||
|
|
||||||
@router.post("/users", status_code=status.HTTP_201_CREATED)
|
@router.post("/users", status_code=status.HTTP_201_CREATED)
|
||||||
async def create_user(
|
async def create_user(
|
||||||
payload: UserCreateRequest, request: Request, admin: CurrentUser = Depends(require_global_admin)
|
payload: UserCreateRequest, request: Request,
|
||||||
|
admin: CurrentUser = Depends(require_admin_or_scope("users", "write")),
|
||||||
):
|
):
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
cursor = await conn.execute("SELECT 1 FROM users WHERE username = ?", (payload.username,))
|
cursor = await conn.execute("SELECT 1 FROM users WHERE username = ?", (payload.username,))
|
||||||
@ -60,7 +80,7 @@ async def create_user(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/users")
|
@router.get("/users")
|
||||||
async def list_users(admin: CurrentUser = Depends(require_global_admin)):
|
async def list_users(admin: CurrentUser = Depends(require_admin_or_scope("users", "read"))):
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
cursor = await conn.execute(
|
cursor = await conn.execute(
|
||||||
"SELECT id, username, is_admin, is_active, totp_enrolled, created_at FROM users ORDER BY id"
|
"SELECT id, username, is_admin, is_active, totp_enrolled, created_at FROM users ORDER BY id"
|
||||||
@ -77,7 +97,8 @@ async def list_users(admin: CurrentUser = Depends(require_global_admin)):
|
|||||||
|
|
||||||
@router.post("/users/{user_id}/deactivate")
|
@router.post("/users/{user_id}/deactivate")
|
||||||
async def deactivate_user(
|
async def deactivate_user(
|
||||||
user_id: int, request: Request, admin: CurrentUser = Depends(require_global_admin)
|
user_id: int, request: Request,
|
||||||
|
admin: CurrentUser = Depends(require_admin_or_scope("users", "write")),
|
||||||
):
|
):
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
@ -92,11 +113,115 @@ async def deactivate_user(
|
|||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Benutzergruppen (Teams) -------------------------------------------------
|
||||||
|
#
|
||||||
|
# "Verbindungen mit einer Gruppe teilen" = einer Benutzergruppe ueber
|
||||||
|
# /admin/group-roles/grant eine Rolle auf einer Hostgruppe geben -- jedes
|
||||||
|
# aktuelle und zukuenftige Mitglied erbt diese Rolle vollstaendig (siehe
|
||||||
|
# app/rbac.py: user_has_role() vereinigt direkte und Gruppen-Grants).
|
||||||
|
|
||||||
|
@router.post("/user-groups", status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_user_group(
|
||||||
|
payload: UserGroupCreateRequest, request: Request,
|
||||||
|
admin: CurrentUser = Depends(require_admin_or_scope("user_groups", "write")),
|
||||||
|
):
|
||||||
|
conn = get_db()
|
||||||
|
cursor = await conn.execute(
|
||||||
|
"SELECT 1 FROM user_groups WHERE name = ?", (payload.name,)
|
||||||
|
)
|
||||||
|
if await cursor.fetchone() is not None:
|
||||||
|
raise HTTPException(status.HTTP_409_CONFLICT, "Gruppenname existiert bereits")
|
||||||
|
cursor = await conn.execute(
|
||||||
|
"INSERT INTO user_groups (name, description) VALUES (?, ?)",
|
||||||
|
(payload.name, payload.description),
|
||||||
|
)
|
||||||
|
new_id = cursor.lastrowid
|
||||||
|
await write_audit_event(
|
||||||
|
conn, event_type="user_group_created", user_id=admin.id, client_ip=_client_ip(request),
|
||||||
|
details={"id": new_id, "name": payload.name},
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
return {"id": new_id, "name": payload.name}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/user-groups")
|
||||||
|
async def list_user_groups(admin: CurrentUser = Depends(require_admin_or_scope("user_groups", "read"))):
|
||||||
|
conn = get_db()
|
||||||
|
cursor = await conn.execute(
|
||||||
|
"SELECT ug.id, ug.name, ug.description, ug.created_at, COUNT(ugm.user_id) "
|
||||||
|
"FROM user_groups ug LEFT JOIN user_group_members ugm ON ugm.user_group_id = ug.id "
|
||||||
|
"GROUP BY ug.id ORDER BY ug.name"
|
||||||
|
)
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
return [
|
||||||
|
{"id": r[0], "name": r[1], "description": r[2], "created_at": r[3], "member_count": r[4]}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/user-groups/{group_id}/members")
|
||||||
|
async def list_group_members(
|
||||||
|
group_id: int, admin: CurrentUser = Depends(require_admin_or_scope("user_groups", "read"))
|
||||||
|
):
|
||||||
|
conn = get_db()
|
||||||
|
cursor = await conn.execute(
|
||||||
|
"SELECT u.id, u.username, ugm.added_at FROM user_group_members ugm "
|
||||||
|
"JOIN users u ON u.id = ugm.user_id WHERE ugm.user_group_id = ? ORDER BY u.username",
|
||||||
|
(group_id,),
|
||||||
|
)
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
return [{"user_id": r[0], "username": r[1], "added_at": r[2]} for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/user-groups/{group_id}/members", status_code=status.HTTP_201_CREATED)
|
||||||
|
async def add_group_member(
|
||||||
|
group_id: int, payload: GroupMemberRequest, request: Request,
|
||||||
|
admin: CurrentUser = Depends(require_admin_or_scope("user_groups", "write")),
|
||||||
|
):
|
||||||
|
conn = get_db()
|
||||||
|
group_cursor = await conn.execute("SELECT 1 FROM user_groups WHERE id = ?", (group_id,))
|
||||||
|
if await group_cursor.fetchone() is None:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Gruppe nicht gefunden")
|
||||||
|
user_cursor = await conn.execute("SELECT 1 FROM users WHERE id = ?", (payload.user_id,))
|
||||||
|
if await user_cursor.fetchone() is None:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Benutzer nicht gefunden")
|
||||||
|
|
||||||
|
await conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO user_group_members (user_group_id, user_id, added_by) VALUES (?, ?, ?)",
|
||||||
|
(group_id, payload.user_id, admin.id),
|
||||||
|
)
|
||||||
|
await write_audit_event(
|
||||||
|
conn, event_type="user_group_member_added", user_id=admin.id, client_ip=_client_ip(request),
|
||||||
|
details={"user_group_id": group_id, "target_user_id": payload.user_id},
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/user-groups/{group_id}/members/{user_id}")
|
||||||
|
async def remove_group_member(
|
||||||
|
group_id: int, user_id: int, request: Request,
|
||||||
|
admin: CurrentUser = Depends(require_admin_or_scope("user_groups", "write")),
|
||||||
|
):
|
||||||
|
conn = get_db()
|
||||||
|
await conn.execute(
|
||||||
|
"DELETE FROM user_group_members WHERE user_group_id = ? AND user_id = ?",
|
||||||
|
(group_id, user_id),
|
||||||
|
)
|
||||||
|
await write_audit_event(
|
||||||
|
conn, event_type="user_group_member_removed", user_id=admin.id, client_ip=_client_ip(request),
|
||||||
|
details={"user_group_id": group_id, "target_user_id": user_id},
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
# --- Hostgruppen -------------------------------------------------------------
|
# --- Hostgruppen -------------------------------------------------------------
|
||||||
|
|
||||||
@router.post("/host-groups", status_code=status.HTTP_201_CREATED)
|
@router.post("/host-groups", status_code=status.HTTP_201_CREATED)
|
||||||
async def create_host_group(
|
async def create_host_group(
|
||||||
payload: HostGroupCreateRequest, request: Request, admin: CurrentUser = Depends(require_global_admin)
|
payload: HostGroupCreateRequest, request: Request,
|
||||||
|
admin: CurrentUser = Depends(require_admin_or_scope("host_groups", "write")),
|
||||||
):
|
):
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
cursor = await conn.execute(
|
cursor = await conn.execute(
|
||||||
@ -113,7 +238,7 @@ async def create_host_group(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/host-groups")
|
@router.get("/host-groups")
|
||||||
async def list_host_groups(admin: CurrentUser = Depends(require_global_admin)):
|
async def list_host_groups(admin: CurrentUser = Depends(require_admin_or_scope("host_groups", "read"))):
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
cursor = await conn.execute("SELECT id, name, description FROM host_groups ORDER BY id")
|
cursor = await conn.execute("SELECT id, name, description FROM host_groups ORDER BY id")
|
||||||
rows = await cursor.fetchall()
|
rows = await cursor.fetchall()
|
||||||
@ -124,7 +249,8 @@ async def list_host_groups(admin: CurrentUser = Depends(require_global_admin)):
|
|||||||
|
|
||||||
@router.post("/hosts", status_code=status.HTTP_201_CREATED)
|
@router.post("/hosts", status_code=status.HTTP_201_CREATED)
|
||||||
async def create_host(
|
async def create_host(
|
||||||
payload: HostCreateRequest, request: Request, admin: CurrentUser = Depends(require_global_admin)
|
payload: HostCreateRequest, request: Request,
|
||||||
|
admin: CurrentUser = Depends(require_admin_or_scope("hosts", "write")),
|
||||||
):
|
):
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
cursor = await conn.execute(
|
cursor = await conn.execute(
|
||||||
@ -153,7 +279,10 @@ async def create_host(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/hosts")
|
@router.get("/hosts")
|
||||||
async def list_hosts(host_group_id: int | None = None, admin: CurrentUser = Depends(require_global_admin)):
|
async def list_hosts(
|
||||||
|
host_group_id: int | None = None,
|
||||||
|
admin: CurrentUser = Depends(require_admin_or_scope("hosts", "read")),
|
||||||
|
):
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
if host_group_id is not None:
|
if host_group_id is not None:
|
||||||
cursor = await conn.execute(
|
cursor = await conn.execute(
|
||||||
@ -177,7 +306,8 @@ async def list_hosts(host_group_id: int | None = None, admin: CurrentUser = Depe
|
|||||||
|
|
||||||
@router.post("/hosts/{host_id}/discover-host-key")
|
@router.post("/hosts/{host_id}/discover-host-key")
|
||||||
async def discover_host_key(
|
async def discover_host_key(
|
||||||
host_id: int, request: Request, admin: CurrentUser = Depends(require_global_admin)
|
host_id: int, request: Request,
|
||||||
|
admin: CurrentUser = Depends(require_admin_or_scope("hosts", "write")),
|
||||||
):
|
):
|
||||||
"""ACHTUNG: Verbindet einmalig OHNE Host-Key-Pruefung, um den Fingerprint zu
|
"""ACHTUNG: Verbindet einmalig OHNE Host-Key-Pruefung, um den Fingerprint zu
|
||||||
erfassen (bewusste Trust-Entscheidung, siehe Konzept 4.2). Danach gilt fuer
|
erfassen (bewusste Trust-Entscheidung, siehe Konzept 4.2). Danach gilt fuer
|
||||||
@ -196,7 +326,7 @@ async def discover_host_key(
|
|||||||
@router.put("/hosts/{host_id}/rdp-credentials")
|
@router.put("/hosts/{host_id}/rdp-credentials")
|
||||||
async def set_rdp_credentials(
|
async def set_rdp_credentials(
|
||||||
host_id: int, payload: RdpCredentialsRequest, request: Request,
|
host_id: int, payload: RdpCredentialsRequest, request: Request,
|
||||||
admin: CurrentUser = Depends(require_global_admin),
|
admin: CurrentUser = Depends(require_admin_or_scope("hosts", "write")),
|
||||||
):
|
):
|
||||||
"""Speichert/rotiert das RDP-Passwort fuer einen Host, verschluesselt mit
|
"""Speichert/rotiert das RDP-Passwort fuer einen Host, verschluesselt mit
|
||||||
dem KEK (eigener AAD-Kontext, siehe app/security/crypto.py)."""
|
dem KEK (eigener AAD-Kontext, siehe app/security/crypto.py)."""
|
||||||
@ -217,11 +347,12 @@ async def set_rdp_credentials(
|
|||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
# --- Rollenvergabe -------------------------------------------------------------
|
# --- Rollenvergabe (an einzelne User) -----------------------------------------
|
||||||
|
|
||||||
@router.post("/roles/grant")
|
@router.post("/roles/grant")
|
||||||
async def grant_role(
|
async def grant_role(
|
||||||
payload: RoleGrantRequest, request: Request, admin: CurrentUser = Depends(require_global_admin)
|
payload: RoleGrantRequest, request: Request,
|
||||||
|
admin: CurrentUser = Depends(require_admin_or_scope("roles", "write")),
|
||||||
):
|
):
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
role_cursor = await conn.execute("SELECT id FROM roles WHERE name = ?", (payload.role_name,))
|
role_cursor = await conn.execute("SELECT id FROM roles WHERE name = ?", (payload.role_name,))
|
||||||
@ -247,7 +378,8 @@ async def grant_role(
|
|||||||
|
|
||||||
@router.post("/roles/revoke")
|
@router.post("/roles/revoke")
|
||||||
async def revoke_role(
|
async def revoke_role(
|
||||||
payload: RoleGrantRequest, request: Request, admin: CurrentUser = Depends(require_global_admin)
|
payload: RoleGrantRequest, request: Request,
|
||||||
|
admin: CurrentUser = Depends(require_admin_or_scope("roles", "write")),
|
||||||
):
|
):
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
role_cursor = await conn.execute("SELECT id FROM roles WHERE name = ?", (payload.role_name,))
|
role_cursor = await conn.execute("SELECT id FROM roles WHERE name = ?", (payload.role_name,))
|
||||||
@ -270,11 +402,114 @@ async def revoke_role(
|
|||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/roles")
|
||||||
|
async def list_role_grants(admin: CurrentUser = Depends(require_admin_or_scope("roles", "read"))):
|
||||||
|
conn = get_db()
|
||||||
|
cursor = await conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT u.id, u.username, hg.id, hg.name, r.name, uhr.granted_by, uhr.granted_at, uhr.expires_at
|
||||||
|
FROM user_hostgroup_roles uhr
|
||||||
|
JOIN users u ON u.id = uhr.user_id
|
||||||
|
JOIN host_groups hg ON hg.id = uhr.host_group_id
|
||||||
|
JOIN roles r ON r.id = uhr.role_id
|
||||||
|
ORDER BY u.username, hg.name, r.name
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"user_id": r[0], "username": r[1], "host_group_id": r[2], "host_group_name": r[3],
|
||||||
|
"role_name": r[4], "granted_by": r[5], "granted_at": r[6], "expires_at": r[7],
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# --- Rollenvergabe (an Benutzergruppen) ---------------------------------------
|
||||||
|
|
||||||
|
@router.post("/group-roles/grant")
|
||||||
|
async def grant_group_role(
|
||||||
|
payload: GroupRoleGrantRequest, request: Request,
|
||||||
|
admin: CurrentUser = Depends(require_admin_or_scope("roles", "write")),
|
||||||
|
):
|
||||||
|
conn = get_db()
|
||||||
|
role_cursor = await conn.execute("SELECT id FROM roles WHERE name = ?", (payload.role_name,))
|
||||||
|
role_row = await role_cursor.fetchone()
|
||||||
|
if role_row is None:
|
||||||
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Unbekannte Rolle")
|
||||||
|
|
||||||
|
await conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO group_hostgroup_roles "
|
||||||
|
"(user_group_id, host_group_id, role_id, granted_by, expires_at) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(payload.user_group_id, payload.host_group_id, role_row[0], admin.id, payload.expires_at),
|
||||||
|
)
|
||||||
|
await write_audit_event(
|
||||||
|
conn, event_type="group_role_granted", user_id=admin.id, client_ip=_client_ip(request),
|
||||||
|
details={
|
||||||
|
"user_group_id": payload.user_group_id, "host_group_id": payload.host_group_id,
|
||||||
|
"role": payload.role_name, "expires_at": payload.expires_at,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/group-roles/revoke")
|
||||||
|
async def revoke_group_role(
|
||||||
|
payload: GroupRoleGrantRequest, request: Request,
|
||||||
|
admin: CurrentUser = Depends(require_admin_or_scope("roles", "write")),
|
||||||
|
):
|
||||||
|
conn = get_db()
|
||||||
|
role_cursor = await conn.execute("SELECT id FROM roles WHERE name = ?", (payload.role_name,))
|
||||||
|
role_row = await role_cursor.fetchone()
|
||||||
|
if role_row is None:
|
||||||
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Unbekannte Rolle")
|
||||||
|
|
||||||
|
await conn.execute(
|
||||||
|
"DELETE FROM group_hostgroup_roles WHERE user_group_id = ? AND host_group_id = ? AND role_id = ?",
|
||||||
|
(payload.user_group_id, payload.host_group_id, role_row[0]),
|
||||||
|
)
|
||||||
|
await write_audit_event(
|
||||||
|
conn, event_type="group_role_revoked", user_id=admin.id, client_ip=_client_ip(request),
|
||||||
|
details={
|
||||||
|
"user_group_id": payload.user_group_id, "host_group_id": payload.host_group_id,
|
||||||
|
"role": payload.role_name,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/group-roles")
|
||||||
|
async def list_group_role_grants(admin: CurrentUser = Depends(require_admin_or_scope("roles", "read"))):
|
||||||
|
conn = get_db()
|
||||||
|
cursor = await conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT ug.id, ug.name, hg.id, hg.name, r.name, ghr.granted_by, ghr.granted_at, ghr.expires_at
|
||||||
|
FROM group_hostgroup_roles ghr
|
||||||
|
JOIN user_groups ug ON ug.id = ghr.user_group_id
|
||||||
|
JOIN host_groups hg ON hg.id = ghr.host_group_id
|
||||||
|
JOIN roles r ON r.id = ghr.role_id
|
||||||
|
ORDER BY ug.name, hg.name, r.name
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"user_group_id": r[0], "user_group_name": r[1], "host_group_id": r[2],
|
||||||
|
"host_group_name": r[3], "role_name": r[4], "granted_by": r[5],
|
||||||
|
"granted_at": r[6], "expires_at": r[7],
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
# --- SSH-Keyverwaltung ---------------------------------------------------------
|
# --- SSH-Keyverwaltung ---------------------------------------------------------
|
||||||
|
|
||||||
@router.post("/ssh-keys", status_code=status.HTTP_201_CREATED)
|
@router.post("/ssh-keys", status_code=status.HTTP_201_CREATED)
|
||||||
async def create_ssh_key(
|
async def create_ssh_key(
|
||||||
payload: SshKeyCreateRequest, request: Request, admin: CurrentUser = Depends(require_global_admin)
|
payload: SshKeyCreateRequest, request: Request,
|
||||||
|
admin: CurrentUser = Depends(require_admin_or_scope("ssh_keys", "write")),
|
||||||
):
|
):
|
||||||
"""Nimmt einen privaten Schluessel entgegen, verschluesselt ihn sofort mit
|
"""Nimmt einen privaten Schluessel entgegen, verschluesselt ihn sofort mit
|
||||||
dem KEK (AES-256-GCM) und haelt den Klartext nur fuer die Dauer dieses
|
dem KEK (AES-256-GCM) und haelt den Klartext nur fuer die Dauer dieses
|
||||||
@ -295,9 +530,27 @@ async def create_ssh_key(
|
|||||||
return {"id": new_id, "label": payload.label}
|
return {"id": new_id, "label": payload.label}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ssh-keys")
|
||||||
|
async def list_ssh_keys(admin: CurrentUser = Depends(require_admin_or_scope("ssh_keys", "read"))):
|
||||||
|
conn = get_db()
|
||||||
|
cursor = await conn.execute(
|
||||||
|
"SELECT id, label, owner_user_id, key_type, created_at, rotated_at, expires_at "
|
||||||
|
"FROM ssh_keys ORDER BY id"
|
||||||
|
)
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": r[0], "label": r[1], "owner_user_id": r[2], "key_type": r[3],
|
||||||
|
"created_at": r[4], "rotated_at": r[5], "expires_at": r[6],
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/hosts/{host_id}/ssh-keys/{key_id}")
|
@router.post("/hosts/{host_id}/ssh-keys/{key_id}")
|
||||||
async def map_ssh_key_to_host(
|
async def map_ssh_key_to_host(
|
||||||
host_id: int, key_id: int, request: Request, admin: CurrentUser = Depends(require_global_admin)
|
host_id: int, key_id: int, request: Request,
|
||||||
|
admin: CurrentUser = Depends(require_admin_or_scope("ssh_keys", "write")),
|
||||||
):
|
):
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
@ -312,11 +565,97 @@ async def map_ssh_key_to_host(
|
|||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
# --- API-Tokens ------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# Bewusst ausschliesslich ueber require_global_admin (Session), NIE ueber
|
||||||
|
# require_admin_or_scope -- ein geleaktes Token darf sich damit nicht selbst
|
||||||
|
# weitere/staerkere Tokens ausstellen (Privilege-Escalation-Schutz).
|
||||||
|
|
||||||
|
@router.get("/scopes")
|
||||||
|
async def list_valid_scopes(admin: CurrentUser = Depends(require_global_admin)):
|
||||||
|
return {"scopes": sorted(VALID_SCOPES)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tokens", status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_api_token(
|
||||||
|
payload: ApiTokenCreateRequest, request: Request, admin: CurrentUser = Depends(require_global_admin)
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
scopes = validate_scopes(payload.scopes)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
|
||||||
|
|
||||||
|
conn = get_db()
|
||||||
|
user_cursor = await conn.execute("SELECT 1 FROM users WHERE id = ?", (payload.user_id,))
|
||||||
|
if await user_cursor.fetchone() is None:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Benutzer nicht gefunden")
|
||||||
|
|
||||||
|
token = generate_token()
|
||||||
|
cursor = await conn.execute(
|
||||||
|
"INSERT INTO api_tokens (user_id, label, token_hash, token_prefix, scopes_json, "
|
||||||
|
"created_by, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(
|
||||||
|
payload.user_id, payload.label, hash_token(token), token_prefix_for_display(token),
|
||||||
|
json.dumps(scopes), admin.id, payload.expires_at,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
new_id = cursor.lastrowid
|
||||||
|
await write_audit_event(
|
||||||
|
conn, event_type="api_token_created", user_id=admin.id, client_ip=_client_ip(request),
|
||||||
|
details={
|
||||||
|
"token_id": new_id, "target_user_id": payload.user_id, "label": payload.label,
|
||||||
|
"scopes": scopes, "expires_at": payload.expires_at,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
# Klartext-Token wird NUR in dieser einen Response zurueckgegeben -- danach
|
||||||
|
# ist nur noch der Hash in der DB, das Token ist nicht mehr rekonstruierbar.
|
||||||
|
return {"id": new_id, "token": token, "prefix": token_prefix_for_display(token), "scopes": scopes}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tokens")
|
||||||
|
async def list_api_tokens(admin: CurrentUser = Depends(require_global_admin)):
|
||||||
|
conn = get_db()
|
||||||
|
cursor = await conn.execute(
|
||||||
|
"SELECT t.id, t.user_id, u.username, t.label, t.token_prefix, t.scopes_json, "
|
||||||
|
"t.created_at, t.expires_at, t.last_used_at, t.revoked_at "
|
||||||
|
"FROM api_tokens t JOIN users u ON u.id = t.user_id ORDER BY t.id DESC"
|
||||||
|
)
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": r[0], "user_id": r[1], "username": r[2], "label": r[3], "prefix": r[4],
|
||||||
|
"scopes": json.loads(r[5]), "created_at": r[6], "expires_at": r[7],
|
||||||
|
"last_used_at": r[8], "revoked_at": r[9],
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tokens/{token_id}/revoke")
|
||||||
|
async def revoke_api_token(
|
||||||
|
token_id: int, request: Request, admin: CurrentUser = Depends(require_global_admin)
|
||||||
|
):
|
||||||
|
conn = get_db()
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE api_tokens SET revoked_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') "
|
||||||
|
"WHERE id = ? AND revoked_at IS NULL",
|
||||||
|
(token_id,),
|
||||||
|
)
|
||||||
|
await write_audit_event(
|
||||||
|
conn, event_type="api_token_revoked", user_id=admin.id, client_ip=_client_ip(request),
|
||||||
|
details={"token_id": token_id},
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
# --- Audit-Log -----------------------------------------------------------------
|
# --- Audit-Log -----------------------------------------------------------------
|
||||||
|
|
||||||
@router.get("/audit-log")
|
@router.get("/audit-log")
|
||||||
async def get_audit_log(
|
async def get_audit_log(
|
||||||
limit: int = 100, offset: int = 0, admin: CurrentUser = Depends(require_global_admin)
|
limit: int = 100, offset: int = 0,
|
||||||
|
admin: CurrentUser = Depends(require_admin_or_scope("audit", "read")),
|
||||||
):
|
):
|
||||||
limit = max(1, min(limit, 1000))
|
limit = max(1, min(limit, 1000))
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
@ -333,7 +672,7 @@ async def get_audit_log(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/audit-log/verify")
|
@router.get("/audit-log/verify")
|
||||||
async def verify_audit_log(admin: CurrentUser = Depends(require_global_admin)):
|
async def verify_audit_log(admin: CurrentUser = Depends(require_admin_or_scope("audit", "read"))):
|
||||||
"""Prueft die Hash-Chain auf Manipulationsfreiheit (Konzept 6.1/4.7)."""
|
"""Prueft die Hash-Chain auf Manipulationsfreiheit (Konzept 6.1/4.7)."""
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
intact, broken_at = await verify_chain(conn)
|
intact, broken_at = await verify_chain(conn)
|
||||||
|
|||||||
@ -1,12 +1,15 @@
|
|||||||
"""FastAPI-Dependencies fuer Authentifizierung und Autorisierung."""
|
"""FastAPI-Dependencies fuer Authentifizierung und Autorisierung."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from fastapi import Cookie, Depends, HTTPException, Request, Response, WebSocket, status
|
from fastapi import Cookie, Depends, Header, HTTPException, Request, Response, WebSocket, status
|
||||||
|
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.rbac import user_has_role, user_has_role_for_host
|
from app.rbac import user_has_role, user_has_role_for_host
|
||||||
|
from app.security.api_tokens import TOKEN_PREFIX, hash_token, token_has_scope
|
||||||
from app.security.sessions import (
|
from app.security.sessions import (
|
||||||
SESSION_COOKIE_NAME,
|
SESSION_COOKIE_NAME,
|
||||||
decode_session_token,
|
decode_session_token,
|
||||||
@ -120,3 +123,77 @@ def require_host_role(role_name: str):
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
return _dep
|
return _dep
|
||||||
|
|
||||||
|
|
||||||
|
# --- API-Tokens (siehe app/security/api_tokens.py, Migration 0005) -------------
|
||||||
|
#
|
||||||
|
# Tokens sind bewusst NUR fuer die Admin-/Verwaltungs-API gedacht (Hosts,
|
||||||
|
# User, Rollen, ...), NICHT fuer SSH/RDP/SFTP-Sitzungen selbst -- die bleiben
|
||||||
|
# ausschliesslich an eine eingeloggte, TOTP-verifizierte Browser-Session
|
||||||
|
# gebunden. Ein Token ist nie maechtiger als sein explizit vergebener Scope,
|
||||||
|
# auch wenn der zugehoerige User Admin ist (kein automatisches is_admin).
|
||||||
|
# Die Tokenverwaltung selbst (/admin/tokens/*) laeuft ausschliesslich ueber
|
||||||
|
# require_global_admin (Session), niemals ueber ein Token -- ein geleaktes
|
||||||
|
# Token darf sich damit nicht selbst weitere/staerkere Tokens ausstellen.
|
||||||
|
|
||||||
|
|
||||||
|
async def _validate_api_token(token: str, *, resource: str, action: str) -> CurrentUser | None:
|
||||||
|
if not token.startswith(TOKEN_PREFIX):
|
||||||
|
return None
|
||||||
|
conn = get_db()
|
||||||
|
token_hash = hash_token(token)
|
||||||
|
cursor = await conn.execute(
|
||||||
|
"SELECT at.id, at.user_id, at.scopes_json, at.expires_at, at.revoked_at, "
|
||||||
|
"u.username, u.is_active "
|
||||||
|
"FROM api_tokens at JOIN users u ON u.id = at.user_id WHERE at.token_hash = ?",
|
||||||
|
(token_hash,),
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
token_id, user_id, scopes_json, expires_at, revoked_at, username, is_active = row
|
||||||
|
if revoked_at is not None or not is_active:
|
||||||
|
return None
|
||||||
|
now_str = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
|
||||||
|
if expires_at is not None and expires_at <= now_str:
|
||||||
|
return None
|
||||||
|
scopes = json.loads(scopes_json)
|
||||||
|
if not token_has_scope(scopes, resource, action):
|
||||||
|
return None
|
||||||
|
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE api_tokens SET last_used_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
|
||||||
|
(token_id,),
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
return CurrentUser(id=user_id, username=username, is_admin=False)
|
||||||
|
|
||||||
|
|
||||||
|
def require_admin_or_scope(resource: str, action: str):
|
||||||
|
"""Dependency-Factory fuer die Admin-API: erlaubt Zugriff entweder ueber
|
||||||
|
eine eingeloggte Admin-Session (Cookie, wie bisher) ODER ueber ein
|
||||||
|
API-Token mit passendem Scope (Header 'Authorization: Bearer <token>').
|
||||||
|
'write' schliesst 'read' auf derselben Ressource ein (siehe
|
||||||
|
token_has_scope)."""
|
||||||
|
|
||||||
|
async def _dep(
|
||||||
|
response: Response,
|
||||||
|
jh_session: str | None = Cookie(default=None, alias=SESSION_COOKIE_NAME),
|
||||||
|
authorization: str | None = Header(default=None),
|
||||||
|
) -> CurrentUser:
|
||||||
|
if authorization and authorization.startswith("Bearer "):
|
||||||
|
token = authorization.removeprefix("Bearer ").strip()
|
||||||
|
principal = await _validate_api_token(token, resource=resource, action=action)
|
||||||
|
if principal is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_401_UNAUTHORIZED,
|
||||||
|
"Ungueltiges, abgelaufenes oder nicht ausreichend berechtigtes Token",
|
||||||
|
)
|
||||||
|
return principal
|
||||||
|
|
||||||
|
user = await get_current_user(response, jh_session)
|
||||||
|
if not user.is_admin:
|
||||||
|
raise HTTPException(status.HTTP_403_FORBIDDEN, "Admin-Rechte erforderlich")
|
||||||
|
return user
|
||||||
|
|
||||||
|
return _dep
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
"""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 (Konzept 4.6)."""
|
Rolle in der jeweiligen Hostgruppe vergeben hat -- entweder direkt (Konzept
|
||||||
|
4.6) oder ueber eine Benutzergruppe, in der der User Mitglied ist (volle
|
||||||
|
Rollen-Vererbung, siehe app/rbac.py)."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
@ -32,18 +34,31 @@ async def my_hosts(user: CurrentUser = Depends(get_current_user)):
|
|||||||
|
|
||||||
cursor = await conn.execute(
|
cursor = await conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT DISTINCT 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
|
FROM hosts h
|
||||||
JOIN host_groups g ON g.id = h.host_group_id
|
JOIN host_groups g ON g.id = h.host_group_id
|
||||||
JOIN user_hostgroup_roles uhr ON uhr.host_group_id = h.host_group_id
|
WHERE h.is_active = 1
|
||||||
JOIN roles r ON r.id = uhr.role_id
|
AND (
|
||||||
WHERE h.is_active = 1 AND uhr.user_id = ?
|
EXISTS (
|
||||||
AND r.name IN ('ssh_connect', 'rdp_connect')
|
SELECT 1 FROM user_hostgroup_roles uhr
|
||||||
AND (uhr.expires_at IS NULL OR uhr.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
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
|
ORDER BY g.name, h.hostname
|
||||||
""",
|
""",
|
||||||
(user.id,),
|
(user.id, user.id),
|
||||||
)
|
)
|
||||||
rows = await cursor.fetchall()
|
rows = await cursor.fetchall()
|
||||||
hosts = [dict(zip(
|
hosts = [dict(zip(
|
||||||
@ -53,12 +68,18 @@ async def my_hosts(user: CurrentUser = Depends(get_current_user)):
|
|||||||
|
|
||||||
ft_cursor = await conn.execute(
|
ft_cursor = await conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT DISTINCT uhr.host_group_id FROM user_hostgroup_roles uhr
|
SELECT uhr.host_group_id FROM user_hostgroup_roles uhr
|
||||||
JOIN roles r ON r.id = uhr.role_id
|
JOIN roles r ON r.id = uhr.role_id
|
||||||
WHERE uhr.user_id = ? AND r.name = 'file_transfer'
|
WHERE uhr.user_id = ? AND r.name = 'file_transfer'
|
||||||
AND (uhr.expires_at IS NULL OR uhr.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
AND (uhr.expires_at IS NULL OR uhr.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||||
|
UNION
|
||||||
|
SELECT ghr.host_group_id FROM group_hostgroup_roles ghr
|
||||||
|
JOIN roles r ON r.id = ghr.role_id
|
||||||
|
JOIN user_group_members ugm ON ugm.user_group_id = ghr.user_group_id
|
||||||
|
WHERE ugm.user_id = ? AND r.name = 'file_transfer'
|
||||||
|
AND (ghr.expires_at IS NULL OR ghr.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||||
""",
|
""",
|
||||||
(user.id,),
|
(user.id, user.id),
|
||||||
)
|
)
|
||||||
ft_groups = {row[0] for row in await ft_cursor.fetchall()}
|
ft_groups = {row[0] for row in await ft_cursor.fetchall()}
|
||||||
for h in hosts:
|
for h in hosts:
|
||||||
|
|||||||
35
app/db/migrations/0004_user_groups.sql
Normal file
35
app/db/migrations/0004_user_groups.sql
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
-- Benutzergruppen (Teams): Rollen koennen einer ganzen Gruppe auf einer
|
||||||
|
-- Hostgruppe gewaehrt werden ("Verbindungen mit einer Gruppe teilen"),
|
||||||
|
-- statt jedes Mitglied einzeln ueber user_hostgroup_roles pflegen zu
|
||||||
|
-- muessen. Volle Rollen-Vererbung: jedes aktuelle und zukuenftige
|
||||||
|
-- Mitglied der Gruppe erhaelt automatisch alle der Gruppe gewaehrten
|
||||||
|
-- Rollen (Durchsetzung in app/rbac.py: user_has_role() vereinigt direkte
|
||||||
|
-- User-Grants mit Grants ueber Gruppenmitgliedschaft).
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_groups (
|
||||||
|
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'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_group_members (
|
||||||
|
user_group_id INTEGER NOT NULL REFERENCES user_groups(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
added_by INTEGER REFERENCES users(id),
|
||||||
|
added_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||||
|
PRIMARY KEY (user_group_id, user_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ugm_user ON user_group_members(user_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS group_hostgroup_roles (
|
||||||
|
user_group_id INTEGER NOT NULL REFERENCES user_groups(id) ON DELETE CASCADE,
|
||||||
|
host_group_id INTEGER NOT NULL REFERENCES host_groups(id) ON DELETE CASCADE,
|
||||||
|
role_id INTEGER NOT NULL REFERENCES roles(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, host_group_id, role_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ghr_group ON group_hostgroup_roles(user_group_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ghr_hostgroup ON group_hostgroup_roles(host_group_id);
|
||||||
26
app/db/migrations/0005_api_tokens.sql
Normal file
26
app/db/migrations/0005_api_tokens.sql
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
-- API-Tokens mit granularem Scope (ro/rw je Ressourcengruppe) fuer
|
||||||
|
-- programmatischen Zugriff auf die Admin-/Verwaltungs-API, unabhaengig von
|
||||||
|
-- der Browser-Session. Bewusst NICHT nutzbar fuer SSH/RDP/SFTP-Sitzungen
|
||||||
|
-- selbst (Terminal-/RDP-WebSocket und Dateitransfer bleiben ausschliesslich
|
||||||
|
-- an eine eingeloggte, TOTP-verifizierte Session gebunden -- siehe
|
||||||
|
-- app/auth/deps.py) und NICHT nutzbar, um weitere Tokens zu verwalten
|
||||||
|
-- (Privilege-Escalation-Schutz: Token-Verwaltung selbst bleibt reine
|
||||||
|
-- Session-Admin-Aktion).
|
||||||
|
--
|
||||||
|
-- Nur der Hash des Tokens wird gespeichert (siehe app/security/api_tokens.py,
|
||||||
|
-- analog zu Recovery-Codes); das Klartext-Token wird nur einmalig bei der
|
||||||
|
-- Erzeugung angezeigt und ist danach nicht mehr rekonstruierbar.
|
||||||
|
CREATE TABLE IF NOT EXISTS api_tokens (
|
||||||
|
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
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON api_tokens(user_id);
|
||||||
35
app/main.py
35
app/main.py
@ -7,13 +7,14 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import Depends, FastAPI, Request
|
||||||
|
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
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
from app.admin.routes import router as admin_router
|
from app.admin.routes import router as admin_router
|
||||||
from app.auth.deps import get_current_user
|
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.db import close_db, init_db
|
||||||
@ -84,6 +85,15 @@ async def dashboard(request: Request):
|
|||||||
return templates.TemplateResponse(request, "dashboard.html", {})
|
return templates.TemplateResponse(request, "dashboard.html", {})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/admin", response_class=HTMLResponse)
|
||||||
|
async def admin_page(request: Request):
|
||||||
|
# Wie /dashboard: Seite selbst ist statisches Markup ohne Secrets, die
|
||||||
|
# Admin-Pruefung (is_admin) erfolgt clientseitig ueber GET /auth/me
|
||||||
|
# (Redirect zu /dashboard falls kein Admin) UND serverseitig hart auf
|
||||||
|
# jedem einzelnen /admin/*-API-Aufruf (require_admin_or_scope).
|
||||||
|
return templates.TemplateResponse(request, "admin.html", {})
|
||||||
|
|
||||||
|
|
||||||
@app.get("/terminal/{host_id}", response_class=HTMLResponse)
|
@app.get("/terminal/{host_id}", response_class=HTMLResponse)
|
||||||
async def terminal_page(request: Request, host_id: int):
|
async def terminal_page(request: Request, host_id: int):
|
||||||
return templates.TemplateResponse(request, "terminal.html", {"host_id": host_id})
|
return templates.TemplateResponse(request, "terminal.html", {"host_id": host_id})
|
||||||
@ -97,3 +107,24 @@ async def rdp_page(request: Request, host_id: int):
|
|||||||
@app.get("/healthz")
|
@app.get("/healthz")
|
||||||
async def healthz():
|
async def healthz():
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
# --- API-Dokumentation --------------------------------------------------------
|
||||||
|
#
|
||||||
|
# docs_url/redoc_url/openapi_url sind am FastAPI()-Konstruktor bewusst
|
||||||
|
# deaktiviert (siehe oben) -- eine oeffentlich erreichbare API-Uebersicht
|
||||||
|
# waere auf einem oeffentlich exponierten Jumphost unnoetige Informations-
|
||||||
|
# preisgabe (Konzept 6.6). Stattdessen: eigene, auf eingeloggte Admins
|
||||||
|
# beschraenkte Routen. Bewusst KEIN vendored/CDN-bezogenes Swagger-UI-Bundle
|
||||||
|
# (Konzept 4.1/6.6: kein Laufzeit-CDN-Bezug im Browser) -- stattdessen eine
|
||||||
|
# schlanke selbstgebaute Ansicht (templates/api_docs.html +
|
||||||
|
# static/js/api-docs.js), die /openapi.json clientseitig ausliest.
|
||||||
|
|
||||||
|
@app.get("/openapi.json", include_in_schema=False)
|
||||||
|
async def protected_openapi_schema(admin: CurrentUser = Depends(require_global_admin)):
|
||||||
|
return get_openapi(title=app.title, version=app.version, routes=app.routes)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/docs", response_class=HTMLResponse, include_in_schema=False)
|
||||||
|
async def protected_api_docs(request: Request, admin: CurrentUser = Depends(require_global_admin)):
|
||||||
|
return templates.TemplateResponse(request, "api_docs.html", {})
|
||||||
|
|||||||
@ -93,6 +93,32 @@ class RoleGrantRequest(BaseModel):
|
|||||||
expires_at: str | None = None
|
expires_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class UserGroupCreateRequest(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=128)
|
||||||
|
description: str | None = Field(default=None, max_length=1024)
|
||||||
|
|
||||||
|
|
||||||
|
class GroupMemberRequest(BaseModel):
|
||||||
|
user_id: int
|
||||||
|
|
||||||
|
|
||||||
|
class GroupRoleGrantRequest(BaseModel):
|
||||||
|
user_group_id: int
|
||||||
|
host_group_id: int
|
||||||
|
role_name: Literal[
|
||||||
|
"ssh_connect", "rdp_connect", "file_transfer", "clipboard",
|
||||||
|
"session_recording_view", "admin_hostgroup",
|
||||||
|
]
|
||||||
|
expires_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ApiTokenCreateRequest(BaseModel):
|
||||||
|
label: str = Field(min_length=1, max_length=128)
|
||||||
|
user_id: int
|
||||||
|
scopes: list[str] = Field(min_length=1, max_length=32)
|
||||||
|
expires_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class SshKeyCreateRequest(BaseModel):
|
class SshKeyCreateRequest(BaseModel):
|
||||||
label: str = Field(min_length=1, max_length=128)
|
label: str = Field(min_length=1, max_length=128)
|
||||||
owner_user_id: int | None = None
|
owner_user_id: int | None = None
|
||||||
|
|||||||
18
app/rbac.py
18
app/rbac.py
@ -1,4 +1,10 @@
|
|||||||
"""RBAC-Durchsetzung: Rolle × Hostgruppe (siehe Konzept 4.6)."""
|
"""RBAC-Durchsetzung: Rolle × Hostgruppe (siehe Konzept 4.6).
|
||||||
|
|
||||||
|
Eine Rolle gilt fuer einen User entweder, wenn sie ihm DIREKT vergeben wurde
|
||||||
|
(user_hostgroup_roles), ODER wenn sie einer Benutzergruppe (user_groups)
|
||||||
|
vergeben wurde, in der der User Mitglied ist (group_hostgroup_roles) --
|
||||||
|
volle Rollen-Vererbung: jedes Gruppenmitglied erhaelt automatisch alle der
|
||||||
|
Gruppe gewaehrten Rollen, ohne individuellen Eintrag."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
@ -15,9 +21,17 @@ async def user_has_role(
|
|||||||
AND uhr.host_group_id = ?
|
AND uhr.host_group_id = ?
|
||||||
AND r.name = ?
|
AND r.name = ?
|
||||||
AND (uhr.expires_at IS NULL OR uhr.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
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
|
||||||
|
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 = ?
|
||||||
|
AND r.name = ?
|
||||||
|
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
|
||||||
|
|||||||
65
app/security/api_tokens.py
Normal file
65
app/security/api_tokens.py
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
"""
|
||||||
|
API-Tokens mit granularem Scope (siehe app/db/migrations/0005_api_tokens.sql).
|
||||||
|
|
||||||
|
Format: "jht_<43 zeichen urlsicherer Zufalls-String>" (secrets.token_urlsafe(32)).
|
||||||
|
Wie Recovery-Codes (app/security/totp.py) wird nur ein gepfefferter SHA-256-Hash
|
||||||
|
gespeichert -- das Klartext-Token existiert nur einmalig im Response-Body der
|
||||||
|
Erzeugung und ist danach serverseitig nicht mehr rekonstruierbar.
|
||||||
|
|
||||||
|
Scope-Modell: "<ressource>:<aktion>" mit aktion in {read, write}. "write"
|
||||||
|
impliziert automatisch "read" auf derselben Ressource (siehe token_has_scope).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
TOKEN_PREFIX = "jht_"
|
||||||
|
_TOKEN_PEPPER = b"api_token_pepper"
|
||||||
|
|
||||||
|
SCOPE_RESOURCES = (
|
||||||
|
"users",
|
||||||
|
"host_groups",
|
||||||
|
"hosts",
|
||||||
|
"ssh_keys",
|
||||||
|
"roles",
|
||||||
|
"user_groups",
|
||||||
|
"audit",
|
||||||
|
)
|
||||||
|
SCOPE_ACTIONS = ("read", "write")
|
||||||
|
|
||||||
|
# Alle gueltigen "<ressource>:<aktion>"-Strings, z.B. "hosts:read".
|
||||||
|
VALID_SCOPES = frozenset(
|
||||||
|
f"{resource}:{action}" for resource in SCOPE_RESOURCES for action in SCOPE_ACTIONS
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_token() -> str:
|
||||||
|
return TOKEN_PREFIX + secrets.token_urlsafe(32)
|
||||||
|
|
||||||
|
|
||||||
|
def hash_token(token: str) -> str:
|
||||||
|
return hashlib.sha256(token.encode() + _TOKEN_PEPPER).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def token_prefix_for_display(token: str) -> str:
|
||||||
|
"""Kurzer, nicht-geheimer Praefix zur Wiedererkennung in der UI (kein
|
||||||
|
Rueckschluss auf das volle Token moeglich, siehe Recovery-Code-Analogie)."""
|
||||||
|
return token[: len(TOKEN_PREFIX) + 8]
|
||||||
|
|
||||||
|
|
||||||
|
def validate_scopes(scopes: list[str]) -> list[str]:
|
||||||
|
"""Wirft ValueError bei unbekannten Scopes, sonst normalisierte, deduplizierte Liste."""
|
||||||
|
unknown = sorted(set(scopes) - VALID_SCOPES)
|
||||||
|
if unknown:
|
||||||
|
raise ValueError(f"Unbekannte Scope(s): {', '.join(unknown)}")
|
||||||
|
return sorted(set(scopes))
|
||||||
|
|
||||||
|
|
||||||
|
def token_has_scope(granted_scopes: list[str], resource: str, action: str) -> bool:
|
||||||
|
"""'write' schliesst 'read' auf derselben Ressource automatisch mit ein."""
|
||||||
|
if f"{resource}:{action}" in granted_scopes:
|
||||||
|
return True
|
||||||
|
if action == "read" and f"{resource}:write" in granted_scopes:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
@ -31,7 +31,7 @@ body {
|
|||||||
}
|
}
|
||||||
.card h1 { font-size: 1.25rem; margin: 0 0 1.25rem; }
|
.card h1 { font-size: 1.25rem; margin: 0 0 1.25rem; }
|
||||||
label { display: block; font-size: 0.85rem; color: var(--muted); margin: 0.75rem 0 0.25rem; }
|
label { display: block; font-size: 0.85rem; color: var(--muted); margin: 0.75rem 0 0.25rem; }
|
||||||
input[type=text], input[type=password] {
|
input[type=text], input[type=password], select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.6rem 0.7rem;
|
padding: 0.6rem 0.7rem;
|
||||||
background: #0f1218;
|
background: #0f1218;
|
||||||
@ -40,6 +40,7 @@ input[type=text], input[type=password] {
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
}
|
}
|
||||||
|
select { appearance: auto; }
|
||||||
button {
|
button {
|
||||||
margin-top: 1.25rem;
|
margin-top: 1.25rem;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@ -69,7 +70,10 @@ button:hover { filter: brightness(1.1); }
|
|||||||
}
|
}
|
||||||
.topbar .brand { font-weight: 700; }
|
.topbar .brand { font-weight: 700; }
|
||||||
.topbar button { width: auto; margin: 0; padding: 0.4rem 0.9rem; font-size: 0.85rem; }
|
.topbar button { width: auto; margin: 0; padding: 0.4rem 0.9rem; font-size: 0.85rem; }
|
||||||
|
.topbar a { color: var(--accent); text-decoration: none; font-size: 0.85rem; margin-right: 1rem; }
|
||||||
|
.topbar a:hover { text-decoration: underline; }
|
||||||
.container { padding: 1.25rem; max-width: 960px; margin: 0 auto; }
|
.container { padding: 1.25rem; max-width: 960px; margin: 0 auto; }
|
||||||
|
.container.wide { max-width: 1200px; }
|
||||||
.group { margin-bottom: 1.5rem; }
|
.group { margin-bottom: 1.5rem; }
|
||||||
.group h2 { font-size: 1rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; }
|
.group h2 { font-size: 1rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; }
|
||||||
.host-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 0.75rem; }
|
.host-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 0.75rem; }
|
||||||
@ -84,6 +88,100 @@ button:hover { filter: brightness(1.1); }
|
|||||||
background: var(--accent); color: white; font-size: 0.85rem; margin: 0;
|
background: var(--accent); color: white; font-size: 0.85rem; margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Admin-Oberflaeche ------------------------------------------------- */
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex; flex-wrap: wrap; gap: 0.25rem; margin-bottom: 1.25rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.tab-btn {
|
||||||
|
width: auto; margin: 0 0 -1px; padding: 0.6rem 1rem; background: transparent;
|
||||||
|
border: none; border-bottom: 2px solid transparent; border-radius: 0;
|
||||||
|
color: var(--muted); font-weight: 600; font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.tab-btn:hover { filter: none; color: var(--text); }
|
||||||
|
.tab-btn.active { color: var(--text); border-bottom-color: var(--accent); }
|
||||||
|
.tab-panel { display: block; }
|
||||||
|
.tab-panel.hidden { display: none !important; }
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: var(--panel); border: 1px solid var(--border); border-radius: 8px;
|
||||||
|
padding: 1.25rem; margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.panel h2 { font-size: 1rem; margin: 0 0 1rem; }
|
||||||
|
.panel h3 { font-size: 0.9rem; color: var(--muted); margin: 1.5rem 0 0.75rem; }
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 0.75rem 1rem;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
.form-grid label { margin: 0 0 0.25rem; }
|
||||||
|
.form-grid .full-width { grid-column: 1 / -1; }
|
||||||
|
.form-grid button { margin-top: 0; grid-column: 1 / -1; width: auto; padding: 0.55rem 1.25rem; justify-self: start; }
|
||||||
|
textarea {
|
||||||
|
width: 100%; min-height: 5rem; padding: 0.6rem 0.7rem; background: #0f1218;
|
||||||
|
border: 1px solid var(--border); border-radius: 6px; color: var(--text);
|
||||||
|
font-family: monospace; font-size: 0.85rem; resize: vertical;
|
||||||
|
}
|
||||||
|
.checkbox-row { display: flex; align-items: center; gap: 0.4rem; margin-top: 0.75rem; }
|
||||||
|
.checkbox-row input[type=checkbox] { width: auto; margin: 0; }
|
||||||
|
.checkbox-row label { margin: 0; color: var(--text); font-size: 0.9rem; }
|
||||||
|
|
||||||
|
.btn-secondary { background: var(--border); }
|
||||||
|
.btn-danger { background: var(--danger); }
|
||||||
|
.btn-small { width: auto; margin: 0; padding: 0.35rem 0.75rem; font-size: 0.8rem; }
|
||||||
|
|
||||||
|
.data-table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
|
||||||
|
.data-table th, .data-table td {
|
||||||
|
text-align: left; padding: 0.5rem 0.6rem; border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.data-table th { color: var(--muted); font-weight: 600; font-size: 0.75rem; text-transform: uppercase; }
|
||||||
|
.data-table tr:last-child td { border-bottom: none; }
|
||||||
|
.table-wrap { overflow-x: auto; }
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block; padding: 0.15rem 0.5rem; border-radius: 999px; font-size: 0.75rem;
|
||||||
|
background: var(--border); color: var(--text); margin: 0 0.25rem 0.25rem 0;
|
||||||
|
}
|
||||||
|
.badge.ok { background: var(--ok); color: #06210f; }
|
||||||
|
.badge.danger { background: var(--danger); color: #2a0505; }
|
||||||
|
|
||||||
|
.banner {
|
||||||
|
padding: 0.6rem 0.9rem; border-radius: 6px; font-size: 0.85rem; margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.banner.ok { background: rgba(34, 197, 94, 0.15); color: var(--ok); border: 1px solid var(--ok); }
|
||||||
|
.banner.error { background: rgba(239, 68, 68, 0.15); color: var(--danger); border: 1px solid var(--danger); }
|
||||||
|
|
||||||
|
.reveal-box {
|
||||||
|
font-family: monospace; background: #0f1218; border: 1px solid var(--ok); padding: 0.75rem;
|
||||||
|
border-radius: 6px; word-break: break-all; margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
.scope-grid {
|
||||||
|
display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 0.4rem 1rem;
|
||||||
|
margin: 0.5rem 0 1rem;
|
||||||
|
}
|
||||||
|
.scope-grid .checkbox-row { margin-top: 0; }
|
||||||
|
|
||||||
|
pre.json-view {
|
||||||
|
background: #0f1218; border: 1px solid var(--border); border-radius: 6px; padding: 0.75rem;
|
||||||
|
font-size: 0.8rem; overflow-x: auto; white-space: pre-wrap; word-break: break-word;
|
||||||
|
}
|
||||||
|
.endpoint-row {
|
||||||
|
display: flex; align-items: center; gap: 0.6rem; padding: 0.5rem 0; cursor: pointer;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.endpoint-row .method {
|
||||||
|
font-family: monospace; font-weight: 700; font-size: 0.75rem; padding: 0.15rem 0.4rem;
|
||||||
|
border-radius: 4px; width: 3.5rem; text-align: center; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.endpoint-row .method.get { background: rgba(59, 130, 246, 0.2); color: #60a5fa; }
|
||||||
|
.endpoint-row .method.post { background: rgba(34, 197, 94, 0.2); color: #4ade80; }
|
||||||
|
.endpoint-row .method.put { background: rgba(234, 179, 8, 0.2); color: #facc15; }
|
||||||
|
.endpoint-row .method.delete { background: rgba(239, 68, 68, 0.2); color: #f87171; }
|
||||||
|
.endpoint-row .path { font-family: monospace; font-size: 0.85rem; }
|
||||||
|
.endpoint-row .summary { color: var(--muted); font-size: 0.8rem; margin-left: auto; }
|
||||||
|
.endpoint-detail { padding: 0 0 0.75rem 4.1rem; }
|
||||||
|
|
||||||
.session-shell { display: flex; flex-direction: column; height: 100vh; }
|
.session-shell { display: flex; flex-direction: column; height: 100vh; }
|
||||||
.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;
|
||||||
|
|||||||
723
static/js/admin.js
Normal file
723
static/js/admin.js
Normal file
@ -0,0 +1,723 @@
|
|||||||
|
(() => {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const ROLE_NAMES = [
|
||||||
|
"ssh_connect", "rdp_connect", "file_transfer", "clipboard",
|
||||||
|
"session_recording_view", "admin_hostgroup",
|
||||||
|
];
|
||||||
|
|
||||||
|
const bannerBox = document.getElementById("banner-box");
|
||||||
|
|
||||||
|
function showBanner(message, type) {
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.className = `banner ${type === "error" ? "error" : "ok"}`;
|
||||||
|
div.textContent = message;
|
||||||
|
bannerBox.replaceChildren(div);
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (bannerBox.contains(div)) bannerBox.removeChild(div);
|
||||||
|
}, 8000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiFetch(url, options = {}) {
|
||||||
|
const res = await fetch(url, { credentials: "same-origin", ...options });
|
||||||
|
if (res.status === 401) {
|
||||||
|
window.location.href = "/";
|
||||||
|
throw new Error("nicht angemeldet");
|
||||||
|
}
|
||||||
|
let data = null;
|
||||||
|
try {
|
||||||
|
data = await res.json();
|
||||||
|
} catch (_err) {
|
||||||
|
data = null;
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const detail = (data && data.detail) || `Fehler ${res.status}`;
|
||||||
|
throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail));
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getJson(url) {
|
||||||
|
return apiFetch(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendJson(url, method, body) {
|
||||||
|
return apiFetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function el(tag, props = {}, children = []) {
|
||||||
|
const node = document.createElement(tag);
|
||||||
|
for (const [k, v] of Object.entries(props)) {
|
||||||
|
if (k === "className") node.className = v;
|
||||||
|
else if (k === "textContent") node.textContent = v;
|
||||||
|
else node.setAttribute(k, v);
|
||||||
|
}
|
||||||
|
for (const child of children) node.appendChild(child);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionButton(label, className, handler) {
|
||||||
|
const btn = el("button", { type: "button", className: `btn-small ${className}` });
|
||||||
|
btn.textContent = label;
|
||||||
|
btn.addEventListener("click", handler);
|
||||||
|
return btn;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillTable(tbody, rows) {
|
||||||
|
tbody.replaceChildren(...rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionsFromList(items, valueKey, labelFn) {
|
||||||
|
return items.map((item) => {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = item[valueKey];
|
||||||
|
opt.textContent = labelFn(item);
|
||||||
|
return opt;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Tabs
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
const tabLoaders = {
|
||||||
|
users: loadUsersTab,
|
||||||
|
groups: loadGroupsTab,
|
||||||
|
hosts: loadHostsTab,
|
||||||
|
roles: loadRolesTab,
|
||||||
|
tokens: loadTokensTab,
|
||||||
|
audit: loadAuditTab,
|
||||||
|
};
|
||||||
|
const loadedTabs = new Set();
|
||||||
|
|
||||||
|
document.getElementById("tabs").addEventListener("click", (ev) => {
|
||||||
|
const btn = ev.target.closest(".tab-btn");
|
||||||
|
if (!btn) return;
|
||||||
|
const tab = btn.dataset.tab;
|
||||||
|
document.querySelectorAll(".tab-btn").forEach((b) => b.classList.toggle("active", b === btn));
|
||||||
|
document.querySelectorAll(".tab-panel").forEach((panel) => {
|
||||||
|
panel.classList.toggle("hidden", panel.id !== `tab-${tab}`);
|
||||||
|
});
|
||||||
|
if (!loadedTabs.has(tab)) {
|
||||||
|
loadedTabs.add(tab);
|
||||||
|
tabLoaders[tab]().catch((err) => showBanner(err.message, "error"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Benutzer
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function loadUsersTab() {
|
||||||
|
await refreshUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedUsers = [];
|
||||||
|
|
||||||
|
async function refreshUsers() {
|
||||||
|
cachedUsers = await getJson("/admin/users");
|
||||||
|
const tbody = document.querySelector("#users-table tbody");
|
||||||
|
fillTable(
|
||||||
|
tbody,
|
||||||
|
cachedUsers.map((u) => {
|
||||||
|
const actions = el("td", {});
|
||||||
|
if (u.is_active) {
|
||||||
|
actions.appendChild(
|
||||||
|
actionButton("Deaktivieren", "btn-danger", async () => {
|
||||||
|
await sendJson(`/admin/users/${u.id}/deactivate`, "POST", {});
|
||||||
|
showBanner(`Benutzer '${u.username}' deaktiviert.`, "ok");
|
||||||
|
await refreshUsers();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return el("tr", {}, [
|
||||||
|
el("td", { textContent: String(u.id) }),
|
||||||
|
el("td", { textContent: u.username }),
|
||||||
|
el("td", { textContent: u.is_admin ? "ja" : "nein" }),
|
||||||
|
el("td", { textContent: u.is_active ? "ja" : "nein" }),
|
||||||
|
el("td", { textContent: u.totp_enrolled ? "ja" : "nein" }),
|
||||||
|
el("td", { textContent: u.created_at }),
|
||||||
|
actions,
|
||||||
|
]);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("user-create-form").addEventListener("submit", async (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
try {
|
||||||
|
const username = document.getElementById("uc-username").value.trim();
|
||||||
|
const password = document.getElementById("uc-password").value;
|
||||||
|
const isAdmin = document.getElementById("uc-is-admin").checked;
|
||||||
|
await sendJson("/admin/users", "POST", {
|
||||||
|
username, initial_password: password, is_admin: isAdmin,
|
||||||
|
});
|
||||||
|
showBanner(`Benutzer '${username}' angelegt.`, "ok");
|
||||||
|
ev.target.reset();
|
||||||
|
await refreshUsers();
|
||||||
|
await refreshSharedSelects();
|
||||||
|
} catch (err) {
|
||||||
|
showBanner(err.message, "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Benutzergruppen
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
let cachedGroups = [];
|
||||||
|
let activeGroupId = null;
|
||||||
|
|
||||||
|
async function loadGroupsTab() {
|
||||||
|
await refreshGroups();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshGroups() {
|
||||||
|
cachedGroups = await getJson("/admin/user-groups");
|
||||||
|
const tbody = document.querySelector("#groups-table tbody");
|
||||||
|
fillTable(
|
||||||
|
tbody,
|
||||||
|
cachedGroups.map((g) =>
|
||||||
|
el("tr", {}, [
|
||||||
|
el("td", { textContent: String(g.id) }),
|
||||||
|
el("td", { textContent: g.name }),
|
||||||
|
el("td", { textContent: g.description || "" }),
|
||||||
|
el("td", { textContent: String(g.member_count) }),
|
||||||
|
el("td", {}, [
|
||||||
|
actionButton("Mitglieder", "btn-secondary", () => showGroupMembers(g)),
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showGroupMembers(group) {
|
||||||
|
activeGroupId = group.id;
|
||||||
|
document.getElementById("group-members-panel").classList.remove("hidden");
|
||||||
|
document.getElementById("group-members-name").textContent = group.name;
|
||||||
|
await refreshGroupMembers();
|
||||||
|
const select = document.getElementById("gm-user-select");
|
||||||
|
select.replaceChildren(...optionsFromList(cachedUsers, "id", (u) => `${u.username} (#${u.id})`));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshGroupMembers() {
|
||||||
|
if (activeGroupId === null) return;
|
||||||
|
const members = await getJson(`/admin/user-groups/${activeGroupId}/members`);
|
||||||
|
const tbody = document.querySelector("#group-members-table tbody");
|
||||||
|
fillTable(
|
||||||
|
tbody,
|
||||||
|
members.map((m) =>
|
||||||
|
el("tr", {}, [
|
||||||
|
el("td", { textContent: String(m.user_id) }),
|
||||||
|
el("td", { textContent: m.username }),
|
||||||
|
el("td", { textContent: m.added_at }),
|
||||||
|
el("td", {}, [
|
||||||
|
actionButton("Entfernen", "btn-danger", async () => {
|
||||||
|
await apiFetch(`/admin/user-groups/${activeGroupId}/members/${m.user_id}`, { method: "DELETE" });
|
||||||
|
await refreshGroupMembers();
|
||||||
|
await refreshGroups();
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("group-create-form").addEventListener("submit", async (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
try {
|
||||||
|
const name = document.getElementById("gc-name").value.trim();
|
||||||
|
const description = document.getElementById("gc-description").value.trim() || null;
|
||||||
|
await sendJson("/admin/user-groups", "POST", { name, description });
|
||||||
|
showBanner(`Benutzergruppe '${name}' angelegt.`, "ok");
|
||||||
|
ev.target.reset();
|
||||||
|
await refreshGroups();
|
||||||
|
await refreshSharedSelects();
|
||||||
|
} catch (err) {
|
||||||
|
showBanner(err.message, "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("group-member-add-form").addEventListener("submit", async (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
try {
|
||||||
|
const userId = Number(document.getElementById("gm-user-select").value);
|
||||||
|
await sendJson(`/admin/user-groups/${activeGroupId}/members`, "POST", { user_id: userId });
|
||||||
|
showBanner("Mitglied hinzugefuegt.", "ok");
|
||||||
|
await refreshGroupMembers();
|
||||||
|
await refreshGroups();
|
||||||
|
} catch (err) {
|
||||||
|
showBanner(err.message, "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Hosts & Verbindungen
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
let cachedHostGroups = [];
|
||||||
|
let cachedHosts = [];
|
||||||
|
let cachedSshKeys = [];
|
||||||
|
let activeHostId = null;
|
||||||
|
|
||||||
|
async function loadHostsTab() {
|
||||||
|
await Promise.all([refreshHostGroups(), refreshSshKeys()]);
|
||||||
|
await refreshHosts();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshHostGroups() {
|
||||||
|
cachedHostGroups = await getJson("/admin/host-groups");
|
||||||
|
const tbody = document.querySelector("#hostgroups-table tbody");
|
||||||
|
fillTable(
|
||||||
|
tbody,
|
||||||
|
cachedHostGroups.map((g) =>
|
||||||
|
el("tr", {}, [
|
||||||
|
el("td", { textContent: String(g.id) }),
|
||||||
|
el("td", { textContent: g.name }),
|
||||||
|
el("td", { textContent: g.description || "" }),
|
||||||
|
])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const hcSelect = document.getElementById("hc-hostgroup");
|
||||||
|
hcSelect.replaceChildren(...optionsFromList(cachedHostGroups, "id", (g) => `${g.name} (#${g.id})`));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshHosts() {
|
||||||
|
cachedHosts = await getJson("/admin/hosts");
|
||||||
|
const groupName = (id) => (cachedHostGroups.find((g) => g.id === id) || {}).name || `#${id}`;
|
||||||
|
const tbody = document.querySelector("#hosts-table tbody");
|
||||||
|
fillTable(
|
||||||
|
tbody,
|
||||||
|
cachedHosts.map((h) =>
|
||||||
|
el("tr", {}, [
|
||||||
|
el("td", { textContent: String(h.id) }),
|
||||||
|
el("td", { textContent: h.hostname }),
|
||||||
|
el("td", { textContent: h.address }),
|
||||||
|
el("td", { textContent: h.protocol }),
|
||||||
|
el("td", { textContent: h.os_type }),
|
||||||
|
el("td", { textContent: groupName(h.host_group_id) }),
|
||||||
|
el("td", {}, [actionButton("Details", "btn-secondary", () => showHostDetail(h))]),
|
||||||
|
])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshSshKeys() {
|
||||||
|
cachedSshKeys = await getJson("/admin/ssh-keys");
|
||||||
|
const tbody = document.querySelector("#ssh-keys-table tbody");
|
||||||
|
fillTable(
|
||||||
|
tbody,
|
||||||
|
cachedSshKeys.map((k) =>
|
||||||
|
el("tr", {}, [
|
||||||
|
el("td", { textContent: String(k.id) }),
|
||||||
|
el("td", { textContent: k.label }),
|
||||||
|
el("td", { textContent: k.key_type }),
|
||||||
|
el("td", { textContent: k.owner_user_id === null ? "-" : String(k.owner_user_id) }),
|
||||||
|
el("td", { textContent: k.created_at }),
|
||||||
|
])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showHostDetail(host) {
|
||||||
|
activeHostId = host.id;
|
||||||
|
document.getElementById("host-detail-panel").classList.remove("hidden");
|
||||||
|
document.getElementById("host-detail-name").textContent = `${host.hostname} (#${host.id})`;
|
||||||
|
document.getElementById("host-detail-ssh").classList.toggle("hidden", host.protocol !== "ssh");
|
||||||
|
document.getElementById("host-detail-rdp").classList.toggle("hidden", host.protocol !== "rdp");
|
||||||
|
document.getElementById("host-key-result").classList.add("hidden");
|
||||||
|
const keySelect = document.getElementById("hkm-key-select");
|
||||||
|
keySelect.replaceChildren(...optionsFromList(cachedSshKeys, "id", (k) => `${k.label} (#${k.id})`));
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("hc-protocol").addEventListener("change", (ev) => {
|
||||||
|
const isRdp = ev.target.value === "rdp";
|
||||||
|
document.getElementById("hc-ssh-username-box").classList.toggle("hidden", isRdp);
|
||||||
|
document.getElementById("hc-rdp-username-box").classList.toggle("hidden", !isRdp);
|
||||||
|
document.getElementById("hc-rdp-domain-box").classList.toggle("hidden", !isRdp);
|
||||||
|
document.getElementById("hc-nla-box").classList.toggle("hidden", !isRdp);
|
||||||
|
document.getElementById("hc-port").value = isRdp ? "3389" : "22";
|
||||||
|
document.getElementById("hc-os-type").value = isRdp ? "windows" : "linux";
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("hostgroup-create-form").addEventListener("submit", async (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
try {
|
||||||
|
const name = document.getElementById("hgc-name").value.trim();
|
||||||
|
const description = document.getElementById("hgc-description").value.trim() || null;
|
||||||
|
await sendJson("/admin/host-groups", "POST", { name, description });
|
||||||
|
showBanner(`Hostgruppe '${name}' angelegt.`, "ok");
|
||||||
|
ev.target.reset();
|
||||||
|
await refreshHostGroups();
|
||||||
|
await refreshSharedSelects();
|
||||||
|
} catch (err) {
|
||||||
|
showBanner(err.message, "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("host-create-form").addEventListener("submit", async (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
try {
|
||||||
|
const protocol = document.getElementById("hc-protocol").value;
|
||||||
|
const payload = {
|
||||||
|
host_group_id: Number(document.getElementById("hc-hostgroup").value),
|
||||||
|
hostname: document.getElementById("hc-hostname").value.trim(),
|
||||||
|
address: document.getElementById("hc-address").value.trim(),
|
||||||
|
protocol,
|
||||||
|
port: Number(document.getElementById("hc-port").value),
|
||||||
|
os_type: document.getElementById("hc-os-type").value,
|
||||||
|
ssh_username: document.getElementById("hc-ssh-username").value.trim() || null,
|
||||||
|
rdp_username: document.getElementById("hc-rdp-username").value.trim() || null,
|
||||||
|
rdp_domain: document.getElementById("hc-rdp-domain").value.trim() || null,
|
||||||
|
rdp_require_nla: document.getElementById("hc-nla").checked,
|
||||||
|
clipboard_enabled: document.getElementById("hc-clipboard").checked,
|
||||||
|
file_transfer_enabled: document.getElementById("hc-filetransfer").checked,
|
||||||
|
};
|
||||||
|
await sendJson("/admin/hosts", "POST", payload);
|
||||||
|
showBanner(`Verbindung '${payload.hostname}' angelegt.`, "ok");
|
||||||
|
ev.target.reset();
|
||||||
|
document.getElementById("hc-protocol").dispatchEvent(new Event("change"));
|
||||||
|
await refreshHosts();
|
||||||
|
await refreshSharedSelects();
|
||||||
|
} catch (err) {
|
||||||
|
showBanner(err.message, "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("host-discover-key-btn").addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
const result = await sendJson(`/admin/hosts/${activeHostId}/discover-host-key`, "POST", {});
|
||||||
|
const box = document.getElementById("host-key-result");
|
||||||
|
box.textContent = `Fingerprint: ${result.fingerprint}`;
|
||||||
|
box.classList.remove("hidden");
|
||||||
|
showBanner("Host-Key ermittelt und gespeichert.", "ok");
|
||||||
|
} catch (err) {
|
||||||
|
showBanner(err.message, "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("host-key-map-form").addEventListener("submit", async (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
try {
|
||||||
|
const keyId = document.getElementById("hkm-key-select").value;
|
||||||
|
await sendJson(`/admin/hosts/${activeHostId}/ssh-keys/${keyId}`, "POST", {});
|
||||||
|
showBanner("SSH-Key zugeordnet.", "ok");
|
||||||
|
} catch (err) {
|
||||||
|
showBanner(err.message, "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("host-rdp-cred-form").addEventListener("submit", async (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
try {
|
||||||
|
const password = document.getElementById("hrc-password").value;
|
||||||
|
await sendJson(`/admin/hosts/${activeHostId}/rdp-credentials`, "PUT", { password });
|
||||||
|
showBanner("RDP-Zugangsdaten gespeichert.", "ok");
|
||||||
|
ev.target.reset();
|
||||||
|
} catch (err) {
|
||||||
|
showBanner(err.message, "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("ssh-key-create-form").addEventListener("submit", async (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
try {
|
||||||
|
const ownerRaw = document.getElementById("skc-owner").value.trim();
|
||||||
|
const payload = {
|
||||||
|
label: document.getElementById("skc-label").value.trim(),
|
||||||
|
owner_user_id: ownerRaw ? Number(ownerRaw) : null,
|
||||||
|
key_type: document.getElementById("skc-type").value,
|
||||||
|
private_key_pem: document.getElementById("skc-private").value,
|
||||||
|
public_key: document.getElementById("skc-public").value,
|
||||||
|
};
|
||||||
|
await sendJson("/admin/ssh-keys", "POST", payload);
|
||||||
|
showBanner(`SSH-Key '${payload.label}' angelegt.`, "ok");
|
||||||
|
ev.target.reset();
|
||||||
|
await refreshSshKeys();
|
||||||
|
if (activeHostId !== null) {
|
||||||
|
const keySelect = document.getElementById("hkm-key-select");
|
||||||
|
keySelect.replaceChildren(...optionsFromList(cachedSshKeys, "id", (k) => `${k.label} (#${k.id})`));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showBanner(err.message, "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Rollen
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
function roleOptions() {
|
||||||
|
return ROLE_NAMES.map((name) => {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = name;
|
||||||
|
opt.textContent = name;
|
||||||
|
return opt;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRolesTab() {
|
||||||
|
document.getElementById("rg-role").replaceChildren(...roleOptions());
|
||||||
|
document.getElementById("grg-role").replaceChildren(...roleOptions());
|
||||||
|
await refreshSharedSelects();
|
||||||
|
await Promise.all([refreshRoleGrants(), refreshGroupRoleGrants()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshRoleGrants() {
|
||||||
|
const grants = await getJson("/admin/roles");
|
||||||
|
const tbody = document.querySelector("#role-grants-table tbody");
|
||||||
|
fillTable(
|
||||||
|
tbody,
|
||||||
|
grants.map((g) =>
|
||||||
|
el("tr", {}, [
|
||||||
|
el("td", { textContent: g.username }),
|
||||||
|
el("td", { textContent: g.host_group_name }),
|
||||||
|
el("td", { textContent: g.role_name }),
|
||||||
|
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();
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshGroupRoleGrants() {
|
||||||
|
const grants = await getJson("/admin/group-roles");
|
||||||
|
const tbody = document.querySelector("#group-role-grants-table tbody");
|
||||||
|
fillTable(
|
||||||
|
tbody,
|
||||||
|
grants.map((g) =>
|
||||||
|
el("tr", {}, [
|
||||||
|
el("td", { textContent: g.user_group_name }),
|
||||||
|
el("td", { textContent: g.host_group_name }),
|
||||||
|
el("td", { textContent: g.role_name }),
|
||||||
|
el("td", { textContent: g.expires_at || "-" }),
|
||||||
|
el("td", {}, [
|
||||||
|
actionButton("Entziehen", "btn-danger", async () => {
|
||||||
|
await sendJson("/admin/group-roles/revoke", "POST", {
|
||||||
|
user_group_id: g.user_group_id, host_group_id: g.host_group_id, role_name: g.role_name,
|
||||||
|
});
|
||||||
|
await refreshGroupRoleGrants();
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("role-grant-form").addEventListener("submit", async (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
try {
|
||||||
|
await sendJson("/admin/roles/grant", "POST", {
|
||||||
|
user_id: Number(document.getElementById("rg-user").value),
|
||||||
|
host_group_id: Number(document.getElementById("rg-hostgroup").value),
|
||||||
|
role_name: document.getElementById("rg-role").value,
|
||||||
|
expires_at: document.getElementById("rg-expires").value.trim() || null,
|
||||||
|
});
|
||||||
|
showBanner("Rolle vergeben.", "ok");
|
||||||
|
await refreshRoleGrants();
|
||||||
|
} catch (err) {
|
||||||
|
showBanner(err.message, "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("group-role-grant-form").addEventListener("submit", async (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
try {
|
||||||
|
await sendJson("/admin/group-roles/grant", "POST", {
|
||||||
|
user_group_id: Number(document.getElementById("grg-group").value),
|
||||||
|
host_group_id: Number(document.getElementById("grg-hostgroup").value),
|
||||||
|
role_name: document.getElementById("grg-role").value,
|
||||||
|
expires_at: document.getElementById("grg-expires").value.trim() || null,
|
||||||
|
});
|
||||||
|
showBanner("Rolle an Gruppe vergeben.", "ok");
|
||||||
|
await refreshGroupRoleGrants();
|
||||||
|
} catch (err) {
|
||||||
|
showBanner(err.message, "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// API-Tokens
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
let cachedScopes = [];
|
||||||
|
|
||||||
|
async function loadTokensTab() {
|
||||||
|
if (cachedScopes.length === 0) {
|
||||||
|
const result = await getJson("/admin/scopes");
|
||||||
|
cachedScopes = result.scopes;
|
||||||
|
const grid = document.getElementById("tc-scope-grid");
|
||||||
|
grid.replaceChildren(
|
||||||
|
...cachedScopes.map((scope) => {
|
||||||
|
const id = `scope-${scope.replace(":", "-")}`;
|
||||||
|
const row = el("div", { className: "checkbox-row" });
|
||||||
|
const checkbox = el("input", { type: "checkbox", id, value: scope });
|
||||||
|
const label = el("label", { for: id, textContent: scope });
|
||||||
|
row.appendChild(checkbox);
|
||||||
|
row.appendChild(label);
|
||||||
|
return row;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await refreshSharedSelects();
|
||||||
|
await refreshTokens();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshTokens() {
|
||||||
|
const tokens = await getJson("/admin/tokens");
|
||||||
|
const tbody = document.querySelector("#tokens-table tbody");
|
||||||
|
fillTable(
|
||||||
|
tbody,
|
||||||
|
tokens.map((t) => {
|
||||||
|
const status = t.revoked_at ? "widerrufen" : (t.expires_at && t.expires_at < new Date().toISOString() ? "abgelaufen" : "aktiv");
|
||||||
|
const scopesCell = el("td", {});
|
||||||
|
for (const s of t.scopes) {
|
||||||
|
scopesCell.appendChild(el("span", { className: "badge", textContent: s }));
|
||||||
|
}
|
||||||
|
const actionsCell = el("td", {});
|
||||||
|
if (!t.revoked_at) {
|
||||||
|
actionsCell.appendChild(
|
||||||
|
actionButton("Widerrufen", "btn-danger", async () => {
|
||||||
|
await sendJson(`/admin/tokens/${t.id}/revoke`, "POST", {});
|
||||||
|
await refreshTokens();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return el("tr", {}, [
|
||||||
|
el("td", { textContent: String(t.id) }),
|
||||||
|
el("td", { textContent: t.label }),
|
||||||
|
el("td", { textContent: t.username }),
|
||||||
|
el("td", { textContent: t.prefix }),
|
||||||
|
scopesCell,
|
||||||
|
el("td", { textContent: t.last_used_at || "nie" }),
|
||||||
|
el("td", { textContent: status }),
|
||||||
|
actionsCell,
|
||||||
|
]);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("token-create-form").addEventListener("submit", async (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
try {
|
||||||
|
const scopes = Array.from(document.querySelectorAll("#tc-scope-grid input:checked")).map((c) => c.value);
|
||||||
|
if (scopes.length === 0) {
|
||||||
|
throw new Error("Mindestens einen Scope auswaehlen.");
|
||||||
|
}
|
||||||
|
const payload = {
|
||||||
|
label: document.getElementById("tc-label").value.trim(),
|
||||||
|
user_id: Number(document.getElementById("tc-user").value),
|
||||||
|
scopes,
|
||||||
|
expires_at: document.getElementById("tc-expires").value.trim() || null,
|
||||||
|
};
|
||||||
|
const result = await sendJson("/admin/tokens", "POST", payload);
|
||||||
|
const box = document.getElementById("token-reveal-box");
|
||||||
|
box.textContent = `Token (nur jetzt sichtbar, bitte sicher speichern): ${result.token}`;
|
||||||
|
box.classList.remove("hidden");
|
||||||
|
showBanner(`Token '${payload.label}' erstellt.`, "ok");
|
||||||
|
ev.target.reset();
|
||||||
|
document.querySelectorAll("#tc-scope-grid input:checked").forEach((c) => { c.checked = false; });
|
||||||
|
await refreshTokens();
|
||||||
|
} catch (err) {
|
||||||
|
showBanner(err.message, "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Audit-Log
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function loadAuditTab() {
|
||||||
|
await refreshAuditLog();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAuditLog() {
|
||||||
|
const entries = await getJson("/admin/audit-log?limit=200");
|
||||||
|
const tbody = document.querySelector("#audit-table tbody");
|
||||||
|
fillTable(
|
||||||
|
tbody,
|
||||||
|
entries.map((e) =>
|
||||||
|
el("tr", {}, [
|
||||||
|
el("td", { textContent: String(e.id) }),
|
||||||
|
el("td", { textContent: e.ts }),
|
||||||
|
el("td", { textContent: e.user_id === null ? "-" : String(e.user_id) }),
|
||||||
|
el("td", { textContent: e.client_ip || "-" }),
|
||||||
|
el("td", { textContent: e.event_type }),
|
||||||
|
el("td", {}, [el("pre", { className: "json-view", textContent: e.details })]),
|
||||||
|
])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("audit-verify-btn").addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
const result = await getJson("/admin/audit-log/verify");
|
||||||
|
const box = document.getElementById("audit-verify-result");
|
||||||
|
box.classList.remove("hidden");
|
||||||
|
box.className = `banner ${result.intact ? "ok" : "error"}`;
|
||||||
|
box.textContent = result.intact
|
||||||
|
? "Audit-Kette ist intakt."
|
||||||
|
: `Audit-Kette ist ab Eintrag ${result.first_broken_id} manipuliert!`;
|
||||||
|
} catch (err) {
|
||||||
|
showBanner(err.message, "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Gemeinsame Selects (User/Hostgruppen/Gruppen) auf dem aktuellen Stand halten
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function refreshSharedSelects() {
|
||||||
|
if (cachedUsers.length === 0) await refreshUsers();
|
||||||
|
if (cachedHostGroups.length === 0) await refreshHostGroups();
|
||||||
|
if (cachedGroups.length === 0) await refreshGroups();
|
||||||
|
|
||||||
|
const userOpts = () => optionsFromList(cachedUsers, "id", (u) => `${u.username} (#${u.id})`);
|
||||||
|
const hostGroupOpts = () => optionsFromList(cachedHostGroups, "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");
|
||||||
|
if (grgGroup) grgGroup.replaceChildren(...groupOpts());
|
||||||
|
const grgHostgroup = document.getElementById("grg-hostgroup");
|
||||||
|
if (grgHostgroup) grgHostgroup.replaceChildren(...hostGroupOpts());
|
||||||
|
const tcUser = document.getElementById("tc-user");
|
||||||
|
if (tcUser) tcUser.replaceChildren(...userOpts());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// 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");
|
||||||
|
if (!me.is_admin) {
|
||||||
|
window.location.href = "/dashboard";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.getElementById("whoami").textContent = `${me.username} (Admin)`;
|
||||||
|
loadedTabs.add("users");
|
||||||
|
await loadUsersTab();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => showBanner(err.message, "error"));
|
||||||
|
})();
|
||||||
76
static/js/api-docs.js
Normal file
76
static/js/api-docs.js
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
(() => {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const METHODS_ORDER = ["get", "post", "put", "patch", "delete"];
|
||||||
|
|
||||||
|
function el(tag, props = {}, children = []) {
|
||||||
|
const node = document.createElement(tag);
|
||||||
|
for (const [k, v] of Object.entries(props)) {
|
||||||
|
if (k === "className") node.className = v;
|
||||||
|
else if (k === "textContent") node.textContent = v;
|
||||||
|
else node.setAttribute(k, v);
|
||||||
|
}
|
||||||
|
for (const child of children) node.appendChild(child);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const container = document.getElementById("endpoints");
|
||||||
|
let spec;
|
||||||
|
try {
|
||||||
|
const res = await fetch("/openapi.json", { credentials: "same-origin" });
|
||||||
|
if (res.status === 401 || res.status === 403) {
|
||||||
|
window.location.href = "/";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
spec = await res.json();
|
||||||
|
} catch (err) {
|
||||||
|
container.replaceChildren(el("div", { className: "banner error", textContent: `Konnte /openapi.json nicht laden: ${err.message}` }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const byTag = {};
|
||||||
|
for (const [path, methods] of Object.entries(spec.paths || {})) {
|
||||||
|
for (const method of METHODS_ORDER) {
|
||||||
|
const op = methods[method];
|
||||||
|
if (!op) continue;
|
||||||
|
const tag = (op.tags && op.tags[0]) || "sonstige";
|
||||||
|
(byTag[tag] = byTag[tag] || []).push({ path, method, op });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const panels = Object.keys(byTag).sort().map((tag) => {
|
||||||
|
const rows = byTag[tag]
|
||||||
|
.sort((a, b) => a.path.localeCompare(b.path))
|
||||||
|
.map(({ path, method, op }) => {
|
||||||
|
const detail = el("pre", { className: "json-view hidden" });
|
||||||
|
detail.textContent = JSON.stringify(op, null, 2);
|
||||||
|
|
||||||
|
const row = el("div", { className: "endpoint-row" }, [
|
||||||
|
el("span", { className: `method ${method}`, textContent: method.toUpperCase() }),
|
||||||
|
el("span", { className: "path", textContent: path }),
|
||||||
|
el("span", { className: "summary", textContent: op.summary || "" }),
|
||||||
|
]);
|
||||||
|
row.addEventListener("click", () => detail.classList.toggle("hidden"));
|
||||||
|
|
||||||
|
const wrapper = document.createDocumentFragment();
|
||||||
|
wrapper.appendChild(row);
|
||||||
|
const detailWrap = el("div", { className: "endpoint-detail" }, [detail]);
|
||||||
|
wrapper.appendChild(detailWrap);
|
||||||
|
return wrapper;
|
||||||
|
});
|
||||||
|
|
||||||
|
const panel = el("div", { className: "panel" }, [el("h2", { textContent: tag })]);
|
||||||
|
for (const row of rows) panel.appendChild(row);
|
||||||
|
return panel;
|
||||||
|
});
|
||||||
|
|
||||||
|
container.replaceChildren(...panels);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
document.getElementById("endpoints").replaceChildren(
|
||||||
|
document.createTextNode(`Fehler: ${err.message}`)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})();
|
||||||
@ -39,6 +39,9 @@
|
|||||||
async function main() {
|
async function main() {
|
||||||
const me = await getJson("/auth/me");
|
const me = await getJson("/auth/me");
|
||||||
document.getElementById("whoami").textContent = `${me.username}${me.is_admin ? " (Admin)" : ""}`;
|
document.getElementById("whoami").textContent = `${me.username}${me.is_admin ? " (Admin)" : ""}`;
|
||||||
|
if (me.is_admin) {
|
||||||
|
document.getElementById("admin-link").classList.remove("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
const hosts = await getJson("/catalog/hosts");
|
const hosts = await getJson("/catalog/hosts");
|
||||||
const byGroup = {};
|
const byGroup = {};
|
||||||
|
|||||||
393
templates/admin.html
Normal file
393
templates/admin.html
Normal file
@ -0,0 +1,393 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Jumphost Admin</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/app.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="topbar">
|
||||||
|
<div class="brand">Jumphost · Admin</div>
|
||||||
|
<div>
|
||||||
|
<span class="hint" id="whoami"></span>
|
||||||
|
<a href="/dashboard">Zum Dashboard</a>
|
||||||
|
<a href="/docs" target="_blank" rel="noopener">API-Dokumentation</a>
|
||||||
|
<button id="logout-btn">Abmelden</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container wide">
|
||||||
|
<div id="banner-box"></div>
|
||||||
|
|
||||||
|
<div class="tabs" id="tabs">
|
||||||
|
<button type="button" class="tab-btn active" data-tab="users">Benutzer</button>
|
||||||
|
<button type="button" class="tab-btn" data-tab="groups">Benutzergruppen</button>
|
||||||
|
<button type="button" class="tab-btn" data-tab="hosts">Hosts & Verbindungen</button>
|
||||||
|
<button type="button" class="tab-btn" data-tab="roles">Rollen</button>
|
||||||
|
<button type="button" class="tab-btn" data-tab="tokens">API-Tokens</button>
|
||||||
|
<button type="button" class="tab-btn" data-tab="audit">Audit-Log</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ================= Benutzer ================= -->
|
||||||
|
<div class="tab-panel" id="tab-users">
|
||||||
|
<div class="panel">
|
||||||
|
<h2>Neuen Benutzer anlegen</h2>
|
||||||
|
<form id="user-create-form" class="form-grid">
|
||||||
|
<div>
|
||||||
|
<label for="uc-username">Benutzername</label>
|
||||||
|
<input type="text" id="uc-username" required minlength="3" maxlength="64">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="uc-password">Initiales Passwort (min. 12 Zeichen)</label>
|
||||||
|
<input type="text" id="uc-password" required minlength="12" maxlength="256">
|
||||||
|
</div>
|
||||||
|
<div class="checkbox-row">
|
||||||
|
<input type="checkbox" id="uc-is-admin">
|
||||||
|
<label for="uc-is-admin">Globaler Admin</label>
|
||||||
|
</div>
|
||||||
|
<button type="submit">Anlegen</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<h2>Benutzer</h2>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="data-table" id="users-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th><th>Benutzername</th><th>Admin</th><th>Aktiv</th>
|
||||||
|
<th>TOTP</th><th>Erstellt</th><th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ================= Benutzergruppen ================= -->
|
||||||
|
<div class="tab-panel hidden" id="tab-groups">
|
||||||
|
<div class="panel">
|
||||||
|
<h2>Neue Benutzergruppe anlegen</h2>
|
||||||
|
<form id="group-create-form" class="form-grid">
|
||||||
|
<div>
|
||||||
|
<label for="gc-name">Name</label>
|
||||||
|
<input type="text" id="gc-name" required maxlength="128">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="gc-description">Beschreibung (optional)</label>
|
||||||
|
<input type="text" id="gc-description" maxlength="1024">
|
||||||
|
</div>
|
||||||
|
<button type="submit">Anlegen</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<h2>Benutzergruppen</h2>
|
||||||
|
<p class="hint">Rollen fuer eine Gruppe (= Verbindungen mit der ganzen Gruppe teilen) werden im Tab "Rollen" vergeben.</p>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="data-table" id="groups-table">
|
||||||
|
<thead><tr><th>ID</th><th>Name</th><th>Beschreibung</th><th>Mitglieder</th><th></th></tr></thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel hidden" id="group-members-panel">
|
||||||
|
<h2>Mitglieder von <span id="group-members-name"></span></h2>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="data-table" id="group-members-table">
|
||||||
|
<thead><tr><th>User-ID</th><th>Benutzername</th><th>Hinzugefuegt</th><th></th></tr></thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<h3>Mitglied hinzufuegen</h3>
|
||||||
|
<form id="group-member-add-form" class="form-grid">
|
||||||
|
<div>
|
||||||
|
<label for="gm-user-select">Benutzer</label>
|
||||||
|
<select id="gm-user-select" required></select>
|
||||||
|
</div>
|
||||||
|
<button type="submit">Hinzufuegen</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ================= Hosts & Verbindungen ================= -->
|
||||||
|
<div class="tab-panel hidden" id="tab-hosts">
|
||||||
|
<div class="panel">
|
||||||
|
<h2>Neue Hostgruppe anlegen</h2>
|
||||||
|
<form id="hostgroup-create-form" class="form-grid">
|
||||||
|
<div>
|
||||||
|
<label for="hgc-name">Name</label>
|
||||||
|
<input type="text" id="hgc-name" required maxlength="128">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="hgc-description">Beschreibung (optional)</label>
|
||||||
|
<input type="text" id="hgc-description" maxlength="1024">
|
||||||
|
</div>
|
||||||
|
<button type="submit">Anlegen</button>
|
||||||
|
</form>
|
||||||
|
<h3>Hostgruppen</h3>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="data-table" id="hostgroups-table">
|
||||||
|
<thead><tr><th>ID</th><th>Name</th><th>Beschreibung</th></tr></thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<h2>Neue Verbindung (Host) anlegen</h2>
|
||||||
|
<form id="host-create-form" class="form-grid">
|
||||||
|
<div>
|
||||||
|
<label for="hc-hostgroup">Hostgruppe</label>
|
||||||
|
<select id="hc-hostgroup" required></select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="hc-hostname">Hostname (Label)</label>
|
||||||
|
<input type="text" id="hc-hostname" required maxlength="128" pattern="[a-zA-Z0-9][a-zA-Z0-9._-]*">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="hc-address">Adresse (IP/DNS)</label>
|
||||||
|
<input type="text" id="hc-address" required maxlength="255">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="hc-protocol">Protokoll</label>
|
||||||
|
<select id="hc-protocol">
|
||||||
|
<option value="ssh">SSH</option>
|
||||||
|
<option value="rdp">RDP</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="hc-port">Port</label>
|
||||||
|
<input type="text" id="hc-port" inputmode="numeric" value="22" required maxlength="5">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="hc-os-type">Betriebssystem</label>
|
||||||
|
<select id="hc-os-type">
|
||||||
|
<option value="linux">Linux</option>
|
||||||
|
<option value="windows">Windows</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="hc-ssh-username-box">
|
||||||
|
<label for="hc-ssh-username">SSH-Benutzername</label>
|
||||||
|
<input type="text" id="hc-ssh-username" maxlength="128">
|
||||||
|
</div>
|
||||||
|
<div id="hc-rdp-username-box" class="hidden">
|
||||||
|
<label for="hc-rdp-username">RDP-Benutzername</label>
|
||||||
|
<input type="text" id="hc-rdp-username" maxlength="128">
|
||||||
|
</div>
|
||||||
|
<div id="hc-rdp-domain-box" class="hidden">
|
||||||
|
<label for="hc-rdp-domain">RDP-Domaene (optional)</label>
|
||||||
|
<input type="text" id="hc-rdp-domain" maxlength="128">
|
||||||
|
</div>
|
||||||
|
<div class="checkbox-row"><input type="checkbox" id="hc-clipboard" checked><label for="hc-clipboard">Zwischenablage erlaubt</label></div>
|
||||||
|
<div class="checkbox-row"><input type="checkbox" id="hc-filetransfer" checked><label for="hc-filetransfer">Dateitransfer erlaubt</label></div>
|
||||||
|
<div class="checkbox-row hidden" id="hc-nla-box"><input type="checkbox" id="hc-nla" checked><label for="hc-nla">RDP: NLA erforderlich</label></div>
|
||||||
|
<button type="submit">Verbindung anlegen</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<h2>Vorhandene Hosts</h2>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="data-table" id="hosts-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>ID</th><th>Hostname</th><th>Adresse</th><th>Protokoll</th><th>OS</th><th>Hostgruppe</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel hidden" id="host-detail-panel">
|
||||||
|
<h2>Verbindung: <span id="host-detail-name"></span></h2>
|
||||||
|
<div id="host-detail-ssh">
|
||||||
|
<h3>SSH-Host-Key</h3>
|
||||||
|
<p class="hint">Ermittelt den Host-Key EINMALIG ohne Pruefung (bewusste Trust-Entscheidung) und speichert den Fingerprint fuer kuenftiges Pinning. Wird prominent im Audit-Log vermerkt.</p>
|
||||||
|
<button type="button" class="btn-secondary" id="host-discover-key-btn">Host-Key ermitteln</button>
|
||||||
|
<div id="host-key-result" class="reveal-box hidden"></div>
|
||||||
|
|
||||||
|
<h3>SSH-Key zuordnen</h3>
|
||||||
|
<form id="host-key-map-form" class="form-grid">
|
||||||
|
<div>
|
||||||
|
<label for="hkm-key-select">SSH-Key</label>
|
||||||
|
<select id="hkm-key-select" required></select>
|
||||||
|
</div>
|
||||||
|
<button type="submit">Zuordnen</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div id="host-detail-rdp" class="hidden">
|
||||||
|
<h3>RDP-Zugangsdaten setzen</h3>
|
||||||
|
<form id="host-rdp-cred-form" class="form-grid">
|
||||||
|
<div class="full-width">
|
||||||
|
<label for="hrc-password">RDP-Passwort</label>
|
||||||
|
<input type="text" id="hrc-password" required maxlength="512">
|
||||||
|
</div>
|
||||||
|
<button type="submit">Speichern</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<h2>Neuen SSH-Key anlegen</h2>
|
||||||
|
<form id="ssh-key-create-form" class="form-grid">
|
||||||
|
<div>
|
||||||
|
<label for="skc-label">Label</label>
|
||||||
|
<input type="text" id="skc-label" required maxlength="128">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="skc-owner">Besitzer (User-ID, optional)</label>
|
||||||
|
<input type="text" id="skc-owner" inputmode="numeric" maxlength="10">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="skc-type">Key-Typ</label>
|
||||||
|
<select id="skc-type">
|
||||||
|
<option value="ed25519">ed25519</option>
|
||||||
|
<option value="rsa-3072">rsa-3072</option>
|
||||||
|
<option value="rsa-4096">rsa-4096</option>
|
||||||
|
<option value="ca-cert">ca-cert</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="full-width">
|
||||||
|
<label for="skc-private">Privater Schluessel (PEM)</label>
|
||||||
|
<textarea id="skc-private" required></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="full-width">
|
||||||
|
<label for="skc-public">Oeffentlicher Schluessel</label>
|
||||||
|
<textarea id="skc-public" required></textarea>
|
||||||
|
</div>
|
||||||
|
<button type="submit">Anlegen</button>
|
||||||
|
</form>
|
||||||
|
<h3>Vorhandene SSH-Keys</h3>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="data-table" id="ssh-keys-table">
|
||||||
|
<thead><tr><th>ID</th><th>Label</th><th>Typ</th><th>Besitzer</th><th>Erstellt</th></tr></thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ================= Rollen ================= -->
|
||||||
|
<div class="tab-panel hidden" id="tab-roles">
|
||||||
|
<div class="panel">
|
||||||
|
<h2>Rolle an Benutzer vergeben</h2>
|
||||||
|
<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>
|
||||||
|
<label for="rg-role">Rolle</label>
|
||||||
|
<select id="rg-role"></select>
|
||||||
|
</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">
|
||||||
|
<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>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<h2>Rolle an Benutzergruppe vergeben (Verbindung mit Gruppe teilen)</h2>
|
||||||
|
<p class="hint">Volle Rollen-Vererbung: jedes aktuelle und zukuenftige Mitglied der Gruppe erhaelt diese Rolle automatisch.</p>
|
||||||
|
<form id="group-role-grant-form" class="form-grid">
|
||||||
|
<div>
|
||||||
|
<label for="grg-group">Benutzergruppe</label>
|
||||||
|
<select id="grg-group" required></select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="grg-hostgroup">Hostgruppe</label>
|
||||||
|
<select id="grg-hostgroup" required></select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="grg-role">Rolle</label>
|
||||||
|
<select id="grg-role"></select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="grg-expires">Ablaufdatum (optional, ISO)</label>
|
||||||
|
<input type="text" id="grg-expires" placeholder="2026-12-31T00:00:00.000Z" maxlength="40">
|
||||||
|
</div>
|
||||||
|
<button type="submit">Vergeben</button>
|
||||||
|
</form>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="data-table" id="group-role-grants-table">
|
||||||
|
<thead><tr><th>Gruppe</th><th>Hostgruppe</th><th>Rolle</th><th>Ablauf</th><th></th></tr></thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ================= API-Tokens ================= -->
|
||||||
|
<div class="tab-panel hidden" id="tab-tokens">
|
||||||
|
<div class="panel">
|
||||||
|
<h2>Neues API-Token erstellen</h2>
|
||||||
|
<p class="hint">Tokens wirken NUR auf die Verwaltungs-API (Benutzer/Hosts/Rollen/...), nie auf SSH-/RDP-Sitzungen selbst. "rw" schliesst "ro" auf derselben Ressource automatisch mit ein.</p>
|
||||||
|
<form id="token-create-form" class="form-grid">
|
||||||
|
<div>
|
||||||
|
<label for="tc-label">Bezeichnung</label>
|
||||||
|
<input type="text" id="tc-label" required maxlength="128">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="tc-user">Fuer Benutzer</label>
|
||||||
|
<select id="tc-user" required></select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="tc-expires">Ablaufdatum (optional, ISO)</label>
|
||||||
|
<input type="text" id="tc-expires" placeholder="2026-12-31T00:00:00.000Z" maxlength="40">
|
||||||
|
</div>
|
||||||
|
<div class="full-width">
|
||||||
|
<label>Scopes</label>
|
||||||
|
<div class="scope-grid" id="tc-scope-grid"></div>
|
||||||
|
</div>
|
||||||
|
<button type="submit">Token erstellen</button>
|
||||||
|
</form>
|
||||||
|
<div id="token-reveal-box" class="reveal-box hidden"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<h2>Vorhandene Tokens</h2>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="data-table" id="tokens-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>ID</th><th>Bezeichnung</th><th>Benutzer</th><th>Praefix</th><th>Scopes</th><th>Zuletzt genutzt</th><th>Status</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ================= Audit-Log ================= -->
|
||||||
|
<div class="tab-panel hidden" id="tab-audit">
|
||||||
|
<div class="panel">
|
||||||
|
<h2>Audit-Log</h2>
|
||||||
|
<button type="button" class="btn-secondary" id="audit-verify-btn">Ketten-Integritaet pruefen</button>
|
||||||
|
<div id="audit-verify-result" class="hidden"></div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="data-table" id="audit-table">
|
||||||
|
<thead><tr><th>ID</th><th>Zeit</th><th>User-ID</th><th>IP</th><th>Ereignis</th><th>Details</th></tr></thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/js/admin.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
32
templates/api_docs.html
Normal file
32
templates/api_docs.html
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Jumphost API-Dokumentation</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/app.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="topbar">
|
||||||
|
<div class="brand">Jumphost · API-Dokumentation</div>
|
||||||
|
<div>
|
||||||
|
<a href="/admin">Zum Admin-Bereich</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container wide">
|
||||||
|
<div class="panel">
|
||||||
|
<h2>API-Uebersicht</h2>
|
||||||
|
<p class="hint">
|
||||||
|
Nur fuer eingeloggte Admins sichtbar (kein oeffentliches /docs). Liest
|
||||||
|
das OpenAPI-Schema unter <code>/openapi.json</code> aus. Bewusst kein
|
||||||
|
CDN-bezogenes Swagger-UI-Bundle -- diese Ansicht ist vollstaendig
|
||||||
|
selbst gehostet, ohne Laufzeit-Abhaengigkeit zu Drittanbietern.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div id="endpoints"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/js/api-docs.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -11,6 +11,7 @@
|
|||||||
<div class="brand">Jumphost</div>
|
<div class="brand">Jumphost</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="hint" id="whoami"></span>
|
<span class="hint" id="whoami"></span>
|
||||||
|
<a href="/admin" id="admin-link" class="hidden">Admin-Bereich</a>
|
||||||
<button id="logout-btn">Abmelden</button>
|
<button id="logout-btn">Abmelden</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
344
tests/test_admin_groups_tokens.py
Normal file
344
tests/test_admin_groups_tokens.py
Normal file
@ -0,0 +1,344 @@
|
|||||||
|
"""
|
||||||
|
Tests fuer die Erweiterung des RBAC-Modells um Benutzergruppen (Teams) mit
|
||||||
|
voller Rollen-Vererbung ("Verbindungen mit einer Gruppe teilen", siehe
|
||||||
|
app/rbac.py, app/db/migrations/0004_user_groups.sql) sowie fuer API-Tokens
|
||||||
|
mit granularem ro/rw-Scope je Ressource (app/security/api_tokens.py,
|
||||||
|
app/db/migrations/0005_api_tokens.sql).
|
||||||
|
|
||||||
|
Deckt ab:
|
||||||
|
1) Gruppenmitgliedschaft gewaehrt/entzieht Katalog-Zugriff ohne
|
||||||
|
individuellen Grant.
|
||||||
|
2) Nur Admins duerfen Gruppen/Rollen/Tokens verwalten.
|
||||||
|
3) Token-Scope wird durchgesetzt (read/write je Ressource, write impliziert
|
||||||
|
read), abgelaufene/widerrufene Tokens werden abgelehnt.
|
||||||
|
4) Tokens koennen sich NICHT selbst weitere Tokens ausstellen
|
||||||
|
(Privilege-Escalation-Schutz) -- Token-Verwaltung bleibt reine
|
||||||
|
Session-Admin-Aktion.
|
||||||
|
5) /docs und /openapi.json sind admin-only (kein oeffentliches Swagger-UI).
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1) Benutzergruppen: volle Rollen-Vererbung
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_group_role_grant_gives_catalog_access_without_individual_grant(client):
|
||||||
|
from app.db import get_db
|
||||||
|
|
||||||
|
conn = get_db()
|
||||||
|
admin_id = await _create_user(conn, "grp_admin", "Correct-Horse-Battery-Staple-G1", is_admin=True)
|
||||||
|
member_id = await _create_user(conn, "grp_member", "Correct-Horse-Battery-Staple-G2")
|
||||||
|
hg_id, host_id = await _setup_hostgroup_and_host(conn)
|
||||||
|
|
||||||
|
await _login_full(client, "grp_admin", "Correct-Horse-Battery-Staple-G1")
|
||||||
|
|
||||||
|
resp = await client.post("/admin/user-groups", json={"name": "netzwerk-team", "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_name": "ssh_connect"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
# Als Gruppenmitglied einloggen: KEIN individueller user_hostgroup_roles-Eintrag existiert.
|
||||||
|
client.cookies.clear()
|
||||||
|
await _login_full(client, "grp_member", "Correct-Horse-Battery-Staple-G2")
|
||||||
|
|
||||||
|
resp = await client.get("/catalog/hosts")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
hosts = resp.json()
|
||||||
|
assert any(h["id"] == host_id for h in hosts), "Gruppenmitglied sollte den geteilten Host sehen"
|
||||||
|
assert next(h for h in hosts if h["id"] == host_id)["can_connect"] is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_removing_group_member_revokes_inherited_catalog_access(client):
|
||||||
|
from app.db import get_db
|
||||||
|
|
||||||
|
conn = get_db()
|
||||||
|
await _create_user(conn, "grp_admin2", "Correct-Horse-Battery-Staple-G3", is_admin=True)
|
||||||
|
member_id = await _create_user(conn, "grp_member2", "Correct-Horse-Battery-Staple-G4")
|
||||||
|
hg_id, host_id = await _setup_hostgroup_and_host(conn, group_name="team-b", hostname="srv-b")
|
||||||
|
|
||||||
|
await _login_full(client, "grp_admin2", "Correct-Horse-Battery-Staple-G3")
|
||||||
|
resp = await client.post("/admin/user-groups", json={"name": "devops-team", "description": None})
|
||||||
|
group_id = resp.json()["id"]
|
||||||
|
await client.post(f"/admin/user-groups/{group_id}/members", json={"user_id": member_id})
|
||||||
|
await client.post(
|
||||||
|
"/admin/group-roles/grant",
|
||||||
|
json={"user_group_id": group_id, "host_group_id": hg_id, "role_name": "ssh_connect"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mitgliedschaft wieder entfernen, BEVOR sich der User einloggt.
|
||||||
|
resp = await client.delete(f"/admin/user-groups/{group_id}/members/{member_id}")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
client.cookies.clear()
|
||||||
|
await _login_full(client, "grp_member2", "Correct-Horse-Battery-Staple-G4")
|
||||||
|
resp = await client.get("/catalog/hosts")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert all(h["id"] != host_id for h in resp.json()), "Entferntes Mitglied darf Host nicht mehr sehen"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_non_admin_cannot_manage_user_groups_or_group_roles(client):
|
||||||
|
from app.db import get_db
|
||||||
|
|
||||||
|
conn = get_db()
|
||||||
|
await _create_user(conn, "plain_user", "Correct-Horse-Battery-Staple-P1", is_admin=False)
|
||||||
|
await _login_full(client, "plain_user", "Correct-Horse-Battery-Staple-P1")
|
||||||
|
|
||||||
|
resp = await client.post("/admin/user-groups", json={"name": "sollte-nicht-klappen"})
|
||||||
|
assert resp.status_code == 403
|
||||||
|
resp = await client.get("/admin/user-groups")
|
||||||
|
assert resp.status_code == 403
|
||||||
|
resp = await client.post(
|
||||||
|
"/admin/group-roles/grant",
|
||||||
|
json={"user_group_id": 1, "host_group_id": 1, "role_name": "ssh_connect"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2) API-Tokens: Scope-Durchsetzung
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _create_admin_and_token(client, conn, *, scopes, username="tok_admin", pw="Correct-Horse-Battery-Staple-T1", expires_at=None):
|
||||||
|
admin_id = await _create_user(conn, username, pw, is_admin=True)
|
||||||
|
await _login_full(client, username, pw)
|
||||||
|
resp = await client.post(
|
||||||
|
"/admin/tokens",
|
||||||
|
json={"label": "ci-token", "user_id": admin_id, "scopes": scopes, "expires_at": expires_at},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
return resp.json()["token"], resp.json()["id"], admin_id
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_token_with_read_scope_allows_get_but_not_write(client):
|
||||||
|
from app.db import get_db
|
||||||
|
|
||||||
|
conn = get_db()
|
||||||
|
token, _token_id, _admin_id = await _create_admin_and_token(client, conn, scopes=["hosts:read"])
|
||||||
|
|
||||||
|
resp = await client.get("/admin/hosts", headers={"Authorization": f"Bearer {token}"})
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
"/admin/host-groups",
|
||||||
|
json={"name": "sollte-nicht-klappen"},
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
)
|
||||||
|
# host_groups:write ist nicht im Scope enthalten -> abgelehnt.
|
||||||
|
assert resp.status_code == 401, resp.text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_token_with_write_scope_implies_read(client):
|
||||||
|
from app.db import get_db
|
||||||
|
|
||||||
|
conn = get_db()
|
||||||
|
token, _token_id, _admin_id = await _create_admin_and_token(
|
||||||
|
client, conn, scopes=["host_groups:write"], username="tok_admin2", pw="Correct-Horse-Battery-Staple-T2"
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = await client.get("/admin/host-groups", headers={"Authorization": f"Bearer {token}"})
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
"/admin/host-groups", json={"name": "per-token-angelegt"},
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_revoked_token_rejected(client):
|
||||||
|
from app.db import get_db
|
||||||
|
|
||||||
|
conn = get_db()
|
||||||
|
token, token_id, _admin_id = await _create_admin_and_token(
|
||||||
|
client, conn, scopes=["hosts:read"], username="tok_admin3", pw="Correct-Horse-Battery-Staple-T3"
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = await client.post(f"/admin/tokens/{token_id}/revoke")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
resp = await client.get("/admin/hosts", headers={"Authorization": f"Bearer {token}"})
|
||||||
|
assert resp.status_code == 401, resp.text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_expired_token_rejected(client):
|
||||||
|
from app.db import get_db
|
||||||
|
|
||||||
|
conn = get_db()
|
||||||
|
past = "2000-01-01T00:00:00.000000Z"
|
||||||
|
token, _token_id, _admin_id = await _create_admin_and_token(
|
||||||
|
client, conn, scopes=["hosts:read"], username="tok_admin4",
|
||||||
|
pw="Correct-Horse-Battery-Staple-T4", expires_at=past,
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = await client.get("/admin/hosts", headers={"Authorization": f"Bearer {token}"})
|
||||||
|
assert resp.status_code == 401, resp.text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unknown_scope_rejected_on_token_creation(client):
|
||||||
|
from app.db import get_db
|
||||||
|
|
||||||
|
conn = get_db()
|
||||||
|
admin_id = await _create_user(conn, "tok_admin5", "Correct-Horse-Battery-Staple-T5", is_admin=True)
|
||||||
|
await _login_full(client, "tok_admin5", "Correct-Horse-Battery-Staple-T5")
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
"/admin/tokens",
|
||||||
|
json={"label": "bad-scope", "user_id": admin_id, "scopes": ["hosts:delete-everything"]},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_token_cannot_be_used_to_create_or_list_other_tokens(client):
|
||||||
|
"""Privilege-Escalation-Schutz: /admin/tokens/* laeuft ausschliesslich
|
||||||
|
ueber require_global_admin (Session), niemals ueber require_admin_or_scope
|
||||||
|
-- ein geleaktes Token darf sich selbst keine weiteren/staerkeren Tokens
|
||||||
|
ausstellen, selbst wenn ein (in diesem Scope-Modell gar nicht existierender)
|
||||||
|
'tokens'-Scope faelschlich mitgeschickt wuerde."""
|
||||||
|
from app.db import get_db
|
||||||
|
|
||||||
|
conn = get_db()
|
||||||
|
token, _token_id, admin_id = await _create_admin_and_token(
|
||||||
|
client, conn, scopes=["users:read", "users:write", "hosts:read", "hosts:write"],
|
||||||
|
username="tok_admin6", pw="Correct-Horse-Battery-Staple-T6",
|
||||||
|
)
|
||||||
|
# Session-Cookie bewusst entfernen: es soll ausschliesslich das Token als
|
||||||
|
# Credential vorliegen (sonst wuerde die noch gueltige Admin-Session der
|
||||||
|
# Token-Erzeugung die /admin/tokens-Endpunkte -- die NUR Cookies pruefen
|
||||||
|
# -- unabhaengig vom Token-Test durchwinken und der Test taeuschend gruen sein).
|
||||||
|
client.cookies.clear()
|
||||||
|
|
||||||
|
resp = await client.get("/admin/tokens", headers={"Authorization": f"Bearer {token}"})
|
||||||
|
assert resp.status_code == 401, resp.text
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
"/admin/tokens",
|
||||||
|
json={"label": "self-issued", "user_id": admin_id, "scopes": ["users:read"]},
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401, resp.text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_non_admin_cannot_create_tokens(client):
|
||||||
|
from app.db import get_db
|
||||||
|
|
||||||
|
conn = get_db()
|
||||||
|
user_id = await _create_user(conn, "tok_plain", "Correct-Horse-Battery-Staple-T7")
|
||||||
|
await _login_full(client, "tok_plain", "Correct-Horse-Battery-Staple-T7")
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
"/admin/tokens", json={"label": "x", "user_id": user_id, "scopes": ["hosts:read"]}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3) API-Dokumentation: admin-only, kein oeffentliches Swagger-UI
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_docs_and_openapi_require_admin(client):
|
||||||
|
resp = await client.get("/docs")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
resp = await client.get("/openapi.json")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_docs_and_openapi_reject_non_admin(client):
|
||||||
|
from app.db import get_db
|
||||||
|
|
||||||
|
conn = get_db()
|
||||||
|
await _create_user(conn, "docs_plain", "Correct-Horse-Battery-Staple-D1", is_admin=False)
|
||||||
|
await _login_full(client, "docs_plain", "Correct-Horse-Battery-Staple-D1")
|
||||||
|
|
||||||
|
resp = await client.get("/docs")
|
||||||
|
assert resp.status_code == 403
|
||||||
|
resp = await client.get("/openapi.json")
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_docs_and_openapi_accessible_to_admin(client):
|
||||||
|
from app.db import get_db
|
||||||
|
|
||||||
|
conn = get_db()
|
||||||
|
await _create_user(conn, "docs_admin", "Correct-Horse-Battery-Staple-D2", is_admin=True)
|
||||||
|
await _login_full(client, "docs_admin", "Correct-Horse-Battery-Staple-D2")
|
||||||
|
|
||||||
|
resp = await client.get("/openapi.json")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
spec = resp.json()
|
||||||
|
assert "/admin/tokens" in spec["paths"]
|
||||||
|
|
||||||
|
resp = await client.get("/docs")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_admin_page_serves_static_markup_without_serverside_gate(client):
|
||||||
|
"""Wie /dashboard: die Seite selbst enthaelt keine Secrets, die
|
||||||
|
Admin-Pruefung erfolgt clientseitig via /auth/me + serverseitig hart auf
|
||||||
|
jedem einzelnen /admin/*-API-Call."""
|
||||||
|
resp = await client.get("/admin")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert "Jumphost" in resp.text
|
||||||
@ -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"],
|
["/", "/dashboard", "/terminal/1", "/rdp/1", "/admin"],
|
||||||
)
|
)
|
||||||
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)
|
||||||
@ -77,6 +77,40 @@ async def test_rendered_pages_contain_no_inline_style_or_script(client, path):
|
|||||||
_assert_no_inline_script(html, path)
|
_assert_no_inline_script(html, path)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_api_docs_page_contains_no_inline_style_or_script(client):
|
||||||
|
"""/docs (siehe test_admin_groups_tokens.py fuer den Admin-Zugriffsschutz
|
||||||
|
selbst) ist bewusst KEIN vendored/CDN-bezogenes Swagger-UI-Bundle, sondern
|
||||||
|
eine selbstgebaute, CSP-konforme Ansicht -- muss also denselben
|
||||||
|
Inline-Regeln genuegen wie alle anderen Seiten."""
|
||||||
|
from app.db import get_db
|
||||||
|
from app.security.passwords import hash_password
|
||||||
|
|
||||||
|
conn = get_db()
|
||||||
|
await conn.execute(
|
||||||
|
"INSERT INTO users (username, password_hash, is_admin, must_change_password) "
|
||||||
|
"VALUES ('docsadmin', ?, 1, 0)",
|
||||||
|
(hash_password("Correct-Horse-Battery-Staple-Docs"),),
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
resp = await client.post(
|
||||||
|
"/auth/login", json={"username": "docsadmin", "password": "Correct-Horse-Battery-Staple-Docs"}
|
||||||
|
)
|
||||||
|
pending = resp.json()["pending_token"]
|
||||||
|
import pyotp
|
||||||
|
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()
|
||||||
|
await client.post("/auth/totp/enroll/confirm", json={"pending_token": pending, "code": code})
|
||||||
|
|
||||||
|
resp = await client.get("/docs")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
html = resp.text
|
||||||
|
_assert_no_inline_style(html, "/docs")
|
||||||
|
_assert_no_inline_script(html, "/docs")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_login_page_hidden_sections_use_css_class_not_inline_style(client):
|
async def test_login_page_hidden_sections_use_css_class_not_inline_style(client):
|
||||||
"""Die anfangs versteckten Login-Bereiche muessen ueber die `.hidden`
|
"""Die anfangs versteckten Login-Bereiche muessen ueber die `.hidden`
|
||||||
|
|||||||
Reference in New Issue
Block a user