6 Commits
Author SHA1 Message Date
florian.wilde d9eec3d991 Merge branch 'phase/034-devlog' 2026-10-12 20:11:40 +02:00
florian.wildeandClaude Opus 5.5 b3787f514c docs(devlog): Sitzung 7 mit #033 und #034 (#034)
Zeiten und Entscheidungen festhalten, bevor die Abnahme beginnt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:11:39 +02:00
florian.wilde 91c4880e40 Merge branch 'phase/034-abnahme-checkliste' 2026-10-12 20:11:16 +02:00
florian.wildeandClaude Opus 5.5 dc202594d3 docs(betrieb): Abnahme-Checkliste fuer 1.0.0 (#034)
Die automatischen Tests laufen gegen Mock-OIDC und Testcontainer.
Echter authentik, echte Hosts und der Betrieb brauchen eine eigene,
abhakbare Prüfung mit erwartetem Ergebnis je Punkt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:11:15 +02:00
florian.wilde 525eddb091 Merge branch 'phase/033-erneut-rotieren' 2026-10-12 20:09:48 +02:00
florian.wildeandClaude Opus 5.5 c06c051637 feat(rotation): nach Abbruch erneut rotieren (#033)
Bisher war vorgaengerId eindeutig: nach einer abgebrochenen Rotation
passte kein zweiter Nachfolger in die Kette, und der Schlüssel ließ sich
nur über Neuanlage, Zuweisen und Widerruf ersetzen.

Jetzt ist die Relation 1:n. Ein partieller Index erlaubt je Vorgänger
nur einen Nachfolger, der weder widerrufen ist noch gerade widerrufen
wird. Maßgeblich ist der jüngste. Erst wenn der Abbruch abgeschlossen
ist, beginnt die Rotation wieder bei Schritt 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:09:42 +02:00
21 changed files with 356 additions and 51 deletions
+1
View File
@@ -11,6 +11,7 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
- 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) - 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) - 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) - „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)
### Geändert ### Geändert
- Protokoll-IDs werden lückenlos vergeben (#032) - Protokoll-IDs werden lückenlos vergeben (#032)
+19 -3
View File
@@ -2,8 +2,8 @@
## Zeitübersicht ## Zeitübersicht
**Gesamt (Wandzeit):** 5 h 20 min **Gesamt (Wandzeit):** 5 h 50 min
**Summe der Issue-Zeiten:** 15 h 13 min – Sub-Agenten arbeiteten parallel an #002, #006, #008, #011, #012–#014, #019 (Oberfläche), #023–#025, #027, #028 und #032. **Summe der Issue-Zeiten:** 15 h 43 min – Sub-Agenten arbeiteten parallel an #002, #006, #008, #011, #012–#014, #019 (Oberfläche), #023–#025, #027, #028 und #032.
| Issue | Zeit | | Issue | Zeit |
|---|---| |---|---|
@@ -40,6 +40,8 @@
| #030 Gitea-Abgleich | 0 h 05 min | | #030 Gitea-Abgleich | 0 h 05 min |
| #031 Rotation des Deployment-Keys | 1 h 10 min | | #031 Rotation des Deployment-Keys | 1 h 10 min |
| #032 Nacharbeiten Phase 4/5 | 0 h 45 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 |
| Meilenstein | Zeit | | Meilenstein | Zeit |
|---|---| |---|---|
@@ -48,7 +50,7 @@
| v0.3.0 | 0 h 30 min (Wandzeit) | **abgeschlossen** | v0.3.0 | 0 h 30 min (Wandzeit) | **abgeschlossen**
| v0.4.0 | 0 h 45 min (Wandzeit) | **abgeschlossen** | v0.4.0 | 0 h 45 min (Wandzeit) | **abgeschlossen**
| v0.5.0 | 0 h 45 min (Wandzeit) | **abgeschlossen** | v0.5.0 | 0 h 45 min (Wandzeit) | **abgeschlossen**
| v1.0.0 | 0 h 55 min (Wandzeit) | in Arbeit – Abnahme steht aus | v1.0.0 | 1 h 25 min (Wandzeit) | in Arbeit – Abnahme steht aus
--- ---
@@ -169,3 +171,17 @@
- **Rotation abbrechen nutzt den vorhandenen Widerruf** statt eines neuen Zustands (#032). - **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. - **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).
+1
View File
@@ -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 - [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` - [Sicherung und Wiederherstellung](docs/betrieb/sicherung.md) – `deploy/sichern.sh`, `deploy/wiederherstellen.sh`
- [Sicherheit](docs/betrieb/sicherheit.md) – Aufbau, Bedrohungsanalyse, Notfallplan - [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) ## Installation (Docker Compose)
+130
View File
@@ -0,0 +1,130 @@
# 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).
## 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
+1
View File
@@ -42,6 +42,7 @@ Kleinere offene Punkte aus den Berichten der Phasen 4 und 5 erledigen.
derselbe Schlüssel deshalb nicht erneut über sshkv rotieren; Seite und Fehlermeldung sagen das 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 (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“. 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 - **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 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 Trigger unter der Advisory-Sperre aus der weiterhin zugeordneten Sequenz. Bestehende Zeilen bleiben
+27
View File
@@ -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).
+23
View File
@@ -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,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';
+8 -3
View File
@@ -318,10 +318,13 @@ model Schluessel {
/// Widerruf eingeleitet (#024): Verwendungen stehen auf ENTFERNEN, der Status wechselt erst /// Widerruf eingeleitet (#024): Verwendungen stehen auf ENTFERNEN, der Status wechselt erst
/// auf WIDERRUFEN/ROTIERT, wenn alle Entfernungen erfolgreich waren. /// auf WIDERRUFEN/ROTIERT, wenn alle Entfernungen erfolgreich waren.
widerrufEingeleitetAm DateTime? widerrufEingeleitetAm DateTime?
/// Rotationskette: der Schluessel, den dieser abgeloest hat. /// Rotationskette: der Schluessel, den dieser abgeloest hat. Nach einer abgebrochenen Rotation
vorgaengerId String? @unique /// 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) 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? erstelltVonId String?
erstelltVon Benutzer? @relation("SchluesselErsteller", fields: [erstelltVonId], references: [id], onDelete: SetNull) erstelltVon Benutzer? @relation("SchluesselErsteller", fields: [erstelltVonId], references: [id], onDelete: SetNull)
/// true, wenn eine Person den Key selbst hinterlegt hat (Self-Service). /// true, wenn eine Person den Key selbst hinterlegt hat (Self-Service).
@@ -334,6 +337,8 @@ model Schluessel {
hostsAlsDeployment Host[] @relation("HostDeploymentSchluessel") hostsAlsDeployment Host[] @relation("HostDeploymentSchluessel")
gruppenAlsDeployment Hostgruppe[] @relation("GruppeDeploymentSchluessel") gruppenAlsDeployment Hostgruppe[] @relation("GruppeDeploymentSchluessel")
vorgaenge Vorgang[] vorgaenge Vorgang[]
@@index([vorgaengerId])
} }
/// Soll: Public Key in authorized_keys eines Benutzers auf einem Host oder einer Hostgruppe. /// Soll: Public Key in authorized_keys eines Benutzers auf einem Host oder einer Hostgruppe.
+10 -5
View File
@@ -246,9 +246,12 @@ export default async function SchluesselSeite({
const rotationLaeuft = s.nachfolger?.status === "AKTIV" && s.status !== "ROTIERT"; const rotationLaeuft = s.nachfolger?.status === "AKTIV" && s.status !== "ROTIERT";
const abgebrocheneRotation = s.status !== "ROTIERT" && rotationAbgebrochen(s.nachfolger); const abgebrocheneRotation = s.status !== "ROTIERT" && rotationAbgebrochen(s.nachfolger);
const darfRotieren = verwaltenRecht && rechte.public && (aktivePrivat === 0 || rechte.privat); const darfRotieren = verwaltenRecht && rechte.public && (aktivePrivat === 0 || rechte.privat);
// Nach einem abgeschlossenen Abbruch (#032) bleibt der Nachfolger in der Kette; ein zweiter // Nach einem abgeschlossenen Abbruch (#032) darf erneut rotiert werden (#033); solange der
// passt nicht hinein (vorgaengerId ist eindeutig) – deshalb kein „Rotieren“ mehr. // abgebrochene Nachfolger noch entfernt wird, nicht.
const rotierbar = darfRotieren && (rotationLaeuft || (!rotationsSperre && !imWiderruf && !s.nachfolger)); 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. // Abbrechen auf dem Nachfolger: gleiche Rechte wie die Rotation, Private Keys nur mit Recht.
const darfAbbrechen = const darfAbbrechen =
Boolean(rotationDesVorgaengers && !rotationDesVorgaengers.widerrufEingeleitetAm) && Boolean(rotationDesVorgaengers && !rotationDesVorgaengers.widerrufEingeleitetAm) &&
@@ -543,8 +546,10 @@ export default async function SchluesselSeite({
<p className="text-muted mt-3 text-[14px]"> <p className="text-muted mt-3 text-[14px]">
Die Rotation zu „{s.nachfolger.name}“ wurde abgebrochen Die Rotation zu „{s.nachfolger.name}“ wurde abgebrochen
{s.nachfolger.status === "WIDERRUFEN" ? "" : " – der neue Schlüssel wird gerade entfernt"}; {s.nachfolger.status === "WIDERRUFEN" ? "" : " – der neue Schlüssel wird gerade entfernt"};
dieser Schlüssel bleibt aktiv. Ein zweiter Nachfolger ist nicht vorgesehen: für eine neue dieser Schlüssel bleibt aktiv
Rotation einen neuen Schlüssel anlegen, genauso zuweisen und diesen danach widerrufen. {s.nachfolger.status === "WIDERRUFEN"
? " und lässt sich erneut rotieren."
: ". Danach lässt er sich erneut rotieren."}
</p> </p>
)} )}
{rotationsSperre?.neuerPublicKeyNoetig && !imWiderruf && ( {rotationsSperre?.neuerPublicKeyNoetig && !imWiderruf && (
@@ -80,6 +80,10 @@ export default async function RotierenSeite({ params }: { params: Promise<{ id:
tests: stand.neu ? stand.tests : [], tests: stand.neu ? stand.tests : [],
widerruf, widerruf,
abbruch, 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"), darfPrivat: darf(benutzer.rolle, "privateKeyAusrollen"),
}} }}
/> />
+19 -16
View File
@@ -53,6 +53,8 @@ export type RotationsDaten = {
* solange sein Widerruf laeuft; null, wenn er abgeschlossen ist. * solange sein Widerruf laeuft; null, wenn er abgeschlossen ist.
*/ */
abbruch: { offen: OffeneEntfernung[] | null } | null; 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 }) { export function Rotation({ daten }: { daten: RotationsDaten }) {
@@ -115,8 +117,8 @@ export function Rotation({ daten }: { daten: RotationsDaten }) {
</> </>
); );
if (daten.abbruch && neu) { if (daten.abbruch?.offen && neu) {
return daten.abbruch.offen ? ( return (
<Karte <Karte
titel="Rotation wird abgebrochen" titel="Rotation wird abgebrochen"
untertitel={`Der neue Schlüssel wird entfernt – „${alt.name}“ bleibt aktiv und unverändert.`} untertitel={`Der neue Schlüssel wird entfernt – „${alt.name}“ bleibt aktiv und unverändert.`}
@@ -132,25 +134,26 @@ export function Rotation({ daten }: { daten: RotationsDaten }) {
ohneZielkonten ohneZielkonten
/> />
</Karte> </Karte>
) : (
<Karte titel="Rotation abgebrochen">
<p className="text-[14px]">
Der neue Schlüssel{" "}
<Link href={`/schluessel/${neu.id}`} className="text-accent hover:underline">
{neu.name}
</Link>{" "}
ist widerrufen und von allen Hosts entfernt.{" "}
<Link href={`/schluessel/${alt.id}`} className="text-accent hover:underline">
{alt.name}
</Link>{" "}
bleibt aktiv.
</p>
</Karte>
); );
} }
return ( return (
<div className="flex flex-col gap-6"> <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" /> <Schrittleiste schritte={SCHRITTE} aktuell={schritt} beschriftung="Schritte der Rotation" />
{schritt <= 1 && ( {schritt <= 1 && (
+17
View File
@@ -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}`; for (let nummer = 2; belegt.has(kandidat); nummer += 1) kandidat = `${basis}-r${datumKompakt}-${nummer}`;
return kandidat; 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 };
}
+9 -5
View File
@@ -1,6 +1,7 @@
import type { Prisma } from "@/generated/prisma/client"; import type { Prisma } from "@/generated/prisma/client";
import type { SchluesselKategorie, SchluesselStatus } from "@/generated/prisma/enums"; import type { SchluesselKategorie, SchluesselStatus } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { JUENGSTER, mitNachfolger } from "@/lib/rotation";
/** /**
* Lesemodelle fuer Schluessel (#012). Alles hier darf jede angemeldete Rolle sehen. * Lesemodelle fuer Schluessel (#012). Alles hier darf jede angemeldete Rolle sehen.
@@ -60,7 +61,10 @@ export const SCHLUESSEL_DETAIL_SELECT = {
erstelltVon: { select: { name: true } }, erstelltVon: { select: { name: true } },
vorgaenger: { select: { id: true, name: true, status: true, widerrufEingeleitetAm: true } }, vorgaenger: { select: { id: true, name: true, status: true, widerrufEingeleitetAm: true } },
// Ein Nachfolger im Widerruf ist eine abgebrochene Rotation (#032). // Ein Nachfolger im Widerruf ist eine abgebrochene Rotation (#032).
nachfolger: { select: { id: true, name: true, status: true, widerrufEingeleitetAm: true } }, nachfolgerListe: {
select: { id: true, name: true, status: true, widerrufEingeleitetAm: true },
...JUENGSTER,
},
publicVerwendungen: { publicVerwendungen: {
where: { zustand: { not: "ENTFERNT" } }, where: { zustand: { not: "ENTFERNT" } },
orderBy: { angelegtAm: "asc" }, orderBy: { angelegtAm: "asc" },
@@ -164,7 +168,7 @@ export type SchluesselListenEintrag = Awaited<ReturnType<typeof schluesselListe>
export async function schluesselDetail(id: string) { export async function schluesselDetail(id: string) {
const schluessel = await prisma.schluessel.findUnique({ where: { id }, select: SCHLUESSEL_DETAIL_SELECT }); const schluessel = await prisma.schluessel.findUnique({ where: { id }, select: SCHLUESSEL_DETAIL_SELECT });
if (!schluessel) return null; if (!schluessel) return null;
const { masterKeyVersion, ...rest } = schluessel; const { masterKeyVersion, ...rest } = mitNachfolger(schluessel);
return { ...rest, privatGespeichert: masterKeyVersion !== null }; return { ...rest, privatGespeichert: masterKeyVersion !== null };
} }
@@ -210,7 +214,7 @@ export async function rotationsKette(id: string, maximal = 25): Promise<KettenGl
name: true, name: true,
status: true, status: true,
vorgaengerId: true, vorgaengerId: true,
nachfolger: { select: { id: true } }, nachfolgerListe: { select: { id: true }, ...JUENGSTER },
}, },
}); });
const start = await glied(id); const start = await glied(id);
@@ -224,12 +228,12 @@ export async function rotationsKette(id: string, maximal = 25): Promise<KettenGl
kette.unshift({ id: g.id, name: g.name, status: g.status }); kette.unshift({ id: g.id, name: g.name, status: g.status });
davor = g.vorgaengerId; 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); const g = await glied(danach);
if (!g) break; if (!g) break;
gesehen.add(g.id); gesehen.add(g.id);
kette.push({ id: g.id, name: g.name, status: g.status }); kette.push({ id: g.id, name: g.name, status: g.status });
danach = g.nachfolger?.id; danach = g.nachfolgerListe[0]?.id;
} }
return kette; return kette;
} }
+23 -10
View File
@@ -1,6 +1,13 @@
import type { Prisma } from "@/generated/prisma/client"; import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db"; 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 { NAME_MAX, type SchluesselVorgangEingabe } from "@/lib/schluessel";
import { eindeutigerSlug } from "@/server/services/schluessel"; import { eindeutigerSlug } from "@/server/services/schluessel";
import { protokollieren } from "@/server/services/protokoll"; import { protokollieren } from "@/server/services/protokoll";
@@ -45,11 +52,15 @@ const ALT_SELECT = {
vorlageId: true, vorlageId: true,
masterKeyVersion: true, masterKeyVersion: true,
widerrufEingeleitetAm: true, widerrufEingeleitetAm: true,
nachfolger: { select: { id: true, name: true, status: true, widerrufEingeleitetAm: true } }, nachfolgerListe: {
select: { id: true, name: true, status: true, widerrufEingeleitetAm: true },
...JUENGSTER,
},
_count: { select: { privateVerwendungen: { where: { zustand: { in: [...AKTIVE] } } } } }, _count: { select: { privateVerwendungen: { where: { zustand: { in: [...AKTIVE] } } } } },
} satisfies Prisma.SchluesselSelect; } 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 * 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> { async function altLaden(id: string): Promise<Alt> {
const alt = await prisma.schluessel.findUnique({ where: { id }, select: ALT_SELECT }); const alt = await prisma.schluessel.findUnique({ where: { id }, select: ALT_SELECT });
if (!alt) throw new RotationsFehler("Diesen Schlüssel gibt es nicht."); if (!alt) throw new RotationsFehler("Diesen Schlüssel gibt es nicht.");
return alt; return mitNachfolger(alt);
} }
function gesperrtPruefen(alt: Alt) { function gesperrtPruefen(alt: Alt) {
@@ -92,14 +103,15 @@ function gesperrtPruefen(alt: Alt) {
export async function rotationStarten(altId: string, akteur: Akteur): Promise<string> { export async function rotationStarten(altId: string, akteur: Akteur): Promise<string> {
const alt = await altLaden(altId); const alt = await altLaden(altId);
gesperrtPruefen(alt); gesperrtPruefen(alt);
if (alt.nachfolger && rotationAbgebrochen(alt.nachfolger)) { // Ein vollstaendig abgebrochener (widerrufener) Nachfolger bleibt in der Historie; danach darf
// vorgaengerId ist eindeutig und bleibt fuer die Historie stehen (#032): ein zweiter // ein neuer folgen (#033). Der partielle Index haelt die Kette trotzdem eindeutig.
// Nachfolger passt nicht in die Kette. if (alt.nachfolger && alt.nachfolger.status !== "WIDERRUFEN") {
throw new RotationsFehler( throw new RotationsFehler(
"Die Rotation dieses Schlüssels wurde abgebrochen; ein zweiter Nachfolger ist nicht vorgesehen. Bitte einen neuen Schlüssel anlegen, genauso zuweisen und diesen danach widerrufen.", 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.",
); );
} }
if (alt.nachfolger) throw new RotationsFehler("Für diesen Schlüssel gibt es schon einen Nachfolger.");
const name = rotationsName(alt.name, rotationsDatum().anzeige, NAME_MAX); const name = rotationsName(alt.name, rotationsDatum().anzeige, NAME_MAX);
const slug = await eindeutigerSlug(null, name); const slug = await eindeutigerSlug(null, name);
// Ein vergangenes Ablaufdatum wuerde der Nachfolger sonst erben – er waere sofort abgelaufen. // Ein vergangenes Ablaufdatum wuerde der Nachfolger sonst erben – er waere sofort abgelaufen.
@@ -272,7 +284,8 @@ export type RotationsStand = Awaited<ReturnType<typeof rotationsStand>>;
export async function rotationsStand(altId: string) { export async function rotationsStand(altId: string) {
const alt = await altLaden(altId); const alt = await altLaden(altId);
const gesperrt = rotationGesperrt({ ...alt, privatZuweisungen: alt._count.privateVerwendungen }); const gesperrt = rotationGesperrt({ ...alt, privatZuweisungen: alt._count.privateVerwendungen });
const neu = alt.nachfolger; // 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 }; if (!neu) return { alt, gesperrt, neu: null, schritt: 0 as const, abgebrochen: false };
const abgebrochen = rotationAbgebrochen(neu); const abgebrochen = rotationAbgebrochen(neu);
+5 -3
View File
@@ -3,6 +3,7 @@ import { prisma } from "@/lib/db";
import { protokollieren } from "@/server/services/protokoll"; import { protokollieren } from "@/server/services/protokoll";
import { VerwendungsFehler } from "@/server/services/verwendungen"; import { VerwendungsFehler } from "@/server/services/verwendungen";
import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge"; import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge";
import { JUENGSTER, type MitNachfolger, mitNachfolger } from "@/lib/rotation";
/** /**
* Widerruf und Massenwiderruf (#024). * Widerruf und Massenwiderruf (#024).
@@ -37,10 +38,11 @@ const SCHLUESSEL_KOPF = {
status: true, status: true,
fingerprint: true, fingerprint: true,
widerrufEingeleitetAm: true, widerrufEingeleitetAm: true,
nachfolger: { select: { id: true, status: true, widerrufEingeleitetAm: true } }, nachfolgerListe: { select: { id: true, status: true, widerrufEingeleitetAm: true }, ...JUENGSTER },
} satisfies Prisma.SchluesselSelect; } 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. */ /** Warum ein Schluessel nicht widerrufen werden kann – oder null. */
export function widerrufGesperrt(s: Pick<SchluesselKopf, "kategorie" | "status" | "name">): string | 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" }, orderBy: { name: "asc" },
}); });
if (liste.length !== eindeutig.length) throw new WiderrufFehler("Einen der Schlüssel gibt es nicht."); if (liste.length !== eindeutig.length) throw new WiderrufFehler("Einen der Schlüssel gibt es nicht.");
return liste; return liste.map(mitNachfolger);
} }
// ---------------------------------------------------------------- Zielkonten // ---------------------------------------------------------------- Zielkonten
+2 -1
View File
@@ -110,7 +110,8 @@ export const schluesselErzeugenHandler: Handler = async ({ vorgang, geheim }) =>
}); });
} catch (fehler) { } catch (fehler) {
if (istEindeutigkeitsFehler(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."); if (eingabe.vorgaengerId) throw new VorgangsFehler("Für diesen Schlüssel läuft bereits eine Rotation.");
throw new VorgangsFehler( throw new VorgangsFehler(
`Der Kurzname „${eingabe.slug}“ wurde inzwischen vergeben. Bitte erneut erzeugen.`, `Der Kurzname „${eingabe.slug}“ wurde inzwischen vergeben. Bitte erneut erzeugen.`,
+1
View File
@@ -26,6 +26,7 @@ const REGELN = [
const INDIZES = [ const INDIZES = [
"schluessel_ein_instanzstandard", "schluessel_ein_instanzstandard",
"schluessel_ein_offener_nachfolger",
"publicverwendung_eindeutig_host", "publicverwendung_eindeutig_host",
"publicverwendung_eindeutig_gruppe", "publicverwendung_eindeutig_gruppe",
"privateverwendung_eindeutig", "privateverwendung_eindeutig",
+7 -3
View File
@@ -105,12 +105,16 @@ test("Rotation abbrechen, nachdem der neue Schlüssel ausgerollt ist (#032)", as
expect(aufZiel("ziel-a", "cat /root/.ssh/config")).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"); 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, kein „Rotieren“ mehr. // Der alte bleibt aktiv; die Kette zeigt den widerrufenen Nachfolger – und er laesst sich
// erneut rotieren (#033).
await page.goto(schluesselSeite); await page.goto(schluesselSeite);
const kette = page.getByRole("list", { name: "Rotationskette" }); const kette = page.getByRole("list", { name: "Rotationskette" });
await expect(kette.getByText("widerrufen", { exact: true })).toBeVisible(); await expect(kette.getByText("widerrufen", { exact: true })).toBeVisible();
await expect(page.getByText(/Die Rotation zu „.*“ wurde abgebrochen/)).toBeVisible(); await expect(page.getByText(/Die Rotation zu „.*“ wurde abgebrochen.*erneut rotieren/)).toBeVisible();
await expect(page.getByRole("link", { name: "Rotieren" })).toHaveCount(0); 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, hostA);
await archivieren(page, hostB); await archivieren(page, hostB);
+2 -1
View File
@@ -174,6 +174,7 @@ describe("Rotation", () => {
...(await prisma.privateVerwendung.findMany({ where: { schluesselId: alt.id } })), ...(await prisma.privateVerwendung.findMany({ where: { schluesselId: alt.id } })),
].map((v) => v.zustand); ].map((v) => v.zustand);
expect(zustaende).toEqual(["AUSGEROLLT", "AUSGEROLLT"]); expect(zustaende).toEqual(["AUSGEROLLT", "AUSGEROLLT"]);
expect((await rotationsStand(alt.id)).abgebrochen).toBe(true); // Abbruch abgeschlossen: die Rotation steht wieder am Anfang (#033).
expect(await rotationsStand(alt.id)).toMatchObject({ neu: null, schritt: 0, abgebrochen: false });
}); });
}); });
+36 -1
View File
@@ -97,8 +97,10 @@ async function alterSchluessel() {
async function nachfolgerErzeugen(altId: string): Promise<string> { async function nachfolgerErzeugen(altId: string): Promise<string> {
const vorgangId = await rotationStarten(altId, admin); const vorgangId = await rotationStarten(altId, admin);
await vorgangImWorker(vorgangId); await vorgangImWorker(vorgangId);
// Nach einem Abbruch gibt es mehrere Nachfolger; der juengste ist der gerade erzeugte (#033).
const neu = await prisma.schluessel.findFirstOrThrow({ const neu = await prisma.schluessel.findFirstOrThrow({
where: { vorgaengerId: altId }, where: { vorgaengerId: altId },
orderBy: { angelegtAm: "desc" },
select: { id: true }, select: { id: true },
}); });
return neu.id; return neu.id;
@@ -311,6 +313,9 @@ describe("Rotation abbrechen (#032)", () => {
}); });
expect(eintrag).toMatchObject({ fingerprint: "SHA256:alt", details: { nachfolgerId: neuId } }); 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. // Nach dem Anwenden: der Nachfolger ist WIDERRUFEN (nicht ROTIERT), die Kette bleibt.
await prisma.publicVerwendung.updateMany({ await prisma.publicVerwendung.updateMany({
where: { schluesselId: neuId }, where: { schluesselId: neuId },
@@ -329,7 +334,37 @@ describe("Rotation abbrechen (#032)", () => {
(await prisma.schluessel.findUniqueOrThrow({ where: { id: neuId }, select: { vorgaengerId: true } })) (await prisma.schluessel.findUniqueOrThrow({ where: { id: neuId }, select: { vorgaengerId: true } }))
.vorgaengerId, .vorgaengerId,
).toBe(altId); ).toBe(altId);
await expect(rotationStarten(altId, admin)).rejects.toThrow(/abgebrochen/);
// 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 () => { it("setzt nie ausgerollte Zuweisungen des neuen Schluessels sofort auf ENTFERNT", async () => {