add admin stuff

This commit is contained in:
2026-08-20 10:43:19 +02:00
parent 7108d446d8
commit 91758a2701
19 changed files with 2514 additions and 85 deletions

187
README.md
View File

@ -1,23 +1,23 @@
# Jumphost Gateway — Implementierung
Umsetzung des Konzepts `Jumphost_Konzept.md` (v0.2): browserbasiertes
SSH/RDP-Gateway mit TOTP-Pflicht, Hostgruppen-RBAC, manipulationssicherem
Audit-Log, SQLite/Python-Backend und Ansible-Deployment (mit/ohne nginx,
mehrere TLS-Modi).
SSH/RDP-Gateway mit TOTP-Pflicht, Gruppen-/Hostgruppen-RBAC, manipulationssicherem
Audit-Log, SQLite/Python-Backend, browserbasierter Admin-Oberflaeche und
Ansible-Deployment (mit/ohne nginx, mehrere TLS-Modi).
## Verzeichnisstruktur
```
app/ Backend (FastAPI, Python 3.11+)
security/ Crypto, Passwoerter, TOTP, Sessions, Audit-Hash-Chain, AV-Scan
auth/ Login-Flow, RBAC-Dependencies
admin/ Admin-API (User/Hosts/Hostgruppen/Rollen/SSH-Keys)
catalog/ Sicht fuer normale Nutzer (nur zugewiesene Hosts)
security/ Crypto, Passwoerter, TOTP, Sessions, Audit-Hash-Chain, AV-Scan, API-Tokens
auth/ Login-Flow, RBAC-/Token-Dependencies
admin/ Admin-API (User/Gruppen/Hosts/Hostgruppen/Rollen/SSH-Keys/API-Tokens)
catalog/ Sicht fuer normale Nutzer (direkt und ueber Gruppen zugewiesene Hosts)
ssh_proxy/ SSH-Terminal-WebSocket + SFTP-Filetransfer
rdp_proxy/ Guacamole-Protokoll-Tunnel zu guacd
recordings/ Hash-verkettete Session-Aufzeichnung
db/ SQLite-Migrationen
static/, templates/ Frontend (Vanilla JS, xterm.js, guacamole-common-js)
db/ SQLite-Migrationen (0001-0005, laufen automatisch beim Start)
static/, templates/ Frontend (Vanilla JS, xterm.js, guacamole-common-js, Admin-Oberflaeche, API-Doku)
scripts/ Betriebs-/Hilfsskripte (Admin anlegen, Assets bauen)
ansible/ Deployment (Rollen, systemd-Unit-Templates)
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
scannen). Fuer echte SSH-/RDP-Sessions muessen zuvor ueber die Admin-API
(`/admin/host-groups`, `/admin/hosts`, `/admin/ssh-keys`,
`/admin/hosts/{id}/ssh-keys/{id}`, `/admin/hosts/{id}/rdp-credentials`,
`/admin/roles/grant`) Hostgruppen, Hosts, Schluessel/Zugangsdaten und
Rollenzuweisungen angelegt werden. Fuer RDP muss zusaetzlich ein laufender
`guacd` erreichbar sein (siehe `JUMPHOST_GUACD_HOST`/`_PORT`).
scannen). Auf dem Dashboard erscheint fuer Admin-Konten oben rechts der Link
**"Admin-Bereich"** (`/admin`) — darueber lassen sich Benutzer, Benutzergruppen,
Hostgruppen/Hosts/SSH-Keys/RDP-Zugangsdaten, Rollenzuweisungen (direkt und
gruppenweise) sowie API-Tokens vollstaendig ueber die Oberflaeche anlegen und
verwalten; ein manueller Umweg ueber die rohe JSON-API ist dafuer nicht mehr
noetig. Fuer RDP muss zusaetzlich ein laufender `guacd` erreichbar sein (siehe
`JUMPHOST_GUACD_HOST`/`_PORT`).
Die SQLite-Migrationen 0004 (`user_groups`) und 0005 (`api_tokens`) werden
beim naechsten Start automatisch angewendet (`app/db.py`, `_apply_migrations`)
— kein manueller Migrationsschritt noetig, auch nicht bei einem bestehenden
Datenbestand.
## 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
@ -53,16 +111,34 @@ Rollenzuweisungen angelegt werden. Fuer RDP muss zusaetzlich ein laufender
pytest -q
```
27 Tests decken ab: Argon2id/TOTP-Grundfunktionen, Audit-Hash-Chain (inkl.
Manipulationserkennung und Trigger-Durchsetzung), RBAC-Logik inkl.
46 Tests (vorher 27) decken ab: Argon2id/TOTP-Grundfunktionen, Audit-Hash-Chain
(inkl. Manipulationserkennung und Trigger-Durchsetzung), RBAC-Logik inkl.
Ablaufdaten, den vollstaendigen Login-Flow (Passwort -> TOTP-Enrollment ->
Session-Cookie -> geschuetzte Endpunkte) gegen die echte FastAPI-App, sowie
17 dedizierte Security-/Pentest-Tests (`tests/test_pentest_security.py`) zu
Auth-Bypass, Cookie-/Session-Manipulation, RBAC/IDOR, Injection-Versuchen,
Rate-Limiting/Lockout, Filetransfer-Haertung, Security-Headern und
Audit-Vollstaendigkeit. Details, Vorgehen und Ergebnisse: siehe
Session-Cookie -> geschuetzte Endpunkte) gegen die echte FastAPI-App, 17
dedizierte Security-/Pentest-Tests (`tests/test_pentest_security.py`), 5
CSP-Regressionstests (`tests/test_csp_compliance.py`, inkl. `/admin` und
`/docs`) sowie 14 neue Tests zu Gruppen-RBAC-Vererbung, Admin-Oberflaeche und
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`.
> **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):
Server-Start, Static-/Template-Auslieferung, Security-Header, vollstaendiger
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
- **SAST**: `bandit -r app -c .bandit.yml` sowie `pip-audit` (Dependency-CVE-
Scan) laufen sauber durch (0 Findings, 0 bekannte Schwachstellen) — Details
inkl. vorher/nachher-Tabellen und der dabei gefundenen und gefixten
Starlette-Regression in `Pentest_Report.md`.
- **DAST**: `tests/test_pentest_security.py` (17 Tests, s.o.) simuliert
konkrete Angriffsmuster gegen die laufende ASGI-App.
Scan) liefen zu Beginn dieser Implementierung sauber durch (0 Findings, 0
bekannte Schwachstellen) — Details inkl. vorher/nachher-Tabellen und der
dabei gefundenen und gefixten Starlette-Regression in `Pentest_Report.md`.
Fuer die in dieser Session neu hinzugekommenen Dateien (Admin-Oberflaeche,
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
in dieser Session um ~10 zusaetzliche Task-Dateien vertieft (Kernel-Module,
sysctl, PAM/Passwort-Policy, erweiterte auditd-Regeln, AIDE, rkhunter,
Banner, cron/at-Restriktion, Dateirechte/sudo-Logging, SSHD-Haertung).
Vollstaendiges Mapping auf CIS-Controls inkl. bewusst nicht automatisierter
Punkte (mit Begruendung, z.B. Partitionslayout, Bootloader-Passwort,
Volltextverschluesselung, physische Sicherheit) und bekannter
Einschraenkungen: `ansible/roles/os_hardening/CIS_STIG_MAPPING.md`.
in einer frueheren Session um ~10 zusaetzliche Task-Dateien vertieft
(Kernel-Module, sysctl, PAM/Passwort-Policy, erweiterte auditd-Regeln,
AIDE, rkhunter, Banner, cron/at-Restriktion, Dateirechte/sudo-Logging,
SSHD-Haertung). Vollstaendiges Mapping auf CIS-Controls inkl. bewusst
nicht automatisierter Punkte (mit Begruendung, z.B. Partitionslayout,
Bootloader-Passwort, Volltextverschluesselung, physische Sicherheit) und
bekannter Einschraenkungen: `ansible/roles/os_hardening/CIS_STIG_MAPPING.md`.
- Abhaengigkeiten sind in `requirements.txt`/`requirements-dev.txt` exakt
auf gegen `pip-audit` gepruefte Versionen gepinnt (u.a. fastapi 0.141.1,
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
und `ansible/inventory/group_vars/all.yml`.
`ansible-playbook site.yml --syntax-check` laeuft sauber durch (in dieser
Session verifiziert); ein voller `--check`-Lauf gegen eine echte
`ansible-playbook site.yml --syntax-check` laeuft sauber durch (in einer
frueheren Session verifiziert); ein voller `--check`-Lauf gegen eine echte
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
Diese Implementierung ist ein funktionsfaehiges, getestetes und in dieser
Session per SAST+DAST geprueftes Grundgeruest, aber weiterhin **kein fertig
auditiertes Produktivsystem**. Der vollstaendige Befund inkl. Methodik,
Vorgehen und Restrisikobewertung steht in `Pentest_Report.md` — die dortige
Abschnitt-4-Tabelle ("Restrisiko / vor Produktivbetrieb noch zu tun") ist die
massgebliche, aktuelle Fassung dieser Liste. Kurzfassung:
Diese Implementierung ist ein funktionsfaehiges, getestetes und in einer
frueheren Session per SAST+DAST geprueftes Grundgeruest, aber weiterhin
**kein fertig auditiertes Produktivsystem**. Der vollstaendige Befund inkl.
Methodik, Vorgehen und Restrisikobewertung steht in `Pentest_Report.md` — die
dortige Abschnitt-4-Tabelle ("Restrisiko / vor Produktivbetrieb noch zu tun")
ist die massgebliche, aktuelle Fassung dieser Liste. Kurzfassung:
1. **Echter Netzwerk-Penetrationstest** gegen eine laufende Instanz
(Portscan, TLS-Konfiguration live, Session-Isolation unter Last,
Guacamole-Protokoll-Fuzzing) — in dieser Sandbox ohne Netzwerkzugriff auf
ein reales Zielsystem nicht durchfuehrbar. Was stattdessen gemacht wurde:
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
Guacamole-Protokoll-Implementierung (`app/rdp_proxy/guacd_client.py`) wurde
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
verifiziert, siehe `ansible/roles/os_hardening/CIS_STIG_MAPPING.md`.
4. **OpenSCAP-Compliance-Scan** (`oscap xccdf eval`) gegen das zutreffende
CIS/STIG-Profil — die `os_hardening`-Rolle wurde in dieser Session um ca.
10 Task-Dateien vertieft (siehe CIS_STIG_MAPPING.md fuer das vollstaendige
Mapping inkl. bewusst nicht automatisierter Punkte), ersetzt aber keinen
zertifizierten Benchmark-Scan.
CIS/STIG-Profil — die `os_hardening`-Rolle wurde in einer frueheren
Session um ca. 10 Task-Dateien vertieft (siehe CIS_STIG_MAPPING.md fuer
das vollstaendige Mapping inkl. bewusst nicht automatisierter Punkte),
ersetzt aber keinen zertifizierten Benchmark-Scan.
5. **Verteiltes Rate-Limiting** — der aktuelle Login-Rate-Limiter ist
In-Memory/Single-Process (siehe `app/security/rate_limit.py`); bei
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
nicht getestet — ausserhalb des Scopes eines Code-/Konfigurations-Reviews
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>

View File

@ -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
Datenmodell vorbereitet, wird hier aus Uebersichtlichkeitsgruenden aber nicht
vollstaendig verdrahtet -- siehe TODO-Markierungen fuer den naechsten Ausbauschritt.
"""
from __future__ import annotations
import json
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.models.schemas import (
ApiTokenCreateRequest,
GroupMemberRequest,
GroupRoleGrantRequest,
HostCreateRequest,
HostGroupCreateRequest,
RdpCredentialsRequest,
RoleGrantRequest,
SshKeyCreateRequest,
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.crypto import encrypt_secret
@ -37,7 +56,8 @@ def _client_ip(request: Request) -> str:
@router.post("/users", status_code=status.HTTP_201_CREATED)
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()
cursor = await conn.execute("SELECT 1 FROM users WHERE username = ?", (payload.username,))
@ -60,7 +80,7 @@ async def create_user(
@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()
cursor = await conn.execute(
"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")
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()
await conn.execute(
@ -92,11 +113,115 @@ async def deactivate_user(
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 -------------------------------------------------------------
@router.post("/host-groups", status_code=status.HTTP_201_CREATED)
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()
cursor = await conn.execute(
@ -113,7 +238,7 @@ async def create_host_group(
@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()
cursor = await conn.execute("SELECT id, name, description FROM host_groups ORDER BY id")
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)
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()
cursor = await conn.execute(
@ -153,7 +279,10 @@ async def create_host(
@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()
if host_group_id is not None:
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")
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
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")
async def set_rdp_credentials(
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
dem KEK (eigener AAD-Kontext, siehe app/security/crypto.py)."""
@ -217,11 +347,12 @@ async def set_rdp_credentials(
return {"status": "ok"}
# --- Rollenvergabe -------------------------------------------------------------
# --- Rollenvergabe (an einzelne User) -----------------------------------------
@router.post("/roles/grant")
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()
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")
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()
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"}
@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 ---------------------------------------------------------
@router.post("/ssh-keys", status_code=status.HTTP_201_CREATED)
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
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}
@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}")
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()
await conn.execute(
@ -312,11 +565,97 @@ async def map_ssh_key_to_host(
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 -----------------------------------------------------------------
@router.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))
conn = get_db()
@ -333,7 +672,7 @@ async def get_audit_log(
@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)."""
conn = get_db()
intact, broken_at = await verify_chain(conn)

View File

@ -1,12 +1,15 @@
"""FastAPI-Dependencies fuer Authentifizierung und Autorisierung."""
from __future__ import annotations
import json
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.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 (
SESSION_COOKIE_NAME,
decode_session_token,
@ -120,3 +123,77 @@ def require_host_role(role_name: str):
return user
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

View File

@ -1,5 +1,7 @@
"""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 fastapi import APIRouter, Depends
@ -32,18 +34,31 @@ async def my_hosts(user: CurrentUser = Depends(get_current_user)):
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
FROM hosts h
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
JOIN roles r ON r.id = uhr.role_id
WHERE h.is_active = 1 AND uhr.user_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'))
WHERE h.is_active = 1
AND (
EXISTS (
SELECT 1 FROM user_hostgroup_roles uhr
JOIN roles r ON r.id = uhr.role_id
WHERE uhr.user_id = ? AND uhr.host_group_id = h.host_group_id
AND r.name IN ('ssh_connect', 'rdp_connect')
AND (uhr.expires_at IS NULL OR uhr.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now'))
)
OR EXISTS (
SELECT 1 FROM group_hostgroup_roles ghr
JOIN roles r ON r.id = ghr.role_id
JOIN user_group_members ugm ON ugm.user_group_id = ghr.user_group_id
WHERE ugm.user_id = ? AND ghr.host_group_id = h.host_group_id
AND r.name IN ('ssh_connect', 'rdp_connect')
AND (ghr.expires_at IS NULL OR ghr.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now'))
)
)
ORDER BY g.name, h.hostname
""",
(user.id,),
(user.id, user.id),
)
rows = await cursor.fetchall()
hosts = [dict(zip(
@ -53,12 +68,18 @@ async def my_hosts(user: CurrentUser = Depends(get_current_user)):
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
WHERE uhr.user_id = ? AND r.name = 'file_transfer'
AND (uhr.expires_at IS NULL OR uhr.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now'))
UNION
SELECT ghr.host_group_id FROM group_hostgroup_roles ghr
JOIN roles r ON r.id = ghr.role_id
JOIN user_group_members ugm ON ugm.user_group_id = ghr.user_group_id
WHERE ugm.user_id = ? AND r.name = 'file_transfer'
AND (ghr.expires_at IS NULL OR ghr.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now'))
""",
(user.id,),
(user.id, user.id),
)
ft_groups = {row[0] for row in await ft_cursor.fetchall()}
for h in hosts:

View 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);

View 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);

View File

@ -7,13 +7,14 @@ from __future__ import annotations
import logging
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.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
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.catalog.routes import router as catalog_router
from app.db import close_db, init_db
@ -84,6 +85,15 @@ async def dashboard(request: Request):
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)
async def terminal_page(request: Request, host_id: int):
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")
async def healthz():
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", {})

View File

@ -93,6 +93,32 @@ class RoleGrantRequest(BaseModel):
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):
label: str = Field(min_length=1, max_length=128)
owner_user_id: int | None = None

View File

@ -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
import aiosqlite
@ -15,9 +21,17 @@ async def user_has_role(
AND uhr.host_group_id = ?
AND r.name = ?
AND (uhr.expires_at IS NULL OR uhr.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now'))
UNION
SELECT 1 FROM group_hostgroup_roles ghr
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
""",
(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()
return row is not None

View 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

View File

@ -31,7 +31,7 @@ body {
}
.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; }
input[type=text], input[type=password] {
input[type=text], input[type=password], select {
width: 100%;
padding: 0.6rem 0.7rem;
background: #0f1218;
@ -40,6 +40,7 @@ input[type=text], input[type=password] {
color: var(--text);
font-size: 0.95rem;
}
select { appearance: auto; }
button {
margin-top: 1.25rem;
width: 100%;
@ -69,7 +70,10 @@ button:hover { filter: brightness(1.1); }
}
.topbar .brand { font-weight: 700; }
.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.wide { max-width: 1200px; }
.group { margin-bottom: 1.5rem; }
.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; }
@ -84,6 +88,100 @@ button:hover { filter: brightness(1.1); }
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-toolbar {
display: flex; align-items: center; gap: 0.75rem; padding: 0.5rem 0.9rem;

723
static/js/admin.js Normal file
View 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
View 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}`)
);
});
})();

View File

@ -39,6 +39,9 @@
async function main() {
const me = await getJson("/auth/me");
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 byGroup = {};

393
templates/admin.html Normal file
View 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 &middot; 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 &amp; 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
View 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 &middot; 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>

View File

@ -11,6 +11,7 @@
<div class="brand">Jumphost</div>
<div>
<span class="hint" id="whoami"></span>
<a href="/admin" id="admin-link" class="hidden">Admin-Bereich</a>
<button id="logout-btn">Abmelden</button>
</div>
</div>

View 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

View File

@ -67,7 +67,7 @@ def _assert_no_inline_script(html: str, page: str) -> None:
@pytest.mark.asyncio
@pytest.mark.parametrize(
"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):
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)
@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
async def test_login_page_hidden_sections_use_css_class_not_inline_style(client):
"""Die anfangs versteckten Login-Bereiche muessen ueber die `.hidden`