Compare commits
22
Commits
75077f9101
..
main
@@ -51,6 +51,12 @@ SSHKV_OIDC_GROUP_READER=sshkv-readers
|
||||
# Nur Entwicklung: OIDC ueber http erlauben (Mock-Anbieter). Im Betrieb nie setzen.
|
||||
# SSHKV_OIDC_HTTP_ERLAUBT=1
|
||||
|
||||
# --- Lokale Konten (#035) -------------------------------------------------------
|
||||
# Admins legen unter /einstellungen/benutzer Konten mit Passwort und Pflicht-TOTP an – neben
|
||||
# oder statt authentik. 0 schaltet sie ab (vorhandene lokale Konten kommen dann nicht mehr
|
||||
# hinein); der Notfall-Admin bleibt immer.
|
||||
SSHKV_LOKALE_KONTEN=1
|
||||
|
||||
# --- Notfall-Admin -------------------------------------------------------------
|
||||
# Wird beim ersten Seed angelegt. TOTP wird bei der ersten Anmeldung eingerichtet.
|
||||
SSHKV_BREAKGLASS_USER=notfall
|
||||
|
||||
@@ -8,10 +8,20 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
||||
## [Unveröffentlicht]
|
||||
|
||||
### Hinzugefügt
|
||||
- Geführte Rotation des Deployment-Keys in fünf Schritten (erzeugen, verteilen, prüfen, umstellen, aufräumen) – kein Host steht dabei ohne funktionierenden Key da; nicht erreichbare Hosts lassen sich bewusst übergehen (#031)
|
||||
- Laufende Schlüsselrotation abbrechen – der Nachfolger wird per Dry-Run wieder entfernt (#032)
|
||||
- „Zum Dry-Run“ in der Zuweisungsmatrix springt direkt zur Dry-Run-Karte (#032)
|
||||
- Nach einer abgebrochenen Rotation lässt sich derselbe Schlüssel erneut rotieren (#033)
|
||||
- Rotation des Deployment-Keys lässt sich vor dem Umstellen abbrechen; der neue Key wird verworfen und per Dry-Run wieder entfernt (#036)
|
||||
- Lokale Konten mit Passwort und Pflicht-TOTP neben oder statt authentik: Admins legen sie unter Einstellungen → Benutzer an (Einmal-Passwort, Rollen, Sperren, Zurücksetzen); abschaltbar mit `SSHKV_LOKALE_KONTEN=0` (#035)
|
||||
|
||||
### Geändert
|
||||
- Protokoll-IDs werden lückenlos vergeben (#032)
|
||||
|
||||
### Behoben
|
||||
- Aktionen der Benutzerverwaltung erscheinen im Audit-Log unter einer eigenen Gruppe „Benutzer“ statt unter „Anmeldung“ (#035)
|
||||
- Massenwiderruf lehnte im Browser erzeugte Schlüssel ab (IDs mit Bindestrich) (#032)
|
||||
- Hinweis nach verweigerter Bestätigung nannte beim Massenwiderruf den falschen Knopf (#032)
|
||||
|
||||
### Sicherheit
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
## Zeitübersicht
|
||||
|
||||
**Gesamt (Wandzeit):** 4 h 25 min
|
||||
**Summe der Issue-Zeiten:** 13 h 18 min – Sub-Agenten arbeiteten parallel an #002, #006, #008, #011, #012–#014, #019 (Oberfläche), #023–#025, #027 und #028.
|
||||
**Gesamt (Wandzeit):** 6 h 40 min
|
||||
**Summe der Issue-Zeiten:** 16 h 38 min – Sub-Agenten arbeiteten parallel an #002, #006, #008, #011, #012–#014, #019 (Oberfläche), #023–#025, #027, #028 und #032.
|
||||
|
||||
| Issue | Zeit |
|
||||
|---|---|
|
||||
@@ -37,7 +37,13 @@
|
||||
| #027 Protokollansicht, CSV, Syslog | 0 h 20 min |
|
||||
| #028 Benachrichtigungen | 0 h 30 min |
|
||||
| #029 Handbuch und Betriebsdokumentation | 0 h 50 min |
|
||||
| #030 Gitea-Abgleich (vorbereitet) | 0 h 05 min |
|
||||
| #030 Gitea-Abgleich | 0 h 05 min |
|
||||
| #031 Rotation des Deployment-Keys | 1 h 10 min |
|
||||
| #032 Nacharbeiten Phase 4/5 | 0 h 45 min |
|
||||
| #033 Erneut rotieren nach Abbruch | 0 h 20 min |
|
||||
| #034 Abnahme-Checkliste | 0 h 10 min |
|
||||
| #035 Lokale Benutzerkonten | 0 h 35 min |
|
||||
| #036 Deployment-Key-Rotation abbrechen | 0 h 20 min |
|
||||
|
||||
| Meilenstein | Zeit |
|
||||
|---|---|
|
||||
@@ -46,6 +52,7 @@
|
||||
| v0.3.0 | 0 h 30 min (Wandzeit) | **abgeschlossen**
|
||||
| v0.4.0 | 0 h 45 min (Wandzeit) | **abgeschlossen**
|
||||
| v0.5.0 | 0 h 45 min (Wandzeit) | **abgeschlossen**
|
||||
| v1.0.0 | 2 h 15 min (Wandzeit) | in Arbeit – Abnahme steht aus
|
||||
|
||||
---
|
||||
|
||||
@@ -149,3 +156,49 @@
|
||||
- **Master-Key nicht in die Sicherung,** nur ein Prüfwert; Worker verweigert den Start mit falschem Master-Key.
|
||||
- **Benachrichtigungen lesen ihre Anlässe aus der Datenbank** statt sich in den Abgleich einzuhängen; Doppelmeldungen verhindert ein Versandprotokoll mit Fingerabdruck je Anlass.
|
||||
- **Keine 1.0.0 ohne Abnahme:** 0.5.0 schließt die fünf Phasen; authentik, Gitea und ein Lauf auf echten Hosts stehen noch aus.
|
||||
|
||||
## 2026-10-12 · Sitzung 6 · 17:12–18:07
|
||||
**Issue:** #030–#032
|
||||
**Version:** unveröffentlicht (auf dem Weg zu 1.0.0)
|
||||
|
||||
### Erledigt
|
||||
- Remote-Repo in Gitea: `main` und Tags v0.1.0–v0.5.0 gepusht, Issues abgeglichen (Basic-Auth, weil die hinterlegten Zugangsdaten ein Passwort sind)
|
||||
- #031 Geführte Rotation des Deployment-Keys: Service, Worker-Schritte, Seite mit Schrittleiste, Integrationstest gegen beide Testziele, E2E
|
||||
- #032 Nacharbeiten (Sub-Agent): lückenlose Protokoll-IDs, Rotation abbrechen, Anker „Zum Dry-Run“, Massenwiderruf-E2E – der fand, dass der Massenwiderruf UUIDs mit Bindestrich abwies
|
||||
- Stand: 876 Unit-/DB-Tests, 46 Integrationstests, 27 E2E-Abläufe grün
|
||||
|
||||
### Entscheidungen
|
||||
- **Der Nachfolger des Deployment-Keys ist ein eigener Schlüssel,** der während der Rotation über `sollFuer()` in Block und Pflichtliste steht – so verteilt ihn der normale Dry-Run, und der Aussperrschutz deckt beide Keys.
|
||||
- **Umstellen erst nach Prüfung** auf allen Hosts; ein Host lässt sich nur bewusst und protokolliert übergehen.
|
||||
- **Rotation abbrechen nutzt den vorhandenen Widerruf** statt eines neuen Zustands (#032).
|
||||
- **Protokoll-ID nur noch aus dem Trigger** unter der Advisory-Sperre – keine Lücken mehr durch verworfene Sequenzwerte.
|
||||
|
||||
## 2026-10-12 · Sitzung 7 · 19:48–20:18
|
||||
**Issue:** #033, #034
|
||||
**Version:** unveröffentlicht (auf dem Weg zu 1.0.0)
|
||||
|
||||
### Erledigt
|
||||
- #033 Nach einer abgebrochenen Rotation lässt sich derselbe Schlüssel erneut rotieren (1:n-Relation, partieller Index)
|
||||
- #034 Abnahme-Checkliste `docs/betrieb/abnahme.md` für authentik, echte Hosts und Betrieb
|
||||
- Stand: 877 Unit-/DB-Tests, 46 Integrationstests grün; E2E Rotation, Widerruf, Schlüssel grün
|
||||
|
||||
### Entscheidungen
|
||||
- **Je Vorgänger höchstens ein offener Nachfolger** statt genau einem: abgebrochene bleiben in der Historie, maßgeblich ist der jüngste.
|
||||
- **Neustart erst nach abgeschlossenem Abbruch** – sonst stünden zwei neue Schlüssel gleichzeitig auf den Hosts.
|
||||
- **Bestätigung „Rotation abgebrochen“ bleibt sichtbar** über dem Neustart, statt kommentarlos auf Schritt 1 zu springen (fand der E2E-Test).
|
||||
|
||||
## 2026-10-12 · Sitzung 8 · 22:05–22:55
|
||||
**Issue:** #035, #036
|
||||
**Version:** unveröffentlicht (auf dem Weg zu 1.0.0)
|
||||
|
||||
### Erledigt
|
||||
- #035 Lokale Benutzerkonten mit Pflicht-TOTP: Einmal-Passwort, eigenes Passwort bei der ersten Anmeldung, Benutzerverwaltung unter Einstellungen, Schalter `SSHKV_LOKALE_KONTEN`
|
||||
- #036 Rotation des Deployment-Keys vor dem Umstellen abbrechen
|
||||
- Stand: 911 Unit-/DB-Tests, 47 Integrationstests, 29 E2E-Abläufe grün
|
||||
|
||||
### Entscheidungen
|
||||
- **TOTP auch für lokale Konten Pflicht; lokale Konten dauerhaft, aber abschaltbar** (Florian).
|
||||
- **Admins wählen keine Passwörter für andere** – sshkv erzeugt ein Einmal-Passwort, das genau einmal angezeigt wird.
|
||||
- **`SSHKV_LOKALE_KONTEN` muss in `docker-compose.yml` stehen:** Compose reicht nur ausdrücklich genannte Variablen an den Web-Container weiter – sonst hätte der Schalter im Betrieb nicht gewirkt.
|
||||
- **Testreste machen Produktlücken sichtbar:** Eine hängengebliebene Rotation auf verwaisten Testhosts zeigte, dass es keinen Weg zurück gab (#036). Die E2E-Tests räumen solche Reste jetzt selbst ab.
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ Notfallplan) und [`docs/betrieb/sicherung.md`](docs/betrieb/sicherung.md).
|
||||
- [Installation und Betrieb](docs/betrieb/installation.md) – Docker Compose, authentik, Reverse Proxy
|
||||
- [Sicherung und Wiederherstellung](docs/betrieb/sicherung.md) – `deploy/sichern.sh`, `deploy/wiederherstellen.sh`
|
||||
- [Sicherheit](docs/betrieb/sicherheit.md) – Aufbau, Bedrohungsanalyse, Notfallplan
|
||||
- [Abnahme vor 1.0.0](docs/betrieb/abnahme.md) – Prüfliste für authentik, echte Hosts und Betrieb
|
||||
|
||||
## Installation (Docker Compose)
|
||||
|
||||
|
||||
@@ -64,6 +64,8 @@ services:
|
||||
SSHKV_OIDC_GROUP_ADMIN: ${SSHKV_OIDC_GROUP_ADMIN:-sshkv-admins}
|
||||
SSHKV_OIDC_GROUP_OPERATOR: ${SSHKV_OIDC_GROUP_OPERATOR:-sshkv-operators}
|
||||
SSHKV_OIDC_GROUP_READER: ${SSHKV_OIDC_GROUP_READER:-sshkv-readers}
|
||||
# Lokale Konten mit Passwort und TOTP (#035); 0 schaltet sie ab, der Notfall-Admin bleibt.
|
||||
SSHKV_LOKALE_KONTEN: ${SSHKV_LOKALE_KONTEN:-1}
|
||||
# Syslog-Kopie des Protokolls (#027) – optional, leer heisst aus.
|
||||
SSHKV_SYSLOG_HOST: ${SSHKV_SYSLOG_HOST:-}
|
||||
SSHKV_SYSLOG_PORT: ${SSHKV_SYSLOG_PORT:-514}
|
||||
|
||||
@@ -40,9 +40,13 @@ in die Verwaltung.
|
||||
|
||||
- **Mit authentik anmelden** – der Normalfall. Ihre Rolle (Leser, Operator, Admin) kommt aus den
|
||||
authentik-Gruppen und wird bei jeder Anmeldung neu bestimmt.
|
||||
- **Notfallzugang** – nur, wenn authentik ausfällt. Anmeldename, Passwort und ein Code aus einer
|
||||
Authenticator-App. Bei der ersten Anmeldung richtet der Notfall-Admin den zweiten Faktor ein
|
||||
(QR-Code scannen, Code bestätigen).
|
||||
- **Lokales Konto** – ohne authentik oder zusätzlich dazu. Ein Admin legt das Konto an und gibt
|
||||
Ihnen ein **Einmal-Passwort**. Bei der ersten Anmeldung (Abschnitt „Lokales Konto oder
|
||||
Notfallzugang“) setzen Sie ein eigenes Passwort (mindestens 12 Zeichen) und richten den zweiten
|
||||
Faktor ein: QR-Code mit einer Authenticator-App scannen, Code bestätigen. Danach immer
|
||||
Anmeldename, Passwort und Code.
|
||||
- **Notfallzugang** – nur, wenn authentik ausfällt. Gleiches Formular; der Notfall-Admin richtet
|
||||
bei der ersten Anmeldung ebenfalls den zweiten Faktor ein.
|
||||
|
||||
Kritische Aktionen (Private Key anzeigen, Benutzer mit sudo anlegen, Massenwiderruf, Host-Key
|
||||
ersetzen) verlangen eine **erneute Bestätigung**; sie gilt fünf Minuten.
|
||||
@@ -170,6 +174,15 @@ auswählen (nur Admin).
|
||||
|
||||

|
||||
|
||||
**Deployment-Key rotieren** (Einstellungen → Instanz, nur Admin): fünf Schritte – neuen Key
|
||||
**erzeugen**, per Dry-Run auf alle Hosts **verteilen** (er steht dann neben dem alten), die Anmeldung
|
||||
mit dem neuen Key **prüfen**, **umstellen** und zuletzt den alten per Dry-Run **aufräumen**. Scheitert
|
||||
die Prüfung auf einem Host, lässt er sich bewusst übergehen; sshkv erreicht ihn danach nur noch nach
|
||||
einem neuen Bootstrap. Solange noch nicht umgestellt ist, lässt sich die
|
||||
Rotation **abbrechen**: Der neue Key wird verworfen, ein Dry-Run nimmt ihn wieder von den Hosts.
|
||||
|
||||

|
||||
|
||||
## 8. Personen, Self-Service und Zuweisungsmatrix
|
||||
|
||||
- **Personen:** Admins, Mitarbeiter, Dienstleister mit ihren Schlüsseln. Private Keys von Personen
|
||||
@@ -217,6 +230,16 @@ weder ändern noch löschen; jeder Eintrag ist per Hash mit seinem Vorgänger ve
|
||||
|
||||
## 12. Einstellungen und Benachrichtigungen
|
||||
|
||||
**Benutzer** (Einstellungen → Instanz → „Konten verwalten“, nur Admin): lokale Konten anlegen –
|
||||
sshkv erzeugt das Einmal-Passwort und zeigt es **genau einmal** –, Rollen ändern, Konten sperren
|
||||
(beendet laufende Sitzungen sofort) und zurücksetzen (neues Einmal-Passwort, TOTP neu einrichten,
|
||||
etwa nach verlorenem Telefon). Konten aus authentik erscheinen nach ihrer ersten Anmeldung; ihre
|
||||
Rolle kommt aus den Gruppen, sie lassen sich hier nur sperren. Der Notfall-Admin ist
|
||||
unveränderlich.
|
||||
|
||||

|
||||
|
||||
|
||||
Unter **Einstellungen** (ändern nur Admins) richten Sie Benachrichtigungen ein:
|
||||
|
||||
- **E-Mail** über SMTP – für Microsoft 365 vorbelegt (`smtp.office365.com:587`, STARTTLS).
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 128 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 151 KiB |
@@ -0,0 +1,143 @@
|
||||
# Abnahme vor Version 1.0.0
|
||||
|
||||
Was die automatischen Tests **nicht** abdecken können: der echte Anbieter (authentik), echte Hosts
|
||||
und der Betrieb auf dem Zielserver. Diese Liste geht genau das durch. Jeder Punkt nennt das
|
||||
erwartete Ergebnis; was abweicht, kommt als Issue in `docs/issues/` (oder direkt als Notiz hier
|
||||
darunter).
|
||||
|
||||
Voraussetzung: Installation nach [installation.md](installation.md) ist durch, `docker compose ps`
|
||||
zeigt `db`, `web` und `worker` als *healthy*.
|
||||
|
||||
**Abnahme durch:** ____________________ **Datum:** __________ **Version/Commit:** __________
|
||||
|
||||
---
|
||||
|
||||
## A · Installation und Grundschutz
|
||||
|
||||
- [ ] `https://<sshkv>/api/gesundheit` antwortet mit `{"status":"ok","version":…}`; über `http://` gibt es keine Anmeldung
|
||||
(Secure-Cookie).
|
||||
- [ ] Von außerhalb des internen Netzes ist die Oberfläche **nicht** erreichbar (Reverse Proxy /
|
||||
Firewall liefert 403 oder Timeout).
|
||||
- [ ] Der Web-Container sieht den Master-Key nicht:
|
||||
`docker compose exec web ls /run/secrets` → leer bzw. Fehler.
|
||||
- [ ] Worker-Log beim Start: Master-Key passt, Deployment-Key vorhanden
|
||||
(`docker compose logs worker | head`).
|
||||
- [ ] Antwort-Header prüfen (`curl -sI https://<sshkv>/anmelden`): `Content-Security-Policy`,
|
||||
`Strict-Transport-Security`, `X-Frame-Options`/`frame-ancestors`, `Referrer-Policy`.
|
||||
|
||||
## B · Anmeldung mit authentik
|
||||
|
||||
- [ ] Anmelden mit einem Konto in `sshkv-admins` → Rolle **Admin** (unten links in der Navigation).
|
||||
- [ ] Konto in `sshkv-operators` → **Operator**; Konto in `sshkv-readers` → **Leser**.
|
||||
- [ ] Konto in **keiner** dieser Gruppen → kein Zugang, verständliche Meldung, Eintrag
|
||||
`anmeldung.verweigert` im Audit-Log.
|
||||
- [ ] Gruppe in authentik entziehen → bei der nächsten Anfrage gilt die neue Rolle bzw. kein
|
||||
Zugang (die Rolle kommt aus der Datenbank, nicht aus dem alten Token – spätestens nach
|
||||
erneuter Anmeldung).
|
||||
- [ ] **Erneute Bestätigung:** als Admin „Private Key anzeigen“ wählen → authentik fragt das
|
||||
Passwort (und ggf. MFA) **wirklich neu** ab; danach geht die Aktion fünf Minuten lang ohne
|
||||
erneute Frage. *Kritischer Punkt:* Kommt authentik ohne neue Eingabe zurück, meldet sshkv
|
||||
„auth_time fehlt oder ist zu alt“ – dann die Flow-Einstellung in authentik prüfen.
|
||||
- [ ] Abmelden beendet die Sitzung (Zurück-Knopf zeigt keine Daten mehr).
|
||||
|
||||
## B2 · Lokale Konten (wenn ohne oder neben authentik)
|
||||
|
||||
- [ ] Einstellungen → „Konten verwalten“ → Konto anlegen (frische Bestätigung nötig) → das
|
||||
Einmal-Passwort erscheint genau einmal; nach Neuladen ist es weg.
|
||||
- [ ] Erste Anmeldung im privaten Browserfenster: eigenes Passwort setzen (unter 12 Zeichen wird
|
||||
abgelehnt), TOTP einrichten, Rolle stimmt (unten links „über lokales Konto“).
|
||||
- [ ] Sperren → die Sitzung im anderen Fenster endet beim nächsten Klick; Entsperren → wieder
|
||||
anmeldbar.
|
||||
- [ ] Zurücksetzen → neues Einmal-Passwort, TOTP wird neu eingerichtet.
|
||||
- [ ] Audit-Log enthält Anlegen, Passwort gesetzt, Sperren – **kein** Passwort im Klartext.
|
||||
- [ ] Mit `SSHKV_LOKALE_KONTEN=0` und `docker compose up -d`: lokales Konto kommt nicht mehr
|
||||
hinein, der Notfall-Admin schon.
|
||||
|
||||
## C · Notfall-Admin
|
||||
|
||||
- [ ] `/anmelden` → „Notfallzugang“: Benutzer `notfall` + Passwort aus `.env` → TOTP-Einrichtung
|
||||
erzwungen (QR-Code mit Authenticator-App scannen).
|
||||
- [ ] Erneute Anmeldung verlangt Passwort **und** TOTP-Code; falscher Code wird abgelehnt.
|
||||
- [ ] Nach fünf Fehlversuchen in 15 Minuten → „Zu viele Fehlversuche“, auch mit richtigem Passwort.
|
||||
- [ ] `SSHKV_BREAKGLASS_PASSWORD` danach aus der `.env` entfernen (der Hash steht in der
|
||||
Datenbank) und das Passwort im Passwortmanager ablegen.
|
||||
|
||||
## D · Echte Hosts aufnehmen
|
||||
|
||||
Mindestens zwei Hosts, möglichst unterschiedlich (z. B. Debian 12 und Ubuntu 24.04), einer mit
|
||||
Anmeldeart *Dienstbenutzer*, einer mit *root*.
|
||||
|
||||
- [ ] Host anlegen → „Host-Key abrufen“ → Fingerprint mit dem Host vergleichen
|
||||
(`ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub` **auf dem Host**) → vertrauen.
|
||||
- [ ] Bootstrap per Einmal-Passwort: danach existiert `sshkv` mit `/etc/sudoers.d/sshkv`, das
|
||||
Passwort steht **nirgends** (Audit-Log, `docker compose logs`, Datenbank:
|
||||
`grep -r` im Dump findet es nicht).
|
||||
- [ ] Bootstrap per Kopier-Befehl auf dem zweiten Host: Befehl in die Root-Shell einfügen, danach
|
||||
„Einrichtung erneut prüfen“ → eingerichtet.
|
||||
- [ ] „Verbindung prüfen“ → erreichbar; sshd-Befund wird angezeigt (z. B. PasswordAuthentication).
|
||||
- [ ] Host-Key auf einem Host absichtlich tauschen (`ssh-keygen -A` nach Löschen der Keys,
|
||||
sshd neu starten) → nächster Abgleich meldet **Host-Key geändert**, sshkv verbindet sich
|
||||
nicht mehr, bis ein Admin den neuen Key bestätigt. Danach zurückdrehen.
|
||||
|
||||
## E · Schlüssel ausrollen (Kern-Anwendungsfall)
|
||||
|
||||
- [ ] Vorhandene `authorized_keys` eines Hosts mit eigenen Zeilen (Kommentare, Optionen, leere
|
||||
Zeilen) vorher sichern: `cp ~/.ssh/authorized_keys /tmp/vorher`.
|
||||
- [ ] Public Key eines Admins auf beide Hosts ausrollen → Dry-Run zeigt **nur** den verwalteten
|
||||
Block; nach „Anwenden“ ist alles außerhalb des Blocks **byte-gleich**
|
||||
(`diff` gegen `/tmp/vorher` zeigt nur den Block).
|
||||
- [ ] Backup-Datei `authorized_keys.sshkv.bak.*` liegt daneben; Rechte 0600, Verzeichnis 0700.
|
||||
- [ ] Host-zu-Host-Assistent: Vorlage „Deployment-Zugang“ von Host A nach Host B → Verbindungstest
|
||||
grün; auf A: `ssh <alias> id -un` funktioniert ohne Rückfrage (known_hosts gesetzt).
|
||||
- [ ] Datei `~/.ssh/config` auf A: eigener Inhalt außerhalb des sshkv-Blocks unverändert.
|
||||
- [ ] Zielkonto anlegen (z. B. `deploy` mit sudo) → Benutzer und sudoers-Datei entstehen,
|
||||
`visudo -c` auf dem Host ist sauber.
|
||||
|
||||
## F · Drift, Rotation, Widerruf
|
||||
|
||||
- [ ] Im verwalteten Block auf einem Host von Hand eine Zeile ändern → Abgleich meldet
|
||||
Abweichung; Übersicht zeigt den Host; Dry-Run stellt den Soll-Stand wieder her.
|
||||
- [ ] Fremden Key außerhalb des Blocks eintragen → Abgleich meldet **unbekannten Schlüssel**;
|
||||
„übernehmen“ funktioniert.
|
||||
- [ ] Geführte Rotation des Assistenten-Schlüssels: Alias auf A funktioniert vorher, während und
|
||||
nachher; alte Datei ist weg.
|
||||
- [ ] Rotation einmal **abbrechen** und danach erneut rotieren (#032, #033).
|
||||
- [ ] Widerruf eines Schlüssels mit einem nicht erreichbaren Host (Host kurz stoppen) →
|
||||
„Widerruf unvollständig“; nach dem Wiederanlauf abschließbar.
|
||||
- [ ] **Deployment-Key rotieren** (Einstellungen → Instanz) über alle echten Hosts – danach
|
||||
klappen Abgleich und Ausrollen weiter; der alte Fingerprint steht auf keinem Host mehr.
|
||||
- [ ] Aussperrschutz: Den Deployment-Key auf einem Host per Hand aus dem Block löschen und
|
||||
ausrollen → sshkv schreibt ihn zurück bzw. verweigert eine Änderung, die ihn entfernen würde.
|
||||
|
||||
## G · Audit-Log und Benachrichtigungen
|
||||
|
||||
- [ ] Audit-Log zeigt alle Aktionen aus D–F mit Akteur; „Kette prüfen“ → intakt.
|
||||
- [ ] CSV-Export öffnet sich sauber in LibreOffice/Excel (Umlaute, Trennzeichen).
|
||||
- [ ] Syslog-Kopie kommt beim Zielserver an (falls `SSHKV_SYSLOG_*` gesetzt).
|
||||
- [ ] Testversand E-Mail (Microsoft 365) und Webhook aus den Einstellungen kommt an.
|
||||
- [ ] Am nächsten Morgen: tägliche Zusammenfassung ist da und nennt die offenen Befunde.
|
||||
|
||||
## H · Sicherung und Wiederherstellung
|
||||
|
||||
- [ ] `sudo bash deploy/sichern.sh` läuft durch; Sicherung landet am vorgesehenen Ort.
|
||||
- [ ] Master-Key **getrennt** gesichert (Passwortmanager/Tresor), Wiederauffinden geprobt.
|
||||
- [ ] Wiederherstellung auf einer **Wegwerf-Instanz** nach [sicherung.md](sicherung.md): danach
|
||||
Anmeldung, Private Key anzeigen und ein Abgleich funktionieren.
|
||||
- [ ] Mit falschem Master-Key verweigert der Worker den Start.
|
||||
|
||||
## I · Oberfläche
|
||||
|
||||
- [ ] Smartphone (schmale Ansicht): Navigation, Übersicht, Hostliste und Dry-Run bedienbar.
|
||||
- [ ] Hell/dunkel: Die App ist bewusst dunkel (Tresor-Design); Kontrast in heller Umgebung
|
||||
ausreichend lesbar.
|
||||
- [ ] Handbuch (`docs/anleitung/README.md`) passt zu dem, was die Oberfläche zeigt.
|
||||
|
||||
---
|
||||
|
||||
## Ergebnis
|
||||
|
||||
- [ ] Alle Punkte erledigt oder als Issue erfasst und als *nicht blockierend* eingestuft.
|
||||
- [ ] **Freigabe für 1.0.0** – danach: Version in `package.json`, CHANGELOG-Abschnitt `[1.0.0]`,
|
||||
Tag `v1.0.0`, Push.
|
||||
|
||||
### Notizen
|
||||
@@ -71,6 +71,20 @@ Erneute Bestätigung vor kritischen Aktionen schickt `prompt=login` und `max_age
|
||||
| Operator | zusätzlich Hosts verwalten, Host-Keys bestätigen, Schlüssel erzeugen, Public Keys ausrollen, abgleichen |
|
||||
| Admin | zusätzlich Private Keys ausrollen/anzeigen/löschen, Zielkonten mit sudo, Massenwiderruf, Einstellungen, Vorlagen |
|
||||
|
||||
## Ohne authentik starten: lokale Konten
|
||||
|
||||
authentik ist nicht Pflicht. Ohne `SSHKV_OIDC_*` zeigt die Anmeldeseite nur das Formular für
|
||||
lokale Konten und den Notfallzugang:
|
||||
|
||||
1. Als **Notfall-Admin** anmelden (TOTP einrichten).
|
||||
2. Einstellungen → „Konten verwalten“ → für jede Person ein Konto mit passender Rolle anlegen und
|
||||
das Einmal-Passwort auf sicherem Weg weitergeben.
|
||||
3. Für den Alltag die eigenen Konten nutzen, den Notfall-Admin nur im Notfall – so bleibt im
|
||||
Audit-Log erkennbar, wer was getan hat.
|
||||
|
||||
Später lässt sich authentik dazuschalten; lokale Konten und OIDC laufen nebeneinander.
|
||||
`SSHKV_LOKALE_KONTEN=0` schaltet die lokalen Konten danach ab.
|
||||
|
||||
## Reverse Proxy (Beispiel Caddy)
|
||||
|
||||
```caddyfile
|
||||
|
||||
@@ -37,7 +37,11 @@ Private Key steht. Bootstrap-Passwörter werden nie gespeichert.
|
||||
- **Rollen:** Private Keys anzeigen, ausrollen und löschen, Deployment-Key, Zielkonten mit sudo und
|
||||
Massenwiderruf nur für Admins; kritische Aktionen zusätzlich mit erneuter Anmeldung (5 Minuten).
|
||||
Ohne passende authentik-Gruppe gibt es keinen Zugang, auch keinen lesenden.
|
||||
- **Notfall-Admin** nur mit Passwort **und** TOTP; Rate-Limit gegen Raten.
|
||||
- **Notfall-Admin und lokale Konten** nur mit Passwort **und** TOTP; Rate-Limit gegen Raten
|
||||
(5 Fehlversuche je Anmeldename in 15 Minuten). Lokale Konten bekommen ein vom System erzeugtes
|
||||
Einmal-Passwort, das bei der ersten Anmeldung ersetzt werden muss – Admins kennen die Passwörter
|
||||
anderer nie. Sperren beendet Sitzungen sofort; `SSHKV_LOKALE_KONTEN=0` schaltet lokale Konten
|
||||
ganz ab.
|
||||
- **Weboberfläche:** Content-Security-Policy mit Nonce, `X-Frame-Options: DENY`, `Referrer-Policy:
|
||||
no-referrer`, HttpOnly-/SameSite-Cookies, Secure hinter HTTPS; Server Actions mit Origin-Prüfung.
|
||||
|
||||
@@ -67,8 +71,12 @@ Private Key steht. Bootstrap-Passwörter werden nie gespeichert.
|
||||
5. **Protokoll auswerten:** Syslog-Kopie (unveränderlich außerhalb von sshkv) mit dem Protokoll
|
||||
vergleichen; `protokoll_kette_pruefen()` zeigt manipulierte Zeilen.
|
||||
|
||||
> Eine eingebaute **Rotation des Deployment-Keys** gibt es noch nicht (siehe `docs/issues`). Bis dahin
|
||||
> ist Schritt 2 Handarbeit.
|
||||
> **Rotation des Deployment-Keys** (Einstellungen → Instanz → „Deployment-Key rotieren“, nur Admin mit
|
||||
> frischer Bestätigung): regelmäßig, oder wenn nur der Key abgeflossen sein kann – etwa ein verlorenes
|
||||
> Backup samt Master-Key. Der neue Key kommt erst neben den alten, wird auf jedem Host geprüft und löst
|
||||
> den alten dann ab; zuletzt wird der alte aus allen Blöcken entfernt und sein Private Key verworfen.
|
||||
> Ist sshkv **selbst** kompromittiert, hilft das nicht – der Instanz ist dann nicht mehr zu trauen,
|
||||
> Schritt 2 bleibt Handarbeit.
|
||||
|
||||
## Betriebsregeln
|
||||
|
||||
|
||||
@@ -1,25 +1,30 @@
|
||||
# #031 Rotation des Deployment-Keys
|
||||
**Status:** offen
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v1.0.0
|
||||
**Labels:** sicherheit, backend
|
||||
**Geschätzt:** 3 h **Tatsächlich:** –
|
||||
**Geschätzt:** 3 h **Tatsächlich:** 1 h 10 min
|
||||
|
||||
## Ziel
|
||||
Den Deployment-Key der Instanz geführt tauschen – regelmäßig oder im Notfall (sshkv kompromittiert, siehe `docs/betrieb/sicherheit.md`).
|
||||
|
||||
## Aufgaben
|
||||
- [ ] Neuen Deployment-Key erzeugen, ohne den alten abzulösen (zweiter Instanz-Key im Übergang)
|
||||
- [ ] Auf jedem eingerichteten Host den neuen Key neben den alten in den verwalteten Block des Anmeldebenutzers schreiben (Dry-Run, Ausrollen)
|
||||
- [ ] Verbindung mit dem neuen Key je Host prüfen
|
||||
- [ ] Erst wenn alle Hosts den neuen Key akzeptieren: alten aus allen Blöcken entfernen, alten Key verwerfen
|
||||
- [ ] Nicht erreichbare Hosts sichtbar machen; Abschluss erst, wenn alle umgestellt sind (oder bewusst übergangen)
|
||||
- [ ] Nur Admin mit frischer Bestätigung, alles protokolliert
|
||||
- [x] Neuen Deployment-Key erzeugen, ohne den alten abzulösen (zweiter Instanz-Key im Übergang)
|
||||
- [x] Auf jedem eingerichteten Host den neuen Key neben den alten in den verwalteten Block des Anmeldebenutzers schreiben (Dry-Run, Ausrollen)
|
||||
- [x] Verbindung mit dem neuen Key je Host prüfen
|
||||
- [x] Erst wenn alle Hosts den neuen Key akzeptieren: alten aus allen Blöcken entfernen, alten Key verwerfen
|
||||
- [x] Nicht erreichbare Hosts sichtbar machen; Abschluss erst, wenn alle umgestellt sind (oder bewusst übergangen)
|
||||
- [x] Nur Admin mit frischer Bestätigung, alles protokolliert
|
||||
|
||||
## Akzeptanzkriterien
|
||||
- [ ] Integrationstest: nach der Rotation meldet sich sshkv nur noch mit dem neuen Key an, der alte steht auf keinem Host mehr
|
||||
- [ ] Aussperrschutz: zu keinem Zeitpunkt steht ein Host ohne funktionierenden Deployment-Key da
|
||||
- [x] Integrationstest: nach der Rotation meldet sich sshkv nur noch mit dem neuen Key an, der alte steht auf keinem Host mehr
|
||||
- [x] Aussperrschutz: zu keinem Zeitpunkt steht ein Host ohne funktionierenden Deployment-Key da
|
||||
|
||||
## Abhängigkeiten
|
||||
#016, #021
|
||||
|
||||
## Notizen / Entscheidungen
|
||||
- Der Nachfolger ist ein eigener Schlüssel (`kategorie DEPLOYMENT`, `vorgaengerId` = alter, noch nicht `instanzStandard`). `sollFuer()` nimmt ihn für alle Hosts mit dem Instanz-Key zusätzlich in Block **und** Pflichtliste – der normale Dry-Run verteilt ihn also, und der Aussperrschutz schützt beide.
|
||||
- Hosts mit eigenem oder Gruppen-Deployment-Key nehmen nicht teil.
|
||||
- Umstellen ist eine Transaktion (alter ROTIERT, neuer Standard) und verlangt eine Prüfung **nach** dem Erzeugen, in der jeder Host den neuen Key angenommen hat – oder bewusst übergangen wird (protokolliert).
|
||||
- Abschließen prüft je Host, dass der alte Fingerprint aus dem Block verschwunden ist; erst dann wird sein Private Key verworfen. Unerreichbare Hosts blockieren den Abschluss nicht (sshkv benutzt den alten Key ohnehin nicht mehr), werden aber im Ergebnis genannt.
|
||||
- Tests: Integrationstest gegen beide Testziele (DIENSTBENUTZER und ROOT, alte Anmeldung scheitert danach), E2E im Browser.
|
||||
|
||||
@@ -1,22 +1,53 @@
|
||||
# #032 Nacharbeiten aus Phase 4/5
|
||||
**Status:** offen
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v1.0.0
|
||||
**Labels:** test, chore
|
||||
**Geschätzt:** 2 h **Tatsächlich:** –
|
||||
**Geschätzt:** 2 h **Tatsächlich:** 0,75 h
|
||||
|
||||
## Ziel
|
||||
Kleinere offene Punkte aus den Berichten der Phasen 4 und 5 erledigen.
|
||||
|
||||
## Aufgaben
|
||||
- [ ] Massenwiderruf im Browser testen (E2E mit frischer Bestätigung über den Mock-OIDC)
|
||||
- [ ] Laufende Rotation abbrechen können (Nachfolger widerrufen, Vorgänger wieder aktiv)
|
||||
- [ ] Protokoll-IDs ohne Zweierschritte (Spalten-Default und Trigger ziehen beide `nextval`)
|
||||
- [ ] „Zum Dry-Run“ aus der Zuweisungsmatrix direkt zur Dry-Run-Karte springen (Anker)
|
||||
- [x] Massenwiderruf im Browser testen (E2E mit frischer Bestätigung über den Mock-OIDC)
|
||||
- [x] Laufende Rotation abbrechen können (Nachfolger widerrufen, Vorgänger wieder aktiv)
|
||||
- [x] Protokoll-IDs ohne Zweierschritte (Spalten-Default und Trigger ziehen beide `nextval`)
|
||||
- [x] „Zum Dry-Run“ aus der Zuweisungsmatrix direkt zur Dry-Run-Karte springen (Anker)
|
||||
|
||||
## Akzeptanzkriterien
|
||||
- [ ] Alle Suiten grün
|
||||
- [x] Alle Suiten grün
|
||||
|
||||
## Abhängigkeiten
|
||||
#023, #024, #025, #027
|
||||
|
||||
## Notizen / Entscheidungen
|
||||
- **Massenwiderruf:** Der neue E2E-Test (`widerruf.spec.ts`) fand einen echten Fehler: Die Seite
|
||||
`/schluessel/widerruf` filterte `?id=` mit `/^[a-z0-9]{1,64}$/`. Vom Worker erzeugte Schlüssel tragen
|
||||
eine UUID mit Bindestrichen – jeder erzeugte Schlüssel fiel still heraus („Keine Schlüssel gewählt“).
|
||||
Jetzt `idsAusSuchparameter` in `src/lib/schluessel.ts` (mit Test). Außerdem nannte der Hinweis nach
|
||||
verweigerter Bestätigung den falschen Knopf („Widerruf vorbereiten“ statt „2 Schlüssel widerrufen“).
|
||||
`datenbank()` und `oidcBestaetigen()` liegen jetzt in `tests/e2e/hilfen.ts`.
|
||||
- **Rotation abbrechen:** kein neues Feld. Abbrechen = Widerruf des Nachfolgers über den Ablauf aus #024
|
||||
(Dry-Run, er kann schon ausgerollt sein); ein Nachfolger im Widerruf bzw. widerrufen *ist* die
|
||||
abgebrochene Rotation (`rotationAbgebrochen`). `vorgaengerId` bleibt für die Historie, der Abbruch
|
||||
steht als `schluessel.rotation.abgebrochen` im Protokoll – in derselben Transaktion wie das Vormerken
|
||||
(`widerrufEinleiten` nimmt dafür optional `zusaetzlich` an). Der alte Schlüssel bleibt unverändert; bei
|
||||
gleichem Alias zeigt die config nach dem Anwenden wieder auf die alte Datei (nur verbleibende
|
||||
Zuweisungen landen im Block).
|
||||
- Abbrechen geht nur, solange der alte Schlüssel noch nicht entfernt wird (Schritt 5) – sonst wären am
|
||||
Ende beide halb entfernt. Auf dem Nachfolger ersetzt „Rotation abbrechen“ den normalen
|
||||
„Widerrufen“-Knopf, damit der Abbruch immer im Protokoll steht.
|
||||
- `widerrufAbschliessen` setzt `ROTIERT` nur noch bei einem aktiven Nachfolger, der nicht selbst im
|
||||
Widerruf ist; die Seite schreibt „Ersetzt durch …“ nur bei `ROTIERT`.
|
||||
- **Kein zweiter Nachfolger:** `vorgaengerId` ist eindeutig (1:1-Relation). Nach einem Abbruch lässt sich
|
||||
derselbe Schlüssel deshalb nicht erneut über sshkv rotieren; Seite und Fehlermeldung sagen das
|
||||
(neuen Schlüssel anlegen, zuweisen, alten widerrufen). Eine erneute Rotation bräuchte eine
|
||||
1:n-Relation mit eindeutigem Teilindex „ein nicht widerrufener Nachfolger je Vorgänger“.
|
||||
→ Behoben in #033 (partieller Index statt 1:1).
|
||||
- **Protokoll-IDs:** Migration `20261012173000_protokoll_id_lueckenlos` setzt den Spalten-Default auf den
|
||||
Platzhalter `0` (Prisma: `@default(0)`, `create` ohne ID geht weiter); die ID zieht nur noch der
|
||||
Trigger unter der Advisory-Sperre aus der weiterhin zugeordneten Sequenz. Bestehende Zeilen bleiben
|
||||
unverändert (die ID steckt in der Hash-Eingabe), alte Lücken bleiben also stehen. Eine mitgegebene ID
|
||||
überschreibt der Trigger weiterhin.
|
||||
- **Anker:** Die Dry-Run-Karte trägt `id="dry-run"` (`Karte` nimmt eine optionale `id`) und
|
||||
`scroll-mt-20` wegen der festen Kopfleiste der schmalen Ansicht; „Zum Dry-Run“ zeigt auf
|
||||
`/schluessel/<id>#dry-run`.
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# #033 Erneut rotieren nach abgebrochener Rotation
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v1.0.0
|
||||
**Labels:** backend, datenbank
|
||||
**Geschätzt:** 1 h **Tatsächlich:** 0 h 20 min
|
||||
|
||||
## Ziel
|
||||
Nach einer abgebrochenen Rotation (#032) lässt sich derselbe Schlüssel erneut rotieren, statt dass ein neuer Schlüssel angelegt, zugewiesen und der alte widerrufen werden muss.
|
||||
|
||||
## Aufgaben
|
||||
- [x] `vorgaengerId` nicht mehr eindeutig; stattdessen partieller Index: je Vorgänger höchstens ein Nachfolger, der weder widerrufen ist noch gerade widerrufen wird
|
||||
- [x] Relation 1:n (`nachfolgerListe`); maßgeblich ist der jüngste Nachfolger (`JUENGSTER`, `mitNachfolger`)
|
||||
- [x] Neue Rotation erst, wenn der Abbruch abgeschlossen ist (Nachfolger WIDERRUFEN)
|
||||
- [x] Schlüsselseite: „Rotieren“ wieder anbieten, Hinweistext angepasst
|
||||
|
||||
## Akzeptanzkriterien
|
||||
- [x] DB-Test: Abbruch → während des Abbruchs keine neue Rotation → danach zweiter Nachfolger, Kette zeigt ihn
|
||||
- [x] DB-Test: die Datenbank lehnt einen zweiten offenen Nachfolger ab
|
||||
- [x] E2E: nach dem Abbruch führt „Rotieren“ wieder zu Schritt 1
|
||||
|
||||
## Abhängigkeiten
|
||||
#023, #032
|
||||
|
||||
## Notizen / Entscheidungen
|
||||
- **Abgebrochene Nachfolger bleiben** mit `vorgaengerId` in der Historie; die Rotationskette folgt dem jüngsten Nachfolger.
|
||||
- **Kein neuer Rotations-Zustand:** Ist der jüngste Nachfolger vollständig widerrufen und der alte nicht ROTIERT, steht die Rotation wieder bei Schritt 0.
|
||||
- Prisma kennt partielle Indizes nicht; `migrate diff` bleibt trotzdem leer, weil der Index nur in der Migration steht (wie beim Instanz-Standard).
|
||||
@@ -0,0 +1,23 @@
|
||||
# #034 Abnahme-Checkliste für 1.0.0
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v1.0.0
|
||||
**Labels:** docs
|
||||
**Geschätzt:** 0,5 h **Tatsächlich:** 0 h 10 min
|
||||
|
||||
## Ziel
|
||||
Was die automatischen Tests nicht abdecken – echter authentik, echte Hosts, Betrieb auf dem Zielserver – als abhakbare Liste mit erwartetem Ergebnis je Punkt, damit die Abnahme von 1.0.0 vollständig und nachvollziehbar ist.
|
||||
|
||||
## Aufgaben
|
||||
- [x] `docs/betrieb/abnahme.md`: Installation, authentik, Notfall-Admin, Hosts, Ausrollen, Drift/Rotation/Widerruf, Audit-Log/Benachrichtigungen, Sicherung, Oberfläche
|
||||
- [x] Aussagen gegen den Code geprüft (Knopfnamen, Healthcheck, Ratenbegrenzung, Header)
|
||||
- [x] Verlinkt im README
|
||||
|
||||
## Akzeptanzkriterien
|
||||
- [x] Jeder Punkt nennt ein prüfbares Ergebnis
|
||||
- [x] Die kritischen offenen Punkte (authentik-Neuanmeldung mit `auth_time`, Byte-Gleichheit außerhalb des Blocks auf echten Hosts) sind ausdrücklich enthalten
|
||||
|
||||
## Abhängigkeiten
|
||||
#029
|
||||
|
||||
## Notizen / Entscheidungen
|
||||
- Die Abnahme selbst macht der Betreiber; 1.0.0 wird erst danach getaggt.
|
||||
@@ -0,0 +1,32 @@
|
||||
# #035 Lokale Benutzerkonten
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v1.0.0
|
||||
**Labels:** sicherheit, backend, ui
|
||||
**Geschätzt:** 2 h **Tatsächlich:** 0 h 35 min
|
||||
|
||||
## Ziel
|
||||
sshkv ohne authentik in Betrieb nehmen können – mit eigenen Konten je Person statt eines geteilten Notfall-Admins, damit Rollen und Audit-Log stimmen. Später laufen lokale Konten und OIDC nebeneinander, oder die lokalen werden abgeschaltet.
|
||||
|
||||
## Aufgaben
|
||||
- [x] Anmeldung mit Anmeldename, Passwort und Pflicht-TOTP für lokale Konten (gleicher Ablauf wie der Notfall-Admin)
|
||||
- [x] Einmal-Passwort vom System, Pflichtwechsel bei der ersten Anmeldung (`passwortWechselNoetig`, Seite `/anmelden/passwort`)
|
||||
- [x] Benutzerverwaltung `/einstellungen/benutzer`: anlegen, Rolle ändern, sperren/entsperren, zurücksetzen – Recht `benutzerVerwalten` (Admin + frische Bestätigung)
|
||||
- [x] OIDC-Konten nur sperrbar; Notfall-Admin und eigenes Konto unveränderlich
|
||||
- [x] Schalter `SSHKV_LOKALE_KONTEN` (Standard an), auch in `docker-compose.yml`
|
||||
- [x] Protokoll für alle Schritte, nie mit Passwort
|
||||
- [x] Doku: Handbuch, Installation („Ohne authentik starten“), Sicherheit, Abnahme-Liste
|
||||
|
||||
## Akzeptanzkriterien
|
||||
- [x] DB-Tests: erste Anmeldung (Einmal-Passwort → eigenes → TOTP → angemeldet), schwache/unveränderte Passwörter, doppelte Namen, Rollen- und Sperrregeln, Zurücksetzen, abgeschaltet
|
||||
- [x] E2E: Admin legt an, Person meldet sich zum ersten Mal an, Operator kommt nicht in die Benutzerverwaltung, Sperren beendet die Sitzung sofort
|
||||
|
||||
## Abhängigkeiten
|
||||
#004
|
||||
|
||||
## Notizen / Entscheidungen
|
||||
- **TOTP ist Pflicht** auch für lokale Konten (Entscheidung Florian, 12.10.2026).
|
||||
- **Dauerhaft, aber abschaltbar** per `SSHKV_LOKALE_KONTEN=0` (Entscheidung Florian, 12.10.2026).
|
||||
- **Admins wählen keine Passwörter für andere:** sshkv erzeugt ein Einmal-Passwort (4 × 5 Zeichen ohne verwechselbare Zeichen, gut 115 Bit), das genau einmal angezeigt wird.
|
||||
- **12 Zeichen Mindestlänge** für eigene Passwörter – mit Pflicht-TOTP ausreichend; der Notfall-Admin behält 16.
|
||||
- **Sperren beendet Sitzungen sofort** (`sitzungenGueltigAb`); eine Rollenänderung verwirft die gespeicherte Bestätigung.
|
||||
- Lokale Konten bekommen keine E-Mail-Adresse, damit die OIDC-Verknüpfung per E-Mail nie ein lokales Konto übernehmen kann.
|
||||
@@ -0,0 +1,25 @@
|
||||
# #036 Rotation des Deployment-Keys abbrechen
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v1.0.0
|
||||
**Labels:** sicherheit, backend
|
||||
**Geschätzt:** 0,5 h **Tatsächlich:** 0 h 20 min
|
||||
|
||||
## Ziel
|
||||
Eine begonnene Rotation des Deployment-Keys (#031) zurücknehmen können, etwa wenn ein Host den neuen Key dauerhaft nicht annimmt – bisher blieb nur, Hosts beim Umstellen zu übergehen.
|
||||
|
||||
## Aufgaben
|
||||
- [x] Worker-Schritt `abbrechen`: Nachfolger WIDERRUFEN, Private Key sofort verwerfen, protokollieren
|
||||
- [x] Nur vor dem Umstellen; danach meldet sich sshkv schon mit dem neuen Key an
|
||||
- [x] Seite: Knopf „Rotation abbrechen“, danach Karte „Abgebrochene Rotation aufräumen“ mit Dry-Run
|
||||
- [x] Neue Rotation sofort möglich (partieller Index aus #033)
|
||||
|
||||
## Akzeptanzkriterien
|
||||
- [x] Integrationstest: verteilen → abbrechen → Dry-Run entfernt den neuen Key, alter arbeitet weiter → neue Rotation startet
|
||||
- [x] E2E: abbrechen und aufräumen im Browser; nimmt auch eine liegengebliebene Rotation mit
|
||||
|
||||
## Abhängigkeiten
|
||||
#031, #033
|
||||
|
||||
## Notizen / Entscheidungen
|
||||
- Gefunden, weil ein fehlgeschlagener E2E-Lauf eine Rotation auf verwaisten Testhosts hängen ließ: Das Umstellen verweigerte sich zu Recht, aber es gab keinen Weg zurück.
|
||||
- Der verworfene Key bleibt bis zum Dry-Run in den Blöcken stehen (harmlos – sein Private Key existiert nicht mehr); die Aufräum-Karte zeigt das.
|
||||
@@ -0,0 +1,15 @@
|
||||
-- #032: Protokoll-IDs ohne Zweierschritte.
|
||||
--
|
||||
-- Bisher zog der Spalten-Default (BIGSERIAL) eine Nummer aus der Sequenz und der Trigger
|
||||
-- `protokoll_verketten` gleich noch eine – jede Zeile verbrauchte zwei IDs. Die ID muss aber
|
||||
-- im Trigger unter der Advisory-Sperre vergeben werden: Eine vorher gezogene Nummer koennte
|
||||
-- kleiner sein als die einer parallelen Transaktion, die zuerst verkettet, und die Kette liesse
|
||||
-- sich in ID-Reihenfolge nicht mehr pruefen. Deshalb verzichtet der Default auf die Sequenz.
|
||||
--
|
||||
-- 0 ist nur ein Platzhalter, damit `prisma.protokoll.create` ohne ID auskommt; der Trigger
|
||||
-- ueberschreibt jede ID (auch eine mitgegebene). Die Sequenz bleibt der Spalte zugeordnet
|
||||
-- (OWNED BY), deshalb findet `pg_get_serial_sequence` sie im Trigger weiterhin.
|
||||
--
|
||||
-- Bestehende Zeilen bleiben, wie sie sind: Die ID ist Teil der Hash-Eingabe, Umnummerieren
|
||||
-- wuerde die Kette brechen. Die alten Luecken bleiben also stehen.
|
||||
ALTER TABLE "Protokoll" ALTER COLUMN "id" SET DEFAULT 0;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Nach einer abgebrochenen Rotation darf derselbe Schluessel erneut rotiert werden (#033).
|
||||
-- Die Kette bleibt eindeutig: je Vorgaenger hoechstens ein Nachfolger, der nicht widerrufen
|
||||
-- ist oder gerade widerrufen wird.
|
||||
DROP INDEX "Schluessel_vorgaengerId_key";
|
||||
|
||||
CREATE INDEX "Schluessel_vorgaengerId_idx" ON "Schluessel"("vorgaengerId");
|
||||
|
||||
CREATE UNIQUE INDEX "schluessel_ein_offener_nachfolger" ON "Schluessel" ("vorgaengerId")
|
||||
WHERE "vorgaengerId" IS NOT NULL
|
||||
AND "widerrufEingeleitetAm" IS NULL
|
||||
AND "status" <> 'WIDERRUFEN';
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Lokale Konten (#035): ein vom Admin vergebenes Einmal-Passwort muss zuerst ersetzt werden.
|
||||
ALTER TABLE "Benutzer" ADD COLUMN "passwortWechselNoetig" BOOLEAN NOT NULL DEFAULT false;
|
||||
+14
-5
@@ -155,13 +155,15 @@ model Benutzer {
|
||||
/// Hat der OIDC-Anbieter die Adresse bestaetigt (`email_verified`)? Nur dann verknuepft der
|
||||
/// Self-Service das Konto per E-Mail mit einer vorhandenen Person (#013).
|
||||
emailBestaetigt Boolean @default(false)
|
||||
/// Anmeldename des Notfall-Admins.
|
||||
/// Anmeldename lokaler Konten: Notfall-Admin und von Admins angelegte Konten (#035).
|
||||
anmeldename String? @unique
|
||||
name String
|
||||
rolle Rolle @default(LESER)
|
||||
aktiv Boolean @default(true)
|
||||
istNotfall Boolean @default(false)
|
||||
passwortHash String?
|
||||
/// Einmal-Passwort vom Admin: vor allem anderen muss ein eigenes gesetzt werden (#035).
|
||||
passwortWechselNoetig Boolean @default(false)
|
||||
/// TOTP-Geheimnis, verschluesselt mit dem aus SSHKV_APP_SECRET abgeleiteten Schluessel
|
||||
/// (nicht mit dem Master-Key: der liegt nur im Worker, geprueft wird aber im Web).
|
||||
totpGeheimnis Bytes?
|
||||
@@ -318,10 +320,13 @@ model Schluessel {
|
||||
/// Widerruf eingeleitet (#024): Verwendungen stehen auf ENTFERNEN, der Status wechselt erst
|
||||
/// auf WIDERRUFEN/ROTIERT, wenn alle Entfernungen erfolgreich waren.
|
||||
widerrufEingeleitetAm DateTime?
|
||||
/// Rotationskette: der Schluessel, den dieser abgeloest hat.
|
||||
vorgaengerId String? @unique
|
||||
/// Rotationskette: der Schluessel, den dieser abgeloest hat. Nach einer abgebrochenen Rotation
|
||||
/// darf ein weiterer Nachfolger folgen; je Vorgaenger hoechstens ein nicht widerrufener
|
||||
/// (partieller Index, #033).
|
||||
vorgaengerId String?
|
||||
vorgaenger Schluessel? @relation("Rotation", fields: [vorgaengerId], references: [id], onDelete: SetNull)
|
||||
nachfolger Schluessel? @relation("Rotation")
|
||||
/// Alle Nachfolger, auch abgebrochene. Massgeblich ist der juengste (`nachfolgerVon`).
|
||||
nachfolgerListe Schluessel[] @relation("Rotation")
|
||||
erstelltVonId String?
|
||||
erstelltVon Benutzer? @relation("SchluesselErsteller", fields: [erstelltVonId], references: [id], onDelete: SetNull)
|
||||
/// true, wenn eine Person den Key selbst hinterlegt hat (Self-Service).
|
||||
@@ -334,6 +339,8 @@ model Schluessel {
|
||||
hostsAlsDeployment Host[] @relation("HostDeploymentSchluessel")
|
||||
gruppenAlsDeployment Hostgruppe[] @relation("GruppeDeploymentSchluessel")
|
||||
vorgaenge Vorgang[]
|
||||
|
||||
@@index([vorgaengerId])
|
||||
}
|
||||
|
||||
/// Soll: Public Key in authorized_keys eines Benutzers auf einem Host oder einer Hostgruppe.
|
||||
@@ -483,7 +490,9 @@ model Vorgang {
|
||||
/// Revisionssicheres Protokoll (#005). Append-only per Trigger; `hash` verkettet jede Zeile
|
||||
/// mit ihrer Vorgaengerin und wird in der Datenbank berechnet.
|
||||
model Protokoll {
|
||||
id BigInt @id @default(autoincrement())
|
||||
/// Vergibt der Trigger `protokoll_verketten` unter der Advisory-Sperre aus der Sequenz; der
|
||||
/// Default 0 ist nur ein Platzhalter, damit `create` ohne ID auskommt (#032).
|
||||
id BigInt @id @default(0)
|
||||
zeitpunkt DateTime @default(now())
|
||||
akteurId String?
|
||||
/// Name zum Zeitpunkt der Aktion – bleibt lesbar, auch wenn das Konto spaeter umbenannt wird.
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import { CheckCheck, KeyRound, ShieldCheck, Undo2 } from "lucide-react";
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel";
|
||||
import { Umstellen } from "@/components/deploymentkey/Umstellen";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Breadcrumb } from "@/components/ui/Breadcrumb";
|
||||
import { Karte } from "@/components/ui/Karte";
|
||||
import { MonoKopieren } from "@/components/ui/MonoKopieren";
|
||||
import { Schrittleiste } from "@/components/ui/Schrittleiste";
|
||||
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||
import { VorgangsKnopf } from "@/components/vorgang/VorgangsKnopf";
|
||||
import { rotationsSchrittAktion } from "@/server/actions/deploymentkey";
|
||||
import { bestaetigungFrisch, sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { rotationsStand } from "@/server/services/deploymentkey";
|
||||
|
||||
export const metadata: Metadata = { title: "Deployment-Key rotieren" };
|
||||
|
||||
const SCHRITTE = ["Erzeugen", "Verteilen", "Prüfen", "Umstellen", "Aufräumen"] as const;
|
||||
|
||||
/**
|
||||
* Rotation des Instanz-Deployment-Keys (#031). Der neue Key kommt erst neben den alten,
|
||||
* wird geprueft und erst dann zum Standard; der alte verschwindet zuletzt.
|
||||
*/
|
||||
export default async function DeploymentKeySeite() {
|
||||
const { benutzer } = await sitzungErforderlich("deploymentKeyVerwalten", "/deployment-key");
|
||||
const stand = await rotationsStand();
|
||||
const fehlgeschlagen = (stand.pruefung ?? []).filter((p) => !p.ok);
|
||||
const aktuell =
|
||||
stand.phase === "bereit"
|
||||
? 0
|
||||
: stand.phase === "aufraeumen"
|
||||
? 4
|
||||
: !stand.pruefung
|
||||
? 1
|
||||
: fehlgeschlagen.length
|
||||
? 2
|
||||
: 3;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 sm:gap-7">
|
||||
<Seitenkopf
|
||||
oben={
|
||||
<Breadcrumb
|
||||
pfad={[{ titel: "Einstellungen", href: "/einstellungen" }, { titel: "Deployment-Key" }]}
|
||||
/>
|
||||
}
|
||||
titel="Deployment-Key rotieren"
|
||||
untertitel="Ohne Unterbrechung: der neue Key steht erst neben dem alten, wird geprüft und löst ihn dann ab."
|
||||
/>
|
||||
<Schrittleiste schritte={SCHRITTE} aktuell={aktuell} beschriftung="Schritte der Rotation" />
|
||||
|
||||
{!bestaetigungFrisch(benutzer.bestaetigtAm) && (
|
||||
<p className="text-accent text-[13.5px]">
|
||||
Jeder Schritt verlangt eine frische Bestätigung Ihrer Anmeldung.{" "}
|
||||
<Link href="/bestaetigen?ziel=%2Fdeployment-key" className="underline">
|
||||
Jetzt bestätigen
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Karte titel="Aktueller Deployment-Key" untertitel={`Benutzt auf ${stand.hosts.length} Host(s)`}>
|
||||
{stand.aktuell ? (
|
||||
<MonoKopieren
|
||||
wert={stand.aktuell.fingerprint}
|
||||
beschriftung="Fingerprint des aktuellen Keys"
|
||||
umbrechen
|
||||
/>
|
||||
) : (
|
||||
<p className="text-accent text-[14px]">Noch kein Deployment-Key – läuft der Worker?</p>
|
||||
)}
|
||||
{stand.nachfolger && (
|
||||
<div className="mt-4">
|
||||
<p className="text-muted mb-1 text-[13px]">Neuer Key (noch nicht Standard)</p>
|
||||
<MonoKopieren
|
||||
wert={stand.nachfolger.fingerprint}
|
||||
beschriftung="Fingerprint des neuen Keys"
|
||||
umbrechen
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Karte>
|
||||
|
||||
{stand.phase === "bereit" && stand.abgebrochen && (
|
||||
<Karte
|
||||
titel="Abgebrochene Rotation aufräumen"
|
||||
untertitel="Der verworfene Key kann noch in den Blöcken stehen. Ein Dry-Run zeigt es; steht alles auf Soll, ist nichts mehr zu tun."
|
||||
>
|
||||
<p className="text-muted mb-4 font-mono text-[13px] break-all">{stand.abgebrochen.fingerprint}</p>
|
||||
<AusrollenPanel auswahl={{ deploymentKey: true }} offen={0} />
|
||||
</Karte>
|
||||
)}
|
||||
|
||||
{stand.phase === "bereit" && stand.aktuell && (
|
||||
<Karte
|
||||
titel="1 · Neuen Key erzeugen"
|
||||
untertitel="Der Worker erzeugt ein neues ed25519-Paar. Noch ändert sich auf keinem Host etwas."
|
||||
>
|
||||
<VorgangsKnopf
|
||||
aktion={rotationsSchrittAktion.bind(null, "erzeugen")}
|
||||
beschriftung="Neuen Deployment-Key erzeugen"
|
||||
variante="primaer"
|
||||
icon={<KeyRound className="size-4" />}
|
||||
/>
|
||||
</Karte>
|
||||
)}
|
||||
|
||||
{stand.phase === "verteilen" && (
|
||||
<>
|
||||
<Karte
|
||||
titel="2 · Neuen Key verteilen"
|
||||
untertitel="Der neue Key kommt zusätzlich in den verwalteten Block – der alte bleibt vorerst."
|
||||
>
|
||||
<AusrollenPanel auswahl={{ deploymentKey: true }} offen={stand.hosts.length} />
|
||||
</Karte>
|
||||
<Karte
|
||||
titel="3 · Anmeldung mit dem neuen Key prüfen"
|
||||
untertitel="sshkv meldet sich auf jedem Host einmal mit dem neuen Key an."
|
||||
>
|
||||
<VorgangsKnopf
|
||||
aktion={rotationsSchrittAktion.bind(null, "pruefen")}
|
||||
beschriftung="Neuen Key prüfen"
|
||||
icon={<ShieldCheck className="size-4" />}
|
||||
/>
|
||||
{stand.pruefung && (
|
||||
<ul className="mt-4 flex flex-col gap-1.5" aria-label="Ergebnis der Prüfung">
|
||||
{stand.pruefung.map((p) => (
|
||||
<li key={p.hostId} className="flex flex-wrap items-center gap-2 text-[14px]">
|
||||
<span className="font-mono">{p.hostName}</span>
|
||||
{p.ok ? (
|
||||
<Badge variante="ok">angemeldet</Badge>
|
||||
) : (
|
||||
<Badge variante="gefahr">fehlgeschlagen</Badge>
|
||||
)}
|
||||
{p.fehler && <span className="text-muted text-[13px]">{p.fehler}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Karte>
|
||||
{stand.pruefung && (
|
||||
<Karte
|
||||
titel="4 · Umstellen"
|
||||
untertitel="Ab jetzt benutzt sshkv den neuen Key; der alte gilt als rotiert."
|
||||
>
|
||||
<Umstellen fehlgeschlagen={fehlgeschlagen} />
|
||||
</Karte>
|
||||
)}
|
||||
<Karte
|
||||
titel="Rotation abbrechen"
|
||||
untertitel="Solange nicht umgestellt ist: der neue Key wird verworfen, der alte bleibt unverändert. Danach per Dry-Run den neuen wieder von den Hosts entfernen."
|
||||
className="border-danger-border"
|
||||
>
|
||||
<VorgangsKnopf
|
||||
aktion={rotationsSchrittAktion.bind(null, "abbrechen")}
|
||||
beschriftung="Rotation abbrechen"
|
||||
variante="gefahr"
|
||||
icon={<Undo2 className="size-4" />}
|
||||
/>
|
||||
</Karte>
|
||||
</>
|
||||
)}
|
||||
|
||||
{stand.phase === "aufraeumen" && stand.vorgaenger && (
|
||||
<Karte
|
||||
titel="5 · Alten Key entfernen"
|
||||
untertitel="Dry-Run und Ausrollen entfernen den alten Key aus allen Blöcken; danach wird sein Private Key verworfen."
|
||||
>
|
||||
<div className="flex flex-col gap-5">
|
||||
<p className="text-muted text-[13px]">
|
||||
Alter Key: <span className="font-mono">{stand.vorgaenger.fingerprint}</span>
|
||||
</p>
|
||||
<AusrollenPanel auswahl={{ deploymentKey: true }} offen={stand.hosts.length} />
|
||||
<div>
|
||||
<VorgangsKnopf
|
||||
aktion={rotationsSchrittAktion.bind(null, "abschliessen")}
|
||||
beschriftung="Rotation abschließen"
|
||||
variante="primaer"
|
||||
icon={<CheckCheck className="size-4" />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Karte>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { KontoAktionen } from "@/components/benutzer/KontoAktionen";
|
||||
import { KontoAnlegen } from "@/components/benutzer/KontoAnlegen";
|
||||
import { Badge, Breadcrumb, Karte, Seitenkopf, Tabelle, TabellenZeile, TabellenZelle } from "@/components/ui";
|
||||
import { datumZeit } from "@/lib/format";
|
||||
import { lokaleKontenAktiv } from "@/lib/lokale-konten";
|
||||
import { bestaetigungFrisch, sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { benutzerListe, type KontoHerkunft } from "@/server/queries/benutzer";
|
||||
|
||||
export const metadata: Metadata = { title: "Benutzer" };
|
||||
|
||||
const ROLLE: Record<string, string> = { ADMIN: "Admin", OPERATOR: "Operator", LESER: "Leser" };
|
||||
const HERKUNFT: Record<KontoHerkunft, string> = {
|
||||
notfall: "Notfall-Admin",
|
||||
lokal: "lokal",
|
||||
oidc: process.env["SSHKV_OIDC_NAME"] ?? "OIDC",
|
||||
};
|
||||
|
||||
/**
|
||||
* Benutzerverwaltung (#035): lokale Konten anlegen, Rollen vergeben, sperren, zuruecksetzen.
|
||||
* OIDC-Konten erscheinen nach ihrer ersten Anmeldung; ihre Rolle kommt aus dem Anbieter.
|
||||
*/
|
||||
export default async function BenutzerSeite() {
|
||||
const { benutzer } = await sitzungErforderlich("benutzerVerwalten", "/einstellungen/benutzer");
|
||||
const konten = await benutzerListe();
|
||||
const lokal = lokaleKontenAktiv();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 sm:gap-7">
|
||||
<Seitenkopf
|
||||
oben={
|
||||
<Breadcrumb pfad={[{ titel: "Einstellungen", href: "/einstellungen" }, { titel: "Benutzer" }]} />
|
||||
}
|
||||
titel="Benutzer"
|
||||
untertitel="Lokale Konten mit Passwort und TOTP – neben oder statt des Identitätsanbieters."
|
||||
/>
|
||||
|
||||
{!bestaetigungFrisch(benutzer.bestaetigtAm) && (
|
||||
<p className="text-accent text-[13.5px]">
|
||||
Änderungen an Konten verlangen eine frische Bestätigung Ihrer Anmeldung.{" "}
|
||||
<Link href="/bestaetigen?ziel=%2Feinstellungen%2Fbenutzer" className="underline">
|
||||
Jetzt bestätigen
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{lokal ? (
|
||||
<Karte
|
||||
titel="Lokales Konto anlegen"
|
||||
untertitel="sshkv erzeugt ein Einmal-Passwort; bei der ersten Anmeldung setzt die Person ein eigenes und richtet TOTP ein."
|
||||
>
|
||||
<KontoAnlegen />
|
||||
</Karte>
|
||||
) : (
|
||||
<p className="text-muted text-[14px]">
|
||||
Lokale Konten sind abgeschaltet (<code className="font-mono">SSHKV_LOKALE_KONTEN=0</code>).
|
||||
Vorhandene lokale Konten können sich nicht anmelden.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Karte titel="Konten" untertitel={konten.length === 1 ? "1 Konto" : `${konten.length} Konten`}>
|
||||
<Tabelle
|
||||
beschriftung="Konten"
|
||||
spalten={[
|
||||
{ titel: "Name" },
|
||||
{ titel: "Anmeldung" },
|
||||
{ titel: "Rolle" },
|
||||
{ titel: "Zuletzt angemeldet", versteckt: true },
|
||||
{ titel: "Aktionen", rechts: true },
|
||||
]}
|
||||
>
|
||||
{konten.map((k) => (
|
||||
<TabellenZeile key={k.id}>
|
||||
<TabellenZelle>
|
||||
<span className="block font-medium">{k.name}</span>
|
||||
<span className="text-muted block font-mono text-[12.5px]">
|
||||
{k.anmeldename ?? k.email ?? "–"}
|
||||
</span>
|
||||
</TabellenZelle>
|
||||
<TabellenZelle className="whitespace-nowrap">
|
||||
<span className="flex flex-wrap gap-1.5">
|
||||
<Badge>{HERKUNFT[k.herkunft]}</Badge>
|
||||
{!k.aktiv && <Badge variante="gefahr">gesperrt</Badge>}
|
||||
{k.aktiv && k.einrichtungOffen && <Badge variante="messing">Einrichtung offen</Badge>}
|
||||
</span>
|
||||
</TabellenZelle>
|
||||
<TabellenZelle>{ROLLE[k.rolle]}</TabellenZelle>
|
||||
<TabellenZelle className="text-muted text-[13px] whitespace-nowrap">
|
||||
{k.letzteAnmeldungAm ? datumZeit(k.letzteAnmeldungAm) : "noch nie"}
|
||||
</TabellenZelle>
|
||||
<TabellenZelle rechts>
|
||||
<KontoAktionen konto={k} selbst={k.id === benutzer.id} />
|
||||
</TabellenZelle>
|
||||
</TabellenZeile>
|
||||
))}
|
||||
</Tabelle>
|
||||
</Karte>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Inbox } from "lucide-react";
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
import { BenachrichtigungFormular } from "@/components/einstellungen/BenachrichtigungFormular";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
@@ -161,11 +162,21 @@ export default async function Einstellungen() {
|
||||
</Info>
|
||||
<Info titel="Deployment-Key">
|
||||
{instanz.deploymentKey ? (
|
||||
<>
|
||||
<MonoKopieren
|
||||
wert={instanz.deploymentKey.fingerprint}
|
||||
beschriftung="Fingerprint des Deployment-Keys"
|
||||
umbrechen
|
||||
/>
|
||||
{darf(benutzer.rolle, "deploymentKeyVerwalten") && (
|
||||
<Link
|
||||
href="/deployment-key"
|
||||
className="text-accent mt-1 inline-flex min-h-11 items-center text-[14px] font-medium hover:underline"
|
||||
>
|
||||
Deployment-Key rotieren
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-accent">noch nicht erzeugt – läuft der Worker?</span>
|
||||
)}
|
||||
@@ -177,6 +188,18 @@ export default async function Einstellungen() {
|
||||
<span className="text-muted">nicht eingerichtet</span>
|
||||
)}
|
||||
</Info>
|
||||
<Info titel="Benutzer">
|
||||
{darf(benutzer.rolle, "benutzerVerwalten") ? (
|
||||
<Link
|
||||
href="/einstellungen/benutzer"
|
||||
className="text-accent inline-flex min-h-11 items-center text-[14px] font-medium hover:underline"
|
||||
>
|
||||
Konten verwalten
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-muted">nur für Admins</span>
|
||||
)}
|
||||
</Info>
|
||||
<Info titel="Notfall-Admin">
|
||||
{!instanz.notfall.vorhanden ? (
|
||||
<span className="text-muted">nicht angelegt</span>
|
||||
|
||||
@@ -11,6 +11,10 @@ export async function huellenBenutzerErmitteln(): Promise<HuellenBenutzer> {
|
||||
name: benutzer.name,
|
||||
rolle: benutzer.rolle,
|
||||
herkunft:
|
||||
sitzung.herkunft === "notfall" ? "Notfallzugang" : (process.env["SSHKV_OIDC_NAME"] ?? "authentik"),
|
||||
sitzung.herkunft === "notfall"
|
||||
? "Notfallzugang"
|
||||
: sitzung.herkunft === "lokal"
|
||||
? "lokales Konto"
|
||||
: (process.env["SSHKV_OIDC_NAME"] ?? "authentik"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,7 +23,8 @@ import { widerrufUebersicht } from "@/server/queries/widerruf";
|
||||
import { WiderrufAblauf } from "@/components/widerruf/WiderrufAblauf";
|
||||
import { WiderrufKnopf } from "@/components/widerruf/WiderrufKnopf";
|
||||
import { ablaufZustand } from "@/lib/schluessel";
|
||||
import { rotationGesperrt } from "@/server/services/rotation";
|
||||
import { RotationAbbrechen } from "@/components/rotation/RotationAbbrechen";
|
||||
import { rotationAbgebrochen, rotationGesperrt } from "@/server/services/rotation";
|
||||
import { PrivateAusrollenAktion } from "./PrivateAusrollenAktion";
|
||||
import { PrivateKeyAnzeigen, PrivateKeyLoeschen } from "./PrivateKeyAktionen";
|
||||
import { PublicAusrollenAktion } from "./PublicAusrollenAktion";
|
||||
@@ -216,9 +217,17 @@ export default async function SchluesselSeite({
|
||||
const jetzt = new Date();
|
||||
const abgelaufen = s.status === "ABGELAUFEN" || ablaufZustand(s.ablaufAm, jetzt).art === "abgelaufen";
|
||||
const verwaltenRecht = darf(benutzer.rolle, "schluesselVerwalten");
|
||||
// Auf dem Nachfolger: die laufende Rotation gehoert zum Vorgaenger.
|
||||
const rotationDesVorgaengers =
|
||||
s.vorgaenger && (s.vorgaenger.status === "AKTIV" || s.vorgaenger.status === "ABGELAUFEN")
|
||||
? s.vorgaenger
|
||||
: null;
|
||||
const widerrufbar =
|
||||
!imWiderruf &&
|
||||
!(s.nachfolger?.status === "AKTIV") &&
|
||||
// Ein Nachfolger mitten in der Rotation wird ueber „Rotation abbrechen“ widerrufen (#032) –
|
||||
// so steht der Abbruch im Protokoll und die Rotationsseite kennt den Stand.
|
||||
!rotationDesVorgaengers &&
|
||||
s.kategorie !== "DEPLOYMENT" &&
|
||||
(s.status === "AKTIV" || s.status === "ABGELAUFEN") &&
|
||||
rechte.public &&
|
||||
@@ -235,13 +244,22 @@ export default async function SchluesselSeite({
|
||||
privatZuweisungen: aktivePrivat,
|
||||
});
|
||||
const rotationLaeuft = s.nachfolger?.status === "AKTIV" && s.status !== "ROTIERT";
|
||||
const abgebrocheneRotation = s.status !== "ROTIERT" && rotationAbgebrochen(s.nachfolger);
|
||||
const darfRotieren = verwaltenRecht && rechte.public && (aktivePrivat === 0 || rechte.privat);
|
||||
const rotierbar = darfRotieren && (rotationLaeuft || (!rotationsSperre && !imWiderruf));
|
||||
// Auf dem Nachfolger: die laufende Rotation gehoert zum Vorgaenger.
|
||||
const rotationDesVorgaengers =
|
||||
s.vorgaenger && (s.vorgaenger.status === "AKTIV" || s.vorgaenger.status === "ABGELAUFEN")
|
||||
? s.vorgaenger
|
||||
: null;
|
||||
// Nach einem abgeschlossenen Abbruch (#032) darf erneut rotiert werden (#033); solange der
|
||||
// abgebrochene Nachfolger noch entfernt wird, nicht.
|
||||
const rotierbar =
|
||||
darfRotieren &&
|
||||
(rotationLaeuft ||
|
||||
(!rotationsSperre && !imWiderruf && (!s.nachfolger || s.nachfolger.status === "WIDERRUFEN")));
|
||||
// Abbrechen auf dem Nachfolger: gleiche Rechte wie die Rotation, Private Keys nur mit Recht.
|
||||
const darfAbbrechen =
|
||||
Boolean(rotationDesVorgaengers && !rotationDesVorgaengers.widerrufEingeleitetAm) &&
|
||||
!imWiderruf &&
|
||||
s.status === "AKTIV" &&
|
||||
verwaltenRecht &&
|
||||
rechte.public &&
|
||||
(s.privateVerwendungen.length === 0 || rechte.privat);
|
||||
const vorauswahl = (
|
||||
Array.isArray(zielkontenParameter) ? zielkontenParameter.join(",") : (zielkontenParameter ?? "")
|
||||
)
|
||||
@@ -308,7 +326,19 @@ export default async function SchluesselSeite({
|
||||
}
|
||||
/>
|
||||
|
||||
{rotationDesVorgaengers && (
|
||||
{rotationDesVorgaengers &&
|
||||
(imWiderruf || s.status === "WIDERRUFEN" ? (
|
||||
<Karte titel="Rotation abgebrochen">
|
||||
<p className="text-muted text-[14px]">
|
||||
Dieser Schlüssel sollte „{rotationDesVorgaengers.name}“ ablösen; die Rotation wurde abgebrochen.
|
||||
{imWiderruf ? " Er wird gerade entfernt; " : " Er ist von allen Hosts entfernt; "}
|
||||
<Link href={`/schluessel/${rotationDesVorgaengers.id}`} className="text-accent hover:underline">
|
||||
{rotationDesVorgaengers.name}
|
||||
</Link>{" "}
|
||||
bleibt aktiv.
|
||||
</p>
|
||||
</Karte>
|
||||
) : (
|
||||
<Karte titel="Rotation läuft" className="border-accent">
|
||||
<p className="text-muted text-[14px]">
|
||||
Dieser Schlüssel löst „{rotationDesVorgaengers.name}“ ab. Die Rotation ist erst fertig, wenn der
|
||||
@@ -320,8 +350,17 @@ export default async function SchluesselSeite({
|
||||
Rotation fortsetzen
|
||||
</Link>
|
||||
</p>
|
||||
</Karte>
|
||||
{darfAbbrechen && (
|
||||
<div className="mt-4">
|
||||
<RotationAbbrechen
|
||||
altId={rotationDesVorgaengers.id}
|
||||
altName={rotationDesVorgaengers.name}
|
||||
neuName={s.name}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Karte>
|
||||
))}
|
||||
|
||||
{imWiderruf && widerruf && (
|
||||
<Karte
|
||||
@@ -336,7 +375,7 @@ export default async function SchluesselSeite({
|
||||
vorauswahl={vorauswahl}
|
||||
ruecksprung={`/schluessel/${s.id}`}
|
||||
darfPrivat={rechte.privat}
|
||||
ohneZielkonten={rotationLaeuft}
|
||||
ohneZielkonten={rotationLaeuft || Boolean(rotationDesVorgaengers)}
|
||||
/>
|
||||
</Karte>
|
||||
)}
|
||||
@@ -367,7 +406,8 @@ export default async function SchluesselSeite({
|
||||
<Karte titel={s.status === "WIDERRUFEN" ? "Widerrufen" : "Rotiert"}>
|
||||
<p className="text-muted text-[14px]">
|
||||
Von allen Hosts entfernt; der Schlüssel lässt sich nicht mehr zuweisen.
|
||||
{s.nachfolger && (
|
||||
{/* Ein widerrufener Nachfolger (abgebrochene Rotation, #032) ersetzt nichts. */}
|
||||
{s.nachfolger && s.status === "ROTIERT" && (
|
||||
<>
|
||||
{" "}
|
||||
Ersetzt durch{" "}
|
||||
@@ -457,6 +497,10 @@ export default async function SchluesselSeite({
|
||||
!imWiderruf &&
|
||||
(s.publicVerwendungen.length > 0 || (rechte.privat && s.privateVerwendungen.length > 0)) && (
|
||||
<Karte
|
||||
// Sprungziel fuer „Zum Dry-Run“ aus der Zuweisungsmatrix; scroll-mt haelt die Karte
|
||||
// unter der festen Kopfleiste der schmalen Ansicht (#032).
|
||||
id="dry-run"
|
||||
className="scroll-mt-20"
|
||||
titel="Dry-Run-Vorschau"
|
||||
untertitel="Erst prüfen, dann anwenden – auf den Hosts ändert sich vorher nichts"
|
||||
>
|
||||
@@ -498,6 +542,16 @@ export default async function SchluesselSeite({
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
{abgebrocheneRotation && s.nachfolger && (
|
||||
<p className="text-muted mt-3 text-[14px]">
|
||||
Die Rotation zu „{s.nachfolger.name}“ wurde abgebrochen
|
||||
{s.nachfolger.status === "WIDERRUFEN" ? "" : " – der neue Schlüssel wird gerade entfernt"};
|
||||
dieser Schlüssel bleibt aktiv
|
||||
{s.nachfolger.status === "WIDERRUFEN"
|
||||
? " und lässt sich erneut rotieren."
|
||||
: ". Danach lässt er sich erneut rotieren."}
|
||||
</p>
|
||||
)}
|
||||
{rotationsSperre?.neuerPublicKeyNoetig && !imWiderruf && (
|
||||
<p className={`text-muted text-[14px] ${kette.length > 1 ? "mt-3" : ""}`}>
|
||||
{rotationsSperre.grund}
|
||||
|
||||
@@ -59,9 +59,14 @@ export default async function RotierenSeite({ params }: { params: Promise<{ id:
|
||||
}
|
||||
|
||||
const widerruf =
|
||||
stand.neu && alt.widerrufEingeleitetAm && alt.status !== "ROTIERT"
|
||||
stand.neu && !stand.abgebrochen && alt.widerrufEingeleitetAm && alt.status !== "ROTIERT"
|
||||
? await offeneEntfernungen([alt.id])
|
||||
: null;
|
||||
// Abbruch (#032): solange der neue Schluessel nicht widerrufen ist, laeuft sein Widerruf hier weiter.
|
||||
const abbruch =
|
||||
stand.neu && stand.abgebrochen
|
||||
? { offen: stand.neu.status === "WIDERRUFEN" ? null : await offeneEntfernungen([stand.neu.id]) }
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 sm:gap-7">
|
||||
@@ -74,6 +79,11 @@ export default async function RotierenSeite({ params }: { params: Promise<{ id:
|
||||
neuOffen: stand.neu ? stand.neuOffen : 0,
|
||||
tests: stand.neu ? stand.tests : [],
|
||||
widerruf,
|
||||
abbruch,
|
||||
letzterAbbruch:
|
||||
!stand.neu && stand.alt.nachfolger?.status === "WIDERRUFEN"
|
||||
? { id: stand.alt.nachfolger.id, name: stand.alt.nachfolger.name }
|
||||
: null,
|
||||
darfPrivat: darf(benutzer.rolle, "privateKeyAusrollen"),
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Leerzustand } from "@/components/ui/Leerzustand";
|
||||
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||
import { WiderrufAblauf } from "@/components/widerruf/WiderrufAblauf";
|
||||
import { WiderrufFormular } from "@/components/widerruf/WiderrufFormular";
|
||||
import { idsAusSuchparameter } from "@/lib/schluessel";
|
||||
import { darf } from "@/server/auth/rechte";
|
||||
import { bestaetigungFrisch, sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { widerrufUebersicht } from "@/server/queries/widerruf";
|
||||
@@ -17,14 +18,6 @@ export const metadata: Metadata = { title: "Massenwiderruf" };
|
||||
|
||||
type Suchparameter = Promise<{ id?: string | string[]; zielkonten?: string | string[] }>;
|
||||
|
||||
function liste(wert: string | string[] | undefined): string[] {
|
||||
const roh = Array.isArray(wert) ? wert : wert ? [wert] : [];
|
||||
return [...new Set(roh.flatMap((w) => w.split(",")).filter((w) => /^[a-z0-9]{1,64}$/i.test(w)))].slice(
|
||||
0,
|
||||
200,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Massenwiderruf (#024): mehrere Schluessel – etwa alle einer Person nach Projektende – in einem
|
||||
* Durchgang mit gemeinsamem Dry-Run. Nur Admins; das Vormerken verlangt eine frische
|
||||
@@ -32,7 +25,7 @@ function liste(wert: string | string[] | undefined): string[] {
|
||||
*/
|
||||
export default async function MassenwiderrufSeite({ searchParams }: { searchParams: Suchparameter }) {
|
||||
const parameter = await searchParams;
|
||||
const ids = liste(parameter.id);
|
||||
const ids = idsAusSuchparameter(parameter.id);
|
||||
const selbst = `/schluessel/widerruf?${ids.map((id) => `id=${id}`).join("&")}`;
|
||||
const { benutzer } = await sitzungErforderlich("massenwiderruf", selbst);
|
||||
|
||||
@@ -61,7 +54,7 @@ export default async function MassenwiderrufSeite({ searchParams }: { searchPara
|
||||
const offeneStati = (s: { status: string }) => s.status === "AKTIV" || s.status === "ABGELAUFEN";
|
||||
const nochNichtEingeleitet = uebersicht.schluessel.filter((s) => offeneStati(s) && !s.eingeleitet);
|
||||
const laufend = uebersicht.schluessel.filter((s) => offeneStati(s) && s.eingeleitet);
|
||||
const vorauswahl = liste(parameter.zielkonten);
|
||||
const vorauswahl = idsAusSuchparameter(parameter.zielkonten);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 sm:gap-7">
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { AnmeldeRahmen } from "@/components/anmeldung/AnmeldeRahmen";
|
||||
import { NotfallFormular } from "@/components/anmeldung/NotfallFormular";
|
||||
import { KontoFormular } from "@/components/anmeldung/KontoFormular";
|
||||
import { buttonKlassen, Formularfehler } from "@/components/ui";
|
||||
import { lokaleKontenAktiv } from "@/lib/lokale-konten";
|
||||
import { sicheresZiel } from "@/server/auth/anmeldung";
|
||||
import { oidcAktiv, oidcName } from "@/server/auth/oidc";
|
||||
import { angemeldet } from "@/server/auth/sitzungspflicht";
|
||||
@@ -33,6 +34,7 @@ export default async function AnmeldenSeite({
|
||||
if (await angemeldet()) redirect(ziel);
|
||||
|
||||
const oidc = oidcAktiv();
|
||||
const lokal = lokaleKontenAktiv();
|
||||
const fehler = parameter.fehler ? (FEHLER[parameter.fehler] ?? FEHLER["allgemein"]) : undefined;
|
||||
|
||||
return (
|
||||
@@ -53,15 +55,17 @@ export default async function AnmeldenSeite({
|
||||
</a>
|
||||
) : (
|
||||
<p className="text-muted text-[14px]">
|
||||
Kein Identitätsanbieter eingerichtet – nur der Notfallzugang ist aktiv.
|
||||
{lokal
|
||||
? "Kein Identitätsanbieter eingerichtet – Anmeldung mit lokalem Konto."
|
||||
: "Kein Identitätsanbieter eingerichtet – nur der Notfallzugang ist aktiv."}
|
||||
</p>
|
||||
)}
|
||||
<details className="group border-border rounded-box border" open={!oidc}>
|
||||
<summary className="text-muted hover:text-text flex min-h-11 cursor-pointer items-center px-4 text-[14px] font-medium">
|
||||
Notfallzugang
|
||||
{lokal ? "Lokales Konto oder Notfallzugang" : "Notfallzugang"}
|
||||
</summary>
|
||||
<div className="border-border border-t p-4">
|
||||
<NotfallFormular ziel={ziel} />
|
||||
<KontoFormular ziel={ziel} lokal={lokal} />
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { AnmeldeRahmen } from "@/components/anmeldung/AnmeldeRahmen";
|
||||
import { PasswortSetzenFormular } from "@/components/anmeldung/PasswortSetzenFormular";
|
||||
import { KONTO_PASSWORT_MINDESTLAENGE } from "@/lib/lokale-konten";
|
||||
import { passwortSetzenLesen } from "@/server/auth/totp-einrichtung";
|
||||
|
||||
export const metadata: Metadata = { title: "Eigenes Passwort setzen" };
|
||||
|
||||
/**
|
||||
* Erste Anmeldung eines lokalen Kontos (#035): das Einmal-Passwort vom Admin wird durch ein
|
||||
* eigenes ersetzt. Nur mit dem Cookie erreichbar, das ein richtiges Einmal-Passwort ausstellt.
|
||||
*/
|
||||
export default async function PasswortSetzenSeite() {
|
||||
if (!(await passwortSetzenLesen())) redirect("/anmelden");
|
||||
return (
|
||||
<AnmeldeRahmen
|
||||
titel="Eigenes Passwort setzen"
|
||||
text={`Das Einmal-Passwort gilt nur für diese erste Anmeldung. Bitte ein eigenes mit mindestens ${KONTO_PASSWORT_MINDESTLAENGE} Zeichen wählen – danach wird der zweite Faktor eingerichtet.`}
|
||||
>
|
||||
<PasswortSetzenFormular />
|
||||
</AnmeldeRahmen>
|
||||
);
|
||||
}
|
||||
@@ -4,20 +4,30 @@ import QRCode from "qrcode";
|
||||
import { AnmeldeRahmen } from "@/components/anmeldung/AnmeldeRahmen";
|
||||
import { TotpFormular } from "@/components/anmeldung/TotpFormular";
|
||||
import { MonoKopieren } from "@/components/ui";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { totpAdresse } from "@/lib/totp";
|
||||
import { totpEinrichtungLesen } from "@/server/auth/totp-einrichtung";
|
||||
|
||||
export const metadata: Metadata = { title: "TOTP einrichten" };
|
||||
|
||||
/**
|
||||
* Erste Anmeldung des Notfall-Admins: TOTP ist Pflicht (Freigabe Frage 5). Die Seite ist
|
||||
* nur mit dem Einrichtungs-Cookie erreichbar, das ein richtiges Passwort ausgestellt hat.
|
||||
* Erste Anmeldung eines Passwort-Kontos (Notfall-Admin, lokale Konten #035): TOTP ist Pflicht
|
||||
* (Freigabe Frage 5). Die Seite ist nur mit dem Einrichtungs-Cookie erreichbar, das ein
|
||||
* richtiges Passwort ausgestellt hat.
|
||||
*/
|
||||
export default async function TotpSeite() {
|
||||
const einrichtung = await totpEinrichtungLesen();
|
||||
if (!einrichtung) redirect("/anmelden");
|
||||
|
||||
const adresse = totpAdresse(einrichtung.geheimnis, "Notfall-Admin");
|
||||
const konto = await prisma.benutzer.findUnique({
|
||||
where: { id: einrichtung.benutzerId },
|
||||
select: { anmeldename: true, istNotfall: true },
|
||||
});
|
||||
// In der Authenticator-App soll stehen, welches Konto das ist.
|
||||
const adresse = totpAdresse(
|
||||
einrichtung.geheimnis,
|
||||
konto?.istNotfall ? "Notfall-Admin" : (konto?.anmeldename ?? "Konto"),
|
||||
);
|
||||
// Als SVG im Server gerendert: kein Bild-Endpunkt, keine Bibliothek im Browser.
|
||||
const svg = await QRCode.toString(adresse, {
|
||||
type: "svg",
|
||||
|
||||
@@ -22,7 +22,7 @@ export default async function BestaetigenSeite({
|
||||
titel="Anmeldung bestätigen"
|
||||
text="Diese Aktion ist kritisch. Bitte bestätigen Sie Ihre Anmeldung – die Bestätigung gilt fünf Minuten."
|
||||
>
|
||||
{benutzer.istNotfall ? (
|
||||
{benutzer.passwortHash && !benutzer.oidcSub ? (
|
||||
<BestaetigenFormular ziel={ziel} />
|
||||
) : (
|
||||
<a
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { Button, Feld, Formularfehler } from "@/components/ui";
|
||||
import { notfallBestaetigenAktion, type FormularZustand } from "@/server/actions/anmeldung";
|
||||
import { kontoBestaetigenAktion, type FormularZustand } from "@/server/actions/anmeldung";
|
||||
|
||||
/** Erneute Bestaetigung des Notfall-Admins vor kritischen Aktionen (#004). */
|
||||
export function BestaetigenFormular({ ziel }: { ziel: string }) {
|
||||
const [zustand, aktion, laeuft] = useActionState<FormularZustand, FormData>(notfallBestaetigenAktion, {});
|
||||
const [zustand, aktion, laeuft] = useActionState<FormularZustand, FormData>(kontoBestaetigenAktion, {});
|
||||
return (
|
||||
<form action={aktion} className="flex flex-col gap-4">
|
||||
<input type="hidden" name="ziel" value={ziel} />
|
||||
|
||||
+8
-5
@@ -2,11 +2,14 @@
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { Button, Feld, Formularfehler } from "@/components/ui";
|
||||
import { notfallAnmeldenAktion, type FormularZustand } from "@/server/actions/anmeldung";
|
||||
import { kontoAnmeldenAktion, type FormularZustand } from "@/server/actions/anmeldung";
|
||||
|
||||
/** Notfallzugang: Anmeldename, Passwort und – sobald eingerichtet – TOTP-Code (#004). */
|
||||
export function NotfallFormular({ ziel }: { ziel: string }) {
|
||||
const [zustand, aktion, laeuft] = useActionState<FormularZustand, FormData>(notfallAnmeldenAktion, {});
|
||||
/**
|
||||
* Lokale Konten und Notfallzugang: Anmeldename, Passwort und – sobald eingerichtet – TOTP-Code
|
||||
* (#004, #035).
|
||||
*/
|
||||
export function KontoFormular({ ziel, lokal }: { ziel: string; lokal: boolean }) {
|
||||
const [zustand, aktion, laeuft] = useActionState<FormularZustand, FormData>(kontoAnmeldenAktion, {});
|
||||
return (
|
||||
<form action={aktion} className="flex flex-col gap-4">
|
||||
<input type="hidden" name="ziel" value={ziel} />
|
||||
@@ -29,7 +32,7 @@ export function NotfallFormular({ ziel }: { ziel: string }) {
|
||||
hinweis="Bei der ersten Anmeldung leer lassen – dann wird TOTP eingerichtet."
|
||||
/>
|
||||
<Button type="submit" variante="sekundaer" disabled={laeuft}>
|
||||
{laeuft ? "Prüfe …" : "Mit Notfallzugang anmelden"}
|
||||
{laeuft ? "Prüfe …" : lokal ? "Mit Benutzername anmelden" : "Mit Notfallzugang anmelden"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { Button, Feld, Formularfehler } from "@/components/ui";
|
||||
import { passwortSetzenAktion, type FormularZustand } from "@/server/actions/anmeldung";
|
||||
|
||||
export function PasswortSetzenFormular() {
|
||||
const [zustand, aktion, laeuft] = useActionState<FormularZustand, FormData>(passwortSetzenAktion, {});
|
||||
return (
|
||||
<form action={aktion} className="flex flex-col gap-4">
|
||||
<Formularfehler text={zustand.fehler} />
|
||||
<Feld
|
||||
beschriftung="Neues Passwort"
|
||||
name="passwort"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
<Feld
|
||||
beschriftung="Neues Passwort wiederholen"
|
||||
name="wiederholung"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
/>
|
||||
<Button type="submit" variante="primaer" disabled={laeuft}>
|
||||
{laeuft ? "Speichere …" : "Passwort setzen"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { MonoKopieren } from "@/components/ui";
|
||||
|
||||
/** Einmal-Passwort fuer ein neues oder zurueckgesetztes Konto (#035) – wird nur jetzt gezeigt. */
|
||||
export function EinmalPasswort({ anmeldename, passwort }: { anmeldename: string; passwort: string }) {
|
||||
return (
|
||||
<section
|
||||
aria-label={`Einmal-Passwort für ${anmeldename}`}
|
||||
className="border-accent bg-raised rounded-box flex flex-col gap-2 border p-4"
|
||||
>
|
||||
<p className="text-[14px]">
|
||||
Einmal-Passwort für <span className="font-mono">{anmeldename}</span> –{" "}
|
||||
<strong>nur jetzt sichtbar</strong>. Bitte auf sicherem Weg weitergeben; bei der ersten Anmeldung wird
|
||||
ein eigenes Passwort und TOTP eingerichtet.
|
||||
</p>
|
||||
<MonoKopieren wert={passwort} beschriftung="Einmal-Passwort" />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { EinmalPasswort } from "@/components/benutzer/EinmalPasswort";
|
||||
import { Button } from "@/components/ui";
|
||||
import type { KontoHerkunft } from "@/server/queries/benutzer";
|
||||
import { aktivSetzenAktion, rolleAendernAktion, zuruecksetzenAktion } from "@/server/actions/benutzer";
|
||||
|
||||
const ROLLEN = [
|
||||
{ wert: "LESER", text: "Leser" },
|
||||
{ wert: "OPERATOR", text: "Operator" },
|
||||
{ wert: "ADMIN", text: "Admin" },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Aktionen je Konto (#035): Rolle (nur lokal), sperren/entsperren, zuruecksetzen (nur lokal).
|
||||
* Der Notfall-Admin und das eigene Konto bekommen keine Knoepfe – der Server lehnt es ohnehin ab.
|
||||
*/
|
||||
export function KontoAktionen({
|
||||
konto,
|
||||
selbst,
|
||||
}: {
|
||||
konto: {
|
||||
id: string;
|
||||
name: string;
|
||||
anmeldename: string | null;
|
||||
rolle: string;
|
||||
aktiv: boolean;
|
||||
herkunft: KontoHerkunft;
|
||||
};
|
||||
selbst: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [fehler, setFehler] = useState<string>();
|
||||
const [einmal, setEinmal] = useState<string>();
|
||||
const [rueckfrage, setRueckfrage] = useState(false);
|
||||
const [laeuft, starten] = useTransition();
|
||||
|
||||
if (konto.herkunft === "notfall" || selbst) {
|
||||
return <span className="text-muted text-[13px]">{selbst ? "eigenes Konto" : "unveränderlich"}</span>;
|
||||
}
|
||||
|
||||
const ausfuehren = (aktion: () => Promise<{ fehler?: string; einmalPasswort?: string }>) =>
|
||||
starten(async () => {
|
||||
setFehler(undefined);
|
||||
const antwort = await aktion();
|
||||
if (antwort.fehler) setFehler(antwort.fehler);
|
||||
if (antwort.einmalPasswort) setEinmal(antwort.einmalPasswort);
|
||||
router.refresh();
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
{konto.herkunft === "lokal" && (
|
||||
<label className="sr-only" htmlFor={`rolle-${konto.id}`}>
|
||||
Rolle von {konto.name}
|
||||
</label>
|
||||
)}
|
||||
{konto.herkunft === "lokal" && (
|
||||
<select
|
||||
id={`rolle-${konto.id}`}
|
||||
className="bg-bg border-border-strong rounded-feld min-h-11 border px-2 text-[14px]"
|
||||
value={konto.rolle}
|
||||
disabled={laeuft}
|
||||
onChange={(e) => ausfuehren(() => rolleAendernAktion(konto.id, e.target.value))}
|
||||
>
|
||||
{ROLLEN.map((r) => (
|
||||
<option key={r.wert} value={r.wert}>
|
||||
{r.text}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<Button
|
||||
klein
|
||||
disabled={laeuft}
|
||||
onClick={() => ausfuehren(() => aktivSetzenAktion(konto.id, !konto.aktiv))}
|
||||
>
|
||||
{konto.aktiv ? "Sperren" : "Entsperren"}
|
||||
<span className="sr-only"> – {konto.name}</span>
|
||||
</Button>
|
||||
{konto.herkunft === "lokal" &&
|
||||
(rueckfrage ? (
|
||||
<Button
|
||||
klein
|
||||
variante="gefahr"
|
||||
disabled={laeuft}
|
||||
onClick={() => {
|
||||
setRueckfrage(false);
|
||||
ausfuehren(() => zuruecksetzenAktion(konto.id));
|
||||
}}
|
||||
>
|
||||
Wirklich zurücksetzen?
|
||||
</Button>
|
||||
) : (
|
||||
<Button klein disabled={laeuft} onClick={() => setRueckfrage(true)}>
|
||||
Zurücksetzen<span className="sr-only"> – {konto.name}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
{fehler && (
|
||||
<p role="alert" className="text-danger text-[13px]">
|
||||
{fehler}
|
||||
</p>
|
||||
)}
|
||||
{einmal && konto.anmeldename && <EinmalPasswort anmeldename={konto.anmeldename} passwort={einmal} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { UserPlus } from "lucide-react";
|
||||
import { useActionState } from "react";
|
||||
import { EinmalPasswort } from "@/components/benutzer/EinmalPasswort";
|
||||
import { Auswahlliste, Button, Feld, Formularfehler } from "@/components/ui";
|
||||
import { kontoAnlegenAktion, type KontoZustand } from "@/server/actions/benutzer";
|
||||
|
||||
/** Lokales Konto anlegen (#035). Das Passwort waehlt nicht der Admin, sshkv erzeugt es. */
|
||||
export function KontoAnlegen() {
|
||||
const [zustand, aktion, laeuft] = useActionState<KontoZustand, FormData>(kontoAnlegenAktion, {});
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{zustand.einmalPasswort && zustand.anmeldename && (
|
||||
<EinmalPasswort anmeldename={zustand.anmeldename} passwort={zustand.einmalPasswort} />
|
||||
)}
|
||||
<form action={aktion} className="grid gap-4 sm:grid-cols-[1fr_1fr_12rem_auto] sm:items-end">
|
||||
<div className="sm:col-span-4 empty:hidden">
|
||||
<Formularfehler text={zustand.fehler} />
|
||||
</div>
|
||||
<Feld
|
||||
beschriftung="Anmeldename"
|
||||
name="anmeldename"
|
||||
autoComplete="off"
|
||||
required
|
||||
mono
|
||||
fehler={zustand.feldfehler?.["anmeldename"]}
|
||||
/>
|
||||
<Feld
|
||||
beschriftung="Name"
|
||||
name="name"
|
||||
autoComplete="off"
|
||||
required
|
||||
fehler={zustand.feldfehler?.["name"]}
|
||||
/>
|
||||
<Auswahlliste beschriftung="Rolle" name="rolle" defaultValue="LESER">
|
||||
<option value="LESER">Leser</option>
|
||||
<option value="OPERATOR">Operator</option>
|
||||
<option value="ADMIN">Admin</option>
|
||||
</Auswahlliste>
|
||||
<Button type="submit" variante="primaer" disabled={laeuft} icon={<UserPlus className="size-4" />}>
|
||||
{laeuft ? "Lege an …" : "Konto anlegen"}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { Repeat } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { umstellenAktion } from "@/server/actions/deploymentkey";
|
||||
|
||||
/**
|
||||
* Schritt 4 der Rotation (#031). Hosts, auf denen die Pruefung scheiterte, lassen sich bewusst
|
||||
* uebergehen – sshkv kommt danach nicht mehr auf sie und sie brauchen einen neuen Bootstrap.
|
||||
*/
|
||||
export function Umstellen({
|
||||
fehlgeschlagen,
|
||||
}: {
|
||||
fehlgeschlagen: readonly { hostId: string; hostName: string }[];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [uebergehen, setUebergehen] = useState<string[]>([]);
|
||||
const [fehler, setFehler] = useState<string>();
|
||||
const [laeuft, starten] = useTransition();
|
||||
const bereit = fehlgeschlagen.every((h) => uebergehen.includes(h.hostId));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{fehlgeschlagen.length > 0 && (
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<legend className="text-danger mb-2 text-[13.5px]">
|
||||
Auf diesen Hosts hat der neue Key nicht funktioniert. Übergangene Hosts erreicht sshkv nach dem
|
||||
Umstellen nicht mehr – sie brauchen einen neuen Bootstrap.
|
||||
</legend>
|
||||
{fehlgeschlagen.map((h) => (
|
||||
<label key={h.hostId} className="flex min-h-11 items-center gap-3 text-[14px]">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-accent size-4"
|
||||
checked={uebergehen.includes(h.hostId)}
|
||||
onChange={(e) =>
|
||||
setUebergehen((alt) =>
|
||||
e.target.checked ? [...alt, h.hostId] : alt.filter((id) => id !== h.hostId),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span>
|
||||
<span className="font-mono">{h.hostName}</span> übergehen
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
variante="primaer"
|
||||
icon={<Repeat className="size-4" />}
|
||||
disabled={!bereit || laeuft}
|
||||
onClick={() =>
|
||||
starten(async () => {
|
||||
setFehler(undefined);
|
||||
const antwort = await umstellenAktion(uebergehen);
|
||||
if (antwort.fehler) setFehler(antwort.fehler);
|
||||
else router.refresh();
|
||||
})
|
||||
}
|
||||
>
|
||||
Auf neuen Key umstellen
|
||||
</Button>
|
||||
{fehler && (
|
||||
<p role="alert" className="text-danger text-[13.5px]">
|
||||
{fehler}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { Button, Formularfehler, Karte } from "@/components/ui";
|
||||
import { Schrittleiste } from "@/components/ui/Schrittleiste";
|
||||
import { warten } from "@/components/vorgang/warten";
|
||||
import { WiderrufAblauf } from "@/components/widerruf/WiderrufAblauf";
|
||||
import { RotationAbbrechen } from "./RotationAbbrechen";
|
||||
import {
|
||||
rotationAltWiderrufenAktion,
|
||||
rotationStartenAktion,
|
||||
@@ -47,6 +48,13 @@ export type RotationsDaten = {
|
||||
/** Offene Entfernungen des alten Schluessels, sobald sein Widerruf laeuft. */
|
||||
widerruf: OffeneEntfernung[] | null;
|
||||
darfPrivat: boolean;
|
||||
/**
|
||||
* Abgebrochene Rotation (#032): `offen` sind die offenen Entfernungen des neuen Schluessels,
|
||||
* solange sein Widerruf laeuft; null, wenn er abgeschlossen ist.
|
||||
*/
|
||||
abbruch: { offen: OffeneEntfernung[] | null } | null;
|
||||
/** Zuletzt abgebrochener Nachfolger, wenn die Rotation danach wieder am Anfang steht (#033). */
|
||||
letzterAbbruch: { id: string; name: string } | null;
|
||||
};
|
||||
|
||||
export function Rotation({ daten }: { daten: RotationsDaten }) {
|
||||
@@ -109,8 +117,43 @@ export function Rotation({ daten }: { daten: RotationsDaten }) {
|
||||
</>
|
||||
);
|
||||
|
||||
if (daten.abbruch?.offen && neu) {
|
||||
return (
|
||||
<Karte
|
||||
titel="Rotation wird abgebrochen"
|
||||
untertitel={`Der neue Schlüssel wird entfernt – „${alt.name}“ bleibt aktiv und unverändert.`}
|
||||
className="border-danger-border"
|
||||
>
|
||||
<WiderrufAblauf
|
||||
schluesselIds={[neu.id]}
|
||||
offen={daten.abbruch.offen}
|
||||
zielkonten={[]}
|
||||
vorauswahl={[]}
|
||||
ruecksprung={`/schluessel/${alt.id}/rotieren`}
|
||||
darfPrivat={daten.darfPrivat}
|
||||
ohneZielkonten
|
||||
/>
|
||||
</Karte>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{daten.letzterAbbruch && schritt === 0 && (
|
||||
<Karte titel="Rotation abgebrochen">
|
||||
<p className="text-[14px]">
|
||||
Der neue Schlüssel{" "}
|
||||
<Link href={`/schluessel/${daten.letzterAbbruch.id}`} className="text-accent hover:underline">
|
||||
{daten.letzterAbbruch.name}
|
||||
</Link>{" "}
|
||||
ist widerrufen und von allen Hosts entfernt.{" "}
|
||||
<Link href={`/schluessel/${alt.id}`} className="text-accent hover:underline">
|
||||
{alt.name}
|
||||
</Link>{" "}
|
||||
bleibt aktiv und lässt sich erneut rotieren.
|
||||
</p>
|
||||
</Karte>
|
||||
)}
|
||||
<Schrittleiste schritte={SCHRITTE} aktuell={schritt} beschriftung="Schritte der Rotation" />
|
||||
|
||||
{schritt <= 1 && (
|
||||
@@ -225,6 +268,11 @@ export function Rotation({ daten }: { daten: RotationsDaten }) {
|
||||
</Karte>
|
||||
)}
|
||||
|
||||
{/* Abbrechen geht, solange der alte Schluessel noch nicht entfernt wird (#032). */}
|
||||
{neu && schritt >= 1 && schritt <= 4 && !daten.widerruf && (
|
||||
<RotationAbbrechen altId={alt.id} altName={alt.name} neuName={neu.name} />
|
||||
)}
|
||||
|
||||
{schritt === 5 && neu && (
|
||||
<Karte titel="Rotation abgeschlossen">
|
||||
<p className="text-[14px]">
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { Undo2 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { Button, Formularfehler } from "@/components/ui";
|
||||
import { rotationAbbrechenAktion } from "@/server/actions/rotation";
|
||||
|
||||
/**
|
||||
* „Rotation abbrechen“ (#032) – auf der Rotationsseite und auf der Seite des Nachfolgers. Der
|
||||
* Klick merkt den neuen Schluessel nur zum Widerruf vor; entfernt wird er erst nach Dry-Run und
|
||||
* „Anwenden“. Deshalb erst eine Rueckfrage, die genau das sagt.
|
||||
*/
|
||||
export function RotationAbbrechen({
|
||||
altId,
|
||||
altName,
|
||||
neuName,
|
||||
}: {
|
||||
altId: string;
|
||||
altName: string;
|
||||
neuName: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [offen, setOffen] = useState(false);
|
||||
const [fehler, setFehler] = useState<string>();
|
||||
const [laeuft, starten] = useTransition();
|
||||
|
||||
if (!offen) {
|
||||
return (
|
||||
<div>
|
||||
<Button icon={<Undo2 className="size-4" />} onClick={() => setOffen(true)}>
|
||||
Rotation abbrechen
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label="Rotation abbrechen?"
|
||||
className="border-danger-border bg-bg rounded-box flex flex-col gap-3 border px-4 py-3 text-[14px]"
|
||||
>
|
||||
<p className="font-medium">Rotation abbrechen?</p>
|
||||
<ul className="text-muted list-disc space-y-1 pl-5 text-[13.5px]">
|
||||
<li>
|
||||
Der neue Schlüssel „{neuName}“ wird widerrufen – wo er schon ausgerollt ist, zeigt der Dry-Run, was
|
||||
entfernt wird.
|
||||
</li>
|
||||
<li>„{altName}“ bleibt aktiv und unverändert; ein config-Alias zeigt danach wieder auf ihn.</li>
|
||||
<li>Die Verknüpfung bleibt in der Rotationskette sichtbar, der Abbruch steht im Protokoll.</li>
|
||||
</ul>
|
||||
<Formularfehler text={fehler} />
|
||||
<div className="flex flex-wrap justify-end gap-2.5">
|
||||
<Button disabled={laeuft} onClick={() => setOffen(false)}>
|
||||
Zurück
|
||||
</Button>
|
||||
<Button
|
||||
variante="gefahr"
|
||||
icon={<Undo2 className="size-4" />}
|
||||
disabled={laeuft}
|
||||
onClick={() =>
|
||||
starten(async () => {
|
||||
setFehler(undefined);
|
||||
const antwort = await rotationAbbrechenAktion(altId);
|
||||
if (antwort.fehler) {
|
||||
setFehler(antwort.fehler);
|
||||
return;
|
||||
}
|
||||
setOffen(false);
|
||||
router.refresh();
|
||||
})
|
||||
}
|
||||
>
|
||||
{laeuft ? "Wird vorgemerkt …" : "Neuen Schlüssel widerrufen"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ export function Karte({
|
||||
className,
|
||||
innen = "normal",
|
||||
as: Element = "section",
|
||||
id,
|
||||
}: {
|
||||
titel?: ReactNode;
|
||||
untertitel?: ReactNode;
|
||||
@@ -22,9 +23,12 @@ export function Karte({
|
||||
/** „ohne“ für Inhalte, die bis an den Rand reichen (z. B. Tabellen mit eigenem Abstand). */
|
||||
innen?: "normal" | "ohne";
|
||||
as?: "section" | "div" | "article";
|
||||
/** Sprungziel für Links von anderen Seiten (z. B. `#dry-run`, #032). */
|
||||
id?: string;
|
||||
}) {
|
||||
return (
|
||||
<Element
|
||||
id={id}
|
||||
className={clsx(
|
||||
"border-border bg-surface min-w-0 rounded-karte border",
|
||||
innen === "normal" && "p-5 sm:p-6",
|
||||
|
||||
@@ -32,6 +32,7 @@ export function WiderrufFormular({
|
||||
const [laeuft, starten] = useTransition();
|
||||
const gesperrt = uebersicht.schluessel.filter((s) => s.gesperrt);
|
||||
const mehrere = uebersicht.schluessel.length > 1;
|
||||
const knopfText = mehrere ? `${uebersicht.schluessel.length} Schlüssel widerrufen` : "Widerruf vorbereiten";
|
||||
|
||||
const ziel = (() => {
|
||||
const url = new URL(ruecksprung, "http://x");
|
||||
@@ -100,7 +101,8 @@ export function WiderrufFormular({
|
||||
href={`/bestaetigen?ziel=${encodeURIComponent(ruecksprung)}`}
|
||||
className="text-accent inline-flex min-h-11 items-center text-[14px] font-medium"
|
||||
>
|
||||
Anmeldung bestätigen und danach erneut „Widerruf vorbereiten“ wählen
|
||||
{/* Nennt den Knopf, den es hier wirklich gibt – beim Massenwiderruf heisst er anders (#032). */}
|
||||
Anmeldung bestätigen und danach erneut „{knopfText}“ wählen
|
||||
</a>
|
||||
)}
|
||||
<div className="flex flex-wrap justify-end gap-2.5">
|
||||
@@ -124,11 +126,7 @@ export function WiderrufFormular({
|
||||
})
|
||||
}
|
||||
>
|
||||
{laeuft
|
||||
? "Wird vorgemerkt …"
|
||||
: mehrere
|
||||
? `${uebersicht.schluessel.length} Schlüssel widerrufen`
|
||||
: "Widerruf vorbereiten"}
|
||||
{laeuft ? "Wird vorgemerkt …" : knopfText}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -175,7 +175,7 @@ export function ZellenDialog({
|
||||
zeigt und schreibt die Änderung.
|
||||
</p>
|
||||
<Button
|
||||
href={`/schluessel/${schluessel.id}`}
|
||||
href={`/schluessel/${schluessel.id}#dry-run`}
|
||||
klein
|
||||
className="self-start"
|
||||
icon={<ArrowRight className="size-4" />}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { randomInt } from "node:crypto";
|
||||
import { z } from "@/lib/validierung";
|
||||
|
||||
/**
|
||||
* Regeln fuer lokale Konten (#035): Anmeldename, Passwortlaenge, Einmal-Passwort. Rein, damit
|
||||
* testbar. Lokale Konten melden sich wie der Notfall-Admin mit Passwort und Pflicht-TOTP an.
|
||||
*/
|
||||
|
||||
/** Mit Pflicht-TOTP genuegen 12 Zeichen; der Notfall-Admin behaelt seine 16 (#004). */
|
||||
export const KONTO_PASSWORT_MINDESTLAENGE = 12;
|
||||
export const KONTO_PASSWORT_HOECHSTLAENGE = 256;
|
||||
|
||||
export const anmeldenameSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.regex(/^[a-z][a-z0-9._-]{1,63}$/, {
|
||||
message:
|
||||
"2–64 Zeichen: Kleinbuchstaben, Ziffern, Punkt, Binde- und Unterstrich; mit einem Buchstaben beginnen.",
|
||||
});
|
||||
|
||||
export const kontoNameSchema = z.string().trim().min(1).max(120);
|
||||
|
||||
/** Welche Rollen ein lokales Konto haben kann – dieselben wie ueber OIDC. */
|
||||
export const kontoRolleSchema = z.enum(["ADMIN", "OPERATOR", "LESER"]);
|
||||
|
||||
export function passwortFehler(passwort: string, anmeldename?: string | null): string | null {
|
||||
if (passwort.length < KONTO_PASSWORT_MINDESTLAENGE)
|
||||
return `Das Passwort braucht mindestens ${KONTO_PASSWORT_MINDESTLAENGE} Zeichen.`;
|
||||
if (passwort.length > KONTO_PASSWORT_HOECHSTLAENGE) return "Das Passwort ist zu lang.";
|
||||
if (anmeldename && passwort.toLowerCase().includes(anmeldename.toLowerCase()))
|
||||
return "Das Passwort darf den Anmeldenamen nicht enthalten.";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Ohne leicht verwechselbare Zeichen (0/O, 1/l/I) – das Passwort wird abgetippt. */
|
||||
const ZEICHEN = "abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
|
||||
/**
|
||||
* Einmal-Passwort fuer ein neues oder zurueckgesetztes Konto: 4 × 5 Zeichen mit Bindestrichen,
|
||||
* gut 115 Bit. Es wird genau einmal angezeigt und muss bei der ersten Anmeldung ersetzt werden.
|
||||
*/
|
||||
export function einmalPasswortErzeugen(): string {
|
||||
return Array.from({ length: 4 }, () =>
|
||||
Array.from({ length: 5 }, () => ZEICHEN[randomInt(ZEICHEN.length)]).join(""),
|
||||
).join("-");
|
||||
}
|
||||
|
||||
/** Lokale Konten lassen sich per `SSHKV_LOKALE_KONTEN=0` abschalten; der Notfall-Admin bleibt. */
|
||||
export function lokaleKontenAktiv(
|
||||
umgebung: Readonly<Record<string, string | undefined>> = process.env,
|
||||
): boolean {
|
||||
const wert = umgebung["SSHKV_LOKALE_KONTEN"]?.trim().toLowerCase();
|
||||
return !(wert === "0" || wert === "false" || wert === "nein" || wert === "aus");
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
export const AKTIONSGRUPPEN = {
|
||||
anmeldung: "Anmeldung",
|
||||
benutzer: "Benutzer",
|
||||
hostkey: "Host-Key",
|
||||
ausrollen: "Ausrollen",
|
||||
schluessel: "Schlüssel",
|
||||
@@ -44,6 +45,17 @@ export const AKTIONEN: Readonly<Record<string, Beschreibung>> = {
|
||||
"anmeldung.oidc.erstmals": a("Erste Anmeldung (OIDC)", "anmeldung"),
|
||||
"anmeldung.verweigert": a("Anmeldung verweigert", "anmeldung", false),
|
||||
"anmeldung.notfall": a("Anmeldung Notfall-Admin", "anmeldung", false),
|
||||
"anmeldung.lokal": a("Anmeldung (lokales Konto)", "anmeldung", false),
|
||||
"anmeldung.lokal.fehlgeschlagen": a("Anmeldung (lokal) fehlgeschlagen", "anmeldung", false),
|
||||
"anmeldung.lokal.gesperrt": a("Anmeldung (lokal) gesperrt – zu viele Fehlversuche", "anmeldung", false),
|
||||
"anmeldung.lokal.totp_falsch": a("Anmeldung (lokal): falscher TOTP-Code", "anmeldung", false),
|
||||
"konto.passwort_gesetzt": a("Eigenes Passwort gesetzt", "benutzer"),
|
||||
"konto.totp_eingerichtet": a("TOTP eingerichtet", "anmeldung"),
|
||||
"benutzer.angelegt": a("Lokales Konto angelegt", "benutzer"),
|
||||
"benutzer.rolle_geaendert": a("Rolle geändert", "benutzer"),
|
||||
"benutzer.gesperrt": a("Konto gesperrt", "benutzer"),
|
||||
"benutzer.entsperrt": a("Konto entsperrt", "benutzer"),
|
||||
"benutzer.zurueckgesetzt": a("Konto zurückgesetzt (Passwort und TOTP)", "benutzer"),
|
||||
"anmeldung.notfall.fehlgeschlagen": a("Notfall-Anmeldung fehlgeschlagen", "anmeldung", false),
|
||||
"anmeldung.notfall.gesperrt": a("Notfall-Anmeldung gesperrt", "anmeldung", false),
|
||||
"anmeldung.notfall.totp_falsch": a("Notfall-Anmeldung: TOTP falsch", "anmeldung", false),
|
||||
@@ -77,12 +89,17 @@ export const AKTIONEN: Readonly<Record<string, Beschreibung>> = {
|
||||
"privatekey.heruntergeladen": a("Private Key heruntergeladen", "schluessel", false),
|
||||
"privatekey.geloescht": a("Private Key gelöscht", "schluessel"),
|
||||
"deploymentkey.erzeugt": a("Deployment-Key erzeugt", "schluessel"),
|
||||
"deploymentkey.geprueft": a("Neuer Deployment-Key geprüft", "widerruf", false),
|
||||
"deploymentkey.umgestellt": a("Deployment-Key umgestellt", "widerruf"),
|
||||
"deploymentkey.abgebrochen": a("Rotation des Deployment-Keys abgebrochen", "widerruf"),
|
||||
"deploymentkey.abgeschlossen": a("Rotation des Deployment-Keys abgeschlossen", "widerruf"),
|
||||
|
||||
// Widerruf und Rotation (#023, #024)
|
||||
"schluessel.widerruf.eingeleitet": a("Widerruf eingeleitet", "widerruf"),
|
||||
"schluessel.widerrufen": a("Schlüssel widerrufen", "widerruf"),
|
||||
"schluessel.rotiert": a("Schlüssel rotiert", "widerruf"),
|
||||
"schluessel.rotation.uebernommen": a("Rotation: Zuweisungen übernommen", "widerruf"),
|
||||
"schluessel.rotation.abgebrochen": a("Rotation abgebrochen", "widerruf"),
|
||||
|
||||
// Zielkonten (#018)
|
||||
"zielkonto.angelegt": a("Zielkonto angelegt", "zielkonto"),
|
||||
|
||||
@@ -35,3 +35,20 @@ export function rotationsPfad(pfad: string, datumKompakt: string, belegt: Readon
|
||||
for (let nummer = 2; belegt.has(kandidat); nummer += 1) kandidat = `${basis}-r${datumKompakt}-${nummer}`;
|
||||
return kandidat;
|
||||
}
|
||||
|
||||
/** Laedt nur den juengsten Nachfolger – nach einem Abbruch kann es mehrere geben (#033). */
|
||||
export const JUENGSTER = { orderBy: { angelegtAm: "desc" }, take: 1 } as const;
|
||||
|
||||
/**
|
||||
* Macht aus `nachfolgerListe` (hoechstens ein Element, siehe JUENGSTER) wieder `nachfolger`.
|
||||
* Massgeblich ist immer der juengste: ein laufender oder – wenn keiner laeuft – der zuletzt
|
||||
* abgebrochene.
|
||||
*/
|
||||
export type MitNachfolger<T extends { nachfolgerListe: readonly unknown[] }> = Omit<T, "nachfolgerListe"> & {
|
||||
nachfolger: T["nachfolgerListe"][number] | null;
|
||||
};
|
||||
|
||||
export function mitNachfolger<T extends { nachfolgerListe: readonly unknown[] }>(s: T): MitNachfolger<T> {
|
||||
const { nachfolgerListe, ...rest } = s;
|
||||
return { ...rest, nachfolger: nachfolgerListe[0] ?? null };
|
||||
}
|
||||
|
||||
@@ -293,3 +293,14 @@ export function publicKeyZeileMitKommentar(zeile: string, kommentar: string): st
|
||||
const [typ, base64] = zeile.trim().split(/\s+/);
|
||||
return kommentar ? `${typ} ${base64} ${kommentar}` : `${typ} ${base64}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* IDs aus einem Suchparameter (`?id=a&id=b` oder `?id=a,b`), etwa fuer den Massenwiderruf (#024).
|
||||
* Erlaubt Bindestriche: Vom Worker erzeugte Schluessel tragen eine UUID, nur Prisma-Defaults sind
|
||||
* cuids – ohne Bindestrich fielen alle erzeugten Schluessel still aus der Auswahl (#032).
|
||||
*/
|
||||
export function idsAusSuchparameter(wert: string | string[] | undefined, hoechstens = 200): string[] {
|
||||
const roh = Array.isArray(wert) ? wert : wert ? [wert] : [];
|
||||
const gueltig = roh.flatMap((w) => w.split(",")).filter((w) => /^[a-z0-9-]{1,64}$/i.test(w));
|
||||
return [...new Set(gueltig)].slice(0, hoechstens);
|
||||
}
|
||||
|
||||
+2
-2
@@ -21,7 +21,7 @@ export const SITZUNG_COOKIE = "sshkv_sitzung";
|
||||
*/
|
||||
export const SITZUNG_LAUFZEIT_STUNDEN = 8;
|
||||
|
||||
export type SitzungsHerkunft = "notfall" | "oidc";
|
||||
export type SitzungsHerkunft = "notfall" | "lokal" | "oidc";
|
||||
|
||||
export type Sitzung = {
|
||||
benutzerId: string;
|
||||
@@ -45,7 +45,7 @@ const ALGORITHMUS = "HS256";
|
||||
|
||||
const MINDESTLAENGE = 32;
|
||||
|
||||
const ERLAUBTE_HERKUNFT: readonly SitzungsHerkunft[] = ["notfall", "oidc"];
|
||||
const ERLAUBTE_HERKUNFT: readonly SitzungsHerkunft[] = ["notfall", "lokal", "oidc"];
|
||||
|
||||
export class SitzungsFehler extends Error {
|
||||
constructor(nachricht: string, optionen?: ErrorOptions) {
|
||||
|
||||
@@ -7,16 +7,21 @@ import { totpGeheimnisErzeugen } from "@/lib/totp";
|
||||
import { z } from "@/lib/validierung";
|
||||
import { sicheresZiel, sitzungSetzen, ueberHttps } from "@/server/auth/anmeldung";
|
||||
import { angemeldet } from "@/server/auth/sitzungspflicht";
|
||||
import type { Benutzer } from "@/generated/prisma/client";
|
||||
import {
|
||||
EINRICHTUNG_SEKUNDEN,
|
||||
EINRICHTUNG_ZWECK,
|
||||
PASSWORT_SETZEN_COOKIE,
|
||||
PASSWORT_SETZEN_ZWECK,
|
||||
TOTP_EINRICHTUNG_COOKIE,
|
||||
passwortSetzenLesen,
|
||||
totpEinrichtungLesen,
|
||||
} from "@/server/auth/totp-einrichtung";
|
||||
import {
|
||||
AnmeldeFehler,
|
||||
notfallAnmelden,
|
||||
notfallBestaetigen,
|
||||
eigenesPasswortSetzen,
|
||||
kontoAnmelden,
|
||||
kontoBestaetigen,
|
||||
totpEinrichtungAbschliessen,
|
||||
} from "@/server/services/anmeldung";
|
||||
|
||||
@@ -27,48 +32,97 @@ import {
|
||||
*/
|
||||
export type FormularZustand = { readonly fehler?: string };
|
||||
|
||||
const notfallSchema = z.object({
|
||||
const kontoSchema = z.object({
|
||||
anmeldename: z.string().trim().min(1).max(64),
|
||||
passwort: z.string().min(1).max(256),
|
||||
code: z.string().trim().max(10).default(""),
|
||||
ziel: z.string().max(512).optional(),
|
||||
});
|
||||
|
||||
export async function notfallAnmeldenAktion(
|
||||
_vorher: FormularZustand,
|
||||
formular: FormData,
|
||||
): Promise<FormularZustand> {
|
||||
const eingabe = notfallSchema.safeParse(Object.fromEntries(formular));
|
||||
if (!eingabe.success) return { fehler: "Bitte Anmeldename und Passwort angeben." };
|
||||
const ziel = sicheresZiel(eingabe.data.ziel);
|
||||
const herkunft = (benutzer: Benutzer) => (benutzer.istNotfall ? "notfall" : "lokal");
|
||||
|
||||
let ergebnis;
|
||||
try {
|
||||
ergebnis = await notfallAnmelden(eingabe.data.anmeldename, eingabe.data.passwort, eingabe.data.code);
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof AnmeldeFehler) return { fehler: fehler.message };
|
||||
throw fehler;
|
||||
}
|
||||
|
||||
if (ergebnis.art === "totpEinrichten") {
|
||||
// Das neue Geheimnis lebt bis zur Bestaetigung nur in einem signierten, kurzlebigen
|
||||
// httpOnly-Cookie dieses Browsers – gespeichert wird es erst, wenn ein Code passt.
|
||||
const token = await kurzzeitTokenErzeugen(
|
||||
EINRICHTUNG_ZWECK,
|
||||
{ benutzerId: ergebnis.benutzer.id, geheimnis: totpGeheimnisErzeugen(), ziel },
|
||||
EINRICHTUNG_SEKUNDEN,
|
||||
);
|
||||
(await cookies()).set(TOTP_EINRICHTUNG_COOKIE, token, {
|
||||
/** Kurzlebige httpOnly-Cookies fuer die Einrichtungsschritte – nur auf /anmelden gueltig. */
|
||||
async function einrichtungsCookie(name: string, token: string) {
|
||||
(await cookies()).set(name, token, {
|
||||
httpOnly: true,
|
||||
sameSite: "strict",
|
||||
secure: ueberHttps(),
|
||||
path: "/anmelden",
|
||||
maxAge: EINRICHTUNG_SEKUNDEN,
|
||||
});
|
||||
}
|
||||
|
||||
async function totpEinrichtungStarten(benutzer: Benutzer, ziel: string): Promise<never> {
|
||||
// Das neue Geheimnis lebt bis zur Bestaetigung nur in einem signierten, kurzlebigen
|
||||
// httpOnly-Cookie dieses Browsers – gespeichert wird es erst, wenn ein Code passt.
|
||||
const token = await kurzzeitTokenErzeugen(
|
||||
EINRICHTUNG_ZWECK,
|
||||
{ benutzerId: benutzer.id, geheimnis: totpGeheimnisErzeugen(), ziel },
|
||||
EINRICHTUNG_SEKUNDEN,
|
||||
);
|
||||
await einrichtungsCookie(TOTP_EINRICHTUNG_COOKIE, token);
|
||||
redirect("/anmelden/totp");
|
||||
}
|
||||
|
||||
await sitzungSetzen(ergebnis.benutzer, "notfall");
|
||||
export async function kontoAnmeldenAktion(
|
||||
_vorher: FormularZustand,
|
||||
formular: FormData,
|
||||
): Promise<FormularZustand> {
|
||||
const eingabe = kontoSchema.safeParse(Object.fromEntries(formular));
|
||||
if (!eingabe.success) return { fehler: "Bitte Anmeldename und Passwort angeben." };
|
||||
const ziel = sicheresZiel(eingabe.data.ziel);
|
||||
|
||||
let ergebnis;
|
||||
try {
|
||||
ergebnis = await kontoAnmelden(eingabe.data.anmeldename, eingabe.data.passwort, eingabe.data.code);
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof AnmeldeFehler) return { fehler: fehler.message };
|
||||
throw fehler;
|
||||
}
|
||||
|
||||
if (ergebnis.art === "passwortSetzen") {
|
||||
const token = await kurzzeitTokenErzeugen(
|
||||
PASSWORT_SETZEN_ZWECK,
|
||||
{ benutzerId: ergebnis.benutzer.id, ziel },
|
||||
EINRICHTUNG_SEKUNDEN,
|
||||
);
|
||||
await einrichtungsCookie(PASSWORT_SETZEN_COOKIE, token);
|
||||
redirect("/anmelden/passwort");
|
||||
}
|
||||
if (ergebnis.art === "totpEinrichten") await totpEinrichtungStarten(ergebnis.benutzer, ziel);
|
||||
|
||||
await sitzungSetzen(ergebnis.benutzer, herkunft(ergebnis.benutzer));
|
||||
redirect(ziel);
|
||||
}
|
||||
|
||||
const passwortSchema = z.object({
|
||||
passwort: z.string().max(256),
|
||||
wiederholung: z.string().max(256),
|
||||
});
|
||||
|
||||
/** Einmal-Passwort ersetzen (#035); danach geht es zur TOTP-Einrichtung. */
|
||||
export async function passwortSetzenAktion(
|
||||
_vorher: FormularZustand,
|
||||
formular: FormData,
|
||||
): Promise<FormularZustand> {
|
||||
const ausstehend = await passwortSetzenLesen();
|
||||
if (!ausstehend)
|
||||
return { fehler: "Die Sitzung zum Passwortwechsel ist abgelaufen. Bitte erneut anmelden." };
|
||||
const eingabe = passwortSchema.safeParse(Object.fromEntries(formular));
|
||||
if (!eingabe.success) return { fehler: "Bitte das neue Passwort zweimal eingeben." };
|
||||
if (eingabe.data.passwort !== eingabe.data.wiederholung)
|
||||
return { fehler: "Die beiden Eingaben stimmen nicht überein." };
|
||||
let benutzer;
|
||||
try {
|
||||
benutzer = await eigenesPasswortSetzen(ausstehend.benutzerId, eingabe.data.passwort);
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof AnmeldeFehler) return { fehler: fehler.message };
|
||||
throw fehler;
|
||||
}
|
||||
(await cookies()).delete({ name: PASSWORT_SETZEN_COOKIE, path: "/anmelden" });
|
||||
const ziel = sicheresZiel(ausstehend.ziel);
|
||||
if (!benutzer.totpEingerichtetAm) await totpEinrichtungStarten(benutzer, ziel);
|
||||
await sitzungSetzen(benutzer, herkunft(benutzer));
|
||||
redirect(ziel);
|
||||
}
|
||||
|
||||
@@ -88,7 +142,7 @@ export async function totpEinrichtenAktion(
|
||||
}
|
||||
const laden = await cookies();
|
||||
laden.delete({ name: TOTP_EINRICHTUNG_COOKIE, path: "/anmelden" });
|
||||
await sitzungSetzen(benutzer, "notfall");
|
||||
await sitzungSetzen(benutzer, herkunft(benutzer));
|
||||
redirect(sicheresZiel(einrichtung.ziel));
|
||||
}
|
||||
|
||||
@@ -98,7 +152,7 @@ const bestaetigenSchema = z.object({
|
||||
ziel: z.string().max(512).optional(),
|
||||
});
|
||||
|
||||
export async function notfallBestaetigenAktion(
|
||||
export async function kontoBestaetigenAktion(
|
||||
_vorher: FormularZustand,
|
||||
formular: FormData,
|
||||
): Promise<FormularZustand> {
|
||||
@@ -107,7 +161,7 @@ export async function notfallBestaetigenAktion(
|
||||
const eingabe = bestaetigenSchema.safeParse(Object.fromEntries(formular));
|
||||
if (!eingabe.success) return { fehler: "Bitte Passwort und Code angeben." };
|
||||
try {
|
||||
await notfallBestaetigen(vorhanden.benutzer, eingabe.data.passwort, eingabe.data.code);
|
||||
await kontoBestaetigen(vorhanden.benutzer, eingabe.data.passwort, eingabe.data.code);
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof AnmeldeFehler) return { fehler: fehler.message };
|
||||
throw fehler;
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { anmeldenameSchema, kontoNameSchema, kontoRolleSchema } from "@/lib/lokale-konten";
|
||||
import { z } from "@/lib/validierung";
|
||||
import { akteurVon, fehlerZustand, textfeld, type FormularZustand } from "@/server/actions/formular";
|
||||
import { rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import {
|
||||
kontoAktivSetzen,
|
||||
kontoZuruecksetzen,
|
||||
lokalesKontoAnlegen,
|
||||
rolleAendern,
|
||||
} from "@/server/services/benutzer";
|
||||
|
||||
/** Benutzerverwaltung (#035). Duenn: Recht mit Re-Auth, Eingabe, Service. */
|
||||
|
||||
const PFAD = "/einstellungen/benutzer";
|
||||
const id = z.string().min(1).max(64);
|
||||
|
||||
/** Das Einmal-Passwort geht genau einmal an den Browser des Admins – nie ins Protokoll. */
|
||||
export type KontoZustand = FormularZustand & {
|
||||
readonly einmalPasswort?: string;
|
||||
readonly anmeldename?: string;
|
||||
};
|
||||
|
||||
export async function kontoAnlegenAktion(_vorher: KontoZustand, formular: FormData): Promise<KontoZustand> {
|
||||
try {
|
||||
const { benutzer } = await rechtErforderlich("benutzerVerwalten");
|
||||
const eingabe = z
|
||||
.object({ anmeldename: anmeldenameSchema, name: kontoNameSchema, rolle: kontoRolleSchema })
|
||||
.safeParse({
|
||||
anmeldename: textfeld(formular, "anmeldename") ?? "",
|
||||
name: textfeld(formular, "name") ?? "",
|
||||
rolle: textfeld(formular, "rolle") ?? "LESER",
|
||||
});
|
||||
if (!eingabe.success) {
|
||||
const feldfehler: Record<string, string> = {};
|
||||
for (const fehler of eingabe.error.issues) feldfehler[String(fehler.path[0])] ??= fehler.message;
|
||||
return { feldfehler };
|
||||
}
|
||||
const { einmalPasswort } = await lokalesKontoAnlegen(eingabe.data, akteurVon(benutzer));
|
||||
revalidatePath(PFAD);
|
||||
return { einmalPasswort, anmeldename: eingabe.data.anmeldename };
|
||||
} catch (fehler) {
|
||||
return fehlerZustand(fehler);
|
||||
}
|
||||
}
|
||||
|
||||
export async function rolleAendernAktion(kontoId: string, rolle: string): Promise<{ fehler?: string }> {
|
||||
try {
|
||||
const { benutzer } = await rechtErforderlich("benutzerVerwalten");
|
||||
await rolleAendern(id.parse(kontoId), kontoRolleSchema.parse(rolle), akteurVon(benutzer));
|
||||
revalidatePath(PFAD);
|
||||
return {};
|
||||
} catch (fehler) {
|
||||
return fehlerZustand(fehler);
|
||||
}
|
||||
}
|
||||
|
||||
export async function aktivSetzenAktion(kontoId: string, aktiv: boolean): Promise<{ fehler?: string }> {
|
||||
try {
|
||||
const { benutzer } = await rechtErforderlich("benutzerVerwalten");
|
||||
await kontoAktivSetzen(id.parse(kontoId), z.boolean().parse(aktiv), akteurVon(benutzer));
|
||||
revalidatePath(PFAD);
|
||||
return {};
|
||||
} catch (fehler) {
|
||||
return fehlerZustand(fehler);
|
||||
}
|
||||
}
|
||||
|
||||
export async function zuruecksetzenAktion(
|
||||
kontoId: string,
|
||||
): Promise<{ fehler?: string; einmalPasswort?: string }> {
|
||||
try {
|
||||
const { benutzer } = await rechtErforderlich("benutzerVerwalten");
|
||||
const { einmalPasswort } = await kontoZuruecksetzen(id.parse(kontoId), akteurVon(benutzer));
|
||||
revalidatePath(PFAD);
|
||||
return { einmalPasswort };
|
||||
} catch (fehler) {
|
||||
return fehlerZustand(fehler);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "@/lib/validierung";
|
||||
import { akteurVon } from "@/server/actions/formular";
|
||||
import type { VorgangsAntwort } from "@/server/actions/verwendungen";
|
||||
import { BestaetigungFehler, RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { DeploymentKeyFehler, rotationsSchrittAnfordern, umstellen } from "@/server/services/deploymentkey";
|
||||
|
||||
/** Rotation des Deployment-Keys (#031). Nur Admins mit frischer Bestaetigung. */
|
||||
|
||||
function alsAntwort(fehler: unknown): { fehler: string } {
|
||||
if (
|
||||
fehler instanceof DeploymentKeyFehler ||
|
||||
fehler instanceof RechteFehler ||
|
||||
fehler instanceof BestaetigungFehler
|
||||
) {
|
||||
return { fehler: fehler.message };
|
||||
}
|
||||
throw fehler;
|
||||
}
|
||||
|
||||
export async function rotationsSchrittAktion(
|
||||
schritt: "erzeugen" | "pruefen" | "abbrechen" | "abschliessen",
|
||||
): Promise<VorgangsAntwort> {
|
||||
try {
|
||||
const { benutzer } = await rechtErforderlich("deploymentKeyVerwalten");
|
||||
const geprueft = z.enum(["erzeugen", "pruefen", "abbrechen", "abschliessen"]).parse(schritt);
|
||||
return { vorgangId: await rotationsSchrittAnfordern(geprueft, akteurVon(benutzer)) };
|
||||
} catch (fehler) {
|
||||
return alsAntwort(fehler);
|
||||
}
|
||||
}
|
||||
|
||||
export async function umstellenAktion(uebergehen: readonly string[]): Promise<{ fehler?: string }> {
|
||||
try {
|
||||
const { benutzer } = await rechtErforderlich("deploymentKeyVerwalten");
|
||||
await umstellen(akteurVon(benutzer), z.array(z.string().min(1).max(64)).max(500).parse(uebergehen));
|
||||
revalidatePath("/deployment-key");
|
||||
return {};
|
||||
} catch (fehler) {
|
||||
return alsAntwort(fehler);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,12 @@ import { akteurVon } from "@/server/actions/formular";
|
||||
import { darf } from "@/server/auth/rechte";
|
||||
import { BestaetigungFehler, RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { FachFehler } from "@/server/services/fachfehler";
|
||||
import { rotationAltWiderrufen, rotationStarten, rotationUebernehmen } from "@/server/services/rotation";
|
||||
import {
|
||||
rotationAbbrechen,
|
||||
rotationAltWiderrufen,
|
||||
rotationStarten,
|
||||
rotationUebernehmen,
|
||||
} from "@/server/services/rotation";
|
||||
import { VerwendungsFehler } from "@/server/services/verwendungen";
|
||||
|
||||
/**
|
||||
@@ -69,3 +74,22 @@ export async function rotationAltWiderrufenAktion(schluesselId: string): Promise
|
||||
return alsAntwort(fehler);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Laufende Rotation abbrechen (#032): merkt den neuen Schluessel zum Widerruf vor. Gleiche Rechte
|
||||
* wie die Rotation selbst; Private Keys entfernt nur, wer sie ausrollen darf (Service).
|
||||
*/
|
||||
export async function rotationAbbrechenAktion(schluesselId: string): Promise<RotationsAntwort> {
|
||||
try {
|
||||
const { benutzer } = await berechtigt();
|
||||
const altId = id.parse(schluesselId);
|
||||
await rotationAbbrechen(altId, akteurVon(benutzer), {
|
||||
privatErlaubt: darf(benutzer.rolle, "privateKeyAusrollen"),
|
||||
});
|
||||
revalidatePath(`/schluessel/${altId}`);
|
||||
revalidatePath(`/schluessel/${altId}/rotieren`);
|
||||
return {};
|
||||
} catch (fehler) {
|
||||
return alsAntwort(fehler);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
publicVerwendungEntfernen,
|
||||
trockenlaufStarten,
|
||||
} from "@/server/services/verwendungen";
|
||||
import { instanzZiele } from "@/server/services/deploymentkey";
|
||||
import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge";
|
||||
|
||||
/** Public-Key-Zuweisungen und Ausrollen (#016). Duenn: Recht, Eingabe, Service. */
|
||||
@@ -78,10 +79,16 @@ export type AusrollAuswahl =
|
||||
| { readonly schluesselId: string }
|
||||
| { readonly hostId: string }
|
||||
/** Ein gemeinsamer Dry-Run fuer mehrere Schluessel (Massenwiderruf, #024). */
|
||||
| { readonly schluesselIds: readonly string[] };
|
||||
| { readonly schluesselIds: readonly string[] }
|
||||
/** Alle Hosts mit dem Instanz-Deployment-Key – fuer dessen Rotation (#031). */
|
||||
| { readonly deploymentKey: true };
|
||||
|
||||
export async function trockenlaufAktion(auswahl: AusrollAuswahl): Promise<VorgangsAntwort> {
|
||||
try {
|
||||
if ("deploymentKey" in auswahl) {
|
||||
const { benutzer } = await rechtErforderlich("deploymentKeyVerwalten");
|
||||
return { vorgangId: await trockenlaufStarten({ ziele: await instanzZiele() }, akteurVon(benutzer)) };
|
||||
}
|
||||
const { benutzer } = await rechtErforderlich("publicKeyAusrollen");
|
||||
const id = z.string().min(1).max(64);
|
||||
return {
|
||||
|
||||
@@ -20,7 +20,8 @@ export type Recht =
|
||||
| "deploymentKeyVerwalten"
|
||||
| "massenwiderruf"
|
||||
| "zielkontenVerwalten"
|
||||
| "einstellungenVerwalten";
|
||||
| "einstellungenVerwalten"
|
||||
| "benutzerVerwalten";
|
||||
|
||||
export const RECHTE: Readonly<Record<Recht, readonly Rolle[]>> = {
|
||||
lesen: ["ADMIN", "OPERATOR", "LESER"],
|
||||
@@ -40,6 +41,9 @@ export const RECHTE: Readonly<Record<Recht, readonly Rolle[]>> = {
|
||||
massenwiderruf: ["ADMIN"],
|
||||
zielkontenVerwalten: ["ADMIN"],
|
||||
einstellungenVerwalten: ["ADMIN"],
|
||||
// Lokale Konten anlegen, Rollen vergeben, zuruecksetzen (#035) – wer das darf, kann sich jede
|
||||
// Rolle verschaffen; deshalb nur Admins und nur mit Re-Auth.
|
||||
benutzerVerwalten: ["ADMIN"],
|
||||
};
|
||||
|
||||
/** Rechte, die zusaetzlich eine frische Bestaetigung (Re-Auth) verlangen. */
|
||||
@@ -49,6 +53,7 @@ export const BESTAETIGUNG_NOETIG: ReadonlySet<Recht> = new Set<Recht>([
|
||||
"deploymentKeyVerwalten",
|
||||
"massenwiderruf",
|
||||
"zielkontenVerwalten",
|
||||
"benutzerVerwalten",
|
||||
]);
|
||||
|
||||
export function darf(rolle: Rolle, recht: Recht): boolean {
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { Benutzer } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { SITZUNG_COOKIE, sitzungLesen, type Sitzung } from "@/lib/sitzung";
|
||||
import { BESTAETIGUNG_NOETIG, darf, type Recht } from "@/server/auth/rechte";
|
||||
import { passwortKonto } from "@/server/services/anmeldung";
|
||||
|
||||
/**
|
||||
* Wer ist angemeldet und was darf er (#004, Muster aus Laufzettel).
|
||||
@@ -74,9 +75,13 @@ export const angemeldet = cache(async (): Promise<Angemeldet | undefined> => {
|
||||
const benutzer = await prisma.benutzer.findUnique({ where: { id: sitzung.benutzerId } });
|
||||
const urteil = sitzungBewerten(sitzung, benutzer);
|
||||
if (!urteil.gueltig || !benutzer) return undefined;
|
||||
// Ein Notfall-Admin ohne eingerichtetes TOTP ist nur halb angemeldet und kommt nicht
|
||||
// ueber die Einrichtung hinaus – siehe /anmelden/totp.
|
||||
if (benutzer.istNotfall && !benutzer.totpEingerichtetAm) return undefined;
|
||||
// Ein Passwort-Konto ohne eigenes Passwort oder ohne TOTP ist nur halb angemeldet und kommt
|
||||
// nicht ueber die Einrichtung hinaus (/anmelden/passwort, /anmelden/totp). Abgeschaltete lokale
|
||||
// Konten verlieren ihre Sitzung sofort (#035).
|
||||
if (benutzer.passwortHash && !benutzer.oidcSub) {
|
||||
if (!passwortKonto(benutzer) || benutzer.passwortWechselNoetig || !benutzer.totpEingerichtetAm)
|
||||
return undefined;
|
||||
}
|
||||
return { benutzer, sitzung };
|
||||
});
|
||||
|
||||
|
||||
@@ -18,3 +18,17 @@ export async function totpEinrichtungLesen(): Promise<
|
||||
if (!werte?.["benutzerId"] || !werte["geheimnis"]) return undefined;
|
||||
return { benutzerId: werte["benutzerId"], geheimnis: werte["geheimnis"], ziel: werte["ziel"] ?? "/" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Ausstehender Passwortwechsel eines lokalen Kontos (#035): ausgestellt nur nach einem
|
||||
* richtigen Einmal-Passwort, gilt so lange wie die TOTP-Einrichtung.
|
||||
*/
|
||||
export const PASSWORT_SETZEN_COOKIE = "sshkv_passwort_setzen";
|
||||
export const PASSWORT_SETZEN_ZWECK = "passwort-setzen";
|
||||
|
||||
export async function passwortSetzenLesen(): Promise<{ benutzerId: string; ziel: string } | undefined> {
|
||||
const token = (await cookies()).get(PASSWORT_SETZEN_COOKIE)?.value;
|
||||
const werte = await kurzzeitTokenLesen(PASSWORT_SETZEN_ZWECK, token);
|
||||
if (!werte?.["benutzerId"]) return undefined;
|
||||
return { benutzerId: werte["benutzerId"], ziel: werte["ziel"] ?? "/" };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
/** Lesemodell der Benutzerverwaltung (#035). Nie Hashes oder TOTP-Geheimnisse – nur Zustaende. */
|
||||
export type KontoHerkunft = "notfall" | "lokal" | "oidc";
|
||||
|
||||
export async function benutzerListe() {
|
||||
const konten = await prisma.benutzer.findMany({
|
||||
orderBy: [{ aktiv: "desc" }, { name: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
anmeldename: true,
|
||||
email: true,
|
||||
rolle: true,
|
||||
aktiv: true,
|
||||
istNotfall: true,
|
||||
oidcSub: true,
|
||||
passwortWechselNoetig: true,
|
||||
totpEingerichtetAm: true,
|
||||
letzteAnmeldungAm: true,
|
||||
angelegtAm: true,
|
||||
},
|
||||
});
|
||||
return konten.map(({ oidcSub, istNotfall, totpEingerichtetAm, passwortWechselNoetig, ...k }) => {
|
||||
const herkunft: KontoHerkunft = istNotfall ? "notfall" : oidcSub ? "oidc" : "lokal";
|
||||
return {
|
||||
...k,
|
||||
herkunft,
|
||||
// Nur fuer Passwort-Konten von Bedeutung: noch nicht fertig eingerichtet.
|
||||
einrichtungOffen: herkunft !== "oidc" && (passwortWechselNoetig || !totpEingerichtetAm),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export type BenutzerZeile = Awaited<ReturnType<typeof benutzerListe>>[number];
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import type { SchluesselKategorie, SchluesselStatus } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { JUENGSTER, mitNachfolger } from "@/lib/rotation";
|
||||
|
||||
/**
|
||||
* Lesemodelle fuer Schluessel (#012). Alles hier darf jede angemeldete Rolle sehen.
|
||||
@@ -58,8 +59,12 @@ export const SCHLUESSEL_DETAIL_SELECT = {
|
||||
person: { select: { id: true, name: true } },
|
||||
vorlage: { select: { id: true, name: true } },
|
||||
erstelltVon: { select: { name: true } },
|
||||
vorgaenger: { select: { id: true, name: true, status: true } },
|
||||
nachfolger: { select: { id: true, name: true, status: true } },
|
||||
vorgaenger: { select: { id: true, name: true, status: true, widerrufEingeleitetAm: true } },
|
||||
// Ein Nachfolger im Widerruf ist eine abgebrochene Rotation (#032).
|
||||
nachfolgerListe: {
|
||||
select: { id: true, name: true, status: true, widerrufEingeleitetAm: true },
|
||||
...JUENGSTER,
|
||||
},
|
||||
publicVerwendungen: {
|
||||
where: { zustand: { not: "ENTFERNT" } },
|
||||
orderBy: { angelegtAm: "asc" },
|
||||
@@ -163,7 +168,7 @@ export type SchluesselListenEintrag = Awaited<ReturnType<typeof schluesselListe>
|
||||
export async function schluesselDetail(id: string) {
|
||||
const schluessel = await prisma.schluessel.findUnique({ where: { id }, select: SCHLUESSEL_DETAIL_SELECT });
|
||||
if (!schluessel) return null;
|
||||
const { masterKeyVersion, ...rest } = schluessel;
|
||||
const { masterKeyVersion, ...rest } = mitNachfolger(schluessel);
|
||||
return { ...rest, privatGespeichert: masterKeyVersion !== null };
|
||||
}
|
||||
|
||||
@@ -209,7 +214,7 @@ export async function rotationsKette(id: string, maximal = 25): Promise<KettenGl
|
||||
name: true,
|
||||
status: true,
|
||||
vorgaengerId: true,
|
||||
nachfolger: { select: { id: true } },
|
||||
nachfolgerListe: { select: { id: true }, ...JUENGSTER },
|
||||
},
|
||||
});
|
||||
const start = await glied(id);
|
||||
@@ -223,12 +228,12 @@ export async function rotationsKette(id: string, maximal = 25): Promise<KettenGl
|
||||
kette.unshift({ id: g.id, name: g.name, status: g.status });
|
||||
davor = g.vorgaengerId;
|
||||
}
|
||||
for (let danach = start.nachfolger?.id; danach && kette.length < maximal && !gesehen.has(danach);) {
|
||||
for (let danach = start.nachfolgerListe[0]?.id; danach && kette.length < maximal && !gesehen.has(danach);) {
|
||||
const g = await glied(danach);
|
||||
if (!g) break;
|
||||
gesehen.add(g.id);
|
||||
kette.push({ id: g.id, name: g.name, status: g.status });
|
||||
danach = g.nachfolger?.id;
|
||||
danach = g.nachfolgerListe[0]?.id;
|
||||
}
|
||||
return kette;
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ export function aktionEinordnen(aktion: string, ergebnis: string): AktionsArt {
|
||||
if (aktion.startsWith("authorized_keys.") || aktion.startsWith("privatekey.ausgerollt"))
|
||||
return "ausgerollt";
|
||||
if (aktion.includes("widerruf")) return "widerrufen";
|
||||
if (aktion.includes("rotation")) return "rotiert";
|
||||
if (aktion.includes("rotation") || aktion === "deploymentkey.umgestellt") return "rotiert";
|
||||
if (aktion === "abgleich") return "abgleich";
|
||||
if (aktion.startsWith("anmeldung") || aktion.startsWith("bestaetigung")) return "anmeldung";
|
||||
if (
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { Benutzer } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { entschluesseln, verschluesseln } from "@/lib/geheimnisse";
|
||||
import { passwortPruefen } from "@/lib/passwort-hash";
|
||||
import { lokaleKontenAktiv, passwortFehler } from "@/lib/lokale-konten";
|
||||
import { passwortHashen, passwortPruefen } from "@/lib/passwort-hash";
|
||||
import { totpPruefen } from "@/lib/totp";
|
||||
import type { OidcAnspruecke } from "@/server/auth/oidc";
|
||||
import { rolleAusGruppen } from "@/server/auth/oidc";
|
||||
@@ -9,7 +10,7 @@ import { protokollieren } from "@/server/services/protokoll";
|
||||
import { versuchZaehlen, zaehlerLoeschen } from "@/server/services/ratenbegrenzung";
|
||||
|
||||
/**
|
||||
* Anmeldelogik ohne Next (#004): OIDC-Konto uebernehmen, Notfall-Admin pruefen, TOTP
|
||||
* Anmeldelogik ohne Next (#004, #035): OIDC-Konto uebernehmen, Passwort-Konten pruefen, TOTP
|
||||
* einrichten, erneute Bestaetigung. Die Next-Schicht setzt danach nur noch das Cookie.
|
||||
*/
|
||||
|
||||
@@ -27,8 +28,8 @@ export class AnmeldeFehler extends Error {
|
||||
}
|
||||
|
||||
/** Fuenf Fehlversuche je Anmeldename in 15 Minuten. */
|
||||
const NOTFALL_VERSUCHE = 5;
|
||||
const NOTFALL_FENSTER_SEKUNDEN = 15 * 60;
|
||||
const KONTO_VERSUCHE = 5;
|
||||
const KONTO_FENSTER_SEKUNDEN = 15 * 60;
|
||||
|
||||
/** Allgemeine Meldung – verraet nicht, ob der Name, das Passwort oder der Code falsch war. */
|
||||
const FALSCHE_ANGABEN = "Anmeldung fehlgeschlagen. Bitte Angaben prüfen.";
|
||||
@@ -115,53 +116,67 @@ export async function oidcBestaetigungVermerken(
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Notfall-Admin
|
||||
// ---------------------------------------------------------------- Lokale Konten
|
||||
|
||||
export type NotfallErgebnis =
|
||||
/**
|
||||
* Passwort-Konten: der Notfall-Admin (#004) und von Admins angelegte lokale Konten (#035).
|
||||
* Beide melden sich mit Anmeldename, Passwort und Pflicht-TOTP an.
|
||||
*/
|
||||
export type KontoErgebnis =
|
||||
| { readonly art: "angemeldet"; readonly benutzer: Benutzer }
|
||||
| { readonly art: "passwortSetzen"; readonly benutzer: Benutzer }
|
||||
| { readonly art: "totpEinrichten"; readonly benutzer: Benutzer };
|
||||
|
||||
/** Darf sich dieses Konto mit Passwort anmelden? OIDC-Konten nie, lokale nur wenn eingeschaltet. */
|
||||
export function passwortKonto(benutzer: Pick<Benutzer, "passwortHash" | "oidcSub" | "istNotfall">): boolean {
|
||||
return Boolean(benutzer.passwortHash) && !benutzer.oidcSub && (benutzer.istNotfall || lokaleKontenAktiv());
|
||||
}
|
||||
|
||||
/** Protokoll-Praefix: der Notfall-Admin bleibt im Audit-Log als solcher erkennbar. */
|
||||
const praefix = (benutzer: Pick<Benutzer, "istNotfall"> | null) =>
|
||||
benutzer?.istNotfall ? "anmeldung.notfall" : "anmeldung.lokal";
|
||||
|
||||
/**
|
||||
* Prueft Anmeldename, Passwort und – sobald eingerichtet – den TOTP-Code.
|
||||
*
|
||||
* Ohne eingerichtetes TOTP fuehrt ein richtiges Passwort nur zur Einrichtung, nie direkt
|
||||
* in die Anwendung (Freigabe Frage 5: TOTP ist Pflicht).
|
||||
* Ein Einmal-Passwort fuehrt nur zum Setzen eines eigenen, ein Konto ohne TOTP nur zur
|
||||
* Einrichtung – nie direkt in die Anwendung (Freigabe Frage 5: TOTP ist Pflicht).
|
||||
*/
|
||||
export async function notfallAnmelden(
|
||||
export async function kontoAnmelden(
|
||||
anmeldename: string,
|
||||
passwort: string,
|
||||
code: string,
|
||||
jetzt = new Date(),
|
||||
): Promise<NotfallErgebnis> {
|
||||
const zaehler = `notfall:${anmeldename.toLowerCase()}`;
|
||||
const versuch = await versuchZaehlen(zaehler, NOTFALL_VERSUCHE, NOTFALL_FENSTER_SEKUNDEN);
|
||||
): Promise<KontoErgebnis> {
|
||||
const name = anmeldename.trim().toLowerCase();
|
||||
const zaehler = `konto:${name}`;
|
||||
const versuch = await versuchZaehlen(zaehler, KONTO_VERSUCHE, KONTO_FENSTER_SEKUNDEN);
|
||||
const benutzer = await prisma.benutzer.findUnique({ where: { anmeldename: name } });
|
||||
if (!versuch.erlaubt) {
|
||||
await protokollieren({
|
||||
akteur: "System",
|
||||
aktion: "anmeldung.notfall.gesperrt",
|
||||
aktion: `${praefix(benutzer)}.gesperrt`,
|
||||
ergebnis: "VERWEIGERT",
|
||||
details: { anmeldename },
|
||||
details: { anmeldename: name },
|
||||
});
|
||||
throw new AnmeldeFehler(
|
||||
`Zu viele Fehlversuche. Bitte in ${Math.ceil(versuch.wiederInSekunden / 60)} Minuten erneut versuchen.`,
|
||||
);
|
||||
}
|
||||
|
||||
const benutzer = await prisma.benutzer.findUnique({ where: { anmeldename } });
|
||||
const passwortStimmt =
|
||||
benutzer?.istNotfall && benutzer.passwortHash
|
||||
? await passwortPruefen(benutzer.passwortHash, passwort)
|
||||
: false;
|
||||
benutzer && passwortKonto(benutzer) ? await passwortPruefen(benutzer.passwortHash!, passwort) : false;
|
||||
if (!benutzer || !passwortStimmt || !benutzer.aktiv) {
|
||||
await protokollieren({
|
||||
akteur: "System",
|
||||
aktion: "anmeldung.notfall.fehlgeschlagen",
|
||||
aktion: `${praefix(benutzer)}.fehlgeschlagen`,
|
||||
ergebnis: "VERWEIGERT",
|
||||
details: { anmeldename },
|
||||
details: { anmeldename: name },
|
||||
});
|
||||
throw new AnmeldeFehler(FALSCHE_ANGABEN);
|
||||
}
|
||||
|
||||
if (benutzer.passwortWechselNoetig) return { art: "passwortSetzen", benutzer };
|
||||
if (!benutzer.totpGeheimnis || !benutzer.totpEingerichtetAm) {
|
||||
return { art: "totpEinrichten", benutzer };
|
||||
}
|
||||
@@ -175,7 +190,7 @@ export async function notfallAnmelden(
|
||||
if (schritt === undefined) {
|
||||
await protokollieren({
|
||||
akteur: { id: benutzer.id, name: benutzer.name },
|
||||
aktion: "anmeldung.notfall.totp_falsch",
|
||||
aktion: `${praefix(benutzer)}.totp_falsch`,
|
||||
ergebnis: "VERWEIGERT",
|
||||
});
|
||||
throw new AnmeldeFehler(FALSCHE_ANGABEN);
|
||||
@@ -188,16 +203,42 @@ export async function notfallAnmelden(
|
||||
});
|
||||
await protokollieren({
|
||||
akteur: { id: benutzer.id, name: benutzer.name },
|
||||
aktion: "anmeldung.notfall",
|
||||
aktion: praefix(benutzer),
|
||||
ergebnis: "ERFOLG",
|
||||
});
|
||||
return { art: "angemeldet", benutzer: aktualisiert };
|
||||
}
|
||||
|
||||
/**
|
||||
* Ersetzt das Einmal-Passwort durch ein eigenes (#035). Nur moeglich, solange der Wechsel
|
||||
* aussteht – das Kurzzeit-Token dafuer stellt allein ein richtiges Einmal-Passwort aus.
|
||||
*/
|
||||
export async function eigenesPasswortSetzen(benutzerId: string, neu: string): Promise<Benutzer> {
|
||||
const benutzer = await prisma.benutzer.findUnique({ where: { id: benutzerId } });
|
||||
if (!benutzer || !passwortKonto(benutzer) || !benutzer.passwortWechselNoetig || !benutzer.aktiv) {
|
||||
throw new AnmeldeFehler("Für dieses Konto steht kein Passwortwechsel aus.");
|
||||
}
|
||||
const fehler = passwortFehler(neu, benutzer.anmeldename);
|
||||
if (fehler) throw new AnmeldeFehler(fehler);
|
||||
if (await passwortPruefen(benutzer.passwortHash!, neu)) {
|
||||
throw new AnmeldeFehler("Bitte ein anderes Passwort als das Einmal-Passwort wählen.");
|
||||
}
|
||||
const aktualisiert = await prisma.benutzer.update({
|
||||
where: { id: benutzerId },
|
||||
data: { passwortHash: await passwortHashen(neu), passwortWechselNoetig: false },
|
||||
});
|
||||
await protokollieren({
|
||||
akteur: { id: benutzer.id, name: benutzer.name },
|
||||
aktion: "konto.passwort_gesetzt",
|
||||
ergebnis: "ERFOLG",
|
||||
});
|
||||
return aktualisiert;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schliesst die TOTP-Einrichtung ab. Das Geheimnis kommt aus dem kurzlebigen
|
||||
* Einrichtungs-Token und wird erst gespeichert, wenn ein Code dazu passt – sonst saesse
|
||||
* der Notfall-Admin mit einem Geheimnis fest, das in keiner App steht.
|
||||
* das Konto mit einem Geheimnis fest, das in keiner App steht.
|
||||
*/
|
||||
export async function totpEinrichtungAbschliessen(
|
||||
benutzerId: string,
|
||||
@@ -206,7 +247,12 @@ export async function totpEinrichtungAbschliessen(
|
||||
jetzt = new Date(),
|
||||
): Promise<Benutzer> {
|
||||
const benutzer = await prisma.benutzer.findUnique({ where: { id: benutzerId } });
|
||||
if (!benutzer?.istNotfall || benutzer.totpEingerichtetAm) {
|
||||
if (
|
||||
!benutzer ||
|
||||
!passwortKonto(benutzer) ||
|
||||
benutzer.totpEingerichtetAm ||
|
||||
benutzer.passwortWechselNoetig
|
||||
) {
|
||||
throw new AnmeldeFehler("TOTP ist für dieses Konto bereits eingerichtet.");
|
||||
}
|
||||
const schritt = totpPruefen(geheimnisBase32, code, jetzt.getTime());
|
||||
@@ -227,18 +273,18 @@ export async function totpEinrichtungAbschliessen(
|
||||
});
|
||||
await protokollieren({
|
||||
akteur: { id: benutzer.id, name: benutzer.name },
|
||||
aktion: "notfall.totp_eingerichtet",
|
||||
aktion: benutzer.istNotfall ? "notfall.totp_eingerichtet" : "konto.totp_eingerichtet",
|
||||
ergebnis: "ERFOLG",
|
||||
});
|
||||
return aktualisiert;
|
||||
}
|
||||
|
||||
/** Erneute Bestaetigung des Notfall-Admins: Passwort und TOTP noch einmal. */
|
||||
export async function notfallBestaetigen(benutzer: Benutzer, passwort: string, code: string): Promise<void> {
|
||||
if (!benutzer.istNotfall || !benutzer.anmeldename) {
|
||||
/** Erneute Bestaetigung eines Passwort-Kontos: Passwort und TOTP noch einmal. */
|
||||
export async function kontoBestaetigen(benutzer: Benutzer, passwort: string, code: string): Promise<void> {
|
||||
if (!passwortKonto(benutzer) || !benutzer.anmeldename) {
|
||||
throw new AnmeldeFehler("Dieses Konto bestätigt sich über den Identitätsanbieter.");
|
||||
}
|
||||
const ergebnis = await notfallAnmelden(benutzer.anmeldename, passwort, code);
|
||||
const ergebnis = await kontoAnmelden(benutzer.anmeldename, passwort, code);
|
||||
if (ergebnis.art !== "angemeldet" || ergebnis.benutzer.id !== benutzer.id) {
|
||||
throw new AnmeldeFehler(FALSCHE_ANGABEN);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import type { Rolle } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { einmalPasswortErzeugen, lokaleKontenAktiv } from "@/lib/lokale-konten";
|
||||
import { passwortHashen } from "@/lib/passwort-hash";
|
||||
import { FachFehler } from "@/server/services/fachfehler";
|
||||
import { protokollieren } from "@/server/services/protokoll";
|
||||
|
||||
/**
|
||||
* Benutzerverwaltung (#035). Lokale Konten legt ein Admin an; sshkv erzeugt ein Einmal-Passwort,
|
||||
* das genau einmal angezeigt wird – Admins waehlen also nie Passwoerter fuer andere. Bei der
|
||||
* ersten Anmeldung setzt der Benutzer ein eigenes Passwort und richtet TOTP ein.
|
||||
*
|
||||
* OIDC-Konten bekommen ihre Rolle aus den Gruppen des Anbieters; hier lassen sie sich nur
|
||||
* sperren und entsperren. Der Notfall-Admin ist unantastbar – er ist der Weg zurueck, wenn
|
||||
* alles andere versagt.
|
||||
*/
|
||||
type Akteur = { readonly id: string; readonly name: string };
|
||||
|
||||
const KONTO = {
|
||||
id: true,
|
||||
name: true,
|
||||
anmeldename: true,
|
||||
rolle: true,
|
||||
aktiv: true,
|
||||
istNotfall: true,
|
||||
oidcSub: true,
|
||||
passwortHash: true,
|
||||
} as const;
|
||||
|
||||
async function kontoLaden(id: string) {
|
||||
const konto = await prisma.benutzer.findUnique({ where: { id }, select: KONTO });
|
||||
if (!konto) throw new FachFehler("Dieses Konto gibt es nicht.");
|
||||
return konto;
|
||||
}
|
||||
|
||||
function lokal(konto: { istNotfall: boolean; oidcSub: string | null; passwortHash: string | null }) {
|
||||
return !konto.istNotfall && !konto.oidcSub && Boolean(konto.passwortHash);
|
||||
}
|
||||
|
||||
function nichtSelbst(konto: { id: string }, akteur: Akteur, was: string) {
|
||||
if (konto.id === akteur.id) throw new FachFehler(`Das eigene Konto lässt sich nicht ${was}.`);
|
||||
}
|
||||
|
||||
function kontenErlaubt() {
|
||||
if (!lokaleKontenAktiv()) {
|
||||
throw new FachFehler("Lokale Konten sind abgeschaltet (SSHKV_LOKALE_KONTEN=0).");
|
||||
}
|
||||
}
|
||||
|
||||
export async function lokalesKontoAnlegen(
|
||||
eingabe: { anmeldename: string; name: string; rolle: Rolle },
|
||||
akteur: Akteur,
|
||||
): Promise<{ id: string; einmalPasswort: string }> {
|
||||
kontenErlaubt();
|
||||
if (
|
||||
await prisma.benutzer.findUnique({ where: { anmeldename: eingabe.anmeldename }, select: { id: true } })
|
||||
) {
|
||||
throw new FachFehler("Diesen Anmeldenamen gibt es schon.", { anmeldename: "Bereits vergeben." });
|
||||
}
|
||||
const einmalPasswort = einmalPasswortErzeugen();
|
||||
const konto = await prisma.benutzer.create({
|
||||
data: {
|
||||
anmeldename: eingabe.anmeldename,
|
||||
name: eingabe.name,
|
||||
rolle: eingabe.rolle,
|
||||
passwortHash: await passwortHashen(einmalPasswort),
|
||||
passwortWechselNoetig: true,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
await protokollieren({
|
||||
akteur,
|
||||
aktion: "benutzer.angelegt",
|
||||
ergebnis: "ERFOLG",
|
||||
details: { anmeldename: eingabe.anmeldename, name: eingabe.name, rolle: eingabe.rolle },
|
||||
});
|
||||
return { id: konto.id, einmalPasswort };
|
||||
}
|
||||
|
||||
/** Rolle nur fuer lokale Konten – bei OIDC entscheidet die Gruppe im Anbieter. */
|
||||
export async function rolleAendern(id: string, rolle: Rolle, akteur: Akteur): Promise<void> {
|
||||
const konto = await kontoLaden(id);
|
||||
if (!lokal(konto)) {
|
||||
throw new FachFehler(
|
||||
konto.istNotfall
|
||||
? "Der Notfall-Admin bleibt Admin."
|
||||
: "Die Rolle dieses Kontos kommt aus den Gruppen des Identitätsanbieters.",
|
||||
);
|
||||
}
|
||||
nichtSelbst(konto, akteur, "herabstufen");
|
||||
if (konto.rolle === rolle) return;
|
||||
await prisma.benutzer.update({
|
||||
where: { id },
|
||||
// Neue Rolle gilt sofort; alte Sitzungen sollen nicht mit gespeicherter Bestaetigung weiterlaufen.
|
||||
data: { rolle, bestaetigtAm: null },
|
||||
});
|
||||
await protokollieren({
|
||||
akteur,
|
||||
aktion: "benutzer.rolle_geaendert",
|
||||
ergebnis: "ERFOLG",
|
||||
details: { anmeldename: konto.anmeldename, name: konto.name, vorher: konto.rolle, nachher: rolle },
|
||||
});
|
||||
}
|
||||
|
||||
/** Sperren beendet alle Sitzungen sofort; gilt auch fuer OIDC-Konten. */
|
||||
export async function kontoAktivSetzen(id: string, aktiv: boolean, akteur: Akteur): Promise<void> {
|
||||
const konto = await kontoLaden(id);
|
||||
if (konto.istNotfall) throw new FachFehler("Der Notfall-Admin lässt sich nicht sperren.");
|
||||
nichtSelbst(konto, akteur, "sperren");
|
||||
if (konto.aktiv === aktiv) return;
|
||||
await prisma.benutzer.update({
|
||||
where: { id },
|
||||
data: aktiv ? { aktiv } : { aktiv, sitzungenGueltigAb: new Date(), bestaetigtAm: null },
|
||||
});
|
||||
await protokollieren({
|
||||
akteur,
|
||||
aktion: aktiv ? "benutzer.entsperrt" : "benutzer.gesperrt",
|
||||
ergebnis: "ERFOLG",
|
||||
details: { anmeldename: konto.anmeldename, name: konto.name },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Passwort und TOTP eines lokalen Kontos zuruecksetzen – etwa nach verlorenem Telefon. Es gibt
|
||||
* ein neues Einmal-Passwort; alle Sitzungen enden.
|
||||
*/
|
||||
export async function kontoZuruecksetzen(id: string, akteur: Akteur): Promise<{ einmalPasswort: string }> {
|
||||
kontenErlaubt();
|
||||
const konto = await kontoLaden(id);
|
||||
if (!lokal(konto)) throw new FachFehler("Nur lokale Konten lassen sich zurücksetzen.");
|
||||
nichtSelbst(konto, akteur, "zurücksetzen");
|
||||
const einmalPasswort = einmalPasswortErzeugen();
|
||||
await prisma.benutzer.update({
|
||||
where: { id },
|
||||
data: {
|
||||
passwortHash: await passwortHashen(einmalPasswort),
|
||||
passwortWechselNoetig: true,
|
||||
totpGeheimnis: null,
|
||||
totpEingerichtetAm: null,
|
||||
totpLetzterSchritt: null,
|
||||
sitzungenGueltigAb: new Date(),
|
||||
bestaetigtAm: null,
|
||||
},
|
||||
});
|
||||
await protokollieren({
|
||||
akteur,
|
||||
aktion: "benutzer.zurueckgesetzt",
|
||||
ergebnis: "ERFOLG",
|
||||
details: { anmeldename: konto.anmeldename, name: konto.name },
|
||||
});
|
||||
return { einmalPasswort };
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { protokollieren } from "@/server/services/protokoll";
|
||||
import { anmeldebenutzerVon, deploymentNachfolger, type Ziel } from "@/server/services/soll";
|
||||
import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge";
|
||||
|
||||
/**
|
||||
* Rotation des Instanz-Deployment-Keys (#031) – geführt in fünf Schritten:
|
||||
*
|
||||
* 1. erzeugen: Nachfolger anlegen (DEPLOYMENT, nicht Standard, vorgaengerId = alter).
|
||||
* 2. verteilen: Dry-Run + Ausrollen auf alle Hosts mit Instanz-Key – der Nachfolger kommt
|
||||
* **neben** den alten in den Block (soll.ts).
|
||||
* 3. pruefen: je Host eine Anmeldung mit dem neuen Key.
|
||||
* 4. umstellen: Nachfolger wird Standard, alter ROTIERT; danach Dry-Run + Ausrollen entfernt den
|
||||
* alten aus allen Blöcken.
|
||||
* 5. abschliessen: auf jedem Host nachsehen, dass der alte weg ist, dann seinen Private Key
|
||||
* verwerfen.
|
||||
*
|
||||
* Zu keinem Zeitpunkt steht ein Host ohne funktionierenden Deployment-Key da: umgestellt wird
|
||||
* erst, wenn der neue Key überall geprüft ist (oder ein Admin einen Host bewusst übergeht –
|
||||
* der braucht danach einen neuen Bootstrap).
|
||||
*/
|
||||
export class DeploymentKeyFehler extends Error {
|
||||
constructor(nachricht: string) {
|
||||
super(nachricht);
|
||||
this.name = "DeploymentKeyFehler";
|
||||
}
|
||||
}
|
||||
|
||||
type Akteur = { readonly id: string; readonly name: string };
|
||||
|
||||
export type PruefErgebnis = { hostId: string; hostName: string; ok: boolean; fehler?: string };
|
||||
|
||||
/** Hosts, die den Instanz-Key benutzen (kein eigener, keiner über eine Gruppe). */
|
||||
export async function instanzHosts() {
|
||||
return prisma.host.findMany({
|
||||
where: {
|
||||
archiviertAm: null,
|
||||
bootstrapAm: { not: null },
|
||||
deploymentSchluesselId: null,
|
||||
gruppen: { none: { hostgruppe: { deploymentSchluesselId: { not: null } } } },
|
||||
},
|
||||
select: { id: true, name: true, anmeldeart: true },
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
}
|
||||
|
||||
export async function instanzZiele(): Promise<Ziel[]> {
|
||||
return (await instanzHosts()).map((h) => ({
|
||||
hostId: h.id,
|
||||
zielbenutzer: anmeldebenutzerVon(h),
|
||||
art: "public",
|
||||
}));
|
||||
}
|
||||
|
||||
export async function rotationsStand() {
|
||||
const aktuell = await prisma.schluessel.findFirst({
|
||||
where: { kategorie: "DEPLOYMENT", instanzStandard: true, status: "AKTIV" },
|
||||
select: { id: true, fingerprint: true, angelegtAm: true, vorgaengerId: true },
|
||||
});
|
||||
const nachfolger = await deploymentNachfolger();
|
||||
// Nach dem Umstellen: der alte ist ROTIERT, sein Private Key noch nicht verworfen.
|
||||
const vorgaenger = aktuell?.vorgaengerId
|
||||
? await prisma.schluessel.findFirst({
|
||||
where: {
|
||||
id: aktuell.vorgaengerId,
|
||||
kategorie: "DEPLOYMENT",
|
||||
status: "ROTIERT",
|
||||
privatGeloeschtAm: null,
|
||||
},
|
||||
select: { id: true, fingerprint: true },
|
||||
})
|
||||
: null;
|
||||
const letztePruefung = await prisma.vorgang.findFirst({
|
||||
where: { art: "ROTATION", eingabe: { path: ["schritt"], equals: "pruefen" } },
|
||||
orderBy: { angelegtAm: "desc" },
|
||||
select: { id: true, status: true, ergebnis: true, beendetAm: true, angelegtAm: true },
|
||||
});
|
||||
const hosts = await instanzHosts();
|
||||
const pruefung =
|
||||
letztePruefung && nachfolger && letztePruefung.angelegtAm > nachfolger.angelegtAm
|
||||
? ((letztePruefung.ergebnis as { hosts?: PruefErgebnis[] } | null)?.hosts ?? null)
|
||||
: null;
|
||||
const phase: "bereit" | "verteilen" | "aufraeumen" = nachfolger
|
||||
? "verteilen"
|
||||
: vorgaenger
|
||||
? "aufraeumen"
|
||||
: "bereit";
|
||||
// Zuletzt abgebrochener Nachfolger des aktuellen Keys (#036): kann noch in Bloecken stehen,
|
||||
// bis ein Dry-Run ihn entfernt.
|
||||
const abgebrochen =
|
||||
phase === "bereit" && aktuell
|
||||
? await prisma.schluessel.findFirst({
|
||||
where: { kategorie: "DEPLOYMENT", vorgaengerId: aktuell.id, status: "WIDERRUFEN" },
|
||||
orderBy: { angelegtAm: "desc" },
|
||||
select: { id: true, fingerprint: true },
|
||||
})
|
||||
: null;
|
||||
return { aktuell, nachfolger, vorgaenger, phase, hosts, pruefung, abgebrochen };
|
||||
}
|
||||
|
||||
export async function rotationsSchrittAnfordern(
|
||||
schritt: "erzeugen" | "pruefen" | "abbrechen" | "abschliessen",
|
||||
akteur: Akteur,
|
||||
): Promise<string> {
|
||||
const stand = await rotationsStand();
|
||||
if (schritt === "erzeugen" && stand.phase !== "bereit") {
|
||||
throw new DeploymentKeyFehler("Es läuft bereits eine Rotation des Deployment-Keys.");
|
||||
}
|
||||
if (schritt === "pruefen" && !stand.nachfolger)
|
||||
throw new DeploymentKeyFehler("Es gibt keinen neuen Deployment-Key zu prüfen.");
|
||||
// Abbrechen nur vor dem Umstellen – danach meldet sich sshkv schon mit dem neuen Key an.
|
||||
if (schritt === "abbrechen" && !stand.nachfolger)
|
||||
throw new DeploymentKeyFehler("Es läuft keine Rotation, die sich abbrechen ließe.");
|
||||
if (schritt === "abschliessen" && !stand.vorgaenger)
|
||||
throw new DeploymentKeyFehler("Es gibt nichts abzuschließen.");
|
||||
return vorgangEinreihen({ art: "ROTATION", angefordertVonId: akteur.id, eingabe: { schritt } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Schritt 4: Nachfolger wird Standard. Verlangt eine Prüfung nach dem Erzeugen, in der jeder
|
||||
* Host den neuen Key angenommen hat – oder ausdrücklich übergangen wird.
|
||||
*/
|
||||
export async function umstellen(akteur: Akteur, uebergehen: readonly string[] = []): Promise<void> {
|
||||
const stand = await rotationsStand();
|
||||
if (!stand.nachfolger || !stand.aktuell)
|
||||
throw new DeploymentKeyFehler("Es gibt keinen neuen Deployment-Key.");
|
||||
if (!stand.pruefung) throw new DeploymentKeyFehler("Erst den neuen Key auf allen Hosts prüfen.");
|
||||
const geprueft = new Map(stand.pruefung.map((p) => [p.hostId, p]));
|
||||
const offen = stand.hosts.filter((h) => !geprueft.get(h.id)?.ok && !uebergehen.includes(h.id));
|
||||
if (offen.length) {
|
||||
throw new DeploymentKeyFehler(
|
||||
`Der neue Key ist noch nicht auf allen Hosts geprüft: ${offen.map((h) => h.name).join(", ")}.`,
|
||||
);
|
||||
}
|
||||
const alt = stand.aktuell;
|
||||
const neu = stand.nachfolger;
|
||||
await prisma.$transaction([
|
||||
// Erst den alten abloesen, dann den neuen setzen – der partielle Index erlaubt nur einen Standard.
|
||||
prisma.schluessel.update({
|
||||
where: { id: alt.id },
|
||||
data: { instanzStandard: false, status: "ROTIERT" },
|
||||
select: { id: true },
|
||||
}),
|
||||
prisma.schluessel.update({
|
||||
where: { id: neu.id },
|
||||
data: { instanzStandard: true },
|
||||
select: { id: true },
|
||||
}),
|
||||
]);
|
||||
await protokollieren({
|
||||
akteur,
|
||||
aktion: "deploymentkey.umgestellt",
|
||||
ergebnis: "ERFOLG",
|
||||
fingerprint: neu.fingerprint,
|
||||
details: { alt: alt.fingerprint, uebergangen: [...uebergehen] },
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,13 @@
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { rotationsDatum, rotationsName, rotationsPfad } from "@/lib/rotation";
|
||||
import {
|
||||
JUENGSTER,
|
||||
type MitNachfolger,
|
||||
mitNachfolger,
|
||||
rotationsDatum,
|
||||
rotationsName,
|
||||
rotationsPfad,
|
||||
} from "@/lib/rotation";
|
||||
import { NAME_MAX, type SchluesselVorgangEingabe } from "@/lib/schluessel";
|
||||
import { eindeutigerSlug } from "@/server/services/schluessel";
|
||||
import { protokollieren } from "@/server/services/protokoll";
|
||||
@@ -45,11 +52,15 @@ const ALT_SELECT = {
|
||||
vorlageId: true,
|
||||
masterKeyVersion: true,
|
||||
widerrufEingeleitetAm: true,
|
||||
nachfolger: { select: { id: true, name: true, status: true } },
|
||||
nachfolgerListe: {
|
||||
select: { id: true, name: true, status: true, widerrufEingeleitetAm: true },
|
||||
...JUENGSTER,
|
||||
},
|
||||
_count: { select: { privateVerwendungen: { where: { zustand: { in: [...AKTIVE] } } } } },
|
||||
} satisfies Prisma.SchluesselSelect;
|
||||
|
||||
type Alt = Prisma.SchluesselGetPayload<{ select: typeof ALT_SELECT }>;
|
||||
type AltRoh = Prisma.SchluesselGetPayload<{ select: typeof ALT_SELECT }>;
|
||||
type Alt = MitNachfolger<AltRoh>;
|
||||
|
||||
/**
|
||||
* Warum sshkv diesen Schluessel nicht selbst rotieren kann – oder null. Ein Personen-Key (oder
|
||||
@@ -77,7 +88,7 @@ export function rotationGesperrt(
|
||||
async function altLaden(id: string): Promise<Alt> {
|
||||
const alt = await prisma.schluessel.findUnique({ where: { id }, select: ALT_SELECT });
|
||||
if (!alt) throw new RotationsFehler("Diesen Schlüssel gibt es nicht.");
|
||||
return alt;
|
||||
return mitNachfolger(alt);
|
||||
}
|
||||
|
||||
function gesperrtPruefen(alt: Alt) {
|
||||
@@ -92,7 +103,15 @@ function gesperrtPruefen(alt: Alt) {
|
||||
export async function rotationStarten(altId: string, akteur: Akteur): Promise<string> {
|
||||
const alt = await altLaden(altId);
|
||||
gesperrtPruefen(alt);
|
||||
if (alt.nachfolger) throw new RotationsFehler("Für diesen Schlüssel gibt es schon einen Nachfolger.");
|
||||
// Ein vollstaendig abgebrochener (widerrufener) Nachfolger bleibt in der Historie; danach darf
|
||||
// ein neuer folgen (#033). Der partielle Index haelt die Kette trotzdem eindeutig.
|
||||
if (alt.nachfolger && alt.nachfolger.status !== "WIDERRUFEN") {
|
||||
throw new RotationsFehler(
|
||||
rotationAbgebrochen(alt.nachfolger)
|
||||
? "Der Abbruch der letzten Rotation läuft noch – bitte erst den neuen Schlüssel fertig entfernen (Dry-Run und Anwenden)."
|
||||
: "Für diesen Schlüssel gibt es schon einen Nachfolger.",
|
||||
);
|
||||
}
|
||||
const name = rotationsName(alt.name, rotationsDatum().anzeige, NAME_MAX);
|
||||
const slug = await eindeutigerSlug(null, name);
|
||||
// Ein vergangenes Ablaufdatum wuerde der Nachfolger sonst erben – er waere sofort abgelaufen.
|
||||
@@ -111,8 +130,19 @@ export async function rotationStarten(altId: string, akteur: Akteur): Promise<st
|
||||
return vorgangEinreihen({ art: "SCHLUESSEL_ERZEUGEN", angefordertVonId: akteur.id, eingabe });
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Nachfolger im Widerruf (oder widerrufen) heisst: die Rotation wurde abgebrochen (#032).
|
||||
* Ein eigenes Feld braucht es dafuer nicht – der Widerruf des Nachfolgers ist der Abbruch.
|
||||
*/
|
||||
export function rotationAbgebrochen(
|
||||
neu: { readonly status: string; readonly widerrufEingeleitetAm: Date | null } | null,
|
||||
): boolean {
|
||||
return neu !== null && (neu.status === "WIDERRUFEN" || neu.widerrufEingeleitetAm !== null);
|
||||
}
|
||||
|
||||
async function nachfolgerVon(alt: Alt) {
|
||||
if (!alt.nachfolger) throw new RotationsFehler("Erst den neuen Schlüssel erzeugen.");
|
||||
if (rotationAbgebrochen(alt.nachfolger)) throw new RotationsFehler("Die Rotation wurde abgebrochen.");
|
||||
if (alt.nachfolger.status !== "AKTIV") throw new RotationsFehler("Der neue Schlüssel ist nicht aktiv.");
|
||||
return alt.nachfolger;
|
||||
}
|
||||
@@ -254,8 +284,10 @@ export type RotationsStand = Awaited<ReturnType<typeof rotationsStand>>;
|
||||
export async function rotationsStand(altId: string) {
|
||||
const alt = await altLaden(altId);
|
||||
const gesperrt = rotationGesperrt({ ...alt, privatZuweisungen: alt._count.privateVerwendungen });
|
||||
const neu = alt.nachfolger;
|
||||
if (!neu) return { alt, gesperrt, neu: null, schritt: 0 as const };
|
||||
// Nach einem abgeschlossenen Abbruch beginnt die Rotation von vorn (#033).
|
||||
const neu = alt.nachfolger?.status === "WIDERRUFEN" && alt.status !== "ROTIERT" ? null : alt.nachfolger;
|
||||
if (!neu) return { alt, gesperrt, neu: null, schritt: 0 as const, abgebrochen: false };
|
||||
const abgebrochen = rotationAbgebrochen(neu);
|
||||
|
||||
const [altPublic, altPrivat, neuPublic, neuPrivat, tests] = await Promise.all([
|
||||
prisma.publicVerwendung.count({ where: { schluesselId: alt.id, zustand: { in: [...AKTIVE] } } }),
|
||||
@@ -287,7 +319,7 @@ export async function rotationsStand(altId: string) {
|
||||
: !testsGruen
|
||||
? (3 as const)
|
||||
: (4 as const);
|
||||
return { alt, gesperrt, neu, schritt, neuOffen, tests, testsGruen };
|
||||
return { alt, gesperrt, neu, schritt, neuOffen, tests, testsGruen, abgebrochen };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -302,6 +334,7 @@ export async function rotationAltWiderrufen(
|
||||
const stand = await rotationsStand(altId);
|
||||
if (!stand.neu) throw new RotationsFehler("Erst den neuen Schlüssel erzeugen.");
|
||||
if (stand.schritt === 5) return;
|
||||
if (stand.abgebrochen) throw new RotationsFehler("Die Rotation wurde abgebrochen.");
|
||||
if (stand.schritt < 4) {
|
||||
throw new RotationsFehler(
|
||||
stand.schritt === 3
|
||||
@@ -311,3 +344,46 @@ export async function rotationAltWiderrufen(
|
||||
}
|
||||
await widerrufEinleiten([altId], akteur, optionen);
|
||||
}
|
||||
|
||||
/**
|
||||
* Laufende Rotation abbrechen (#032): der neue Schluessel wird ueber den normalen Widerruf (#024)
|
||||
* entfernt – mit Dry-Run, denn er kann schon ausgerollt sein. Der alte bleibt aktiv und
|
||||
* unveraendert; wo beide denselben Alias tragen, zeigt die config nach dem Anwenden wieder auf
|
||||
* die alte Datei (juengste *verbleibende* Zuweisung gewinnt, src/worker/ablaeufe/privat.ts).
|
||||
*
|
||||
* `vorgaengerId` des neuen Schluessels bleibt fuer die Historie stehen; dass abgebrochen wurde,
|
||||
* erkennt man am Widerruf des Nachfolgers und am Protokolleintrag. Ist der alte Schluessel schon
|
||||
* im Widerruf (Schritt 5), gibt es kein Zurueck mehr – sonst waeren am Ende beide halb entfernt.
|
||||
*/
|
||||
export async function rotationAbbrechen(
|
||||
altId: string,
|
||||
akteur: Akteur,
|
||||
optionen: { readonly privatErlaubt: boolean },
|
||||
): Promise<void> {
|
||||
const alt = await altLaden(altId);
|
||||
const neu = alt.nachfolger;
|
||||
if (!neu) throw new RotationsFehler("Für diesen Schlüssel läuft keine Rotation.");
|
||||
if (alt.status === "ROTIERT") throw new RotationsFehler("Die Rotation ist bereits abgeschlossen.");
|
||||
// Idempotent: ein zweiter Klick (oder ein Neuladen) setzt den laufenden Abbruch nur fort.
|
||||
if (rotationAbgebrochen(neu)) return;
|
||||
if (alt.widerrufEingeleitetAm) {
|
||||
throw new RotationsFehler(
|
||||
"Der alte Schlüssel wird bereits entfernt – die Rotation lässt sich nicht mehr abbrechen.",
|
||||
);
|
||||
}
|
||||
await widerrufEinleiten([neu.id], akteur, {
|
||||
privatErlaubt: optionen.privatErlaubt,
|
||||
zusaetzlich: async (tx) => {
|
||||
await protokollieren(
|
||||
{
|
||||
akteur,
|
||||
aktion: "schluessel.rotation.abgebrochen",
|
||||
ergebnis: "ERFOLG",
|
||||
fingerprint: alt.fingerprint,
|
||||
details: { name: alt.name, nachfolgerId: neu.id, nachfolger: neu.name },
|
||||
},
|
||||
tx,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -46,6 +46,23 @@ async function deploymentKeyFuer(host: Pick<Host, "id" | "deploymentSchluesselId
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Nachfolger des Instanz-Deployment-Keys waehrend einer Rotation (#031): neu erzeugt,
|
||||
* aber noch nicht Standard. Er gehoert schon in jeden Block – neben den alten –, damit kein
|
||||
* Host je ohne funktionierenden Deployment-Key dasteht.
|
||||
*/
|
||||
export async function deploymentNachfolger() {
|
||||
return prisma.schluessel.findFirst({
|
||||
where: {
|
||||
kategorie: "DEPLOYMENT",
|
||||
instanzStandard: false,
|
||||
status: "AKTIV",
|
||||
vorgaenger: { kategorie: "DEPLOYMENT", instanzStandard: true, status: "AKTIV" },
|
||||
},
|
||||
select: { id: true, publicKey: true, fingerprint: true, vorgaengerId: true, angelegtAm: true },
|
||||
});
|
||||
}
|
||||
|
||||
export async function sollFuer(hostId: string, zielbenutzer: string): Promise<Soll> {
|
||||
const host = await prisma.host.findUniqueOrThrow({
|
||||
where: { id: hostId },
|
||||
@@ -77,6 +94,18 @@ export async function sollFuer(hostId: string, zielbenutzer: string): Promise<So
|
||||
eintraege.push({ publicKey: deployment.publicKey, fingerprint: deployment.fingerprint });
|
||||
pflicht.push(deployment.fingerprint);
|
||||
}
|
||||
// Nur Hosts mit dem Instanz-Key nehmen an dessen Rotation teil (#031).
|
||||
const eigener = host.deploymentSchluesselId !== null;
|
||||
const nachfolger = eigener ? null : await deploymentNachfolger();
|
||||
if (nachfolger && (await deploymentKeyFuer(host))?.fingerprint !== nachfolger.fingerprint) {
|
||||
const gruppe = await prisma.hostgruppeMitglied.count({
|
||||
where: { hostId: host.id, hostgruppe: { deploymentSchluesselId: { not: null } } },
|
||||
});
|
||||
if (gruppe === 0) {
|
||||
eintraege.push({ publicKey: nachfolger.publicKey, fingerprint: nachfolger.fingerprint });
|
||||
pflicht.push(nachfolger.fingerprint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const drin: string[] = [];
|
||||
|
||||
@@ -3,6 +3,7 @@ import { prisma } from "@/lib/db";
|
||||
import { protokollieren } from "@/server/services/protokoll";
|
||||
import { VerwendungsFehler } from "@/server/services/verwendungen";
|
||||
import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge";
|
||||
import { JUENGSTER, type MitNachfolger, mitNachfolger } from "@/lib/rotation";
|
||||
|
||||
/**
|
||||
* Widerruf und Massenwiderruf (#024).
|
||||
@@ -37,10 +38,11 @@ const SCHLUESSEL_KOPF = {
|
||||
status: true,
|
||||
fingerprint: true,
|
||||
widerrufEingeleitetAm: true,
|
||||
nachfolger: { select: { id: true, status: true } },
|
||||
nachfolgerListe: { select: { id: true, status: true, widerrufEingeleitetAm: true }, ...JUENGSTER },
|
||||
} satisfies Prisma.SchluesselSelect;
|
||||
|
||||
type SchluesselKopf = Prisma.SchluesselGetPayload<{ select: typeof SCHLUESSEL_KOPF }>;
|
||||
type KopfRoh = Prisma.SchluesselGetPayload<{ select: typeof SCHLUESSEL_KOPF }>;
|
||||
type SchluesselKopf = MitNachfolger<KopfRoh>;
|
||||
|
||||
/** Warum ein Schluessel nicht widerrufen werden kann – oder null. */
|
||||
export function widerrufGesperrt(s: Pick<SchluesselKopf, "kategorie" | "status" | "name">): string | null {
|
||||
@@ -61,7 +63,7 @@ async function schluesselLaden(ids: readonly string[]): Promise<SchluesselKopf[]
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
if (liste.length !== eindeutig.length) throw new WiderrufFehler("Einen der Schlüssel gibt es nicht.");
|
||||
return liste;
|
||||
return liste.map(mitNachfolger);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Zielkonten
|
||||
@@ -167,6 +169,11 @@ export async function zielkontoKandidaten(schluesselIds: readonly string[]): Pro
|
||||
export type EinleitenOptionen = {
|
||||
/** Darf der Akteur Private Keys entfernen (privateKeyAusrollen)? Prueft die Aktion per darf(). */
|
||||
readonly privatErlaubt: boolean;
|
||||
/**
|
||||
* Laeuft in derselben Transaktion wie das Vormerken – etwa der Protokolleintrag eines
|
||||
* Rotationsabbruchs (#032), damit es keinen Abbruch ohne Eintrag gibt.
|
||||
*/
|
||||
readonly zusaetzlich?: (tx: Prisma.TransactionClient) => Promise<void>;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -264,6 +271,7 @@ export async function widerrufEinleiten(
|
||||
tx,
|
||||
);
|
||||
}
|
||||
await optionen.zusaetzlich?.(tx);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -406,7 +414,9 @@ export async function widerrufAbschliessen(
|
||||
continue;
|
||||
}
|
||||
// Ein aktiver Nachfolger heisst: der Widerruf ist der letzte Schritt einer Rotation (#023).
|
||||
const status = s.nachfolger?.status === "AKTIV" ? "ROTIERT" : "WIDERRUFEN";
|
||||
// Ein Nachfolger im Widerruf ist eine abgebrochene Rotation (#032) – ersetzt ist dann nichts.
|
||||
const status =
|
||||
s.nachfolger?.status === "AKTIV" && !s.nachfolger.widerrufEingeleitetAm ? "ROTIERT" : "WIDERRUFEN";
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const geaendert = await tx.schluessel.updateMany({
|
||||
where: { id: s.id, status: { in: ["AKTIV", "ABGELAUFEN"] } },
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { hostname } from "node:os";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { authorizedKeysLesen, type SchluesselZeile } from "@/lib/ssh/authorized-keys";
|
||||
import { instanzHosts, rotationsStand, type PruefErgebnis } from "@/server/services/deploymentkey";
|
||||
import { protokollieren } from "@/server/services/protokoll";
|
||||
import { anmeldebenutzerVon } from "@/server/services/soll";
|
||||
import { VorgangsFehler, type Handler } from "@/worker/ausfuehren";
|
||||
import { privateKeyLaden, privateKeyVerwerfen, schluesselpaarVersiegeltErzeugen } from "@/worker/geheimnisse";
|
||||
import { hostVerbinden } from "@/worker/ssh/host";
|
||||
import { authorizedKeysHolen } from "@/worker/ssh/ssh-dateien";
|
||||
import { befehlAusfuehren } from "@/worker/ssh/verbindung";
|
||||
|
||||
/** Worker-Schritte der Rotation des Deployment-Keys (#031): erzeugen, pruefen, abschliessen. */
|
||||
|
||||
function fehlerText(fehler: unknown): string {
|
||||
return fehler instanceof VorgangsFehler ? fehler.message : "Verbindung fehlgeschlagen.";
|
||||
}
|
||||
|
||||
async function akteurVon(id: string | null) {
|
||||
const benutzer = id
|
||||
? await prisma.benutzer.findUnique({ where: { id }, select: { id: true, name: true } })
|
||||
: null;
|
||||
return benutzer ?? ("System" as const);
|
||||
}
|
||||
|
||||
async function erzeugen(angefordertVonId: string | null) {
|
||||
const stand = await rotationsStand();
|
||||
if (!stand.aktuell || stand.phase !== "bereit") throw new VorgangsFehler("Es läuft bereits eine Rotation.");
|
||||
const datum = new Date().toISOString().slice(0, 10);
|
||||
const kommentar = `sshkv-deployment@${hostname()}-${datum}`;
|
||||
const neu = await schluesselpaarVersiegeltErzeugen("ED25519", kommentar);
|
||||
const anzahl = await prisma.schluessel.count({ where: { kategorie: "DEPLOYMENT" } });
|
||||
await prisma.schluessel.create({
|
||||
data: {
|
||||
id: neu.id,
|
||||
name: `Deployment-Key der Instanz (${datum})`,
|
||||
slug: `sshkv-deployment-${anzahl + 1}`,
|
||||
typ: "ED25519",
|
||||
kategorie: "DEPLOYMENT",
|
||||
publicKey: neu.publicKeyZeile,
|
||||
fingerprint: neu.fingerprint,
|
||||
kommentar,
|
||||
vorgaengerId: stand.aktuell.id,
|
||||
...neu.versiegelt,
|
||||
},
|
||||
});
|
||||
await protokollieren({
|
||||
akteur: await akteurVon(angefordertVonId),
|
||||
aktion: "deploymentkey.erzeugt",
|
||||
ergebnis: "ERFOLG",
|
||||
fingerprint: neu.fingerprint,
|
||||
details: { vorgaenger: stand.aktuell.fingerprint },
|
||||
});
|
||||
return { ergebnis: { fingerprint: neu.fingerprint } };
|
||||
}
|
||||
|
||||
/** Meldet sich auf jedem Instanz-Host mit dem NEUEN Key an. Wirft nicht je Host. */
|
||||
async function pruefen(angefordertVonId: string | null) {
|
||||
const stand = await rotationsStand();
|
||||
if (!stand.nachfolger) throw new VorgangsFehler("Es gibt keinen neuen Deployment-Key.");
|
||||
const hosts: PruefErgebnis[] = [];
|
||||
const privateKey = await privateKeyLaden(stand.nachfolger.id);
|
||||
try {
|
||||
for (const kurz of await instanzHosts()) {
|
||||
const host = await prisma.host.findUniqueOrThrow({ where: { id: kurz.id } });
|
||||
try {
|
||||
const verbindung = await hostVerbinden(host, { benutzer: anmeldebenutzerVon(host), privateKey });
|
||||
try {
|
||||
const wer = await befehlAusfuehren(verbindung, "id -un");
|
||||
hosts.push({
|
||||
hostId: host.id,
|
||||
hostName: host.name,
|
||||
ok: wer.stdout.trim() === anmeldebenutzerVon(host),
|
||||
});
|
||||
} finally {
|
||||
verbindung.end();
|
||||
}
|
||||
} catch (fehler) {
|
||||
hosts.push({ hostId: host.id, hostName: host.name, ok: false, fehler: fehlerText(fehler) });
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
privateKey.fill(0);
|
||||
}
|
||||
await protokollieren({
|
||||
akteur: await akteurVon(angefordertVonId),
|
||||
aktion: "deploymentkey.geprueft",
|
||||
ergebnis: hosts.every((h) => h.ok) ? "ERFOLG" : "FEHLER",
|
||||
fingerprint: stand.nachfolger.fingerprint,
|
||||
details: {
|
||||
ok: hosts.filter((h) => h.ok).length,
|
||||
fehler: hosts.filter((h) => !h.ok).map((h) => h.hostName),
|
||||
},
|
||||
});
|
||||
return { ergebnis: { hosts } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotation abbrechen (#036), nur vor dem Umstellen: der Nachfolger wird widerrufen und sein
|
||||
* Private Key sofort verworfen – sshkv hat sich mit ihm nie produktiv angemeldet. Aus den
|
||||
* Bloecken verschwindet er beim naechsten Dry-Run, weil `sollFuer()` nur aktive Nachfolger kennt.
|
||||
*/
|
||||
async function abbrechen(angefordertVonId: string | null) {
|
||||
const stand = await rotationsStand();
|
||||
if (!stand.nachfolger) throw new VorgangsFehler("Es läuft keine Rotation, die sich abbrechen ließe.");
|
||||
await prisma.schluessel.update({
|
||||
where: { id: stand.nachfolger.id },
|
||||
data: { status: "WIDERRUFEN", widerrufEingeleitetAm: new Date() },
|
||||
select: { id: true },
|
||||
});
|
||||
await privateKeyVerwerfen(stand.nachfolger.id);
|
||||
await protokollieren({
|
||||
akteur: await akteurVon(angefordertVonId),
|
||||
aktion: "deploymentkey.abgebrochen",
|
||||
ergebnis: "ERFOLG",
|
||||
fingerprint: stand.nachfolger.fingerprint,
|
||||
details: { aktuell: stand.aktuell?.fingerprint ?? null },
|
||||
});
|
||||
return { ergebnis: { fingerprint: stand.nachfolger.fingerprint } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Prueft auf jedem Instanz-Host, dass der alte Key nicht mehr im Block steht, und verwirft
|
||||
* erst dann seinen Private Key. Nicht erreichbare Hosts verhindern den Abschluss nicht – sie
|
||||
* melden sich mit dem alten Key ohnehin nicht mehr an, weil sshkv ihn nicht mehr benutzt –,
|
||||
* werden aber im Ergebnis genannt.
|
||||
*/
|
||||
async function abschliessen(angefordertVonId: string | null) {
|
||||
const stand = await rotationsStand();
|
||||
if (!stand.vorgaenger) throw new VorgangsFehler("Es gibt nichts abzuschließen.");
|
||||
const nochDa: string[] = [];
|
||||
const nichtErreicht: string[] = [];
|
||||
for (const kurz of await instanzHosts()) {
|
||||
const host = await prisma.host.findUniqueOrThrow({ where: { id: kurz.id } });
|
||||
try {
|
||||
const verbindung = await hostVerbinden(host);
|
||||
try {
|
||||
const stand2 = await authorizedKeysHolen(verbindung, host, anmeldebenutzerVon(host));
|
||||
const zeilen = authorizedKeysLesen(stand2.inhalt).zeilen.filter(
|
||||
(z): z is SchluesselZeile => z.art === "schluessel",
|
||||
);
|
||||
if (zeilen.some((z) => z.fingerprint === stand.vorgaenger!.fingerprint)) nochDa.push(host.name);
|
||||
} finally {
|
||||
verbindung.end();
|
||||
}
|
||||
} catch {
|
||||
nichtErreicht.push(host.name);
|
||||
}
|
||||
}
|
||||
if (nochDa.length) {
|
||||
throw new VorgangsFehler(
|
||||
`Der alte Deployment-Key steht noch auf: ${nochDa.join(", ")}. Bitte zuerst Dry-Run und Ausrollen.`,
|
||||
);
|
||||
}
|
||||
await privateKeyVerwerfen(stand.vorgaenger.id);
|
||||
await protokollieren({
|
||||
akteur: await akteurVon(angefordertVonId),
|
||||
aktion: "deploymentkey.abgeschlossen",
|
||||
ergebnis: "ERFOLG",
|
||||
fingerprint: stand.vorgaenger.fingerprint,
|
||||
details: { nichtErreicht },
|
||||
});
|
||||
return { ergebnis: { nichtErreicht } };
|
||||
}
|
||||
|
||||
export const deploymentRotationHandler: Handler = async ({ vorgang }) => {
|
||||
const schritt = (vorgang.eingabe as { schritt?: unknown } | null)?.schritt;
|
||||
if (schritt === "erzeugen") return erzeugen(vorgang.angefordertVonId);
|
||||
if (schritt === "pruefen") return pruefen(vorgang.angefordertVonId);
|
||||
if (schritt === "abbrechen") return abbrechen(vorgang.angefordertVonId);
|
||||
if (schritt === "abschliessen") return abschliessen(vorgang.angefordertVonId);
|
||||
throw new VorgangsFehler("Unbekannter Schritt der Rotation.");
|
||||
};
|
||||
@@ -110,7 +110,8 @@ export const schluesselErzeugenHandler: Handler = async ({ vorgang, geheim }) =>
|
||||
});
|
||||
} catch (fehler) {
|
||||
if (istEindeutigkeitsFehler(fehler)) {
|
||||
// vorgaengerId ist eindeutig: zwei Rotationen desselben Schluessels gleichzeitig (#023).
|
||||
// Je Vorgaenger nur ein offener Nachfolger (partieller Index, #023/#033): zwei Rotationen
|
||||
// desselben Schluessels gleichzeitig.
|
||||
if (eingabe.vorgaengerId) throw new VorgangsFehler("Für diesen Schlüssel läuft bereits eine Rotation.");
|
||||
throw new VorgangsFehler(
|
||||
`Der Kurzname „${eingabe.slug}“ wurde inzwischen vergeben. Bitte erneut erzeugen.`,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { abgleichHandler } from "@/worker/ablaeufe/abgleich";
|
||||
import { benachrichtigungTestHandler } from "@/worker/ablaeufe/benachrichtigung";
|
||||
import { ausrollenHandler, trockenlaufHandler } from "@/worker/ablaeufe/ausrollen";
|
||||
import { bootstrapHandler } from "@/worker/ablaeufe/bootstrap";
|
||||
import { deploymentRotationHandler } from "@/worker/ablaeufe/deploymentkey";
|
||||
import { erreichbarkeitHandler, hostKeyAbrufenHandler } from "@/worker/ablaeufe/hostkey";
|
||||
import { privateKeyAbholenHandler, privateKeyLoeschenHandler } from "@/worker/ablaeufe/privatekey";
|
||||
import { schluesselErzeugenHandler, schluesselImportierenHandler } from "@/worker/ablaeufe/schluessel";
|
||||
@@ -24,6 +25,7 @@ export const HANDLER: HandlerTabelle = {
|
||||
PRIVATE_KEY_LOESCHEN: privateKeyLoeschenHandler,
|
||||
VERBINDUNGSTEST: verbindungstestHandler,
|
||||
ABGLEICH: abgleichHandler,
|
||||
ROTATION: deploymentRotationHandler,
|
||||
ZIELKONTO_ANLEGEN: zielkontoAnlegenHandler,
|
||||
ZIELKONTO_ENTFERNEN: zielkontoEntfernenHandler,
|
||||
BENACHRICHTIGUNG_TEST: benachrichtigungTestHandler,
|
||||
|
||||
@@ -74,6 +74,7 @@ describe("Rechte", () => {
|
||||
it("verlangt fuer kritische Aktionen eine Bestaetigung, und die nur fuer Admin-Rechte", () => {
|
||||
expect([...BESTAETIGUNG_NOETIG].sort()).toEqual(
|
||||
[
|
||||
"benutzerVerwalten",
|
||||
"deploymentKeyVerwalten",
|
||||
"massenwiderruf",
|
||||
"privateKeyAnzeigen",
|
||||
|
||||
@@ -26,6 +26,7 @@ const REGELN = [
|
||||
|
||||
const INDIZES = [
|
||||
"schluessel_ein_instanzstandard",
|
||||
"schluessel_ein_offener_nachfolger",
|
||||
"publicverwendung_eindeutig_host",
|
||||
"publicverwendung_eindeutig_gruppe",
|
||||
"privateverwendung_eindeutig",
|
||||
@@ -236,8 +237,30 @@ describe("Protokoll", () => {
|
||||
expect(pruefung?.ergebnis).toBe(zweiter.id);
|
||||
});
|
||||
|
||||
it("vergibt IDs ohne Zweierschritte (#032)", async () => {
|
||||
// Frueher zogen Spalten-Default und Trigger je eine Nummer aus der Sequenz.
|
||||
const [a, b, c] = [await eintragen("eins"), await eintragen("zwei"), await eintragen("drei")];
|
||||
expect(b.id).toBe(a.id + 1n);
|
||||
expect(c.id).toBe(a.id + 2n);
|
||||
const [pruefung] = await prisma.$queryRaw<
|
||||
{ ergebnis: bigint | null }[]
|
||||
>`SELECT protokoll_kette_pruefen() AS ergebnis`;
|
||||
expect(pruefung?.ergebnis).toBeNull();
|
||||
});
|
||||
|
||||
it("ueberschreibt eine mitgegebene ID unter der Sperre (#032)", async () => {
|
||||
const erster = await eintragen("eins");
|
||||
const zweiter = await prisma.protokoll.create({
|
||||
data: { id: 999n, akteurName: "Test", aktion: "zwei", ergebnis: "ERFOLG" },
|
||||
});
|
||||
expect(zweiter.id).toBe(erster.id + 1n);
|
||||
expect(zweiter.vorgaengerHash).toBe(erster.hash);
|
||||
});
|
||||
|
||||
it("bleibt bei gleichzeitigem Schreiben eine lueckenlose Kette", async () => {
|
||||
await Promise.all(Array.from({ length: 20 }, (_, i) => eintragen(`parallel-${i}`)));
|
||||
const zeilen = await Promise.all(Array.from({ length: 20 }, (_, i) => eintragen(`parallel-${i}`)));
|
||||
const ids = zeilen.map((z) => z.id).sort((x, y) => (x < y ? -1 : 1));
|
||||
expect(ids.at(-1)! - ids[0]!).toBe(19n);
|
||||
const [pruefung] = await prisma.$queryRaw<
|
||||
{ ergebnis: bigint | null }[]
|
||||
>`SELECT protokoll_kette_pruefen() AS ergebnis`;
|
||||
|
||||
@@ -66,10 +66,10 @@ test("OIDC ohne sshkv-Gruppe wird abgewiesen", async ({ page }) => {
|
||||
test("Notfall-Admin richtet TOTP ein und bestätigt sich erneut", async ({ page }) => {
|
||||
test.skip(NOTFALL_PASSWORT.length < 16, "SSHKV_BREAKGLASS_PASSWORD fehlt in .env");
|
||||
await page.goto("/anmelden");
|
||||
await page.getByText("Notfallzugang", { exact: true }).click();
|
||||
await page.getByText("Lokales Konto oder Notfallzugang", { exact: true }).click();
|
||||
await page.getByLabel("Anmeldename").fill("notfall");
|
||||
await page.getByLabel("Passwort").fill(NOTFALL_PASSWORT);
|
||||
await page.getByRole("button", { name: "Mit Notfallzugang anmelden" }).click();
|
||||
await page.getByRole("button", { name: "Mit Benutzername anmelden" }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/anmelden\/totp/);
|
||||
await expect(page.getByRole("img", { name: /QR-Code/ })).toBeVisible();
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { anmelden, archivieren, aufZiel, hostEinrichten, PORT, zieleZuruecksetzen } from "./hilfen";
|
||||
|
||||
/**
|
||||
* Rotation des Deployment-Keys im Browser (#031), gebauter Stand + Worker: zwei frisch
|
||||
* eingerichtete Hosts, fuenf Schritte, danach meldet sich sshkv nur noch mit dem neuen Key an.
|
||||
*/
|
||||
test.setTimeout(300_000);
|
||||
|
||||
test.beforeAll(() => zieleZuruecksetzen());
|
||||
|
||||
async function bestaetigen(page: Page) {
|
||||
await page.goto("/bestaetigen?ziel=%2Fdeployment-key");
|
||||
await page.getByRole("link", { name: /erneut anmelden/ }).click();
|
||||
await page.locator('button[data-rolle="admin"]').click();
|
||||
const claims = JSON.parse(await page.locator("#claims").inputValue()) as Record<string, unknown>;
|
||||
await page.locator("#claims").fill(JSON.stringify({ ...claims, auth_time: Math.floor(Date.now() / 1000) }));
|
||||
await page.getByRole("button", { name: "Anmelden", exact: true }).click();
|
||||
await expect(page.getByRole("heading", { name: "Deployment-Key rotieren" })).toBeVisible();
|
||||
}
|
||||
|
||||
test("Rotation des Deployment-Keys abbrechen und aufräumen (#036)", async ({ page }) => {
|
||||
const kennung = Date.now().toString(36);
|
||||
const hostA = `zz-e2e-a-${kennung}`;
|
||||
await anmelden(page);
|
||||
await hostEinrichten(page, hostA, PORT.a);
|
||||
await page.goto("/deployment-key");
|
||||
await bestaetigen(page);
|
||||
|
||||
// Eine liegengebliebene Rotation (etwa aus einem abgebrochenen Testlauf) wird mit abgebrochen.
|
||||
const erzeugen = page.getByRole("button", { name: "Neuen Deployment-Key erzeugen" });
|
||||
if (await erzeugen.isVisible()) {
|
||||
await erzeugen.click();
|
||||
await expect(page.getByText("Neuer Key (noch nicht Standard)")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
await page.getByRole("button", { name: "Dry-Run starten" }).click();
|
||||
await page.getByRole("button", { name: /Änderung anwenden/ }).click({ timeout: 30_000 });
|
||||
await expect(page.getByText("Ausgerollt.", { exact: true })).toBeVisible({ timeout: 60_000 });
|
||||
expect(aufZiel("ziel-a", "grep -c sshkv-deployment /home/sshkv/.ssh/authorized_keys").trim()).toBe("2");
|
||||
|
||||
await page.getByRole("button", { name: "Rotation abbrechen" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Abgebrochene Rotation aufräumen" })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await page.getByRole("button", { name: "Dry-Run starten" }).click();
|
||||
await page.getByRole("button", { name: /Änderung anwenden/ }).click({ timeout: 30_000 });
|
||||
await expect(page.getByText("Ausgerollt.", { exact: true })).toBeVisible({ timeout: 60_000 });
|
||||
expect(aufZiel("ziel-a", "grep -c sshkv-deployment /home/sshkv/.ssh/authorized_keys").trim()).toBe("1");
|
||||
// Ein neuer Anlauf ist sofort moeglich.
|
||||
await expect(page.getByRole("button", { name: "Neuen Deployment-Key erzeugen" })).toBeVisible();
|
||||
|
||||
await archivieren(page, hostA);
|
||||
});
|
||||
|
||||
test("Deployment-Key rotieren", async ({ page }) => {
|
||||
const kennung = Date.now().toString(36);
|
||||
const hostA = `zz-e2e-a-${kennung}`;
|
||||
const hostB = `zz-e2e-b-${kennung}`;
|
||||
|
||||
await anmelden(page);
|
||||
await hostEinrichten(page, hostA, PORT.a);
|
||||
await hostEinrichten(page, hostB, PORT.b);
|
||||
const vorher = aufZiel("ziel-a", "cat /home/sshkv/.ssh/authorized_keys");
|
||||
|
||||
await page.goto("/einstellungen");
|
||||
await page.getByRole("link", { name: "Deployment-Key rotieren" }).click();
|
||||
await bestaetigen(page);
|
||||
|
||||
await page.getByRole("button", { name: "Neuen Deployment-Key erzeugen" }).click();
|
||||
await expect(page.getByText("Neuer Key (noch nicht Standard)")).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Schritt 2: verteilen – der neue Key kommt neben den alten.
|
||||
await page.getByRole("button", { name: "Dry-Run starten" }).click();
|
||||
await expect(page.getByRole("button", { name: /Änderung anwenden/ })).toBeVisible({ timeout: 30_000 });
|
||||
await page.screenshot({ path: "docs/anleitung/bilder/deployment-key-desktop.png", fullPage: true });
|
||||
await page.getByRole("button", { name: /Änderung anwenden/ }).click();
|
||||
await expect(page.getByText("Ausgerollt.", { exact: true })).toBeVisible({ timeout: 60_000 });
|
||||
expect(aufZiel("ziel-a", "grep -c sshkv-deployment /home/sshkv/.ssh/authorized_keys").trim()).toBe("2");
|
||||
|
||||
// Schritt 3 und 4: pruefen, umstellen.
|
||||
await page.getByRole("button", { name: "Neuen Key prüfen" }).click();
|
||||
const ergebnis = page.getByRole("list", { name: "Ergebnis der Prüfung" });
|
||||
await expect(ergebnis.getByText("angemeldet")).toHaveCount(2, { timeout: 60_000 });
|
||||
await page.getByRole("button", { name: "Auf neuen Key umstellen" }).click();
|
||||
|
||||
// Schritt 5: alten Key entfernen und abschliessen.
|
||||
await expect(page.getByRole("heading", { name: "5 · Alten Key entfernen" })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await page.getByRole("button", { name: "Dry-Run starten" }).click();
|
||||
await page.getByRole("button", { name: /Änderung anwenden/ }).click({ timeout: 30_000 });
|
||||
await expect(page.getByText("Ausgerollt.", { exact: true })).toBeVisible({ timeout: 60_000 });
|
||||
await page.getByRole("button", { name: "Rotation abschließen" }).click();
|
||||
await expect(page.getByRole("heading", { name: "1 · Neuen Key erzeugen" })).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
|
||||
const nachher = aufZiel("ziel-a", "cat /home/sshkv/.ssh/authorized_keys");
|
||||
expect(nachher.match(/sshkv-deployment/g)).toHaveLength(1);
|
||||
expect(nachher).not.toBe(vorher);
|
||||
|
||||
// Der Alltag geht weiter: Verbindung pruefen klappt mit dem neuen Key.
|
||||
await page.goto("/hosts");
|
||||
await page.getByRole("link", { name: hostA }).first().click();
|
||||
const pruefen = page.getByRole("button", { name: "Verbindung prüfen" });
|
||||
await pruefen.click();
|
||||
await expect(pruefen.locator("..").getByText("Erledigt.")).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await archivieren(page, hostA);
|
||||
await archivieren(page, hostB);
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import pg from "pg";
|
||||
import "dotenv/config";
|
||||
|
||||
/**
|
||||
@@ -35,6 +36,30 @@ export async function anmelden(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("heading", { name: "Übersicht" })).toBeVisible();
|
||||
}
|
||||
|
||||
/** Direkter Zugriff auf die Datenbank des gebauten Stands – fuer Vorbedingungen und Pruefungen. */
|
||||
export async function datenbank<T>(arbeit: (client: pg.Client) => Promise<T>): Promise<T> {
|
||||
const client = new pg.Client({ connectionString: process.env["DATABASE_URL"] });
|
||||
await client.connect();
|
||||
try {
|
||||
return await arbeit(client);
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Erneute Anmeldung beim Mock-OIDC (#014). Der Mock uebernimmt nur die Claims aus dem
|
||||
* Formular; `auth_time` muss deshalb mitgeschickt werden, sonst lehnt sshkv die Bestaetigung ab.
|
||||
*/
|
||||
export async function oidcBestaetigen(page: Page): Promise<void> {
|
||||
await expect(page).toHaveURL(/\/bestaetigen\?ziel=/);
|
||||
await page.getByRole("link", { name: /erneut anmelden/ }).click();
|
||||
await page.locator('button[data-rolle="admin"]').click();
|
||||
const claims = JSON.parse(await page.locator("#claims").inputValue()) as Record<string, unknown>;
|
||||
await page.locator("#claims").fill(JSON.stringify({ ...claims, auth_time: Math.floor(Date.now() / 1000) }));
|
||||
await page.getByRole("button", { name: "Anmelden", exact: true }).click();
|
||||
}
|
||||
|
||||
export async function hostEinrichten(page: Page, name: string, port: string): Promise<void> {
|
||||
await page.goto("/hosts/neu");
|
||||
await page.getByLabel("Name").fill(name);
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { base32Dekodieren, totpCode, totpSchritt } from "../../src/lib/totp";
|
||||
import { anmelden, oidcBestaetigen } from "./hilfen";
|
||||
|
||||
/**
|
||||
* Lokale Konten (#035) im gebauten Stand: Admin legt ein Konto an, die Person meldet sich zum
|
||||
* ersten Mal an (eigenes Passwort, TOTP), der Admin sperrt sie – die Sitzung endet sofort.
|
||||
*/
|
||||
test.setTimeout(120_000);
|
||||
|
||||
test("lokales Konto anlegen, erste Anmeldung, sperren", async ({ page, browser }) => {
|
||||
const anmeldename = `zz-e2e-${Date.now().toString(36)}`;
|
||||
const passwort = "ein-eigenes-e2e-passwort";
|
||||
|
||||
await anmelden(page);
|
||||
await page.goto("/einstellungen");
|
||||
await page.getByRole("link", { name: "Konten verwalten" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Benutzer", exact: true })).toBeVisible();
|
||||
|
||||
// Frische Bestaetigung immer selbst holen – ob ein frueherer Test sie schon gesetzt hat,
|
||||
// haengt von der Reihenfolge ab. Dass das Recht sie verlangt, pruefen die Unit-Tests.
|
||||
await page.goto("/bestaetigen?ziel=%2Feinstellungen%2Fbenutzer");
|
||||
await oidcBestaetigen(page);
|
||||
await expect(page).toHaveURL(/\/einstellungen\/benutzer$/);
|
||||
await page.getByLabel("Anmeldename").fill(anmeldename);
|
||||
await page.getByLabel("Name", { exact: true }).fill("E2E Lokal");
|
||||
await page.getByLabel("Rolle", { exact: true }).selectOption("OPERATOR");
|
||||
await page.getByRole("button", { name: "Konto anlegen" }).click();
|
||||
const anzeige = page.getByRole("region", { name: `Einmal-Passwort für ${anmeldename}` });
|
||||
const einmal = ((await anzeige.locator("code").textContent()) ?? "").trim();
|
||||
expect(einmal).toMatch(/^[a-zA-Z2-9]{5}(-[a-zA-Z2-9]{5}){3}$/);
|
||||
await page.screenshot({ path: "docs/anleitung/bilder/benutzer-desktop.png", fullPage: true });
|
||||
|
||||
// Zweiter Browser: die Person meldet sich zum ersten Mal an.
|
||||
const kontext = await browser.newContext();
|
||||
const person = await kontext.newPage();
|
||||
await person.goto("/anmelden");
|
||||
await person.getByText("Lokales Konto oder Notfallzugang", { exact: true }).click();
|
||||
await person.getByLabel("Anmeldename").fill(anmeldename);
|
||||
await person.getByLabel("Passwort").fill(einmal);
|
||||
await person.getByRole("button", { name: "Mit Benutzername anmelden" }).click();
|
||||
await expect(person).toHaveURL(/\/anmelden\/passwort/);
|
||||
await person.getByLabel("Neues Passwort", { exact: true }).fill("zu-kurz");
|
||||
await person.getByLabel("Neues Passwort wiederholen").fill("zu-kurz");
|
||||
await person.getByRole("button", { name: "Passwort setzen" }).click();
|
||||
await expect(person.getByText(/mindestens 12 Zeichen/).last()).toBeVisible();
|
||||
await person.getByLabel("Neues Passwort", { exact: true }).fill(passwort);
|
||||
await person.getByLabel("Neues Passwort wiederholen").fill(passwort);
|
||||
await person.getByRole("button", { name: "Passwort setzen" }).click();
|
||||
|
||||
await expect(person).toHaveURL(/\/anmelden\/totp/);
|
||||
const geheimnis = ((await person.locator("code").first().textContent()) ?? "").trim();
|
||||
await person
|
||||
.getByLabel("Code zur Bestätigung")
|
||||
.fill(totpCode(base32Dekodieren(geheimnis), totpSchritt(Date.now())));
|
||||
await person.getByRole("button", { name: /TOTP einrichten/ }).click();
|
||||
await expect(person.getByRole("heading", { name: "Übersicht" })).toBeVisible();
|
||||
await expect(person.getByText("Operator · über lokales Konto")).toBeVisible();
|
||||
// Ein Operator verwaltet keine Benutzer.
|
||||
await person.goto("/einstellungen/benutzer");
|
||||
await expect(person).toHaveURL(/\/kein-zugriff/);
|
||||
|
||||
// Admin sperrt das Konto – die Sitzung der Person endet sofort.
|
||||
await page.reload();
|
||||
const zeile = page.getByRole("row").filter({ hasText: anmeldename });
|
||||
await expect(zeile.getByText("Einrichtung offen")).toHaveCount(0);
|
||||
await zeile.getByRole("button", { name: /Sperren/ }).click();
|
||||
await expect(zeile.getByText("gesperrt", { exact: true })).toBeVisible();
|
||||
await person.goto("/");
|
||||
await expect(person).toHaveURL(/\/anmelden/);
|
||||
await kontext.close();
|
||||
});
|
||||
@@ -69,3 +69,53 @@ test("Rotation mit Schrittanzeige", async ({ page }) => {
|
||||
await archivieren(page, hostA);
|
||||
await archivieren(page, hostB);
|
||||
});
|
||||
|
||||
test("Rotation abbrechen, nachdem der neue Schlüssel ausgerollt ist (#032)", async ({ page }) => {
|
||||
const kennung = Date.now().toString(36);
|
||||
const hostA = `zz-e2e-a-${kennung}`;
|
||||
const hostB = `zz-e2e-b-${kennung}`;
|
||||
const zweck = `zz-e2e Abbruch ${kennung}`;
|
||||
|
||||
zieleZuruecksetzen();
|
||||
await anmelden(page);
|
||||
await hostEinrichten(page, hostA, PORT.a);
|
||||
await hostEinrichten(page, hostB, PORT.b);
|
||||
const schluesselSeite = await kernAufbauen(page, zweck, hostA, hostB);
|
||||
await page.reload();
|
||||
|
||||
await page.getByRole("link", { name: "Rotieren" }).click();
|
||||
await page.getByRole("button", { name: "Neuen Schlüssel erzeugen und Zuweisungen übernehmen" }).click();
|
||||
await page.getByRole("button", { name: "Dry-Run starten" }).click({ timeout: 60_000 });
|
||||
await page.getByRole("button", { name: /Änderung anwenden/ }).click({ timeout: 30_000 });
|
||||
await expect(page.getByRole("button", { name: "Verbindungen testen" })).toBeVisible({ timeout: 60_000 });
|
||||
expect(aufZiel("ziel-a", "cat /root/.ssh/config")).toMatch(/IdentityFile ~\/\.ssh\/.*-r\d{8}$/m);
|
||||
|
||||
// Abbrechen: Rueckfrage, dann Widerruf des neuen Schluessels mit Dry-Run.
|
||||
await page.getByRole("button", { name: "Rotation abbrechen" }).click();
|
||||
const rueckfrage = page.getByRole("group", { name: "Rotation abbrechen?" });
|
||||
await expect(rueckfrage.getByText(`„${zweck}“ bleibt aktiv und unverändert`)).toBeVisible();
|
||||
await rueckfrage.getByRole("button", { name: "Neuen Schlüssel widerrufen" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Rotation wird abgebrochen" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Dry-Run starten" }).click();
|
||||
await page.getByRole("button", { name: /Änderung anwenden/ }).click({ timeout: 30_000 });
|
||||
await expect(page.getByRole("heading", { name: "Rotation abgebrochen" })).toBeVisible({ timeout: 60_000 });
|
||||
|
||||
// Auf den Hosts: wie vorher. Der Alias geht wieder mit dem alten Schluessel.
|
||||
expect(aufZiel("ziel-a", "ls /root/.ssh")).not.toMatch(/-r\d{8}/);
|
||||
expect(aufZiel("ziel-a", "cat /root/.ssh/config")).not.toMatch(/-r\d{8}/);
|
||||
expect(aufZiel("ziel-a", "ssh -o BatchMode=yes zu-b id -un").trim()).toBe("root");
|
||||
|
||||
// Der alte bleibt aktiv; die Kette zeigt den widerrufenen Nachfolger – und er laesst sich
|
||||
// erneut rotieren (#033).
|
||||
await page.goto(schluesselSeite);
|
||||
const kette = page.getByRole("list", { name: "Rotationskette" });
|
||||
await expect(kette.getByText("widerrufen", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(/Die Rotation zu „.*“ wurde abgebrochen.*erneut rotieren/)).toBeVisible();
|
||||
await page.getByRole("link", { name: "Rotieren" }).click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Neuen Schlüssel erzeugen und Zuweisungen übernehmen" }),
|
||||
).toBeVisible();
|
||||
|
||||
await archivieren(page, hostA);
|
||||
await archivieren(page, hostB);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import pg from "pg";
|
||||
import "dotenv/config";
|
||||
import { schluesselpaarErzeugen } from "../../src/lib/ssh/openssh";
|
||||
import { datenbank, oidcBestaetigen } from "./hilfen";
|
||||
|
||||
/**
|
||||
* Schluessel im gebauten Stand mit laufendem Worker (#012–#014). Testdaten heissen zz-e2e-…
|
||||
@@ -17,16 +17,6 @@ async function oidcAnmelden(page: Page, rolle: "admin" | "operator" | "reader")
|
||||
await expect(page.getByRole("heading", { name: "Übersicht" })).toBeVisible();
|
||||
}
|
||||
|
||||
async function datenbank<T>(arbeit: (client: pg.Client) => Promise<T>): Promise<T> {
|
||||
const client = new pg.Client({ connectionString: process.env["DATABASE_URL"] });
|
||||
await client.connect();
|
||||
try {
|
||||
return await arbeit(client);
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
function eindeutig(): string {
|
||||
return `zz-e2e-${Date.now().toString(36)}`;
|
||||
}
|
||||
@@ -104,19 +94,6 @@ test("Leser hinterlegt einen eigenen Public Key und entfernt ihn wieder", async
|
||||
await expect(page.locator("li", { hasText: name })).toHaveCount(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* Erneute Anmeldung beim Mock-OIDC (#014). Der Mock uebernimmt nur die Claims aus dem
|
||||
* Formular; `auth_time` muss deshalb mitgeschickt werden, sonst lehnt sshkv die Bestaetigung ab.
|
||||
*/
|
||||
async function oidcBestaetigen(page: Page) {
|
||||
await expect(page).toHaveURL(/\/bestaetigen\?ziel=/);
|
||||
await page.getByRole("link", { name: /erneut anmelden/ }).click();
|
||||
await page.locator('button[data-rolle="admin"]').click();
|
||||
const claims = JSON.parse(await page.locator("#claims").inputValue()) as Record<string, unknown>;
|
||||
await page.locator("#claims").fill(JSON.stringify({ ...claims, auth_time: Math.floor(Date.now() / 1000) }));
|
||||
await page.getByRole("button", { name: "Anmelden", exact: true }).click();
|
||||
}
|
||||
|
||||
test("Admin zeigt den Private Key nach Bestätigung an, lädt ihn herunter und löscht ihn", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import {
|
||||
anmelden,
|
||||
archivieren,
|
||||
aufZiel,
|
||||
datenbank,
|
||||
hostEinrichten,
|
||||
kernAufbauen,
|
||||
oidcBestaetigen,
|
||||
PORT,
|
||||
zieleZuruecksetzen,
|
||||
} from "./hilfen";
|
||||
@@ -59,3 +61,90 @@ test("Widerruf mit einem Klick", async ({ page }) => {
|
||||
await archivieren(page, hostA);
|
||||
await archivieren(page, hostB);
|
||||
});
|
||||
|
||||
/** Schluessel erzeugen, Public Key auf `host` zuweisen und ausrollen. Liefert den Key-Teil. */
|
||||
async function publicKeyAusrollen(page: Page, zweck: string, host: string): Promise<string> {
|
||||
await page.goto("/schluessel/neu");
|
||||
await page.getByLabel("Zweck").fill(zweck);
|
||||
await page.getByRole("button", { name: "Schlüssel erzeugen" }).click();
|
||||
await expect(page.getByRole("heading", { name: zweck })).toBeVisible({ timeout: 30_000 });
|
||||
const zeile = (await page.getByLabel("Public Key", { exact: true }).textContent()) ?? "";
|
||||
const keyTeil = zeile.trim().split(/\s+/)[1] ?? "";
|
||||
expect(keyTeil).toMatch(/^AAAA/);
|
||||
|
||||
await page.getByRole("button", { name: "Ausrollen" }).nth(1).click();
|
||||
const oeffentlich = page.getByRole("dialog", { name: "Public Key zuweisen" });
|
||||
await oeffentlich.getByLabel("Ziel", { exact: true }).selectOption({ label: host });
|
||||
await oeffentlich.getByRole("button", { name: "Zuweisen" }).click();
|
||||
await expect(oeffentlich).toBeHidden();
|
||||
await page.getByRole("button", { name: "Dry-Run starten" }).click();
|
||||
await page.getByRole("button", { name: /Änderung anwenden/ }).click({ timeout: 30_000 });
|
||||
await expect(page.getByText("Ausgerollt.")).toBeVisible({ timeout: 60_000 });
|
||||
return keyTeil;
|
||||
}
|
||||
|
||||
test("Massenwiderruf mit frischer Bestätigung (#032)", async ({ page }) => {
|
||||
const kennung = Date.now().toString(36);
|
||||
const hostB = `zz-e2e-mb-${kennung}`;
|
||||
const praefix = `zz-e2e Massen ${kennung}`;
|
||||
const zwecke = [`${praefix} eins`, `${praefix} zwei`];
|
||||
|
||||
await anmelden(page);
|
||||
// Eine Bestaetigung aus der Anmeldung eben oder einem frueheren Lauf soll nicht mitzaehlen.
|
||||
await datenbank((client) =>
|
||||
client.query(`UPDATE "Benutzer" SET "bestaetigtAm" = NULL WHERE "email" = 'admin@sshkv.test'`),
|
||||
);
|
||||
await hostEinrichten(page, hostB, PORT.b);
|
||||
const keys = [];
|
||||
for (const zweck of zwecke) keys.push(await publicKeyAusrollen(page, zweck, hostB));
|
||||
const vorher = aufZiel("ziel-b", "cat /root/.ssh/authorized_keys");
|
||||
for (const key of keys) expect(vorher).toContain(key);
|
||||
|
||||
// Auswahl in der Liste – ein GET-Formular auf die Massenwiderruf-Seite.
|
||||
await page.goto(`/schluessel?q=${encodeURIComponent(praefix)}`);
|
||||
for (const zweck of zwecke)
|
||||
await page.getByRole("checkbox", { name: `${zweck} für den Massenwiderruf auswählen` }).check();
|
||||
await page.getByRole("button", { name: "Ausgewählte widerrufen" }).click();
|
||||
await expect(page).toHaveURL(/\/schluessel\/widerruf\?id=/);
|
||||
await expect(page.getByRole("heading", { name: "2 Schlüssel widerrufen?" })).toBeVisible();
|
||||
for (const zweck of zwecke)
|
||||
await expect(
|
||||
page.getByRole("region", { name: `Widerruf von ${zweck}` }).getByText(`root@${hostB}`),
|
||||
).toBeVisible();
|
||||
|
||||
// Ohne frische Bestaetigung merkt sshkv nichts vor und schickt zur erneuten Anmeldung.
|
||||
await page.getByRole("button", { name: "2 Schlüssel widerrufen" }).click();
|
||||
await page.getByRole("link", { name: /Anmeldung bestätigen/ }).click();
|
||||
await oidcBestaetigen(page);
|
||||
await expect(page).toHaveURL(/\/schluessel\/widerruf\?id=/);
|
||||
const eingeleitet = await datenbank((client) =>
|
||||
client.query(
|
||||
`SELECT count(*)::int AS n FROM "Schluessel" WHERE "name" = ANY($1) AND "widerrufEingeleitetAm" IS NOT NULL`,
|
||||
[zwecke],
|
||||
),
|
||||
);
|
||||
expect(eingeleitet.rows[0]).toEqual({ n: 0 });
|
||||
|
||||
await page.getByRole("button", { name: "2 Schlüssel widerrufen" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Widerruf läuft" })).toBeVisible();
|
||||
|
||||
// Ein gemeinsamer Dry-Run fuer beide Schluessel, dann anwenden; der Abschluss laeuft danach.
|
||||
await page.getByRole("button", { name: "Dry-Run starten" }).click();
|
||||
await expect(page.getByText(`root@${hostB}:/root/.ssh/authorized_keys`)).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByRole("button", { name: /Änderung anwenden/ }).click();
|
||||
await expect(page.getByRole("heading", { name: "Widerruf läuft" })).toBeHidden({ timeout: 60_000 });
|
||||
const gewaehlt = page.locator("section", {
|
||||
has: page.getByRole("heading", { name: "Gewählte Schlüssel" }),
|
||||
});
|
||||
await expect(gewaehlt.getByText("widerrufen", { exact: true })).toHaveCount(2);
|
||||
await expect(gewaehlt.getByText("Widerruf unvollständig")).toHaveCount(0);
|
||||
|
||||
const nachher = aufZiel("ziel-b", "cat /root/.ssh/authorized_keys");
|
||||
for (const key of keys) expect(nachher).not.toContain(key);
|
||||
const status = await datenbank((client) =>
|
||||
client.query(`SELECT "status" FROM "Schluessel" WHERE "name" = ANY($1)`, [zwecke]),
|
||||
);
|
||||
expect(status.rows).toEqual([{ status: "WIDERRUFEN" }, { status: "WIDERRUFEN" }]);
|
||||
|
||||
await archivieren(page, hostB);
|
||||
});
|
||||
|
||||
@@ -128,6 +128,14 @@ test("Matrix zeigt Gruppen-Zuweisung, filtert, weist zu und exportiert", async (
|
||||
`"${name} Backup";"SHA256:${name}";"${name}-h2";"root";"Public Key";"${name}-g";"restrict";"noch nicht ausgerollt"`,
|
||||
);
|
||||
expect(text).toContain(`"${name}-h3";"deploy";"Public Key";""`);
|
||||
|
||||
// „Zum Dry-Run“ springt direkt zur Dry-Run-Karte der Schluesselseite (#032).
|
||||
await matrix.getByRole("button", { name: new RegExp(`auf ${name}-h3: Public Key direkt`) }).click();
|
||||
await dialog.getByRole("link", { name: "Zum Dry-Run" }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/schluessel/${ids.schluessel}#dry-run$`));
|
||||
const karte = page.locator("#dry-run");
|
||||
await expect(karte).toContainText("Dry-Run-Vorschau");
|
||||
await expect(karte).toBeInViewport();
|
||||
});
|
||||
|
||||
test("Leser sieht die Matrix, aber keinen Zuweisen-Knopf", async ({ page }) => {
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { authorizedKeysLesen } from "@/lib/ssh/authorized-keys";
|
||||
import {
|
||||
DeploymentKeyFehler,
|
||||
instanzZiele,
|
||||
rotationsStand,
|
||||
umstellen,
|
||||
} from "@/server/services/deploymentkey";
|
||||
import { anmeldebenutzerVon } from "@/server/services/soll";
|
||||
import { deploymentKeySicherstellen, privateKeyLaden } from "@/worker/geheimnisse";
|
||||
import { hostVerbinden } from "@/worker/ssh/host";
|
||||
import { authorizedKeysHolen } from "@/worker/ssh/ssh-dateien";
|
||||
import { datenbankLeeren } from "../hilfen/db";
|
||||
import { warteschlangeStoppen } from "../hilfen/vorgang";
|
||||
import { eingerichteterHost, masterKeyAnlegen, vorgangLaufen } from "./hilfen/host";
|
||||
import { befehlAlsRoot, zielA, zielB, zielBereit } from "./hilfen/ziele";
|
||||
|
||||
/**
|
||||
* Rotation des Deployment-Keys gegen echte Hosts (#031): danach meldet sich sshkv nur noch mit
|
||||
* dem neuen Key an, der alte steht auf keinem Host mehr, und zu keinem Zeitpunkt war ein Host
|
||||
* ohne funktionierenden Key.
|
||||
*/
|
||||
const AKTEUR = { id: "", name: "Test-Admin" };
|
||||
let aufraeumen: () => void;
|
||||
|
||||
beforeAll(async () => {
|
||||
await Promise.all([zielBereit(zielA), zielBereit(zielB)]);
|
||||
aufraeumen = masterKeyAnlegen();
|
||||
});
|
||||
afterAll(async () => {
|
||||
aufraeumen();
|
||||
await warteschlangeStoppen();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await datenbankLeeren();
|
||||
await deploymentKeySicherstellen();
|
||||
const reset = "userdel -r sshkv 2>/dev/null; rm -f /etc/sudoers.d/sshkv*; rm -rf /root/.ssh; true";
|
||||
await Promise.all([befehlAlsRoot(zielA, reset), befehlAlsRoot(zielB, reset)]);
|
||||
AKTEUR.id = (await prisma.benutzer.create({ data: { name: "Test-Admin", rolle: "ADMIN" } })).id;
|
||||
});
|
||||
|
||||
const schritt = (s: "erzeugen" | "pruefen" | "abbrechen" | "abschliessen") =>
|
||||
vorgangLaufen("ROTATION", { eingabe: { schritt: s }, angefordertVonId: AKTEUR.id });
|
||||
|
||||
async function trockenlaufUndAusrollen() {
|
||||
const trockenlauf = await vorgangLaufen("TROCKENLAUF", {
|
||||
eingabe: { ziele: await instanzZiele() },
|
||||
angefordertVonId: AKTEUR.id,
|
||||
});
|
||||
expect(trockenlauf.status).toBe("ERLEDIGT");
|
||||
const ausrollen = await vorgangLaufen("AUSROLLEN", {
|
||||
eingabe: { trockenlaufId: trockenlauf.id },
|
||||
angefordertVonId: AKTEUR.id,
|
||||
});
|
||||
expect(ausrollen.fehler).toBeNull();
|
||||
}
|
||||
|
||||
type Host = Awaited<ReturnType<typeof eingerichteterHost>>;
|
||||
|
||||
async function fingerprintsAuf(host: Host): Promise<string[]> {
|
||||
const verbindung = await hostVerbinden(host);
|
||||
try {
|
||||
const datei = await authorizedKeysHolen(verbindung, host, anmeldebenutzerVon(host));
|
||||
return authorizedKeysLesen(datei.inhalt).zeilen.flatMap((z) =>
|
||||
z.art === "schluessel" ? [z.fingerprint] : [],
|
||||
);
|
||||
} finally {
|
||||
verbindung.end();
|
||||
}
|
||||
}
|
||||
|
||||
async function anmeldungKlappt(host: Host, schluesselId: string): Promise<boolean> {
|
||||
const privateKey = await privateKeyLaden(schluesselId);
|
||||
try {
|
||||
const verbindung = await hostVerbinden(host, { benutzer: anmeldebenutzerVon(host), privateKey });
|
||||
verbindung.end();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
privateKey.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
describe("Rotation des Deployment-Keys", () => {
|
||||
it("tauscht den Key auf allen Hosts ohne Unterbrechung aus", async () => {
|
||||
const a = await eingerichteterHost(zielA, "ziel-a");
|
||||
const b = await eingerichteterHost(zielB, "ziel-b", "ROOT");
|
||||
const alt = await prisma.schluessel.findFirstOrThrow({ where: { instanzStandard: true } });
|
||||
|
||||
expect((await schritt("erzeugen")).status).toBe("ERLEDIGT");
|
||||
const neu = (await rotationsStand()).nachfolger!;
|
||||
expect(neu.vorgaengerId).toBe(alt.id);
|
||||
// Ein zweites Erzeugen waehrend der Rotation wird abgelehnt.
|
||||
expect((await schritt("erzeugen")).status).toBe("FEHLGESCHLAGEN");
|
||||
|
||||
// Vor dem Verteilen scheitert die Pruefung – und ohne erfolgreiche Pruefung kein Umstellen.
|
||||
await schritt("pruefen");
|
||||
expect((await rotationsStand()).pruefung?.every((p) => !p.ok)).toBe(true);
|
||||
await expect(umstellen(AKTEUR)).rejects.toThrow(DeploymentKeyFehler);
|
||||
|
||||
await trockenlaufUndAusrollen();
|
||||
for (const host of [a, b]) {
|
||||
expect(await fingerprintsAuf(host)).toEqual(expect.arrayContaining([alt.fingerprint, neu.fingerprint]));
|
||||
}
|
||||
await schritt("pruefen");
|
||||
expect((await rotationsStand()).pruefung?.map((p) => p.ok)).toEqual([true, true]);
|
||||
|
||||
await umstellen(AKTEUR);
|
||||
const umgestellt = await rotationsStand();
|
||||
expect(umgestellt.aktuell?.id).toBe(neu.id);
|
||||
expect(umgestellt.phase).toBe("aufraeumen");
|
||||
// Abschliessen geht erst, wenn der alte Key ueberall entfernt ist.
|
||||
expect((await schritt("abschliessen")).fehler).toMatch(/steht noch auf/);
|
||||
|
||||
await trockenlaufUndAusrollen();
|
||||
for (const host of [a, b]) {
|
||||
const fingerprints = await fingerprintsAuf(host);
|
||||
expect(fingerprints).toContain(neu.fingerprint);
|
||||
expect(fingerprints).not.toContain(alt.fingerprint);
|
||||
expect(await anmeldungKlappt(host, alt.id)).toBe(false);
|
||||
expect(await anmeldungKlappt(host, neu.id)).toBe(true);
|
||||
}
|
||||
|
||||
expect((await schritt("abschliessen")).status).toBe("ERLEDIGT");
|
||||
const verworfen = await prisma.schluessel.findUniqueOrThrow({ where: { id: alt.id } });
|
||||
expect(verworfen.status).toBe("ROTIERT");
|
||||
expect(verworfen.privatGeloeschtAm).not.toBeNull();
|
||||
expect((await rotationsStand()).phase).toBe("bereit");
|
||||
|
||||
// Der Alltag geht mit dem neuen Key weiter.
|
||||
const abgleich = await vorgangLaufen("ABGLEICH", { hostId: a.id });
|
||||
expect(abgleich.status).toBe("ERLEDIGT");
|
||||
expect(await prisma.abweichung.count({ where: { hostId: a.id, behobenAm: null } })).toBe(0);
|
||||
});
|
||||
|
||||
it("bricht vor dem Umstellen ab und laesst danach eine neue Rotation zu (#036)", async () => {
|
||||
const a = await eingerichteterHost(zielA, "ziel-a");
|
||||
const alt = await prisma.schluessel.findFirstOrThrow({ where: { instanzStandard: true } });
|
||||
expect((await schritt("erzeugen")).status).toBe("ERLEDIGT");
|
||||
const neu = (await rotationsStand()).nachfolger!;
|
||||
await trockenlaufUndAusrollen();
|
||||
expect(await fingerprintsAuf(a)).toContain(neu.fingerprint);
|
||||
|
||||
expect((await schritt("abbrechen")).status).toBe("ERLEDIGT");
|
||||
const verworfen = await prisma.schluessel.findUniqueOrThrow({ where: { id: neu.id } });
|
||||
expect(verworfen).toMatchObject({ status: "WIDERRUFEN", privatVerschluesselt: null });
|
||||
expect(await rotationsStand()).toMatchObject({ phase: "bereit", abgebrochen: { id: neu.id } });
|
||||
// Ohne laufende Rotation gibt es nichts mehr abzubrechen.
|
||||
expect((await schritt("abbrechen")).status).toBe("FEHLGESCHLAGEN");
|
||||
|
||||
// Der Dry-Run nimmt den verworfenen Key wieder heraus; der alte arbeitet unveraendert.
|
||||
await trockenlaufUndAusrollen();
|
||||
const fingerprints = await fingerprintsAuf(a);
|
||||
expect(fingerprints).toContain(alt.fingerprint);
|
||||
expect(fingerprints).not.toContain(neu.fingerprint);
|
||||
expect(await anmeldungKlappt(a, alt.id)).toBe(true);
|
||||
|
||||
// Eine neue Rotation beginnt von vorn.
|
||||
expect((await schritt("erzeugen")).status).toBe("ERLEDIGT");
|
||||
const zweiter = (await rotationsStand()).nachfolger!;
|
||||
expect(zweiter.id).not.toBe(neu.id);
|
||||
expect(zweiter.vorgaengerId).toBe(alt.id);
|
||||
});
|
||||
|
||||
it("laesst Hosts mit eigenem Deployment-Key aus", async () => {
|
||||
const a = await eingerichteterHost(zielA, "ziel-a");
|
||||
const b = await eingerichteterHost(zielB, "ziel-b");
|
||||
const eigener = await prisma.schluessel.findFirstOrThrow({ where: { instanzStandard: true } });
|
||||
// b bekommt (formal) einen eigenen Key – derselbe genuegt, es zaehlt die Zuordnung.
|
||||
await prisma.host.update({ where: { id: b.id }, data: { deploymentSchluesselId: eigener.id } });
|
||||
expect((await instanzZiele()).map((z) => z.hostId)).toEqual([a.id]);
|
||||
});
|
||||
});
|
||||
@@ -29,7 +29,8 @@ export async function vorgangLaufen(
|
||||
| "VERBINDUNGSTEST"
|
||||
| "ZIELKONTO_ANLEGEN"
|
||||
| "ZIELKONTO_ENTFERNEN"
|
||||
| "ABGLEICH",
|
||||
| "ABGLEICH"
|
||||
| "ROTATION",
|
||||
daten: {
|
||||
hostId?: string;
|
||||
eingabe?: object;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { rotationsDatum } from "@/lib/rotation";
|
||||
import {
|
||||
rotationAbbrechen,
|
||||
rotationAltWiderrufen,
|
||||
rotationStarten,
|
||||
rotationUebernehmen,
|
||||
@@ -55,10 +56,8 @@ async function trockenlaufUndAusrollen(schluesselId: string) {
|
||||
expect(ausrollen.fehler).toBeNull();
|
||||
}
|
||||
|
||||
describe("Rotation", () => {
|
||||
it("tauscht den Schluessel aus, ohne dass der Alias aufhoert zu funktionieren", async () => {
|
||||
const a = await eingerichteterHost(zielA, "ziel-a");
|
||||
const b = await eingerichteterHost(zielB, "ziel-b");
|
||||
/** Dienst-Key wie nach dem Assistenten: Private Key auf A (Alias zu-b), Public Key auf B, ausgerollt. */
|
||||
async function alterSchluesselAusgerollt(aId: string, bId: string) {
|
||||
const paar = await schluesselpaarVersiegeltErzeugen("ED25519", "sshkv:backup");
|
||||
const alt = await prisma.schluessel.create({
|
||||
data: {
|
||||
@@ -77,16 +76,24 @@ describe("Rotation", () => {
|
||||
await privateVerwendungAnlegen(
|
||||
{
|
||||
schluesselId: alt.id,
|
||||
hostId: a.id,
|
||||
hostId: aId,
|
||||
zielbenutzer: "root",
|
||||
pfad: "~/.ssh/id_backup",
|
||||
sshConfig: { alias: "zu-b", hostName: "ziel-b", benutzer: "root", port: 22 },
|
||||
knownHostsFuer: [b.id],
|
||||
knownHostsFuer: [bId],
|
||||
},
|
||||
AKTEUR,
|
||||
);
|
||||
await publicVerwendungAnlegen({ schluesselId: alt.id, hostId: b.id, zielbenutzer: "root" }, AKTEUR);
|
||||
await publicVerwendungAnlegen({ schluesselId: alt.id, hostId: bId, zielbenutzer: "root" }, AKTEUR);
|
||||
await trockenlaufUndAusrollen(alt.id);
|
||||
return alt;
|
||||
}
|
||||
|
||||
describe("Rotation", () => {
|
||||
it("tauscht den Schluessel aus, ohne dass der Alias aufhoert zu funktionieren", async () => {
|
||||
const a = await eingerichteterHost(zielA, "ziel-a");
|
||||
const b = await eingerichteterHost(zielB, "ziel-b");
|
||||
const alt = await alterSchluesselAusgerollt(a.id, b.id);
|
||||
|
||||
// 1–2: Nachfolger erzeugen, Zuweisungen uebernehmen.
|
||||
await vorgangImWorker(await rotationStarten(alt.id, AKTEUR));
|
||||
@@ -123,4 +130,51 @@ describe("Rotation", () => {
|
||||
const ssh = await befehlAlsRoot(zielA, "ssh -o BatchMode=yes -o StrictHostKeyChecking=yes zu-b id -un");
|
||||
expect(ssh.stdout.trim()).toBe("root");
|
||||
});
|
||||
|
||||
it("bricht ab: der ausgerollte neue Schluessel verschwindet, der alte arbeitet weiter (#032)", async () => {
|
||||
const a = await eingerichteterHost(zielA, "ziel-a");
|
||||
const b = await eingerichteterHost(zielB, "ziel-b");
|
||||
const alt = await alterSchluesselAusgerollt(a.id, b.id);
|
||||
const altVorher = await prisma.schluessel.findUniqueOrThrow({ where: { id: alt.id } });
|
||||
|
||||
await vorgangImWorker(await rotationStarten(alt.id, AKTEUR));
|
||||
await rotationUebernehmen(alt.id, AKTEUR, { privatErlaubt: true });
|
||||
const neu = await prisma.schluessel.findFirstOrThrow({
|
||||
where: { vorgaengerId: alt.id },
|
||||
select: { id: true, publicKey: true },
|
||||
});
|
||||
await trockenlaufUndAusrollen(neu.id);
|
||||
const neuerPfad = `id_backup-r${rotationsDatum().kompakt}`;
|
||||
expect((await befehlAlsRoot(zielA, "cat /root/.ssh/config")).stdout).toContain(
|
||||
`IdentityFile ~/.ssh/${neuerPfad}`,
|
||||
);
|
||||
|
||||
// Abbrechen = Widerruf des neuen Schluessels mit Dry-Run und Anwenden.
|
||||
await rotationAbbrechen(alt.id, AKTEUR, { privatErlaubt: true });
|
||||
await trockenlaufUndAusrollen(neu.id);
|
||||
expect((await widerrufAbschliessen([neu.id], AKTEUR))[0]?.status).toBe("WIDERRUFEN");
|
||||
|
||||
const authorized = (await befehlAlsRoot(zielB, "cat /root/.ssh/authorized_keys")).stdout;
|
||||
expect(authorized).not.toContain(neu.publicKey.split(" ")[1]!);
|
||||
expect(authorized).toContain(alt.publicKey.split(" ")[1]!);
|
||||
const dateien = (await befehlAlsRoot(zielA, "ls /root/.ssh")).stdout.split("\n");
|
||||
expect(dateien).toContain("id_backup");
|
||||
expect(dateien).not.toContain(neuerPfad);
|
||||
// Der Alias zeigt wieder auf die alte Datei – und funktioniert.
|
||||
const config = (await befehlAlsRoot(zielA, "cat /root/.ssh/config")).stdout;
|
||||
expect(config.match(/^Host zu-b$/gm)).toHaveLength(1);
|
||||
expect(config).toContain("IdentityFile ~/.ssh/id_backup\n");
|
||||
const ssh = await befehlAlsRoot(zielA, "ssh -o BatchMode=yes -o StrictHostKeyChecking=yes zu-b id -un");
|
||||
expect(ssh.stdout.trim()).toBe("root");
|
||||
|
||||
// Der alte Schluessel ist unveraendert, seine Zuweisungen sind ausgerollt.
|
||||
expect(await prisma.schluessel.findUniqueOrThrow({ where: { id: alt.id } })).toEqual(altVorher);
|
||||
const zustaende = [
|
||||
...(await prisma.publicVerwendung.findMany({ where: { schluesselId: alt.id } })),
|
||||
...(await prisma.privateVerwendung.findMany({ where: { schluesselId: alt.id } })),
|
||||
].map((v) => v.zustand);
|
||||
expect(zustaende).toEqual(["AUSGEROLLT", "AUSGEROLLT"]);
|
||||
// Abbruch abgeschlossen: die Rotation steht wieder am Anfang (#033).
|
||||
expect(await rotationsStand(alt.id)).toMatchObject({ neu: null, schritt: 0, abgebrochen: false });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
anmeldenameSchema,
|
||||
einmalPasswortErzeugen,
|
||||
lokaleKontenAktiv,
|
||||
passwortFehler,
|
||||
} from "@/lib/lokale-konten";
|
||||
import { passwortHashen } from "@/lib/passwort-hash";
|
||||
import { base32Dekodieren, totpCode, totpGeheimnisErzeugen, totpSchritt } from "@/lib/totp";
|
||||
import {
|
||||
AnmeldeFehler,
|
||||
eigenesPasswortSetzen,
|
||||
kontoAnmelden,
|
||||
totpEinrichtungAbschliessen,
|
||||
} from "@/server/services/anmeldung";
|
||||
import {
|
||||
kontoAktivSetzen,
|
||||
kontoZuruecksetzen,
|
||||
lokalesKontoAnlegen,
|
||||
rolleAendern,
|
||||
} from "@/server/services/benutzer";
|
||||
import { FachFehler } from "@/server/services/fachfehler";
|
||||
import { datenbankLeeren } from "./hilfen/db";
|
||||
|
||||
/** Lokale Konten (#035): Regeln, erste Anmeldung, Verwaltung, Abschalten. */
|
||||
|
||||
const EIGENES = "ein-eigenes-langes-passwort";
|
||||
let admin: { id: string; name: string };
|
||||
|
||||
beforeEach(async () => {
|
||||
await datenbankLeeren();
|
||||
delete process.env["SSHKV_LOKALE_KONTEN"];
|
||||
admin = await prisma.benutzer.create({
|
||||
data: { name: "Ada Admin", rolle: "ADMIN", oidcSub: "ada" },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
delete process.env["SSHKV_LOKALE_KONTEN"];
|
||||
});
|
||||
|
||||
const code = (geheimnis: string, zeit: Date) =>
|
||||
totpCode(base32Dekodieren(geheimnis), totpSchritt(zeit.getTime()));
|
||||
|
||||
/** Anlegen, Einmal-Passwort ersetzen, TOTP einrichten – wie bei der ersten Anmeldung. */
|
||||
async function eingerichtetesKonto(anmeldename = "anna") {
|
||||
const { id, einmalPasswort } = await lokalesKontoAnlegen(
|
||||
{ anmeldename, name: "Anna", rolle: "OPERATOR" },
|
||||
admin,
|
||||
);
|
||||
expect((await kontoAnmelden(anmeldename, einmalPasswort, "")).art).toBe("passwortSetzen");
|
||||
await eigenesPasswortSetzen(id, EIGENES);
|
||||
expect((await kontoAnmelden(anmeldename, EIGENES, "")).art).toBe("totpEinrichten");
|
||||
const geheimnis = totpGeheimnisErzeugen();
|
||||
const t0 = new Date("2026-10-12T10:00:00Z");
|
||||
await totpEinrichtungAbschliessen(id, geheimnis, code(geheimnis, t0), t0);
|
||||
return { id, geheimnis, einmalPasswort };
|
||||
}
|
||||
|
||||
describe("Regeln", () => {
|
||||
it("erzeugt gut abtippbare, verschiedene Einmal-Passwoerter", () => {
|
||||
const pw = einmalPasswortErzeugen();
|
||||
expect(pw).toMatch(/^[a-zA-Z2-9]{5}(-[a-zA-Z2-9]{5}){3}$/);
|
||||
expect(pw).not.toMatch(/[01OlI]/);
|
||||
expect(new Set(Array.from({ length: 50 }, einmalPasswortErzeugen)).size).toBe(50);
|
||||
});
|
||||
|
||||
it("prueft Anmeldename und Passwort", () => {
|
||||
expect(anmeldenameSchema.parse(" Anna.Muster ")).toBe("anna.muster");
|
||||
expect(anmeldenameSchema.safeParse("1anna").success).toBe(false);
|
||||
expect(anmeldenameSchema.safeParse("a").success).toBe(false);
|
||||
expect(anmeldenameSchema.safeParse("anna muster").success).toBe(false);
|
||||
expect(passwortFehler("kurz")).toMatch(/12 Zeichen/);
|
||||
expect(passwortFehler("xx-anna-xx-geheim", "Anna")).toMatch(/Anmeldenamen/);
|
||||
expect(passwortFehler(EIGENES, "anna")).toBeNull();
|
||||
});
|
||||
|
||||
it("schaltet lokale Konten nur mit ausdruecklichem Aus ab", () => {
|
||||
expect(lokaleKontenAktiv({})).toBe(true);
|
||||
expect(lokaleKontenAktiv({ SSHKV_LOKALE_KONTEN: "1" })).toBe(true);
|
||||
for (const aus of ["0", "false", "nein", "AUS"])
|
||||
expect(lokaleKontenAktiv({ SSHKV_LOKALE_KONTEN: aus })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Erste Anmeldung", () => {
|
||||
it("verlangt erst ein eigenes Passwort, dann TOTP, dann geht es hinein", async () => {
|
||||
const { id, geheimnis, einmalPasswort } = await eingerichtetesKonto();
|
||||
const t1 = new Date("2026-10-12T10:01:00Z");
|
||||
// Das Einmal-Passwort gilt nicht mehr, Gross-/Kleinschreibung des Namens egal.
|
||||
await expect(kontoAnmelden("anna", einmalPasswort, code(geheimnis, t1), t1)).rejects.toThrow(
|
||||
AnmeldeFehler,
|
||||
);
|
||||
const ergebnis = await kontoAnmelden("Anna", EIGENES, code(geheimnis, t1), t1);
|
||||
expect(ergebnis).toMatchObject({ art: "angemeldet", benutzer: { id, rolle: "OPERATOR" } });
|
||||
expect(await prisma.protokoll.count({ where: { aktion: "anmeldung.lokal" } })).toBe(1);
|
||||
});
|
||||
|
||||
it("lehnt schwache und unveraenderte Passwoerter ab und laesst nichts im Protokoll", async () => {
|
||||
const { id, einmalPasswort } = await lokalesKontoAnlegen(
|
||||
{ anmeldename: "bert", name: "Bert", rolle: "LESER" },
|
||||
admin,
|
||||
);
|
||||
await expect(eigenesPasswortSetzen(id, "zu-kurz")).rejects.toThrow(/12 Zeichen/);
|
||||
await expect(eigenesPasswortSetzen(id, einmalPasswort)).rejects.toThrow(/anderes Passwort/);
|
||||
await eigenesPasswortSetzen(id, EIGENES);
|
||||
// Kein zweites Mal ohne Zuruecksetzen.
|
||||
await expect(eigenesPasswortSetzen(id, `${EIGENES}-2`)).rejects.toThrow(/kein Passwortwechsel/);
|
||||
const protokoll = JSON.stringify(await prisma.protokoll.findMany(), (_k, v: unknown) =>
|
||||
typeof v === "bigint" ? v.toString() : v,
|
||||
);
|
||||
expect(protokoll).not.toContain(einmalPasswort);
|
||||
expect(protokoll).not.toContain(EIGENES);
|
||||
});
|
||||
|
||||
it("kennt keinen doppelten Anmeldenamen – auch nicht den des Notfall-Admins", async () => {
|
||||
await prisma.benutzer.create({
|
||||
data: { anmeldename: "notfall", name: "Notfall", rolle: "ADMIN", istNotfall: true, passwortHash: "x" },
|
||||
});
|
||||
await lokalesKontoAnlegen({ anmeldename: "anna", name: "Anna", rolle: "LESER" }, admin);
|
||||
for (const name of ["anna", "notfall"]) {
|
||||
await expect(
|
||||
lokalesKontoAnlegen({ anmeldename: name, name: "X", rolle: "LESER" }, admin),
|
||||
).rejects.toThrow(FachFehler);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Verwaltung", () => {
|
||||
it("aendert Rollen nur bei lokalen Konten und nie am eigenen", async () => {
|
||||
const { id } = await eingerichtetesKonto();
|
||||
await rolleAendern(id, "ADMIN", admin);
|
||||
expect((await prisma.benutzer.findUniqueOrThrow({ where: { id } })).rolle).toBe("ADMIN");
|
||||
// OIDC-Konto: Rolle kommt aus den Gruppen.
|
||||
await expect(rolleAendern(admin.id, "LESER", { id: id, name: "Anna" })).rejects.toThrow(/Gruppen/);
|
||||
await expect(rolleAendern(id, "LESER", { id, name: "Anna" })).rejects.toThrow(/eigene Konto/);
|
||||
const notfall = await prisma.benutzer.create({
|
||||
data: { anmeldename: "notfall", name: "Notfall", rolle: "ADMIN", istNotfall: true, passwortHash: "x" },
|
||||
});
|
||||
await expect(rolleAendern(notfall.id, "LESER", admin)).rejects.toThrow(/Notfall-Admin/);
|
||||
await expect(kontoAktivSetzen(notfall.id, false, admin)).rejects.toThrow(/Notfall-Admin/);
|
||||
});
|
||||
|
||||
it("sperrt sofort: Sitzungen enden, Anmeldung scheitert", async () => {
|
||||
const { id, geheimnis } = await eingerichtetesKonto();
|
||||
const vorher = (await prisma.benutzer.findUniqueOrThrow({ where: { id } })).sitzungenGueltigAb;
|
||||
await kontoAktivSetzen(id, false, admin);
|
||||
const gesperrt = await prisma.benutzer.findUniqueOrThrow({ where: { id } });
|
||||
expect(gesperrt.aktiv).toBe(false);
|
||||
expect(gesperrt.sitzungenGueltigAb.getTime()).toBeGreaterThan(vorher.getTime());
|
||||
const t = new Date("2026-10-12T10:02:00Z");
|
||||
await expect(kontoAnmelden("anna", EIGENES, code(geheimnis, t), t)).rejects.toThrow(AnmeldeFehler);
|
||||
await kontoAktivSetzen(id, true, admin);
|
||||
expect((await kontoAnmelden("anna", EIGENES, code(geheimnis, t), t)).art).toBe("angemeldet");
|
||||
});
|
||||
|
||||
it("setzt Passwort und TOTP zurueck", async () => {
|
||||
const { id } = await eingerichtetesKonto();
|
||||
const { einmalPasswort } = await kontoZuruecksetzen(id, admin);
|
||||
const konto = await prisma.benutzer.findUniqueOrThrow({ where: { id } });
|
||||
expect(konto).toMatchObject({
|
||||
passwortWechselNoetig: true,
|
||||
totpGeheimnis: null,
|
||||
totpEingerichtetAm: null,
|
||||
});
|
||||
expect((await kontoAnmelden("anna", einmalPasswort, "")).art).toBe("passwortSetzen");
|
||||
await expect(kontoZuruecksetzen(admin.id, { id, name: "Anna" })).rejects.toThrow(/lokale Konten/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Abgeschaltet (SSHKV_LOKALE_KONTEN=0)", () => {
|
||||
it("laesst lokale Konten nicht mehr hinein, den Notfall-Admin schon", async () => {
|
||||
const { geheimnis } = await eingerichtetesKonto();
|
||||
await prisma.benutzer.create({
|
||||
data: {
|
||||
anmeldename: "notfall",
|
||||
name: "Notfall",
|
||||
rolle: "ADMIN",
|
||||
istNotfall: true,
|
||||
passwortHash: await passwortHashen("notfall-test-passwort-123"),
|
||||
},
|
||||
});
|
||||
process.env["SSHKV_LOKALE_KONTEN"] = "0";
|
||||
const t = new Date("2026-10-12T10:03:00Z");
|
||||
await expect(kontoAnmelden("anna", EIGENES, code(geheimnis, t), t)).rejects.toThrow(AnmeldeFehler);
|
||||
expect((await kontoAnmelden("notfall", "notfall-test-passwort-123", "")).art).toBe("totpEinrichten");
|
||||
await expect(
|
||||
lokalesKontoAnlegen({ anmeldename: "carl", name: "Carl", rolle: "LESER" }, admin),
|
||||
).rejects.toThrow(/abgeschaltet/);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import { passwortHashen } from "@/lib/passwort-hash";
|
||||
import { base32Dekodieren, totpCode, totpGeheimnisErzeugen, totpSchritt } from "@/lib/totp";
|
||||
import {
|
||||
AnmeldeFehler,
|
||||
notfallAnmelden,
|
||||
kontoAnmelden,
|
||||
oidcBenutzerUebernehmen,
|
||||
totpEinrichtungAbschliessen,
|
||||
} from "@/server/services/anmeldung";
|
||||
@@ -37,7 +37,7 @@ beforeEach(async () => {
|
||||
describe("Notfall-Admin", () => {
|
||||
it("fuehrt beim ersten Mal zur TOTP-Einrichtung, nicht in die Anwendung", async () => {
|
||||
await notfallAnlegen();
|
||||
const ergebnis = await notfallAnmelden("notfall", PASSWORT, "");
|
||||
const ergebnis = await kontoAnmelden("notfall", PASSWORT, "");
|
||||
expect(ergebnis.art).toBe("totpEinrichten");
|
||||
});
|
||||
|
||||
@@ -56,33 +56,33 @@ describe("Notfall-Admin", () => {
|
||||
expect(Buffer.from(gespeichert.totpGeheimnis ?? []).toString("utf8")).not.toContain(geheimnis);
|
||||
|
||||
const t1 = new Date(t0.getTime() + 60_000);
|
||||
await expect(notfallAnmelden("notfall", PASSWORT, "123456", t1)).rejects.toThrow(AnmeldeFehler);
|
||||
const ergebnis = await notfallAnmelden("notfall", PASSWORT, codeZu(geheimnis, t1), t1);
|
||||
await expect(kontoAnmelden("notfall", PASSWORT, "123456", t1)).rejects.toThrow(AnmeldeFehler);
|
||||
const ergebnis = await kontoAnmelden("notfall", PASSWORT, codeZu(geheimnis, t1), t1);
|
||||
expect(ergebnis.art).toBe("angemeldet");
|
||||
// Derselbe Code ein zweites Mal: abgelehnt.
|
||||
await expect(notfallAnmelden("notfall", PASSWORT, codeZu(geheimnis, t1), t1)).rejects.toThrow(
|
||||
await expect(kontoAnmelden("notfall", PASSWORT, codeZu(geheimnis, t1), t1)).rejects.toThrow(
|
||||
AnmeldeFehler,
|
||||
);
|
||||
});
|
||||
|
||||
it("verraet nicht, ob Name oder Passwort falsch war", async () => {
|
||||
await notfallAnlegen();
|
||||
const a = await notfallAnmelden("gibtsnicht", PASSWORT, "").catch((f: Error) => f.message);
|
||||
const b = await notfallAnmelden("notfall", "falsch-falsch-falsch", "").catch((f: Error) => f.message);
|
||||
const a = await kontoAnmelden("gibtsnicht", PASSWORT, "").catch((f: Error) => f.message);
|
||||
const b = await kontoAnmelden("notfall", "falsch-falsch-falsch", "").catch((f: Error) => f.message);
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("sperrt nach fuenf Fehlversuchen, auch fuer das richtige Passwort", async () => {
|
||||
await notfallAnlegen();
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
await expect(notfallAnmelden("notfall", "falsch-falsch-falsch", "")).rejects.toThrow();
|
||||
await expect(kontoAnmelden("notfall", "falsch-falsch-falsch", "")).rejects.toThrow();
|
||||
}
|
||||
await expect(notfallAnmelden("notfall", PASSWORT, "")).rejects.toThrow(/Zu viele Fehlversuche/);
|
||||
await expect(kontoAnmelden("notfall", PASSWORT, "")).rejects.toThrow(/Zu viele Fehlversuche/);
|
||||
});
|
||||
|
||||
it("protokolliert Fehlversuche ohne Passwort", async () => {
|
||||
await notfallAnlegen();
|
||||
await notfallAnmelden("notfall", "geheimes-falsches-pw", "").catch(() => undefined);
|
||||
await kontoAnmelden("notfall", "geheimes-falsches-pw", "").catch(() => undefined);
|
||||
const eintraege = await prisma.protokoll.findMany();
|
||||
expect(eintraege.map((e) => e.aktion)).toContain("anmeldung.notfall.fehlgeschlagen");
|
||||
expect(
|
||||
|
||||
@@ -75,7 +75,7 @@ beforeEach(async () => {
|
||||
|
||||
describe("protokollSeite", () => {
|
||||
it("blaettert ueber die ID, neueste zuerst, ohne Luecken und Doppelte", async () => {
|
||||
// IDs sind nicht lueckenlos (der Trigger zieht die Sequenz selbst) – nur die Ordnung zaehlt.
|
||||
// IDs koennen Luecken haben (Rollbacks, alte Zeilen vor #032) – nur die Ordnung zaehlt.
|
||||
const ids: string[] = [];
|
||||
for (let i = 0; i < SEITENGROESSE + 7; i += 1)
|
||||
ids.unshift(String((await eintragen({ aktion: "host.angelegt" })).id));
|
||||
|
||||
+152
-1
@@ -8,13 +8,14 @@ import { rotationsDatum, rotationsName, rotationsPfad } from "@/lib/rotation";
|
||||
import { rotationsKette } from "@/server/queries/schluessel";
|
||||
import {
|
||||
RotationsFehler,
|
||||
rotationAbbrechen,
|
||||
rotationAltWiderrufen,
|
||||
rotationGesperrt,
|
||||
rotationStarten,
|
||||
rotationUebernehmen,
|
||||
rotationsStand,
|
||||
} from "@/server/services/rotation";
|
||||
import { widerrufAbschliessen } from "@/server/services/widerruf";
|
||||
import { widerrufAbschliessen, widerrufEinleiten } from "@/server/services/widerruf";
|
||||
import { masterKeyVergessen } from "@/worker/geheimnisse";
|
||||
import { datenbankLeeren } from "./hilfen/db";
|
||||
import { datenbankAlsText, vorgangImWorker, warteschlangeStoppen } from "./hilfen/vorgang";
|
||||
@@ -96,8 +97,10 @@ async function alterSchluessel() {
|
||||
async function nachfolgerErzeugen(altId: string): Promise<string> {
|
||||
const vorgangId = await rotationStarten(altId, admin);
|
||||
await vorgangImWorker(vorgangId);
|
||||
// Nach einem Abbruch gibt es mehrere Nachfolger; der juengste ist der gerade erzeugte (#033).
|
||||
const neu = await prisma.schluessel.findFirstOrThrow({
|
||||
where: { vorgaengerId: altId },
|
||||
orderBy: { angelegtAm: "desc" },
|
||||
select: { id: true },
|
||||
});
|
||||
return neu.id;
|
||||
@@ -258,3 +261,151 @@ describe("Rotation Schritt fuer Schritt", () => {
|
||||
await expect(rotationStarten(person.id, admin)).rejects.toThrow(/neuen Public Key/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Rotation abbrechen (#032)", () => {
|
||||
/** Rotation bis Schritt 3: Nachfolger uebernommen und ausgerollt. */
|
||||
async function laufendeRotation() {
|
||||
const altId = await alterSchluessel();
|
||||
const neuId = await nachfolgerErzeugen(altId);
|
||||
await rotationUebernehmen(altId, admin, { privatErlaubt: true });
|
||||
const jetzt = new Date();
|
||||
await prisma.publicVerwendung.updateMany({
|
||||
where: { schluesselId: neuId },
|
||||
data: { zustand: "AUSGEROLLT", zuletztAngewendetAm: jetzt },
|
||||
});
|
||||
await prisma.privateVerwendung.updateMany({
|
||||
where: { schluesselId: neuId },
|
||||
data: { zustand: "AUSGEROLLT", zuletztAngewendetAm: jetzt },
|
||||
});
|
||||
return { altId, neuId };
|
||||
}
|
||||
|
||||
async function verwendungen(schluesselId: string) {
|
||||
const [oeffentlich, privat] = await Promise.all([
|
||||
prisma.publicVerwendung.findMany({ where: { schluesselId }, select: { zustand: true } }),
|
||||
prisma.privateVerwendung.findMany({ where: { schluesselId }, select: { zustand: true } }),
|
||||
]);
|
||||
return [...oeffentlich, ...privat].map((v) => v.zustand).sort();
|
||||
}
|
||||
|
||||
it("widerruft den neuen Schluessel, der alte bleibt unveraendert", async () => {
|
||||
const { altId, neuId } = await laufendeRotation();
|
||||
const altVorher = await prisma.schluessel.findUniqueOrThrow({ where: { id: altId } });
|
||||
|
||||
await expect(rotationAbbrechen(altId, admin, { privatErlaubt: false })).rejects.toThrow(/Admin/);
|
||||
await rotationAbbrechen(altId, admin, { privatErlaubt: true });
|
||||
|
||||
// Ausgerollt heisst: erst Dry-Run und Anwenden entfernen ihn – vorgemerkt, nicht sofort weg.
|
||||
expect(await verwendungen(neuId)).toEqual(["ENTFERNEN", "ENTFERNEN"]);
|
||||
expect(await verwendungen(altId)).toEqual(["AUSGEROLLT", "AUSGEROLLT"]);
|
||||
expect(await prisma.schluessel.findUniqueOrThrow({ where: { id: altId } })).toEqual(altVorher);
|
||||
const stand = await rotationsStand(altId);
|
||||
expect(stand.abgebrochen).toBe(true);
|
||||
|
||||
// Kein Weiterdrehen mehr, ein zweiter Abbruch aendert nichts.
|
||||
await expect(rotationUebernehmen(altId, admin, { privatErlaubt: true })).rejects.toThrow(/abgebrochen/);
|
||||
await expect(rotationAltWiderrufen(altId, admin, { privatErlaubt: true })).rejects.toThrow(/abgebrochen/);
|
||||
await rotationAbbrechen(altId, admin, { privatErlaubt: true });
|
||||
expect(await prisma.protokoll.count({ where: { aktion: "schluessel.rotation.abgebrochen" } })).toBe(1);
|
||||
const eintrag = await prisma.protokoll.findFirstOrThrow({
|
||||
where: { aktion: "schluessel.rotation.abgebrochen" },
|
||||
select: { fingerprint: true, details: true },
|
||||
});
|
||||
expect(eintrag).toMatchObject({ fingerprint: "SHA256:alt", details: { nachfolgerId: neuId } });
|
||||
|
||||
// Solange der Abbruch laeuft, keine neue Rotation (#033).
|
||||
await expect(rotationStarten(altId, admin)).rejects.toThrow(/läuft noch/);
|
||||
|
||||
// Nach dem Anwenden: der Nachfolger ist WIDERRUFEN (nicht ROTIERT), die Kette bleibt.
|
||||
await prisma.publicVerwendung.updateMany({
|
||||
where: { schluesselId: neuId },
|
||||
data: { zustand: "ENTFERNT" },
|
||||
});
|
||||
await prisma.privateVerwendung.updateMany({
|
||||
where: { schluesselId: neuId },
|
||||
data: { zustand: "ENTFERNT" },
|
||||
});
|
||||
expect((await widerrufAbschliessen([neuId], admin))[0]?.status).toBe("WIDERRUFEN");
|
||||
expect((await rotationsKette(altId)).map((g) => [g.id, g.status])).toEqual([
|
||||
[altId, "AKTIV"],
|
||||
[neuId, "WIDERRUFEN"],
|
||||
]);
|
||||
expect(
|
||||
(await prisma.schluessel.findUniqueOrThrow({ where: { id: neuId }, select: { vorgaengerId: true } }))
|
||||
.vorgaengerId,
|
||||
).toBe(altId);
|
||||
|
||||
// Danach laesst sich derselbe Schluessel erneut rotieren (#033).
|
||||
expect(await rotationsStand(altId)).toMatchObject({ neu: null, schritt: 0, abgebrochen: false });
|
||||
const neu2 = await nachfolgerErzeugen(altId);
|
||||
expect(neu2).not.toBe(neuId);
|
||||
expect(await rotationsStand(altId)).toMatchObject({ neu: { id: neu2 }, schritt: 1, abgebrochen: false });
|
||||
expect((await rotationsKette(altId)).map((g) => g.id)).toEqual([altId, neu2]);
|
||||
await expect(rotationStarten(altId, admin)).rejects.toThrow(/schon einen Nachfolger/);
|
||||
});
|
||||
|
||||
it("erlaubt in der Datenbank nur einen offenen Nachfolger je Schluessel (#033)", async () => {
|
||||
const altId = await alterSchluessel();
|
||||
const nachfolger = (n: string, daten: object = {}) =>
|
||||
prisma.schluessel.create({
|
||||
data: {
|
||||
name: n,
|
||||
slug: n,
|
||||
typ: "ED25519",
|
||||
kategorie: "DIENST",
|
||||
publicKey: zeile("ssh-ed25519", ed25519Blob(), n),
|
||||
fingerprint: `SHA256:${n}`,
|
||||
kommentar: n,
|
||||
vorgaengerId: altId,
|
||||
...daten,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
await nachfolger("abgebrochen", { status: "WIDERRUFEN", widerrufEingeleitetAm: new Date() });
|
||||
await nachfolger("im-abbruch", { widerrufEingeleitetAm: new Date() });
|
||||
await nachfolger("offen");
|
||||
await expect(nachfolger("zweiter-offen")).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("setzt nie ausgerollte Zuweisungen des neuen Schluessels sofort auf ENTFERNT", async () => {
|
||||
const altId = await alterSchluessel();
|
||||
const neuId = await nachfolgerErzeugen(altId);
|
||||
await rotationUebernehmen(altId, admin, { privatErlaubt: true });
|
||||
await rotationAbbrechen(altId, admin, { privatErlaubt: true });
|
||||
expect(await verwendungen(neuId)).toEqual(["ENTFERNT", "ENTFERNT"]);
|
||||
expect((await widerrufAbschliessen([neuId], admin))[0]?.status).toBe("WIDERRUFEN");
|
||||
expect(await verwendungen(altId)).toEqual(["AUSGEROLLT", "AUSGEROLLT"]);
|
||||
});
|
||||
|
||||
it("geht nicht mehr, sobald der alte Schluessel entfernt wird", async () => {
|
||||
const { altId } = await laufendeRotation();
|
||||
const privat = await prisma.privateVerwendung.findFirstOrThrow({
|
||||
where: { schluessel: { vorgaengerId: altId } },
|
||||
select: { id: true },
|
||||
});
|
||||
await prisma.vorgang.create({
|
||||
data: { art: "VERBINDUNGSTEST", status: "ERLEDIGT", eingabe: { privatVerwendungId: privat.id } },
|
||||
});
|
||||
await rotationAltWiderrufen(altId, admin, { privatErlaubt: true });
|
||||
await expect(rotationAbbrechen(altId, admin, { privatErlaubt: true })).rejects.toThrow(
|
||||
/nicht mehr abbrechen/,
|
||||
);
|
||||
await expect(rotationAbbrechen(b.id, admin, { privatErlaubt: true })).rejects.toThrow(/gibt es nicht/);
|
||||
});
|
||||
|
||||
it("macht aus dem Widerruf des alten Schluessels kein ROTIERT, solange der Abbruch laeuft", async () => {
|
||||
const { altId, neuId } = await laufendeRotation();
|
||||
await rotationAbbrechen(altId, admin, { privatErlaubt: true });
|
||||
await widerrufEinleiten([altId], admin, { privatErlaubt: true });
|
||||
await prisma.publicVerwendung.updateMany({
|
||||
where: { schluesselId: altId },
|
||||
data: { zustand: "ENTFERNT" },
|
||||
});
|
||||
await prisma.privateVerwendung.updateMany({
|
||||
where: { schluesselId: altId },
|
||||
data: { zustand: "ENTFERNT" },
|
||||
});
|
||||
expect((await widerrufAbschliessen([altId], admin))[0]?.status).toBe("WIDERRUFEN");
|
||||
expect((await prisma.schluessel.findUniqueOrThrow({ where: { id: neuId } })).status).toBe("AKTIV");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ablaufZustand,
|
||||
fingerprintKurz,
|
||||
freierSlug,
|
||||
idsAusSuchparameter,
|
||||
slugAus,
|
||||
typAnzeige,
|
||||
erzeugenSchema,
|
||||
@@ -66,6 +67,15 @@ function kern(privateKey: string): string {
|
||||
}
|
||||
|
||||
describe("Regeln (rein)", () => {
|
||||
it("liest IDs aus dem Suchparameter, auch UUIDs vom Worker (#032)", () => {
|
||||
const uuid = "5f703ff1-3877-4af4-af08-84338237fcd8";
|
||||
expect(idsAusSuchparameter([uuid, "cm1abc"])).toEqual([uuid, "cm1abc"]);
|
||||
expect(idsAusSuchparameter(`a,b,a`)).toEqual(["a", "b"]);
|
||||
expect(idsAusSuchparameter(["x y", "../etc", ""])).toEqual([]);
|
||||
expect(idsAusSuchparameter(undefined)).toEqual([]);
|
||||
expect(idsAusSuchparameter(["a", "b", "c"], 2)).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Backup srv-backup01 → srv-pve01", "backup-srv-backup01-srv-pve01"],
|
||||
["Größter Schlüssel für Ärzte", "groesster-schluessel-fuer-aerzte"],
|
||||
|
||||
@@ -10,6 +10,7 @@ import { describe, expect, it } from "vitest";
|
||||
const AUSNAHMEN: Record<string, string> = {
|
||||
"src/app/anmelden/page.tsx": "Anmeldeseite selbst",
|
||||
"src/app/anmelden/totp/page.tsx": "nur mit Einrichtungs-Cookie aus richtigem Passwort erreichbar",
|
||||
"src/app/anmelden/passwort/page.tsx": "nur mit Cookie aus richtigem Einmal-Passwort erreichbar (#035)",
|
||||
"src/app/api/auth/abmelden/route.ts": "Abmelden braucht keine Pruefung",
|
||||
"src/app/api/auth/oidc/start/route.ts": "Beginn der Anmeldung",
|
||||
"src/app/api/auth/oidc/rueckruf/route.ts": "Ende der Anmeldung; prueft state, nonce und PKCE",
|
||||
|
||||
Reference in New Issue
Block a user