From 91758a2701edb0b8b4a0a9ae4fc96862e386400b Mon Sep 17 00:00:00 2001 From: Midas Wollinger Date: Thu, 20 Aug 2026 10:43:19 +0200 Subject: [PATCH] add admin stuff --- README.md | 187 +++++-- app/admin/routes.py | 379 ++++++++++++- app/auth/deps.py | 79 ++- app/catalog/routes.py | 41 +- app/db/migrations/0004_user_groups.sql | 35 ++ app/db/migrations/0005_api_tokens.sql | 26 + app/main.py | 35 +- app/models/schemas.py | 26 + app/rbac.py | 18 +- app/security/api_tokens.py | 65 +++ static/css/app.css | 100 +++- static/js/admin.js | 723 +++++++++++++++++++++++++ static/js/api-docs.js | 76 +++ static/js/dashboard.js | 3 + templates/admin.html | 393 ++++++++++++++ templates/api_docs.html | 32 ++ templates/dashboard.html | 1 + tests/test_admin_groups_tokens.py | 344 ++++++++++++ tests/test_csp_compliance.py | 36 +- 19 files changed, 2514 insertions(+), 85 deletions(-) create mode 100644 app/db/migrations/0004_user_groups.sql create mode 100644 app/db/migrations/0005_api_tokens.sql create mode 100644 app/security/api_tokens.py create mode 100644 static/js/admin.js create mode 100644 static/js/api-docs.js create mode 100644 templates/admin.html create mode 100644 templates/api_docs.html create mode 100644 tests/test_admin_groups_tokens.py diff --git a/README.md b/README.md index 836ba6d..7161a89 100644 --- a/README.md +++ b/README.md @@ -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 `), 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). + diff --git a/app/admin/routes.py b/app/admin/routes.py index 0e5e47b..84d9ff3 100644 --- a/app/admin/routes.py +++ b/app/admin/routes.py @@ -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) diff --git a/app/auth/deps.py b/app/auth/deps.py index 3bb7dd5..47dbf87 100644 --- a/app/auth/deps.py +++ b/app/auth/deps.py @@ -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 '). + '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 diff --git a/app/catalog/routes.py b/app/catalog/routes.py index 66d1d9f..9203cad 100644 --- a/app/catalog/routes.py +++ b/app/catalog/routes.py @@ -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: diff --git a/app/db/migrations/0004_user_groups.sql b/app/db/migrations/0004_user_groups.sql new file mode 100644 index 0000000..9cb2e34 --- /dev/null +++ b/app/db/migrations/0004_user_groups.sql @@ -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); diff --git a/app/db/migrations/0005_api_tokens.sql b/app/db/migrations/0005_api_tokens.sql new file mode 100644 index 0000000..2622dc8 --- /dev/null +++ b/app/db/migrations/0005_api_tokens.sql @@ -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); diff --git a/app/main.py b/app/main.py index e91ecd1..8812807 100644 --- a/app/main.py +++ b/app/main.py @@ -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", {}) diff --git a/app/models/schemas.py b/app/models/schemas.py index 67e38e7..178bb4c 100644 --- a/app/models/schemas.py +++ b/app/models/schemas.py @@ -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 diff --git a/app/rbac.py b/app/rbac.py index 9df6224..44fbca4 100644 --- a/app/rbac.py +++ b/app/rbac.py @@ -1,4 +1,10 @@ -"""RBAC-Durchsetzung: Rolle × Hostgruppe (siehe Konzept 4.6).""" +"""RBAC-Durchsetzung: Rolle × Hostgruppe (siehe Konzept 4.6). + +Eine Rolle gilt fuer einen User entweder, wenn sie ihm DIREKT vergeben wurde +(user_hostgroup_roles), ODER wenn sie einer Benutzergruppe (user_groups) +vergeben wurde, in der der User Mitglied ist (group_hostgroup_roles) -- +volle Rollen-Vererbung: jedes Gruppenmitglied erhaelt automatisch alle der +Gruppe gewaehrten Rollen, ohne individuellen Eintrag.""" from __future__ import annotations 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 diff --git a/app/security/api_tokens.py b/app/security/api_tokens.py new file mode 100644 index 0000000..0803596 --- /dev/null +++ b/app/security/api_tokens.py @@ -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: ":" 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 ":"-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 diff --git a/static/css/app.css b/static/css/app.css index b34cfa9..98b7721 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -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; diff --git a/static/js/admin.js b/static/js/admin.js new file mode 100644 index 0000000..4d8850a --- /dev/null +++ b/static/js/admin.js @@ -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")); +})(); diff --git a/static/js/api-docs.js b/static/js/api-docs.js new file mode 100644 index 0000000..1258335 --- /dev/null +++ b/static/js/api-docs.js @@ -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}`) + ); + }); +})(); diff --git a/static/js/dashboard.js b/static/js/dashboard.js index 80bb605..7246b74 100644 --- a/static/js/dashboard.js +++ b/static/js/dashboard.js @@ -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 = {}; diff --git a/templates/admin.html b/templates/admin.html new file mode 100644 index 0000000..0670c2d --- /dev/null +++ b/templates/admin.html @@ -0,0 +1,393 @@ + + + + + + Jumphost Admin + + + +
+
Jumphost · Admin
+
+ + Zum Dashboard + API-Dokumentation + +
+
+ +
+ + +
+ + + + + + +
+ + +
+
+

Neuen Benutzer anlegen

+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ +
+

Benutzer

+
+ + + + + + + + +
IDBenutzernameAdminAktivTOTPErstellt
+
+
+
+ + + + + + + + + + + + + + + +
+ + + + diff --git a/templates/api_docs.html b/templates/api_docs.html new file mode 100644 index 0000000..0709b2a --- /dev/null +++ b/templates/api_docs.html @@ -0,0 +1,32 @@ + + + + + + Jumphost API-Dokumentation + + + +
+
Jumphost · API-Dokumentation
+ +
+ +
+
+

API-Uebersicht

+

+ Nur fuer eingeloggte Admins sichtbar (kein oeffentliches /docs). Liest + das OpenAPI-Schema unter /openapi.json aus. Bewusst kein + CDN-bezogenes Swagger-UI-Bundle -- diese Ansicht ist vollstaendig + selbst gehostet, ohne Laufzeit-Abhaengigkeit zu Drittanbietern. +

+
+
+
+ + + + diff --git a/templates/dashboard.html b/templates/dashboard.html index cc98f15..71a65a0 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -11,6 +11,7 @@
Jumphost
+
diff --git a/tests/test_admin_groups_tokens.py b/tests/test_admin_groups_tokens.py new file mode 100644 index 0000000..bf96bd7 --- /dev/null +++ b/tests/test_admin_groups_tokens.py @@ -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 diff --git a/tests/test_csp_compliance.py b/tests/test_csp_compliance.py index 589e68f..928ec29 100644 --- a/tests/test_csp_compliance.py +++ b/tests/test_csp_compliance.py @@ -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`