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